diff --git a/.gitignore b/.gitignore index ac37312..b99f840 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ imgui.ini # OCIO config LUT files — downloaded automatically at CMake configure time resources/OpenColorIO-Configs/ resources/hdri/ +resources/vdb/ diff --git a/CMakeLists.txt b/CMakeLists.txt index ed94508..18227b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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" "$/resources/hdri" - COMMENT "Copying fonts, SVG icons and HDRI presets to build output" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/resources/preview" + "$/resources/preview" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/resources/vdb" + "$/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). diff --git a/cmake/modules/FindOpenUSD.cmake b/cmake/modules/FindOpenUSD.cmake index 207092f..60d3e0e 100644 --- a/cmake/modules/FindOpenUSD.cmake +++ b/cmake/modules/FindOpenUSD.cmake @@ -59,6 +59,7 @@ set(OpenUSD_REQUIRED_LIBS usdGeom usdLux usdShade + usdVol usdImaging usdImagingGL hdx diff --git a/resources/preview/hair.usdc b/resources/preview/hair.usdc new file mode 100644 index 0000000..0187432 Binary files /dev/null and b/resources/preview/hair.usdc differ diff --git a/resources/preview/teapot.usdc b/resources/preview/teapot.usdc new file mode 100644 index 0000000..1cf2491 Binary files /dev/null and b/resources/preview/teapot.usdc differ diff --git a/src/ui/MaterialEditorPanel.cpp b/src/ui/MaterialEditorPanel.cpp index 8c223bd..dfdf83b 100644 --- a/src/ui/MaterialEditorPanel.cpp +++ b/src/ui/MaterialEditorPanel.cpp @@ -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 = [¤t]() -> float { if (current.IsHolding()) return current.UncheckedGet(); @@ -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() ? current.UncheckedGet() : 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() ? current.UncheckedGet() : 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() ? current.UncheckedGet() : 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(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(f); + else if (type == tn->Half) newValue = pxr::GfHalf(f); + else newValue = f; } } else if (type == tn->Int) { int v = current.IsHolding() ? current.UncheckedGet() : 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() && current.UncheckedGet(); if (ImGui::Checkbox(widgetId.c_str(), &v)) diff --git a/src/ui/MaterialPreviewRenderer.cpp b/src/ui/MaterialPreviewRenderer.cpp index 8a7c416..57b6e98 100644 --- a/src/ui/MaterialPreviewRenderer.cpp +++ b/src/ui/MaterialPreviewRenderer.cpp @@ -1,9 +1,17 @@ #include "MaterialPreviewRenderer.h" #include "../utils/Logger.h" #include "../utils/PathUtils.h" +#include #include +#include +#include +#include #include #include +#include +#include +#include +#include #include #include #include @@ -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(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); diff --git a/src/ui/MaterialPreviewRenderer.h b/src/ui/MaterialPreviewRenderer.h index 02ac5ee..6a1a288 100644 --- a/src/ui/MaterialPreviewRenderer.h +++ b/src/ui/MaterialPreviewRenderer.h @@ -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;