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:
+197
-14
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user