Add preview shape picker to shader ball; drag controls for node properties

Preview geometry dropdown: Sphere / Cube / Cylinder (native prims),
Teapot and Hair (bundled resources/preview/*.usdc - teapot tessellated
from the Newell patch data with the Blinn 1.3x height correction, hair
as 220 procedural B-spline strands), and Cloud Volume (UsdVolVolume
over the OpenVDB bunny_cloud sample, downloaded at CMake configure and
gitignored like the HDRIs). Shapes live under one scope with the
material bound on it; switching activates one shape and reframes the
camera. Missing assets drop out of the dropdown. usdVol added to the
USD link set. The camera starts in a Maya-style 3/4 view.

Node property values switch from Input* fields back to draggable
controls (Ctrl+click to type): live-apply while dragging, one undo
command on release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 08:39:16 +08:00
parent 6b8cb2406e
commit 0f30e080e3
8 changed files with 181 additions and 31 deletions
+1
View File
@@ -79,3 +79,4 @@ imgui.ini
# OCIO config LUT files — downloaded automatically at CMake configure time
resources/OpenColorIO-Configs/
resources/hdri/
resources/vdb/
+34 -1
View File
@@ -606,6 +606,33 @@ foreach(_hdri ${_hdri_files})
endif()
endforeach()
# ---------------------------------------------------------------------------
# VDB: cloud volume for the shader-ball preview (OpenVDB sample model) at
# configure time if not present
# ---------------------------------------------------------------------------
set(_vdb_dir "${CMAKE_SOURCE_DIR}/resources/vdb")
file(MAKE_DIRECTORY "${_vdb_dir}")
if(NOT EXISTS "${_vdb_dir}/bunny_cloud.vdb")
message(STATUS "Downloading OpenVDB sample bunny_cloud.vdb ...")
set(_vdb_zip "${CMAKE_BINARY_DIR}/bunny_cloud.zip")
file(DOWNLOAD
"https://artifacts.aswf.io/io/aswf/openvdb/models/bunny_cloud.vdb/1.0.0/bunny_cloud.vdb-1.0.0.zip"
"${_vdb_zip}"
STATUS _vdb_dl_status
)
list(GET _vdb_dl_status 0 _vdb_dl_code)
if(_vdb_dl_code EQUAL 0)
execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xf "${_vdb_zip}"
WORKING_DIRECTORY "${_vdb_dir}"
)
file(REMOVE "${_vdb_zip}")
message(STATUS "bunny_cloud.vdb installed to ${_vdb_dir}")
else()
message(WARNING "OpenVDB sample download failed (${_vdb_dl_status}) — the Cloud Volume preview shape will be unavailable.")
endif()
endif()
# Copy resources to build directory
file(COPY ${CMAKE_SOURCE_DIR}/resources
DESTINATION ${CMAKE_BINARY_DIR}
@@ -624,7 +651,13 @@ add_custom_command(TARGET UsdLayerManager POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/resources/hdri"
"$<TARGET_FILE_DIR:UsdLayerManager>/resources/hdri"
COMMENT "Copying fonts, SVG icons and HDRI presets to build output"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/resources/preview"
"$<TARGET_FILE_DIR:UsdLayerManager>/resources/preview"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/resources/vdb"
"$<TARGET_FILE_DIR:UsdLayerManager>/resources/vdb"
COMMENT "Copying fonts, SVG icons, HDRI presets and preview shapes to build output"
)
# Copy ACES OCIO config to build output — only on first build (skipped if already present).
+1
View File
@@ -59,6 +59,7 @@ set(OpenUSD_REQUIRED_LIBS
usdGeom
usdLux
usdShade
usdVol
usdImaging
usdImagingGL
hdx
Binary file not shown.
Binary file not shown.
+20 -23
View File
@@ -293,6 +293,7 @@ void MaterialEditorPanel::RenderPreviewPanel() {
m_preview.RenderRendererDropdown(halfWidth);
ImGui::SameLine();
m_preview.RenderLightingDropdown(halfWidth);
m_preview.RenderShapeDropdown(-1.0f);
if (m_stage && !m_materialPath.IsEmpty()) {
// Hypershade-style: with a node selected, the ball previews that
@@ -454,12 +455,13 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node,
bool authored = false;
pxr::VtValue current = ReadInputValue(prim, node.shaderId, input, &authored);
// Widget vocabulary mirrors PropertyPanel's DrawVtValueWidget (Input*
// fields with %.4f, applied on IsItemDeactivatedAfterEdit; ColorEdit
// applied live) — plus this panel's undo convention: pre-edit state is
// stashed on activation and one undoable command is pushed per edit.
pxr::VtValue newValue; // applied live (colors, checkbox) as it changes
pxr::VtValue finalValue; // applied + committed on deactivate (Input* fields)
// PropertyPanel-style table around draggable controls: Drag* widgets
// (Ctrl+click to type exact values) apply live every frame so the shader
// ball and viewport track the drag, with this panel's undo convention
// pre-edit state stashed on activation, one undoable command pushed when
// the edit ends (IsItemDeactivatedAfterEdit).
pxr::VtValue newValue; // applied live as it changes (drags, colors, checkbox)
pxr::VtValue finalValue; // applied + committed on deactivate (text fields)
auto scalarAsFloat = [&current]() -> float {
if (current.IsHolding<float>()) return current.UncheckedGet<float>();
@@ -482,34 +484,29 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node,
} else if (type == tn->Float3 || type == tn->Vector3f || type == tn->Normal3f || type == tn->Point3f) {
pxr::GfVec3f v = current.IsHolding<pxr::GfVec3f>() ? current.UncheckedGet<pxr::GfVec3f>()
: pxr::GfVec3f(0.0f);
ImGui::InputScalarN(widgetId.c_str(), ImGuiDataType_Float, v.data(), 3, nullptr, nullptr, "%.4f");
if (ImGui::IsItemDeactivatedAfterEdit())
finalValue = v;
if (ImGui::DragFloat3(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f"))
newValue = v;
} else if (type == tn->Float2 || type == tn->TexCoord2f) {
pxr::GfVec2f v = current.IsHolding<pxr::GfVec2f>() ? current.UncheckedGet<pxr::GfVec2f>()
: pxr::GfVec2f(0.0f);
ImGui::InputScalarN(widgetId.c_str(), ImGuiDataType_Float, v.data(), 2, nullptr, nullptr, "%.4f");
if (ImGui::IsItemDeactivatedAfterEdit())
finalValue = v;
if (ImGui::DragFloat2(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f"))
newValue = v;
} else if (type == tn->Float4) {
pxr::GfVec4f v = current.IsHolding<pxr::GfVec4f>() ? current.UncheckedGet<pxr::GfVec4f>()
: pxr::GfVec4f(0.0f);
ImGui::InputScalarN(widgetId.c_str(), ImGuiDataType_Float, v.data(), 4, nullptr, nullptr, "%.4f");
if (ImGui::IsItemDeactivatedAfterEdit())
finalValue = v;
if (ImGui::DragFloat4(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f"))
newValue = v;
} else if (type == tn->Float || type == tn->Double || type == tn->Half) {
float f = scalarAsFloat();
ImGui::InputFloat(widgetId.c_str(), &f, 0.f, 0.f, "%.4f");
if (ImGui::IsItemDeactivatedAfterEdit()) {
if (type == tn->Double) finalValue = static_cast<double>(f);
else if (type == tn->Half) finalValue = pxr::GfHalf(f);
else finalValue = f;
if (ImGui::DragFloat(widgetId.c_str(), &f, 0.01f, 0.f, 0.f, "%.4f")) {
if (type == tn->Double) newValue = static_cast<double>(f);
else if (type == tn->Half) newValue = pxr::GfHalf(f);
else newValue = f;
}
} else if (type == tn->Int) {
int v = current.IsHolding<int>() ? current.UncheckedGet<int>() : 0;
ImGui::InputInt(widgetId.c_str(), &v);
if (ImGui::IsItemDeactivatedAfterEdit())
finalValue = v;
if (ImGui::DragInt(widgetId.c_str(), &v))
newValue = v;
} else if (type == tn->Bool) {
bool v = current.IsHolding<bool>() && current.UncheckedGet<bool>();
if (ImGui::Checkbox(widgetId.c_str(), &v))
+116 -7
View File
@@ -1,9 +1,17 @@
#include "MaterialPreviewRenderer.h"
#include "../utils/Logger.h"
#include "../utils/PathUtils.h"
#include <pxr/base/tf/stringUtils.h>
#include <pxr/usd/usdGeom/sphere.h>
#include <pxr/usd/usdGeom/cube.h>
#include <pxr/usd/usdGeom/cylinder.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/vec3f.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/shader.h>
@@ -23,10 +31,26 @@
namespace UsdLayerManager {
namespace {
const pxr::SdfPath kSpherePath("/Preview/Sphere");
const pxr::SdfPath kShapesRootPath("/Preview/Shapes");
const pxr::SdfPath kDistantLightPath("/Preview/Light");
const pxr::SdfPath kDomeLightPath("/Preview/DomeLight");
/// Selectable preview geometry; prim name is a child of kShapesRootPath.
/// Shapes backed by an asset file are only created when the file exists.
struct ShapePreset {
const char* label;
const char* primName;
};
const ShapePreset kShapePresets[] = {
{"Sphere", "Sphere"},
{"Cube", "Cube"},
{"Cylinder", "Cylinder"},
{"Teapot", "Teapot"},
{"Hair", "Hair"},
{"Cloud Volume", "Cloud"},
};
constexpr int kShapePresetCount = static_cast<int>(sizeof(kShapePresets) / sizeof(kShapePresets[0]));
/// Scratch UsdPreviewSurface authored inside the (referenced) material scope
/// when previewing a non-terminal node output — child of the material so the
/// connection stays encapsulated.
@@ -56,9 +80,51 @@ void MaterialPreviewRenderer::EnsureInitialized() {
// Pin the up axis rather than inheriting the site fallback: the light
// rig, dome-pole compensation, and camera framing all assume Y-up.
pxr::UsdGeomSetStageUpAxis(m_previewStage, pxr::UsdGeomTokens->y);
pxr::UsdGeomSphere sphere = pxr::UsdGeomSphere::Define(m_previewStage, kSpherePath);
// All selectable preview shapes live under one scope; the material is
// bound on the scope so it inherits to whichever shape is active, and
// 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)}));
// Asset-backed shapes, created only when their file is bundled.
for (const char* name : {"Teapot", "Hair"}) {
const std::string file = ResourcePath(
std::string("resources/preview/") + pxr::TfStringToLower(name) + ".usdc");
if (!std::filesystem::exists(file)) continue;
pxr::UsdPrim prim = m_previewStage->DefinePrim(
kShapesRootPath.AppendChild(pxr::TfToken(name)));
prim.GetReferences().AddReference(file);
}
const std::string vdbFile = ResourcePath("resources/vdb/bunny_cloud.vdb");
if (std::filesystem::exists(vdbFile)) {
pxr::UsdVolVolume cloud = pxr::UsdVolVolume::Define(
m_previewStage, kShapesRootPath.AppendChild(pxr::TfToken("Cloud")));
pxr::UsdVolOpenVDBAsset density = pxr::UsdVolOpenVDBAsset::Define(
m_previewStage, cloud.GetPath().AppendChild(pxr::TfToken("density")));
density.CreateFilePathAttr().Set(pxr::SdfAssetPath(vdbFile));
density.CreateFieldNameAttr().Set(pxr::TfToken("density"));
cloud.CreateFieldRelationship(pxr::TfToken("density"), density.GetPath());
}
// Storm renders fine with zero authored lights (it injects a GL headlight
// as a fallback), but proper Hydra delegates like Embree/Arnold shade
// through scene lights only — without one they render solid black.
@@ -83,9 +149,12 @@ void MaterialPreviewRenderer::EnsureInitialized() {
m_renderer.SetComplexity(1.3f);
ApplyLightPreset(m_lightPreset);
ApplyPreviewShape(m_previewShape); // activates one shape + frames the camera
m_previewBBox = pxr::GfBBox3d(m_renderer.ComputeStageBounds());
m_camera.FrameSelection(m_previewBBox, 1.6); // extra margin so the sphere doesn't touch the frame edge
// Maya-style default 3/4 view: orbit the framed camera ~45° around and
// ~25° down instead of the dead-on front view (subsequent shape switches
// keep whatever orientation the user has orbited to).
m_camera.Tumble(-45.0, 25.0);
m_renderer.SetCameraStateFromGfCamera(m_camera.ComputeGfCamera(m_previewBBox));
m_initialized = true;
@@ -130,14 +199,14 @@ void MaterialPreviewRenderer::SetMaterial(const pxr::UsdStageRefPtr& sourceStage
sourceStage->GetRootLayer()->GetIdentifier(), materialPath);
}
pxr::UsdPrim spherePrim = m_previewStage->GetPrimAtPath(kSpherePath);
pxr::UsdPrim shapesRoot = m_previewStage->GetPrimAtPath(kShapesRootPath);
pxr::UsdShadeMaterial material(m_previewStage->GetPrimAtPath(materialPath));
if (!material) {
LOG_WARNING("MaterialPreviewRenderer: no material composed at " + materialPath.GetString());
return;
}
if (spherePrim)
pxr::UsdShadeMaterialBindingAPI::Apply(spherePrim).Bind(material);
if (shapesRoot) // bound on the scope: inherits to whichever shape is active
pxr::UsdShadeMaterialBindingAPI::Apply(shapesRoot).Bind(material);
// ── Selected-node preview (Hypershade-style) ─────────────────────────
// All node-preview overrides (the scratch wrapper shader and the local
@@ -220,6 +289,46 @@ void MaterialPreviewRenderer::ApplyLightPreset(int index) {
m_dirty = true;
}
void MaterialPreviewRenderer::ApplyPreviewShape(int index) {
if (index < 0 || index >= kShapePresetCount) index = 0;
// Fall back to the sphere if the requested shape's asset wasn't bundled.
if (!m_previewStage->GetPrimAtPath(
kShapesRootPath.AppendChild(pxr::TfToken(kShapePresets[index].primName))))
index = 0;
m_previewShape = index;
for (int i = 0; i < kShapePresetCount; ++i) {
pxr::UsdPrim prim = m_previewStage->GetPrimAtPath(
kShapesRootPath.AppendChild(pxr::TfToken(kShapePresets[i].primName)));
if (prim)
prim.SetActive(i == index);
}
// Shapes differ wildly in size (the cloud VDB spans hundreds of units) —
// reframe on the active shape's bounds; inactive prims don't contribute.
m_previewBBox = pxr::GfBBox3d(m_renderer.ComputeStageBounds());
m_camera.FrameSelection(m_previewBBox, 1.6); // margin so the shape doesn't touch the frame edge
m_renderer.SetCameraStateFromGfCamera(m_camera.ComputeGfCamera(m_previewBBox));
m_dirty = true;
}
void MaterialPreviewRenderer::RenderShapeDropdown(float width) {
if (!m_initialized) return;
ImGui::SetNextItemWidth(width);
if (ImGui::BeginCombo("##PreviewShape", kShapePresets[m_previewShape].label)) {
for (int i = 0; i < kShapePresetCount; ++i) {
if (!m_previewStage->GetPrimAtPath(
kShapesRootPath.AppendChild(pxr::TfToken(kShapePresets[i].primName))))
continue; // asset not bundled
if (ImGui::Selectable(kShapePresets[i].label, i == m_previewShape) && i != m_previewShape)
ApplyPreviewShape(i);
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Shader-ball preview geometry");
}
void MaterialPreviewRenderer::UpdateDomeOrientation() {
if (!m_previewStage) return;
pxr::UsdPrim dome = m_previewStage->GetPrimAtPath(kDomeLightPath);
+9
View File
@@ -59,9 +59,17 @@ public:
/// width: ImGui item width (-1 = fill available).
void RenderLightingDropdown(float width = -1.0f);
/// Preview-geometry picker (Sphere / Cube / Cylinder / Teapot / Hair /
/// Cloud Volume). Shapes whose asset is missing are omitted.
/// width: ImGui item width (-1 = fill available).
void RenderShapeDropdown(float width = -1.0f);
private:
void EnsureInitialized();
void ApplyLightPreset(int index);
/// Activates the chosen preview shape (deactivating the others) and
/// reframes the camera to its bounds.
void ApplyPreviewShape(int index);
/// Compensates the dome-light pole convention per render delegate: Storm
/// samples with the pole along local +Y, spec-following delegates
/// (hdArnold, hdCycles) along local +Z. Called on preset and renderer
@@ -78,6 +86,7 @@ private:
pxr::SdfPath m_previewNodePath; // node whose output the ball previews (empty = whole material)
std::string m_previewNodeOutput;
int m_lightPreset = 0;
int m_previewShape = 0;
bool m_initialized = false;
bool m_dirty = true;
int m_lastWidth = 0;