From d9826b7bbb6a35b6c46883a53eec2e46cbd4d6ca Mon Sep 17 00:00:00 2001 From: indigo Date: Fri, 10 Jul 2026 15:08:56 +0800 Subject: [PATCH] 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 --- src/core/MaterialManager.cpp | 1 + src/core/MaterialManager.h | 5 + src/ui/MaterialEditorPanel.cpp | 211 +++++++++++++++-- src/ui/MaterialEditorPanel.h | 7 + src/ui/MaterialPreviewRenderer.cpp | 201 ++++++++++++++-- src/ui/NodeThumbnailCache.cpp | 352 +++++++++++++++++++++++++++++ src/ui/NodeThumbnailCache.h | 107 +++++++++ 7 files changed, 853 insertions(+), 31 deletions(-) create mode 100644 src/ui/NodeThumbnailCache.cpp create mode 100644 src/ui/NodeThumbnailCache.h diff --git a/src/core/MaterialManager.cpp b/src/core/MaterialManager.cpp index 12bbcc4..160b040 100644 --- a/src/core/MaterialManager.cpp +++ b/src/core/MaterialManager.cpp @@ -185,6 +185,7 @@ ShaderGraphSnapshot MaterialManager::GetShaderGraph(const pxr::SdfPath& material pxr::SdrShaderNodeConstPtr sdrNode = pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(shaderId); if (sdrNode) { + node.category = DeriveCategory(sdrNode); for (const pxr::TfToken& inputName : sdrNode->GetShaderInputNames()) { if (auto* prop = sdrNode->GetShaderInput(inputName)) node.inputs.push_back({inputName.GetString(), prop->GetTypeAsSdfType().GetSdfType()}); diff --git a/src/core/MaterialManager.h b/src/core/MaterialManager.h index 367460d..42d855a 100644 --- a/src/core/MaterialManager.h +++ b/src/core/MaterialManager.h @@ -39,6 +39,11 @@ struct ShaderPinInfo { struct ShaderGraphNode { pxr::SdfPath path; std::string shaderId; + /// Coarse Hypershade-style bucket ("Material", "Texture", "Geometry", + /// "Utility", "Light") from the same classifier the create-node list uses; + /// empty when the shader id isn't in the Sdr registry. Drives which nodes + /// get an in-canvas thumbnail. + std::string category; 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. diff --git a/src/ui/MaterialEditorPanel.cpp b/src/ui/MaterialEditorPanel.cpp index 909d7f4..c4312b9 100644 --- a/src/ui/MaterialEditorPanel.cpp +++ b/src/ui/MaterialEditorPanel.cpp @@ -7,6 +7,7 @@ #include "../core/commands/AttributeSetCommand.h" #include "../core/commands/RenamePrimCommand.h" #include "../utils/Logger.h" +#include "../utils/FileDialog.h" #include #include #include @@ -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() && current.UncheckedGet(); 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()) s = current.UncheckedGet(); else if (current.IsHolding()) s = current.UncheckedGet().GetString(); - else if (current.IsHolding()) s = current.UncheckedGet().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()) s = current.UncheckedGet().GetAssetPath(); + else if (current.IsHolding()) s = current.UncheckedGet(); + + // 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( + "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(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(NodeThumbnailCache::kThumbPx), + static_cast(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()) return {}; + const pxr::SdfAssetPath ap = v.UncheckedGet(); + // 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; diff --git a/src/ui/MaterialEditorPanel.h b/src/ui/MaterialEditorPanel.h index 93f328d..24aa180 100644 --- a/src/ui/MaterialEditorPanel.h +++ b/src/ui/MaterialEditorPanel.h @@ -4,6 +4,7 @@ #include "../core/MaterialManager.h" #include "IconManager.h" #include "MaterialPreviewRenderer.h" +#include "NodeThumbnailCache.h" #include #include #include @@ -88,6 +89,7 @@ private: /// MaterialX standard_surface + image nodes fed by a texcoord node. void CreateUsdPresetGraph(); void CreateMaterialXPresetGraph(); + void CreateArnoldPresetGraph(); void RenderNodeGraphCanvas(); void RenderPreviewPanel(); void HandleCreateAndDelete(); @@ -113,6 +115,9 @@ private: void CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput); void DeleteNode(const pxr::SdfPath& nodePath); void DisconnectAttr(const pxr::SdfPath& destNode, const std::string& destInput); + /// Resolved on-disk path of a texture node's image (its Asset-typed `file` + /// input), or empty if unauthored/unresolvable. Feeds the thumbnail cache. + std::string ResolveTextureFilePath(const ShaderGraphNode& node) const; void SyncFromUsd(); void PersistNodePosition(ax::NodeEditor::NodeId nodeId); bool IsPinLinked(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const; @@ -146,6 +151,8 @@ private: std::unordered_map m_linkIdToInfo; MaterialPreviewRenderer m_preview; + /// Per-node in-canvas thumbnails (texture images + material shader balls). + NodeThumbnailCache m_thumbnails; ImVec2 m_pendingCreateNodePos{0.0f, 0.0f}; diff --git a/src/ui/MaterialPreviewRenderer.cpp b/src/ui/MaterialPreviewRenderer.cpp index d2e1aaa..ac4713c 100644 --- a/src/ui/MaterialPreviewRenderer.cpp +++ b/src/ui/MaterialPreviewRenderer.cpp @@ -5,12 +5,15 @@ #include #include #include +#include +#include #include #include #include #include #include #include +#include #include #include #include @@ -71,6 +74,180 @@ const LightPreset kLightPresets[] = { {"Sunset", "venice_sunset_1k.exr"}, }; constexpr int kLightPresetCount = static_cast(sizeof(kLightPresets) / sizeof(kLightPresets[0])); + +/// Authors a polygonal UsdGeomMesh from prebuilt arrays: vertex-interpolated +/// normals + a `st` texCoord2f primvar, no subdivision, single-sided. Shared by +/// every preview shape so they all light and texture identically. +void DefineUvMesh(const pxr::UsdStageRefPtr& stage, const pxr::SdfPath& path, + const pxr::VtVec3fArray& points, const pxr::VtVec3fArray& normals, + const pxr::VtVec2fArray& st, const pxr::VtIntArray& fvCounts, + const pxr::VtIntArray& fvIndices, + const pxr::GfVec3f& extentMin, const pxr::GfVec3f& extentMax) { + pxr::UsdGeomMesh mesh = pxr::UsdGeomMesh::Define(stage, path); + mesh.CreatePointsAttr(pxr::VtValue(points)); + mesh.CreateNormalsAttr(pxr::VtValue(normals)); + mesh.SetNormalsInterpolation(pxr::UsdGeomTokens->vertex); + mesh.CreateFaceVertexCountsAttr(pxr::VtValue(fvCounts)); + mesh.CreateFaceVertexIndicesAttr(pxr::VtValue(fvIndices)); + // Polygonal (not subdivided) so the authored smooth normals and UVs are used + // verbatim. Single-sided (the default): a closed shape never shows a + // backface, and double-sided shading runs a view-dependent normal flip that + // corrupts the mirror-reflection vector near the silhouette. + mesh.CreateSubdivisionSchemeAttr(pxr::VtValue(pxr::UsdGeomTokens->none)); + mesh.CreateExtentAttr(pxr::VtValue(pxr::VtVec3fArray{extentMin, extentMax})); + pxr::UsdGeomPrimvarsAPI(mesh).CreatePrimvar( + pxr::TfToken("st"), pxr::SdfValueTypeNames->TexCoord2fArray, pxr::UsdGeomTokens->vertex) + .Set(st); +} + +/// Authors a UV-mapped mesh sphere (Y-up) at `path` with vertex normals and a +/// `st` texCoord2f primvar. The implicit UsdGeomSphere carries no texture +/// coordinates (GeomUtilSphereMeshGenerator emits only points/normals/topology), +/// so a UsdUVTexture fed by a UsdPrimvarReader_float2("st") would only ever read +/// the reader's fallback and paint a flat colour. This mesh gives the shader +/// ball real UVs so textures map across every render delegate. +void CreateUvSphere(const pxr::UsdStageRefPtr& stage, const pxr::SdfPath& path, double radius) { + constexpr int rings = 48; // latitude bands (theta 0..pi) + constexpr int sectors = 96; // longitude bands (phi 0..2pi) + constexpr double kPi = 3.14159265358979323846; + const float r = static_cast(radius); + + pxr::VtVec3fArray points, normals; + pxr::VtVec2fArray st; + points.reserve((rings + 1) * (sectors + 1)); + normals.reserve((rings + 1) * (sectors + 1)); + st.reserve((rings + 1) * (sectors + 1)); + // A duplicated seam column (sectors+1 wide) gives distinct u=0 and u=1 + // vertices so the texture wraps without a smear across the seam. + for (int i = 0; i <= rings; ++i) { + const float v = static_cast(i) / rings; + const float th = v * static_cast(kPi); + const float sinT = std::sin(th), cosT = std::cos(th); + for (int j = 0; j <= sectors; ++j) { + const float u = static_cast(j) / sectors; + const float phi = u * 2.0f * static_cast(kPi); + const pxr::GfVec3f n(sinT * std::cos(phi), cosT, sinT * std::sin(phi)); + points.push_back(n * r); + normals.push_back(n); + st.push_back(pxr::GfVec2f(u, 1.0f - v)); // flip V so image top → sphere top + } + } + + pxr::VtIntArray fvCounts, fvIndices; + const int stride = sectors + 1; + for (int i = 0; i < rings; ++i) + for (int j = 0; j < sectors; ++j) { + const int a = i * stride + j; + fvCounts.push_back(4); + // Wind CCW as seen from outside so the (single-sided) front face and + // the outward vertex normals agree; a reversed winding would either + // cull the visible surface or flip its shading normal. + fvIndices.push_back(a); + fvIndices.push_back(a + 1); + fvIndices.push_back(a + stride + 1); + fvIndices.push_back(a + stride); + } + + DefineUvMesh(stage, path, points, normals, st, fvCounts, fvIndices, + pxr::GfVec3f(-r, -r, -r), pxr::GfVec3f(r, r, r)); +} + +/// UV-mapped box: 24 vertices (4 per face) so every face gets its own outward +/// normal and a full 0..1 UV square. halfSize is the half-edge length. +void CreateUvCube(const pxr::UsdStageRefPtr& stage, const pxr::SdfPath& path, double halfSize) { + const float h = static_cast(halfSize); + pxr::VtVec3fArray points, normals; + pxr::VtVec2fArray st; + pxr::VtIntArray fvCounts, fvIndices; + + // p0..p3 are wound CCW as seen from outside, so the front face agrees with n. + auto addFace = [&](const pxr::GfVec3f& p0, const pxr::GfVec3f& p1, + const pxr::GfVec3f& p2, const pxr::GfVec3f& p3, + const pxr::GfVec3f& n) { + const int base = static_cast(points.size()); + points.push_back(p0); points.push_back(p1); points.push_back(p2); points.push_back(p3); + for (int k = 0; k < 4; ++k) normals.push_back(n); + st.push_back(pxr::GfVec2f(0, 0)); st.push_back(pxr::GfVec2f(1, 0)); + st.push_back(pxr::GfVec2f(1, 1)); st.push_back(pxr::GfVec2f(0, 1)); + fvCounts.push_back(4); + fvIndices.push_back(base + 0); fvIndices.push_back(base + 1); + fvIndices.push_back(base + 2); fvIndices.push_back(base + 3); + }; + addFace({ h,-h, h}, { h,-h,-h}, { h, h,-h}, { h, h, h}, { 1, 0, 0}); // +X + addFace({-h,-h,-h}, {-h,-h, h}, {-h, h, h}, {-h, h,-h}, {-1, 0, 0}); // -X + addFace({-h, h,-h}, {-h, h, h}, { h, h, h}, { h, h,-h}, { 0, 1, 0}); // +Y + addFace({-h,-h,-h}, { h,-h,-h}, { h,-h, h}, {-h,-h, h}, { 0,-1, 0}); // -Y + addFace({-h,-h, h}, { h,-h, h}, { h, h, h}, {-h, h, h}, { 0, 0, 1}); // +Z + addFace({ h,-h,-h}, {-h,-h,-h}, {-h, h,-h}, { h, h,-h}, { 0, 0,-1}); // -Z + + DefineUvMesh(stage, path, points, normals, st, fvCounts, fvIndices, + pxr::GfVec3f(-h, -h, -h), pxr::GfVec3f(h, h, h)); +} + +/// UV-mapped cylinder (Y axis): radial side wall + two end caps. Side normals +/// are radial, caps ±Y; UVs wrap the wall (u around, v along height) and map +/// each cap as a disk. +void CreateUvCylinder(const pxr::UsdStageRefPtr& stage, const pxr::SdfPath& path, + double radius, double halfHeight) { + constexpr int sectors = 96; + constexpr double kPi = 3.14159265358979323846; + const float R = static_cast(radius); + const float hy = static_cast(halfHeight); + + pxr::VtVec3fArray points, normals; + pxr::VtVec2fArray st; + pxr::VtIntArray fvCounts, fvIndices; + + // Side wall: sectors+1 columns (duplicated seam for a clean u wrap), each a + // bottom+top pair interleaved so column j owns indices 2j (bottom), 2j+1 (top). + for (int j = 0; j <= sectors; ++j) { + const float u = static_cast(j) / sectors; + const float phi = u * 2.0f * static_cast(kPi); + const float c = std::cos(phi), s = std::sin(phi); + points.push_back(pxr::GfVec3f(R * c, -hy, R * s)); + normals.push_back(pxr::GfVec3f(c, 0, s)); + st.push_back(pxr::GfVec2f(u, 0)); + points.push_back(pxr::GfVec3f(R * c, hy, R * s)); + normals.push_back(pxr::GfVec3f(c, 0, s)); + st.push_back(pxr::GfVec2f(u, 1)); + } + for (int j = 0; j < sectors; ++j) { + const int b0 = 2 * j, t0 = 2 * j + 1, b1 = 2 * (j + 1), t1 = 2 * (j + 1) + 1; + fvCounts.push_back(4); // b_j, t_j, t_{j+1}, b_{j+1} → outward radial + fvIndices.push_back(b0); fvIndices.push_back(t0); + fvIndices.push_back(t1); fvIndices.push_back(b1); + } + + // End caps as triangle fans around a center vertex; ring vertices carry the + // cap normal (not the radial one) and disk UVs. + auto addCap = [&](float y, float ny) { + const int center = static_cast(points.size()); + points.push_back(pxr::GfVec3f(0, y, 0)); + normals.push_back(pxr::GfVec3f(0, ny, 0)); + st.push_back(pxr::GfVec2f(0.5f, 0.5f)); + const int ringBase = static_cast(points.size()); + for (int j = 0; j < sectors; ++j) { + const float phi = static_cast(j) / sectors * 2.0f * static_cast(kPi); + const float c = std::cos(phi), s = std::sin(phi); + points.push_back(pxr::GfVec3f(R * c, y, R * s)); + normals.push_back(pxr::GfVec3f(0, ny, 0)); + st.push_back(pxr::GfVec2f(0.5f + 0.5f * c, 0.5f + 0.5f * s)); + } + for (int j = 0; j < sectors; ++j) { + const int a = ringBase + j, b = ringBase + (j + 1) % sectors; + fvCounts.push_back(3); + fvIndices.push_back(center); + // Wind so the tri normal matches the cap direction (+Y vs -Y). + if (ny > 0) { fvIndices.push_back(b); fvIndices.push_back(a); } + else { fvIndices.push_back(a); fvIndices.push_back(b); } + } + }; + addCap( hy, 1.0f); + addCap(-hy, -1.0f); + + DefineUvMesh(stage, path, points, normals, st, fvCounts, fvIndices, + pxr::GfVec3f(-R, -hy, -R), pxr::GfVec3f(R, hy, R)); +} } // namespace void MaterialPreviewRenderer::EnsureInitialized() { @@ -90,23 +267,13 @@ void MaterialPreviewRenderer::EnsureInitialized() { // ApplyPreviewShape keeps exactly one child active. pxr::UsdGeomScope::Define(m_previewStage, kShapesRootPath); - pxr::UsdGeomSphere sphere = pxr::UsdGeomSphere::Define( - m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Sphere"))); - sphere.GetRadiusAttr().Set(1.0); - - pxr::UsdGeomCube cube = pxr::UsdGeomCube::Define( - m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Cube"))); - cube.GetSizeAttr().Set(1.2); - cube.CreateExtentAttr(pxr::VtValue(pxr::VtVec3fArray{ - pxr::GfVec3f(-0.6f), pxr::GfVec3f(0.6f)})); - - pxr::UsdGeomCylinder cylinder = pxr::UsdGeomCylinder::Define( - m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Cylinder"))); - cylinder.GetRadiusAttr().Set(0.7); - cylinder.GetHeightAttr().Set(1.6); - cylinder.GetAxisAttr().Set(pxr::UsdGeomTokens->y); // default Z lies on its side in a Y-up stage - cylinder.CreateExtentAttr(pxr::VtValue(pxr::VtVec3fArray{ - pxr::GfVec3f(-0.7f, -0.8f, -0.7f), pxr::GfVec3f(0.7f, 0.8f, 0.7f)})); + // UV-mapped meshes (not implicit UsdGeom prims) so textured materials — + // e.g. a UsdUVTexture wired into diffuseColor via a UsdPrimvarReader "st" — + // map onto the preview geometry instead of sampling a single texel. The + // implicit primitives generate no `st` primvar, so they can't show textures. + CreateUvSphere(m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Sphere")), 1.0); + CreateUvCube(m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Cube")), 0.6); + CreateUvCylinder(m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Cylinder")), 0.7, 0.8); // Asset-backed shapes, created only when their file is bundled. for (const char* name : {"Teapot", "Hair"}) { diff --git a/src/ui/NodeThumbnailCache.cpp b/src/ui/NodeThumbnailCache.cpp new file mode 100644 index 0000000..4994b21 --- /dev/null +++ b/src/ui/NodeThumbnailCache.cpp @@ -0,0 +1,352 @@ +#include "NodeThumbnailCache.h" +#include "../utils/GLExt.h" +#include "../utils/Logger.h" + +#include +#include +#include + +#include +#include +#include + +namespace UsdLayerManager { + +namespace { +// Mix a graph revision with a node path so switching which node the shared +// preview renderer draws always reads as "changed" to MaterialPreviewRenderer's +// revision gate (it treats the value as an opaque change token), while a real +// graph edit (revision bump) still forces a re-render. +size_t RenderToken(size_t revision, const std::string& nodeKey) { + size_t h = revision * 1099511628211ull; + h ^= std::hash{}(nodeKey) + 0x9e3779b97f4a7c15ull + (h << 6) + (h >> 2); + return h; +} +} // namespace + +NodeThumbnailCache::NodeThumbnailCache() { + // Prime Hio's plugin registry on the main thread so the worker never + // first-touches plugin discovery off-thread. + pxr::HioImage::IsSupportedImageFile("prime.png"); + + m_running = true; + m_worker = std::thread(&NodeThumbnailCache::WorkerLoop, this); +} + +NodeThumbnailCache::~NodeThumbnailCache() { + m_running = false; + m_jobCv.notify_all(); + if (m_worker.joinable()) + m_worker.join(); + Clear(); + if (m_readFbo) glDeleteFramebuffers(1, &m_readFbo); + if (m_drawFbo) glDeleteFramebuffers(1, &m_drawFbo); +} + +// --------------------------------------------------------------------------- +// Texture thumbnails +// --------------------------------------------------------------------------- + +ImTextureID NodeThumbnailCache::GetTextureThumbnail(const pxr::SdfPath& node, + const std::string& resolvedFile) { + const std::string key = node.GetString(); + TexEntry& e = m_texEntries[key]; + e.touched = true; + if (resolvedFile.empty()) + return e.texId; + // (Re)request when the file differs from both the uploaded and in-flight one. + if (resolvedFile != e.fileKey && resolvedFile != e.reqFile) { + e.reqFile = resolvedFile; + { + std::lock_guard lk(m_jobMutex); + m_jobs.push_back(TexJob{key, resolvedFile}); + } + m_jobCv.notify_one(); + } + return e.texId; +} + +void NodeThumbnailCache::WorkerLoop() { + for (;;) { + TexJob job; + { + std::unique_lock lk(m_jobMutex); + m_jobCv.wait(lk, [this] { return !m_running || !m_jobs.empty(); }); + if (!m_running) return; + job = std::move(m_jobs.front()); + m_jobs.pop_front(); + } + TexResult result; + result.nodeKey = job.nodeKey; + result.file = job.file; + result.ok = DecodeThumbnail(job.file, result); + { + std::lock_guard lk(m_resultMutex); + m_results.push_back(std::move(result)); + } + } +} + +bool NodeThumbnailCache::DecodeThumbnail(const std::string& file, TexResult& out) { + pxr::HioImageSharedPtr img = pxr::HioImage::OpenForReading(file, /*subimage=*/0, /*mip=*/0, + pxr::HioImage::SourceColorSpace::Auto, + /*suppressErrors=*/true); + if (!img) return false; + const int sw = img->GetWidth(); + const int sh = img->GetHeight(); + if (sw <= 0 || sh <= 0) return false; + + // Hio's stb reader does no format conversion: it requires the requested + // StorageSpec.format to *exactly* equal the file's own format (channel + // count + component type + sRGB-ness) or it raises "Image format mismatch". + // So read into the image's native format and normalize to RGBA8 ourselves. + const pxr::HioFormat fmt = img->GetFormat(); + const int nComp = pxr::HioGetComponentCount(fmt); + const pxr::HioType type = pxr::HioGetHioType(fmt); + if (nComp < 1 || nComp > 4) return false; + + size_t bpc = 0; // bytes per component + switch (type) { + case pxr::HioTypeUnsignedByte: + case pxr::HioTypeUnsignedByteSRGB: + case pxr::HioTypeSignedByte: bpc = 1; break; + case pxr::HioTypeUnsignedShort: + case pxr::HioTypeSignedShort: + case pxr::HioTypeHalfFloat: bpc = 2; break; + case pxr::HioTypeUnsignedInt: + case pxr::HioTypeInt: + case pxr::HioTypeFloat: bpc = 4; break; + case pxr::HioTypeDouble: bpc = 8; break; + default: return false; + } + + const size_t pixels = static_cast(sw) * sh; + std::vector raw(pixels * nComp * bpc); + pxr::HioImage::StorageSpec spec; + spec.width = sw; + spec.height = sh; + spec.depth = 1; + spec.format = fmt; + spec.flipped = false; // keep top row first for ImGui display + spec.data = raw.data(); + if (!img->Read(spec)) return false; + + // Collapse whatever component type the file uses down to an 8-bit value. + // sRGB byte data is passed through as-is (ImGui does no color management, + // so the encoded bytes display correctly); float/half is clamped to [0,1]. + const void* rp = raw.data(); + auto toU8 = [&](size_t i) -> unsigned char { + switch (type) { + case pxr::HioTypeUnsignedByte: + case pxr::HioTypeUnsignedByteSRGB: + return reinterpret_cast(rp)[i]; + case pxr::HioTypeSignedByte: + return static_cast(std::max(0, static_cast(reinterpret_cast(rp)[i]) * 2)); + case pxr::HioTypeUnsignedShort: + return static_cast(reinterpret_cast(rp)[i] >> 8); + case pxr::HioTypeSignedShort: + return static_cast(std::clamp(static_cast(reinterpret_cast(rp)[i]) >> 7, 0, 255)); + case pxr::HioTypeHalfFloat: + return static_cast(std::clamp(static_cast(reinterpret_cast(rp)[i]), 0.f, 1.f) * 255.f + 0.5f); + case pxr::HioTypeFloat: + return static_cast(std::clamp(reinterpret_cast(rp)[i], 0.f, 1.f) * 255.f + 0.5f); + case pxr::HioTypeUnsignedInt: + return static_cast(reinterpret_cast(rp)[i] >> 24); + case pxr::HioTypeInt: + return static_cast(std::clamp(reinterpret_cast(rp)[i] >> 23, 0, 255)); + case pxr::HioTypeDouble: + return static_cast(std::clamp(reinterpret_cast(rp)[i], 0.0, 1.0) * 255.0 + 0.5); + default: return 0; + } + }; + + // Expand to RGBA8: 1ch → grey, 2ch → grey+alpha, 3ch → opaque, 4ch → as-is. + std::vector native(pixels * 4); + for (size_t px = 0; px < pixels; ++px) { + const size_t s = px * nComp; + const unsigned char c0 = toU8(s); + unsigned char* d = &native[px * 4]; + d[0] = c0; + d[1] = nComp >= 3 ? toU8(s + 1) : c0; + d[2] = nComp >= 3 ? toU8(s + 2) : c0; + d[3] = nComp == 4 ? toU8(s + 3) : (nComp == 2 ? toU8(s + 1) : 255); + } + + const int longSide = std::max(sw, sh); + const float scale = longSide > kThumbPx ? static_cast(kThumbPx) / longSide : 1.0f; + const int dw = std::max(1, static_cast(std::lround(sw * scale))); + const int dh = std::max(1, static_cast(std::lround(sh * scale))); + + if (dw == sw && dh == sh) { + out.rgba = std::move(native); + } else { + out.rgba.assign(static_cast(dw) * dh * 4, 0); + for (int y = 0; y < dh; ++y) { + const int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh); + for (int x = 0; x < dw; ++x) { + const int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw); + unsigned int acc[4] = {0, 0, 0, 0}; + unsigned int n = 0; + for (int sy = sy0; sy < sy1; ++sy) + for (int sx = sx0; sx < sx1; ++sx) { + const unsigned char* p = &native[(static_cast(sy) * sw + sx) * 4]; + acc[0] += p[0]; acc[1] += p[1]; acc[2] += p[2]; acc[3] += p[3]; + ++n; + } + unsigned char* d = &out.rgba[(static_cast(y) * dw + x) * 4]; + d[0] = static_cast(acc[0] / n); + d[1] = static_cast(acc[1] / n); + d[2] = static_cast(acc[2] / n); + d[3] = static_cast(acc[3] / n); + } + } + } + out.w = dw; + out.h = dh; + return true; +} + +void NodeThumbnailCache::UploadTexture(TexEntry& e, const TexResult& r) { + DeleteTex(e.texId); + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, r.w, r.h, 0, GL_RGBA, GL_UNSIGNED_BYTE, r.rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + e.texId = static_cast(static_cast(tex)); +} + +// --------------------------------------------------------------------------- +// Material thumbnails +// --------------------------------------------------------------------------- + +ImTextureID NodeThumbnailCache::GetMaterialThumbnail(const pxr::UsdStageRefPtr& stage, + const pxr::SdfPath& materialPath, + const pxr::SdfPath& node, + const std::string& output, bool terminal, + size_t revision) { + MatEntry& e = m_matEntries[node.GetString()]; + e.touched = true; + e.stage = stage; + e.materialPath = materialPath; + e.nodePath = node; + e.output = output; + e.terminal = terminal; + if (revision != e.revision || e.texId == 0) { + e.revision = revision; + e.stale = true; + } + return e.texId; +} + +void NodeThumbnailCache::EnsureBlitFbos() { + if (!m_readFbo) glGenFramebuffers(1, &m_readFbo); + if (!m_drawFbo) glGenFramebuffers(1, &m_drawFbo); +} + +void NodeThumbnailCache::BlitToEntry(MatEntry& e, uint32_t srcTex) { + int sw = 0, sh = 0; + glBindTexture(GL_TEXTURE_2D, srcTex); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &sw); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &sh); + glBindTexture(GL_TEXTURE_2D, 0); + if (sw <= 0 || sh <= 0) return; + + if (e.texId == 0) { + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kThumbPx, kThumbPx, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + e.texId = static_cast(static_cast(tex)); + } + const GLuint dst = static_cast(static_cast(e.texId)); + + EnsureBlitFbos(); + GLint prevRead = 0, prevDraw = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevRead); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &prevDraw); + glBindFramebuffer(GL_READ_FRAMEBUFFER, m_readFbo); + glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, srcTex, 0); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_drawFbo); + glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, dst, 0); + // Flip Y (dst rows reversed): the draw target is a GL bottom-up texture, so + // this leaves the cache texture top-down for uniform ImGui (0,0)-(1,1) UVs. + glBlitFramebuffer(0, 0, sw, sh, 0, kThumbPx, kThumbPx, 0, GL_COLOR_BUFFER_BIT, GL_LINEAR); + glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(prevRead)); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(prevDraw)); +} + +// --------------------------------------------------------------------------- +// Per-frame pump / lifecycle +// --------------------------------------------------------------------------- + +void NodeThumbnailCache::PumpMainThread() { + // 1. Upload finished texture decodes. + std::vector done; + { + std::lock_guard lk(m_resultMutex); + done.swap(m_results); + } + for (const TexResult& r : done) { + auto it = m_texEntries.find(r.nodeKey); + if (it == m_texEntries.end()) continue; + TexEntry& e = it->second; + if (r.file != e.reqFile) continue; // superseded by a newer request + if (r.ok) { + UploadTexture(e, r); + e.fileKey = r.file; + } + e.reqFile.clear(); + } + + // 2. Render at most one stale material thumbnail (amortized). + for (auto& kv : m_matEntries) { + MatEntry& e = kv.second; + if (!e.stale || !e.touched || !e.stage) continue; + m_matRenderer.SetMaterial(e.stage, e.materialPath, RenderToken(e.revision, kv.first), + e.nodePath, e.output, e.terminal); + const uint32_t src = m_matRenderer.Render(kThumbPx, kThumbPx); + if (src != 0) { + BlitToEntry(e, src); + e.stale = false; + } + break; // one per frame + } + + // 3. Prune entries not requested since the previous pump (node deleted or + // no longer a thumbnailed category), then reset touch flags for this frame. + for (auto it = m_texEntries.begin(); it != m_texEntries.end();) { + if (!it->second.touched) { DeleteTex(it->second.texId); it = m_texEntries.erase(it); } + else { it->second.touched = false; ++it; } + } + for (auto it = m_matEntries.begin(); it != m_matEntries.end();) { + if (!it->second.touched) { DeleteTex(it->second.texId); it = m_matEntries.erase(it); } + else { it->second.touched = false; ++it; } + } +} + +void NodeThumbnailCache::Clear() { + for (auto& kv : m_texEntries) DeleteTex(kv.second.texId); + for (auto& kv : m_matEntries) DeleteTex(kv.second.texId); + m_texEntries.clear(); + m_matEntries.clear(); +} + +void NodeThumbnailCache::DeleteTex(ImTextureID& id) { + if (id) { + GLuint t = static_cast(static_cast(id)); + glDeleteTextures(1, &t); + id = 0; + } +} + +} // namespace UsdLayerManager diff --git a/src/ui/NodeThumbnailCache.h b/src/ui/NodeThumbnailCache.h new file mode 100644 index 0000000..d573488 --- /dev/null +++ b/src/ui/NodeThumbnailCache.h @@ -0,0 +1,107 @@ +#pragma once + +#include "MaterialPreviewRenderer.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Owns the in-canvas node thumbnails for the material editor. +/// +/// Two kinds, both handed back as an ImTextureID (0 while not ready): +/// - Texture nodes: the source image, decoded on a background worker thread +/// (USD HioImage / OpenImageIO) and uploaded to GL on the main thread. +/// - Material nodes: a shader-ball render through a dedicated +/// MaterialPreviewRenderer. Hydra is GL-/main-thread-bound, so these are +/// rendered one-per-frame (amortized) and cached by graph revision. +/// +/// Lifecycle is driven from the panel: Get*Thumbnail() per node during the +/// canvas draw, then PumpMainThread() once per frame (GL context current). +class NodeThumbnailCache { +public: + NodeThumbnailCache(); + ~NodeThumbnailCache(); + + NodeThumbnailCache(const NodeThumbnailCache&) = delete; + NodeThumbnailCache& operator=(const NodeThumbnailCache&) = delete; + + /// Displayed edge length; also the material render size. + static constexpr int kThumbPx = 96; + + /// Returns the node's texture thumbnail (0 = not ready). Enqueues a + /// background decode when resolvedFile is new or has changed. An empty + /// resolvedFile (unresolvable asset) yields whatever is cached (usually 0). + ImTextureID GetTextureThumbnail(const pxr::SdfPath& node, const std::string& resolvedFile); + + /// Returns the node's material shader-ball thumbnail (0 = not ready yet). + /// Marks it stale (to be re-rendered on a later pump) when revision changes. + /// output/terminal select the node output to preview (a token-typed + /// terminal is wired as the surface; otherwise routed into diffuseColor). + ImTextureID GetMaterialThumbnail(const pxr::UsdStageRefPtr& stage, + const pxr::SdfPath& materialPath, + const pxr::SdfPath& node, + const std::string& output, bool terminal, + size_t revision); + + /// Main thread, once per frame, GL context current: drains finished + /// decodes and uploads them, renders at most one stale material thumbnail, + /// and prunes entries not requested since the previous pump. + void PumpMainThread(); + + /// Frees every thumbnail (call on material/stage change). Main thread. + void Clear(); + +private: + // ---- texture thumbnails (background decode) ---- + struct TexJob { std::string nodeKey; std::string file; }; + struct TexResult { std::string nodeKey; std::string file; + std::vector rgba; int w = 0, h = 0; bool ok = false; }; + struct TexEntry { ImTextureID texId = 0; std::string fileKey; std::string reqFile; + bool touched = false; }; + + void WorkerLoop(); + static bool DecodeThumbnail(const std::string& file, TexResult& out); + void UploadTexture(TexEntry& e, const TexResult& r); + + std::thread m_worker; + std::atomic m_running{false}; + std::mutex m_jobMutex; + std::condition_variable m_jobCv; + std::deque m_jobs; + std::mutex m_resultMutex; + std::vector m_results; + std::unordered_map m_texEntries; + + // ---- material thumbnails (main-thread, amortized) ---- + struct MatEntry { + ImTextureID texId = 0; + size_t revision = 0; + bool stale = true; + bool touched = false; + pxr::UsdStageRefPtr stage; + pxr::SdfPath materialPath; + pxr::SdfPath nodePath; + std::string output; + bool terminal = false; + }; + std::unordered_map m_matEntries; + MaterialPreviewRenderer m_matRenderer; + unsigned int m_readFbo = 0; // scratch GL FBOs for the render->cache blit + unsigned int m_drawFbo = 0; + + void EnsureBlitFbos(); + void BlitToEntry(MatEntry& e, uint32_t srcTex); + static void DeleteTex(ImTextureID& id); +}; + +} // namespace UsdLayerManager