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
+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();