Add Hypershade-style Material Editor with node graph and shader-ball preview

Browser column lists all scene materials plus a searchable create-node
list; Show Graph (or double-click) loads a material into the node-graph
work area. The graph shows the entire network including MaterialX
(.mtlx) node graphs, resolving connections through NodeGraph boundaries
via UsdShadeUtils::GetValueProducingAttributes, with layered auto-layout
for nodes lacking authored uiPosition. Canvas navigates viewport-style
(Alt+MMB pan / Alt+RMB zoom) and TAB opens a Nuke-style search popup.
Selecting a node shows a typed property editor (live-apply, one undo
command per edit) and previews that node's output on the shader ball.

The preview renders through its own Hydra engine into a scratch stage
that composes the material via a reference to the source root layer (so
referenced .mtlx materials work), re-renders until progressive delegates
(Arnold/Cycles/Embree) converge, and lights with HDR dome presets
(External/Room/Interior/Sunset; CC0 Poly Haven EXRs fetched at CMake
configure). texture:format is authored latlong explicitly - left
automatic, hdArnold falls back to Arnold's angular fisheye default -
and hdCycles gets a -90 X pole rotation to match Storm's +Y-pole
sampling.

Mutations go through new ICommand subclasses (create shader node,
connect/disconnect attrs). imgui-node-editor is vendored (gitignored)
with a local one-line patch: c_ScrollButtonIndex 1->2 for MMB pan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:44:54 +08:00
parent 338970b243
commit 09819091c4
20 changed files with 2366 additions and 4 deletions
+33 -1
View File
@@ -13,6 +13,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/modules")
find_package(OpenGL REQUIRED)
find_package(OpenUSD REQUIRED)
find_package(Imgui REQUIRED)
find_package(ImguiNodeEditor REQUIRED)
find_package(Glad REQUIRED)
find_package(FFmpeg REQUIRED)
@@ -195,6 +196,7 @@ target_include_directories(UsdLayerManager PRIVATE
target_link_libraries(UsdLayerManager PRIVATE
OpenUSD::OpenUSD
Imgui::Imgui
ImguiNodeEditor::ImguiNodeEditor
Glad::Glad
FFmpeg::FFmpeg
OpenColorIO::OpenColorIO
@@ -577,6 +579,33 @@ if(NOT EXISTS "${_ocio_config_dir}/config.ocio")
endif()
endif()
# ---------------------------------------------------------------------------
# HDRI: shader-ball lighting presets (Poly Haven, CC0) at configure time
# ---------------------------------------------------------------------------
set(_hdri_dir "${CMAKE_SOURCE_DIR}/resources/hdri")
set(_hdri_files
kloofendal_48d_partly_cloudy_puresky_1k.exr
lebombo_1k.exr
artist_workshop_1k.exr
venice_sunset_1k.exr
)
file(MAKE_DIRECTORY "${_hdri_dir}")
foreach(_hdri ${_hdri_files})
if(NOT EXISTS "${_hdri_dir}/${_hdri}")
message(STATUS "Downloading HDRI ${_hdri} ...")
file(DOWNLOAD
"https://dl.polyhaven.org/file/ph-assets/HDRIs/exr/1k/${_hdri}"
"${_hdri_dir}/${_hdri}"
STATUS _hdri_dl_status
)
list(GET _hdri_dl_status 0 _hdri_dl_code)
if(NOT _hdri_dl_code EQUAL 0)
file(REMOVE "${_hdri_dir}/${_hdri}")
message(WARNING "HDRI download failed for ${_hdri} (${_hdri_dl_status}) — the shader-ball lighting preset will fall back to a distant light.")
endif()
endif()
endforeach()
# Copy resources to build directory
file(COPY ${CMAKE_SOURCE_DIR}/resources
DESTINATION ${CMAKE_BINARY_DIR}
@@ -592,7 +621,10 @@ add_custom_command(TARGET UsdLayerManager POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/resources/icons"
"$<TARGET_FILE_DIR:UsdLayerManager>/resources/icons"
COMMENT "Copying fonts and SVG icons to build output"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/resources/hdri"
"$<TARGET_FILE_DIR:UsdLayerManager>/resources/hdri"
COMMENT "Copying fonts, SVG icons and HDRI presets to build output"
)
# Copy ACES OCIO config to build output — only on first build (skipped if already present).
+82
View File
@@ -0,0 +1,82 @@
# FindImguiNodeEditor.cmake - Find or configure thedmd/imgui-node-editor
#
# This module looks for imgui-node-editor in the third_party/imgui-node-editor
# directory (vendored, docking branch) and compiles its sources directly
# against this project's own Imgui::Imgui target so it shares the exact same
# ImGui headers/defines (no second copy of ImGui is pulled in).
#
# Inputs:
# IMGUI_NODE_EDITOR_DIR - Path to imgui-node-editor/NodeEditor root
#
# Outputs:
# ImguiNodeEditor_FOUND
# ImguiNodeEditor::ImguiNodeEditor (imported STATIC target)
if(NOT IMGUI_NODE_EDITOR_DIR)
set(IMGUI_NODE_EDITOR_DIR "${CMAKE_SOURCE_DIR}/third_party/imgui-node-editor/NodeEditor")
endif()
if(NOT IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR)
set(IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR
"${CMAKE_SOURCE_DIR}/third_party/imgui-node-editor/Examples/Common/BlueprintUtilities")
endif()
find_path(ImguiNodeEditor_INCLUDE_DIR
NAMES imgui_node_editor.h
PATHS "${IMGUI_NODE_EDITOR_DIR}/Include"
NO_DEFAULT_PATH
)
set(ImguiNodeEditor_SOURCES
"${IMGUI_NODE_EDITOR_DIR}/Source/crude_json.cpp"
"${IMGUI_NODE_EDITOR_DIR}/Source/imgui_canvas.cpp"
"${IMGUI_NODE_EDITOR_DIR}/Source/imgui_node_editor_api.cpp"
"${IMGUI_NODE_EDITOR_DIR}/Source/imgui_node_editor.cpp"
# Blueprint pin-icon widgets (Widgets/Drawing only — Builders.cpp is
# intentionally excluded, it needs Spring()/BeginHorizontal()/BeginVertical()
# from thedmd's own ImGui fork, which this project's vanilla ImGui lacks).
"${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Source/ax/Drawing.cpp"
"${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Source/ax/Widgets.cpp"
)
foreach(_src ${ImguiNodeEditor_SOURCES})
if(NOT EXISTS "${_src}")
set(ImguiNodeEditor_SOURCES_MISSING TRUE)
endif()
endforeach()
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(ImguiNodeEditor
REQUIRED_VARS ImguiNodeEditor_INCLUDE_DIR
FAIL_MESSAGE "imgui-node-editor not found — expected it vendored at third_party/imgui-node-editor/NodeEditor"
)
if(ImguiNodeEditor_FOUND AND ImguiNodeEditor_SOURCES_MISSING)
message(FATAL_ERROR "imgui-node-editor sources missing under ${IMGUI_NODE_EDITOR_DIR}/Source")
endif()
if(ImguiNodeEditor_FOUND AND NOT TARGET ImguiNodeEditor::ImguiNodeEditor)
add_library(imgui_node_editor_impl STATIC ${ImguiNodeEditor_SOURCES})
target_include_directories(imgui_node_editor_impl
PUBLIC "${ImguiNodeEditor_INCLUDE_DIR}"
"${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Include"
PRIVATE "${IMGUI_NODE_EDITOR_DIR}/Source"
"${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Source"
)
# Must be defined before the first #include <imgui.h> in every translation
# unit (imgui_internal.h hard-errors otherwise) — a command-line define
# guarantees that ordering regardless of vendored include order.
target_compile_definitions(imgui_node_editor_impl PRIVATE IMGUI_DEFINE_MATH_OPERATORS)
# Must share this project's own ImGui build (same headers/defines) rather
# than pulling in a second ImGui — imgui-node-editor only needs the public
# API plus ImRect/ImFloor from imgui_internal.h.
target_link_libraries(imgui_node_editor_impl PUBLIC Imgui::Imgui)
add_library(ImguiNodeEditor::ImguiNodeEditor ALIAS imgui_node_editor_impl)
message(STATUS "imgui-node-editor found: ${ImguiNodeEditor_INCLUDE_DIR}")
endif()
mark_as_advanced(ImguiNodeEditor_INCLUDE_DIR)
+145
View File
@@ -0,0 +1,145 @@
#include "MaterialManager.h"
#include <pxr/usd/sdr/registry.h>
#include <pxr/usd/usd/primRange.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/nodeGraph.h>
#include <pxr/usd/usdShade/connectableAPI.h>
#include <pxr/usd/usdShade/utils.h>
#include <pxr/base/tf/token.h>
#include <pxr/base/vt/value.h>
#include <algorithm>
#include <set>
namespace UsdLayerManager {
const std::vector<ShaderNodeTypeInfo>& MaterialManager::GetAvailableShaderNodes() {
if (m_shaderNodeCacheBuilt)
return m_shaderNodeCache;
for (pxr::SdrShaderNodeConstPtr node : pxr::SdrRegistry::GetInstance().GetShaderNodesByFamily()) {
if (!node) continue;
ShaderNodeTypeInfo info;
info.identifier = node->GetIdentifier().GetString();
info.label = !node->GetLabel().IsEmpty() ? node->GetLabel().GetString() : node->GetName();
info.family = node->GetFamily().GetString();
m_shaderNodeCache.push_back(std::move(info));
}
std::sort(m_shaderNodeCache.begin(), m_shaderNodeCache.end(),
[](const ShaderNodeTypeInfo& a, const ShaderNodeTypeInfo& b) {
if (a.family != b.family) return a.family < b.family;
return a.label < b.label;
});
m_shaderNodeCacheBuilt = true;
return m_shaderNodeCache;
}
std::vector<pxr::SdfPath> MaterialManager::GetAllMaterials() const {
std::vector<pxr::SdfPath> result;
if (!m_stage) return result;
for (const pxr::UsdPrim& prim : m_stage->Traverse()) {
if (prim.IsA<pxr::UsdShadeMaterial>())
result.push_back(prim.GetPath());
}
return result;
}
ShaderGraphSnapshot MaterialManager::GetShaderGraph(const pxr::SdfPath& materialPath) const {
ShaderGraphSnapshot snapshot;
if (!m_stage) return snapshot;
pxr::UsdPrim materialPrim = m_stage->GetPrimAtPath(materialPath);
if (!materialPrim.IsValid()) return snapshot;
static const pxr::TfToken kUiPositionKey("uiPosition");
// Seed with every shader under the material, recursing into nested node
// graphs (usdMtlx nests a material's nodes inside UsdShadeNodeGraph
// scopes rather than authoring them as direct children).
std::vector<pxr::UsdPrim> pending;
std::vector<pxr::UsdPrim> scopes{materialPrim};
while (!scopes.empty()) {
pxr::UsdPrim scope = scopes.back();
scopes.pop_back();
for (const pxr::UsdPrim& child : scope.GetChildren()) {
if (child.IsA<pxr::UsdShadeShader>())
pending.push_back(child);
else if (child.IsA<pxr::UsdShadeNodeGraph>())
scopes.push_back(child);
}
}
// Worklist walk: resolving each input's true upstream shader output pulls
// in nodes living in node graphs outside the material prim too.
std::set<pxr::SdfPath> visited;
while (!pending.empty()) {
pxr::UsdPrim child = pending.back();
pending.pop_back();
if (!visited.insert(child.GetPath()).second)
continue;
pxr::UsdShadeShader shader(child);
if (!shader) continue;
ShaderGraphNode node;
node.path = child.GetPath();
pxr::TfToken shaderId;
shader.GetIdAttr().Get(&shaderId);
node.shaderId = shaderId.GetString();
pxr::VtValue posValue = child.GetCustomDataByKey(kUiPositionKey);
if (posValue.IsHolding<pxr::GfVec2f>()) {
node.uiPosition = posValue.UncheckedGet<pxr::GfVec2f>();
node.hasAuthoredPosition = true;
}
// Prefer the full Sdr-defined pin set (so unauthored pins can still be
// dragged to create a connection); fall back to authored attributes
// only for shader types the registry doesn't know about.
pxr::SdrShaderNodeConstPtr sdrNode =
pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(shaderId);
if (sdrNode) {
for (const pxr::TfToken& inputName : sdrNode->GetShaderInputNames()) {
if (auto* prop = sdrNode->GetShaderInput(inputName))
node.inputs.push_back({inputName.GetString(), prop->GetTypeAsSdfType().GetSdfType()});
}
for (const pxr::TfToken& outputName : sdrNode->GetShaderOutputNames()) {
if (auto* prop = sdrNode->GetShaderOutput(outputName))
node.outputs.push_back({outputName.GetString(), prop->GetTypeAsSdfType().GetSdfType()});
}
} else {
for (const auto& input : shader.GetInputs())
node.inputs.push_back({input.GetBaseName().GetString(), input.GetTypeName()});
for (const auto& output : shader.GetOutputs())
node.outputs.push_back({output.GetBaseName().GetString(), output.GetTypeName()});
}
// Only authored inputs can carry a connection. Resolve each one
// through node-graph boundaries (shaderOutputsOnly) so a MaterialX
// connection routed via NodeGraph interface attrs links straight to
// the shader output that actually produces the value.
for (const auto& input : shader.GetInputs()) {
for (const pxr::UsdAttribute& sourceAttr :
pxr::UsdShadeUtils::GetValueProducingAttributes(input, /*shaderOutputsOnly=*/true)) {
pxr::UsdPrim sourcePrim = sourceAttr.GetPrim();
if (!sourcePrim.IsA<pxr::UsdShadeShader>()) continue;
ShaderGraphLink link;
link.destNode = child.GetPath();
link.destInput = input.GetBaseName().GetString();
link.sourceNode = sourcePrim.GetPath();
link.sourceOutput = pxr::UsdShadeOutput(sourceAttr).GetBaseName().GetString();
snapshot.links.push_back(std::move(link));
pending.push_back(sourcePrim); // may live outside the material
}
}
snapshot.nodes.push_back(std::move(node));
}
return snapshot;
}
} // namespace UsdLayerManager
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/sdf/path.h>
#include <pxr/usd/sdf/valueTypeName.h>
#include <pxr/base/gf/vec2f.h>
#include <string>
#include <vector>
namespace UsdLayerManager {
/// One entry in the shader-node creation menu, derived from the Sdr/Ndr
/// shader registry (covers UsdPreviewSurface, MaterialX, Arnold, Cycles,
/// etc. — whatever shader definitions are discoverable in this build).
struct ShaderNodeTypeInfo {
std::string identifier; ///< Sdr identifier, authored as info:id
std::string label; ///< display label for the create-node menu
std::string family; ///< grouping for the create-node menu (may be empty)
};
/// One input or output pin on a shader node, with its USD value type so
/// connections/CreateInput/CreateOutput can author the right type.
struct ShaderPinInfo {
std::string name;
pxr::SdfValueTypeName typeName;
};
/// One UsdShadeShader prim read back from an existing material network.
/// inputs/outputs list the full Sdr-defined pin set (not just authored
/// attributes) so unauthored pins can still be dragged to create a connection.
struct ShaderGraphNode {
pxr::SdfPath path;
std::string shaderId;
pxr::GfVec2f uiPosition{0.0f, 0.0f};
/// False when no uiPosition custom data is authored (typical for networks
/// referenced from .mtlx) — the editor auto-lays such nodes out instead.
bool hasAuthoredPosition = false;
std::vector<ShaderPinInfo> inputs;
std::vector<ShaderPinInfo> outputs;
};
/// One authored connection between two shader nodes in the same network.
struct ShaderGraphLink {
pxr::SdfPath destNode;
std::string destInput;
pxr::SdfPath sourceNode;
std::string sourceOutput;
};
struct ShaderGraphSnapshot {
std::vector<ShaderGraphNode> nodes;
std::vector<ShaderGraphLink> links;
};
/// Reads/introspects UsdShade material networks and the Sdr shader registry.
/// Mirrors PropertyManager's role: holds read/derive logic only — mutations
/// go through ICommand subclasses in src/core/commands/.
class MaterialManager {
public:
void SetStage(pxr::UsdStageRefPtr stage) { m_stage = stage; }
/// All shader node types registered in the Sdr registry, grouped for a
/// categorized create-node menu. Cached after the first (slow) call.
const std::vector<ShaderNodeTypeInfo>& GetAvailableShaderNodes();
/// The entire shader network of the material at materialPath: shaders
/// under the material (recursing into nested UsdShadeNodeGraphs) plus
/// everything reachable upstream through connections — including nodes in
/// node graphs outside the material, as authored by MaterialX (.mtlx)
/// imports. Connections are resolved through node-graph boundary attrs to
/// the shader outputs that actually produce the values.
ShaderGraphSnapshot GetShaderGraph(const pxr::SdfPath& materialPath) const;
/// Paths of every UsdShadeMaterial prim on the stage, in traversal order.
/// Drives the material browser list (Hypershade-style).
std::vector<pxr::SdfPath> GetAllMaterials() const;
private:
pxr::UsdStageRefPtr m_stage;
std::vector<ShaderNodeTypeInfo> m_shaderNodeCache;
bool m_shaderNodeCacheBuilt = false;
};
} // namespace UsdLayerManager
+1 -1
View File
@@ -705,7 +705,7 @@ void UsdSceneRenderer::Render(int width, int height)
// --- Render params (matches stageView.renderSinglePass) ---
m_renderParams = pxr::UsdImagingGLRenderParams();
m_renderParams.frame = m_currentTime;
m_renderParams.complexity = 1.0f;
m_renderParams.complexity = m_complexity;
// Apply shading mode → drawMode + enableLighting
switch (m_shadingMode) {
case ShadingMode::FlatShaded:
+12
View File
@@ -185,12 +185,23 @@ public:
/// unknown or the switch fails. The renderer is re-initialised if needed.
bool SetRendererPlugin(const pxr::TfToken& pluginId);
/// False while a progressive delegate (Arnold/Cycles/Embree…) is still
/// accumulating samples for the last-submitted frame; Storm is always
/// converged after one pass. Callers that cache the output texture must
/// keep calling Render() until this is true.
bool IsConverged() const { return !m_renderer || m_renderer->IsConverged(); }
// -----------------------------------------------------------------------
// View settings
// -----------------------------------------------------------------------
bool ShowGrid() const { return m_showGrid; }
void SetShowGrid(bool show) { m_showGrid = show; }
/// Hydra refinement complexity (1.0 = base mesh, up to 2.0). The shader
/// ball uses a raised value so the sphere tessellates smoothly.
float Complexity() const { return m_complexity; }
void SetComplexity(float c) { m_complexity = c; }
bool ShowCameraGuide() const { return m_showCameraGuide; }
void SetShowCameraGuide(bool show) { m_showCameraGuide = show; }
@@ -360,6 +371,7 @@ private:
std::vector<pxr::GfVec4d> m_clipPlanes;
bool m_showGrid;
float m_complexity = 1.0f;
bool m_showCameraGuide;
bool m_showGuides;
bool m_showProxy;
@@ -0,0 +1,82 @@
#include "ConnectShaderAttrsCommand.h"
#include "../../utils/Logger.h"
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/connectableAPI.h>
#include <pxr/base/tf/token.h>
namespace UsdLayerManager {
ConnectShaderAttrsCommand::ConnectShaderAttrsCommand(pxr::UsdStageRefPtr stage,
const pxr::SdfPath& destNode,
const std::string& destInput,
const pxr::SdfValueTypeName& destType,
const pxr::SdfPath& sourceNode,
const std::string& sourceOutput,
const pxr::SdfValueTypeName& sourceType)
: m_stage(stage)
, m_destNode(destNode)
, m_destInput(destInput)
, m_destType(destType)
, m_sourceNode(sourceNode)
, m_sourceOutput(sourceOutput)
, m_sourceType(sourceType)
, m_description("Connect " + sourceNode.GetName() + "." + sourceOutput +
" -> " + destNode.GetName() + "." + destInput)
{
if (!stage) return;
pxr::UsdShadeShader destShader(stage->GetPrimAtPath(destNode));
if (!destShader) return;
pxr::UsdShadeInput existingInput = destShader.GetInput(pxr::TfToken(destInput));
if (!existingInput) return;
for (const auto& source : pxr::UsdShadeConnectableAPI::GetConnectedSources(existingInput)) {
if (!source.IsValid()) continue;
m_hadPriorConnection = true;
m_priorSourceNode = source.source.GetPrim().GetPath();
m_priorSourceOutput = source.sourceName.GetString();
break; // shader inputs carry a single upstream connection in this editor's model
}
}
void ConnectShaderAttrsCommand::Execute() {
if (!m_stage) return;
try {
pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode));
pxr::UsdShadeShader sourceShader(m_stage->GetPrimAtPath(m_sourceNode));
if (!destShader || !sourceShader) {
LOG_ERROR("ConnectShaderAttrsCommand: invalid shader prim(s)");
return;
}
pxr::UsdShadeInput destInput = destShader.CreateInput(pxr::TfToken(m_destInput), m_destType);
pxr::UsdShadeOutput sourceOutput = sourceShader.CreateOutput(pxr::TfToken(m_sourceOutput), m_sourceType);
pxr::UsdShadeConnectableAPI::ConnectToSource(destInput, sourceOutput);
} catch (const std::exception& e) {
LOG_ERROR(std::string("ConnectShaderAttrsCommand::Execute error: ") + e.what());
}
}
void ConnectShaderAttrsCommand::Undo() {
if (!m_stage) return;
try {
pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode));
if (!destShader) return;
pxr::UsdShadeInput destInput = destShader.GetInput(pxr::TfToken(m_destInput));
if (!destInput) return;
if (m_hadPriorConnection) {
pxr::UsdShadeShader priorSourceShader(m_stage->GetPrimAtPath(m_priorSourceNode));
pxr::UsdShadeOutput priorSourceOutput = priorSourceShader.GetOutput(pxr::TfToken(m_priorSourceOutput));
if (priorSourceShader && priorSourceOutput)
pxr::UsdShadeConnectableAPI::ConnectToSource(destInput, priorSourceOutput);
} else {
pxr::UsdShadeConnectableAPI::DisconnectSource(destInput);
}
} catch (const std::exception& e) {
LOG_ERROR(std::string("ConnectShaderAttrsCommand::Undo error: ") + e.what());
}
}
} // namespace UsdLayerManager
@@ -0,0 +1,44 @@
#pragma once
#include "../CommandHistory.h"
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/sdf/path.h>
#include <pxr/usd/sdf/valueTypeName.h>
#include <string>
namespace UsdLayerManager {
/// Connects a shader input to an upstream shader output, sparsely authoring
/// both attributes (with the given types) if they don't already exist.
/// Captures whatever the input was previously connected to (if anything) so
/// Undo can restore it exactly rather than merely disconnecting.
class ConnectShaderAttrsCommand : public ICommand {
public:
ConnectShaderAttrsCommand(pxr::UsdStageRefPtr stage,
const pxr::SdfPath& destNode,
const std::string& destInput,
const pxr::SdfValueTypeName& destType,
const pxr::SdfPath& sourceNode,
const std::string& sourceOutput,
const pxr::SdfValueTypeName& sourceType);
void Execute() override;
void Undo() override;
std::string GetDescription() const override { return m_description; }
private:
pxr::UsdStageRefPtr m_stage;
pxr::SdfPath m_destNode;
std::string m_destInput;
pxr::SdfValueTypeName m_destType;
pxr::SdfPath m_sourceNode;
std::string m_sourceOutput;
pxr::SdfValueTypeName m_sourceType;
std::string m_description;
bool m_hadPriorConnection = false;
pxr::SdfPath m_priorSourceNode;
std::string m_priorSourceOutput;
};
} // namespace UsdLayerManager
@@ -0,0 +1,46 @@
#include "CreateShaderNodeCommand.h"
#include "../../utils/Logger.h"
#include <pxr/usd/usdShade/shader.h>
#include <pxr/base/tf/token.h>
#include <pxr/base/vt/value.h>
namespace UsdLayerManager {
CreateShaderNodeCommand::CreateShaderNodeCommand(pxr::UsdStageRefPtr stage,
const pxr::SdfPath& path,
const std::string& shaderId,
const pxr::GfVec2f& uiPosition)
: m_stage(stage)
, m_path(path)
, m_shaderId(shaderId)
, m_uiPosition(uiPosition)
, m_description("Create Shader " + shaderId + " " + path.GetString())
{
}
void CreateShaderNodeCommand::Execute() {
if (!m_stage) return;
try {
pxr::UsdShadeShader shader = pxr::UsdShadeShader::Define(m_stage, m_path);
if (!shader) {
LOG_ERROR("CreateShaderNodeCommand: failed to define shader " + m_path.GetString());
return;
}
shader.SetShaderId(pxr::TfToken(m_shaderId));
shader.GetPrim().SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(m_uiPosition));
} catch (const std::exception& e) {
LOG_ERROR(std::string("CreateShaderNodeCommand::Execute error: ") + e.what());
}
}
void CreateShaderNodeCommand::Undo() {
if (!m_stage) return;
try {
if (!m_stage->RemovePrim(m_path))
LOG_ERROR("CreateShaderNodeCommand: failed to remove prim " + m_path.GetString());
} catch (const std::exception& e) {
LOG_ERROR(std::string("CreateShaderNodeCommand::Undo error: ") + e.what());
}
}
} // namespace UsdLayerManager
@@ -0,0 +1,34 @@
#pragma once
#include "../CommandHistory.h"
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/sdf/path.h>
#include <pxr/base/gf/vec2f.h>
#include <string>
namespace UsdLayerManager {
/// Creates a UsdShadeShader prim with the given Sdr shader identifier and
/// authors its canvas position as uiPosition custom data (see
/// docs/adr — Material Editor node-graph layout is stored as prim
/// customData, not scene data, so it never affects composition/renders).
class CreateShaderNodeCommand : public ICommand {
public:
CreateShaderNodeCommand(pxr::UsdStageRefPtr stage,
const pxr::SdfPath& path,
const std::string& shaderId,
const pxr::GfVec2f& uiPosition);
void Execute() override;
void Undo() override;
std::string GetDescription() const override { return m_description; }
private:
pxr::UsdStageRefPtr m_stage;
pxr::SdfPath m_path;
std::string m_shaderId;
pxr::GfVec2f m_uiPosition;
std::string m_description;
};
} // namespace UsdLayerManager
@@ -0,0 +1,63 @@
#include "DisconnectShaderAttrCommand.h"
#include "../../utils/Logger.h"
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/connectableAPI.h>
#include <pxr/base/tf/token.h>
namespace UsdLayerManager {
DisconnectShaderAttrCommand::DisconnectShaderAttrCommand(pxr::UsdStageRefPtr stage,
const pxr::SdfPath& destNode,
const std::string& destInput)
: m_stage(stage)
, m_destNode(destNode)
, m_destInput(destInput)
, m_description("Disconnect " + destNode.GetName() + "." + destInput)
{
if (!stage) return;
pxr::UsdShadeShader destShader(stage->GetPrimAtPath(destNode));
if (!destShader) return;
pxr::UsdShadeInput input = destShader.GetInput(pxr::TfToken(destInput));
if (!input) return;
for (const auto& source : pxr::UsdShadeConnectableAPI::GetConnectedSources(input)) {
if (!source.IsValid()) continue;
m_hadConnection = true;
m_sourceNode = source.source.GetPrim().GetPath();
m_sourceOutput = source.sourceName.GetString();
break;
}
}
void DisconnectShaderAttrCommand::Execute() {
if (!m_stage) return;
try {
pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode));
if (!destShader) return;
pxr::UsdShadeInput input = destShader.GetInput(pxr::TfToken(m_destInput));
if (!input) return;
pxr::UsdShadeConnectableAPI::DisconnectSource(input);
} catch (const std::exception& e) {
LOG_ERROR(std::string("DisconnectShaderAttrCommand::Execute error: ") + e.what());
}
}
void DisconnectShaderAttrCommand::Undo() {
if (!m_stage || !m_hadConnection) return;
try {
pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode));
pxr::UsdShadeShader sourceShader(m_stage->GetPrimAtPath(m_sourceNode));
if (!destShader || !sourceShader) return;
pxr::UsdShadeInput input = destShader.GetInput(pxr::TfToken(m_destInput));
pxr::UsdShadeOutput output = sourceShader.GetOutput(pxr::TfToken(m_sourceOutput));
if (input && output)
pxr::UsdShadeConnectableAPI::ConnectToSource(input, output);
} catch (const std::exception& e) {
LOG_ERROR(std::string("DisconnectShaderAttrCommand::Undo error: ") + e.what());
}
}
} // namespace UsdLayerManager
@@ -0,0 +1,33 @@
#pragma once
#include "../CommandHistory.h"
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/sdf/path.h>
#include <string>
namespace UsdLayerManager {
/// Disconnects a shader input from its upstream source, capturing that
/// source at construction time so Undo can restore it.
class DisconnectShaderAttrCommand : public ICommand {
public:
DisconnectShaderAttrCommand(pxr::UsdStageRefPtr stage,
const pxr::SdfPath& destNode,
const std::string& destInput);
void Execute() override;
void Undo() override;
std::string GetDescription() const override { return m_description; }
private:
pxr::UsdStageRefPtr m_stage;
pxr::SdfPath m_destNode;
std::string m_destInput;
std::string m_description;
bool m_hadConnection = false;
pxr::SdfPath m_sourceNode;
std::string m_sourceOutput;
};
} // namespace UsdLayerManager
+43
View File
@@ -46,6 +46,7 @@ Application::Application()
, m_showPropertyPanel(true)
, m_showTimeline(true)
, m_showCurveEditor(false)
, m_showMaterialEditor(false)
, m_running(false) {
}
@@ -84,6 +85,12 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
m_curveEditorPanel = std::make_unique<CurveEditorPanel>();
m_curveEditorPanel->SetCommandHistory(&m_commandHistory);
m_materialManager = std::make_unique<MaterialManager>();
m_materialEditorPanel = std::make_unique<MaterialEditorPanel>();
m_materialEditorPanel->SetMaterialManager(m_materialManager.get());
m_materialEditorPanel->SetCommandHistory(&m_commandHistory);
m_timelinePanel = std::make_unique<TimelinePanel>();
m_timelinePanel->OnTimeChanged = [this](pxr::UsdTimeCode displayTime,
pxr::UsdTimeCode editTime) {
@@ -107,12 +114,14 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
m_viewportPanel->SetIconManager(m_iconManager.get());
m_timelinePanel->SetIconManager(m_iconManager.get());
m_stageEditorPanel->SetIconManager(m_iconManager.get());
m_materialEditorPanel->SetIconManager(m_iconManager.get());
m_sceneHierarchyPanel->SetOnPrimSelected(
[this](const std::string& path) {
m_viewportPanel->SetSelectedPrimPath(path);
m_propertyPanel->SetSelectedPrimPath(path);
m_curveEditorPanel->SetSelectedPrimPath(path);
m_materialEditorPanel->SetTargetPrimPath(path);
});
m_sceneHierarchyPanel->SetOnPrimsSelected(
@@ -130,6 +139,7 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
m_sceneHierarchyPanel->SetSelectedPath(path);
m_propertyPanel->SetSelectedPrimPath(path);
m_curveEditorPanel->SetSelectedPrimPath(path);
m_materialEditorPanel->SetTargetPrimPath(path);
};
// Rect drag in viewport → sync hierarchy + property panel + curve editor (primary)
@@ -137,6 +147,14 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
m_sceneHierarchyPanel->SetSelectedPaths(paths);
m_propertyPanel->SetSelectedPrimPath(paths.empty() ? "" : paths.front());
m_curveEditorPanel->SetSelectedPrimPath(paths.empty() ? "" : paths.front());
m_materialEditorPanel->SetTargetPrimPath(paths.empty() ? "" : paths.front());
};
// "Edit Material" in the Property Panel's Material Binding section →
// open the Material Editor on the resolved material.
m_propertyPanel->OnEditMaterialRequested = [this](const std::string& materialPath) {
m_showMaterialEditor = true;
m_materialEditorPanel->OpenOrCreateMaterial(materialPath);
};
@@ -173,6 +191,9 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
m_viewportPanel->ApplyGlobalColorCorrection(
m_prefs.colorCorrectionMode, m_prefs.ocioDisplay,
m_prefs.ocioView, m_prefs.ocioColorSpace, m_prefs.ocioLook);
m_materialEditorPanel->SetColorCorrectionFromPrefs(
m_prefs.colorCorrectionMode, m_prefs.ocioDisplay,
m_prefs.ocioView, m_prefs.ocioColorSpace, m_prefs.ocioLook);
}
LOG_INFO("Application initialized successfully");
@@ -201,6 +222,9 @@ void Application::Shutdown() {
m_sceneHierarchyPanel.reset();
m_propertyPanel.reset();
m_stageEditorPanel.reset();
// Owns the shader-ball preview's Hydra engine + GL draw target — must be
// destroyed while the GL context still exists, like m_viewportPanel.
m_materialEditorPanel.reset();
m_propertyManager.reset();
m_layerManager.reset();
@@ -233,6 +257,8 @@ void Application::RefreshManagers() {
m_propertyPanel->SetStage(stage);
m_timelinePanel->SetStage(stage);
m_curveEditorPanel->SetStage(stage);
m_materialManager->SetStage(stage);
m_materialEditorPanel->SetStage(stage);
} else {
m_layerManager->SetStage(nullptr);
m_propertyManager->SetStage(nullptr);
@@ -241,6 +267,8 @@ void Application::RefreshManagers() {
m_propertyPanel->SetStage(nullptr);
m_timelinePanel->SetStage(nullptr);
m_curveEditorPanel->SetStage(nullptr);
m_materialManager->SetStage(nullptr);
m_materialEditorPanel->SetStage(nullptr);
}
}
@@ -318,6 +346,13 @@ void Application::RenderUI() {
ImGui::End();
}
if (m_showMaterialEditor) {
ImGui::SetNextWindowSize({960, 320}, ImGuiCond_FirstUseEver);
ImGui::Begin("Material Editor", &m_showMaterialEditor, ImGuiWindowFlags_NoCollapse);
m_materialEditorPanel->Render();
ImGui::End();
}
if (m_showPreferences)
RenderPreferencesDialog();
@@ -414,6 +449,13 @@ void Application::ApplyPrefsToAllViewports()
m_prefs.ocioView,
m_prefs.ocioColorSpace,
m_prefs.ocioLook);
if (m_materialEditorPanel)
m_materialEditorPanel->SetColorCorrectionFromPrefs(
m_prefs.colorCorrectionMode,
m_prefs.ocioDisplay,
m_prefs.ocioView,
m_prefs.ocioColorSpace,
m_prefs.ocioLook);
}
void Application::RenderPreferencesDialog()
@@ -689,6 +731,7 @@ void Application::RenderMenuBar() {
ImGui::MenuItem("Property Panel", nullptr, &m_showPropertyPanel);
ImGui::MenuItem("Timeline", nullptr, &m_showTimeline);
ImGui::MenuItem("Curve Editor", nullptr, &m_showCurveEditor);
ImGui::MenuItem("Material Editor", nullptr, &m_showMaterialEditor);
ImGui::Separator();
ImGui::MenuItem("Stage Info", nullptr, &m_showStageInfo);
ImGui::MenuItem("Demo Window", nullptr, &m_showDemoWindow);
+5
View File
@@ -8,9 +8,11 @@
#include "PropertyPanel.h"
#include "TimelinePanel.h"
#include "CurveEditorPanel.h"
#include "MaterialEditorPanel.h"
#include "../core/UsdStageManager.h"
#include "../core/LayerManager.h"
#include "../core/PropertyManager.h"
#include "../core/MaterialManager.h"
#include "../core/CommandHistory.h"
#include "../utils/MovieEncoder.h"
#include <memory>
@@ -70,6 +72,7 @@ private:
std::unique_ptr<UsdStageManager> m_stageManager;
std::unique_ptr<LayerManager> m_layerManager;
std::unique_ptr<PropertyManager> m_propertyManager;
std::unique_ptr<MaterialManager> m_materialManager;
CommandHistory m_commandHistory;
std::unique_ptr<StageEditorPanel> m_stageEditorPanel;
std::unique_ptr<SceneHierarchyPanel> m_sceneHierarchyPanel;
@@ -77,6 +80,7 @@ private:
std::unique_ptr<PropertyPanel> m_propertyPanel;
std::unique_ptr<TimelinePanel> m_timelinePanel;
std::unique_ptr<CurveEditorPanel> m_curveEditorPanel;
std::unique_ptr<MaterialEditorPanel> m_materialEditorPanel;
bool m_showDemoWindow;
bool m_showStageInfo;
bool m_showStageEditor;
@@ -85,6 +89,7 @@ private:
bool m_showPropertyPanel;
bool m_showTimeline;
bool m_showCurveEditor;
bool m_showMaterialEditor;
bool m_running;
MovieEncoder m_movieEncoder;
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
#pragma once
#include "../core/CommandHistory.h"
#include "../core/MaterialManager.h"
#include "IconManager.h"
#include "MaterialPreviewRenderer.h"
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/sdf/path.h>
#include <pxr/usd/sdf/valueTypeName.h>
#include <pxr/base/vt/value.h>
#include <imgui_node_editor.h>
#include <memory>
#include <string>
#include <unordered_map>
namespace UsdLayerManager {
/// Node-graph editor for authoring UsdShade material networks, with a
/// shader-ball preview rendered through Hydra.
class MaterialEditorPanel {
public:
MaterialEditorPanel();
~MaterialEditorPanel();
void SetStage(pxr::UsdStageRefPtr stage);
void SetMaterialManager(MaterialManager* mgr) { m_materialManager = mgr; }
void SetCommandHistory(CommandHistory* history) { m_commandHistory = history; }
void SetIconManager(IconManager* icons) { m_iconManager = icons; }
/// Forwarded to the shader-ball preview so it matches the main viewport's
/// global color-correction settings.
void SetColorCorrectionFromPrefs(int ccMode, const std::string& ocioDisplay,
const std::string& ocioView,
const std::string& ocioColorSpace,
const std::string& ocioLook);
/// Opens the material at pathStr in the graph work area, creating it (as a
/// "Material" prim) if it doesn't exist yet. Also selects it in the browser.
void OpenOrCreateMaterial(const std::string& pathStr);
/// Tracks the current selection (mirrors PropertyPanel/ViewportPanel's
/// SetSelectedPrimPath) so the toolbar can offer "create + bind" / "bind"
/// affordances, and so selecting a prim with an already-bound material
/// auto-loads it into the canvas.
void SetTargetPrimPath(const std::string& path);
void Render();
private:
/// Resolved endpoint of a rendered pin, keyed by its ax::NodeEditor PinId.
struct PinInfo {
pxr::SdfPath nodePath;
std::string name;
bool isOutput;
pxr::SdfValueTypeName typeName;
};
/// Resolved endpoint of a rendered link, keyed by its ax::NodeEditor LinkId.
struct LinkInfo {
pxr::SdfPath destNode;
std::string destInput;
};
void RenderToolbar();
/// Property editor for the node selected in the graph, shown below the
/// shader-ball preview. Values apply live while a widget is being
/// dragged; one undoable command is pushed when the edit ends.
void RenderSelectedNodeProperties();
void RenderInputValueWidget(const ShaderGraphNode& node, const ShaderPinInfo& input,
const pxr::UsdPrim& prim);
void CommitInputEdit(const ShaderGraphNode& node, const ShaderPinInfo& input);
/// Hypershade-style browser: lists every material on the stage; the
/// selected one is loaded into the graph work area via "Show Graph"
/// (or double-click).
void RenderMaterialBrowser();
/// Creates a uniquely-named empty material under /Materials and opens it.
void CreateNewMaterial();
void RenderNodeGraphCanvas();
void RenderPreviewPanel();
void HandleCreateAndDelete();
/// Nuke-style TAB popup: type-to-filter shader node list; Enter or click
/// creates the highlighted node at canvasPos.
void RenderNodeSearchMenu(const ImVec2& canvasPos);
void CreateShaderNode(const std::string& shaderId, const ImVec2& canvasPos);
/// Binds an existing material to targetPath, undoably, restoring whatever
/// direct binding (if any) targetPath had before.
void BindMaterialToTarget(const pxr::SdfPath& materialPath, const pxr::SdfPath& targetPath);
/// Creates a new material named after m_targetPrimPath under /Materials,
/// binds it there, and opens it in the canvas.
void CreateAndBindMaterialForTarget();
void CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput);
void DeleteNode(const pxr::SdfPath& nodePath);
void DisconnectAttr(const pxr::SdfPath& destNode, const std::string& destInput);
void SyncFromUsd();
void PersistNodePosition(ax::NodeEditor::NodeId nodeId);
bool IsPinLinked(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const;
static bool SaveNodeSettingsCallback(ax::NodeEditor::NodeId nodeId,
const char* data, size_t size,
ax::NodeEditor::SaveReasonFlags reason,
void* userPointer);
ax::NodeEditor::EditorContext* m_editorContext = nullptr;
pxr::UsdStageRefPtr m_stage;
MaterialManager* m_materialManager = nullptr;
CommandHistory* m_commandHistory = nullptr;
IconManager* m_iconManager = nullptr;
/// Material currently shown in the graph work area (empty = none).
pxr::SdfPath m_materialPath;
/// Material highlighted in the browser list; becomes m_materialPath when
/// the user clicks "Show Graph" (or double-clicks the entry).
pxr::SdfPath m_browserSelection;
/// Currently-selected prim, tracked for the "create + bind" / "bind"
/// toolbar affordances; empty when nothing (or a non-prim path) is selected.
pxr::SdfPath m_targetPrimPath;
ShaderGraphSnapshot m_graph;
/// NodeId (uintptr_t) -> prim path, for nodes already known to the editor
/// this session (seeded position, so re-syncing won't fight live drags).
std::unordered_map<uintptr_t, pxr::SdfPath> m_nodeIdToPath;
/// Rebuilt every frame from the current m_graph while rendering.
std::unordered_map<uintptr_t, PinInfo> m_pinIdToInfo;
std::unordered_map<uintptr_t, LinkInfo> m_linkIdToInfo;
MaterialPreviewRenderer m_preview;
ImVec2 m_pendingCreateNodePos{0.0f, 0.0f};
/// Alt+RMB drag distance not yet converted into a discrete wheel-zoom
/// step (the node editor only zooms in wheel increments).
float m_zoomDragAccum = 0.0f;
char m_nodeSearchBuf[128] = "";
int m_nodeSearchSelected = 0;
/// Search text of the browser's persistent create-node list.
char m_browserNodeSearchBuf[128] = "";
/// Shader id clicked in the browser's create-node list; created at the
/// canvas view center on the next RenderNodeGraphCanvas (ScreenToCanvas
/// is only valid inside the editor's Begin/End).
std::string m_pendingCreateShaderId;
/// First node currently selected in the graph editor (empty = none);
/// drives the properties section under the preview.
pxr::SdfPath m_selectedNodePath;
/// Value/authored-state of the input being edited, captured when its
/// widget activates, so the commit command can restore it on undo. Only
/// one ImGui widget can be active at a time, so one slot suffices.
pxr::VtValue m_preEditValue;
bool m_preEditWasAuthored = false;
};
} // namespace UsdLayerManager
+332
View File
@@ -0,0 +1,332 @@
#include "MaterialPreviewRenderer.h"
#include "../utils/Logger.h"
#include "../utils/PathUtils.h"
#include <pxr/usd/usdGeom/sphere.h>
#include <pxr/usd/usdGeom/metrics.h>
#include <pxr/usd/usdGeom/tokens.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/connectableAPI.h>
#include <pxr/usd/usdLux/distantLight.h>
#include <pxr/usd/usdLux/domeLight.h>
#include <pxr/usd/usdLux/lightAPI.h>
#include <pxr/usd/usdLux/tokens.h>
#include <pxr/usd/usdGeom/xformCommonAPI.h>
#include <pxr/usd/usd/references.h>
#include <pxr/usd/sdf/assetPath.h>
#include <pxr/usd/sdf/primSpec.h>
#include <imgui.h>
#include <filesystem>
namespace UsdLayerManager {
namespace {
const pxr::SdfPath kSpherePath("/Preview/Sphere");
const pxr::SdfPath kDistantLightPath("/Preview/Light");
const pxr::SdfPath kDomeLightPath("/Preview/DomeLight");
/// Scratch UsdPreviewSurface authored inside the (referenced) material scope
/// when previewing a non-terminal node output — child of the material so the
/// connection stays encapsulated.
const pxr::TfToken kNodePreviewShaderName("__uslm_node_preview");
struct LightPreset {
const char* label;
const char* file; ///< under resources/hdri/ (CC0, Poly Haven; fetched by CMake)
};
const LightPreset kLightPresets[] = {
{"External", "kloofendal_48d_partly_cloudy_puresky_1k.exr"},
{"Room", "lebombo_1k.exr"},
{"Interior", "artist_workshop_1k.exr"},
{"Sunset", "venice_sunset_1k.exr"},
};
constexpr int kLightPresetCount = static_cast<int>(sizeof(kLightPresets) / sizeof(kLightPresets[0]));
} // namespace
void MaterialPreviewRenderer::EnsureInitialized() {
if (m_initialized) return;
m_previewStage = pxr::UsdStage::CreateInMemory();
// Pin the up axis rather than inheriting the site fallback: the light
// rig, dome-pole compensation, and camera framing all assume Y-up.
pxr::UsdGeomSetStageUpAxis(m_previewStage, pxr::UsdGeomTokens->y);
pxr::UsdGeomSphere sphere = pxr::UsdGeomSphere::Define(m_previewStage, kSpherePath);
sphere.GetRadiusAttr().Set(1.0);
// Storm renders fine with zero authored lights (it injects a GL headlight
// as a fallback), but proper Hydra delegates like Embree/Arnold shade
// through scene lights only — without one they render solid black.
// This distant light is the fallback when the HDR dome preset's texture
// isn't bundled; ApplyLightPreset toggles between it and the dome.
pxr::UsdLuxDistantLight light = pxr::UsdLuxDistantLight::Define(m_previewStage, kDistantLightPath);
// pxr::UsdLuxLightAPI(light.GetPrim()).CreateIntensityAttr(pxr::VtValue(15000.0f));
pxr::UsdLuxLightAPI(light.GetPrim()).CreateIntensityAttr(pxr::VtValue(1500.0f));
// Default distant-light orientation shines along local -Z, which lights
// the far side of the sphere from the camera's default view. Angle it
// like a classic front-upper-left key light instead. Adding this scene
// light suppresses UsdSceneRenderer's GL headlight fallback (it only
// kicks in when the stage has no authored lights), so this needs to
// carry the whole preview on its own.
pxr::UsdGeomXformCommonAPI(light.GetPrim()).SetRotate(pxr::GfVec3f(-35.0f, -35.0f, 0.0f));
m_camera.SetStage(m_previewStage);
m_renderer.SetStage(m_previewStage);
m_renderer.SetShowGrid(false);
// Raised refinement so the implicit sphere tessellates smoothly instead
// of showing faceted silhouettes at swatch scale.
m_renderer.SetComplexity(1.3f);
ApplyLightPreset(m_lightPreset);
m_previewBBox = pxr::GfBBox3d(m_renderer.ComputeStageBounds());
m_camera.FrameSelection(m_previewBBox, 1.6); // extra margin so the sphere doesn't touch the frame edge
m_renderer.SetCameraStateFromGfCamera(m_camera.ComputeGfCamera(m_previewBBox));
m_initialized = true;
m_dirty = true;
}
void MaterialPreviewRenderer::SetMaterial(const pxr::UsdStageRefPtr& sourceStage,
const pxr::SdfPath& materialPath,
size_t graphRevision,
const pxr::SdfPath& previewNodePath,
const std::string& previewNodeOutput,
bool previewOutputIsTerminal) {
EnsureInitialized();
if (!sourceStage || materialPath.IsEmpty()) return;
if (materialPath == m_materialPath && graphRevision == m_lastGraphRevision &&
previewNodePath == m_previewNodePath && previewNodeOutput == m_previewNodeOutput)
return; // nothing the shader ball cares about has changed
if (!sourceStage->GetPrimAtPath(materialPath)) {
LOG_WARNING("MaterialPreviewRenderer: no prim at " + materialPath.GetString());
return;
}
// Compose the material into the preview stage via a reference to the
// source stage's root layer, rather than SdfCopySpec from one local
// layer: a material pulled in by a reference (e.g. a .mtlx asset) has no
// spec in any local layer — it only exists composed — and even local
// materials can be spread across sublayers. Referencing at the identical
// path keeps internal connection paths valid without remapping, and
// relative asset paths keep resolving against their original layers.
pxr::UsdPrim mirrorPrim = m_previewStage->OverridePrim(materialPath);
if (!mirrorPrim) {
LOG_ERROR("MaterialPreviewRenderer: failed to create override at " + materialPath.GetString());
return;
}
mirrorPrim.GetReferences().ClearReferences();
mirrorPrim.GetReferences().AddReference(
sourceStage->GetRootLayer()->GetIdentifier(), materialPath);
pxr::UsdPrim spherePrim = m_previewStage->GetPrimAtPath(kSpherePath);
pxr::UsdShadeMaterial material(m_previewStage->GetPrimAtPath(materialPath));
if (!material) {
LOG_WARNING("MaterialPreviewRenderer: no material composed at " + materialPath.GetString());
return;
}
if (spherePrim)
pxr::UsdShadeMaterialBindingAPI::Apply(spherePrim).Bind(material);
// ── Selected-node preview (Hypershade-style) ─────────────────────────
// Wipe any override left by a previous selection: the scratch wrapper
// shader and the local opinions on the material's surface outputs, all
// of which live only in the scratch stage's root layer.
pxr::SdfLayerHandle rootLayer = m_previewStage->GetRootLayer();
if (pxr::SdfPrimSpecHandle matSpec = rootLayer->GetPrimAtPath(materialPath)) {
if (pxr::SdfPrimSpecHandle wrapSpec =
rootLayer->GetPrimAtPath(materialPath.AppendChild(kNodePreviewShaderName)))
matSpec->RemoveNameChild(wrapSpec);
for (const char* outName : {"outputs:surface", "outputs:mtlx:surface"}) {
if (pxr::SdfPropertySpecHandle prop =
rootLayer->GetPropertyAtPath(materialPath.AppendProperty(pxr::TfToken(outName))))
matSpec->RemoveProperty(prop);
}
}
// Route the material's surface through the selected node. Nodes outside
// the material's subtree (possible in hand-authored cross-scope networks)
// aren't composed into the scratch stage, so those keep the whole-material
// preview.
if (!previewNodePath.IsEmpty() && !previewNodeOutput.empty() &&
previewNodePath.HasPrefix(materialPath)) {
pxr::UsdShadeShader nodeShader(m_previewStage->GetPrimAtPath(previewNodePath));
if (nodeShader) {
pxr::UsdShadeConnectableAPI sourceApi = nodeShader.ConnectableAPI();
pxr::TfToken sourceName(previewNodeOutput);
if (!previewOutputIsTerminal) {
// Pattern/texture output: show it as the diffuse color of a
// scratch UsdPreviewSurface, like Maya's swatch for textures.
pxr::UsdShadeShader wrap = pxr::UsdShadeShader::Define(
m_previewStage, materialPath.AppendChild(kNodePreviewShaderName));
wrap.CreateIdAttr(pxr::VtValue(pxr::TfToken("UsdPreviewSurface")));
wrap.CreateInput(pxr::TfToken("roughness"), pxr::SdfValueTypeNames->Float)
.Set(0.4f);
wrap.CreateInput(pxr::TfToken("diffuseColor"), pxr::SdfValueTypeNames->Color3f)
.ConnectToSource(sourceApi, sourceName);
sourceApi = wrap.ConnectableAPI();
sourceName = pxr::TfToken("surface");
}
// Override both the universal and the mtlx render-context outputs
// so the preview target wins regardless of which context the
// material authored (a .mtlx import only authors outputs:mtlx:*,
// which Storm would otherwise prefer).
material.CreateSurfaceOutput().ConnectToSource(sourceApi, sourceName);
material.CreateSurfaceOutput(pxr::TfToken("mtlx")).ConnectToSource(sourceApi, sourceName);
}
}
m_materialPath = materialPath;
m_lastGraphRevision = graphRevision;
m_previewNodePath = previewNodePath;
m_previewNodeOutput = previewNodeOutput;
m_dirty = true;
}
void MaterialPreviewRenderer::ApplyLightPreset(int index) {
if (index < 0 || index >= kLightPresetCount) index = 0;
m_lightPreset = index;
const std::string file = ResourcePath(std::string("resources/hdri/") + kLightPresets[index].file);
const bool haveHdr = std::filesystem::exists(file);
pxr::UsdPrim distant = m_previewStage->GetPrimAtPath(kDistantLightPath);
if (haveHdr) {
pxr::UsdLuxDomeLight dome = pxr::UsdLuxDomeLight::Define(m_previewStage, kDomeLightPath);
dome.CreateTextureFileAttr().Set(pxr::SdfAssetPath(file));
// Declare the projection explicitly: left at "automatic", hdArnold
// falls through to Arnold's skydome default ("angular" fisheye),
// which squeezes an equirect panorama into a blob at the center.
// Storm always samples latlong regardless, so this only affects
// spec-following delegates.
dome.CreateTextureFormatAttr().Set(pxr::UsdLuxTokens->latlong);
dome.GetPrim().SetActive(true);
if (distant) distant.SetActive(false); // dome carries the lighting alone
UpdateDomeOrientation();
} else {
LOG_WARNING("MaterialPreviewRenderer: HDRI not found (" + file +
") — falling back to distant light. Re-run CMake configure to download presets.");
if (pxr::UsdPrim dome = m_previewStage->GetPrimAtPath(kDomeLightPath))
dome.SetActive(false);
if (distant) distant.SetActive(true);
}
m_dirty = true;
}
void MaterialPreviewRenderer::UpdateDomeOrientation() {
if (!m_previewStage) return;
pxr::UsdPrim dome = m_previewStage->GetPrimAtPath(kDomeLightPath);
if (!dome) return;
// Storm samples the dome texture with the pole along the light's local
// +Y (domeLight.glslfx: v = acos(dir.y)/pi), while spec-following
// delegates map it with the pole along local +Z (hdCycles feeds the
// dome-local direction to Cycles' Z-up EnvironmentTextureNode; hdArnold's
// skydome follows the same UsdLux convention). Rotating the dome -90°
// about X for those points their +Z pole at world +Y, which reproduces
// Storm's orientation exactly — including the horizontal (longitude)
// alignment, since both parameterize u from the same axes.
const pxr::TfToken rendererId = m_renderer.GetCurrentRendererId();
const bool yUpPole = rendererId.IsEmpty() ||
rendererId == pxr::TfToken("HdStormRendererPlugin");
if (rendererId == pxr::TfToken("HdArnoldRendererPlugin")) {
// With texture:format explicitly latlong, hdArnold aligns its skydome
// with Storm on its own — earlier rotation offsets here were chasing
// what turned out to be the angular-projection bug (format left
// "automatic" fell through to Arnold's fisheye default). If a real
// residual yaw shows up against Storm, put it in the Y component.
pxr::UsdGeomXformCommonAPI(dome).SetRotate(pxr::GfVec3f(0.0f, 0.0f, 0.0f));
} else {
if(rendererId == pxr::TfToken("HdCyclesPlugin")) {
// hdCycles' latlong mapping is flipped 180° from Storm's, so add
// a yaw to match Storm's orientation. The pole is still along +Z.
pxr::UsdGeomXformCommonAPI(dome).SetRotate(pxr::GfVec3f(yUpPole ? 0.0f : -90.0f, 270.0f, 0.0f));
} else {
pxr::UsdGeomXformCommonAPI(dome).SetRotate(pxr::GfVec3f(yUpPole ? 0.0f : -90.0f, 0.0f, 0.0f));
}
}
m_dirty = true;
}
void MaterialPreviewRenderer::RenderLightingDropdown() {
if (!m_initialized) return;
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::BeginCombo("##PreviewLighting", kLightPresets[m_lightPreset].label)) {
for (int i = 0; i < kLightPresetCount; ++i) {
if (ImGui::Selectable(kLightPresets[i].label, i == m_lightPreset) && i != m_lightPreset)
ApplyLightPreset(i);
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Shader-ball HDR environment");
}
uint32_t MaterialPreviewRenderer::Render(int width, int height) {
EnsureInitialized();
if (width <= 0 || height <= 0) return 0;
// Re-render while a progressive delegate (Arnold/Cycles/Embree) is still
// accumulating — rendering only once would freeze its first noisy sample
// pass into the cached texture. Storm converges in one pass, so the
// dirty-flag caching still applies there.
if (m_dirty || width != m_lastWidth || height != m_lastHeight || !m_renderer.IsConverged()) {
// 2x supersample: the texture is displayed at (width, height) by
// ImGui, so rendering larger gives the swatch cheap anti-aliasing.
m_renderer.Render(width * 2, height * 2);
m_dirty = false;
m_lastWidth = width;
m_lastHeight = height;
}
return m_renderer.GetColorTextureID();
}
void MaterialPreviewRenderer::OrbitDrag(float deltaX, float deltaY) {
if (!m_initialized) return;
m_camera.Tumble(deltaX * 0.5, deltaY * 0.5); // matches the viewport's tumble sensitivity
m_renderer.SetCameraStateFromGfCamera(m_camera.ComputeGfCamera(m_previewBBox));
m_dirty = true;
}
void MaterialPreviewRenderer::SetColorCorrection(int ccMode, const std::string& ocioDisplay,
const std::string& ocioView,
const std::string& ocioColorSpace,
const std::string& ocioLook) {
m_renderer.SetColorCorrectionMode(static_cast<ColorCorrectionMode>(ccMode));
m_renderer.SetOcioDisplay(ocioDisplay);
m_renderer.SetOcioView(ocioView);
m_renderer.SetOcioColorSpace(ocioColorSpace);
m_renderer.SetOcioLook(ocioLook);
m_dirty = true;
}
void MaterialPreviewRenderer::RenderRendererDropdown() {
pxr::TfToken currentId = m_renderer.GetCurrentRendererId();
std::string displayName = currentId.IsEmpty()
? "Renderer"
: UsdSceneRenderer::GetRendererDisplayName(currentId);
ImGui::Button(displayName.c_str(), ImVec2(-1.0f, 0.0f));
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Shader-ball render delegate");
if (ImGui::BeginPopupContextItem("MaterialPreviewRendererPopup", ImGuiPopupFlags_MouseButtonLeft)) {
for (const auto& pluginId : UsdSceneRenderer::GetRendererPlugins()) {
std::string name = UsdSceneRenderer::GetRendererDisplayName(pluginId);
if (name.empty()) name = pluginId.GetString();
bool selected = (pluginId == currentId);
if (ImGui::MenuItem(name.c_str(), nullptr, selected) && !selected) {
m_renderer.SetRendererPlugin(pluginId);
UpdateDomeOrientation(); // pole convention differs per delegate
m_dirty = true;
}
}
ImGui::EndPopup();
}
}
} // namespace UsdLayerManager
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "../core/UsdSceneRenderer.h"
#include "../core/ViewportCamera.h"
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/sdf/path.h>
#include <pxr/base/gf/bbox3d.h>
#include <cstdint>
#include <string>
namespace UsdLayerManager {
/// Shader-ball preview: mirrors the material currently open in the Material
/// Editor onto a scratch sphere and renders it through an independent Hydra
/// instance, with its own renderer-delegate switch (mirrors the viewport's
/// dropdown so the swatch can preview Storm, Cycles, etc. independently of
/// the main viewport's choice).
class MaterialPreviewRenderer {
public:
/// Mirrors the material subtree at materialPath (composed via a reference
/// to sourceStage's root layer) onto the internal scratch stage and binds
/// it to the preview sphere. graphRevision is a cheap caller-computed
/// hash of the current node/link graph + values — compared against the
/// last call so unrelated frames don't pay for a re-render (real cost
/// with a path-traced delegate).
///
/// previewNodePath (optional, Hypershade-style): the shader node selected
/// in the graph; the ball then previews that node's output instead of the
/// material's own surface. Terminal (token-typed) outputs are connected
/// as the surface directly; pattern outputs are routed into the
/// diffuseColor of a scratch UsdPreviewSurface. Pass an empty path to
/// preview the whole material.
void SetMaterial(const pxr::UsdStageRefPtr& sourceStage,
const pxr::SdfPath& materialPath,
size_t graphRevision,
const pxr::SdfPath& previewNodePath = pxr::SdfPath(),
const std::string& previewNodeOutput = std::string(),
bool previewOutputIsTerminal = false);
/// Renders only if something changed since the last call. Returns the GL
/// color texture ID suitable for ImGui::Image().
uint32_t Render(int width, int height);
/// Click-drag orbit input, in pixel deltas (mirrors Maya's swatch orbit).
void OrbitDrag(float deltaX, float deltaY);
void SetColorCorrection(int ccMode, const std::string& ocioDisplay,
const std::string& ocioView,
const std::string& ocioColorSpace,
const std::string& ocioLook);
/// Renderer-delegate picker UI (matches the viewport's dropdown pattern).
void RenderRendererDropdown();
/// HDR-environment picker (External / Room / Interior / Sunset). Presets
/// map to bundled resources/hdri/*.exr dome-light textures; a preset
/// whose file is missing falls back to the distant key light.
void RenderLightingDropdown();
private:
void EnsureInitialized();
void ApplyLightPreset(int index);
/// Compensates the dome-light pole convention per render delegate: Storm
/// samples with the pole along local +Y, spec-following delegates
/// (hdArnold, hdCycles) along local +Z. Called on preset and renderer
/// switches so the HDR environment reads identically everywhere.
void UpdateDomeOrientation();
pxr::UsdStageRefPtr m_previewStage;
UsdSceneRenderer m_renderer;
ViewportCamera m_camera;
pxr::GfBBox3d m_previewBBox;
pxr::SdfPath m_materialPath; // path (identical in source & scratch stage)
size_t m_lastGraphRevision = 0;
pxr::SdfPath m_previewNodePath; // node whose output the ball previews (empty = whole material)
std::string m_previewNodeOutput;
int m_lightPreset = 0;
bool m_initialized = false;
bool m_dirty = true;
int m_lastWidth = 0;
int m_lastHeight = 0;
};
} // namespace UsdLayerManager
+7 -2
View File
@@ -1240,8 +1240,13 @@ void PropertyPanel::RenderMaterialBindSection(const pxr::UsdPrim& prim) {
ImGui::TableSetColumnIndex(0); ImGui::TextDisabled("Resolved");
ImGui::TableSetColumnIndex(1);
if (resolved) {
ImGui::TextColored(ImVec4(0.7f, 1.f, 0.7f, 1.f),
"%s", resolved.GetPrim().GetPath().GetText());
std::string matPathStr = resolved.GetPrim().GetPath().GetString();
ImGui::TextColored(ImVec4(0.7f, 1.f, 0.7f, 1.f), "%s", matPathStr.c_str());
if (OnEditMaterialRequested) {
ImGui::SameLine();
if (ImGui::SmallButton("Edit Material"))
OnEditMaterialRequested(matPathStr);
}
} else {
ImGui::TextDisabled("(none)");
}
+6
View File
@@ -10,6 +10,7 @@
#include <pxr/base/gf/vec3d.h>
#include <pxr/usd/usdGeom/xformCommonAPI.h>
#include <imgui.h>
#include <functional>
#include <string>
namespace UsdLayerManager {
@@ -33,6 +34,11 @@ public:
void Render();
/// Fired when the user clicks "Edit Material" next to the resolved
/// binding in the Material Binding section, with the resolved material's
/// path. Wired by Application to open the Material Editor on that material.
std::function<void(const std::string&)> OnEditMaterialRequested;
private:
void ReadTransform();
void WriteTranslate();