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:
@@ -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()});
|
||||
|
||||
@@ -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.
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "../core/MaterialManager.h"
|
||||
#include "IconManager.h"
|
||||
#include "MaterialPreviewRenderer.h"
|
||||
#include "NodeThumbnailCache.h"
|
||||
#include <pxr/usd/usd/stage.h>
|
||||
#include <pxr/usd/sdf/path.h>
|
||||
#include <pxr/usd/sdf/valueTypeName.h>
|
||||
@@ -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<uintptr_t, LinkInfo> 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};
|
||||
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
#include <pxr/usd/usdGeom/sphere.h>
|
||||
#include <pxr/usd/usdGeom/cube.h>
|
||||
#include <pxr/usd/usdGeom/cylinder.h>
|
||||
#include <pxr/usd/usdGeom/mesh.h>
|
||||
#include <pxr/usd/usdGeom/primvarsAPI.h>
|
||||
#include <pxr/usd/usdGeom/scope.h>
|
||||
#include <pxr/usd/usdGeom/metrics.h>
|
||||
#include <pxr/usd/usdGeom/tokens.h>
|
||||
#include <pxr/usd/usdVol/volume.h>
|
||||
#include <pxr/usd/usdVol/openVDBAsset.h>
|
||||
#include <pxr/base/vt/array.h>
|
||||
#include <pxr/base/gf/vec2f.h>
|
||||
#include <pxr/base/gf/vec3f.h>
|
||||
#include <pxr/usd/usdShade/materialBindingAPI.h>
|
||||
#include <pxr/usd/usdShade/material.h>
|
||||
@@ -71,6 +74,180 @@ const LightPreset kLightPresets[] = {
|
||||
{"Sunset", "venice_sunset_1k.exr"},
|
||||
};
|
||||
constexpr int kLightPresetCount = static_cast<int>(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<float>(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<float>(i) / rings;
|
||||
const float th = v * static_cast<float>(kPi);
|
||||
const float sinT = std::sin(th), cosT = std::cos(th);
|
||||
for (int j = 0; j <= sectors; ++j) {
|
||||
const float u = static_cast<float>(j) / sectors;
|
||||
const float phi = u * 2.0f * static_cast<float>(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<float>(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<int>(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<float>(radius);
|
||||
const float hy = static_cast<float>(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<float>(j) / sectors;
|
||||
const float phi = u * 2.0f * static_cast<float>(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<int>(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<int>(points.size());
|
||||
for (int j = 0; j < sectors; ++j) {
|
||||
const float phi = static_cast<float>(j) / sectors * 2.0f * static_cast<float>(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"}) {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
#include "NodeThumbnailCache.h"
|
||||
#include "../utils/GLExt.h"
|
||||
#include "../utils/Logger.h"
|
||||
|
||||
#include <pxr/imaging/hio/image.h>
|
||||
#include <pxr/imaging/hio/types.h>
|
||||
#include <pxr/base/gf/half.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
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<std::string>{}(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<size_t>(sw) * sh;
|
||||
std::vector<unsigned char> 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<const uint8_t*>(rp)[i];
|
||||
case pxr::HioTypeSignedByte:
|
||||
return static_cast<unsigned char>(std::max(0, static_cast<int>(reinterpret_cast<const int8_t*>(rp)[i]) * 2));
|
||||
case pxr::HioTypeUnsignedShort:
|
||||
return static_cast<unsigned char>(reinterpret_cast<const uint16_t*>(rp)[i] >> 8);
|
||||
case pxr::HioTypeSignedShort:
|
||||
return static_cast<unsigned char>(std::clamp(static_cast<int>(reinterpret_cast<const int16_t*>(rp)[i]) >> 7, 0, 255));
|
||||
case pxr::HioTypeHalfFloat:
|
||||
return static_cast<unsigned char>(std::clamp(static_cast<float>(reinterpret_cast<const pxr::GfHalf*>(rp)[i]), 0.f, 1.f) * 255.f + 0.5f);
|
||||
case pxr::HioTypeFloat:
|
||||
return static_cast<unsigned char>(std::clamp(reinterpret_cast<const float*>(rp)[i], 0.f, 1.f) * 255.f + 0.5f);
|
||||
case pxr::HioTypeUnsignedInt:
|
||||
return static_cast<unsigned char>(reinterpret_cast<const uint32_t*>(rp)[i] >> 24);
|
||||
case pxr::HioTypeInt:
|
||||
return static_cast<unsigned char>(std::clamp(reinterpret_cast<const int32_t*>(rp)[i] >> 23, 0, 255));
|
||||
case pxr::HioTypeDouble:
|
||||
return static_cast<unsigned char>(std::clamp(reinterpret_cast<const double*>(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<unsigned char> 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<float>(kThumbPx) / longSide : 1.0f;
|
||||
const int dw = std::max(1, static_cast<int>(std::lround(sw * scale)));
|
||||
const int dh = std::max(1, static_cast<int>(std::lround(sh * scale)));
|
||||
|
||||
if (dw == sw && dh == sh) {
|
||||
out.rgba = std::move(native);
|
||||
} else {
|
||||
out.rgba.assign(static_cast<size_t>(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<size_t>(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<size_t>(y) * dw + x) * 4];
|
||||
d[0] = static_cast<unsigned char>(acc[0] / n);
|
||||
d[1] = static_cast<unsigned char>(acc[1] / n);
|
||||
d[2] = static_cast<unsigned char>(acc[2] / n);
|
||||
d[3] = static_cast<unsigned char>(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<ImTextureID>(static_cast<ImU64>(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<ImTextureID>(static_cast<ImU64>(tex));
|
||||
}
|
||||
const GLuint dst = static_cast<GLuint>(static_cast<ImU64>(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<GLuint>(prevRead));
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast<GLuint>(prevDraw));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame pump / lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void NodeThumbnailCache::PumpMainThread() {
|
||||
// 1. Upload finished texture decodes.
|
||||
std::vector<TexResult> done;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<GLuint>(static_cast<ImU64>(id));
|
||||
glDeleteTextures(1, &t);
|
||||
id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace UsdLayerManager
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include "MaterialPreviewRenderer.h"
|
||||
#include <pxr/usd/usd/stage.h>
|
||||
#include <pxr/usd/sdf/path.h>
|
||||
#include <imgui.h>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<unsigned char> 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<bool> m_running{false};
|
||||
std::mutex m_jobMutex;
|
||||
std::condition_variable m_jobCv;
|
||||
std::deque<TexJob> m_jobs;
|
||||
std::mutex m_resultMutex;
|
||||
std::vector<TexResult> m_results;
|
||||
std::unordered_map<std::string, TexEntry> 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<std::string, MatEntry> 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
|
||||
Reference in New Issue
Block a user