Add in-canvas node thumbnails to material editor graph

Texture nodes show their decoded source image (background thread);
material nodes show a shader-ball render, amortized one-per-frame.
Also classifies shader nodes by Hypershade-style category and adds
an Arnold preset graph alongside the existing USD/MaterialX ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:08:56 +08:00
parent 7d214821f5
commit d9826b7bbb
7 changed files with 853 additions and 31 deletions
+197 -14
View File
@@ -7,6 +7,7 @@
#include "../core/commands/AttributeSetCommand.h"
#include "../core/commands/RenamePrimCommand.h"
#include "../utils/Logger.h"
#include "../utils/FileDialog.h"
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
@@ -328,6 +329,7 @@ void MaterialEditorPanel::SetStage(pxr::UsdStageRefPtr stage) {
m_graph = ShaderGraphSnapshot();
m_materialPath = pxr::SdfPath();
m_browserSelection = pxr::SdfPath();
m_thumbnails.Clear();
}
void MaterialEditorPanel::SetColumnWidths(float browserWidth, float previewWidth) {
@@ -616,18 +618,56 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node,
bool v = current.IsHolding<bool>() && current.UncheckedGet<bool>();
if (ImGui::Checkbox(widgetId.c_str(), &v))
newValue = v;
} else if (type == tn->String || type == tn->Token || type == tn->Asset) {
} else if (type == tn->String || type == tn->Token) {
std::string s;
if (current.IsHolding<std::string>()) s = current.UncheckedGet<std::string>();
else if (current.IsHolding<pxr::TfToken>()) s = current.UncheckedGet<pxr::TfToken>().GetString();
else if (current.IsHolding<pxr::SdfAssetPath>()) s = current.UncheckedGet<pxr::SdfAssetPath>().GetAssetPath();
char buf[512];
std::snprintf(buf, sizeof(buf), "%s", s.c_str());
if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
if (type == tn->String) newValue = std::string(buf);
else if (type == tn->Token) newValue = pxr::TfToken(buf);
else newValue = pxr::SdfAssetPath(buf);
else newValue = pxr::TfToken(buf);
}
} else if (type == tn->Asset) {
std::string s;
if (current.IsHolding<pxr::SdfAssetPath>()) s = current.UncheckedGet<pxr::SdfAssetPath>().GetAssetPath();
else if (current.IsHolding<std::string>()) s = current.UncheckedGet<std::string>();
// Browse button first so the text field stays the "last item" the
// shared activate/commit logic below inspects. The button authors +
// commits its own undoable edit inline.
bool browseClicked;
if (m_iconManager)
browseClicked = ImGui::ImageButton("##browseTex",
ImTextureRef(m_iconManager->Get(Icon::FolderOpen)),
ImVec2(14.f, 14.f));
else
browseClicked = ImGui::SmallButton("...");
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Browse for a texture file");
ImGui::SameLine();
if (browseClicked) {
static const char* kTexFilter =
"Images (*.png;*.jpg;*.jpeg;*.exr;*.tif;*.tiff;*.hdr;*.tga;*.bmp;*.tx)\0"
"*.png;*.jpg;*.jpeg;*.exr;*.tif;*.tiff;*.hdr;*.tga;*.bmp;*.tx\0"
"All Files (*.*)\0*.*\0";
std::string picked = FileDialog::OpenFile(kTexFilter, "Select Texture File");
if (!picked.empty() && picked != s) {
m_preEditValue = current;
m_preEditWasAuthored = authored;
pxr::UsdShadeShader shader(prim);
if (shader)
shader.CreateInput(pxr::TfToken(input.name), input.typeName)
.Set(pxr::VtValue(pxr::SdfAssetPath(picked)));
CommitInputEdit(node, input);
}
}
char buf[512];
std::snprintf(buf, sizeof(buf), "%s", s.c_str());
if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue))
newValue = pxr::SdfAssetPath(buf);
} else {
ImGui::AlignTextToFramePadding();
ImGui::TextDisabled("%s", type.GetAsToken().GetText());
@@ -737,6 +777,14 @@ void MaterialEditorPanel::RenderToolbar() {
ImGui::EndDisabled();
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Arnold")) {
ImGui::BeginDisabled(
!sdr.GetShaderNodeByIdentifier(pxr::TfToken("arnold:standard_surface")));
if (ImGui::MenuItem("Standard Surface Graph"))
CreateArnoldPresetGraph();
ImGui::EndDisabled();
ImGui::EndMenu();
}
ImGui::EndPopup();
}
@@ -988,8 +1036,6 @@ void MaterialEditorPanel::CreateUsdPresetGraph() {
"metallic", pxr::SdfValueTypeNames->Float, true},
{"roughnessTexture", "r", pxr::SdfValueTypeNames->Float,
"roughness", pxr::SdfValueTypeNames->Float, true},
{"normalTexture", "rgb", pxr::SdfValueTypeNames->Float3,
"normal", pxr::SdfValueTypeNames->Normal3f, true},
};
float y = 0.f;
for (const TexPreset& t : textures) {
@@ -1004,13 +1050,6 @@ void MaterialEditorPanel::CreateUsdPresetGraph() {
surf.CreateInput(pxr::TfToken(t.destInput), t.destType)
.ConnectToSource(tex.ConnectableAPI(), pxr::TfToken(t.texOutput));
}
// Normal maps need the [0,1] texture range remapped to [-1,1].
pxr::UsdShadeShader normalTex(
stage->GetPrimAtPath(matPath.AppendChild(pxr::TfToken("normalTexture"))));
normalTex.CreateInput(pxr::TfToken("scale"), pxr::SdfValueTypeNames->Float4)
.Set(pxr::GfVec4f(2.f, 2.f, 2.f, 1.f));
normalTex.CreateInput(pxr::TfToken("bias"), pxr::SdfValueTypeNames->Float4)
.Set(pxr::GfVec4f(-1.f, -1.f, -1.f, 0.f));
pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath));
if (material)
@@ -1019,7 +1058,7 @@ void MaterialEditorPanel::CreateUsdPresetGraph() {
};
auto remove = [stage, matPath]() {
for (const char* name : {"UsdPreviewSurface", "stReader", "diffuseTexture",
"metallicTexture", "roughnessTexture", "normalTexture"})
"metallicTexture", "roughnessTexture"})
stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name)));
if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath))
mat.RemoveProperty(pxr::TfToken("outputs:surface"));
@@ -1094,6 +1133,65 @@ void MaterialEditorPanel::CreateMaterialXPresetGraph() {
SyncFromUsd();
}
void MaterialEditorPanel::CreateArnoldPresetGraph() {
if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return;
// Re-applying is allowed; see CreateUsdPresetGraph.
pxr::UsdStageRefPtr stage = m_stage;
pxr::SdfPath matPath = m_materialPath;
auto build = [stage, matPath]() {
// See the USD preset: spacing must exceed rendered node sizes or
// overlapping nodes steal each other's drag hit-tests.
pxr::UsdShadeShader surf = DefinePresetShader(
stage, matPath, "standard_surface", "arnold:standard_surface",
pxr::GfVec2f(0.f, 300.f));
struct ImagePreset {
const char* name;
const char* destInput; pxr::SdfValueTypeName destType;
bool rawColorSpace;
};
const ImagePreset images[] = {
{"base_color_image", "base_color", pxr::SdfValueTypeNames->Color3f, false},
{"specular_roughness_image", "specular_roughness", pxr::SdfValueTypeNames->Float, true},
{"metalness_image", "metalness", pxr::SdfValueTypeNames->Float, true},
};
// Arnold's image node samples the mesh's default UV set when uvcoords is
// left unconnected, so no explicit texcoord reader node is needed.
float y = 0.f;
for (const ImagePreset& img : images) {
pxr::UsdShadeShader tex = DefinePresetShader(
stage, matPath, img.name, "arnold:image", pxr::GfVec2f(-420.f, y));
y += 400.f;
if (img.rawColorSpace)
tex.CreateInput(pxr::TfToken("color_space"), pxr::SdfValueTypeNames->String)
.Set(std::string("raw"));
surf.CreateInput(pxr::TfToken(img.destInput), img.destType)
.ConnectToSource(tex.ConnectableAPI(), pxr::TfToken("out"));
}
// Arnold materials terminate on the arnold render-context surface output
// (matches hdArnold-authored materials).
pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath));
if (material)
material.CreateSurfaceOutput(pxr::TfToken("arnold")).ConnectToSource(
surf.ConnectableAPI(), pxr::TfToken("out"));
};
auto remove = [stage, matPath]() {
for (const char* name : {"standard_surface", "base_color_image",
"specular_roughness_image", "metalness_image"})
stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name)));
if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath))
mat.RemoveProperty(pxr::TfToken("outputs:arnold:surface"));
};
m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
"Create Arnold Preset Graph", build, remove));
m_nodeIdToPath.clear(); // reseed positions (see CreateUsdPresetGraph)
SyncFromUsd();
}
void MaterialEditorPanel::OpenOrCreateMaterial(const std::string& pathStr) {
if (!m_stage) return;
if (!pxr::SdfPath::IsValidPathString(pathStr, nullptr)) {
@@ -1112,6 +1210,7 @@ void MaterialEditorPanel::OpenOrCreateMaterial(const std::string& pathStr) {
m_materialPath = path;
m_browserSelection = path;
m_nodeIdToPath.clear(); // force position reseed for the (possibly different) material now open
m_thumbnails.Clear(); // drop the previous material's node thumbnails
SyncFromUsd();
}
@@ -1242,6 +1341,11 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
const ImVec2 viewCenterScreen(regionPos.x + regionSize.x * 0.5f,
regionPos.y + regionSize.y * 0.5f);
// Drain finished texture decodes (GL upload), render one stale material
// thumbnail, and prune dead entries — GL/FBO work kept outside the
// node-editor's Begin/End so it never touches mid-draw ImGui state.
m_thumbnails.PumpMainThread();
NE::SetCurrentEditor(m_editorContext);
NE::Begin("MaterialEditorCanvas", ImVec2(0.0f, 0.0f));
@@ -1258,6 +1362,9 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
const float nodeRounding = NE::GetStyle().NodeRounding;
const ImVec2 pinIconSize(16.0f, 16.0f);
// One whole-graph revision hash drives every material thumbnail this frame.
const size_t graphRevision = ComputeGraphRevision(m_stage, m_graph);
for (const auto& node : m_graph.nodes) {
uintptr_t nodeIdValue = HashId(node.path.GetString());
NE::NodeId nodeId(nodeIdValue);
@@ -1270,6 +1377,38 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
const std::string& title = node.shaderId.empty() ? node.path.GetName() : node.shaderId;
const ImU32 headerColor = GetHeaderColor(title);
// Thumbnail below the title: the source image for Texture nodes, a
// shader-ball render for Material nodes. thumb is 0 until ready; the
// space is still reserved so the node doesn't jump when it appears.
ImTextureID thumb = 0;
bool wantsThumb = false;
if (node.category == "Texture") {
// Any texture node that exposes a file/asset input reserves a
// thumbnail slot. It stays black until a valid, on-disk image is
// decoded, so an unset or unresolvable path reads as a black chip.
bool hasFileInput = false;
for (const auto& in : node.inputs)
if (in.typeName == pxr::SdfValueTypeNames->Asset) { hasFileInput = true; break; }
if (hasFileInput) {
wantsThumb = true;
const std::string file = ResolveTextureFilePath(node);
if (!file.empty())
thumb = m_thumbnails.GetTextureThumbnail(node.path, file);
}
} else if (node.category == "Material") {
wantsThumb = true;
// Preview the terminal (token-typed) output as the surface; fall
// back to the first output routed into diffuseColor.
std::string previewOut;
bool terminal = false;
for (const auto& o : node.outputs)
if (o.typeName == pxr::SdfValueTypeNames->Token) { previewOut = o.name; terminal = true; break; }
if (previewOut.empty() && !node.outputs.empty())
previewOut = node.outputs.front().name;
thumb = m_thumbnails.GetMaterialThumbnail(m_stage, m_materialPath, node.path,
previewOut, terminal, graphRevision);
}
// Node width is auto-fit to content with no flex-layout available (the
// library's Spring()/BeginHorizontal() column layout needs a custom
// ImGui fork this project doesn't use) — approximate the classic
@@ -1281,6 +1420,8 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(input.name.c_str()).x);
for (const auto& output : node.outputs)
contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(output.name.c_str()).x);
if (wantsThumb)
contentWidth = std::max(contentWidth, static_cast<float>(NodeThumbnailCache::kThumbPx));
NE::BeginNode(nodeId);
ImVec2 headerTop = ImGui::GetCursorScreenPos();
@@ -1288,6 +1429,24 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
ImVec2 headerBottom = ImGui::GetItemRectMax();
ImGui::Dummy(ImVec2(0.0f, 4.0f)); // breathing room between title and pins
if (wantsThumb) {
const ImVec2 thumbSize(static_cast<float>(NodeThumbnailCache::kThumbPx),
static_cast<float>(NodeThumbnailCache::kThumbPx));
if (thumb) {
ImGui::Image(thumb, thumbSize);
} else if (node.category == "Texture") {
// No resolvable image (unset path / file not found / still
// decoding): draw a solid black chip in the reserved slot.
const ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::Dummy(thumbSize);
ImGui::GetWindowDrawList()->AddRectFilled(
p, ImVec2(p.x + thumbSize.x, p.y + thumbSize.y), IM_COL32(0, 0, 0, 255));
} else {
ImGui::Dummy(thumbSize); // material thumbnail still rendering
}
ImGui::Dummy(ImVec2(0.0f, 4.0f));
}
for (const auto& input : node.inputs) {
uintptr_t pinIdValue = HashId(node.path.GetString() + ":in:" + input.name);
m_pinIdToInfo[pinIdValue] = PinInfo{node.path, input.name, false, input.typeName};
@@ -1796,6 +1955,30 @@ void MaterialEditorPanel::DisconnectAttr(const pxr::SdfPath& destNode, const std
SyncFromUsd();
}
std::string MaterialEditorPanel::ResolveTextureFilePath(const ShaderGraphNode& node) const {
if (!m_stage) return {};
// UsdUVTexture and MaterialX image/tiledimage all expose the source as an
// Asset-typed `file` input; prefer that name, else any Asset input.
std::string inputName;
for (const auto& in : node.inputs) {
if (in.typeName == pxr::SdfValueTypeNames->Asset) {
inputName = in.name;
if (in.name == "file") break;
}
}
if (inputName.empty()) return {};
pxr::UsdPrim prim = m_stage->GetPrimAtPath(node.path);
if (!prim) return {};
pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + inputName));
pxr::VtValue v;
if (!attr || !attr.Get(&v) || !v.IsHolding<pxr::SdfAssetPath>()) return {};
const pxr::SdfAssetPath ap = v.UncheckedGet<pxr::SdfAssetPath>();
// Resolved path for on-disk load; fall back to the authored path (may
// already be absolute) when the resolver returns nothing.
return ap.GetResolvedPath().empty() ? ap.GetAssetPath() : ap.GetResolvedPath();
}
void MaterialEditorPanel::PersistNodePosition(NE::NodeId nodeId) {
if (!m_stage || !m_commandHistory) return;