From 612d65bcd025b1dd3f4b2af2b18e7d39027b4952 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 24 Jul 2026 16:52:04 -0500 Subject: [PATCH 01/78] docs(usd): specify USD Stage import rework Records the design for reworking USD Stage import, derived from a fresh look at TSD and USD together rather than from the existing importer. ADRs: - 0015: consume a Hydra scene index rather than traversing UsdGeom and UsdShade directly, so composition, purpose/visibility, instancing, material binding, primvar interpolation and skinning are resolved by OpenUSD instead of being reimplemented - 0016: bake prototype-internal transforms, since the render index never pushes a transform-array node's matrices onto the transform stack - 0017: deviate from usdview defaults for purpose and subdivision, and record why neither deviation is a bug - 0018: let imported scenes retain an open UsdStage so deforming geometry can be bound lazily instead of baked per frame Glossary: - Add a Foreign-Format Import section to the TSD I/O context, grouping the existing terms under Native Persistence, and reserve "Layer" and "Instance" for their TSD and ANARI meanings Co-Authored-By: Claude Opus 5 (1M context) --- ...-import-usd-through-a-hydra-scene-index.md | 17 +++++++ ...0016-bake-prototype-internal-transforms.md | 17 +++++++ ...ew-defaults-for-purpose-and-subdivision.md | 15 +++++++ ...mported-scenes-retain-an-open-usd-stage.md | 14 ++++++ tsd/src/tsd/io/CONTEXT.md | 45 +++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 tsd/docs/adr/0015-import-usd-through-a-hydra-scene-index.md create mode 100644 tsd/docs/adr/0016-bake-prototype-internal-transforms.md create mode 100644 tsd/docs/adr/0017-deviate-from-usdview-defaults-for-purpose-and-subdivision.md create mode 100644 tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md diff --git a/tsd/docs/adr/0015-import-usd-through-a-hydra-scene-index.md b/tsd/docs/adr/0015-import-usd-through-a-hydra-scene-index.md new file mode 100644 index 000000000..5a988a45f --- /dev/null +++ b/tsd/docs/adr/0015-import-usd-through-a-hydra-scene-index.md @@ -0,0 +1,17 @@ +# Import USD through a Hydra scene index + +TSD imports USD stages by consuming a UsdImaging scene index chain rather than +traversing UsdGeom and UsdShade schemas directly. Composition, purpose and +visibility resolution, native and point instancing, material binding and render +context selection, primvar interpolation, implicit-shape conversion, NURBS +approximation, and skinning are all resolved by OpenUSD's own filtering scene +indices; the importer contributes one converter per Hydra prim type and nothing +more. The stage stays open alongside the scene index, so TSD-specific data that +Hydra does not model — `customData` carriers, `anari:` and `tsd:io:` attributes, +render settings — is still read directly from prims by path. This costs links +against `hd`, `usdImaging`, `hio`, and `hdsi`, and requires working in +data-source idioms rather than schema APIs, but it removes the class of silent +omissions that a hand-rolled traversal accumulates one unhandled prim type at a +time. A Hydra render delegate was rejected for the same job: it is a push-based +sync architecture for repeated frame updates, not a one-shot conversion into a +scene the user then edits by hand. diff --git a/tsd/docs/adr/0016-bake-prototype-internal-transforms.md b/tsd/docs/adr/0016-bake-prototype-internal-transforms.md new file mode 100644 index 000000000..c9ba15377 --- /dev/null +++ b/tsd/docs/adr/0016-bake-prototype-internal-transforms.md @@ -0,0 +1,17 @@ +# Bake prototype-internal transforms when importing instanced USD content + +`TransformsToAnariVisitor` never pushes a transform-array node's matrices onto +the transform stack, so a transform node nested beneath one composes against +that array node's ancestors instead: its subtree renders once, un-instanced, +rather than once per instance. Transform-array nodes are therefore leaf-only +instancing, while USD prototypes are typically `Xform` subtrees holding several +gprims at their own local transforms. The importer resolves this by importing +each prototype exactly once into shared TSD objects and baking each gprim's +prototype-root-relative transform into its vertex data, leaving a flat set of +Surfaces that a transform-array node may legally instance; a point instancer +becomes one transform-array node and a native instance becomes one mat4 node +reusing the same objects. Baking is cheap precisely because a prototype is +imported once regardless of instance count. Prototypes whose internal transforms +are themselves animated cannot be baked and fall back to expanded per-instance +transform nodes. Do not "correct" the baked vertex data without first changing +how the render index composes transform-array nodes. diff --git a/tsd/docs/adr/0017-deviate-from-usdview-defaults-for-purpose-and-subdivision.md b/tsd/docs/adr/0017-deviate-from-usdview-defaults-for-purpose-and-subdivision.md new file mode 100644 index 000000000..b725bf04b --- /dev/null +++ b/tsd/docs/adr/0017-deviate-from-usdview-defaults-for-purpose-and-subdivision.md @@ -0,0 +1,15 @@ +# Deviate from usdview defaults for purpose and subdivision + +USD import is judged by visual parity with a reference Hydra render, and two +import defaults deliberately differ from stock usdview anyway. usdview defaults +to `showProxy(true)` and `showRender(false)`, so it displays proxy stand-in +geometry; TSD imports `default` + `render` because it renders with a path tracer +for which proxy assets are the wrong input, and because an asset whose real +content sits behind `purpose=render` would otherwise import as its bounding-box +card. usdview defaults to complexity 1.0, which maps to refinement level 0 and +draws subdivision meshes as their unrefined control cage; TSD refines with +OpenSubdiv by default so silhouettes are correct. Both are configurable through +`UsdImportOptions`, and any parity comparison must set matching purpose and +Complexity on the reference render before treating a difference as a defect. +Neither deviation is a bug, and neither should be "aligned" with usdview without +revisiting this decision. diff --git a/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md b/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md new file mode 100644 index 000000000..be9e242b8 --- /dev/null +++ b/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md @@ -0,0 +1,14 @@ +# Let imported scenes retain an open UsdStage + +Time-varying geometry is imported as one eager first frame plus a `FileBinding` +that re-pulls point and index arrays from the still-open stage and scene index +at time `t`, rather than baking every sampled frame into TSD Arrays. Baking a +hundred-frame, million-vertex mesh costs on the order of a gigabyte per mesh, +while the lazy binding keeps memory flat regardless of frame count and follows +the pattern `EnSightFileBinding` and `SpatialFieldFileBinding` already +establish. The consequence is that import is not a fully detached operation for +animated content: an imported scene's lifetime holds a `UsdStage` and its scene +index, serialization records file and prim paths and reconstructs by re-opening +rather than by copying data, scrubbing performs real work per frame, and stage +access must be accounted for when a scene is used across threads. Static content +carries no such dependency. diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index 609aadb1e..4f0f16106 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -6,6 +6,8 @@ application-specific vocabulary used by SciVis Studio. ## Language +### Native Persistence + **Archive**: A native serialized representation of some TSD state. An Archive is independent of its carrier: it may be embedded in another representation, transmitted @@ -82,3 +84,46 @@ _Avoid_: Load, deserialize **Export**: Convert TSD state into a non-native representation. _Avoid_: Save, serialize + +### Foreign-Format Import + +**Stage**: +The composed scene an import reads a foreign representation from, identified by +the file that was opened. +_Avoid_: USD scene, USD file + +**USD Layer**: +A single composition-arc source contributing opinions to a Stage. Always +qualified: unqualified **Layer** means a TSD Layer and never this. +_Avoid_: Layer (unqualified), sublayer + +**Prototype**: +Content authored once and shared by every placement of it. An imported +Prototype is one set of TSD scene objects referenced from many layer nodes, +never a per-placement copy. + +**USD Instance**: +A prim that places a Prototype at its own transform. Always qualified: +unqualified **Instance** means the ANARI object a render index emits. +_Avoid_: Instance (unqualified) + +**Purpose**: +The visibility category of a foreign prim — `default`, `render`, `proxy`, or +`guide` — that determines whether an import includes it. + +**Render Context**: +The flavor of shading network selected from a bound foreign material, such as +UsdPreviewSurface, MaterialX, or MDL. +_Avoid_: shader target, material backend + +**Claimed Prim**: +A prim an importer handles outside its generic conversion path, and which is +therefore withheld from that path. + +**Placeholder Node**: +A named, empty, disabled layer node standing where a prim that produced no +renderable TSD content would have been. + +**Import Report**: +A structured record of one Import: which prims became TSD scene objects and, +for each that did not, the reason. From b5bab5cdcb723d12db68a5f566479ab4c0998968 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 24 Jul 2026 17:31:51 -0500 Subject: [PATCH 02/78] build(usd): add imaging + OpenSubdiv deps and make TSD_USE_USD public The USD importer rework consumes OpenUSD's own resolved scene through a Hydra scene index (ADR 0015), so tsd_io needs the imaging, scene-index filter, and image libraries, plus OpenSubdiv for subdivision refinement. TSD_USE_USD becomes a public compile definition in both its enabled and disabled forms so that consumers -- the test target in particular -- can compile conditionally against USD support. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/CMakeLists.txt | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 99940afd3..17a3009bf 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -150,9 +150,24 @@ endif() if (TSD_USE_USD) find_package(OpenGL) find_package(X11) - find_package(pxr REQUIRED sdf usd usdGeom tf gf usdShade usdLux) - project_compile_definitions(PRIVATE -DTSD_USE_USD=1) - project_link_libraries(PRIVATE ${PXR_LIBRARIES}) + # The imaging components (usdImaging/hd/hdsi/hio/pxOsd) are what the USD + # importer consumes: it reads OpenUSD's own resolved scene through a Hydra + # scene index rather than traversing schemas (ADR 0015). None of them carry a + # GL or X11 link dependency, so headless and CI builds are unaffected. + find_package(pxr REQUIRED + sdf usd usdGeom usdShade usdLux usdSkel usdVol + tf gf vt work plug + usdImaging hd hdsi hio pxOsd cameraUtil + ) + # OpenSubdiv is required for subdivision-surface refinement; pxOsd's refiner + # factory includes OpenSubdiv headers that are not shipped inside the USD + # install, so a separate CPU-only OpenSubdiv is needed. + find_package(OpenSubdiv REQUIRED) + # PUBLIC so that consumers -- the test target in particular -- can compile + # conditionally against USD support. A deliberate widening of the library's + # interface made for testability. + project_compile_definitions(PUBLIC -DTSD_USE_USD=1) + project_link_libraries(PRIVATE ${PXR_LIBRARIES} OpenSubdiv::osdCPU) # CaseFileFormat -- SdfFileFormat plugin for EnSight .case files add_library(CaseFileFormat SHARED @@ -184,7 +199,7 @@ if (TSD_USE_USD) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/usd/plugInfo.json DESTINATION lib/usd/CaseFileFormat/resources) else() - project_compile_definitions(PRIVATE -DTSD_USE_USD=0) + project_compile_definitions(PUBLIC -DTSD_USE_USD=0) endif() if (TSD_USE_VTK) From 70b8120c67caa8616093bad7c3cf462202f62286 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 24 Jul 2026 18:32:17 -0500 Subject: [PATCH 03/78] feat(usd): import Stages through a Hydra scene index Replace the hand-written UsdGeom/UsdShade traversal with a converter over OpenUSD's own resolved scene (ADR 0015). Composition, Purpose and visibility resolution, native and point instancing, material binding, primvar interpolation, implicit shapes, NURBS approximation and skinning are resolved by OpenUSD before TSD sees them; TSD converts already-resolved prims into Scene objects and Layer nodes. The entry point gains a typed options value and returns an Import Report, so "content was silently dropped" becomes an assertable condition rather than something to notice by eye. Prims that do not become renderable content leave a named, disabled Placeholder Node at their place in the hierarchy, tagged with why, and are counted by reason in the report. What this changes for a user: - Point instancers import, as one transform-array node over shared Prototype objects; placements USD marks invisible are omitted. - USD Instances share one converted Prototype across placements, with each gprim's Prototype-relative transform baked into its vertex data (ADR 0016). - Guide and proxy Purpose are excluded by default and render is included (ADR 0017); exclusions are reported per Purpose and are configurable. - Prims resolving to invisible import as disabled nodes rather than vanishing. - Meshes tessellate through OpenUSD's topology-aware utilities, so non-convex polygons and holes are handled; per-face material subsets become several Surfaces sharing the mesh's vertex arrays. - The UV primvar comes from the material's own reader node instead of being assumed to be named "st"; texture colour space is honoured. - Unmaterialed prims take display colour and display opacity. - Light intensity accounts for exposure, normalization and colour temperature; shaped sphere and disk lights become spot lights; dome orientation is baked into the light's own direction and up. - Sphere, cone and cylinder stay analytic; capsule, cube and plane become meshes. - Subdivision surfaces are refined with OpenSubdiv, carrying vertex primvars through the same refinement and honouring creases, corners and holes. - Transform animation uses the authored sample times, densified only where a two-key spherical interpolation would collapse the rotation. - Deforming geometry imports one eager frame plus a file-backed binding that re-pulls from the retained Stage, and survives save and reload (ADR 0018). - Stage up-axis and unit scale are recorded on the import root; coordinates are left exactly as authored. Two open questions from the spec are settled here. Refinement applies only to meshes that explicitly author a subdivision scheme: USD's schema default is catmullClark for every mesh, so refining unauthored meshes would silently distort ordinary polygon meshes. And OpenUSD's imaging libraries carry no GL or X11 link dependency, so headless and CI builds are unaffected. The TSD dialect stays a claim-and-prune pre-pass: markers on the raw Stage claim whole subtrees, which are pruned from the resolved scene and routed to the existing EnSight, volume, transfer-function and render-settings handlers, so a carrier prim is never also converted as generic geometry. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/CMakeLists.txt | 16 +- tsd/src/tsd/io/CONTEXT.md | 7 + tsd/src/tsd/io/UsdImport.cpp | 152 + tsd/src/tsd/io/UsdImport.hpp | 140 + .../io/animation/UsdGeometryFileBinding.cpp | 182 ++ .../io/animation/UsdGeometryFileBinding.hpp | 71 + tsd/src/tsd/io/archives/AnimationArchive.cpp | 14 + .../tsd/io/archives/detail/ArchivePlan.cpp | 10 + tsd/src/tsd/io/importers.hpp | 3 +- .../importers/detail/usd/MaterialCommon.cpp | 402 --- .../io/importers/detail/usd/MaterialCommon.h | 56 - .../importers/detail/usd/OmniPbrMaterial.cpp | 225 -- .../io/importers/detail/usd/OmniPbrMaterial.h | 27 - .../io/importers/detail/usd/UsdAnimation.cpp | 177 ++ .../io/importers/detail/usd/UsdAnimation.h | 22 + .../io/importers/detail/usd/UsdDialect.cpp | 525 ++++ .../tsd/io/importers/detail/usd/UsdDialect.h | 61 + .../io/importers/detail/usd/UsdGeometry.cpp | 736 +++++ .../tsd/io/importers/detail/usd/UsdGeometry.h | 27 + .../importers/detail/usd/UsdImportContext.h | 93 + .../io/importers/detail/usd/UsdInstancing.cpp | 384 +++ .../io/importers/detail/usd/UsdInstancing.h | 56 + .../tsd/io/importers/detail/usd/UsdLights.cpp | 478 ++++ .../tsd/io/importers/detail/usd/UsdLights.h | 28 + .../io/importers/detail/usd/UsdMaterials.cpp | 448 +++ .../io/importers/detail/usd/UsdMaterials.h | 32 + .../importers/detail/usd/UsdSubdivision.cpp | 226 ++ .../io/importers/detail/usd/UsdSubdivision.h | 52 + tsd/src/tsd/io/importers/import_USD.cpp | 2495 ++--------------- .../serialization/serialization_datatree.cpp | 3 + tsd/src/tsd/scripting/bindings/IOBindings.cpp | 54 + tsd/src/tsd/scripting/tsd.lua | 8 + tsd/tests/CMakeLists.txt | 2 + tsd/tests/test_UsdImport.cpp | 1439 ++++++++++ 34 files changed, 5726 insertions(+), 2925 deletions(-) create mode 100644 tsd/src/tsd/io/UsdImport.cpp create mode 100644 tsd/src/tsd/io/UsdImport.hpp create mode 100644 tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp create mode 100644 tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp delete mode 100644 tsd/src/tsd/io/importers/detail/usd/MaterialCommon.cpp delete mode 100644 tsd/src/tsd/io/importers/detail/usd/MaterialCommon.h delete mode 100644 tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.cpp delete mode 100644 tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdDialect.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdLights.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h create mode 100644 tsd/tests/test_UsdImport.cpp diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 17a3009bf..a0603f110 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -22,6 +22,7 @@ PRIVATE archives/detail/SubtreeArchiveDeserialization.cpp animation/EnSightFileBinding.cpp animation/SpatialFieldFileBinding.cpp + UsdImport.cpp exporters/NanoVdbSidecar.cpp exporters/SceneToUSD.cpp exporters/StructuredVolumeToNanoVDB.cpp @@ -31,8 +32,7 @@ PRIVATE importers/detail/pbrt/PbrtLexer.cpp importers/detail/pbrt/PbrtParser.cpp importers/detail/pbrt/PbrtScene.cpp - importers/detail/usd/MaterialCommon.cpp - importers/detail/usd/OmniPbrMaterial.cpp + animation/UsdGeometryFileBinding.cpp importers/import_AGX.cpp importers/import_ASSIMP.cpp importers/import_AXYZ.cpp @@ -169,6 +169,18 @@ if (TSD_USE_USD) project_compile_definitions(PUBLIC -DTSD_USE_USD=1) project_link_libraries(PRIVATE ${PXR_LIBRARIES} OpenSubdiv::osdCPU) + # The USD importer's converters, which have no meaning without OpenUSD. + project_sources( + PRIVATE + importers/detail/usd/UsdAnimation.cpp + importers/detail/usd/UsdDialect.cpp + importers/detail/usd/UsdGeometry.cpp + importers/detail/usd/UsdInstancing.cpp + importers/detail/usd/UsdLights.cpp + importers/detail/usd/UsdMaterials.cpp + importers/detail/usd/UsdSubdivision.cpp + ) + # CaseFileFormat -- SdfFileFormat plugin for EnSight .case files add_library(CaseFileFormat SHARED usd/CaseFileFormat.cpp diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index 4f0f16106..3aaab4a63 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -127,3 +127,10 @@ renderable TSD content would have been. **Import Report**: A structured record of one Import: which prims became TSD scene objects and, for each that did not, the reason. + +**Import Options**: +The typed settings governing one Import — which Purposes to include, which +Render Contexts to prefer, what to emit materials as, how far to refine +subdivision surfaces, and which prim to import from. Converts to and from a +DataTree so it can be persisted with a project and driven from scripting. +_Avoid_: import config, import params diff --git a/tsd/src/tsd/io/UsdImport.cpp b/tsd/src/tsd/io/UsdImport.cpp new file mode 100644 index 000000000..2b9978c47 --- /dev/null +++ b/tsd/src/tsd/io/UsdImport.cpp @@ -0,0 +1,152 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/UsdImport.hpp" +// std +#include +#include + +namespace tsd::io { + +// Helper functions /////////////////////////////////////////////////////////// + +static const char *materialModeToString(UsdMaterialMode mode) +{ + switch (mode) { + case UsdMaterialMode::MATERIALX: + return "materialx"; + case UsdMaterialMode::MDL: + return "mdl"; + case UsdMaterialMode::PHYSICALLY_BASED: + default: + return "physicallyBased"; + } +} + +static UsdMaterialMode materialModeFromString(const std::string &s) +{ + if (s == "materialx") + return UsdMaterialMode::MATERIALX; + if (s == "mdl") + return UsdMaterialMode::MDL; + return UsdMaterialMode::PHYSICALLY_BASED; +} + +// UsdImportOptions definitions /////////////////////////////////////////////// + +void UsdImportOptions::toDataNode(core::DataNode &node) const +{ + auto &purposesNode = node["purposes"]; + purposesNode["default"] = purposes.defaultPurpose; + purposesNode["render"] = purposes.render; + purposesNode["proxy"] = purposes.proxy; + purposesNode["guide"] = purposes.guide; + + auto &contextsNode = node["renderContexts"]; + for (const auto &context : renderContexts) + contextsNode.append() = context; + + node["materialMode"] = std::string(materialModeToString(materialMode)); + node["refinementLevel"] = refinementLevel; + node["primPath"] = primPath; +} + +void UsdImportOptions::fromDataNode(const core::DataNode &node) +{ + if (const auto *purposesNode = node.child("purposes")) { + auto readFlag = [&](const char *name, bool &out) { + if (const auto *n = purposesNode->child(name)) + out = n->getValueOr(out); + }; + readFlag("default", purposes.defaultPurpose); + readFlag("render", purposes.render); + readFlag("proxy", purposes.proxy); + readFlag("guide", purposes.guide); + } + + if (const auto *contextsNode = node.child("renderContexts")) { + renderContexts.clear(); + for (size_t i = 0; i < contextsNode->numChildren(); ++i) + renderContexts.push_back( + contextsNode->child(i)->getValueOr("")); + } + + if (const auto *n = node.child("materialMode")) + materialMode = materialModeFromString(n->getValueOr("")); + if (const auto *n = node.child("refinementLevel")) + refinementLevel = n->getValueOr(refinementLevel); + if (const auto *n = node.child("primPath")) + primPath = n->getValueOr(primPath); +} + +// UsdImportReport definitions //////////////////////////////////////////////// + +const char *toString(UsdSkipReason reason) +{ + switch (reason) { + case UsdSkipReason::PURPOSE_EXCLUDED: + return "purpose excluded"; + case UsdSkipReason::RESOLVED_INVISIBLE: + return "resolved invisible"; + case UsdSkipReason::UNSUPPORTED_PRIM_TYPE: + return "unsupported prim type"; + case UsdSkipReason::MATERIAL_RESOLUTION_FAILED: + return "material resolution failed"; + case UsdSkipReason::TEXTURE_LOAD_FAILED: + return "texture load failed"; + case UsdSkipReason::UNSUPPORTED_LIGHT_TYPE: + return "unsupported light type"; + case UsdSkipReason::RICHER_MATERIAL_AVAILABLE: + return "richer material network available"; + case UsdSkipReason::TIME_VARYING_VALUE_DROPPED: + return "time-varying value dropped"; + } + return "unknown"; +} + +size_t UsdImportReport::countOf(UsdSkipReason reason) const +{ + return size_t(std::count_if(skipped.begin(), + skipped.end(), + [&](const UsdSkippedPrim &s) { return s.reason == reason; })); +} + +bool UsdImportReport::contains(UsdSkipReason reason) const +{ + return countOf(reason) > 0; +} + +std::string UsdImportReport::summary() const +{ + if (!stageOpened) + return "stage failed to open"; + + std::string retval = std::to_string(convertedPrims) + " prims converted, " + + std::to_string(skipped.size()) + " skipped"; + + // Counts by reason, in enum order, omitting reasons that did not occur. + const UsdSkipReason allReasons[] = {UsdSkipReason::PURPOSE_EXCLUDED, + UsdSkipReason::RESOLVED_INVISIBLE, + UsdSkipReason::UNSUPPORTED_PRIM_TYPE, + UsdSkipReason::MATERIAL_RESOLUTION_FAILED, + UsdSkipReason::TEXTURE_LOAD_FAILED, + UsdSkipReason::UNSUPPORTED_LIGHT_TYPE, + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + UsdSkipReason::TIME_VARYING_VALUE_DROPPED}; + + bool first = true; + for (auto reason : allReasons) { + const auto count = countOf(reason); + if (count == 0) + continue; + retval += first ? " (" : ", "; + retval += std::to_string(count) + " " + toString(reason); + first = false; + } + if (!first) + retval += ")"; + + return retval; +} + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/UsdImport.hpp b/tsd/src/tsd/io/UsdImport.hpp new file mode 100644 index 000000000..ef1fb4251 --- /dev/null +++ b/tsd/src/tsd/io/UsdImport.hpp @@ -0,0 +1,140 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/DataTree.hpp" +// std +#include +#include + +namespace tsd::io { + +/////////////////////////////////////////////////////////////////////////////// +// Import options ///////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +/* + * Which USD Purposes a Stage import includes. TSD deliberately deviates from + * reference-viewer defaults by including render Purpose (ADR 0017), because + * assets whose real content sits behind a render Purpose would otherwise + * import as bounding-box stand-ins. + * + * Example: + * UsdPurposeSelection p; // default + render + * p.proxy = true; // also inspect proxy stand-ins + */ +struct UsdPurposeSelection +{ + bool defaultPurpose{true}; + bool render{true}; + bool proxy{false}; + bool guide{false}; +}; + +/* + * How USD materials are emitted into the Scene. Portable physically-based + * materials are the default so that imported Stages render on every ANARI + * device; the native passthrough modes trade portability for fidelity. + */ +enum class UsdMaterialMode +{ + PHYSICALLY_BASED, + MATERIALX, + MDL +}; + +/* + * Typed settings for one USD Stage import. Every field has a default that + * makes the common case need no configuration, and the whole value converts to + * and from TSD's data-tree representation so applications can persist it and + * scripting can drive it. + * + * Example: + * UsdImportOptions opts; + * opts.purposes.proxy = true; + * opts.primPath = "/World/Asset"; + * auto report = import_USD(scene, animMgr, file, {}, opts); + */ +struct UsdImportOptions +{ + UsdPurposeSelection purposes; + + // Ordered Render Context preference with per-material fallback. Entries are + // USD Render Context names; the empty string is the universal context. + std::vector renderContexts{"", "glslfx"}; + + UsdMaterialMode materialMode{UsdMaterialMode::PHYSICALLY_BASED}; + + // Subdivision refinement level. TSD refines by default (ADR 0017) rather + // than matching a reference viewer's unrefined complexity. + int refinementLevel{2}; + + // Import one subtree instead of everything beneath the pseudo-root. Empty + // imports the whole Stage. + std::string primPath; + + void toDataNode(core::DataNode &node) const; + void fromDataNode(const core::DataNode &node); +}; + +/////////////////////////////////////////////////////////////////////////////// +// Import report ////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +/* + * Why a prim did not become renderable TSD content. Recorded per prim in the + * Import Report and tagged onto the Placeholder Node left in the prim's place. + */ +enum class UsdSkipReason +{ + PURPOSE_EXCLUDED, + RESOLVED_INVISIBLE, + UNSUPPORTED_PRIM_TYPE, + MATERIAL_RESOLUTION_FAILED, + TEXTURE_LOAD_FAILED, + UNSUPPORTED_LIGHT_TYPE, + RICHER_MATERIAL_AVAILABLE, + TIME_VARYING_VALUE_DROPPED +}; + +const char *toString(UsdSkipReason reason); + +/* + * One prim that did not become renderable TSD content, together with why. + */ +struct UsdSkippedPrim +{ + std::string primPath; + std::string primType; + UsdSkipReason reason{UsdSkipReason::UNSUPPORTED_PRIM_TYPE}; + std::string detail; +}; + +/* + * What an import did. Returned from the import entry point rather than only + * logged, which is what turns "content was silently dropped" into an + * assertable condition. + * + * Note that a prim can appear in `skipped` and still have produced a Layer + * node: a prim resolving to invisible imports its real content as a disabled + * node so it can be toggled on, and is reported because it does not render. + * Prims skipped for any other reason leave an empty, disabled Placeholder Node + * at their position in the hierarchy. + * + * Example: + * auto report = import_USD(scene, animMgr, file); + * REQUIRE(report.skipped.empty()); + */ +struct UsdImportReport +{ + bool stageOpened{false}; + size_t convertedPrims{0}; + std::vector skipped; + + size_t countOf(UsdSkipReason reason) const; + bool contains(UsdSkipReason reason) const; + std::string summary() const; +}; + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp new file mode 100644 index 000000000..0f7ac6475 --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -0,0 +1,182 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/animation/UsdGeometryFileBinding.hpp" +// tsd_core +#include "tsd/core/DataTree.hpp" +#include "tsd/core/Logging.hpp" +#include "tsd/scene/objects/Array.hpp" +#if TSD_USE_USD +// usd +#include +#include +#include +#endif +// std +#include +#include + +namespace tsd::io { + +using namespace tsd::core; + +#if TSD_USE_USD + +struct UsdGeometryFileBinding::StageHolder +{ + pxr::UsdStageRefPtr stage; +}; + +#else + +struct UsdGeometryFileBinding::StageHolder +{ +}; + +#endif + +UsdGeometryFileBinding::UsdGeometryFileBinding(scene::Scene *scene, + scene::Geometry *geometry, + std::string stageFile, + std::string primPath, + std::vector sampleTimes) + : FileBinding(scene), + m_geometry(geometry), + m_stageFile(std::move(stageFile)), + m_primPath(std::move(primPath)), + m_sampleTimes(std::move(sampleTimes)) +{} + +std::string UsdGeometryFileBinding::kind() const +{ + return "usdGeometry"; +} + +void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const +{ + auto *geometry = m_geometry.get(); + node["targetIndex"] = geometry ? geometry->index() : size_t(-1); + node["stageFile"] = m_stageFile; + node["primPath"] = m_primPath; + + auto ×Node = node["sampleTimes"]; + for (double t : m_sampleTimes) + timesNode.append() = float(t); +} + +void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) +{ + if (m_geometry) { + const size_t newIndex = cb(m_geometry->type(), m_geometry->index()); + m_geometry.updateDefragmentedIndex(newIndex); + } +} + +size_t UsdGeometryFileBinding::frameCount() const +{ + return m_sampleTimes.size(); +} + +int UsdGeometryFileBinding::currentFrame() const +{ + return m_currentFrame; +} + +void UsdGeometryFileBinding::addCallbackToAnimation( + tsd::animation::Animation &anim) +{ + anim.addCallbackBinding([this](float t) { this->update(t); }); +} + +UsdGeometryFileBinding *UsdGeometryFileBinding::addToAnimation( + tsd::animation::Animation &anim, scene::Scene &scene, core::DataNode &node) +{ + const auto targetIndex = node["targetIndex"].getValueOr(size_t(-1)); + auto *geometry = static_cast( + scene.getObject(ANARI_GEOMETRY, targetIndex)); + if (!geometry) { + logWarning( + "[UsdGeometryFileBinding] geometry index %zu not found; skipping", + targetIndex); + return nullptr; + } + + std::vector sampleTimes; + if (auto *timesNode = node.child("sampleTimes")) { + timesNode->foreach_child([&](core::DataNode &n) { + sampleTimes.push_back(n.getValueOr(0.f)); + }); + } + + return &anim.emplaceFileBinding(&scene, + geometry, + node["stageFile"].getValueOr(""), + node["primPath"].getValueOr(""), + std::move(sampleTimes)); +} + +#if TSD_USE_USD + +void UsdGeometryFileBinding::update(float t) +{ + if (m_sampleTimes.empty() || !scene()) + return; + + const int frameCount = int(m_sampleTimes.size()); + const int frame = + std::clamp(int(std::round(t * float(frameCount - 1))), 0, frameCount - 1); + if (frame == m_currentFrame && m_stage) + return; + + if (!m_stage) { + m_stage = std::make_shared(); + m_stage->stage = pxr::UsdStage::Open(m_stageFile); + if (!m_stage->stage) { + logWarning("[UsdGeometryFileBinding] failed to open stage '%s'", + m_stageFile.c_str()); + return; + } + } + + auto prim = m_stage->stage->GetPrimAtPath(pxr::SdfPath(m_primPath)); + pxr::UsdGeomPointBased pointBased(prim); + if (!pointBased) + return; + + auto *geometry = m_geometry.get(); + if (!geometry) + return; + + const pxr::UsdTimeCode time(m_sampleTimes[size_t(frame)]); + + pxr::VtVec3fArray points; + if (pointBased.GetPointsAttr().Get(&points, time) && !points.empty()) { + if (auto *positions = + geometry->parameterValueAsObject("vertex.position")) { + if (positions->size() == points.size()) + positions->setData(points.cdata()); + } + } + + pxr::VtVec3fArray normals; + if (pointBased.GetNormalsAttr().Get(&normals, time) && !normals.empty()) { + if (auto *normalArray = + geometry->parameterValueAsObject("vertex.normal")) { + if (normalArray->size() == normals.size()) + normalArray->setData(normals.cdata()); + } + } + + m_currentFrame = frame; +} + +#else + +void UsdGeometryFileBinding::update(float) +{ + logError("[UsdGeometryFileBinding] USD not enabled in TSD build."); +} + +#endif + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp new file mode 100644 index 000000000..2275ede0d --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp @@ -0,0 +1,71 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/animation/Animation.hpp" +#include "tsd/animation/FileBinding.hpp" +#include "tsd/scene/ObjectUsePtr.hpp" +#include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Geometry.hpp" +// std +#include +#include + +namespace tsd::io { + +/* + * Binding that re-pulls a deforming mesh's vertex arrays from a USD Stage at + * the current animation time, so that a long animation of a dense mesh does + * not have to fit in memory. The Stage stays open for the binding's lifetime + * (ADR 0018); serialization records the file and prim paths and reconstructs + * by re-opening. + * + * Example: + * auto &b = anim.emplaceFileBinding( + * &scene, geometry.data(), stageFile, "/World/Character", sampleTimes); + */ +struct UsdGeometryFileBinding : public tsd::animation::FileBinding +{ + UsdGeometryFileBinding(scene::Scene *scene, + scene::Geometry *geometry, + std::string stageFile, + std::string primPath, + std::vector sampleTimes); + + // FileBinding interface // + + std::string kind() const override; + void toDataNode(tsd::core::DataNode &node) const override; + void onDefragment(const scene::IndexRemapper &cb) override; + + // Pull the vertex arrays for the sample nearest `t`, which is normalized + // across the binding's authored sample times. No-ops if the frame has not + // changed. + void update(float t) override; + + size_t frameCount() const; + int currentFrame() const; + + // Reconstruct from a serialized node; returns null if the target geometry is + // missing from the scene. + static UsdGeometryFileBinding *addToAnimation(tsd::animation::Animation &anim, + scene::Scene &scene, + tsd::core::DataNode &node); + + private: + void addCallbackToAnimation(tsd::animation::Animation &anim) override; + + scene::ObjectUsePtr m_geometry; + std::string m_stageFile; + std::string m_primPath; + std::vector m_sampleTimes; + int m_currentFrame{0}; + + // Opened lazily on the first update and retained thereafter, so scrubbing + // does not pay stage-open cost per frame. + struct StageHolder; + std::shared_ptr m_stage; +}; + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/archives/AnimationArchive.cpp b/tsd/src/tsd/io/archives/AnimationArchive.cpp index eae84f084..6059a7b58 100644 --- a/tsd/src/tsd/io/archives/AnimationArchive.cpp +++ b/tsd/src/tsd/io/archives/AnimationArchive.cpp @@ -147,6 +147,20 @@ bool validateFileBinding( } return true; } + if (kind == "usdGeometry") { + size_t targetIndex = core::INVALID_INDEX; + if (!readIndex(binding, "targetIndex", targetIndex) + || !scene.getObject(ANARI_GEOMETRY, targetIndex)) { + return fail( + message, "USD geometry binding target is incompatible with Scene"); + } + if (binding.child("stageFile") == nullptr + || binding.child("primPath") == nullptr) { + return fail( + message, "USD geometry binding requires a stage file and prim path"); + } + return true; + } if (kind == "ensight") { auto *parts = binding.child("parts"); if (!parts || parts->numChildren() == 0) diff --git a/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp b/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp index 83756a652..c5ebae760 100644 --- a/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp +++ b/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp @@ -109,6 +109,16 @@ bool classifyFileBinding(const animation::FileBinding &binding, result); } + if (binding.kind() == "usdGeometry") { + return classifyObjectTarget(entries, + ANARI_GEOMETRY, + scratch.root()["targetIndex"].getValueOr( + tsd::core::INVALID_INDEX), + inside, + outside, + result); + } + if (binding.kind() == "ensight") { auto *parts = scratch.root().child("parts"); if (!parts || parts->numChildren() == 0) { diff --git a/tsd/src/tsd/io/importers.hpp b/tsd/src/tsd/io/importers.hpp index ecf6ace6b..ef78e259d 100644 --- a/tsd/src/tsd/io/importers.hpp +++ b/tsd/src/tsd/io/importers.hpp @@ -5,6 +5,7 @@ #include "tsd/core/ColorMapUtil.hpp" #include "tsd/core/FlatMap.hpp" +#include "tsd/io/UsdImport.hpp" #include "tsd/scene/Scene.hpp" // std #include @@ -84,7 +85,7 @@ void import_SMESH(Scene &scene, tsd::animation::AnimationManager &animMgr, const void import_SWC(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filename, LayerNodeRef location = {}); void import_SWC_SDF(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filename, LayerNodeRef location = {}); void import_TRK(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filename, LayerNodeRef location = {}); -void import_USD(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filename, LayerNodeRef location = {}); +UsdImportReport import_USD(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filename, LayerNodeRef location = {}, const UsdImportOptions &options = {}); void import_VTP(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filepath, LayerNodeRef location = {}); void import_VTU(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filepath, LayerNodeRef location, std::optional propertyName = std::nullopt); void import_XYZDP(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filename, LayerNodeRef location = {}); diff --git a/tsd/src/tsd/io/importers/detail/usd/MaterialCommon.cpp b/tsd/src/tsd/io/importers/detail/usd/MaterialCommon.cpp deleted file mode 100644 index faed06899..000000000 --- a/tsd/src/tsd/io/importers/detail/usd/MaterialCommon.cpp +++ /dev/null @@ -1,402 +0,0 @@ -// Copyright 2024-2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#if TSD_USE_USD - -#include "MaterialCommon.h" - -// tsd_core -#include - -// pxr -#include -#include -#include -#include - -namespace tsd::io::materials { - -bool getShaderFloatInput( - const pxr::UsdShadeShader &shader, const char *inputName, float &outValue) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - if (!input) { - return false; - } - - // Check if there's a connected source - if (input.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - if (input.GetConnectedSource(&source, &sourceName, &sourceType)) { - // Check if this is a connection to a material interface input - pxr::UsdPrim sourcePrim = source.GetPrim(); - if (sourcePrim.IsA()) { - // This is a material interface connection - get the value from the - // material's input - pxr::UsdShadeMaterial mat(sourcePrim); - pxr::UsdShadeInput matInput = mat.GetInput(sourceName); - if (matInput && matInput.Get(&outValue)) { - return true; - } - } else { - // This is a connection to another shader's output - pxr::UsdShadeShader sourceShader(sourcePrim); - if (sourceShader) { - pxr::UsdShadeOutput output = sourceShader.GetOutput(sourceName); - if (output) { - pxr::UsdAttribute attr = output.GetAttr(); - if (attr && attr.Get(&outValue)) { - return true; - } - } - } - } - } - } - - // Fall back to direct value - if (input.Get(&outValue)) { - return true; - } - return false; -} - -bool getShaderBoolInput( - const pxr::UsdShadeShader &shader, const char *inputName, bool &outValue) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - if (!input) { - return false; - } - - // Check if there's a connected source - if (input.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - if (input.GetConnectedSource(&source, &sourceName, &sourceType)) { - // Check if this is a connection to a material interface input - pxr::UsdPrim sourcePrim = source.GetPrim(); - if (sourcePrim.IsA()) { - // This is a material interface connection - get the value from the - // material's input - pxr::UsdShadeMaterial mat(sourcePrim); - pxr::UsdShadeInput matInput = mat.GetInput(sourceName); - if (matInput && matInput.Get(&outValue)) { - return true; - } - } else { - // This is a connection to another shader's output - pxr::UsdShadeShader sourceShader(sourcePrim); - if (sourceShader) { - pxr::UsdShadeOutput output = sourceShader.GetOutput(sourceName); - if (output) { - pxr::UsdAttribute attr = output.GetAttr(); - if (attr && attr.Get(&outValue)) { - return true; - } - } - } - } - } - } - - // Fall back to direct value - if (input.Get(&outValue)) { - return true; - } - return false; -} - -bool getShaderColorInput(const pxr::UsdShadeShader &shader, - const char *inputName, - pxr::GfVec3f &outValue) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - if (!input) { - return false; - } - - // Check if there's a connected source - if (input.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - if (input.GetConnectedSource(&source, &sourceName, &sourceType)) { - // Check if this is a connection to a material interface input - pxr::UsdPrim sourcePrim = source.GetPrim(); - if (sourcePrim.IsA()) { - // This is a material interface connection - get the value from the - // material's input - pxr::UsdShadeMaterial mat(sourcePrim); - pxr::UsdShadeInput matInput = mat.GetInput(sourceName); - if (matInput && matInput.Get(&outValue)) { - return true; - } - } else { - // This is a connection to another shader's output - pxr::UsdShadeShader sourceShader(sourcePrim); - if (sourceShader) { - pxr::UsdShadeOutput output = sourceShader.GetOutput(sourceName); - if (output) { - pxr::UsdAttribute attr = output.GetAttr(); - if (attr && attr.Get(&outValue)) { - return true; - } - } - } - } - } - } - - // Fall back to direct value - if (input.Get(&outValue)) { - return true; - } - return false; -} - -bool getShaderTextureInput(const pxr::UsdShadeShader &shader, - const char *inputName, - std::string &outFilePath) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - if (!input) { - return false; - } - - // Check if there's a connected texture reader - if (input.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - input.GetConnectedSource(&source, &sourceName, &sourceType); - - // Check if this is a connection to a material interface input - pxr::UsdPrim sourcePrim = source.GetPrim(); - if (sourcePrim.IsA()) { - // This is a material interface connection - get the value from the - // material's input - pxr::UsdShadeMaterial mat(sourcePrim); - pxr::UsdShadeInput matInput = mat.GetInput(sourceName); - if (matInput) { - pxr::SdfAssetPath assetPath; - if (matInput.Get(&assetPath)) { - outFilePath = assetPath.GetResolvedPath(); - if (outFilePath.empty()) { - outFilePath = assetPath.GetAssetPath(); - } - return !outFilePath.empty(); - } - } - } else { - // Check if this is a texture reader shader - pxr::UsdShadeShader textureShader(sourcePrim); - if (textureShader) { - // Look for file input on the texture reader - pxr::UsdShadeInput fileInput = - textureShader.GetInput(pxr::TfToken("file")); - if (fileInput) { - pxr::SdfAssetPath assetPath; - if (fileInput.Get(&assetPath)) { - outFilePath = assetPath.GetResolvedPath(); - if (outFilePath.empty()) { - outFilePath = assetPath.GetAssetPath(); - } - return !outFilePath.empty(); - } - } - } - } - } - - // Try direct asset path input - pxr::SdfAssetPath assetPath; - if (input.Get(&assetPath)) { - outFilePath = assetPath.GetResolvedPath(); - if (outFilePath.empty()) { - outFilePath = assetPath.GetAssetPath(); - } - return !outFilePath.empty(); - } - - return false; -} - -// Walk input:st of a UsdUVTexture through optional UsdTransform2d and -// UsdPrimvarReader_float2 to extract inAttribute and inTransform. -static void resolveSTChain(const pxr::UsdShadeShader &texShader, - std::string &outAttribute, - bool &outHasTransform, - tsd::math::mat4 &outTransform) -{ - outAttribute = "attribute0"; - outHasTransform = false; - - pxr::UsdShadeInput stInput = texShader.GetInput(pxr::TfToken("st")); - if (!stInput || !stInput.HasConnectedSource()) - return; - - pxr::UsdShadeConnectableAPI stSource; - pxr::TfToken stSourceName; - pxr::UsdShadeAttributeType stSourceType; - if (!stInput.GetConnectedSource(&stSource, &stSourceName, &stSourceType)) - return; - - pxr::UsdShadeShader stShader(stSource.GetPrim()); - if (!stShader) - return; - - pxr::TfToken stShaderId; - stShader.GetShaderId(&stShaderId); - - // Case 1: directly connected to UsdPrimvarReader_float2 - if (stShaderId == pxr::TfToken("UsdPrimvarReader_float2")) { - pxr::UsdShadeInput varnameInput = - stShader.GetInput(pxr::TfToken("varname")); - if (varnameInput) { - std::string varname; - if (varnameInput.Get(&varname) && !varname.empty()) - outAttribute = varname; - } - return; - } - - // Case 2: connected to UsdTransform2d - if (stShaderId == pxr::TfToken("UsdTransform2d")) { - pxr::GfVec2f scale(1.0f, 1.0f); - pxr::GfVec2f translation(0.0f, 0.0f); - - if (auto scaleIn = stShader.GetInput(pxr::TfToken("scale"))) - scaleIn.Get(&scale); - if (auto transIn = stShader.GetInput(pxr::TfToken("translation"))) - transIn.Get(&translation); - - outTransform = tsd::math::IDENTITY_MAT4; - outTransform[0][0] = scale[0]; - outTransform[1][1] = scale[1]; - outTransform[3][0] = translation[0]; - outTransform[3][1] = translation[1]; - outHasTransform = true; - - // Follow inputs:in to UsdPrimvarReader_float2 - pxr::UsdShadeInput inInput = stShader.GetInput(pxr::TfToken("in")); - if (inInput && inInput.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI inSource; - pxr::TfToken inSourceName; - pxr::UsdShadeAttributeType inSourceType; - if (inInput.GetConnectedSource( - &inSource, &inSourceName, &inSourceType)) { - pxr::UsdShadeShader readerShader(inSource.GetPrim()); - if (readerShader) { - pxr::TfToken readerId; - readerShader.GetShaderId(&readerId); - if (readerId == pxr::TfToken("UsdPrimvarReader_float2")) { - pxr::UsdShadeInput varnameInput = - readerShader.GetInput(pxr::TfToken("varname")); - if (varnameInput) { - std::string varname; - if (varnameInput.Get(&varname) && !varname.empty()) - outAttribute = varname; - } - } - } - } - } - } -} - -SamplerRef resolveTexturedInput(Scene &scene, - const pxr::UsdShadeShader &shader, - const char *inputName, - const std::string &basePath, - TextureCache &texCache) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - if (!input || !input.HasConnectedSource()) - return {}; - - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - if (!input.GetConnectedSource(&source, &sourceName, &sourceType)) - return {}; - - pxr::UsdShadeShader texShader(source.GetPrim()); - if (!texShader) - return {}; - - pxr::TfToken shaderId; - texShader.GetShaderId(&shaderId); - if (shaderId != pxr::TfToken("UsdUVTexture")) - return {}; - - // Load the texture file - pxr::UsdShadeInput fileInput = texShader.GetInput(pxr::TfToken("file")); - if (!fileInput) - return {}; - - pxr::SdfAssetPath assetPath; - if (!fileInput.Get(&assetPath)) - return {}; - - std::string filePath = assetPath.GetResolvedPath(); - if (filePath.empty()) - filePath = assetPath.GetAssetPath(); - if (filePath.empty()) - return {}; - - if (!isAbsolute(filePath)) - filePath = basePath + filePath; - - auto sampler = importTexture(scene, filePath, texCache, false); - if (!sampler) { - logWarning("[import_USD] Failed to load texture for input '%s': %s\n", - inputName, - filePath.c_str()); - return {}; - } - - // Read wrap modes from UsdUVTexture - auto readWrapMode = [&](const char *usdName) -> std::string { - pxr::UsdShadeInput wrapInput = texShader.GetInput(pxr::TfToken(usdName)); - if (!wrapInput) - return "repeat"; - pxr::TfToken wrapToken; - if (wrapInput.Get(&wrapToken)) { - std::string w = wrapToken.GetString(); - if (w == "clamp") - return "clampToEdge"; - if (w == "mirror") - return "mirrorRepeat"; - return w; - } - return "repeat"; - }; - - sampler->setParameter("wrapMode1", readWrapMode("wrapS").c_str()); - sampler->setParameter("wrapMode2", readWrapMode("wrapT").c_str()); - - // Walk the ST chain for primvar name and optional transform - std::string attribute; - bool hasTransform = false; - tsd::math::mat4 transform = tsd::math::IDENTITY_MAT4; - resolveSTChain(texShader, attribute, hasTransform, transform); - - sampler->setParameter("inAttribute", attribute.c_str()); - if (hasTransform) - sampler->setParameter("inTransform", transform); - - logStatus("[import_USD] Resolved textured input '%s': file=%s, attr=%s\n", - inputName, - filePath.c_str(), - attribute.c_str()); - - return sampler; -} - -} // namespace tsd::io::materials - -#endif diff --git a/tsd/src/tsd/io/importers/detail/usd/MaterialCommon.h b/tsd/src/tsd/io/importers/detail/usd/MaterialCommon.h deleted file mode 100644 index 83c3149a0..000000000 --- a/tsd/src/tsd/io/importers/detail/usd/MaterialCommon.h +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2024-2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#if TSD_USE_USD - -#include "tsd/io/importers/detail/importer_common.hpp" -#include "tsd/scene/Scene.hpp" - -// pxr -#include -#include -#include - -// std -#include - -namespace tsd::io::materials { - -using namespace tsd::core; -using namespace tsd::scene; - -// Helper functions for extracting shader parameters - -/// Get a float input from a USD shader -bool getShaderFloatInput( - const pxr::UsdShadeShader &shader, const char *inputName, float &outValue); - -/// Get a bool input from a USD shader -bool getShaderBoolInput( - const pxr::UsdShadeShader &shader, const char *inputName, bool &outValue); - -/// Get a color3f input from a USD shader -bool getShaderColorInput(const pxr::UsdShadeShader &shader, - const char *inputName, - pxr::GfVec3f &outValue); - -/// Get a texture file path from a USD shader input -/// Handles both connected texture reader nodes and direct asset path inputs -bool getShaderTextureInput(const pxr::UsdShadeShader &shader, - const char *inputName, - std::string &outFilePath); - -/// Walk a UsdPreviewSurface input through UsdUVTexture -> UsdTransform2d -> -/// UsdPrimvarReader_float2 and return a configured image2D sampler. -/// Returns null SamplerRef if the input is not connected to a UsdUVTexture. -SamplerRef resolveTexturedInput(Scene &scene, - const pxr::UsdShadeShader &shader, - const char *inputName, - const std::string &basePath, - TextureCache &texCache); - -} // namespace tsd::io::materials - -#endif diff --git a/tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.cpp b/tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.cpp deleted file mode 100644 index bb8a9e0c8..000000000 --- a/tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.cpp +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2024-2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#if TSD_USE_USD - -#include "OmniPbrMaterial.h" - -// tsd_core -#include - -// pxr -#include -#include -#include -#include - -namespace tsd::io::materials { - -MaterialRef importOmniPBRMaterial(Scene &scene, - const pxr::UsdShadeMaterial &usdMaterial, - const pxr::UsdShadeShader &usdShader, - const std::string &basePath, - TextureCache &textureCache) -{ - // Create physicallyBased material - auto mat = scene.createObject(tokens::material::physicallyBased); - - // Set material name - std::string matName = usdMaterial.GetPrim().GetName().GetString(); - if (matName.empty()) - matName = "OmniPBR_Material"; - mat->setName(matName.c_str()); - - // Read OmniPBR parameters - - // Base color (diffuse) - try texture first, then constant - std::string diffuseTexPath; - if (getShaderTextureInput(usdShader, "diffuse_texture", diffuseTexPath)) { - // Resolve relative path - std::string resolvedPath = diffuseTexPath; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + diffuseTexPath; - } - - auto sampler = importTexture(scene, resolvedPath, textureCache, false); - if (sampler) { - mat->setParameterObject("baseColor", *sampler); - } else { - logWarning("[import_USD] Failed to load diffuse texture: %s\n", - resolvedPath.c_str()); - } - } else { - // Use constant color - pxr::GfVec3f diffuseColor; - if (getShaderColorInput( - usdShader, "diffuse_color_constant", diffuseColor)) { - mat->setParameter("baseColor", - tsd::math::float3(diffuseColor[0], diffuseColor[1], diffuseColor[2])); - } - } - - // Handle emissive with intensity - pxr::GfVec3f emissiveColor(0, 0, 0); - float emissiveIntensity = 1.0f; - bool enableEmission = false; - - getShaderColorInput(usdShader, "emissive_color", emissiveColor); - getShaderFloatInput(usdShader, "emissive_intensity", emissiveIntensity); - getShaderBoolInput(usdShader, "enable_emission", enableEmission); - - if (enableEmission) { - // Scale emissive color by intensity - tsd::math::float3 finalEmissive(emissiveColor[0] * emissiveIntensity, - emissiveColor[1] * emissiveIntensity, - emissiveColor[2] * emissiveIntensity); - mat->setParameter("emissive", finalEmissive); - } - - // Metallic - try texture first, then constant - std::string metallicTexPath; - if (getShaderTextureInput(usdShader, "metallic_texture", metallicTexPath)) { - std::string resolvedPath = metallicTexPath; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + metallicTexPath; - } - - auto sampler = importTexture(scene, resolvedPath, textureCache, true); - if (sampler) { - mat->setParameterObject("metallic", *sampler); - } else { - logWarning("[import_USD] Failed to load metallic texture: %s\n", - resolvedPath.c_str()); - } - } else { - float metallic = 0.0f; - if (getShaderFloatInput(usdShader, "metallic_constant", metallic)) { - mat->setParameter("metallic", metallic); - } else { - mat->setParameter("metallic", 0.0f); - } - } - - // Roughness - try texture first, then constant - std::string roughnessTexPath; - if (getShaderTextureInput( - usdShader, "reflectionroughness_texture", roughnessTexPath)) { - std::string resolvedPath = roughnessTexPath; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + roughnessTexPath; - } - - auto sampler = importTexture(scene, resolvedPath, textureCache, true); - if (sampler) { - mat->setParameterObject("roughness", *sampler); - } else { - logWarning("[import_USD] Failed to load roughness texture: %s\n", - resolvedPath.c_str()); - } - } else { - float roughness = 0.5f; // Default to mid-range roughness - if (getShaderFloatInput( - usdShader, "reflection_roughness_constant", roughness)) { - mat->setParameter("roughness", roughness); - } else { - mat->setParameter("roughness", 0.5f); - } - } - - // Normal map - std::string normalTexPath; - if (getShaderTextureInput(usdShader, "normalmap_texture", normalTexPath)) { - std::string resolvedPath = normalTexPath; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + normalTexPath; - } - - auto sampler = importTexture(scene, resolvedPath, textureCache, true); - if (sampler) { - mat->setParameterObject("normal", *sampler); - } else { - logWarning("[import_USD] Failed to load normal texture: %s\n", - resolvedPath.c_str()); - } - } - - // Ambient Occlusion map - std::string aoTexPath; - if (getShaderTextureInput(usdShader, "ao_texture", aoTexPath)) { - std::string resolvedPath = aoTexPath; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + aoTexPath; - } - - auto sampler = importTexture(scene, resolvedPath, textureCache, true); - if (sampler) { - mat->setParameterObject("occlusion", *sampler); - } else { - logWarning("[import_USD] Failed to load occlusion texture: %s\n", - resolvedPath.c_str()); - } - } - - // Opacity - try texture first, then constant - std::string opacityTexPath; - bool enableOpacity = false; - getShaderBoolInput(usdShader, "enable_opacity", enableOpacity); - - if (enableOpacity) { - if (getShaderTextureInput(usdShader, "opacity_texture", opacityTexPath)) { - std::string resolvedPath = opacityTexPath; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + opacityTexPath; - } - - auto sampler = importTexture(scene, resolvedPath, textureCache, true); - if (sampler) { - mat->setParameterObject("opacity", *sampler); - } else { - logWarning("[import_USD] Failed to load opacity texture: %s\n", - resolvedPath.c_str()); - } - } else { - float opacityConstant = 1.0f; - if (getShaderFloatInput(usdShader, "opacity_constant", opacityConstant)) { - mat->setParameter("opacity", opacityConstant); - } - } - - // Set alpha mode based on opacity threshold - float opacityThreshold = 0.0f; - if (getShaderFloatInput(usdShader, "opacity_threshold", opacityThreshold)) { - if (opacityThreshold > 0.0f) { - mat->setParameter("alphaMode", "mask"); - mat->setParameter("alphaCutoff", opacityThreshold); - } else { - mat->setParameter("alphaMode", "blend"); - } - } else { - // Default to blend mode when opacity is enabled - mat->setParameter("alphaMode", "blend"); - } - } else { - // Fully opaque - mat->setParameter("alphaMode", "opaque"); - } - - // IOR (index of refraction) - float ior = 1.5f; - if (getShaderFloatInput(usdShader, "ior_constant", ior)) { - mat->setParameter("ior", ior); - } - - // Specular level - float specularLevel = 0.5f; - if (getShaderFloatInput(usdShader, "specular_level", specularLevel)) { - mat->setParameter("specular", specularLevel); - } - - logStatus("[import_USD] Created OmniPBR material: '%s'\n", matName.c_str()); - return mat; -} - -} // namespace tsd::io::materials - -#endif diff --git a/tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.h b/tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.h deleted file mode 100644 index fb8f88295..000000000 --- a/tsd/src/tsd/io/importers/detail/usd/OmniPbrMaterial.h +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2024-2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#if TSD_USE_USD - -#include "MaterialCommon.h" - -namespace tsd::io::materials { - -/// Import an OmniPBR material as a physicallyBased TSD material -/// -/// @param scene Scene to create material in -/// @param usdMat USD material to import from -/// @param basePath Base directory path for resolving relative texture paths -/// @param textureCache Cache for reusing loaded textures -/// @return Imported material reference -MaterialRef importOmniPBRMaterial(Scene &scene, - const pxr::UsdShadeMaterial &usdMat, - const pxr::UsdShadeShader &usdShader, - const std::string &basePath, - TextureCache &textureCache); - -} // namespace tsd::io::materials - -#endif diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp new file mode 100644 index 000000000..7f090e0d7 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -0,0 +1,177 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdAnimation.h" +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/animation/UsdGeometryFileBinding.hpp" +// usd +#include +#include +// std +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +// Beyond this a rotation between two keys is subdivided; a quarter turn keeps +// spherical interpolation faithful without densifying content that does not +// need it. +constexpr float MAX_ROTATION_STEP = float(M_PI) * 0.5f; +constexpr int MAX_DENSIFY_DEPTH = 6; + +// Every animated data source contributes the times it actually authored. TSD's +// time bases are arbitrary rather than uniform, so the only transformation is +// a rescale onto the Stage's time-code range, keeping every binding from one +// import on the same clock. +std::vector normalizeTimes( + const pxr::UsdStageRefPtr &stage, const std::vector ×) +{ + double start = stage->GetStartTimeCode(); + double end = stage->GetEndTimeCode(); + if (!(end > start)) { + start = times.front(); + end = times.back(); + } + const double span = end - start; + + std::vector retval; + retval.reserve(times.size()); + for (double t : times) { + retval.push_back(span > 0.0 ? float((t - start) / span) : 0.f); + } + return retval; +} + +// Largest angle any rotation basis vector turns through between two frames. +float rotationDelta(const tsd::math::mat4 &a, const tsd::math::mat4 &b) +{ + float retval = 0.f; + for (int axis = 0; axis < 3; ++axis) { + const auto u = tsd::math::normalize(tsd::math::to_float3(a[axis])); + const auto v = tsd::math::normalize(tsd::math::to_float3(b[axis])); + retval = std::max( + retval, std::acos(std::clamp(tsd::math::dot(u, v), -1.f, 1.f))); + } + return retval; +} + +struct TransformSampler +{ + pxr::UsdGeomXformable xformable; + + tsd::math::mat4 at(double time) const; +}; + +tsd::math::mat4 TransformSampler::at(double time) const +{ + pxr::GfMatrix4d local(1.0); + bool resetsXformStack = false; + xformable.GetLocalTransformation( + &local, &resetsXformStack, pxr::UsdTimeCode(time)); + return toTsdMat4(local); +} + +// A full turn authored with only two keys is degenerate under spherical +// interpolation: the endpoints coincide and the motion collapses. Recursively +// insert the midpoint wherever the path from a key to the interval's midpoint +// turns further than a quarter turn. +void densifyInterval(const TransformSampler &sampler, + double t0, + const tsd::math::mat4 &m0, + double t1, + const tsd::math::mat4 &m1, + int depth, + std::vector &outTimes, + std::vector &outFrames) +{ + const double tm = 0.5 * (t0 + t1); + const auto mm = sampler.at(tm); + + const bool needsSplit = depth < MAX_DENSIFY_DEPTH + && (rotationDelta(m0, mm) > MAX_ROTATION_STEP + || rotationDelta(mm, m1) > MAX_ROTATION_STEP); + + if (!needsSplit) + return; + + densifyInterval(sampler, t0, m0, tm, mm, depth + 1, outTimes, outFrames); + outTimes.push_back(tm); + outFrames.push_back(mm); + densifyInterval(sampler, tm, mm, t1, m1, depth + 1, outTimes, outFrames); +} + +} // namespace + +void addTransformAnimation( + ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node) +{ + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim) + return; + + pxr::UsdGeomXformable xformable(prim); + if (!xformable) + return; + + std::vector authoredTimes; + xformable.GetTimeSamples(&authoredTimes); + if (authoredTimes.size() < 2) + return; + + TransformSampler sampler{xformable}; + + std::vector times; + std::vector frames; + times.push_back(authoredTimes.front()); + frames.push_back(sampler.at(authoredTimes.front())); + for (size_t i = 1; i < authoredTimes.size(); ++i) { + const auto next = sampler.at(authoredTimes[i]); + densifyInterval(sampler, + authoredTimes[i - 1], + frames.back(), + authoredTimes[i], + next, + 0, + times, + frames); + times.push_back(authoredTimes[i]); + frames.push_back(next); + } + + auto &animation = ctx.animMgr.addAnimation(primPath.GetName()); + addTransformStepBinding( + animation, node, frames, normalizeTimes(ctx.stage, times)); +} + +void addDeformingGeometryAnimation( + ImportContext &ctx, const pxr::SdfPath &primPath, GeometryRef geometry) +{ + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim || !geometry) + return; + + pxr::UsdGeomPointBased pointBased(prim); + if (!pointBased) + return; + + std::vector sampleTimes; + pointBased.GetPointsAttr().GetTimeSamples(&sampleTimes); + if (sampleTimes.size() < 2) + return; + + // One eager frame is already in the Scene; the rest is pulled from the + // retained Stage on demand (ADR 0018). + auto &animation = ctx.animMgr.addAnimation(primPath.GetName()); + animation.emplaceFileBinding(&ctx.scene, + geometry.data(), + ctx.filePath, + primPath.GetString(), + std::move(sampleTimes)); +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h new file mode 100644 index 000000000..b777648ff --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h @@ -0,0 +1,22 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" + +namespace tsd::io::usd { + +// Bind a node's transform to the prim's authored sample times, decomposed into +// rotation, translation, and scale. Extra samples are inserted only across +// intervals whose rotation a two-key spherical interpolation would collapse. +void addTransformAnimation( + ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node); + +// Bind a geometry's vertex arrays to the retained Stage so that a long +// animation of a dense mesh is pulled on demand instead of held in memory +// (ADR 0018). Does nothing unless the prim's points are time-sampled. +void addDeformingGeometryAnimation( + ImportContext &ctx, const pxr::SdfPath &primPath, GeometryRef geometry); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp new file mode 100644 index 000000000..0e954b5c3 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -0,0 +1,525 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdDialect.h" +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/core/ColorMapUtil.hpp" +#include "tsd/io/animation/SpatialFieldFileBinding.hpp" +#include "tsd/io/importers.hpp" +#include "tsd/io/importers/detail/usd/UsdMaterials.h" +#include "tsd/scene/objects/Array.hpp" +// usd +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +/////////////////////////////////////////////////////////////////////////////// +// Transfer functions authored on a Stage //////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +struct VolumeTransferFunction +{ + std::vector colors; + std::vector xPointsColor; + std::vector xPoints; + std::vector opacityValues; + math::float2 domain{0.0f, 1.0f}; + float unitDistance{0.0f}; + bool hasTransferFunction{false}; +}; + +bool extractColormapFromPrim( + const pxr::UsdPrim &prim, VolumeTransferFunction &tf) +{ + auto rgbaAttr = prim.GetAttribute(pxr::TfToken("rgbaPoints")); + if (!rgbaAttr) + return false; + + pxr::VtArray rgbaPoints; + if (!rgbaAttr.Get(&rgbaPoints) || rgbaPoints.empty()) + return false; + + tf.colors.resize(rgbaPoints.size()); + for (size_t i = 0; i < rgbaPoints.size(); ++i) { + const auto &c = rgbaPoints[i]; + tf.colors[i] = math::float4(c[0], c[1], c[2], c[3]); + } + + auto readFloatArray = [&](const char *name, std::vector &out) { + if (auto attr = prim.GetAttribute(pxr::TfToken(name))) { + pxr::VtArray values; + if (attr.Get(&values)) + out.assign(values.begin(), values.end()); + } + }; + + readFloatArray("xPointsColor", tf.xPointsColor); + readFloatArray("xPoints", tf.xPoints); + readFloatArray("opacityValues", tf.opacityValues); + + if (auto attr = prim.GetAttribute(pxr::TfToken("domain"))) { + pxr::GfVec2f domain; + if (attr.Get(&domain)) + tf.domain = math::float2(domain[0], domain[1]); + } + + if (auto attr = prim.GetAttribute(pxr::TfToken("unitDistance"))) { + float unitDistance = 0.f; + if (attr.Get(&unitDistance) && unitDistance > 0.0f) + tf.unitDistance = unitDistance; + } + + tf.hasTransferFunction = true; + return true; +} + +core::TransferFunction toTransferFunction(const VolumeTransferFunction &vtf) +{ + core::TransferFunction tf; + tf.range = {vtf.domain.x, vtf.domain.y}; + + const auto &xColor = + vtf.xPointsColor.empty() ? vtf.xPoints : vtf.xPointsColor; + for (size_t i = 0; i < vtf.colors.size() && i < xColor.size(); ++i) { + tf.colorPoints.emplace_back( + xColor[i], vtf.colors[i].x, vtf.colors[i].y, vtf.colors[i].z); + } + + if (!vtf.opacityValues.empty()) { + for (size_t i = 0; i < vtf.opacityValues.size() && i < vtf.xPoints.size(); + ++i) + tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.opacityValues[i]); + } else { + for (size_t i = 0; i < vtf.colors.size() && i < vtf.xPoints.size(); ++i) + tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.colors[i].w); + } + + return tf; +} + +VolumeTransferFunction getVolumeTransferFunction(const pxr::UsdPrim &prim) +{ + VolumeTransferFunction tf; + + // Material binding chain: Material -> VolumeShader -> Colormap. + if (pxr::UsdShadeMaterialBindingAPI::CanApply(prim)) { + pxr::UsdShadeMaterialBindingAPI binding(prim); + pxr::UsdShadeMaterial usdMaterial; + + if (auto materialRel = + prim.GetRelationship(pxr::TfToken("material:binding"))) { + pxr::SdfPathVector targets; + materialRel.GetTargets(&targets); + if (!targets.empty()) { + if (auto materialPrim = prim.GetStage()->GetPrimAtPath(targets[0])) + usdMaterial = pxr::UsdShadeMaterial(materialPrim); + } + } + + if (!usdMaterial && binding) + usdMaterial = binding.ComputeBoundMaterial(); + + if (usdMaterial) { + auto volumeOutput = usdMaterial.GetOutput(pxr::TfToken("nvindex:volume")); + if (volumeOutput && volumeOutput.HasConnectedSource()) { + pxr::UsdShadeConnectableAPI source; + pxr::TfToken sourceName; + pxr::UsdShadeAttributeType sourceType; + volumeOutput.GetConnectedSource(&source, &sourceName, &sourceType); + pxr::UsdShadeShader volumeShader(source.GetPrim()); + + if (volumeShader) { + auto colormapInput = volumeShader.GetInput(pxr::TfToken("colormap")); + if (colormapInput && colormapInput.HasConnectedSource()) { + pxr::UsdShadeConnectableAPI colormapSource; + pxr::TfToken colormapSourceName; + pxr::UsdShadeAttributeType colormapSourceType; + if (colormapInput.GetConnectedSource(&colormapSource, + &colormapSourceName, + &colormapSourceType)) { + if (auto colormapPrim = colormapSource.GetPrim(); + colormapPrim && extractColormapFromPrim(colormapPrim, tf)) + return tf; + } + } + } + } + } + } + + // Child Shader prim carrying colormap attributes directly. + for (const auto &child : prim.GetChildren()) { + if (!child.IsA()) + continue; + if (extractColormapFromPrim(child, tf)) + return tf; + } + + return tf; +} + +/////////////////////////////////////////////////////////////////////////////// +// Render settings and EnSight carriers /////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +void readRenderSettings( + const pxr::UsdStageRefPtr &stage, core::DataNode &settings) +{ + for (const auto &prim : stage->Traverse()) { + if (prim.GetTypeName() != "RenderSettings") + continue; + + if (auto attr = prim.GetAttribute(pxr::TfToken("tsd:io:cutPlane"))) { + pxr::GfVec4f value; + if (attr.Get(&value)) + settings["cutPlane"] = + math::float4(value[0], value[1], value[2], value[3]); + } + + if (auto collection = pxr::UsdCollectionAPI::Get( + prim, pxr::TfToken("tsd:io:cutPlaneTarget"))) { + pxr::SdfPathVector includes; + collection.GetIncludesRel().GetTargets(&includes); + auto &targets = settings["cutPlaneTargets"]; + for (const auto &path : includes) + targets.append() = std::string(path.GetString()); + } + + break; // only the first RenderSettings prim + } +} + +bool primIsEnsightCarrier(const pxr::UsdPrim &prim) +{ + if (!prim.GetChildren()) + return false; + auto firstChild = *prim.GetChildren().begin(); + return firstChild && firstChild.GetCustomData().count("ensight") > 0; +} + +std::string ensightCaseFileOf(const pxr::UsdPrim &scopePrim) +{ + for (const auto &child : scopePrim.GetChildren()) { + for (const auto &spec : child.GetPrimStack()) { + auto customLayerData = spec->GetLayer()->GetCustomLayerData(); + auto found = customLayerData.find("ensight"); + if (found == customLayerData.end()) + continue; + const auto &dictionary = found->second.Get(); + auto caseFile = dictionary.find("caseFile"); + if (caseFile != dictionary.end()) + return caseFile->second.Get(); + } + } + return {}; +} + +// Resolve a material bound on a raw Stage prim, going through the same +// converter the resolved path uses so EnSight parts share materials with the +// rest of the import. +MaterialRef boundMaterialOf(ImportContext &ctx, const pxr::UsdPrim &prim) +{ + pxr::UsdShadeMaterialBindingAPI binding(prim); + if (!binding) + return {}; + auto usdMaterial = binding.ComputeBoundMaterial(); + if (!usdMaterial) + return {}; + + const auto key = usdMaterial.GetPath().GetString(); + if (auto found = ctx.materialCache.find(key); + found != ctx.materialCache.end()) + return found->second; + return {}; +} + +void importEnsightDataset(ImportContext &ctx, + const pxr::UsdPrim &scopePrim, + LayerNodeRef parent, + const core::DataNode &settings) +{ + const auto primName = scopePrim.GetName().GetString(); + const auto caseFile = ensightCaseFileOf(scopePrim); + if (caseFile.empty()) { + logWarning("[import_USD] EnSight scope '%s': no case file found", + primName.c_str()); + return; + } + + std::vector fields; + for (int i = 0; i < 4; ++i) { + const auto attrName = "ensight:fieldMapping:attribute" + std::to_string(i); + auto attr = scopePrim.GetAttribute(pxr::TfToken(attrName)); + if (!attr) + continue; + std::string varName; + if (attr.Get(&varName) && !varName.empty()) + fields.push_back(varName); + } + + const auto primPath = scopePrim.GetPath().GetString(); + core::DataTree datasetSettings; + const auto *targets = settings.child("cutPlaneTargets"); + const auto *cutPlane = settings.child("cutPlane"); + if (cutPlane && targets) { + for (size_t i = 0; i < targets->numChildren(); ++i) { + const auto target = targets->child(i)->getValueAs(); + if (target == primPath) { + datasetSettings.root()["cutPlane"] = cutPlane->getValue(); + datasetSettings.root().remove("cutPlaneTargets"); + break; + } else if (target.substr(0, primPath.size() + 1) == primPath + "/") { + datasetSettings.root()["cutPlane"] = cutPlane->getValue(); + datasetSettings.root()["cutPlaneTarget"].append( + target.substr(primPath.size() + 1)); + } + } + } + + auto fallbackMaterial = boundMaterialOf(ctx, scopePrim); + core::FlatMap perPartMaterials; + for (const auto &child : scopePrim.GetChildren()) { + auto childMaterial = boundMaterialOf(ctx, child); + if (childMaterial && childMaterial != fallbackMaterial) + perPartMaterials[child.GetName().GetString()] = childMaterial; + } + + auto scopeNode = ctx.scene.insertChildNode(parent, primName.c_str()); + import_ENSIGHT(ctx.scene, + ctx.animMgr, + caseFile.c_str(), + scopeNode, + fields, + datasetSettings.root(), + fallbackMaterial, + perPartMaterials, + 0); + ctx.report.convertedPrims++; +} + +} // namespace + +/////////////////////////////////////////////////////////////////////////////// +// Claim and prune //////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +std::shared_ptr claimDialectPrims(ImportContext &ctx) +{ + auto retval = std::make_shared(); + readRenderSettings(ctx.stage, retval->renderSettings.root()); + + for (const auto &prim : ctx.stage->Traverse()) { + if (primIsEnsightCarrier(prim)) { + retval->entries.push_back( + {prim.GetPath(), ClaimedPrims::Kind::ENSIGHT_DATASET}); + } + } + + return retval; +} + +pxr::HdSceneIndexBaseRefPtr pruneClaimedPrims( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const std::shared_ptr &claimed) +{ + if (!claimed || claimed->entries.empty()) + return sceneIndex; + + pxr::SdfPathVector prefixes; + for (const auto &entry : claimed->entries) + prefixes.push_back(entry.path); + + auto inputArgs = pxr::HdRetainedContainerDataSource::New( + pxr::HdsiPrefixPathPruningSceneIndexTokens->excludePathPrefixes, + pxr::HdRetainedTypedSampledDataSource::New(prefixes)); + + return pxr::HdsiPrefixPathPruningSceneIndex::New(sceneIndex, inputArgs); +} + +void importDialectPrims(ImportContext &ctx, + const std::shared_ptr &claimed, + LayerNodeRef importRoot) +{ + if (!claimed) + return; + + for (const auto &entry : claimed->entries) { + auto prim = ctx.stage->GetPrimAtPath(entry.path); + if (!prim) + continue; + switch (entry.kind) { + case ClaimedPrims::Kind::ENSIGHT_DATASET: + importEnsightDataset( + ctx, prim, importRoot, claimed->renderSettings.root()); + break; + } + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Volumes //////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +bool isVolumePrimType(const pxr::TfToken &primType) +{ + return primType == pxr::HdPrimTypeTokens->volume; +} + +bool convertVolume( + ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node) +{ + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim) + return false; + + const auto primName = primPath.GetString(); + + std::vector filePaths; + bool isVtuAsset = false; + std::optional propertyName; + + auto fieldRel = prim.GetRelationship(pxr::TfToken("field:volume")); + if (!fieldRel) + fieldRel = prim.GetRelationship(pxr::TfToken("field:density")); + + if (fieldRel) { + pxr::SdfPathVector targets; + fieldRel.GetTargets(&targets); + if (!targets.empty()) { + if (auto fieldPrim = ctx.stage->GetPrimAtPath(targets[0])) { + isVtuAsset = fieldPrim.GetTypeName() == "VTUAsset"; + if (isVtuAsset) { + if (auto attr = fieldPrim.GetAttribute(pxr::TfToken("property"))) { + std::string value; + if (attr.Get(&value)) + propertyName = std::move(value); + } + } + + if (auto filePathAttr = + fieldPrim.GetAttribute(pxr::TfToken("filePath"))) { + std::vector sampleTimes; + filePathAttr.GetTimeSamples(&sampleTimes); + + auto appendPath = [&](const pxr::SdfAssetPath &assetPath) { + auto path = assetPath.GetResolvedPath(); + if (path.empty()) + path = assetPath.GetAssetPath(); + if (!path.empty()) + filePaths.push_back(std::move(path)); + }; + + if (!sampleTimes.empty()) { + for (double t : sampleTimes) { + pxr::SdfAssetPath assetPath; + if (filePathAttr.Get(&assetPath, t)) + appendPath(assetPath); + } + } else { + pxr::SdfAssetPath assetPath; + if (filePathAttr.Get(&assetPath)) + appendPath(assetPath); + } + } + } + } + } + + if (filePaths.empty()) + return false; + + const auto &filePath = filePaths.front(); + + SpatialFieldRef field; + if (isVtuAsset) { + field = import_spatial_field( + ctx.scene, filePath.c_str(), std::move(propertyName)); + } else { + const auto extension = extensionOf(filePath); + if (extension == ".raw") + field = import_RAW(ctx.scene, filePath.c_str()); + else if (extension == ".flash") + field = import_FLASH(ctx.scene, filePath.c_str()); + else if (extension == ".nvdb" || extension == ".vdb") + field = import_NVDB(ctx.scene, filePath.c_str()); + else if (extension == ".mhd") + field = import_MHD(ctx.scene, filePath.c_str()); + else if (extension == ".vtu") + field = import_VTU(ctx.scene, filePath.c_str(), propertyName); + } + + if (!field) + return false; + + const auto tf = getVolumeTransferFunction(prim); + auto valueRange = field->computeValueRange(); + + auto [volumeNode, volume] = ctx.scene.insertNewChildObjectNode( + node, tokens::volume::transferFunction1D); + volume->setName(primName.c_str()); + volume->setParameterObject("value", *field); + + bool appliedTransferFunction = false; + if (tf.hasTransferFunction && !tf.colors.empty()) { + auto coreTF = toTransferFunction(tf); + if (!coreTF.colorPoints.empty() && !coreTF.opacityPoints.empty()) { + applyTransferFunction(ctx.scene, volume, coreTF); + if (coreTF.range.lower < coreTF.range.upper) + valueRange = math::float2(coreTF.range.lower, coreTF.range.upper); + appliedTransferFunction = true; + } + } + + if (!appliedTransferFunction) { + auto colors = makeDefaultColorMap(256); + auto colorArray = ctx.scene.createArray(ANARI_FLOAT32_VEC4, colors.size()); + colorArray->setData(colors); + volume->setParameterObject("color", *colorArray); + volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); + } + + if (auto attr = prim.GetAttribute(pxr::TfToken("anari:valueRange"))) { + pxr::GfVec2f customRange; + if (attr.Get(&customRange)) { + valueRange = math::float2(customRange[0], customRange[1]); + volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); + } + } + + float unitDistance = tf.unitDistance; + if (unitDistance <= 0.0f) { + if (auto attr = prim.GetAttribute(pxr::TfToken("anari:unitDistance"))) + attr.Get(&unitDistance); + } + if (unitDistance > 0.0f) + volume->setParameter("unitDistance", unitDistance); + + if (filePaths.size() > 1) { + auto &animation = ctx.animMgr.addAnimation(primName); + animation.emplaceFileBinding( + &ctx.scene, volume.data(), field, std::move(filePaths)); + } + + return true; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h new file mode 100644 index 000000000..944dec907 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h @@ -0,0 +1,61 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/DataTree.hpp" +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +// std +#include +#include + +namespace tsd::io::usd { + +/* + * Prims the TSD dialect owns. A pre-pass scans the raw Stage for dialect + * markers and collects the prim paths they claim; those subtrees are pruned + * from the resolved scene so the generic path never sees them, and are routed + * to the handlers that already know these formats. This is what stops an + * EnSight carrier prim -- which is a mesh prim -- from also being converted + * into meaningless geometry. + */ +struct ClaimedPrims +{ + enum class Kind + { + ENSIGHT_DATASET + }; + + struct Entry + { + pxr::SdfPath path; + Kind kind; + }; + + std::vector entries; + core::DataTree renderSettings; +}; + +// Scan the raw Stage for dialect markers. +std::shared_ptr claimDialectPrims(ImportContext &ctx); + +// Remove every Claimed Prim subtree from the resolved scene. +pxr::HdSceneIndexBaseRefPtr pruneClaimedPrims( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const std::shared_ptr &claimed); + +// Route Claimed Prims to the dialect's own importers. +void importDialectPrims(ImportContext &ctx, + const std::shared_ptr &claimed, + LayerNodeRef importRoot); + +bool isVolumePrimType(const pxr::TfToken &primType); + +// Import a UsdVol Volume prim, honouring the `anari:` value-range and +// unit-distance annotations and any transfer function authored on the Stage. +bool convertVolume( + ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp new file mode 100644 index 000000000..1214b686a --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -0,0 +1,736 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdGeometry.h" +#include "tsd/io/importers/detail/usd/UsdMaterials.h" +#include "tsd/io/importers/detail/usd/UsdSubdivision.h" +// usd +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +/////////////////////////////////////////////////////////////////////////////// +// Primvar plumbing /////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +// One primvar, already flattened out of any indexing, with the interpolation +// that decides which TSD attribute slot it lands in. +struct Primvar +{ + pxr::VtValue value; + pxr::TfToken interpolation; + pxr::TfToken role; + + bool valid() const; +}; + +bool Primvar::valid() const +{ + return !value.IsEmpty() && value.IsArrayValued() && value.GetArraySize() > 0; +} + +Primvar readPrimvar( + const pxr::HdPrimvarsSchema &primvars, const pxr::TfToken &name) +{ + Primvar retval; + auto primvar = primvars.GetPrimvar(name); + if (!primvar) + return retval; + if (auto value = primvar.GetFlattenedPrimvarValue()) + retval.value = value->GetValue(0); + if (auto interpolation = primvar.GetInterpolation()) + retval.interpolation = interpolation->GetTypedValue(0); + if (auto role = primvar.GetRole()) + retval.role = role->GetTypedValue(0); + return retval; +} + +// Which TSD parameter prefix an interpolation maps onto. Constant primvars +// have no per-element ANARI slot and are handled by the caller where they +// carry meaning (display colour), otherwise dropped. +const char *prefixForInterpolation(const pxr::TfToken &interpolation) +{ + if (interpolation == pxr::HdPrimvarSchemaTokens->uniform) + return "primitive."; + if (interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) + return "faceVarying."; + if (interpolation == pxr::HdPrimvarSchemaTokens->vertex + || interpolation == pxr::HdPrimvarSchemaTokens->varying) + return "vertex."; + return nullptr; +} + +anari::DataType anariTypeOfPrimvar(const pxr::VtValue &value) +{ + if (value.IsHolding()) + return ANARI_FLOAT32; + if (value.IsHolding()) + return ANARI_FLOAT32_VEC2; + if (value.IsHolding()) + return ANARI_FLOAT32_VEC3; + if (value.IsHolding()) + return ANARI_FLOAT32_VEC4; + return ANARI_UNKNOWN; +} + +/////////////////////////////////////////////////////////////////////////////// +// Mesh conversion //////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) +{ + return source ? source->GetTypedValue(0) : pxr::VtIntArray(); +} + +// Expand a uniform (per-face) primvar to per-triangle values using the +// triangulation's record of which coarse face each triangle came from. +template +pxr::VtArray expandUniform( + const pxr::VtArray &source, const pxr::VtIntArray &primitiveParams) +{ + pxr::VtArray retval; + retval.reserve(primitiveParams.size()); + for (int param : primitiveParams) { + const int face = pxr::HdMeshUtil::DecodeFaceIndexFromCoarseFaceParam(param); + retval.push_back(source[size_t(face) < source.size() ? size_t(face) + : source.size() - 1]); + } + return retval; +} + +pxr::VtValue expandUniformValue( + const pxr::VtValue &value, const pxr::VtIntArray &primitiveParams) +{ + if (value.IsHolding()) + return pxr::VtValue(expandUniform( + value.UncheckedGet(), primitiveParams)); + if (value.IsHolding()) + return pxr::VtValue(expandUniform( + value.UncheckedGet(), primitiveParams)); + if (value.IsHolding()) + return pxr::VtValue(expandUniform( + value.UncheckedGet(), primitiveParams)); + if (value.IsHolding()) + return pxr::VtValue(expandUniform( + value.UncheckedGet(), primitiveParams)); + return {}; +} + +// Everything one converted mesh needs to hand to the Surface builder. The +// vertex arrays are created once and shared by every material subset. +struct ConvertedMesh +{ + ArrayRef vertexPosition; + std::vector> sharedParameters; + pxr::VtVec3iArray triangleIndices; + pxr::VtIntArray primitiveParams; +}; + +} // namespace + +bool isGeometryPrimType(const pxr::TfToken &primType) +{ + return primType == pxr::HdPrimTypeTokens->mesh + || primType == pxr::HdPrimTypeTokens->points + || primType == pxr::HdPrimTypeTokens->basisCurves + || primType == pxr::HdPrimTypeTokens->sphere + || primType == pxr::HdPrimTypeTokens->cone + || primType == pxr::HdPrimTypeTokens->cylinder; +} + +namespace { + +// Bind one primvar onto a geometry, tessellating or expanding it so that it +// matches the triangulated topology. +void bindMeshPrimvar(ImportContext &ctx, + GeometryRef &geometry, + const pxr::SdfPath &primPath, + const pxr::HdMeshUtil &meshUtil, + const ConvertedMesh &mesh, + const Primvar &primvar, + const std::string &tsdName) +{ + const char *prefix = prefixForInterpolation(primvar.interpolation); + if (!prefix) + return; + + pxr::VtValue value = primvar.value; + if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) + value = expandUniformValue(value, mesh.primitiveParams); + else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) { + pxr::VtValue triangulated; + const auto result = meshUtil.ComputeTriangulatedFaceVaryingPrimvar( + pxr::HdGetValueData(primvar.value), + int(primvar.value.GetArraySize()), + pxr::HdGetValueTupleType(primvar.value).type, + &triangulated); + if (result != pxr::HdMeshComputationResult::Success) + return; + value = triangulated; + } + + const auto type = anariTypeOfPrimvar(value); + if (type == ANARI_UNKNOWN || !value.IsArrayValued() + || value.GetArraySize() == 0) + return; + + auto array = ctx.scene.createArray(type, value.GetArraySize()); + array->setData(pxr::HdGetValueData(value)); + geometry->setParameterObject(Token((prefix + tsdName).c_str()), *array); +} + +std::vector convertMesh(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform) +{ + auto meshSchema = pxr::HdMeshSchema::GetFromParent(prim.dataSource); + auto topologySchema = meshSchema.GetTopology(); + auto primvarsSchema = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + + // Resolve every primvar up front so that refinement can replace the + // vertex-interpolated ones in place. + std::map primvars; + for (const auto &name : primvarsSchema.GetPrimvarNames()) { + auto primvar = readPrimvar(primvarsSchema, name); + if (primvar.valid()) + primvars.emplace(name.GetString(), std::move(primvar)); + } + + auto lookup = [&](const std::string &name) -> Primvar { + auto found = primvars.find(name); + return found == primvars.end() ? Primvar() : found->second; + }; + + auto points = lookup(pxr::HdPrimvarsSchemaTokens->points.GetString()); + if (!points.valid() || !points.value.IsHolding()) + return {}; + + auto faceVertexCounts = intArrayOf(topologySchema.GetFaceVertexCounts()); + auto faceVertexIndices = intArrayOf(topologySchema.GetFaceVertexIndices()); + auto holeIndices = intArrayOf(topologySchema.GetHoleIndices()); + if (faceVertexCounts.empty() || faceVertexIndices.empty()) + return {}; + + auto orientationSource = topologySchema.GetOrientation(); + const auto orientation = orientationSource + ? orientationSource->GetTypedValue(0) + : pxr::HdTokens->rightHanded; + + if (meshWantsRefinement(ctx, primPath)) { + std::vector> vertexPrimvars; + for (const auto &[name, primvar] : primvars) { + if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->vertex + || primvar.interpolation == pxr::HdPrimvarSchemaTokens->varying) { + if (name != pxr::HdPrimvarsSchemaTokens->points.GetString()) + vertexPrimvars.emplace_back(name, primvar.value); + } + } + + auto refined = refineMesh(meshSchema, + faceVertexCounts, + faceVertexIndices, + holeIndices, + orientation, + points.value.UncheckedGet(), + vertexPrimvars, + ctx.options.refinementLevel); + + if (refined.valid) { + faceVertexCounts = refined.faceVertexCounts; + faceVertexIndices = refined.faceVertexIndices; + holeIndices = pxr::VtIntArray(); // holes are consumed by refinement + points.value = pxr::VtValue(refined.points); + for (auto &[name, value] : refined.vertexPrimvars) + primvars[name].value = value; + + // Primvars interpolated per face corner do not survive this refinement + // path: their topology changes with the surface. Drop them rather than + // bind values that no longer line up with the refined mesh. + for (auto it = primvars.begin(); it != primvars.end();) { + if (it->second.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying + || it->second.interpolation + == pxr::HdPrimvarSchemaTokens->uniform) { + ctx.reportSkip(primPath, + prim.primType.GetString(), + UsdSkipReason::UNSUPPORTED_PRIM_TYPE, + "primvar '" + it->first + + "' is not carried through subdivision refinement"); + it = primvars.erase(it); + } else { + ++it; + } + } + } + } + + pxr::HdMeshTopology topology(pxr::PxOsdOpenSubdivTokens->none, + orientation, + faceVertexCounts, + faceVertexIndices, + holeIndices); + + // OpenUSD's own topology-aware triangulation handles non-convex polygons + // and holes; a hand-rolled fan does not. + pxr::HdMeshUtil meshUtil(&topology, primPath); + ConvertedMesh mesh; + meshUtil.ComputeTriangleIndices(&mesh.triangleIndices, &mesh.primitiveParams); + if (mesh.triangleIndices.empty()) + return {}; + + // Resolve the bound material first: it names the UV primvar to bind. + auto materialBindings = + pxr::HdMaterialBindingsSchema::GetFromParent(prim.dataSource); + pxr::SdfPath boundMaterialPath; + if (auto binding = materialBindings.GetMaterialBinding()) { + if (auto path = binding.GetPath()) + boundMaterialPath = path->GetTypedValue(0); + } + const auto resolved = resolveMaterial(ctx, sceneIndex, boundMaterialPath); + + auto geometry = ctx.scene.createObject(tokens::geometry::triangle); + geometry->setName(primPath.GetText()); + + // Vertex positions, with Prototype-internal transforms baked in (ADR 0016). + const auto &sourcePoints = points.value.UncheckedGet(); + std::vector positions; + positions.reserve(sourcePoints.size()); + const bool bake = bakeXform != tsd::math::IDENTITY_MAT4; + for (const auto &p : sourcePoints) { + float3 v(p[0], p[1], p[2]); + if (bake) { + const auto t = tsd::math::mul(bakeXform, float4(v.x, v.y, v.z, 1.f)); + v = float3(t.x, t.y, t.z); + } + positions.push_back(v); + } + mesh.vertexPosition = + ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); + mesh.vertexPosition->setData(positions.data(), positions.size()); + geometry->setParameterObject("vertex.position", *mesh.vertexPosition); + + auto indexArray = + ctx.scene.createArray(ANARI_UINT32_VEC3, mesh.triangleIndices.size()); + indexArray->setData( + (const uint3 *)mesh.triangleIndices.data(), mesh.triangleIndices.size()); + geometry->setParameterObject("primitive.index", *indexArray); + + // Normals, UVs, display colour, then any remaining float-typed primvars in + // name order so the attribute assignment is deterministic. + const auto normals = lookup(pxr::HdPrimvarsSchemaTokens->normals.GetString()); + if (normals.valid()) + bindMeshPrimvar(ctx, geometry, primPath, meshUtil, mesh, normals, "normal"); + + const std::string uvName = + resolved.uvPrimvarName.empty() ? "st" : resolved.uvPrimvarName; + const auto uvs = lookup(uvName); + if (uvs.valid()) + bindMeshPrimvar(ctx, geometry, primPath, meshUtil, mesh, uvs, "attribute0"); + + const auto displayColor = lookup(pxr::HdTokens->displayColor.GetString()); + if (displayColor.valid() + && displayColor.interpolation != pxr::HdPrimvarSchemaTokens->constant) { + bindMeshPrimvar( + ctx, geometry, primPath, meshUtil, mesh, displayColor, "color"); + } + + int nextAttribute = 1; + for (const auto &[name, primvar] : primvars) { + if (nextAttribute > 3) + break; + if (name == pxr::HdPrimvarsSchemaTokens->points.GetString() + || name == pxr::HdPrimvarsSchemaTokens->normals.GetString() + || name == pxr::HdTokens->displayColor.GetString() + || name == pxr::HdTokens->displayOpacity.GetString() || name == uvName) + continue; + if (anariTypeOfPrimvar(primvar.value) == ANARI_UNKNOWN) + continue; + if (!prefixForInterpolation(primvar.interpolation)) + continue; + bindMeshPrimvar(ctx, + geometry, + primPath, + meshUtil, + mesh, + primvar, + "attribute" + std::to_string(nextAttribute++)); + } + + // Material // + + auto material = resolved.material; + if (!material) { + // No bound material: take colour from the display-colour primvars so that + // unmaterialed content looks as it does in a reference viewer instead of + // taking TSD's default. + material = ctx.scene.createObject(tokens::material::matte); + material->setName((primPath.GetString() + "_displayColor").c_str()); + if (displayColor.valid() + && displayColor.value.IsHolding()) { + const auto &c = displayColor.value.UncheckedGet(); + material->setParameter("color", float3(c[0][0], c[0][1], c[0][2])); + } + const auto displayOpacity = + lookup(pxr::HdTokens->displayOpacity.GetString()); + if (displayOpacity.valid() + && displayOpacity.value.IsHolding()) { + const auto &o = displayOpacity.value.UncheckedGet(); + material->setParameter("opacity", o[0]); + } + } + + // Per-face material subsets each become their own Surface over their own + // index array, sharing this mesh's vertex arrays. + std::vector retval; + std::vector subsetPaths; + for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) { + if (sceneIndex->GetPrim(childPath).primType + == pxr::HdPrimTypeTokens->geomSubset) + subsetPaths.push_back(childPath); + } + + if (subsetPaths.empty()) { + retval.push_back( + ctx.scene.createSurface(primPath.GetText(), geometry, material)); + return retval; + } + + // Map each coarse face to the triangles it produced, once, so every subset + // can select its own triangles cheaply. + std::vector> trianglesOfFace; + for (size_t i = 0; i < mesh.primitiveParams.size(); ++i) { + const int face = pxr::HdMeshUtil::DecodeFaceIndexFromCoarseFaceParam( + mesh.primitiveParams[i]); + if (face < 0) + continue; + if (trianglesOfFace.size() <= size_t(face)) + trianglesOfFace.resize(size_t(face) + 1); + trianglesOfFace[size_t(face)].push_back(uint32_t(i)); + } + + for (const auto &subsetPath : subsetPaths) { + auto subsetPrim = sceneIndex->GetPrim(subsetPath); + auto subsetSchema = + pxr::HdGeomSubsetSchema::GetFromParent(subsetPrim.dataSource); + const auto faceIndices = intArrayOf(subsetSchema.GetIndices()); + if (faceIndices.empty()) + continue; + + std::vector subsetTriangles; + for (int face : faceIndices) { + if (face < 0 || size_t(face) >= trianglesOfFace.size()) + continue; + for (uint32_t triangle : trianglesOfFace[size_t(face)]) { + const auto &t = mesh.triangleIndices[triangle]; + subsetTriangles.push_back(uint3(t[0], t[1], t[2])); + } + } + if (subsetTriangles.empty()) + continue; + + auto subsetGeometry = + ctx.scene.createObject(tokens::geometry::triangle); + subsetGeometry->setName(subsetPath.GetText()); + subsetGeometry->setParameterObject("vertex.position", *mesh.vertexPosition); + auto subsetIndex = + ctx.scene.createArray(ANARI_UINT32_VEC3, subsetTriangles.size()); + subsetIndex->setData(subsetTriangles.data(), subsetTriangles.size()); + subsetGeometry->setParameterObject("primitive.index", *subsetIndex); + + // Share every vertex-interpolated attribute the parent mesh carries. + for (const auto &name : + {"vertex.normal", "vertex.attribute0", "vertex.color"}) { + if (auto *array = geometry->parameterValueAsObject(name)) + subsetGeometry->setParameterObject(Token(name), *array); + } + + auto subsetBindings = + pxr::HdMaterialBindingsSchema::GetFromParent(subsetPrim.dataSource); + pxr::SdfPath subsetMaterialPath; + if (auto binding = subsetBindings.GetMaterialBinding()) { + if (auto path = binding.GetPath()) + subsetMaterialPath = path->GetTypedValue(0); + } + auto subsetMaterial = + resolveMaterial(ctx, sceneIndex, subsetMaterialPath).material; + + retval.push_back(ctx.scene.createSurface(subsetPath.GetText(), + subsetGeometry, + subsetMaterial ? subsetMaterial : material)); + } + + if (retval.empty()) { + retval.push_back( + ctx.scene.createSurface(primPath.GetText(), geometry, material)); + } + + return retval; +} + +/////////////////////////////////////////////////////////////////////////////// +// Points and curves ////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +std::vector bakedPositions( + const pxr::VtVec3fArray &source, const tsd::math::mat4 &bakeXform) +{ + const bool bake = bakeXform != tsd::math::IDENTITY_MAT4; + std::vector retval; + retval.reserve(source.size()); + for (const auto &p : source) { + float3 v(p[0], p[1], p[2]); + if (bake) { + const auto t = tsd::math::mul(bakeXform, float4(v.x, v.y, v.z, 1.f)); + v = float3(t.x, t.y, t.z); + } + retval.push_back(v); + } + return retval; +} + +MaterialRef materialForPrim(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::HdSceneIndexPrim &prim) +{ + auto bindings = pxr::HdMaterialBindingsSchema::GetFromParent(prim.dataSource); + pxr::SdfPath path; + if (auto binding = bindings.GetMaterialBinding()) { + if (auto p = binding.GetPath()) + path = p->GetTypedValue(0); + } + auto material = resolveMaterial(ctx, sceneIndex, path).material; + return material ? material : ctx.scene.defaultMaterial(); +} + +std::vector convertPoints(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform) +{ + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + const auto points = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); + if (!points.valid() || !points.value.IsHolding()) + return {}; + + auto geometry = ctx.scene.createObject(tokens::geometry::sphere); + geometry->setName(primPath.GetText()); + + const auto positions = + bakedPositions(points.value.UncheckedGet(), bakeXform); + auto positionArray = + ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); + positionArray->setData(positions.data(), positions.size()); + geometry->setParameterObject("vertex.position", *positionArray); + + const auto widths = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths); + if (widths.valid() && widths.value.IsHolding()) { + const auto &w = widths.value.UncheckedGet(); + std::vector radii; + radii.reserve(positions.size()); + for (size_t i = 0; i < positions.size(); ++i) + radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); + auto radiusArray = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); + radiusArray->setData(radii.data(), radii.size()); + geometry->setParameterObject("vertex.radius", *radiusArray); + } + + return {ctx.scene.createSurface( + primPath.GetText(), geometry, materialForPrim(ctx, sceneIndex, prim))}; +} + +std::vector convertCurves(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform) +{ + auto curvesSchema = pxr::HdBasisCurvesSchema::GetFromParent(prim.dataSource); + auto topologySchema = curvesSchema.GetTopology(); + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + + const auto points = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); + if (!points.valid() || !points.value.IsHolding()) + return {}; + + const auto vertexCounts = intArrayOf(topologySchema.GetCurveVertexCounts()); + if (vertexCounts.empty()) + return {}; + + auto geometry = ctx.scene.createObject(tokens::geometry::curve); + geometry->setName(primPath.GetText()); + + const auto positions = + bakedPositions(points.value.UncheckedGet(), bakeXform); + auto positionArray = + ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); + positionArray->setData(positions.data(), positions.size()); + geometry->setParameterObject("vertex.position", *positionArray); + + // A curve segment index per consecutive vertex pair within each curve. + std::vector segments; + uint32_t base = 0; + for (int count : vertexCounts) { + for (int i = 0; i + 1 < count; ++i) + segments.push_back(base + uint32_t(i)); + base += uint32_t(count); + } + if (!segments.empty()) { + auto indexArray = ctx.scene.createArray(ANARI_UINT32, segments.size()); + indexArray->setData(segments.data(), segments.size()); + geometry->setParameterObject("primitive.index", *indexArray); + } + + const auto widths = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths); + if (widths.valid() && widths.value.IsHolding()) { + const auto &w = widths.value.UncheckedGet(); + std::vector radii; + radii.reserve(positions.size()); + for (size_t i = 0; i < positions.size(); ++i) + radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); + auto radiusArray = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); + radiusArray->setData(radii.data(), radii.size()); + geometry->setParameterObject("vertex.radius", *radiusArray); + } + + return {ctx.scene.createSurface( + primPath.GetText(), geometry, materialForPrim(ctx, sceneIndex, prim))}; +} + +/////////////////////////////////////////////////////////////////////////////// +// Analytic quadrics ////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +std::vector convertQuadric(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform) +{ + auto readDouble = [](const pxr::HdDoubleDataSourceHandle &h, double alt) { + return h ? h->GetTypedValue(0) : alt; + }; + + GeometryRef geometry; + const float3 origin = [&] { + if (bakeXform == tsd::math::IDENTITY_MAT4) + return float3(0.f); + const auto t = tsd::math::mul(bakeXform, float4(0.f, 0.f, 0.f, 1.f)); + return float3(t.x, t.y, t.z); + }(); + + if (prim.primType == pxr::HdPrimTypeTokens->sphere) { + auto schema = pxr::HdSphereSchema::GetFromParent(prim.dataSource); + const float radius = float(readDouble(schema.GetRadius(), 1.0)); + geometry = ctx.scene.createObject(tokens::geometry::sphere); + auto positions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, 1); + positions->setData(&origin, 1); + geometry->setParameterObject("vertex.position", *positions); + geometry->setParameter("radius", radius); + } else if (prim.primType == pxr::HdPrimTypeTokens->cone + || prim.primType == pxr::HdPrimTypeTokens->cylinder) { + const bool isCone = prim.primType == pxr::HdPrimTypeTokens->cone; + double height = 2.0; + double radius = 1.0; + pxr::TfToken axis = pxr::HdConeSchemaTokens->Z; + if (isCone) { + auto schema = pxr::HdConeSchema::GetFromParent(prim.dataSource); + height = readDouble(schema.GetHeight(), height); + radius = readDouble(schema.GetRadius(), radius); + if (auto a = schema.GetAxis()) + axis = a->GetTypedValue(0); + } else { + auto schema = pxr::HdCylinderSchema::GetFromParent(prim.dataSource); + height = readDouble(schema.GetHeight(), height); + radius = readDouble(schema.GetRadius(), radius); + if (auto a = schema.GetAxis()) + axis = a->GetTypedValue(0); + } + + // Fold the shape's spine axis into the emitted endpoints so that the + // shape stays analytic rather than becoming a mesh. + float3 spine(0.f, 0.f, 1.f); + if (axis == pxr::HdConeSchemaTokens->X) + spine = float3(1.f, 0.f, 0.f); + else if (axis == pxr::HdConeSchemaTokens->Y) + spine = float3(0.f, 1.f, 0.f); + + const float half = float(height) * 0.5f; + float3 endpoints[2] = {origin - spine * half, origin + spine * half}; + if (bakeXform != tsd::math::IDENTITY_MAT4) { + for (auto &e : endpoints) { + const auto t = tsd::math::mul(bakeXform, float4(e.x, e.y, e.z, 1.f)); + e = float3(t.x, t.y, t.z); + } + } + + geometry = ctx.scene.createObject( + isCone ? tokens::geometry::cone : tokens::geometry::cylinder); + auto positions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, 2); + positions->setData(endpoints, 2); + geometry->setParameterObject("vertex.position", *positions); + if (isCone) { + const float radii[2] = {float(radius), 0.f}; + auto radiusArray = ctx.scene.createArray(ANARI_FLOAT32, 2); + radiusArray->setData(radii, 2); + geometry->setParameterObject("vertex.radius", *radiusArray); + } else { + geometry->setParameter("radius", float(radius)); + } + } + + if (!geometry) + return {}; + + geometry->setName(primPath.GetText()); + return {ctx.scene.createSurface( + primPath.GetText(), geometry, materialForPrim(ctx, sceneIndex, prim))}; +} + +} // namespace + +std::vector convertGeometry(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform) +{ + if (prim.primType == pxr::HdPrimTypeTokens->mesh) + return convertMesh(ctx, sceneIndex, primPath, prim, bakeXform); + if (prim.primType == pxr::HdPrimTypeTokens->points) + return convertPoints(ctx, sceneIndex, primPath, prim, bakeXform); + if (prim.primType == pxr::HdPrimTypeTokens->basisCurves) + return convertCurves(ctx, sceneIndex, primPath, prim, bakeXform); + return convertQuadric(ctx, sceneIndex, primPath, prim, bakeXform); +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h new file mode 100644 index 000000000..f229b78b4 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h @@ -0,0 +1,27 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +// std +#include + +namespace tsd::io::usd { + +// True for the resolved prim types this converter turns into TSD geometry. +bool isGeometryPrimType(const pxr::TfToken &primType); + +// Convert one resolved gprim into TSD Surfaces. A mesh carrying per-face +// material subsets yields several Surfaces, one per subset, sharing the mesh's +// vertex arrays. `bakeXform` is baked into the emitted vertex data and is the +// identity for everything but Prototype-internal geometry (ADR 0016). +std::vector convertGeometry(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h new file mode 100644 index 000000000..3f4e7c6ce --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -0,0 +1,93 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/Logging.hpp" +#include "tsd/core/TSDMath.hpp" +#include "tsd/io/UsdImport.hpp" +#include "tsd/io/importers/detail/importer_common.hpp" +#include "tsd/scene/Scene.hpp" +// usd +#include +#include +#include +// std +#include +#include + +namespace tsd::animation { +struct AnimationManager; +} // namespace tsd::animation + +namespace tsd::io::usd { + +using namespace tsd::scene; + +/* + * Everything one USD Stage import needs to carry between converters: the + * target Scene, the settings driving the import, the report being accumulated, + * and the Stage itself, which is retained so that data OpenUSD does not model + * -- the `anari:` and `tsd:io:` attribute vocabularies, carrier metadata -- + * can be read directly from prims by path. + * + * Example: + * ImportContext ctx{scene, animMgr, options, report, stage, filename}; + * ctx.reportSkip(primPath, "cylinderLight", + * UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + */ +struct ImportContext +{ + Scene &scene; + tsd::animation::AnimationManager &animMgr; + const UsdImportOptions &options; + UsdImportReport &report; + pxr::UsdStageRefPtr stage; + std::string filePath; + std::string basePath; + + // The time everything static is read at. Deliberately not + // UsdTimeCode::Default(), at which values authored only as time samples do + // not resolve at all. + pxr::UsdTimeCode importTime{pxr::UsdTimeCode::EarliestTime()}; + + // Caches keyed by resolved prim path, so shared content converts once. + TextureCache textureCache; + std::unordered_map materialCache; + std::unordered_map uvPrimvarCache; + + void reportSkip(const pxr::SdfPath &primPath, + const std::string &primType, + UsdSkipReason reason, + const std::string &detail = ""); +}; + +// Small conversions shared by every converter ///////////////////////////////// + +tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m); + +// Inlined definitions //////////////////////////////////////////////////////// + +inline void ImportContext::reportSkip(const pxr::SdfPath &primPath, + const std::string &primType, + UsdSkipReason reason, + const std::string &detail) +{ + report.skipped.push_back({primPath.GetString(), primType, reason, detail}); + core::logStatus("[import_USD] %s: %s%s%s", + primPath.GetText(), + toString(reason), + detail.empty() ? "" : " -- ", + detail.c_str()); +} + +inline tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m) +{ + tsd::math::mat4 retval; + for (int i = 0; i < 4; ++i) + for (int j = 0; j < 4; ++j) + retval[i][j] = static_cast(m[i][j]); + return retval; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp new file mode 100644 index 000000000..b8a68a74f --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -0,0 +1,384 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdInstancing.h" +#include "tsd/io/importers/detail/usd/UsdGeometry.h" +// usd +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +const char *NATIVE_INSTANCING_ROOT = "/UsdNiPropagatedPrototypes"; + +tsd::math::mat4 flattenedXformOf( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath) +{ + auto prim = sceneIndex->GetPrim(primPath); + auto xform = pxr::HdXformSchema::GetFromParent(prim.dataSource); + if (!xform) + return tsd::math::IDENTITY_MAT4; + auto matrix = xform.GetMatrix(); + return matrix ? toTsdMat4(matrix->GetTypedValue(0)) + : tsd::math::IDENTITY_MAT4; +} + +// The Stage path a resolved prototype prim came from, which is where its +// authored animation lives. +pxr::SdfPath originOf(const pxr::HdSceneIndexPrim &prim) +{ + auto origin = pxr::HdPrimOriginSchema::GetFromParent(prim.dataSource); + if (!origin) + return {}; + return origin.GetOriginPath(pxr::HdPrimOriginSchemaTokens->scenePath); +} + +bool subtreeHasAnimatedTransforms(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &root) +{ + for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { + const auto origin = originOf(sceneIndex->GetPrim(path)); + if (origin.IsEmpty()) + continue; + auto prim = ctx.stage->GetPrimAtPath(origin); + if (!prim) + continue; + pxr::UsdGeomXformable xformable(prim); + if (!xformable) + continue; + std::vector times; + xformable.GetTimeSamples(×); + if (times.size() > 1) + return true; + } + return false; +} + +std::shared_ptr convertPrototype(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &prototypeRoot, + InstancerRegistry ®istry) +{ + const auto key = prototypeRoot.GetString(); + if (auto found = registry.prototypes.find(key); + found != registry.prototypes.end()) + return found->second; + + auto content = std::make_shared(); + content->internalTransformsAnimated = + subtreeHasAnimatedTransforms(ctx, sceneIndex, prototypeRoot); + + if (!content->internalTransformsAnimated) { + const auto rootXform = flattenedXformOf(sceneIndex, prototypeRoot); + const auto inverseRoot = tsd::math::inverse(rootXform); + for (const pxr::SdfPath &path : + pxr::HdSceneIndexPrimView(sceneIndex, prototypeRoot)) { + auto prim = sceneIndex->GetPrim(path); + if (!isGeometryPrimType(prim.primType)) + continue; + const auto bake = + tsd::math::mul(inverseRoot, flattenedXformOf(sceneIndex, path)); + for (auto &surface : convertGeometry(ctx, sceneIndex, path, prim, bake)) + content->surfaces.push_back(surface); + } + ctx.report.convertedPrims += content->surfaces.size(); + } + + registry.prototypes[key] = content; + return content; +} + +// Expanded fallback for Prototypes whose internal transforms are animated and +// so cannot be baked: mirror the Prototype subtree beneath each placement, +// still sharing the converted objects. +void expandPrototype(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &prototypeRoot, + const pxr::SdfPath &primPath, + const tsd::math::mat4 &parentXform, + LayerNodeRef parent) +{ + auto prim = sceneIndex->GetPrim(primPath); + const auto flattened = flattenedXformOf(sceneIndex, primPath); + const auto local = tsd::math::mul(tsd::math::inverse(parentXform), flattened); + + auto node = ctx.scene.insertChildTransformNode( + parent, local, primPath.GetName().c_str()); + + if (isGeometryPrimType(prim.primType)) { + for (auto &surface : convertGeometry( + ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4)) + ctx.scene.insertChildObjectNode(node, surface, surface->name().c_str()); + } + + for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) + expandPrototype(ctx, sceneIndex, prototypeRoot, childPath, flattened, node); +} + +// The per-instance transforms an instancer carries, either directly as +// matrices or composed from translate/rotate/scale primvars. +std::vector readInstanceTransforms( + const pxr::HdSceneIndexPrim &prim, size_t instanceCount) +{ + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + + auto valueOf = [&](const pxr::TfToken &name) { + auto primvar = primvars.GetPrimvar(name); + if (!primvar) + return pxr::VtValue(); + auto source = primvar.GetPrimvarValue(); + return source ? source->GetValue(0) : pxr::VtValue(); + }; + + std::vector retval(instanceCount, tsd::math::IDENTITY_MAT4); + + const auto transforms = valueOf(pxr::HdInstancerTokens->instanceTransforms); + if (transforms.IsHolding()) { + const auto &m = transforms.UncheckedGet(); + for (size_t i = 0; i < retval.size() && i < m.size(); ++i) + retval[i] = toTsdMat4(m[i]); + return retval; + } + + const auto translations = + valueOf(pxr::HdInstancerTokens->instanceTranslations); + const auto rotations = valueOf(pxr::HdInstancerTokens->instanceRotations); + const auto scales = valueOf(pxr::HdInstancerTokens->instanceScales); + + for (size_t i = 0; i < retval.size(); ++i) { + auto transform = tsd::math::IDENTITY_MAT4; + + if (scales.IsHolding()) { + const auto &s = scales.UncheckedGet(); + if (i < s.size()) { + transform = tsd::math::mul( + tsd::math::scaling_matrix(float3(s[i][0], s[i][1], s[i][2])), + transform); + } + } + + auto applyRotation = [&](float x, float y, float z, float w) { + transform = tsd::math::mul( + tsd::math::rotation_matrix(tsd::math::float4(x, y, z, w)), transform); + }; + if (rotations.IsHolding()) { + const auto &r = rotations.UncheckedGet(); + if (i < r.size()) { + const auto imaginary = r[i].GetImaginary(); + applyRotation(float(imaginary[0]), + float(imaginary[1]), + float(imaginary[2]), + float(r[i].GetReal())); + } + } else if (rotations.IsHolding()) { + const auto &r = rotations.UncheckedGet(); + if (i < r.size()) { + const auto imaginary = r[i].GetImaginary(); + applyRotation(imaginary[0], imaginary[1], imaginary[2], r[i].GetReal()); + } + } + + if (translations.IsHolding()) { + const auto &t = translations.UncheckedGet(); + if (i < t.size()) { + transform = tsd::math::mul( + tsd::math::translation_matrix(float3(t[i][0], t[i][1], t[i][2])), + transform); + } + } + + retval[i] = transform; + } + + return retval; +} + +struct InstancerTopology +{ + pxr::VtArray prototypes; + pxr::VtArray instanceLocations; + std::vector instanceIndices; + pxr::VtBoolArray mask; +}; + +InstancerTopology readTopology(const pxr::HdSceneIndexPrim &prim) +{ + InstancerTopology retval; + auto schema = pxr::HdInstancerTopologySchema::GetFromParent(prim.dataSource); + if (!schema) + return retval; + + if (auto prototypes = schema.GetPrototypes()) + retval.prototypes = prototypes->GetTypedValue(0); + if (auto locations = schema.GetInstanceLocations()) + retval.instanceLocations = locations->GetTypedValue(0); + if (auto mask = schema.GetMask()) + retval.mask = mask->GetTypedValue(0); + + auto indices = schema.GetInstanceIndices(); + for (size_t i = 0; i < indices.GetNumElements(); ++i) { + auto element = indices.GetElement(i); + retval.instanceIndices.push_back( + element ? element->GetTypedValue(0) : pxr::VtIntArray()); + } + return retval; +} + +bool instanceIsVisible(const InstancerTopology &topology, int index) +{ + if (topology.mask.empty()) + return true; + return size_t(index) >= topology.mask.size() || topology.mask[size_t(index)]; +} + +} // namespace + +void convertInstancer(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + LayerNodeRef node, + InstancerRegistry ®istry) +{ + const auto topology = readTopology(prim); + if (topology.prototypes.empty()) + return; + + // Native instancing is resolved against each USD Instance's own node after + // the hierarchy has been mirrored. + if (!topology.instanceLocations.empty()) + return; + + const auto transforms = readInstanceTransforms(prim, [&] { + size_t count = 0; + for (const auto &indices : topology.instanceIndices) { + for (int i : indices) + count = std::max(count, size_t(i) + 1); + } + return count; + }()); + + for (size_t protoIndex = 0; protoIndex < topology.prototypes.size(); + ++protoIndex) { + auto content = convertPrototype( + ctx, sceneIndex, topology.prototypes[protoIndex], registry); + + const auto &indices = protoIndex < topology.instanceIndices.size() + ? topology.instanceIndices[protoIndex] + : pxr::VtIntArray(); + + // Placements USD marks invisible are omitted rather than emitted hidden. + std::vector placements; + for (int index : indices) { + if (!instanceIsVisible(topology, index)) + continue; + if (size_t(index) < transforms.size()) + placements.push_back(transforms[size_t(index)]); + } + if (placements.empty()) + continue; + + if (content->internalTransformsAnimated) { + for (size_t i = 0; i < placements.size(); ++i) { + auto placementNode = ctx.scene.insertChildTransformNode(node, + placements[i], + (primPath.GetName() + "_" + std::to_string(i)).c_str()); + expandPrototype(ctx, + sceneIndex, + topology.prototypes[protoIndex], + topology.prototypes[protoIndex], + flattenedXformOf(sceneIndex, topology.prototypes[protoIndex]), + placementNode); + } + continue; + } + + // One transform-array node so hardware instancing is used rather than + // thousands of individual nodes. Its children must be object nodes: the + // render index does not push a transform-array node's matrices onto the + // transform stack, which is why Prototype geometry is baked (ADR 0016). + auto transformArray = + ctx.scene.createArray(ANARI_FLOAT32_MAT4, placements.size()); + transformArray->setData(placements.data(), placements.size()); + transformArray->setName((primPath.GetString() + "_transforms").c_str()); + + auto arrayNode = ctx.scene.insertChildTransformArrayNode( + node, transformArray.data(), primPath.GetName().c_str()); + for (auto &surface : content->surfaces) + ctx.scene.insertChildObjectNode( + arrayNode, surface, surface->name().c_str()); + } +} + +void attachNativeInstances(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + InstancerRegistry ®istry, + LayerNodeRef importRoot) +{ + const pxr::SdfPath root(NATIVE_INSTANCING_ROOT); + if (sceneIndex->GetPrim(root).dataSource == nullptr + && sceneIndex->GetChildPrimPaths(root).empty()) + return; + + for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { + auto prim = sceneIndex->GetPrim(path); + if (prim.primType != pxr::HdPrimTypeTokens->instancer) + continue; + + const auto topology = readTopology(prim); + if (topology.prototypes.empty() || topology.instanceLocations.empty()) + continue; + + auto content = + convertPrototype(ctx, sceneIndex, topology.prototypes[0], registry); + + const auto &indices = topology.instanceIndices.empty() + ? pxr::VtIntArray() + : topology.instanceIndices[0]; + + for (size_t i = 0; i < topology.instanceLocations.size(); ++i) { + const int index = size_t(i) < indices.size() ? indices[i] : int(i); + if (!instanceIsVisible(topology, index)) + continue; + + // Each USD Instance becomes one node referencing the same shared + // objects, so editing the Prototype's material affects every placement + // as it does in USD. + const auto locationKey = topology.instanceLocations[i].GetString(); + auto found = registry.nodeForPrimPath.find(locationKey); + auto placementNode = + found != registry.nodeForPrimPath.end() ? found->second : importRoot; + + if (content->internalTransformsAnimated) { + expandPrototype(ctx, + sceneIndex, + topology.prototypes[0], + topology.prototypes[0], + flattenedXformOf(sceneIndex, topology.prototypes[0]), + placementNode); + } else { + for (auto &surface : content->surfaces) { + ctx.scene.insertChildObjectNode( + placementNode, surface, surface->name().c_str()); + } + } + } + } +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h new file mode 100644 index 000000000..c6485940c --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -0,0 +1,56 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +// std +#include +#include +#include +#include + +namespace tsd::io::usd { + +// One Prototype converted once, as a flat set of Surfaces with each gprim's +// Prototype-root-relative transform baked into its vertex data (ADR 0016). +// Empty `surfaces` with `internalTransformsAnimated` set means the Prototype +// could not be baked and must be expanded per placement instead. +struct PrototypeContent +{ + std::vector surfaces; + bool internalTransformsAnimated{false}; +}; + +/* + * State shared between the mirrored-hierarchy traversal and the instancing + * pass: where each USD prim landed in the Layer, and every Prototype converted + * so far. Native-instance placements are attached after the traversal, because + * their instancer lives outside the mirrored hierarchy. + */ +struct InstancerRegistry +{ + std::unordered_map nodeForPrimPath; + std::unordered_map> prototypes; +}; + +// Turn one resolved instancer prim into instancing Layer content beneath +// `node`. Point instancers become a single transform-array node; native +// instancers are deferred to attachNativeInstances(). +void convertInstancer(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + LayerNodeRef node, + InstancerRegistry ®istry); + +// Attach each USD Instance's shared Prototype objects at the placement's own +// node in the mirrored hierarchy. +void attachNativeInstances(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + InstancerRegistry ®istry, + LayerNodeRef importRoot); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp new file mode 100644 index 000000000..61a413d4c --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -0,0 +1,478 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdLights.h" +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/importers/detail/HDRImage.h" +// usd +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +// Planckian-locus approximation, used to tint a light by its colour +// temperature the way a reference viewer does. +// https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html +float3 kelvinToRGB(float kelvin) +{ + const float temp = kelvin / 100.0f; + float red = 1.f; + float green = 1.f; + float blue = 1.f; + + if (temp > 66.0f) { + red = std::clamp( + 329.698727446f * std::pow(temp - 60.0f, -0.1332047592f) / 255.0f, + 0.0f, + 1.0f); + green = std::clamp( + 288.1221695283f * std::pow(temp - 60.0f, -0.0755148492f) / 255.0f, + 0.0f, + 1.0f); + } else { + green = + std::clamp((99.4708025861f * std::log(temp) - 161.1195681661f) / 255.0f, + 0.0f, + 1.0f); + blue = temp <= 19.0f + ? 0.0f + : std::clamp( + (138.5177312231f * std::log(temp - 10.0f) - 305.0447927307f) + / 255.0f, + 0.0f, + 1.0f); + } + + return float3(red, green, blue); +} + +// An imported Stage is systematically the wrong brightness unless exposure, +// normalization, and colour temperature all reach the emitted light. +struct LightRadiometry +{ + float3 color{1.f, 1.f, 1.f}; + float intensity{1.f}; +}; + +LightRadiometry readRadiometry(const pxr::UsdPrim &prim, float area) +{ + LightRadiometry retval; + pxr::UsdLuxLightAPI light(prim); + if (!light) + return retval; + + float intensity = 1.f; + light.GetIntensityAttr().Get(&intensity); + + float exposure = 0.f; + light.GetExposureAttr().Get(&exposure); + intensity *= std::pow(2.0f, exposure); + + bool normalize = false; + light.GetNormalizeAttr().Get(&normalize); + if (normalize && area > 0.f) + intensity /= area; + + pxr::GfVec3f color(1.f); + light.GetColorAttr().Get(&color); + retval.color = float3(color[0], color[1], color[2]); + + bool enableColorTemperature = false; + light.GetEnableColorTemperatureAttr().Get(&enableColorTemperature); + if (enableColorTemperature) { + float colorTemperature = 6500.f; + light.GetColorTemperatureAttr().Get(&colorTemperature); + if (colorTemperature > 0.f) + retval.color *= kelvinToRGB(colorTemperature); + } + + retval.intensity = intensity; + return retval; +} + +// A sphere or disk light carrying shaping attributes is a spot light; USD +// expresses the cone as a half-angle plus a softness fraction. +bool readShaping( + const pxr::UsdPrim &prim, float *openingAngle, float *falloffAngle) +{ + pxr::UsdLuxShapingAPI shaping(prim); + if (!shaping || !prim.HasAPI()) + return false; + + auto coneAngleAttr = shaping.GetShapingConeAngleAttr(); + if (!coneAngleAttr || !coneAngleAttr.HasAuthoredValue()) + return false; + + float coneAngle = 90.f; + coneAngleAttr.Get(&coneAngle); + float softness = 0.f; + if (auto softnessAttr = shaping.GetShapingConeSoftnessAttr()) + softnessAttr.Get(&softness); + + *openingAngle = 2.f * coneAngle * float(M_PI) / 180.f; + *falloffAngle = std::clamp(softness, 0.f, 1.f) * 0.5f * *openingAngle; + return true; +} + +ArrayRef readDomeRadiance(ImportContext &ctx, + const pxr::UsdLuxDomeLight &domeLight, + const LightRadiometry &radiometry) +{ + pxr::SdfAssetPath textureAsset; + if (!domeLight.GetTextureFileAttr().Get(&textureAsset)) + return {}; + + auto file = textureAsset.GetResolvedPath(); + if (file.empty()) + file = textureAsset.GetAssetPath(); + if (file.empty()) + return {}; + if (!isAbsolute(file)) + file = ctx.basePath + file; + + HDRImage image; + if (!image.import(file)) { + ctx.reportSkip(domeLight.GetPrim().GetPath(), + "domeLight", + UsdSkipReason::TEXTURE_LOAD_FAILED, + file); + return {}; + } + + std::vector rgb(size_t(image.width) * image.height); + if (image.numComponents == 3) { + std::memcpy(rgb.data(), image.pixel.data(), sizeof(rgb[0]) * rgb.size()); + } else if (image.numComponents == 4) { + for (size_t i = 0; i < image.pixel.size(); i += 4) + rgb[i / 4] = + float3(image.pixel[i], image.pixel[i + 1], image.pixel[i + 2]); + } else { + return {}; + } + + for (auto &texel : rgb) + texel *= radiometry.color; + + auto retval = + ctx.scene.createArray(ANARI_FLOAT32_VEC3, image.width, image.height); + retval->setData(rgb.data()); + return retval; +} + +} // namespace + +bool isLightPrimType(const pxr::TfToken &primType) +{ + return pxr::HdPrimTypeIsLight(primType); +} + +LightRef convertLight(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim) +{ + // Light radiometry is modelled by UsdLux itself, so it is read from the + // retained Stage rather than re-derived from the resolved prim. + auto usdPrim = ctx.stage->GetPrimAtPath(primPath); + if (!usdPrim) { + ctx.reportSkip(primPath, + prim.primType.GetString(), + UsdSkipReason::UNSUPPORTED_LIGHT_TYPE, + "light has no Stage prim (instanced lights are not supported)"); + return {}; + } + + const auto &type = prim.primType; + + if (type == pxr::HdPrimTypeTokens->distantLight) { + const auto radiometry = readRadiometry(usdPrim, 0.f); + auto light = ctx.scene.createObject(tokens::light::directional); + light->setName(primPath.GetName().c_str()); + light->setParameter("color", radiometry.color); + light->setParameter("irradiance", radiometry.intensity); + return light; + } + + if (type == pxr::HdPrimTypeTokens->rectLight) { + pxr::UsdLuxRectLight rectLight(usdPrim); + float width = 1.f; + float height = 1.f; + rectLight.GetWidthAttr().Get(&width); + rectLight.GetHeightAttr().Get(&height); + const auto radiometry = readRadiometry(usdPrim, width * height); + + auto light = ctx.scene.createObject(tokens::light::quad); + light->setName(primPath.GetName().c_str()); + light->setParameter("color", radiometry.color); + light->setParameter("intensity", radiometry.intensity); + light->setParameter("position", float3(-0.5f * width, -0.5f * height, 0.f)); + light->setParameter("edge1", float3(width, 0.f, 0.f)); + light->setParameter("edge2", float3(0.f, height, 0.f)); + return light; + } + + if (type == pxr::HdPrimTypeTokens->sphereLight + || type == pxr::HdPrimTypeTokens->diskLight) { + const bool isDisk = type == pxr::HdPrimTypeTokens->diskLight; + float radius = 0.5f; + if (isDisk) + pxr::UsdLuxDiskLight(usdPrim).GetRadiusAttr().Get(&radius); + else + pxr::UsdLuxSphereLight(usdPrim).GetRadiusAttr().Get(&radius); + + const float area = isDisk ? float(M_PI) * radius * radius + : 4.f * float(M_PI) * radius * radius; + const auto radiometry = readRadiometry(usdPrim, area); + + float openingAngle = 0.f; + float falloffAngle = 0.f; + if (readShaping(usdPrim, &openingAngle, &falloffAngle)) { + auto light = ctx.scene.createObject(tokens::light::spot); + light->setName(primPath.GetName().c_str()); + light->setParameter("color", radiometry.color); + light->setParameter("intensity", radiometry.intensity); + light->setParameter("openingAngle", openingAngle); + light->setParameter("falloffAngle", falloffAngle); + return light; + } + + auto light = ctx.scene.createObject( + isDisk ? tokens::light::ring : tokens::light::point); + light->setName(primPath.GetName().c_str()); + light->setParameter("color", radiometry.color); + light->setParameter("intensity", radiometry.intensity); + light->setParameter("radius", radius); + return light; + } + + if (type == pxr::HdPrimTypeTokens->domeLight) { + pxr::UsdLuxDomeLight domeLight(usdPrim); + const auto radiometry = readRadiometry(usdPrim, 0.f); + + auto light = ctx.scene.createObject(tokens::light::hdri); + light->setName(primPath.GetName().c_str()); + light->setParameter("scale", radiometry.intensity); + + // Dome orientation is baked into the light's own direction and up rather + // than left to a transform, because devices mishandle transformed dome + // lights (and no corrective root transform is inserted for up-axis). + pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + auto worldXform = xformCache.GetLocalToWorldTransform(usdPrim); + auto orientation = pxr::GfMatrix4d( + // clang-format off + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + // clang-format on + ); + orientation *= worldXform; + const auto direction = orientation.TransformDir(pxr::GfVec3d(0, 0, -1)); + const auto up = orientation.TransformDir(pxr::GfVec3d(0, 1, 0)); + light->setParameter( + "direction", float3(direction[0], direction[1], direction[2])); + light->setParameter("up", float3(up[0], up[1], up[2])); + + auto radiance = readDomeRadiance(ctx, domeLight, radiometry); + if (!radiance) { + // Devices require radiance to be set; synthesize a constant environment + // from the light's own colour so an untextured dome still lights. + const float3 solid = radiometry.color * radiometry.intensity; + radiance = ctx.scene.createArray(ANARI_FLOAT32_VEC3, 1, 1); + radiance->setData(&solid, 1); + } + light->setParameterObject("radiance", *radiance); + return light; + } + + ctx.reportSkip( + primPath, type.GetString(), UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + return {}; +} + +/////////////////////////////////////////////////////////////////////////////// +// Cameras //////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +void convertCamera(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim) +{ + auto usdPrim = ctx.stage->GetPrimAtPath(primPath); + if (!usdPrim) + return; + + pxr::UsdGeomCamera usdCamera(usdPrim); + if (!usdCamera) + return; + + const auto name = primPath.GetName(); + const auto defaultCamera = usdCamera.GetCamera(pxr::UsdTimeCode::Default()); + const bool isPerspective = + defaultCamera.GetProjection() == pxr::GfCamera::Perspective; + + auto camera = ctx.scene.createObject(isPerspective + ? tokens::camera::perspective + : tokens::camera::orthographic); + camera->setName(name.c_str()); + + auto setIntrinsics = [&](const pxr::GfCamera &gfCamera) { + const float verticalAperture = gfCamera.GetVerticalAperture(); + const float horizontalAperture = gfCamera.GetHorizontalAperture(); + const float aspect = + verticalAperture > 0.f ? horizontalAperture / verticalAperture : 1.f; + if (isPerspective) { + const float focalLength = gfCamera.GetFocalLength(); + camera->setParameter("fovy", + focalLength > 0.f + ? 2.f * std::atan(verticalAperture / (2.f * focalLength)) + : 1.f); + } else { + camera->setParameter("height", verticalAperture); + } + camera->setParameter("aspect", aspect); + }; + setIntrinsics(defaultCamera); + + auto poseAt = [&](pxr::UsdGeomXformCache &cache) { + const auto xform = cache.GetLocalToWorldTransform(usdPrim); + auto position = xform.Transform(pxr::GfVec3d(0, 0, 0)); + auto direction = xform.TransformDir(pxr::GfVec3d(0, 0, -1)).GetNormalized(); + auto up = xform.TransformDir(pxr::GfVec3d(0, 1, 0)).GetNormalized(); + return std::make_tuple(float3(position[0], position[1], position[2]), + float3(direction[0], direction[1], direction[2]), + float3(up[0], up[1], up[2])); + }; + + { + pxr::UsdGeomXformCache cache(pxr::UsdTimeCode::Default()); + auto [position, direction, up] = poseAt(cache); + camera->setParameter("position", position); + camera->setParameter("direction", direction); + camera->setParameter("up", up); + } + + // Animation is captured at the times actually authored -- anywhere in the + // rig above the camera, so orbit and crane rigs animate even when the camera + // prim itself is static. + std::vector sampleTimes; + for (auto current = usdPrim; current && !current.IsPseudoRoot(); + current = current.GetParent()) { + pxr::UsdGeomXformable xformable(current); + if (!xformable) + continue; + std::vector times; + xformable.GetTimeSamples(×); + sampleTimes.insert(sampleTimes.end(), times.begin(), times.end()); + } + + std::vector intrinsicTimes; + for (auto attribute : {usdCamera.GetFocalLengthAttr(), + usdCamera.GetHorizontalApertureAttr(), + usdCamera.GetVerticalApertureAttr(), + usdCamera.GetFStopAttr(), + usdCamera.GetFocusDistanceAttr()}) { + std::vector times; + if (attribute) + attribute.GetTimeSamples(×); + intrinsicTimes.insert(intrinsicTimes.end(), times.begin(), times.end()); + } + const bool hasIntrinsicAnimation = !intrinsicTimes.empty(); + sampleTimes.insert( + sampleTimes.end(), intrinsicTimes.begin(), intrinsicTimes.end()); + + std::sort(sampleTimes.begin(), sampleTimes.end()); + sampleTimes.erase( + std::unique(sampleTimes.begin(), sampleTimes.end()), sampleTimes.end()); + if (sampleTimes.size() < 2) + return; + + const size_t frameCount = sampleTimes.size(); + auto positions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, frameCount); + auto directions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, frameCount); + auto ups = ctx.scene.createArray(ANARI_FLOAT32_VEC3, frameCount); + + ArrayRef fovs, aspects; + if (hasIntrinsicAnimation) { + fovs = ctx.scene.createArray(ANARI_FLOAT32, frameCount); + aspects = ctx.scene.createArray(ANARI_FLOAT32, frameCount); + } + + auto *positionData = positions->mapAs(); + auto *directionData = directions->mapAs(); + auto *upData = ups->mapAs(); + float *fovData = fovs ? fovs->mapAs() : nullptr; + float *aspectData = aspects ? aspects->mapAs() : nullptr; + + pxr::UsdGeomXformCache cache; + for (size_t i = 0; i < frameCount; ++i) { + const pxr::UsdTimeCode time(sampleTimes[i]); + cache.SetTime(time); + auto [position, direction, up] = poseAt(cache); + positionData[i] = position; + directionData[i] = direction; + upData[i] = up; + if (fovData) { + const auto gfCamera = usdCamera.GetCamera(time); + const float verticalAperture = gfCamera.GetVerticalAperture(); + const float focalLength = gfCamera.GetFocalLength(); + fovData[i] = focalLength > 0.f + ? 2.f * std::atan(verticalAperture / (2.f * focalLength)) + : 1.f; + aspectData[i] = verticalAperture > 0.f + ? gfCamera.GetHorizontalAperture() / verticalAperture + : 1.f; + } + } + + positions->unmap(); + directions->unmap(); + ups->unmap(); + if (fovs) { + fovs->unmap(); + aspects->unmap(); + } + + std::vector parameterNames{"position", "direction", "up"}; + std::vector> parameterArrays{positions, directions, ups}; + if (hasIntrinsicAnimation) { + parameterNames.push_back("fovy"); + parameterArrays.push_back(fovs); + parameterNames.push_back("aspect"); + parameterArrays.push_back(aspects); + } + + // The authored times are the binding's own time base; TSD's time bases are + // arbitrary rather than uniform, so nothing is resampled. + std::vector timeBase(sampleTimes.begin(), sampleTimes.end()); + auto &animation = ctx.animMgr.addAnimation(name); + addValueTimeStepBindings(animation, + camera.data(), + parameterNames, + parameterArrays, + timeBase, + tsd::animation::InterpolationRule::LINEAR); +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.h b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h new file mode 100644 index 000000000..88ffdd1ba --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h @@ -0,0 +1,28 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include + +namespace tsd::io::usd { + +bool isLightPrimType(const pxr::TfToken &primType); + +// Convert one resolved light prim. Returns a null ref for light types TSD +// cannot represent, which the caller reports rather than dropping silently. +LightRef convertLight(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim); + +// Cameras import into the Scene's camera pool with their world transform, +// animated through bindings where a rig authors motion anywhere above them. +void convertCamera(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp new file mode 100644 index 000000000..8aaae785a --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -0,0 +1,448 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdMaterials.h" +// usd +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +// USD shader node identifiers this converter understands. +const pxr::TfToken PREVIEW_SURFACE_ID("UsdPreviewSurface"); +const pxr::TfToken UV_TEXTURE_ID("UsdUVTexture"); +const pxr::TfToken PRIMVAR_READER_ID("UsdPrimvarReader_float2"); +const pxr::TfToken TRANSFORM_2D_ID("UsdTransform2d"); + +// One resolved UsdPreviewSurface network, walked lazily out of the Hydra +// material network container. +struct NetworkWalker +{ + pxr::HdMaterialNetworkSchema network; + + pxr::HdMaterialNodeSchema node(const pxr::TfToken &path) const; + pxr::TfToken nodeId(const pxr::TfToken &path) const; + pxr::VtValue parameter( + const pxr::TfToken &nodePath, const char *paramName) const; + pxr::TfToken connectedNode( + const pxr::TfToken &nodePath, const char *inputName) const; +}; + +pxr::HdMaterialNodeSchema NetworkWalker::node(const pxr::TfToken &path) const +{ + return network.GetNodes().Get(path); +} + +pxr::TfToken NetworkWalker::nodeId(const pxr::TfToken &path) const +{ + auto n = node(path); + if (!n) + return {}; + auto id = n.GetNodeIdentifier(); + return id ? id->GetTypedValue(0) : pxr::TfToken(); +} + +pxr::VtValue NetworkWalker::parameter( + const pxr::TfToken &nodePath, const char *paramName) const +{ + auto n = node(nodePath); + if (!n) + return {}; + auto param = n.GetParameters().Get(pxr::TfToken(paramName)); + if (!param) + return {}; + auto value = param.GetValue(); + return value ? value->GetValue(0) : pxr::VtValue(); +} + +pxr::TfToken NetworkWalker::connectedNode( + const pxr::TfToken &nodePath, const char *inputName) const +{ + auto n = node(nodePath); + if (!n) + return {}; + auto connections = n.GetInputConnections().Get(pxr::TfToken(inputName)); + if (!connections || connections.GetNumElements() == 0) + return {}; + auto upstream = connections.GetElement(0).GetUpstreamNodePath(); + return upstream ? upstream->GetTypedValue(0) : pxr::TfToken(); +} + +// The UV primvar a texture reads, found by following the texture's `st` input +// back to whatever primvar reader ultimately feeds it. This is what lets an +// asset that does not use the conventional primvar name still get its +// textures. +std::string uvPrimvarOfTexture( + const NetworkWalker &walker, const pxr::TfToken &texturePath) +{ + auto current = walker.connectedNode(texturePath, "st"); + // A UsdTransform2d may sit between the texture and the reader. + for (int hop = 0; hop < 4 && !current.IsEmpty(); ++hop) { + const auto id = walker.nodeId(current); + if (id == PRIMVAR_READER_ID) { + const auto varname = walker.parameter(current, "varname"); + if (varname.IsHolding()) + return varname.UncheckedGet(); + if (varname.IsHolding()) + return varname.UncheckedGet().GetString(); + return {}; + } + if (id != TRANSFORM_2D_ID) + return {}; + current = walker.connectedNode(current, "in"); + } + return {}; +} + +math::mat4 uvTransformOfTexture( + const NetworkWalker &walker, const pxr::TfToken &texturePath) +{ + auto transformNode = walker.connectedNode(texturePath, "st"); + if (walker.nodeId(transformNode) != TRANSFORM_2D_ID) + return math::IDENTITY_MAT4; + + auto retval = math::IDENTITY_MAT4; + const auto scale = walker.parameter(transformNode, "scale"); + if (scale.IsHolding()) { + const auto s = scale.UncheckedGet(); + retval[0][0] = s[0]; + retval[1][1] = s[1]; + } + const auto translation = walker.parameter(transformNode, "translation"); + if (translation.IsHolding()) { + const auto t = translation.UncheckedGet(); + retval[3][0] = t[0]; + retval[3][1] = t[1]; + } + return retval; +} + +std::string wrapModeOf(const NetworkWalker &walker, + const pxr::TfToken &texturePath, + const char *input) +{ + const auto value = walker.parameter(texturePath, input); + std::string mode = "repeat"; + if (value.IsHolding()) + mode = value.UncheckedGet().GetString(); + else if (value.IsHolding()) + mode = value.UncheckedGet(); + if (mode == "clamp") + return "clampToEdge"; + if (mode == "mirror") + return "mirrorRepeat"; + if (mode == "black") + return "clampToBorder"; + return "repeat"; +} + +// Textures whose colour space is not sRGB carry data rather than colour and +// must not be de-gamma'd on load. +bool textureIsLinear(const NetworkWalker &walker, + const pxr::TfToken &texturePath, + bool colorRole) +{ + const auto value = walker.parameter(texturePath, "sourceColorSpace"); + std::string space; + if (value.IsHolding()) + space = value.UncheckedGet().GetString(); + else if (value.IsHolding()) + space = value.UncheckedGet(); + + if (space == "raw") + return true; + if (space == "sRGB") + return false; + // "auto" and unauthored: colour inputs are sRGB, data inputs are not. + return !colorRole; +} + +float3 asFloat3(const pxr::VtValue &v, const float3 &alt) +{ + if (v.IsHolding()) { + const auto c = v.UncheckedGet(); + return float3(c[0], c[1], c[2]); + } + if (v.IsHolding()) { + const auto f = v.UncheckedGet(); + return float3(f, f, f); + } + return alt; +} + +// Native MDL passthrough, read from the retained Stage because the MDL source +// asset and its sub-identifier are UsdShade concepts rather than something the +// resolved network models portably. Returns a null ref when the material has +// no MDL network, so the caller can fall back to a portable mapping. +MaterialRef tryMdlPassthrough( + ImportContext &ctx, const pxr::SdfPath &materialPath) +{ + auto usdPrim = ctx.stage->GetPrimAtPath(materialPath); + if (!usdPrim) + return {}; + + pxr::UsdShadeMaterial usdMaterial(usdPrim); + if (!usdMaterial) + return {}; + + auto mdlOutput = usdMaterial.GetSurfaceOutput(pxr::TfToken("mdl")); + if (!mdlOutput) + return {}; + + for (const auto &connection : mdlOutput.GetConnectedSources()) { + pxr::UsdShadeShader shader(connection.source.GetPrim()); + if (!shader) + continue; + + pxr::SdfAssetPath sourceAsset; + if (!shader.GetSourceAsset(&sourceAsset, pxr::TfToken("mdl"))) + continue; + + auto module = sourceAsset.GetResolvedPath(); + if (module.empty()) + module = sourceAsset.GetAssetPath(); + if (module.empty()) + continue; + + pxr::TfToken subIdentifier; + shader.GetSourceAssetSubIdentifier(&subIdentifier, pxr::TfToken("mdl")); + + auto material = ctx.scene.createObject(tokens::material::mdl); + material->setName(materialPath.GetString().c_str()); + material->setParameter("sourceType", "module"); + material->setParameter("source", module.c_str()); + material->setParameter("materialName", subIdentifier.GetText()); + + // Carry the shader's own scalar and colour inputs through as parameters; + // the mdl subtype supports arbitrary parameter passthrough. + for (const auto &input : shader.GetInputs()) { + const auto name = input.GetBaseName().GetString(); + float scalar = 0.f; + pxr::GfVec3f color; + if (input.Get(&color)) + material->setParameter( + Token(name.c_str()), float3(color[0], color[1], color[2])); + else if (input.Get(&scalar)) + material->setParameter(Token(name.c_str()), scalar); + } + + return material; + } + + return {}; +} + +// Try each Render Context in the caller's preference order, falling back per +// material so a Stage mixing network flavours resolves completely either way. +pxr::HdMaterialNetworkSchema selectNetwork( + const pxr::HdMaterialSchema &material, + const std::vector &preference) +{ + for (const auto &context : preference) { + auto network = material.GetMaterialNetwork(pxr::TfToken(context)); + if (network && network.GetNodes()) + return network; + } + // Nothing preferred matched: take whatever the material does have. + for (const auto &context : material.GetRenderContexts()) { + auto network = material.GetMaterialNetwork(context); + if (network && network.GetNodes()) + return network; + } + return material.GetMaterialNetwork(); +} + +} // namespace + +ResolvedMaterial resolveMaterial(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &materialPath) +{ + if (materialPath.IsEmpty()) + return {}; + + const auto key = materialPath.GetString(); + if (auto found = ctx.materialCache.find(key); + found != ctx.materialCache.end()) { + ResolvedMaterial retval; + retval.material = found->second; + retval.uvPrimvarName = ctx.uvPrimvarCache[key]; + return retval; + } + + auto prim = sceneIndex->GetPrim(materialPath); + + // Native passthrough modes are opt-in; each falls back to the portable + // mapping, saying so, rather than dropping the material. + if (ctx.options.materialMode == UsdMaterialMode::MDL) { + if (auto material = tryMdlPassthrough(ctx, materialPath)) { + ctx.materialCache[key] = material; + ctx.uvPrimvarCache[key] = std::string(); + ResolvedMaterial retval; + retval.material = material; + return retval; + } + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "no MDL network authored; reading a portable mapping instead"); + } else if (ctx.options.materialMode == UsdMaterialMode::MATERIALX) { + // MaterialX passthrough needs OpenUSD's HdMtlx document conversion, which + // this build of OpenUSD does not ship. + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "MaterialX passthrough is unavailable in this OpenUSD build; " + "reading a portable mapping instead"); + } + + auto materialSchema = pxr::HdMaterialSchema::GetFromParent(prim.dataSource); + if (!materialSchema) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::MATERIAL_RESOLUTION_FAILED, + "no material network on the resolved prim"); + return {}; + } + + NetworkWalker walker{ + selectNetwork(materialSchema, ctx.options.renderContexts)}; + if (!walker.network) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::MATERIAL_RESOLUTION_FAILED, + "no usable network for the requested Render Contexts"); + return {}; + } + + auto surfaceTerminal = + walker.network.GetTerminals().Get(pxr::HdMaterialTerminalTokens->surface); + if (!surfaceTerminal) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::MATERIAL_RESOLUTION_FAILED, + "network has no surface terminal"); + return {}; + } + + auto terminalPathSource = surfaceTerminal.GetUpstreamNodePath(); + const auto surfacePath = terminalPathSource + ? terminalPathSource->GetTypedValue(0) + : pxr::TfToken(); + if (walker.nodeId(surfacePath) != PREVIEW_SURFACE_ID) { + // Something richer than a preview surface is authored here. Emit what a + // portable mapping can express and say so, rather than dropping it. + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "surface terminal is '" + walker.nodeId(surfacePath).GetString() + + "'; reading it as a preview surface"); + } + + auto material = + ctx.scene.createObject(tokens::material::physicallyBased); + material->setName(key.c_str()); + + ResolvedMaterial retval; + retval.material = material; + + // Scalar inputs // + + auto setFloatIfPresent = [&](const char *usdName, const char *tsdName) { + const auto value = walker.parameter(surfacePath, usdName); + if (value.IsHolding()) + material->setParameter(Token(tsdName), value.UncheckedGet()); + }; + + // Textured or constant inputs // + + auto bindTexture = + [&](const char *usdName, const char *tsdName, bool colorRole) -> bool { + const auto texturePath = walker.connectedNode(surfacePath, usdName); + if (walker.nodeId(texturePath) != UV_TEXTURE_ID) + return false; + + // The reader node names the primvar whether or not the image loads, so + // record it before anything can fail. + if (retval.uvPrimvarName.empty()) + retval.uvPrimvarName = uvPrimvarOfTexture(walker, texturePath); + + const auto fileValue = walker.parameter(texturePath, "file"); + if (!fileValue.IsHolding()) + return false; + + const auto assetPath = fileValue.UncheckedGet(); + auto file = assetPath.GetResolvedPath(); + if (file.empty()) + file = assetPath.GetAssetPath(); + if (file.empty()) + return false; + if (!isAbsolute(file)) + file = ctx.basePath + file; + + const bool isLinear = textureIsLinear(walker, texturePath, colorRole); + auto sampler = importTexture(ctx.scene, file, ctx.textureCache, isLinear); + if (!sampler) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::TEXTURE_LOAD_FAILED, + file); + return false; + } + + sampler->setParameter( + "wrapMode1", wrapModeOf(walker, texturePath, "wrapS").c_str()); + sampler->setParameter( + "wrapMode2", wrapModeOf(walker, texturePath, "wrapT").c_str()); + sampler->setParameter("inAttribute", "attribute0"); + const auto uvTransform = uvTransformOfTexture(walker, texturePath); + if (uvTransform != math::IDENTITY_MAT4) + sampler->setParameter("inTransform", uvTransform); + + material->setParameterObject(Token(tsdName), *sampler); + return true; + }; + + if (!bindTexture("diffuseColor", "baseColor", true)) { + material->setParameter("baseColor", + asFloat3(walker.parameter(surfacePath, "diffuseColor"), + float3(0.18f, 0.18f, 0.18f))); + } + if (!bindTexture("emissiveColor", "emissive", true)) { + const auto emissive = walker.parameter(surfacePath, "emissiveColor"); + if (!emissive.IsEmpty()) + material->setParameter("emissive", asFloat3(emissive, float3(0.f))); + } + if (!bindTexture("normal", "normal", false)) + ; // no normal map authored + if (!bindTexture("metallic", "metallic", false)) + setFloatIfPresent("metallic", "metallic"); + if (!bindTexture("roughness", "roughness", false)) + setFloatIfPresent("roughness", "roughness"); + if (!bindTexture("opacity", "opacity", false)) + setFloatIfPresent("opacity", "opacity"); + setFloatIfPresent("clearcoat", "clearcoat"); + setFloatIfPresent("clearcoatRoughness", "clearcoatRoughness"); + setFloatIfPresent("ior", "ior"); + + ctx.materialCache[key] = material; + ctx.uvPrimvarCache[key] = retval.uvPrimvarName; + return retval; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h new file mode 100644 index 000000000..7fd40cf1a --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h @@ -0,0 +1,32 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +// std +#include + +namespace tsd::io::usd { + +/* + * A material as TSD sees it, together with the primvar its own texture-reader + * node asked for. The UV name travels with the material because the geometry + * converter must bind that primvar rather than assume a conventional name. + */ +struct ResolvedMaterial +{ + MaterialRef material; + std::string uvPrimvarName; +}; + +// Resolve the material bound at `materialPath` in the resolved scene, honouring +// the Render Context preference order with per-material fallback. Results are +// cached on the context so a shared material converts once. +ResolvedMaterial resolveMaterial(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &materialPath); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp new file mode 100644 index 000000000..fd7295baa --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp @@ -0,0 +1,226 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdSubdivision.h" +// usd +#include +#include +#include +#include +#include +#include +// opensubdiv +#include +#include +// std +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +// Adapter OpenSubdiv's PrimvarRefiner interpolates through. It carries a fixed +// number of floats so one implementation covers every float-typed primvar. +template +struct FloatTuple +{ + float value[N]; + + void Clear(); + void AddWithWeight(const FloatTuple &src, float weight); +}; + +template +void FloatTuple::Clear() +{ + for (int i = 0; i < N; ++i) + value[i] = 0.f; +} + +template +void FloatTuple::AddWithWeight(const FloatTuple &src, float weight) +{ + for (int i = 0; i < N; ++i) + value[i] += weight * src.value[i]; +} + +// Refine one buffer of N-component floats through every level of `refiner`, +// returning the values at the last level. +template +std::vector> refineBuffer( + OpenSubdiv::Far::TopologyRefiner &refiner, + const float *source, + size_t count) +{ + const int maxLevel = refiner.GetMaxLevel(); + + size_t total = 0; + for (int level = 0; level <= maxLevel; ++level) + total += size_t(refiner.GetLevel(level).GetNumVertices()); + + std::vector> buffer(total); + std::memcpy(buffer.data(), source, sizeof(float) * N * count); + + OpenSubdiv::Far::PrimvarRefiner primvarRefiner(refiner); + FloatTuple *src = buffer.data(); + for (int level = 1; level <= maxLevel; ++level) { + FloatTuple *dst = src + refiner.GetLevel(level - 1).GetNumVertices(); + primvarRefiner.Interpolate(level, src, dst); + src = dst; + } + + const int lastCount = refiner.GetLevel(maxLevel).GetNumVertices(); + return std::vector>(src, src + lastCount); +} + +template +bool refineTypedPrimvar(OpenSubdiv::Far::TopologyRefiner &refiner, + const pxr::VtValue &value, + pxr::VtValue *out) +{ + if (!value.IsHolding()) + return false; + const auto &source = value.UncheckedGet(); + if (source.empty()) + return false; + + const auto refined = refineBuffer( + refiner, reinterpret_cast(source.cdata()), source.size()); + + VtArrayT result(refined.size()); + std::memcpy( + result.data(), refined.data(), sizeof(float) * N * refined.size()); + *out = pxr::VtValue(result); + return true; +} + +pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) +{ + return source ? source->GetTypedValue(0) : pxr::VtIntArray(); +} + +pxr::VtFloatArray floatArrayOf(const pxr::HdFloatArrayDataSourceHandle &source) +{ + return source ? source->GetTypedValue(0) : pxr::VtFloatArray(); +} + +pxr::PxOsdSubdivTags readSubdivTags(const pxr::HdMeshSchema &meshSchema) +{ + pxr::PxOsdSubdivTags retval; + auto tags = meshSchema.GetSubdivisionTags(); + if (!tags) + return retval; + + if (auto rule = tags.GetInterpolateBoundary()) + retval.SetVertexInterpolationRule(rule->GetTypedValue(0)); + if (auto rule = tags.GetFaceVaryingLinearInterpolation()) + retval.SetFaceVaryingInterpolationRule(rule->GetTypedValue(0)); + if (auto rule = tags.GetTriangleSubdivisionRule()) + retval.SetTriangleSubdivision(rule->GetTypedValue(0)); + + retval.SetCreaseIndices(intArrayOf(tags.GetCreaseIndices())); + retval.SetCreaseLengths(intArrayOf(tags.GetCreaseLengths())); + retval.SetCreaseWeights(floatArrayOf(tags.GetCreaseSharpnesses())); + retval.SetCornerIndices(intArrayOf(tags.GetCornerIndices())); + retval.SetCornerWeights(floatArrayOf(tags.GetCornerSharpnesses())); + + return retval; +} + +} // namespace + +bool meshWantsRefinement(const ImportContext &ctx, const pxr::SdfPath &primPath) +{ + if (ctx.options.refinementLevel <= 0) + return false; + + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim) + return false; + + pxr::UsdGeomMesh mesh(prim); + if (!mesh) + return false; + + auto attribute = mesh.GetSubdivisionSchemeAttr(); + if (!attribute || !attribute.HasAuthoredValue()) + return false; + + pxr::TfToken scheme; + if (!attribute.Get(&scheme)) + return false; + + return scheme != pxr::PxOsdOpenSubdivTokens->none; +} + +RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, + const pxr::VtIntArray &faceVertexCounts, + const pxr::VtIntArray &faceVertexIndices, + const pxr::VtIntArray &holeIndices, + const pxr::TfToken &orientation, + const pxr::VtVec3fArray &points, + const std::vector> &vertexPrimvars, + int refinementLevel) +{ + RefinedMesh retval; + + auto schemeSource = meshSchema.GetSubdivisionScheme(); + const auto scheme = schemeSource ? schemeSource->GetTypedValue(0) + : pxr::PxOsdOpenSubdivTokens->catmullClark; + + pxr::PxOsdMeshTopology topology(scheme, + orientation, + faceVertexCounts, + faceVertexIndices, + holeIndices, + readSubdivTags(meshSchema)); + + auto refiner = pxr::PxOsdRefinerFactory::Create(topology); + if (!refiner) + return retval; + + OpenSubdiv::Far::TopologyRefiner::UniformOptions options(refinementLevel); + options.fullTopologyInLastLevel = true; + refiner->RefineUniform(options); + + const auto &lastLevel = refiner->GetLevel(refiner->GetMaxLevel()); + if (lastLevel.GetNumFaces() == 0 || lastLevel.GetNumVertices() == 0) + return retval; + + // Topology of the refined level. + retval.faceVertexCounts.reserve(lastLevel.GetNumFaces()); + for (int face = 0; face < lastLevel.GetNumFaces(); ++face) { + const auto vertices = lastLevel.GetFaceVertices(face); + retval.faceVertexCounts.push_back(vertices.size()); + for (int i = 0; i < vertices.size(); ++i) + retval.faceVertexIndices.push_back(vertices[i]); + } + + // Points, and every vertex-interpolated primvar through the same refinement. + { + pxr::VtValue refinedPoints; + if (!refineTypedPrimvar<3, pxr::VtVec3fArray>( + *refiner, pxr::VtValue(points), &refinedPoints)) + return retval; + retval.points = refinedPoints.UncheckedGet(); + } + + for (const auto &[name, value] : vertexPrimvars) { + pxr::VtValue refined; + const bool ok = + refineTypedPrimvar<1, pxr::VtFloatArray>(*refiner, value, &refined) + || refineTypedPrimvar<2, pxr::VtVec2fArray>(*refiner, value, &refined) + || refineTypedPrimvar<3, pxr::VtVec3fArray>(*refiner, value, &refined) + || refineTypedPrimvar<4, pxr::VtVec4fArray>(*refiner, value, &refined); + if (ok) + retval.vertexPrimvars.emplace_back(name, refined); + } + + retval.valid = true; + return retval; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h new file mode 100644 index 000000000..3525972ba --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h @@ -0,0 +1,52 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +#include +#include +#include +// std +#include +#include + +namespace tsd::io::usd { + +/* + * A mesh after OpenSubdiv refinement: the limit-level topology together with + * every vertex-interpolated primvar carried through the same refinement, so + * that smooth assets do not arrive faceted and their attributes stay aligned + * with their points. + */ +struct RefinedMesh +{ + bool valid{false}; + pxr::VtIntArray faceVertexCounts; + pxr::VtIntArray faceVertexIndices; + pxr::VtIntArray holeIndices; + pxr::VtVec3fArray points; + std::vector> vertexPrimvars; +}; + +// True when this mesh should be refined: the Stage explicitly declares a +// subdivision scheme other than "none" and the caller asked for refinement. +// USD's schema default is catmullClark for every mesh, so authoring is what +// distinguishes a subdivision surface from an ordinary polygon mesh. +bool meshWantsRefinement( + const ImportContext &ctx, const pxr::SdfPath &primPath); + +// Refine with OpenSubdiv, honouring subdivision tags -- creases, corners, and +// holes -- carried on the resolved prim. +RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, + const pxr::VtIntArray &faceVertexCounts, + const pxr::VtIntArray &faceVertexIndices, + const pxr::VtIntArray &holeIndices, + const pxr::TfToken &orientation, + const pxr::VtVec3fArray &points, + const std::vector> &vertexPrimvars, + int refinementLevel); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 1e0c790b3..80a767dd8 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -1,65 +1,35 @@ // Copyright 2024-2026 NVIDIA Corporation // SPDX-License-Identifier: Apache-2.0 -#include -#ifndef TSD_USE_USD -#define TSD_USE_USD 1 -#endif - #include "tsd/animation/AnimationManager.hpp" -#include "tsd/core/ColorMapUtil.hpp" -#include "tsd/core/DataTree.hpp" #include "tsd/core/Logging.hpp" -#include "tsd/core/TSDMath.hpp" -#include "tsd/io/animation/EnSightFileBinding.hpp" -#include "tsd/io/animation/SpatialFieldFileBinding.hpp" #include "tsd/io/importers.hpp" -#include "tsd/io/importers/detail/HDRImage.h" -#include "tsd/io/importers/detail/ensight_io.hpp" #include "tsd/io/importers/detail/importer_common.hpp" -#include "tsd/io/importers/detail/usd/MaterialCommon.h" -#include "tsd/io/importers/detail/usd/OmniPbrMaterial.h" -#include "tsd/scene/algorithms/computeScalarRange.hpp" -#include "tsd/scene/objects/Array.hpp" #if TSD_USE_USD +#include "tsd/io/importers/detail/usd/UsdAnimation.h" +#include "tsd/io/importers/detail/usd/UsdDialect.h" +#include "tsd/io/importers/detail/usd/UsdGeometry.h" +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +#include "tsd/io/importers/detail/usd/UsdInstancing.h" +#include "tsd/io/importers/detail/usd/UsdLights.h" // usd -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #endif // std -#include #include -#include #include namespace tsd::io { @@ -68,2236 +38,337 @@ using namespace tsd::core; #if TSD_USE_USD -// ----------------------------------------------------------------------------- -// Material-related helpers -// ----------------------------------------------------------------------------- - -// Template helpers for setting material parameters from USD shader inputs -static void setShaderInputIfPresent(MaterialRef &mat, - pxr::UsdShadeShader &shader, - const char *inputName, - const char *paramName) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - pxr::GfVec3f colorVal; - if (input && input.Get(&colorVal)) { - logStatus("[import_USD] Setting %s: %f %f %f\n", - paramName, - colorVal[0], - colorVal[1], - colorVal[2]); - mat->setParameter(tsd::core::Token(paramName), - tsd::math::float3(colorVal[0], colorVal[1], colorVal[2])); - } -} - -static void setShaderInputIfPresent(MaterialRef &mat, - pxr::UsdShadeShader &shader, - const char *inputName, - const char *paramName, - float) -{ - pxr::UsdShadeInput input = shader.GetInput(pxr::TfToken(inputName)); - float floatVal; - if (input && input.Get(&floatVal)) { - logStatus("[import_USD] Setting %s: %f\n", paramName, floatVal); - mat->setParameter(tsd::core::Token(paramName), floatVal); - } -} - -// Helper: Import a UsdPreviewSurface material as a physicallyBased TSD material -static MaterialRef importUsdPreviewSurfaceMaterial(Scene &scene, - const pxr::UsdShadeMaterial &usdMat, - const std::string &basePath, - TextureCache &texCache) +namespace { + +using namespace tsd::io::usd; + +/////////////////////////////////////////////////////////////////////////////// +// Scene index chain ////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +// Everything OpenUSD can resolve for us, resolved before TSD sees it: sphere, +// cone and cylinder stay analytic for TSD's native quadrics while capsule, +// cube and plane become meshes; NURBS are approximated; pinned curves are +// expanded; tetrahedral meshes are converted. +pxr::HdSceneIndexBaseRefPtr buildFilterChain(pxr::HdSceneIndexBaseRefPtr input) +{ + using pxr::HdsiImplicitSurfaceSceneIndexTokens; + + auto implicitArgs = + pxr::HdRetainedContainerDataSource::New(pxr::HdPrimTypeTokens->capsule, + pxr::HdRetainedTypedSampledDataSource::New( + HdsiImplicitSurfaceSceneIndexTokens->toMesh), + pxr::HdPrimTypeTokens->cube, + pxr::HdRetainedTypedSampledDataSource::New( + HdsiImplicitSurfaceSceneIndexTokens->toMesh), + pxr::HdPrimTypeTokens->plane, + pxr::HdRetainedTypedSampledDataSource::New( + HdsiImplicitSurfaceSceneIndexTokens->toMesh)); + // Cone and cylinder are deliberately left alone: axisToTransform would move + // the shape's spine into a transform this importer does not read (local + // transforms come from the Stage, not the resolved scene), so the converter + // folds the axis into the emitted endpoints instead. + + auto retval = pxr::HdsiImplicitSurfaceSceneIndex::New(input, implicitArgs); + auto nurbs = pxr::HdsiNurbsApproximatingSceneIndex::New(retval); + auto curves = pxr::HdsiPinnedCurveExpandingSceneIndex::New(nurbs); + return pxr::HdsiTetMeshConversionSceneIndex::New(curves); +} + +/////////////////////////////////////////////////////////////////////////////// +// Traversal ////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +// The root of the subtree UsdImaging synthesises for native instancing. Its +// contents reach the Scene through their instancers, never directly. +const char *NATIVE_INSTANCING_ROOT = "/UsdNiPropagatedPrototypes"; + +bool purposeIsIncluded( + const pxr::TfToken &purpose, const UsdPurposeSelection &selection) +{ + if (purpose == pxr::HdRenderTagTokens->guide) + return selection.guide; + if (purpose == pxr::HdRenderTagTokens->proxy) + return selection.proxy; + if (purpose == pxr::HdRenderTagTokens->render) + return selection.render; + return selection.defaultPurpose; +} + +// Leave a named, empty, disabled node where content would have been, so that a +// gap is visible in the hierarchy rather than only in a log. +LayerNodeRef insertPlaceholder(ImportContext &ctx, + LayerNodeRef parent, + const pxr::SdfPath &primPath, + UsdSkipReason reason) { - // Find the UsdPreviewSurface shader - pxr::UsdShadeShader surfaceShader; - pxr::TfToken outputName("surface"); - pxr::UsdShadeOutput surfaceOutput = usdMat.GetOutput(outputName); - - if (surfaceOutput && surfaceOutput.HasConnectedSource()) { - logStatus("[import_USD] Surface output has connected source\n"); - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - surfaceOutput.GetConnectedSource(&source, &sourceName, &sourceType); - surfaceShader = pxr::UsdShadeShader(source.GetPrim()); - } - - if (!surfaceShader) - return scene.defaultMaterial(); - - auto mat = scene.createObject(tokens::material::physicallyBased); - - if (auto sampler = materials::resolveTexturedInput( - scene, surfaceShader, "diffuseColor", basePath, texCache)) { - mat->setParameterObject("baseColor", *sampler); - } else { - setShaderInputIfPresent(mat, surfaceShader, "diffuseColor", "baseColor"); - } - setShaderInputIfPresent(mat, surfaceShader, "emissiveColor", "emissive"); - setShaderInputIfPresent(mat, surfaceShader, "metallic", "metallic", 0.0f); - setShaderInputIfPresent(mat, surfaceShader, "roughness", "roughness", 0.0f); - setShaderInputIfPresent(mat, surfaceShader, "clearcoat", "clearcoat", 0.0f); - setShaderInputIfPresent( - mat, surfaceShader, "clearcoatRoughness", "clearcoatRoughness", 0.0f); - setShaderInputIfPresent(mat, surfaceShader, "opacity", "opacity", 0.0f); - setShaderInputIfPresent(mat, surfaceShader, "ior", "ior", 0.0f); - - // Set name - std::string matName = usdMat.GetPrim().GetPath().GetString(); - if (matName.empty()) - matName = "USDPreviewSurface"; - mat->setName(matName.c_str()); - logStatus("[import_USD] Created material: %s\n", matName.c_str()); - - return mat; + // insertChildNode() already leaves the node empty; setEmpty() would clear + // the name along with the value. + auto node = ctx.scene.insertChildNode(parent, primPath.GetName().c_str()); + (*node)->setEnabled(false); + (*node)->setInstanceParameter("usd:skipReason", Any(toString(reason))); + (*node)->setInstanceParameter("usd:primPath", Any(primPath.GetText())); + return node; } -// Caches material refs by USD prim path to avoid duplicate imports -using MaterialCache = std::unordered_map; - -// Try to import the bound material for a prim. Checks the cache first, then -// tries OmniPBR (MDL), then UsdPreviewSurface, then falls back to default. -static MaterialRef getBoundMaterial(Scene &scene, - const pxr::UsdPrim &prim, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) +struct Traversal { - pxr::UsdShadeMaterialBindingAPI binding(prim); - pxr::UsdShadeMaterial usdMat = binding.ComputeBoundMaterial(); - if (!usdMat) - return scene.defaultMaterial(); - - std::string matPath = usdMat.GetPath().GetString(); - auto it = matCache.find(matPath); - if (it != matCache.end()) - return it->second; - - MaterialRef mat; - - // Try OmniPBR via MDL surface output - auto mdlOutput = usdMat.GetSurfaceOutput(pxr::TfToken("mdl")); - if (mdlOutput) { - for (auto &src : mdlOutput.GetConnectedSources()) { - pxr::UsdShadeShader shader(src.source); - pxr::TfToken subId; - shader.GetSourceAssetSubIdentifier(&subId, pxr::TfToken("mdl")); - if (subId == pxr::TfToken("OmniPBR")) { - mat = materials::importOmniPBRMaterial( - scene, usdMat, shader, basePath, texCache); - break; - } - } - } - - // Fall back to UsdPreviewSurface - if (!mat) - mat = importUsdPreviewSurfaceMaterial(scene, usdMat, basePath, texCache); + ImportContext &ctx; + pxr::HdSceneIndexBaseRefPtr sceneIndex; + InstancerRegistry &instancers; - if (!mat) - mat = scene.defaultMaterial(); - - matCache[matPath] = mat; - return mat; -} + void visit(const pxr::SdfPath &primPath, LayerNodeRef parent, bool hidden); -// Helper to extract volume transfer function from USD material -struct VolumeTransferFunction -{ - std::vector colors; - std::vector xPointsColor; // color control point positions - std::vector - xPoints; // opacity control point positions (legacy: shared) - std::vector opacityValues; // opacity values at xPoints - math::float2 domain{0.0f, 1.0f}; - float unitDistance{0.0f}; - bool hasTransferFunction = false; + tsd::math::mat4 localTransformOf( + const pxr::SdfPath &primPath, bool *resetsXformStack) const; + bool isHierarchyPrim(const pxr::SdfPath &primPath) const; }; -// Read all colormap attributes from a prim into VolumeTransferFunction. -// Returns true if at least rgbaPoints was found and read successfully. -static bool extractColormapFromPrim( - const pxr::UsdPrim &prim, VolumeTransferFunction &tf) -{ - auto rgbaAttr = prim.GetAttribute(pxr::TfToken("rgbaPoints")); - if (!rgbaAttr) - return false; - - pxr::VtArray rgbaPoints; - if (!rgbaAttr.Get(&rgbaPoints) || rgbaPoints.empty()) - return false; - - tf.colors.resize(rgbaPoints.size()); - for (size_t i = 0; i < rgbaPoints.size(); ++i) { - const auto &c = rgbaPoints[i]; - tf.colors[i] = math::float4(c[0], c[1], c[2], c[3]); - } - - auto readFloatArray = [&](const char *name, std::vector &out) { - if (auto attr = prim.GetAttribute(pxr::TfToken(name))) { - pxr::VtArray vals; - if (attr.Get(&vals)) - out.assign(vals.begin(), vals.end()); - } - }; - - readFloatArray("xPointsColor", tf.xPointsColor); - readFloatArray("xPoints", tf.xPoints); - readFloatArray("opacityValues", tf.opacityValues); - - if (auto attr = prim.GetAttribute(pxr::TfToken("domain"))) { - pxr::GfVec2f domain; - if (attr.Get(&domain)) - tf.domain = math::float2(domain[0], domain[1]); - } - - if (auto attr = prim.GetAttribute(pxr::TfToken("unitDistance"))) { - float ud; - if (attr.Get(&ud) && ud > 0.0f) - tf.unitDistance = ud; - } - - tf.hasTransferFunction = true; - return true; -} - -// Convert the USD-specific representation to core::TransferFunction so that -// the existing interpolation helpers (interpolateColor / interpolateOpacity) -// can be used directly. -static core::TransferFunction toTransferFunction( - const VolumeTransferFunction &vtf) -{ - core::TransferFunction tf; - tf.range = {vtf.domain.x, vtf.domain.y}; - - // ColorPoint is {x, r, g, b} - const auto &xColor = - vtf.xPointsColor.empty() ? vtf.xPoints : vtf.xPointsColor; - for (size_t i = 0; i < vtf.colors.size() && i < xColor.size(); ++i) { - tf.colorPoints.emplace_back( - xColor[i], vtf.colors[i].x, vtf.colors[i].y, vtf.colors[i].z); - } - - // OpacityPoint is {x, opacity} - if (!vtf.opacityValues.empty()) { - for (size_t i = 0; i < vtf.opacityValues.size() && i < vtf.xPoints.size(); - ++i) - tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.opacityValues[i]); - } else { - for (size_t i = 0; i < vtf.colors.size() && i < vtf.xPoints.size(); ++i) - tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.colors[i].w); - } - - return tf; -} - -static VolumeTransferFunction getVolumeTransferFunction( - const pxr::UsdPrim &prim) -{ - VolumeTransferFunction tf; - - // Strategy 1: Material binding chain (Material → VolumeShader → Colormap) - if (pxr::UsdShadeMaterialBindingAPI::CanApply(prim)) { - pxr::UsdShadeMaterialBindingAPI binding(prim); - pxr::UsdShadeMaterial usdMat; - - pxr::UsdRelationship materialRel = - prim.GetRelationship(pxr::TfToken("material:binding")); - if (materialRel) { - pxr::SdfPathVector targets; - materialRel.GetTargets(&targets); - if (!targets.empty()) { - pxr::UsdPrim materialPrim = prim.GetStage()->GetPrimAtPath(targets[0]); - if (materialPrim) - usdMat = pxr::UsdShadeMaterial(materialPrim); - } - } - - if (!usdMat && binding) - usdMat = binding.ComputeBoundMaterial(); - - if (usdMat) { - pxr::UsdShadeOutput volumeOutput = - usdMat.GetOutput(pxr::TfToken("nvindex:volume")); - if (volumeOutput && volumeOutput.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI src; - pxr::TfToken srcName; - pxr::UsdShadeAttributeType srcType; - volumeOutput.GetConnectedSource(&src, &srcName, &srcType); - pxr::UsdShadeShader volumeShader(src.GetPrim()); - - if (volumeShader) { - pxr::UsdShadeInput cmapInput = - volumeShader.GetInput(pxr::TfToken("colormap")); - if (cmapInput && cmapInput.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI cmapSrc; - pxr::TfToken cmapSrcName; - pxr::UsdShadeAttributeType cmapSrcType; - if (cmapInput.GetConnectedSource( - &cmapSrc, &cmapSrcName, &cmapSrcType)) { - pxr::UsdPrim cmapPrim = cmapSrc.GetPrim(); - if (cmapPrim && extractColormapFromPrim(cmapPrim, tf)) { - logStatus( - "[import_USD] Found volume colormap via material binding, " - "%zu colors, domain: [%f, %f]\n", - tf.colors.size(), - tf.domain.x, - tf.domain.y); - return tf; - } - } - } - } - } - } - } - - // Strategy 2: Child Shader prim with colormap attributes - for (const auto &child : prim.GetChildren()) { - if (!child.IsA()) - continue; - if (extractColormapFromPrim(child, tf)) { - logStatus( - "[import_USD] Found volume colormap on child prim '%s', " - "%zu colors, domain: [%f, %f]\n", - child.GetPath().GetText(), - tf.colors.size(), - tf.domain.x, - tf.domain.y); - return tf; - } - } - - return tf; -} - -// ----------------------------------------------------------------------------- -// Geometry import helpers -// ----------------------------------------------------------------------------- - -// Helper: Convert pxr::GfMatrix4d to tsd::math::mat4 (float4x4) -inline tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m) -{ - tsd::math::mat4 out; - for (int i = 0; i < 4; ++i) - for (int j = 0; j < 4; ++j) - out[i][j] = static_cast(m[i][j]); - return out; -} - -inline float3 min(const float3 &a, const float3 &b) -{ - return float3(std::min(a.x, b.x), std::min(a.y, b.y), std::min(a.z, b.z)); -} - -inline float3 max(const float3 &a, const float3 &b) -{ - return float3(std::max(a.x, b.x), std::max(a.y, b.y), std::max(a.z, b.z)); -} - -// Resample a sparse set of authored USD time samples at the stage's frame rate -// so that consecutive quaternion deltas stay small enough for correct SLERP. -// Without this, a 360° rotation with only 2 keyframes produces identical -// quaternions and no visible animation. -static constexpr size_t MAX_XFORM_SAMPLES = 4096; - -static std::vector densifyTimeSamples( - const std::vector &authored, const pxr::UsdStageRefPtr &stage) -{ - if (authored.size() < 2) - return authored; - - double fps = stage->GetFramesPerSecond(); - if (fps <= 0) - fps = 24.0; - - double tMin = authored.front(); - double tMax = authored.back(); - double range = tMax - tMin; - double step = 1.0 / fps; - - size_t count = static_cast(range / step) + 1; - if (count <= authored.size()) - return authored; - if (count > MAX_XFORM_SAMPLES) { - count = MAX_XFORM_SAMPLES; - step = range / static_cast(count - 1); - } - - std::vector dense; - dense.reserve(count); - for (size_t i = 0; i < count; ++i) - dense.push_back(tMin + i * step); - dense.back() = tMax; - return dense; -} - -// Helper: Generate triangle indices from polygon face data -// Tessellates polygons to triangles using triangle fan (assumes convex -// polygons) Returns indices into the original vertex array -static std::vector generateTriangleIndices( - const pxr::VtArray &faceVertexIndices, - const pxr::VtArray &faceVertexCounts) -{ - std::vector triangleIndices; - size_t faceVertexOffset = 0; - - for (size_t face = 0; face < faceVertexCounts.size(); ++face) { - int vertsInFace = faceVertexCounts[face]; - - // Tessellate polygon as triangle fan: (0,1,2), (0,2,3), (0,3,4), ... - for (int v = 2; v < vertsInFace; ++v) { - triangleIndices.push_back(faceVertexIndices[faceVertexOffset + 0]); - triangleIndices.push_back(faceVertexIndices[faceVertexOffset + v - 1]); - triangleIndices.push_back(faceVertexIndices[faceVertexOffset + v]); - } - - faceVertexOffset += vertsInFace; - } - - return triangleIndices; -} - -// Helper: Tessellate faceVarying data from polygons to triangles -// FaceVarying data has one value per face-vertex (corner) -// Returns tessellated data matching the triangle fan pattern -template -static std::vector tessellateFacevaryingData( - const pxr::VtArray &faceVaryingData, - const pxr::VtArray &faceVertexCounts) -{ - std::vector triangleData; - size_t faceVertexOffset = 0; - - for (size_t face = 0; face < faceVertexCounts.size(); ++face) { - int vertsInFace = faceVertexCounts[face]; - - // Tessellate as triangle fan: (0,1,2), (0,2,3), (0,3,4), ... - for (int v = 2; v < vertsInFace; ++v) { - triangleData.push_back(faceVaryingData[faceVertexOffset + 0]); - triangleData.push_back(faceVaryingData[faceVertexOffset + v - 1]); - triangleData.push_back(faceVaryingData[faceVertexOffset + v]); - } - - faceVertexOffset += vertsInFace; - } - - return triangleData; -} - -// Helper: Tessellate uniform (per-face) data to per-triangle -// Uniform data has one value per face -// Returns replicated data with one value per generated triangle -template -static std::vector tessellateUniformData(const pxr::VtArray &uniformData, - const pxr::VtArray &faceVertexCounts) +// Transforms are deliberately not taken from the resolved scene, which +// flattens them: the Stage is retained so the prim hierarchy can be mirrored +// as nested transform nodes with TSD doing the composition. +tsd::math::mat4 Traversal::localTransformOf( + const pxr::SdfPath &primPath, bool *resetsXformStack) const { - std::vector triangleData; - - for (size_t face = 0; face < faceVertexCounts.size(); ++face) { - int vertsInFace = faceVertexCounts[face]; - int numTriangles = vertsInFace - 2; - - // Each triangle from this face gets the same uniform value - for (int t = 0; t < numTriangles; ++t) { - triangleData.push_back(uniformData[face]); - } - } - - return triangleData; + *resetsXformStack = false; + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim) + return tsd::math::IDENTITY_MAT4; + pxr::UsdGeomXformable xformable(prim); + if (!xformable) + return tsd::math::IDENTITY_MAT4; + pxr::GfMatrix4d local(1.0); + xformable.GetLocalTransformation(&local, resetsXformStack, ctx.importTime); + return toTsdMat4(local); } -// Helper: Import a UsdGeomMesh prim as a TSD mesh under the given parent node -static void importUsdMesh(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) +// A prim with no resolved type is either scene hierarchy (Xform, Scope) or +// something USD cannot image at all; only the latter is a loss worth naming. +bool Traversal::isHierarchyPrim(const pxr::SdfPath &primPath) const { - pxr::UsdGeomMesh mesh(prim); - - // Get vertex positions - pxr::VtArray points; - mesh.GetPointsAttr().Get(&points, pxr::UsdTimeCode::EarliestTime()); - - // Get face topology - pxr::VtArray faceVertexIndices; - mesh.GetFaceVertexIndicesAttr().Get( - &faceVertexIndices, pxr::UsdTimeCode::EarliestTime()); - pxr::VtArray faceVertexCounts; - mesh.GetFaceVertexCountsAttr().Get( - &faceVertexCounts, pxr::UsdTimeCode::EarliestTime()); - - // Get normals and their interpolation - pxr::VtArray normals; - pxr::TfToken normalsInterpolation = pxr::UsdGeomTokens->vertex; // Default - mesh.GetNormalsAttr().Get(&normals, pxr::UsdTimeCode::EarliestTime()); - if (!normals.empty()) { - normalsInterpolation = mesh.GetNormalsInterpolation(); - } - - // Get UVs and their interpolation - pxr::VtArray uvs; - pxr::TfToken uvsInterpolation = pxr::UsdGeomTokens->vertex; // Default - // USD stores UVs as primvars, typically "st" or "UVMap" - pxr::UsdGeomPrimvarsAPI primvarsAPI(mesh); - pxr::UsdGeomPrimvar stPrimvar = primvarsAPI.GetPrimvar(pxr::TfToken("st")); - if (!stPrimvar) { - stPrimvar = primvarsAPI.GetPrimvar(pxr::TfToken("UVMap")); - } - if (stPrimvar) { - // USD primvars can be indexed - need to use ComputeFlattened to expand - // indices - stPrimvar.ComputeFlattened(&uvs); - if (!uvs.empty()) { - uvsInterpolation = stPrimvar.GetInterpolation(); - } - } - - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - - logStatus( - "[import_USD] Mesh '%s': %zu points, %zu faces, %zu normals (interpolation: %s), %zu UVs (interpolation: %s)\n", - prim.GetName().GetString().c_str(), - points.size(), - faceVertexCounts.size(), - normals.size(), - normalsInterpolation.GetText(), - uvs.size(), - uvsInterpolation.GetText()); - - if (points.empty() || faceVertexIndices.empty()) - return; - - // Convert vertex positions to float3 - std::vector positions; - positions.reserve(points.size()); - for (const auto &p : points) { - positions.push_back(float3(p[0], p[1], p[2])); - } - - // Generate triangle indices from polygon faces - std::vector indices = - generateTriangleIndices(faceVertexIndices, faceVertexCounts); - - logStatus( - "[import_USD] Mesh '%s': Generated %zu triangle indices (%zu triangles)\n", - prim.GetName().GetString().c_str(), - indices.size(), - indices.size() / 3); - - // Create ANARI indexed triangle geometry - auto meshObj = scene.createObject(tokens::geometry::triangle); - - // Set vertex positions - auto vertexPositionArray = - scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); - vertexPositionArray->setData(positions.data(), positions.size()); - meshObj->setParameterObject("vertex.position", *vertexPositionArray); - - // Set triangle indices - auto indexArray = scene.createArray(ANARI_UINT32_VEC3, indices.size() / 3); - indexArray->setData((uint3 *)indices.data(), indices.size() / 3); - meshObj->setParameterObject("primitive.index", *indexArray); - - // Handle normals based on USD interpolation - if (!normals.empty()) { - if (normalsInterpolation == pxr::UsdGeomTokens->vertex) { - // Vertex interpolation: normals are per-vertex, shared by all triangles - // No tessellation needed - just convert to float3 - std::vector normalData; - normalData.reserve(normals.size()); - for (const auto &n : normals) { - normalData.push_back(float3(n[0], n[1], n[2])); - } - - auto normalsArray = - scene.createArray(ANARI_FLOAT32_VEC3, normalData.size()); - normalsArray->setData(normalData.data(), normalData.size()); - meshObj->setParameterObject("vertex.normal", *normalsArray); - - logStatus("[import_USD] Mesh '%s': Set %zu normals on vertex.normal\n", - prim.GetName().GetString().c_str(), - normalData.size()); - - } else if (normalsInterpolation == pxr::UsdGeomTokens->faceVarying) { - // FaceVarying interpolation: normals are per face-vertex (corner) - // Need to tessellate from polygon corners to triangle corners - auto tessellatedNormals = - tessellateFacevaryingData(normals, faceVertexCounts); - - std::vector normalData; - normalData.reserve(tessellatedNormals.size()); - for (const auto &n : tessellatedNormals) { - normalData.push_back(float3(n[0], n[1], n[2])); - } - - auto normalsArray = - scene.createArray(ANARI_FLOAT32_VEC3, normalData.size()); - normalsArray->setData(normalData.data(), normalData.size()); - meshObj->setParameterObject("faceVarying.normal", *normalsArray); - - logStatus( - "[import_USD] Mesh '%s': Set %zu normals on faceVarying.normal\n", - prim.GetName().GetString().c_str(), - normalData.size()); - - } else if (normalsInterpolation == pxr::UsdGeomTokens->uniform) { - // Uniform interpolation: one normal per face - // Need to replicate for each triangle generated from that face - auto tessellatedNormals = - tessellateUniformData(normals, faceVertexCounts); - - std::vector normalData; - normalData.reserve(tessellatedNormals.size()); - for (const auto &n : tessellatedNormals) { - normalData.push_back(float3(n[0], n[1], n[2])); - } - - auto normalsArray = - scene.createArray(ANARI_FLOAT32_VEC3, normalData.size()); - normalsArray->setData(normalData.data(), normalData.size()); - meshObj->setParameterObject("primitive.normal", *normalsArray); - - logStatus("[import_USD] Mesh '%s': Set %zu normals on primitive.normal\n", - prim.GetName().GetString().c_str(), - normalData.size()); - } - } - - // Handle UVs based on USD interpolation - if (!uvs.empty()) { - if (uvsInterpolation == pxr::UsdGeomTokens->vertex) { - // Vertex interpolation: UVs are per-vertex, shared by all triangles - // No tessellation needed - just convert to float2 - std::vector uvData; - uvData.reserve(uvs.size()); - for (const auto &uv : uvs) { - // USD is bottom-up, ANARI is top-down - uvData.push_back(float2(uv[0], 1.0f - uv[1])); - } - - auto uvsArray = scene.createArray(ANARI_FLOAT32_VEC2, uvData.size()); - uvsArray->setData(uvData.data(), uvData.size()); - meshObj->setParameterObject("vertex.attribute0", *uvsArray); - - logStatus("[import_USD] Mesh '%s': Set %zu UVs on vertex.attribute0\n", - prim.GetName().GetString().c_str(), - uvData.size()); - - } else if (uvsInterpolation == pxr::UsdGeomTokens->faceVarying) { - // FaceVarying interpolation: UVs are per face-vertex (corner) - // Need to tessellate from polygon corners to triangle corners - auto tessellatedUVs = tessellateFacevaryingData(uvs, faceVertexCounts); - - std::vector uvData; - uvData.reserve(tessellatedUVs.size()); - for (const auto &uv : tessellatedUVs) { - // USD is bottom-up, ANARI is top-down - uvData.push_back(float2(uv[0], 1.0f - uv[1])); - } - - auto uvsArray = scene.createArray(ANARI_FLOAT32_VEC2, uvData.size()); - uvsArray->setData(uvData.data(), uvData.size()); - meshObj->setParameterObject("faceVarying.attribute0", *uvsArray); - - logStatus( - "[import_USD] Mesh '%s': Set %zu UVs on faceVarying.attribute0\n", - prim.GetName().GetString().c_str(), - uvData.size()); - - } else if (uvsInterpolation == pxr::UsdGeomTokens->uniform) { - // Uniform interpolation: one UV per face - // Need to replicate for each triangle generated from that face - auto tessellatedUVs = tessellateUniformData(uvs, faceVertexCounts); - - std::vector uvData; - uvData.reserve(tessellatedUVs.size()); - for (const auto &uv : tessellatedUVs) { - // USD is bottom-up, ANARI is top-down - uvData.push_back(float2(uv[0], 1.0f - uv[1])); - } - - auto uvsArray = scene.createArray(ANARI_FLOAT32_VEC2, uvData.size()); - uvsArray->setData(uvData.data(), uvData.size()); - meshObj->setParameterObject("primitive.attribute0", *uvsArray); - - logStatus("[import_USD] Mesh '%s': Set %zu UVs on primitive.attribute0\n", - prim.GetName().GetString().c_str(), - uvData.size()); - } - } - - meshObj->setName(prim.GetPath().GetText()); - - // Material binding - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); - - auto surface = scene.createSurface(primName.c_str(), meshObj, mat); - logStatus("[import_USD] Assigned material to mesh '%s': %s\n", - primName.c_str(), - mat->name().c_str()); - scene.insertChildObjectNode(parent, surface); + auto prim = ctx.stage->GetPrimAtPath(primPath); + return !prim || bool(pxr::UsdGeomImageable(prim)); } -// Helper: Import a UsdGeomPoints prim as a TSD sphere geometry (point cloud), -// with animation if the positions/widths are time-sampled. -static void importUsdPoints(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - tsd::animation::AnimationManager &animMgr, - MaterialCache &matCache, - TextureCache &texCache) +void Traversal::visit( + const pxr::SdfPath &primPath, LayerNodeRef parent, bool hidden) { - pxr::UsdGeomPoints pointsPrim(prim); - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - - std::vector timeSamples; - pointsPrim.GetPointsAttr().GetTimeSamples(&timeSamples); - - // Build position+radius arrays for one time step - auto buildFrame = [&](pxr::UsdTimeCode tc) - -> std::pair, ObjectUsePtr> { - pxr::VtArray pts; - pxr::VtArray wids; - pointsPrim.GetPointsAttr().Get(&pts, tc); - pointsPrim.GetWidthsAttr().Get(&wids, tc); - std::vector outPos; - std::vector outRad; - outPos.reserve(pts.size()); - outRad.reserve(pts.size()); - for (size_t i = 0; i < pts.size(); ++i) { - const auto &p = pts[i]; - pxr::GfVec4d wp4 = usdXform * pxr::GfVec4d(p[0], p[1], p[2], 1.0); - outPos.push_back(float3(float(wp4[0]), float(wp4[1]), float(wp4[2]))); - outRad.push_back((wids.size() == pts.size()) ? wids[i] * 0.5f : 0.01f); - } - auto pa = scene.createArray(ANARI_FLOAT32_VEC3, outPos.size()); - pa->setData(outPos.data(), outPos.size()); - auto ra = scene.createArray(ANARI_FLOAT32, outRad.size()); - ra->setData(outRad.data(), outRad.size()); - return {pa, ra}; - }; - - pxr::UsdTimeCode firstTC = timeSamples.empty() - ? pxr::UsdTimeCode::EarliestTime() - : pxr::UsdTimeCode(timeSamples[0]); - - auto [firstPosArray, firstRadArray] = buildFrame(firstTC); - if (!firstPosArray || firstPosArray->size() == 0) { - logStatus("[import_USD] Skipping Points prim with no point data: %s\n", - primName.c_str()); - return; - } + auto prim = sceneIndex->GetPrim(primPath); - auto geom = scene.createObject(tokens::geometry::sphere); - geom->setName(primName.c_str()); - geom->setParameterObject("vertex.position", *firstPosArray); - geom->setParameterObject("vertex.radius", *firstRadArray); - - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); - auto surface = scene.createSurface(primName.c_str(), geom, mat); - scene.insertChildObjectNode(parent, surface); - - logStatus("[import_USD] Imported Points '%s' (%zu pts, %zu frames)\n", - primName.c_str(), - firstPosArray->size(), - timeSamples.empty() ? size_t(1) : timeSamples.size()); - - // Build animation if there are multiple time samples - if (timeSamples.size() > 1) { - std::vector> posArrays, radArrays; - posArrays.push_back(firstPosArray); - radArrays.push_back(firstRadArray); - for (size_t ti = 1; ti < timeSamples.size(); ++ti) { - auto [pa, ra] = buildFrame(pxr::UsdTimeCode(timeSamples[ti])); - if (pa && pa->size() > 0) { - posArrays.push_back(pa); - radArrays.push_back(ra); - } - } - if (posArrays.size() > 1) { - auto tb = makeLinearTimeBase(posArrays.size()); - auto &anim = animMgr.addAnimation(primName.c_str()); - addArrayTimeStepBindings(anim, - geom.data(), - {Token("vertex.position"), Token("vertex.radius")}, - {posArrays, radArrays}, - tb); - } + // Prototype content reaches the Scene through its instancer, not here. + auto instancedBy = pxr::HdInstancedBySchema::GetFromParent(prim.dataSource); + if (auto paths = instancedBy.GetPaths()) { + if (!paths->GetTypedValue(0).empty()) + return; } -} -// Helper: Import a UsdGeomBasisCurves prim as TSD curve geometry, -// with animation if the positions are time-sampled. -static void importUsdCurves(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - tsd::animation::AnimationManager &animMgr, - MaterialCache &matCache, - TextureCache &texCache) -{ - pxr::UsdGeomBasisCurves curvesPrim(prim); - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - - // Curve topology is static (curveVertexCounts doesn't animate). - // Use EarliestTime() to handle attributes stored at time samples but not - // at default time. - pxr::VtArray curveVertexCounts; - curvesPrim.GetCurveVertexCountsAttr().Get( - &curveVertexCounts, pxr::UsdTimeCode::EarliestTime()); - if (curveVertexCounts.empty()) { - logStatus("[import_USD] Skipping BasisCurves with no vertex counts: %s\n", - primName.c_str()); - return; - } + // Purpose // - // Build primitive.index: for each curve of N vertices, emit N-1 segment - // start indices, leaving a gap between curves so they stay separate. - std::vector segIndices; - { - uint32_t base = 0; - for (int count : curveVertexCounts) { - for (int i = 0; i < count - 1; ++i) - segIndices.push_back(base + uint32_t(i)); - base += uint32_t(count); - } + pxr::TfToken purpose = pxr::HdRenderTagTokens->geometry; + if (auto purposeSchema = + pxr::HdPurposeSchema::GetFromParent(prim.dataSource)) { + if (auto value = purposeSchema.GetPurpose()) + purpose = value->GetTypedValue(0); } - if (segIndices.empty()) { - logStatus("[import_USD] Skipping BasisCurves with no segments: %s\n", - primName.c_str()); + if (!purposeIsIncluded(purpose, ctx.options.purposes)) { + ctx.reportSkip(primPath, + prim.primType.GetString(), + UsdSkipReason::PURPOSE_EXCLUDED, + purpose.GetString()); + insertPlaceholder(ctx, parent, primPath, UsdSkipReason::PURPOSE_EXCLUDED); return; } - // Build a position array for one time step - auto buildFrame = [&](pxr::UsdTimeCode tc) -> ObjectUsePtr { - pxr::VtArray pts; - curvesPrim.GetPointsAttr().Get(&pts, tc); - if (pts.empty()) - return {}; - std::vector outPts; - outPts.reserve(pts.size()); - for (const auto &p : pts) { - pxr::GfVec4d wp4 = usdXform * pxr::GfVec4d(p[0], p[1], p[2], 1.0); - outPts.push_back(float3(float(wp4[0]), float(wp4[1]), float(wp4[2]))); - } - auto arr = scene.createArray(ANARI_FLOAT32_VEC3, outPts.size()); - arr->setData(outPts.data(), outPts.size()); - return arr; - }; - - std::vector timeSamples; - curvesPrim.GetPointsAttr().GetTimeSamples(&timeSamples); - - pxr::UsdTimeCode firstTC = timeSamples.empty() - ? pxr::UsdTimeCode::EarliestTime() - : pxr::UsdTimeCode(timeSamples[0]); + // Visibility // - auto firstPosArray = buildFrame(firstTC); - if (!firstPosArray) { - logStatus("[import_USD] Skipping BasisCurves with no point data: %s\n", - primName.c_str()); - return; + bool visible = true; + if (auto visibilitySchema = + pxr::HdVisibilitySchema::GetFromParent(prim.dataSource)) { + if (auto value = visibilitySchema.GetVisibility()) + visible = value->GetTypedValue(0); } - - // Build a per-vertex radius array (barney's Curve::bounds() unconditionally - // dereferences vertex.radius, so we must always supply it even when using a - // uniform radius). - const float kCurveRadius = 0.3f; - std::vector radii(firstPosArray->size(), kCurveRadius); - auto radArray = scene.createArray(ANARI_FLOAT32, radii.size()); - radArray->setData(radii.data(), radii.size()); - - auto geom = scene.createObject(tokens::geometry::curve); - geom->setName(primName.c_str()); - geom->setParameterObject("vertex.position", *firstPosArray); - geom->setParameterObject("vertex.radius", *radArray); - - // barney's Curve::setBarneyParameters() also unconditionally dereferences - // primitive.index, so we must always supply it. - auto idxArray = scene.createArray(ANARI_UINT32, segIndices.size()); - idxArray->setData(segIndices.data(), segIndices.size()); - geom->setParameterObject("primitive.index", *idxArray); - - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); - auto surface = scene.createSurface(primName.c_str(), geom, mat); - scene.insertChildObjectNode(parent, surface); - - logStatus("[import_USD] Imported BasisCurves '%s' (%zu segs, %zu frames)\n", - primName.c_str(), - segIndices.size(), - timeSamples.empty() ? size_t(1) : timeSamples.size()); - - // Build animation if there are multiple time samples - if (timeSamples.size() > 1) { - std::vector> posArrays; - posArrays.push_back(firstPosArray); - for (size_t ti = 1; ti < timeSamples.size(); ++ti) { - auto arr = buildFrame(pxr::UsdTimeCode(timeSamples[ti])); - if (arr) - posArrays.push_back(arr); - } - if (posArrays.size() > 1) { - auto tb = makeLinearTimeBase(posArrays.size()); - auto &anim = animMgr.addAnimation(primName.c_str()); - addArrayTimeStepBindings( - anim, geom.data(), {Token("vertex.position")}, {posArrays}, tb); - } + if (!visible && !hidden) { + ctx.reportSkip( + primPath, prim.primType.GetString(), UsdSkipReason::RESOLVED_INVISIBLE); } -} - -// Helper: Import a UsdGeomSphere prim as a TSD sphere geometry -static void importUsdSphere(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) -{ - pxr::UsdGeomSphere spherePrim(prim); - // UsdGeomSphere is always centered at the origin in local space - pxr::GfVec3f center(0.f, 0.f, 0.f); - double radius = 1.0; - spherePrim.GetRadiusAttr().Get(&radius); - pxr::GfVec4d c4(center[0], center[1], center[2], 1.0); - pxr::GfVec4d wc4 = usdXform * c4; - float3 wp{float(wc4[0]), float(wc4[1]), float(wc4[2])}; - auto geom = scene.createObject(tokens::geometry::sphere); - auto posArray = scene.createArray(ANARI_FLOAT32_VEC3, 1); - posArray->setData(&wp, 1); - auto radArray = scene.createArray(ANARI_FLOAT32, 1); - float r = float(radius); - radArray->setData(&r, 1); - geom->setParameterObject("vertex.position", *posArray); - geom->setParameterObject("vertex.radius", *radArray); - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - geom->setName(primName.c_str()); + const bool subtreeHidden = hidden || !visible; - // Material binding - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); + // Node for this prim // - auto surface = scene.createSurface(primName.c_str(), geom, mat); - logStatus("[import_USD] Assigned material to sphere '%s': %s\n", - primName.c_str(), - mat->name().c_str()); - scene.insertChildObjectNode(parent, surface); -} - -// Helper: Import a UsdGeomCone prim as a TSD cone geometry -static void importUsdCone(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) -{ - pxr::UsdGeomCone conePrim(prim); - // UsdGeomCone is always centered at the origin in local space - pxr::GfVec3f center(0.f, 0.f, 0.f); - double height = 2.0; - conePrim.GetHeightAttr().Get(&height); - double radius = 1.0; - conePrim.GetRadiusAttr().Get(&radius); - pxr::TfToken axis; - conePrim.GetAxisAttr().Get(&axis); - // TODO: Handle axis != Z - pxr::GfVec4d c4(center[0], center[1], center[2], 1.0); - pxr::GfVec4d wc4 = usdXform * c4; - float3 wp{float(wc4[0]), float(wc4[1]), float(wc4[2])}; - // Represent as a 2-point cone (base and apex) - std::vector positions = {wp, wp + float3(0, 0, float(height))}; - std::vector radii = {float(radius), 0.f}; - auto geom = scene.createObject(tokens::geometry::cone); - auto posArray = scene.createArray(ANARI_FLOAT32_VEC3, 2); - posArray->setData(positions.data(), 2); - auto radArray = scene.createArray(ANARI_FLOAT32, 2); - radArray->setData(radii.data(), 2); - geom->setParameterObject("vertex.position", *posArray); - geom->setParameterObject("vertex.radius", *radArray); - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - geom->setName(primName.c_str()); - - // Material binding - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); - - auto surface = scene.createSurface(primName.c_str(), geom, mat); - logStatus("[import_USD] Assigned material to cone '%s': %s\n", - primName.c_str(), - mat->name().c_str()); - scene.insertChildObjectNode(parent, surface); -} - -// Helper: Import a UsdGeomCylinder prim as a TSD cylinder geometry -static void importUsdCylinder(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) -{ - pxr::UsdGeomCylinder cylPrim(prim); - // UsdGeomCylinder is always centered at the origin in local space - pxr::GfVec3f center(0.f, 0.f, 0.f); - double height = 2.0; - cylPrim.GetHeightAttr().Get(&height); - double radius = 1.0; - cylPrim.GetRadiusAttr().Get(&radius); - pxr::TfToken axis; - cylPrim.GetAxisAttr().Get(&axis); - // TODO: Handle axis != Z - pxr::GfVec4d c4(center[0], center[1], center[2], 1.0); - pxr::GfVec4d wc4 = usdXform * c4; - float3 wp{float(wc4[0]), float(wc4[1]), float(wc4[2])}; - // Represent as a 2-point cylinder (bottom and top) - std::vector positions = {wp - float3(0, 0, float(height) * 0.5f), - wp + float3(0, 0, float(height) * 0.5f)}; - std::vector radii = {float(radius), float(radius)}; - auto geom = scene.createObject(tokens::geometry::cylinder); - auto posArray = scene.createArray(ANARI_FLOAT32_VEC3, 2); - posArray->setData(positions.data(), 2); - auto radArray = scene.createArray(ANARI_FLOAT32, 2); - radArray->setData(radii.data(), 2); - geom->setParameterObject("vertex.position", *posArray); - geom->setParameterObject("vertex.radius", *radArray); - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - geom->setName(primName.c_str()); - - // Material binding - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); - - auto surface = scene.createSurface(primName.c_str(), geom, mat); - tsd::core::logStatus("[import_USD] Assigned material to cylinder '%s': %s\n", - primName.c_str(), - mat->name().c_str()); - scene.insertChildObjectNode(parent, surface); -} - -// Helper: Import a UsdGeomCube prim as a triangulated TSD triangle mesh, -// with animation if xformOps on the prim or its ancestors are time-sampled. -static void importUsdCube(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - tsd::animation::AnimationManager &animMgr, - MaterialCache &matCache, - TextureCache &texCache) -{ - pxr::UsdGeomCube cubePrim(prim); - double size = 2.0; - cubePrim.GetSizeAttr().Get(&size, pxr::UsdTimeCode::EarliestTime()); - float h = float(size) * 0.5f; - - // 6 faces x 4 verts = 24 vertices with per-face normals, 12 triangles - // Face order: +Z, -Z, +Y, -Y, +X, -X - static const float cx[6][4][3] = { - {{-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1}}, // +Z - {{1, -1, -1}, {-1, -1, -1}, {-1, 1, -1}, {1, 1, -1}}, // -Z - {{-1, 1, -1}, {1, 1, -1}, {1, 1, 1}, {-1, 1, 1}}, // +Y - {{-1, -1, 1}, {1, -1, 1}, {1, -1, -1}, {-1, -1, -1}}, // -Y - {{1, -1, 1}, {1, -1, -1}, {1, 1, -1}, {1, 1, 1}}, // +X - {{-1, -1, -1}, {-1, -1, 1}, {-1, 1, 1}, {-1, 1, -1}}, // -X - }; - static const float lnx[6][3] = { - {0, 0, 1}, {0, 0, -1}, {0, 1, 0}, {0, -1, 0}, {1, 0, 0}, {-1, 0, 0}}; - - // Build index buffer (static, never changes) - std::vector indices; - indices.reserve(36); - for (int f = 0; f < 6; ++f) { - uint32_t base = uint32_t(f * 4); - indices.push_back(base + 0); - indices.push_back(base + 1); - indices.push_back(base + 2); - indices.push_back(base + 0); - indices.push_back(base + 2); - indices.push_back(base + 3); - } - - // Build position+normal arrays for one world transform. - // Positions and normals are baked into world space so that animated xform - // time steps can be represented by animating vertex.position/vertex.normal - // (mirrors the approach used by importUsdCurves). - auto buildFrame = [&](const pxr::GfMatrix4d &xfm) - -> std::pair, ObjectUsePtr> { - std::vector positions; - std::vector normals; - positions.reserve(24); - normals.reserve(24); - for (int f = 0; f < 6; ++f) { - for (int v = 0; v < 4; ++v) { - pxr::GfVec4d lp(cx[f][v][0] * h, cx[f][v][1] * h, cx[f][v][2] * h, 1.0); - pxr::GfVec4d wp = xfm * lp; - positions.push_back(float3(float(wp[0]), float(wp[1]), float(wp[2]))); - } - // TransformDir applies rotation+scale only (no translation) — correct for - // normals when there is no non-uniform scale. Normalize to handle uniform - // scale. - pxr::GfVec3d wn = - xfm.TransformDir(pxr::GfVec3d(lnx[f][0], lnx[f][1], lnx[f][2])); - wn.Normalize(); - float3 wn3{float(wn[0]), float(wn[1]), float(wn[2])}; - for (int v = 0; v < 4; ++v) - normals.push_back(wn3); - } - auto posArr = scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); - posArr->setData(positions.data(), positions.size()); - auto normArr = scene.createArray(ANARI_FLOAT32_VEC3, normals.size()); - normArr->setData(normals.data(), normals.size()); - return {posArr, normArr}; - }; - - // Collect xform time samples from this prim only — do NOT walk ancestors. - // Parent Xform animation is already handled by the animated transform node - // created in importUsdPrimRecursive (setAsTransformSteps). Walking up - // the hierarchy would bake the parent rotation into world-space vertex - // positions while the transform node applies it a second time, producing a - // double-transform (e.g. 720° apparent rotation for a 360° animated parent). - std::vector timeSamples; - { - pxr::UsdGeomXformable xformable(prim); - if (xformable) - xformable.GetTimeSamples(&timeSamples); - std::sort(timeSamples.begin(), timeSamples.end()); - timeSamples.erase( - std::unique(timeSamples.begin(), timeSamples.end()), timeSamples.end()); - } - - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - - auto geom = scene.createObject(tokens::geometry::triangle); - geom->setName(primName.c_str()); - - auto [firstPos, firstNorm] = buildFrame(usdXform); - geom->setParameterObject("vertex.position", *firstPos); - geom->setParameterObject("vertex.normal", *firstNorm); - - auto idxArr = scene.createArray(ANARI_UINT32_VEC3, indices.size() / 3); - idxArr->setData((uint3 *)indices.data(), indices.size() / 3); - geom->setParameterObject("primitive.index", *idxArr); - - MaterialRef mat = getBoundMaterial(scene, prim, basePath, matCache, texCache); - auto surface = scene.createSurface(primName.c_str(), geom, mat); - logStatus("[import_USD] Assigned material to cube '%s': %s\n", - primName.c_str(), - mat->name().c_str()); - scene.insertChildObjectNode(parent, surface); - - // Build per-frame animation if xform ops have time samples - if (timeSamples.size() > 1) { - std::vector> posArrays; - std::vector> normArrays; - posArrays.push_back(firstPos); - normArrays.push_back(firstNorm); - - pxr::UsdGeomXformCache cache; - for (size_t ti = 1; ti < timeSamples.size(); ++ti) { - cache.SetTime(pxr::UsdTimeCode(timeSamples[ti])); - bool resets = false; - auto xfm = cache.GetLocalTransformation(prim, &resets); - auto [posArr, normArr] = buildFrame(xfm); - posArrays.push_back(posArr); - normArrays.push_back(normArr); - } - - auto tb = makeLinearTimeBase(posArrays.size()); - auto &anim = animMgr.addAnimation(primName.c_str()); - addArrayTimeStepBindings(anim, - geom.data(), - {Token("vertex.position"), Token("vertex.normal")}, - {posArrays, normArrays}, - tb); - - logStatus("[import_USD] Cube '%s': animated xform over %zu frames\n", - primName.c_str(), - timeSamples.size()); - } -} - -// Helper: Import a UsdVolVolume prim as a TSD volume geometry -static void importUsdVolume(Scene &scene, - tsd::animation::AnimationManager &animMgr, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform) -{ - pxr::UsdVolVolume volumePrim(prim); - - std::string primName = prim.GetPath().GetString(); - if (primName.empty()) - primName = ""; - - // Find the field data by following field relationships - std::vector filePaths; - bool isVTUAsset = false; - std::optional propertyName; - - // Try field:volume relationship first (for VDB volumes and OpenVDBAsset) - pxr::UsdRelationship fieldRel = - prim.GetRelationship(pxr::TfToken("field:volume")); - if (!fieldRel) - fieldRel = prim.GetRelationship(pxr::TfToken("field:density")); - - if (fieldRel) { - pxr::SdfPathVector targets; - fieldRel.GetTargets(&targets); - if (!targets.empty()) { - pxr::UsdPrim fieldPrim = prim.GetStage()->GetPrimAtPath(targets[0]); - if (fieldPrim) { - isVTUAsset = fieldPrim.GetTypeName() == "VTUAsset"; - if (isVTUAsset) { - // Nullable property: unset means default. - // Empty means no property. else load what's requested. - if (auto propAttr = - fieldPrim.GetAttribute(pxr::TfToken("property"))) { - std::string val; - if (propAttr.Get(&val)) - propertyName = std::move(val); - } - } - - pxr::UsdAttribute filePathAttr = - fieldPrim.GetAttribute(pxr::TfToken("filePath")); - if (filePathAttr) { - // Collect time-sampled file paths for animation - std::vector timeSamples; - filePathAttr.GetTimeSamples(&timeSamples); - - if (!timeSamples.empty()) { - for (double t : timeSamples) { - pxr::SdfAssetPath ap; - if (filePathAttr.Get(&ap, t)) { - auto p = ap.GetResolvedPath(); - if (p.empty()) - p = ap.GetAssetPath(); - if (p.empty()) { - logWarning( - "[import_USD] volume '%s': empty filePath at time %g", - primName.c_str(), - t); - continue; - } - filePaths.push_back(std::move(p)); - } - } - } else { - // No time samples — read default-time value - pxr::SdfAssetPath ap; - if (filePathAttr.Get(&ap)) { - auto p = ap.GetResolvedPath(); - if (p.empty()) - p = ap.GetAssetPath(); - if (!p.empty()) - filePaths.push_back(std::move(p)); - } - } - } - } - } - } - - if (filePaths.empty()) { - tsd::core::logStatus( - "[import_USD] No field data file found for volume '%s'\n", - primName.c_str()); - return; - } - - std::string filePath = filePaths[0]; - - SpatialFieldRef field; - if (isVTUAsset) { - field = - import_spatial_field(scene, filePath.c_str(), std::move(propertyName)); - } else { - const auto ext = extensionOf(filePath); - if (ext == ".raw") - field = import_RAW(scene, filePath.c_str()); - else if (ext == ".flash") - field = import_FLASH(scene, filePath.c_str()); - else if (ext == ".nvdb" || ext == ".vdb") - field = import_NVDB(scene, filePath.c_str()); - else if (ext == ".mhd") - field = import_MHD(scene, filePath.c_str()); - else if (ext == ".vtu") - field = import_VTU(scene, filePath.c_str(), propertyName); - else { - throw std::runtime_error( - "[import_USD] no loader for file type '" + ext + "'"); + bool resetsXformStack = false; + const auto localXform = localTransformOf(primPath, &resetsXformStack); + auto node = ctx.scene.insertChildTransformNode( + parent, localXform, primPath.GetName().c_str()); + if (resetsXformStack) + (*node)->setInstanceParameter("usd:resetXformStack", Any(true)); + if (subtreeHidden) + (*node)->setEnabled(false); + + // Content // + + bool converted = false; + if (prim.primType.IsEmpty()) { + if (!isHierarchyPrim(primPath)) { + ctx.scene.removeNode(node); + ctx.reportSkip(primPath, + ctx.stage->GetPrimAtPath(primPath).GetTypeName().GetString(), + UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + insertPlaceholder( + ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + return; } - } - - if (!field) { - tsd::core::logStatus( - "[import_USD] No field data found for volume '%s'\n", primName.c_str()); - return; - } - - // Get volume bounds from the field itself (MHD files contain spatial - // information) We'll let the field define its own spatial extents - - // Check for transfer function from USD material - VolumeTransferFunction tf = getVolumeTransferFunction(prim); - - // Default to the field's value range to avoid undefined ranges. - math::float2 valueRange = field->computeValueRange(); - - // Create a volume node and assign the field, color map, and value range - auto [inst, volume] = scene.insertNewChildObjectNode( - parent, tokens::volume::transferFunction1D); - volume->setName(primName.c_str()); - volume->setParameterObject("value", *field); - - if (tf.hasTransferFunction && !tf.colors.empty()) { - auto coreTF = toTransferFunction(tf); - if (!coreTF.colorPoints.empty() && !coreTF.opacityPoints.empty()) { - applyTransferFunction(scene, volume, coreTF); - if (coreTF.range.lower < coreTF.range.upper) - valueRange = math::float2(coreTF.range.lower, coreTF.range.upper); + } else if (isGeometryPrimType(prim.primType)) { + for (auto &surface : convertGeometry( + ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4)) { + ctx.scene.insertChildObjectNode(node, surface, surface->name().c_str()); + if (auto *geometry = surface->parameterValueAsObject( + tokens::surface::geometry)) + addDeformingGeometryAnimation(ctx, primPath, geometry->self()); + } + converted = true; + } else if (prim.primType == pxr::HdPrimTypeTokens->instancer) { + convertInstancer(ctx, sceneIndex, primPath, prim, node, instancers); + converted = true; + } else if (isLightPrimType(prim.primType)) { + if (auto light = convertLight(ctx, sceneIndex, primPath, prim)) { + ctx.scene.insertChildObjectNode(node, light, primPath.GetName().c_str()); + converted = true; } else { - auto colors = makeDefaultColorMap(256); - auto colorArray = scene.createArray(ANARI_FLOAT32_VEC4, colors.size()); - colorArray->setData(colors); - volume->setParameterObject("color", *colorArray); - volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); - } - } else { - auto colors = makeDefaultColorMap(256); - auto colorArray = scene.createArray(ANARI_FLOAT32_VEC4, colors.size()); - colorArray->setData(colors); - volume->setParameterObject("color", *colorArray); - volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); - } - - // Override valueRange from custom USD attribute if present - pxr::GfVec2f customRange; - if (auto attr = prim.GetAttribute(pxr::TfToken("anari:valueRange"))) { - if (attr.Get(&customRange)) { - valueRange = math::float2(customRange[0], customRange[1]); - volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); - } - } - - // unitDistance: prefer transfer function value, then custom USD attribute - float unitDistance = tf.unitDistance; - if (unitDistance <= 0.0f) { - if (auto attr = prim.GetAttribute(pxr::TfToken("anari:unitDistance"))) { - attr.Get(&unitDistance); - } - } - if (unitDistance > 0.0f) - volume->setParameter("unitDistance", unitDistance); - - if (filePaths.size() > 1) { - auto &anim = animMgr.addAnimation(primName); - anim.emplaceFileBinding( - &scene, volume.data(), field, std::move(filePaths)); - } -} - -// ----------------------------------------------------------------------------- -// Light import helpers -// ----------------------------------------------------------------------------- - -static void importUsdDistantLight( - Scene &scene, const pxr::UsdPrim &prim, LayerNodeRef parent) -{ - pxr::UsdLuxDistantLight usdLight(prim); - auto light = scene.createObject(tokens::light::directional); - float intensity = 1.0f; - usdLight.GetIntensityAttr().Get(&intensity); - pxr::GfVec3f color(1.0f); - usdLight.GetColorAttr().Get(&color); - light->setParameter("color", float3(color[0], color[1], color[2])); - light->setParameter("irradiance", intensity); - // TODO: set direction from transform - scene.insertChildObjectNode(parent, light); -} - -static void importUsdRectLight( - Scene &scene, const pxr::UsdPrim &prim, LayerNodeRef parent) -{ - pxr::UsdLuxRectLight usdLight(prim); - auto light = scene.createObject(tokens::light::quad); - float intensity = 1.0f; - usdLight.GetIntensityAttr().Get(&intensity); - pxr::GfVec3f color(1.0f); - usdLight.GetColorAttr().Get(&color); - double width = 1.0, height = 1.0; - usdLight.GetWidthAttr().Get(&width); - usdLight.GetHeightAttr().Get(&height); - light->setParameter("color", float3(color[0], color[1], color[2])); - light->setParameter("intensity", intensity); - light->setParameter("edge1", float3(width, 0.f, 0.f)); - light->setParameter("edge2", float3(0.f, height, 0.f)); - // TODO: set position from transform - scene.insertChildObjectNode(parent, light); -} - -static void importUsdSphereLight( - Scene &scene, const pxr::UsdPrim &prim, LayerNodeRef parent) -{ - pxr::UsdLuxSphereLight usdLight(prim); - auto light = scene.createObject(tokens::light::point); - float intensity = 1.0f; - usdLight.GetIntensityAttr().Get(&intensity); - pxr::GfVec3f color(1.0f); - usdLight.GetColorAttr().Get(&color); - double radius = 1.0; - usdLight.GetRadiusAttr().Get(&radius); - light->setParameter("color", float3(color[0], color[1], color[2])); - light->setParameter("intensity", intensity); - // TODO: set position from transform - // Optionally, set radius as metadata or custom param - scene.insertChildObjectNode(parent, light); -} - -static void importUsdDiskLight( - Scene &scene, const pxr::UsdPrim &prim, LayerNodeRef parent) -{ - pxr::UsdLuxDiskLight usdLight(prim); - auto light = scene.createObject(tokens::light::ring); - float intensity = 1.0f; - usdLight.GetIntensityAttr().Get(&intensity); - pxr::GfVec3f color(1.0f); - usdLight.GetColorAttr().Get(&color); - double radius = 1.0; - usdLight.GetRadiusAttr().Get(&radius); - light->setParameter("color", float3(color[0], color[1], color[2])); - light->setParameter("intensity", intensity); - // TODO: set position from transform - // Optionally, set radius as metadata or custom param - scene.insertChildObjectNode(parent, light); -} - -static void importUsdDomeLight(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - const std::string &basePath, - const pxr::GfMatrix4d &usdXform) -{ - pxr::UsdLuxDomeLight usdLight(prim); - auto light = scene.createObject(tokens::light::hdri); - light->setName(prim.GetName().GetText()); - float intensity = 1.0f; - usdLight.GetIntensityAttr().Get(&intensity); - pxr::GfVec3f color(1.0f); - usdLight.GetColorAttr().Get(&color); - light->setParameter("color", float3(color[0], color[1], color[2])); - light->setParameter("scale", intensity); - - // Extract direction and up vectors from transformation matrix - // ANARI defaults: direction=(1,0,0), up=(0,0,1) - // USD dome lights use Z-up by default, matching ANARI - auto xfm = pxr::GfMatrix4d( - // clang-format off - 0.0, 1.0, 0.0, 0.0, - 0.0, 0.0, 1.0, 0.0, - 1.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 1.0 - // clang-format on - ); - xfm *= usdXform; - pxr::GfVec3d dirVec = xfm.TransformDir(pxr::GfVec3d(0, 0, -1)); - pxr::GfVec3d upVec = xfm.TransformDir(pxr::GfVec3d(0, 1, 0)); - - float3 direction(dirVec[0], dirVec[1], dirVec[2]); - float3 up(upVec[0], upVec[1], upVec[2]); - - light->setParameter("direction", direction); - light->setParameter("up", up); - - // Synthesize a 1x1 fallback radiance from solid color * intensity if no - // texture is provided (barney requires radiance to be set on HDRI lights). - { - float3 solidColor(color[0], color[1], color[2]); - solidColor *= intensity; - auto fallbackRadiance = scene.createArray(ANARI_FLOAT32_VEC3, 1, 1); - fallbackRadiance->setData(&solidColor); - light->setParameterObject("radiance", *fallbackRadiance); - } - - // Load and set environment texture from usdLight.GetTextureFileAttr() - pxr::SdfAssetPath textureAsset; - if (usdLight.GetTextureFileAttr().Get(&textureAsset)) { - std::string texFile = textureAsset.GetResolvedPath(); - if (texFile.empty()) - texFile = textureAsset.GetAssetPath(); - if (!texFile.empty()) { - // Use basePath to resolve relative paths if needed - std::string resolvedPath = texFile; - if (!resolvedPath.empty() && !isAbsolute(resolvedPath)) { - resolvedPath = basePath + texFile; - } - - ArrayRef radiance = {}; - if (resolvedPath.find(".exr") != std::string::npos - || resolvedPath.find(".hdr") != std::string::npos) { - HDRImage img; - if (img.import(resolvedPath)) { - std::vector rgb(img.width * img.height); - - if (img.numComponents == 3) { - memcpy(rgb.data(), img.pixel.data(), sizeof(rgb[0]) * rgb.size()); - } else if (img.numComponents == 4) { - for (size_t i = 0; i < img.pixel.size(); i += 4) { - rgb[i / 4] = - float3(img.pixel[i], img.pixel[i + 1], img.pixel[i + 2]); - } - } - - // Handle color temperature if present - float colorTemp = 0.0f; - if (usdLight.GetColorTemperatureAttr().Get(&colorTemp) - && colorTemp > 0.0f) { - // Convert color temperature to RGB multiplier - // Using approximation from Planckian locus - auto kelvinToRGB = [](float kelvin) -> float3 { - // https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html - float temp = kelvin / 100.0f; - float red, green, blue; - - // Calculate red - if (temp <= 66.0f) { - red = 1.0f; - } else { - red = temp - 60.0f; - red = 329.698727446f * std::pow(red, -0.1332047592f); - red = std::clamp(red / 255.0f, 0.0f, 1.0f); - } - - // Calculate green - if (temp <= 66.0f) { - green = temp; - green = 99.4708025861f * std::log(green) - 161.1195681661f; - green = std::clamp(green / 255.0f, 0.0f, 1.0f); - } else { - green = temp - 60.0f; - green = 288.1221695283f * std::pow(green, -0.0755148492f); - green = std::clamp(green / 255.0f, 0.0f, 1.0f); - } - - // Calculate blue - if (temp >= 66.0f) { - blue = 1.0f; - } else if (temp <= 19.0f) { - blue = 0.0f; - } else { - blue = temp - 10.0f; - blue = 138.5177312231f * std::log(blue) - 305.0447927307f; - blue = std::clamp(blue / 255.0f, 0.0f, 1.0f); - } - - return float3(red, green, blue); - }; - - float3 tempColor = kelvinToRGB(colorTemp); - for (auto &color : rgb) { - color *= float3(tempColor.x, tempColor.y, tempColor.z); - } - tsd::core::logStatus( - "[import_USD] Applied dome light color temperature: %f K (%f %f %f)\n", - colorTemp, - tempColor.x, - tempColor.y, - tempColor.z); - } - - // Apply exposure adjustment if present - float exposure = 0.0f; - if (usdLight.GetExposureAttr().Get(&exposure)) { - // Convert exposure to linear scale: multiplier = 2^exposure - float exposureScale = std::pow(2.0f, exposure); - for (auto &color : rgb) { - color *= exposureScale; - } - tsd::core::logStatus( - "[import_USD] Applied dome light exposure: %f (scale: %f)\n", - exposure, - exposureScale); - } - - radiance = - scene.createArray(ANARI_FLOAT32_VEC3, img.width, img.height); - radiance->setData(rgb.data()); - } - } - if (radiance) - light->setParameterObject("radiance", *radiance); - else - tsd::core::logStatus( - "[import_USD] Warning: Failed to load dome light texture: %s\n", - resolvedPath.c_str()); - } - } - scene.insertChildObjectNode(parent, light); -} - -// Helper: Import a UsdGeomCamera prim as an animated TSD camera. -// Collects xform time samples from the prim and parent hierarchy so that -// orbit/crane rigs animate correctly even when the camera prim itself is -// static. -static void importUsdCamera(Scene &scene, - const pxr::UsdPrim &prim, - tsd::animation::AnimationManager &animMgr) -{ - std::string primName = prim.GetName().GetString(); - if (primName.empty()) - primName = ""; - - pxr::UsdGeomCamera usdCamera(prim); - - // Read intrinsics at default time (usually static) - pxr::GfCamera gfCamDef = usdCamera.GetCamera(pxr::UsdTimeCode::Default()); - bool isPerspective = gfCamDef.GetProjection() == pxr::GfCamera::Perspective; - const char *cameraType = isPerspective ? "perspective" : "orthographic"; - float focalLength = gfCamDef.GetFocalLength(); - float horizAp = gfCamDef.GetHorizontalAperture(); - float vertAp = gfCamDef.GetVerticalAperture(); - float fovV = 2.f * std::atan(vertAp / (2.f * focalLength)); - - auto camera = scene.createObject(cameraType); - camera->setName(primName.c_str()); - - if (isPerspective) { - camera->setParameter("fovy", fovV); - camera->setParameter("aspect", horizAp / vertAp); - } else { - camera->setParameter("height", vertAp); - camera->setParameter("aspect", horizAp / vertAp); - } - - // Collect xform time samples from prim and its parent hierarchy - std::vector timeSamples; - for (auto cur = prim; cur && !cur.IsPseudoRoot(); cur = cur.GetParent()) { - pxr::UsdGeomXformable xformable(cur); - if (xformable) { - std::vector ts; - xformable.GetTimeSamples(&ts); - for (double t : ts) - timeSamples.push_back(t); + ctx.scene.removeNode(node); + insertPlaceholder( + ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + return; } - } - - // Also collect time samples from all animatable intrinsic attributes - std::vector intrinsicTs; - { - auto collect = [&](pxr::UsdAttribute attr) { - std::vector tmp; - attr.GetTimeSamples(&tmp); - for (double t : tmp) - intrinsicTs.push_back(t); - }; - collect(usdCamera.GetFocalLengthAttr()); - collect(usdCamera.GetHorizontalApertureAttr()); - collect(usdCamera.GetVerticalApertureAttr()); - collect(usdCamera.GetFStopAttr()); - collect(usdCamera.GetFocusDistanceAttr()); - collect(usdCamera.GetClippingRangeAttr()); - } - bool hasIntrinsicAnimation = !intrinsicTs.empty(); - for (double t : intrinsicTs) - timeSamples.push_back(t); - - std::sort(timeSamples.begin(), timeSamples.end()); - timeSamples.erase( - std::unique(timeSamples.begin(), timeSamples.end()), timeSamples.end()); - - // Compute world-space pose from a transform cache at a given time - auto buildPose = - [&](pxr::UsdGeomXformCache &cache) -> std::tuple { - auto xfm = cache.GetLocalToWorldTransform(prim); - auto gfPos = xfm.Transform(pxr::GfVec3d(0, 0, 0)); - auto gfDir = xfm.TransformDir(pxr::GfVec3d(0, 0, -1)); - gfDir.Normalize(); - auto gfUp = xfm.TransformDir(pxr::GfVec3d(0, 1, 0)); - gfUp.Normalize(); - return {float3{float(gfPos[0]), float(gfPos[1]), float(gfPos[2])}, - float3{float(gfDir[0]), float(gfDir[1]), float(gfDir[2])}, - float3{float(gfUp[0]), float(gfUp[1]), float(gfUp[2])}}; - }; - - // Always seed the camera with a valid pose at Default time - { - pxr::UsdGeomXformCache initCache(pxr::UsdTimeCode::Default()); - auto [pos, dir, up] = buildPose(initCache); - camera->setParameter("position", pos); - camera->setParameter("direction", dir); - camera->setParameter("up", up); - } - - if (timeSamples.size() <= 1) { - logStatus("[import_USD] Created static camera '%s'\n", primName.c_str()); + } else if (prim.primType == pxr::HdPrimTypeTokens->camera) { + convertCamera(ctx, sceneIndex, primPath, prim); + converted = true; + } else if (prim.primType == pxr::HdPrimTypeTokens->material + || prim.primType == pxr::HdPrimTypeTokens->geomSubset) { + // Materials convert on demand from the prims that bind them; geom subsets + // are consumed by their parent mesh. Neither is a loss. + ctx.scene.removeNode(node); return; - } - - // Build flat per-param arrays (TimeStepValues: one big array per parameter, - // element-indexed by frame — same pattern as Context.cpp camera path - // animation) - size_t numFrames = timeSamples.size(); - auto posArr = scene.createArray(ANARI_FLOAT32_VEC3, numFrames); - auto dirArr = scene.createArray(ANARI_FLOAT32_VEC3, numFrames); - auto upArr = scene.createArray(ANARI_FLOAT32_VEC3, numFrames); - posArr->setName((primName + "_anim_position").c_str()); - dirArr->setName((primName + "_anim_direction").c_str()); - upArr->setName((primName + "_anim_up").c_str()); - - // Intrinsic animation arrays (only allocated when needed) - ObjectUsePtr fovArr, aspectArr, focusDistArr, apertureRadiusArr; - float *fovs = nullptr, *aspects = nullptr; - float *focusDists = nullptr, *apertureRadii = nullptr; - if (hasIntrinsicAnimation) { - fovArr = scene.createArray(ANARI_FLOAT32, numFrames); - fovArr->setName((primName + "_anim_fovy").c_str()); - fovs = fovArr->mapAs(); - - aspectArr = scene.createArray(ANARI_FLOAT32, numFrames); - aspectArr->setName((primName + "_anim_aspect").c_str()); - aspects = aspectArr->mapAs(); - - if (isPerspective) { - focusDistArr = scene.createArray(ANARI_FLOAT32, numFrames); - focusDistArr->setName((primName + "_anim_focusDistance").c_str()); - focusDists = focusDistArr->mapAs(); - - apertureRadiusArr = scene.createArray(ANARI_FLOAT32, numFrames); - apertureRadiusArr->setName((primName + "_anim_apertureRadius").c_str()); - apertureRadii = apertureRadiusArr->mapAs(); - } - } - - auto *positions = posArr->mapAs(); - auto *directions = dirArr->mapAs(); - auto *ups = upArr->mapAs(); - - pxr::UsdGeomXformCache cache; - for (size_t i = 0; i < numFrames; ++i) { - pxr::UsdTimeCode tc(timeSamples[i]); - cache.SetTime(tc); - auto [pos, dir, up] = buildPose(cache); - positions[i] = pos; - directions[i] = dir; - ups[i] = up; - if (fovs) { - pxr::GfCamera gfc = usdCamera.GetCamera(tc); - float fl = gfc.GetFocalLength(); - float va = gfc.GetVerticalAperture(); - float ha = gfc.GetHorizontalAperture(); - fovs[i] = 2.f * std::atan(va / (2.f * fl)); - aspects[i] = ha / va; - if (focusDists) { - focusDists[i] = gfc.GetFocusDistance(); - // apertureRadius from fStop: fl is in tenths of scene units - float fStop = gfc.GetFStop(); - apertureRadii[i] = fStop > 0.f ? (fl / 10.f) / (2.f * fStop) : 0.f; - } - } - } - - posArr->unmap(); - dirArr->unmap(); - upArr->unmap(); - if (fovArr) { - fovArr->unmap(); - aspectArr->unmap(); - if (focusDistArr) { - focusDistArr->unmap(); - apertureRadiusArr->unmap(); - } - } - - std::vector animParams{"position", "direction", "up"}; - std::vector> animArrays{posArr, dirArr, upArr}; - if (hasIntrinsicAnimation) { - animParams.push_back("fovy"); - animArrays.push_back(fovArr); - animParams.push_back("aspect"); - animArrays.push_back(aspectArr); - if (isPerspective) { - animParams.push_back("focusDistance"); - animArrays.push_back(focusDistArr); - animParams.push_back("apertureRadius"); - animArrays.push_back(apertureRadiusArr); - } - } - - auto tb = makeLinearTimeBase(numFrames); - auto &anim = animMgr.addAnimation(primName.c_str()); - addValueTimeStepBindings(anim, - camera.data(), - animParams, - animArrays, - tb, - tsd::animation::InterpolationRule::LINEAR); - - logStatus("[import_USD] Created animated camera '%s' (%zu frames)\n", - primName.c_str(), - numFrames); -} - -// Helper to check if a GfMatrix4d is identity -static bool isIdentity(const pxr::GfMatrix4d &m) -{ - static const pxr::GfMatrix4d IDENTITY(1.0); - return m == IDENTITY; -} - -// ----------------------------------------------------------------------------- -// RenderSettings import -// ----------------------------------------------------------------------------- - -static void importRenderSettings( - const pxr::UsdStageRefPtr &stage, core::DataNode &settings) -{ - for (const auto &prim : stage->Traverse()) { - if (prim.GetTypeName() != "RenderSettings") - continue; - - if (auto attr = prim.GetAttribute(pxr::TfToken("tsd:io:cutPlane"))) { - pxr::GfVec4f val; - if (attr.Get(&val)) { - settings["cutPlane"] = math::float4(val[0], val[1], val[2], val[3]); - logStatus("[import_USD] RenderSettings cutPlane: (%f, %f, %f, %f)", - val[0], - val[1], - val[2], - val[3]); - } - } - - auto collection = - pxr::UsdCollectionAPI::Get(prim, pxr::TfToken("tsd:io:cutPlaneTarget")); - if (collection) { - pxr::SdfPathVector includes; - collection.GetIncludesRel().GetTargets(&includes); - auto &targets = settings["cutPlaneTargets"]; - for (const auto &path : includes) { - targets.append() = std::string(path.GetString()); - logStatus("[import_USD] cutPlaneTarget: %s", path.GetText()); - } - } - - break; // only first RenderSettings prim - } -} - -// ----------------------------------------------------------------------------- -// EnSight-backed mesh import -// ----------------------------------------------------------------------------- - -// Import an entire EnSight dataset referenced by a Scope prim. Creates one -// surface per internal part, all under the same parent node. Delegates to -// import_ENSIGHT which already handles the .case → geometry pipeline. -static void importEnsightDataset(Scene &scene, - const pxr::UsdPrim &scopePrim, - LayerNodeRef parent, - tsd::animation::AnimationManager &animMgr, - const core::DataNode &settings, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) -{ - std::string primName = scopePrim.GetName().GetString(); - - // Find the case file path from the first child's layer stack - std::string caseFile; - for (const auto &child : scopePrim.GetChildren()) { - for (const auto &spec : child.GetPrimStack()) { - auto lcd = spec->GetLayer()->GetCustomLayerData(); - auto it = lcd.find("ensight"); - if (it != lcd.end()) { - const auto &d = it->second.Get(); - auto cfIt = d.find("caseFile"); - if (cfIt != d.end()) { - caseFile = cfIt->second.Get(); - break; - } - } + } else if (isVolumePrimType(prim.primType)) { + converted = convertVolume(ctx, primPath, node); + if (!converted) { + ctx.scene.removeNode(node); + insertPlaceholder( + ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + return; } - if (!caseFile.empty()) - break; - } - - if (caseFile.empty()) { - logWarning("[import_USD] EnSight scope '%s': no case file found", - primName.c_str()); + } else { + ctx.scene.removeNode(node); + ctx.reportSkip(primPath, + prim.primType.GetString(), + UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + insertPlaceholder( + ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } - // Read field mapping from the Scope's attributes - std::vector fields; - for (int i = 0; i < 4; ++i) { - std::string attrName = "ensight:fieldMapping:attribute" + std::to_string(i); - pxr::UsdAttribute attr = scopePrim.GetAttribute(pxr::TfToken(attrName)); - if (!attr) - continue; - std::string varName; - if (attr.Get(&varName) && !varName.empty()) - fields.push_back(varName); - } - - // Check if this prim is a cut plane target and build per-dataset settings - std::string primPath = scopePrim.GetPath().GetString(); - core::DataTree datasetSettings; - const auto *targets = settings.child("cutPlaneTargets"); - const auto *cutPlane = settings.child("cutPlane"); - if (cutPlane && targets) { - for (size_t i = 0; i < targets->numChildren(); ++i) { - if (auto target = targets->child(i)->getValueAs(); - target == primPath) { - datasetSettings.root()["cutPlane"] = cutPlane->getValue(); - // Cutting all the parts of the target. - datasetSettings.root().remove("cutPlaneTargets"); - break; - } else if (target.substr(0, primPath.size() + 1) == primPath + "/") { - datasetSettings.root()["cutPlane"] = cutPlane->getValue(); - datasetSettings.root()["cutPlaneTarget"].append( - target.substr(primPath.size() + 1)); - } - } - } - - // Resolve scope-level material binding as fallback for all parts - MaterialRef fallbackMaterial = - getBoundMaterial(scene, scopePrim, basePath, matCache, texCache); - if (fallbackMaterial == scene.defaultMaterial()) - fallbackMaterial = {}; - - // Build per-part material map from USD child prim bindings. - // Child prim names match sanitized EnSight part names (via CaseFileFormat). - core::FlatMap perPartMaterials; - for (const auto &child : scopePrim.GetChildren()) { - MaterialRef childMat = - getBoundMaterial(scene, child, basePath, matCache, texCache); - if (childMat && childMat != scene.defaultMaterial() - && childMat != fallbackMaterial) - perPartMaterials[child.GetName().GetString()] = childMat; - } + if (converted) + ctx.report.convertedPrims++; - logStatus( - "[import_USD] Importing EnSight dataset '%s' from '%s'" - " (%zu per-part material override(s))", - primName.c_str(), - caseFile.c_str(), - perPartMaterials.size()); + instancers.nodeForPrimPath[primPath.GetString()] = node; + addTransformAnimation(ctx, primPath, node); - import_ENSIGHT(scene, - animMgr, - caseFile.c_str(), - parent, - fields, - datasetSettings.root(), - fallbackMaterial, - perPartMaterials, - 0); + for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) + visit(childPath, node, subtreeHidden); } -// ----------------------------------------------------------------------------- -// Recursive import function for prims and their children -// ----------------------------------------------------------------------------- +} // namespace -static void importUsdPrimRecursive(Scene &scene, - const pxr::UsdPrim &prim, - LayerNodeRef parent, - pxr::UsdGeomXformCache &xformCache, - const std::string &basePath, - const pxr::GfMatrix4d &parentWorldXform, +UsdImportReport import_USD(Scene &scene, tsd::animation::AnimationManager &animMgr, - MaterialCache &matCache, - TextureCache &texCache, - const core::DataNode &settings) + const char *filepath, + LayerNodeRef location, + const UsdImportOptions &options) { - // if (prim.IsPrototype()) return; - if (prim.IsInstance()) { - pxr::UsdPrim prototype = prim.GetPrototype(); - if (prototype) { - bool resetsXformStack = false; - pxr::GfMatrix4d usdLocalXform = - xformCache.GetLocalTransformation(prim, &resetsXformStack); - pxr::GfMatrix4d thisWorldXform = - resetsXformStack ? usdLocalXform : parentWorldXform * usdLocalXform; - tsd::math::mat4 tsdXform = toTsdMat4(usdLocalXform); - std::string primName = prim.GetName().GetString(); - if (primName.empty()) - primName = ""; - auto xformNode = - scene.insertChildTransformNode(parent, tsdXform, primName.c_str()); - importUsdPrimRecursive(scene, - prototype, - xformNode, - xformCache, - basePath, - thisWorldXform, - animMgr, - matCache, - texCache, - settings); - } else { - tsd::core::logStatus("[import_USD] Instance has no prototype: %s\n", - prim.GetName().GetString().c_str()); - } - return; - } + UsdImportReport report; - // Cameras are imported as standalone TSD Camera objects (not scene nodes). - // importUsdCamera walks the hierarchy itself for animated rigs. - if (prim.IsA()) { - importUsdCamera(scene, prim, animMgr); - return; + auto stage = pxr::UsdStage::Open(filepath, pxr::UsdStage::LoadAll); + if (!stage) { + logError("[import_USD] failed to open stage '%s'", filepath); + return report; } + report.stageOpened = true; - // Only declare these in the main body (non-instance case) - bool resetsXformStack = false; - pxr::GfMatrix4d usdLocalXform = - xformCache.GetLocalTransformation(prim, &resetsXformStack); - pxr::GfMatrix4d thisWorldXform = - resetsXformStack ? usdLocalXform : parentWorldXform * usdLocalXform; + ImportContext ctx{ + scene, animMgr, options, report, stage, filepath, pathOf(filepath)}; - // Determine if this prim is a geometry or light - bool isGeometry = prim.IsA() - || prim.IsA() || prim.IsA() - || prim.IsA() || prim.IsA() - || prim.IsA() || prim.IsA(); - bool isVolume = prim.IsA(); - bool isLight = prim.IsA() - || prim.IsA() || prim.IsA() - || prim.IsA() || prim.IsA(); - bool isDomeLight = prim.IsA(); - bool isXform = prim.IsA() || prim.IsA(); + // Values authored only as time samples do not resolve at UsdTimeCode's + // default, so the import reads at the Stage's own start of time instead. + if (stage->HasAuthoredTimeCodeRange()) + ctx.importTime = pxr::UsdTimeCode(stage->GetStartTimeCode()); - // Count children - size_t numChildren = 0; - for (const auto &child : prim.GetChildren()) - ++numChildren; + // Dialect pre-pass: markers on the raw Stage claim whole subtrees, which are + // pruned from the resolved scene so the generic path never converts a + // carrier prim into meaningless geometry. + auto claimed = claimDialectPrims(ctx); - // Check for time-sampled xform animation *before* deciding whether to - // create a node — an animated xform that happens to be identity at the - // default time still needs a node. - std::vector xformTimeSamples; - { - pxr::UsdGeomXformable xformable(prim); - if (xformable) - xformable.GetTimeSamples(&xformTimeSamples); - } - bool hasXformAnimation = xformTimeSamples.size() > 1; + pxr::UsdImagingCreateSceneIndicesInfo createInfo; + createInfo.stage = stage; + createInfo.addDrawModeSceneIndex = false; + auto sceneIndices = pxr::UsdImagingCreateSceneIndices(createInfo); + sceneIndices.stageSceneIndex->SetTime(ctx.importTime); - // Only create a transform node if: - // - The local transform is not identity - // - The prim is geometry, light (not dome), or volume - // For the domelight, the rationale is the domelight can encode the - // transformation in - // its orientation axes and at least VisRTX and Barney do not correctly - // support transforming the HDRI lights. - // - The prim resets the xform stack - // - The prim is an animated pure-xform node - bool createNode = !isIdentity(usdLocalXform) || isGeometry || isLight - || isVolume || resetsXformStack || hasXformAnimation; - createNode = createNode && !isDomeLight; + auto sceneIndex = buildFilterChain( + pruneClaimedPrims(sceneIndices.finalSceneIndex, claimed)); - tsd::math::mat4 tsdXform = toTsdMat4(usdLocalXform); - std::string primName = prim.GetName().GetString(); - if (primName.empty()) - primName = ""; + auto root = scene.insertChildNode( + location ? location : scene.defaultLayer()->root(), filepath); - LayerNodeRef thisNode = parent; - if (createNode) { - thisNode = - scene.insertChildTransformNode(parent, tsdXform, primName.c_str()); - } + // Record how the Stage is framed for the application to consume. No + // corrective root transform is inserted: coordinates stay comparable to the + // Stage and dome lights keep their own orientation. + (*root)->setInstanceParameter( + "usd:upAxis", Any(pxr::UsdGeomGetStageUpAxis(stage).GetText())); + (*root)->setInstanceParameter("usd:metersPerUnit", + Any(float(pxr::UsdGeomGetStageMetersPerUnit(stage)))); - // Attach xform animation for any prim with time-sampled transforms. - // Guard on createNode: if we didn't create a dedicated node (e.g. DomeLight), - // thisNode is the parent and animating it would be incorrect. - if (hasXformAnimation && createNode) { - auto denseTimeSamples = - densifyTimeSamples(xformTimeSamples, prim.GetStage()); + const pxr::SdfPath scopeRoot = options.primPath.empty() + ? pxr::SdfPath::AbsoluteRootPath() + : pxr::SdfPath(options.primPath); - std::vector frames; - frames.reserve(denseTimeSamples.size()); + InstancerRegistry instancers; + Traversal traversal{ctx, sceneIndex, instancers}; - pxr::UsdGeomXformCache tc; - for (double t : denseTimeSamples) { - tc.SetTime(pxr::UsdTimeCode(t)); - bool resets = false; - frames.push_back(toTsdMat4(tc.GetLocalTransformation(prim, &resets))); + scene.beginLayerEditBatch(); + if (scopeRoot == pxr::SdfPath::AbsoluteRootPath()) { + for (const auto &childPath : sceneIndex->GetChildPrimPaths(scopeRoot)) { + if (childPath.GetString() == NATIVE_INSTANCING_ROOT) + continue; + traversal.visit(childPath, root, false); } - size_t numFrames = frames.size(); - auto tb = makeLinearTimeBase(numFrames); - auto &anim = animMgr.addAnimation(primName.c_str()); - addTransformStepBinding(anim, thisNode, frames, tb); - logStatus("[import_USD] '%s': animated transform (%zu frames)\n", - primName.c_str(), - numFrames); - } - - // Check if this Scope/Xform references an EnSight .case dataset. If so, - // import the entire dataset here and skip recursion into children (the - // CaseFileFormat plugin's Mesh prims are just metadata carriers). - if (isXform && numChildren > 0) { - auto firstChild = *prim.GetChildren().begin(); - pxr::VtDictionary childCd = firstChild.GetCustomData(); - if (childCd.count("ensight")) { - importEnsightDataset(scene, - prim, - thisNode, - animMgr, - settings, - basePath, - matCache, - texCache); - return; - } - } - - // Import geometry for this prim (if any). - // Pass identity as the vertex-space transform: geometry data (USD mesh - // vertices, curve points, implicit shape origins) is always expressed in the - // prim's own local space. The transform node created above (tsdXform = - // usdLocalXform) and its animated parent chain handle all world positioning, - // so baking world-space positions here would double-apply every ancestor - // transform. Lights and volumes are excluded — they have different - // world-space semantics and are handled separately. - const pxr::GfMatrix4d identity(1.0); - if (prim.IsA()) { - importUsdMesh( - scene, prim, thisNode, identity, basePath, matCache, texCache); - } else if (prim.IsA()) { - importUsdPoints( - scene, prim, thisNode, identity, basePath, animMgr, matCache, texCache); - } else if (prim.IsA()) { - importUsdSphere( - scene, prim, thisNode, identity, basePath, matCache, texCache); - } else if (prim.IsA()) { - importUsdCone( - scene, prim, thisNode, identity, basePath, matCache, texCache); - } else if (prim.IsA()) { - importUsdCylinder( - scene, prim, thisNode, identity, basePath, matCache, texCache); - } else if (prim.IsA()) { - importUsdCube( - scene, prim, thisNode, identity, basePath, animMgr, matCache, texCache); - } else if (prim.IsA()) { - importUsdCurves( - scene, prim, thisNode, identity, basePath, animMgr, matCache, texCache); - } else if (prim.IsA()) { - importUsdDistantLight(scene, prim, thisNode); - } else if (prim.IsA()) { - importUsdRectLight(scene, prim, thisNode); - } else if (prim.IsA()) { - importUsdSphereLight(scene, prim, thisNode); - } else if (prim.IsA()) { - importUsdDiskLight(scene, prim, thisNode); - } else if (prim.IsA()) { - importUsdDomeLight(scene, prim, thisNode, basePath, thisWorldXform); - } else if (prim.IsA()) { - importUsdVolume(scene, animMgr, prim, thisNode, thisWorldXform); - } - // Recurse into children - for (const auto &child : prim.GetChildren()) { - importUsdPrimRecursive(scene, - child, - thisNode, - xformCache, - basePath, - thisWorldXform, - animMgr, - matCache, - texCache, - settings); - } -} - -void import_USD(Scene &scene, - tsd::animation::AnimationManager &animMgr, - const char *filepath, - LayerNodeRef location) -{ - pxr::UsdStageRefPtr stage = pxr::UsdStage::Open(filepath); - if (!stage) { - tsd::core::logStatus("[import_USD] failed to open stage '%s'", filepath); - return; - } - tsd::core::logStatus("[import_USD] Opened USD stage: %s\n", filepath); - auto defaultPrim = stage->GetDefaultPrim(); - if (defaultPrim) { - tsd::core::logStatus("[import_USD] Default prim: %s\n", - defaultPrim.GetPath().GetString().c_str()); } else { - tsd::core::logStatus("[import_USD] No default prim set.\n"); + traversal.visit(scopeRoot, root, false); } - size_t primCount = 0; - for (auto _ : stage->Traverse()) - ++primCount; - tsd::core::logStatus( - "[import_USD] Number of prims in stage: %zu\n", primCount); - auto usd_root = scene.insertChildNode( - location ? location : scene.defaultLayer()->root(), filepath); - pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + // Native-instance placements live outside the mirrored hierarchy; attach + // their shared Prototype objects at each placement's own node. + attachNativeInstances(ctx, sceneIndex, instancers, root); - std::string basePath = pathOf(filepath); - MaterialCache matCache; - TextureCache texCache; + // Dialect content is routed to the handlers that already know these formats. + importDialectPrims(ctx, claimed, root); + scene.endLayerEditBatch(); - core::DataTree settings; - importRenderSettings(stage, settings.root()); + logStatus("[import_USD] %s: %s", filepath, report.summary().c_str()); - // Traverse all prims in the USD file, but only import top-level prims - for (pxr::UsdPrim const &prim : stage->Traverse()) { - // if (prim.IsPrototype()) continue; - if (prim.GetParent() && prim.GetParent().IsPseudoRoot()) { - importUsdPrimRecursive(scene, - prim, - usd_root, - xformCache, - basePath, - pxr::GfMatrix4d(1.0), - animMgr, - matCache, - texCache, - settings.root()); - } - } - - if (!matCache.empty()) - logStatus("[import_USD] Imported %zu unique materials\n", matCache.size()); - if (!texCache.empty()) - logStatus("[import_USD] Loaded %zu unique textures\n", texCache.size()); + return report; } + #else -void import_USD(Scene &scene, + +UsdImportReport import_USD(Scene &scene, tsd::animation::AnimationManager &animMgr, const char *filepath, - LayerNodeRef location) + LayerNodeRef location, + const UsdImportOptions &options) { - tsd::core::logError("[import_USD] USD not enabled in TSD build."); + logError("[import_USD] USD not enabled in TSD build."); + return {}; } + #endif } // namespace tsd::io diff --git a/tsd/src/tsd/io/serialization/serialization_datatree.cpp b/tsd/src/tsd/io/serialization/serialization_datatree.cpp index 91bd457f7..2b93b6e89 100644 --- a/tsd/src/tsd/io/serialization/serialization_datatree.cpp +++ b/tsd/src/tsd/io/serialization/serialization_datatree.cpp @@ -8,6 +8,7 @@ #include "tsd/core/Logging.hpp" #include "tsd/io/animation/EnSightFileBinding.hpp" #include "tsd/io/animation/SpatialFieldFileBinding.hpp" +#include "tsd/io/animation/UsdGeometryFileBinding.hpp" #include "tsd/io/archives/AnimationManagerArchive.hpp" #include "tsd/io/archives/CameraArchive.hpp" #include "tsd/io/archives/RendererArchive.hpp" @@ -208,6 +209,8 @@ void nodeToAnimation( std::move(data->parts), std::move(data->geoFiles), std::move(data->fieldMappings)); + } else if (kind == "usdGeometry") { + UsdGeometryFileBinding::addToAnimation(anim, scene, fbNode); } else { logWarning("[nodeToAnimation] unknown file binding kind '%s'; skipping", kind.c_str()); diff --git a/tsd/src/tsd/scripting/bindings/IOBindings.cpp b/tsd/src/tsd/scripting/bindings/IOBindings.cpp index 3d6264217..2390f4a34 100644 --- a/tsd/src/tsd/scripting/bindings/IOBindings.cpp +++ b/tsd/src/tsd/scripting/bindings/IOBindings.cpp @@ -16,6 +16,49 @@ namespace tsd::scripting { +// Read USD import settings out of a Lua table. Absent keys keep their +// defaults, so the common case stays `tsd.io.importUSD(scene, anim, file)`. +static tsd::io::UsdImportOptions usdImportOptionsFromLuaTable( + const sol::table &settings) +{ + tsd::io::UsdImportOptions retval; + + if (sol::optional purposes = settings["purposes"]) { + auto readFlag = [&](const char *name, bool &out) { + if (sol::optional value = (*purposes)[name]) + out = *value; + }; + readFlag("default", retval.purposes.defaultPurpose); + readFlag("render", retval.purposes.render); + readFlag("proxy", retval.purposes.proxy); + readFlag("guide", retval.purposes.guide); + } + + if (sol::optional contexts = settings["renderContexts"]) { + retval.renderContexts.clear(); + for (size_t i = 1; i <= contexts->size(); ++i) { + if (sol::optional value = (*contexts)[i]) + retval.renderContexts.push_back(*value); + } + } + + if (sol::optional mode = settings["materialMode"]) { + if (*mode == "materialx") + retval.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + else if (*mode == "mdl") + retval.materialMode = tsd::io::UsdMaterialMode::MDL; + else + retval.materialMode = tsd::io::UsdMaterialMode::PHYSICALLY_BASED; + } + + if (sol::optional level = settings["refinementLevel"]) + retval.refinementLevel = *level; + if (sol::optional primPath = settings["primPath"]) + retval.primPath = *primPath; + + return retval; +} + #define TSD_LUA_IMPORT_WRAP(import_call, filename) \ try { \ import_call; \ @@ -109,6 +152,17 @@ void registerIOBindings(sol::state &lua) const std::string &f, scene::LayerNodeRef loc) { TSD_LUA_IMPORT_WRAP(tsd::io::import_USD(s, anim, f.c_str(), loc), f); + }, + // Settings arrive as a plain table mirroring the option names, so + // scripted imports can be configured without a binding per field. + [](scene::Scene &s, + animation::AnimationManager &anim, + const std::string &f, + scene::LayerNodeRef loc, + sol::table settings) { + auto options = usdImportOptionsFromLuaTable(settings); + TSD_LUA_IMPORT_WRAP( + tsd::io::import_USD(s, anim, f.c_str(), loc, options), f); }); io["importPDB"] = sol::overload( diff --git a/tsd/src/tsd/scripting/tsd.lua b/tsd/src/tsd/scripting/tsd.lua index 05870e731..10dd70ee8 100644 --- a/tsd/src/tsd/scripting/tsd.lua +++ b/tsd/src/tsd/scripting/tsd.lua @@ -936,8 +936,16 @@ function tsd.io.importPLY(...) end function tsd.io.importHDRI(...) end --- Import a USD file. +-- +-- The settings table mirrors the import options: +-- purposes = { default = true, render = true, proxy = false, guide = false } +-- renderContexts = { "", "glslfx" } +-- materialMode = "physicallyBased" | "materialx" | "mdl" +-- refinementLevel = 2 +-- primPath = "/World/Asset" ---@overload fun(scene: tsd.Scene, filename: string) ---@overload fun(scene: tsd.Scene, filename: string, location: tsd.LayerNode) +---@overload fun(scene: tsd.Scene, filename: string, location: tsd.LayerNode, settings: table) function tsd.io.importUSD(...) end --- Import a PBRT v4 scene file. diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 86c31789c..ea7ce4216 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -34,6 +34,7 @@ project_add_executable( test_Scene.cpp test_SceneArchive.cpp test_Token.cpp + test_UsdImport.cpp ) if (TARGET tsd_scivis_studio_model) target_sources(${PROJECT_NAME} PRIVATE @@ -87,6 +88,7 @@ add_test(NAME tsd::Parameter COMMAND ${PROJECT_NAME} "[Parameter]" ) add_test(NAME tsd::Scene COMMAND ${PROJECT_NAME} "[Scene]" ) add_test(NAME tsd::SceneArchive COMMAND ${PROJECT_NAME} "[SceneArchive]" ) add_test(NAME tsd::Token COMMAND ${PROJECT_NAME} "[Token]" ) +add_test(NAME tsd::UsdImport COMMAND ${PROJECT_NAME} "[UsdImport]" ) if (TARGET tsd_scivis_studio_model) add_test(NAME tsd::SciVisStudio COMMAND ${PROJECT_NAME} "[SciVisStudio]") endif() diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp new file mode 100644 index 000000000..a4f5b43ab --- /dev/null +++ b/tsd/tests/test_UsdImport.cpp @@ -0,0 +1,1439 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/core/DataTree.hpp" +#include "tsd/io/UsdImport.hpp" +// std +#include + +// Options and report are plain value types compiled regardless of whether the +// build has OpenUSD, so their tests are unguarded. + +SCENARIO("USD import options round-trip through a data tree", "[UsdImport]") +{ + GIVEN("Options differing from their defaults in every field") + { + tsd::io::UsdImportOptions options; + options.purposes.defaultPurpose = false; + options.purposes.render = false; + options.purposes.proxy = true; + options.purposes.guide = true; + options.renderContexts = {"mtlx", "mdl"}; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + options.refinementLevel = 4; + options.primPath = "/World/Asset"; + + WHEN("They are written to a data tree and read back") + { + tsd::core::DataTree tree; + options.toDataNode(tree.root()); + + tsd::io::UsdImportOptions restored; + restored.fromDataNode(tree.root()); + + THEN("Every field survives the round-trip") + { + REQUIRE(restored.purposes.defaultPurpose == false); + REQUIRE(restored.purposes.render == false); + REQUIRE(restored.purposes.proxy == true); + REQUIRE(restored.purposes.guide == true); + REQUIRE(restored.renderContexts == options.renderContexts); + REQUIRE(restored.materialMode == tsd::io::UsdMaterialMode::MATERIALX); + REQUIRE(restored.refinementLevel == 4); + REQUIRE(restored.primPath == "/World/Asset"); + } + } + } +} + +SCENARIO("Import report counts skipped prims by reason", "[UsdImport]") +{ + tsd::io::UsdImportReport report; + report.stageOpened = true; + report.convertedPrims = 3; + report.skipped.push_back( + {"/a", "Mesh", tsd::io::UsdSkipReason::PURPOSE_EXCLUDED, ""}); + report.skipped.push_back( + {"/b", "Mesh", tsd::io::UsdSkipReason::PURPOSE_EXCLUDED, ""}); + report.skipped.push_back({"/c", + "PhysicsScene", + tsd::io::UsdSkipReason::UNSUPPORTED_PRIM_TYPE, + ""}); + + THEN("Counts are reported per reason") + { + REQUIRE(report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) == 2); + REQUIRE(report.countOf(tsd::io::UsdSkipReason::UNSUPPORTED_PRIM_TYPE) == 1); + REQUIRE(report.countOf(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED) == 0); + REQUIRE(report.contains(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED)); + REQUIRE_FALSE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + } +} + +#if TSD_USE_USD + +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/archives/AnimationManagerArchive.hpp" +#include "tsd/io/importers.hpp" +#include "tsd/scene/Scene.hpp" +// std +#include +#include +#include + +namespace { + +// Writes a text-format Stage to a temporary path for the lifetime of one +// scenario. USD's text format keeps every fixture readable next to the +// assertion it supports and keeps binary assets out of the repository. +struct StageFixture +{ + StageFixture(const char *name, const std::string &contents) + : m_path(std::filesystem::temp_directory_path() / name) + { + std::ofstream file(m_path); + file << contents; + } + + ~StageFixture() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + std::string path() const + { + return m_path.string(); + } + + private: + std::filesystem::path m_path; +}; + +// Depth-first search for the first node whose name matches. +tsd::scene::LayerNodeRef findNode(tsd::scene::Layer *layer, const char *name) +{ + tsd::scene::LayerNodeRef found; + layer->traverse(layer->root(), [&](auto &node, int) { + if (!found && node->name() == name) + found = layer->at(node.index()); + return true; + }); + return found; +} + +const char *QUAD_MESH_BODY = R"( + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] +)"; + +} // namespace + +SCENARIO("A USD Stage's meshes arrive as surfaces", "[UsdImport]") +{ + GIVEN("A Stage with a single quad mesh") + { + StageFixture stage("tsd_test_usd_single_mesh.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Quad" + { +)") + QUAD_MESH_BODY + + R"( + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The mesh becomes one triangle-geometry surface") + { + REQUIRE(report.stageOpened); + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); + + auto geometry = scene.getObject(0); + REQUIRE(geometry); + REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::triangle); + + auto *index = geometry->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 2); // a quad tessellates to two triangles + + auto *position = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(position != nullptr); + REQUIRE(position->size() == 4); + } + + THEN("Nothing is silently lost") + { + REQUIRE(report.skipped.empty()); + REQUIRE(report.convertedPrims == 1); + } + } + } +} + +SCENARIO("A USD Stage's prim hierarchy is mirrored in the Layer", "[UsdImport]") +{ + GIVEN("A Stage nesting a mesh two Xforms deep") + { + StageFixture stage("tsd_test_usd_hierarchy.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + double3 xformOp:translate = (1, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Xform "Group" + { + double3 xformOp:translate = (0, 2, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Mesh "Quad" + { +)") + QUAD_MESH_BODY + + R"( + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + auto *layer = scene.defaultLayer(); + + THEN("Each prim's name is findable at its own level") + { + auto world = findNode(layer, "World"); + auto group = findNode(layer, "Group"); + auto quad = findNode(layer, "Quad"); + REQUIRE(world); + REQUIRE(group); + REQUIRE(quad); + REQUIRE(layer->isAncestorOf(world, group)); + REQUIRE(layer->isAncestorOf(group, quad)); + } + + THEN("Transforms are left nested rather than flattened") + { + auto world = findNode(layer, "World"); + auto group = findNode(layer, "Group"); + REQUIRE((*world)->getTransform()[3].x == Approx(1.0f)); + REQUIRE((*world)->getTransform()[3].y == Approx(0.0f)); + REQUIRE((*group)->getTransform()[3].x == Approx(0.0f)); + REQUIRE((*group)->getTransform()[3].y == Approx(2.0f)); + } + + THEN("Nothing is silently lost") + { + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO( + "Guide and proxy Purpose content is excluded by default", "[UsdImport]") +{ + GIVEN("A Stage with one mesh per Purpose") + { + StageFixture stage("tsd_test_usd_purpose.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Real" + { + uniform token purpose = "default" +)") + QUAD_MESH_BODY + + R"( + } + + def Mesh "Rendered" + { + uniform token purpose = "render" +)" + QUAD_MESH_BODY + + R"( + } + + def Mesh "Stand_In" + { + uniform token purpose = "proxy" +)" + QUAD_MESH_BODY + + R"( + } + + def Mesh "Helper" + { + uniform token purpose = "guide" +)" + QUAD_MESH_BODY + + R"( + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported with default options") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Only default and render Purpose content arrives") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + } + + THEN("Each excluded prim is reported") + { + REQUIRE(report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) == 2); + } + + THEN("Excluded prims leave a disabled Placeholder Node") + { + auto *layer = scene.defaultLayer(); + auto helper = findNode(layer, "Helper"); + REQUIRE(helper); + REQUIRE((*helper)->isEmpty()); + REQUIRE_FALSE((*helper)->isEnabled()); + } + } + + WHEN("The Stage is imported asking for proxy Purpose as well") + { + tsd::io::UsdImportOptions options; + options.purposes.proxy = true; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("Proxy content arrives too") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 3); + REQUIRE(report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) == 1); + } + } + } +} + +SCENARIO("Prims resolving to invisible import as disabled nodes", "[UsdImport]") +{ + GIVEN("A Stage with an invisible mesh") + { + StageFixture stage("tsd_test_usd_invisible.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Hidden" + { + token visibility = "invisible" +)") + QUAD_MESH_BODY + + R"( + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The content still arrives so it can be toggled on") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + } + + THEN("Its node is disabled and the reason is reported") + { + auto *layer = scene.defaultLayer(); + auto hidden = findNode(layer, "Hidden"); + REQUIRE(hidden); + REQUIRE_FALSE((*hidden)->isEnabled()); + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::RESOLVED_INVISIBLE) == 1); + } + } + } +} + +SCENARIO( + "Prim types TSD cannot represent become Placeholder Nodes", "[UsdImport]") +{ + GIVEN("A Stage containing a prim type with no TSD equivalent") + { + StageFixture stage("tsd_test_usd_unsupported.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + def CylinderLight "Tube" + { + float inputs:radius = 0.5 + float inputs:length = 2 + } + + def Mesh "Quad" + { +)") + QUAD_MESH_BODY + + R"( + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The supported content still arrives") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 0); + } + + THEN("The unsupported prim is named in the report") + { + REQUIRE(report.skipped.size() == 1); + REQUIRE(report.skipped[0].primPath == "/World/Tube"); + REQUIRE(report.skipped[0].reason + == tsd::io::UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + } + + THEN("It leaves a disabled Placeholder Node where it belongs") + { + auto *layer = scene.defaultLayer(); + auto tube = findNode(layer, "Tube"); + REQUIRE(tube); + REQUIRE((*tube)->isEmpty()); + REQUIRE_FALSE((*tube)->isEnabled()); + } + } + } +} + +SCENARIO("An import can be restricted to one prim subtree", "[UsdImport]") +{ + GIVEN("A Stage with two sibling assets") + { + StageFixture stage("tsd_test_usd_subtree.usda", + std::string(R"(#usda 1.0 + +def Xform "AssetA" +{ + def Mesh "Quad" + { +)") + QUAD_MESH_BODY + + R"( + } +} + +def Xform "AssetB" +{ + def Mesh "Quad" + { +)" + QUAD_MESH_BODY + + R"( + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The import is pointed at one subtree") + { + tsd::io::UsdImportOptions options; + options.primPath = "/AssetB"; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("Only that subtree arrives") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(report.skipped.empty()); + REQUIRE_FALSE(findNode(scene.defaultLayer(), "AssetA")); + REQUIRE(findNode(scene.defaultLayer(), "AssetB")); + } + } + } +} + +SCENARIO( + "Stage framing metadata is recorded for the application", "[UsdImport]") +{ + GIVEN("A Z-up Stage authored in centimetres") + { + StageFixture stage("tsd_test_usd_framing.usda", + std::string(R"(#usda 1.0 +( + upAxis = "Z" + metersPerUnit = 0.01 +) + +def Mesh "Quad" +{ +)") + QUAD_MESH_BODY + + R"( +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Up-axis and unit scale are recorded on the import root node") + { + auto *layer = scene.defaultLayer(); + auto root = findNode(layer, stage.path().c_str()); + REQUIRE(root); + const auto ¶ms = (*root)->getInstanceParameters(); + const auto *upAxis = params.at("usd:upAxis"); + const auto *scale = params.at("usd:metersPerUnit"); + REQUIRE(upAxis != nullptr); + REQUIRE(scale != nullptr); + REQUIRE(upAxis->getString() == "Z"); + REQUIRE(scale->get() == Approx(0.01f)); + } + + THEN("Geometry coordinates are left exactly as authored") + { + auto geometry = scene.getObject(0); + auto *position = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(position != nullptr); + const auto *p = position->dataAs(); + REQUIRE(p[1].x == Approx(1.0f)); + REQUIRE(p[1].y == Approx(0.0f)); + REQUIRE(p[1].z == Approx(0.0f)); + } + } + } +} + +SCENARIO("A point instancer shares one set of Prototype objects", "[UsdImport]") +{ + GIVEN("A Stage scattering one Prototype three times, one of them hidden") + { + StageFixture stage("tsd_test_usd_point_instancer.usda", R"(#usda 1.0 + +def PointInstancer "Scatter" +{ + point3f[] positions = [(0, 0, 0), (2, 0, 0), (4, 0, 0)] + int64[] ids = [0, 1, 2] + int[] protoIndices = [0, 0, 0] + int64[] invisibleIds = [1] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The Prototype is imported once, not once per placement") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); + } + + THEN("The placements become a single transform-array node") + { + auto *layer = scene.defaultLayer(); + auto scatter = findNode(layer, "Scatter"); + REQUIRE(scatter); + + // The array node is the instancer's own child, holding the visible + // placements only. + tsd::scene::Array *transforms = nullptr; + layer->traverse(scatter, [&](auto &node, int) { + if (!transforms && node->type() == ANARI_ARRAY1D) + transforms = node->getTransformArray(); + return true; + }); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->size() == 2); // the invisible placement is omitted + } + + THEN("Nothing is silently lost") + { + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO("USD Instances share objects across placements", "[UsdImport]") +{ + GIVEN("A Stage referencing one Prototype from two instanceable prims") + { + StageFixture stage("tsd_test_usd_native_instance.usda", R"(#usda 1.0 + +def Xform "Protos" +{ + def Xform "Asset" + { + def Mesh "Quad" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } + } +} + +def Xform "InstanceA" ( + instanceable = true + prepend references = +) +{ + double3 xformOp:translate = (5, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] +} + +def Xform "InstanceB" ( + instanceable = true + prepend references = +) +{ + double3 xformOp:translate = (9, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The Prototype geometry exists once, plus the un-instanced source") + { + // /Protos/Asset/Quad imports as ordinary content; the two placements + // share a single converted Prototype rather than copying it. + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + } + + THEN("Each placement is a node referencing the shared objects") + { + auto *layer = scene.defaultLayer(); + auto a = findNode(layer, "InstanceA"); + auto b = findNode(layer, "InstanceB"); + REQUIRE(a); + REQUIRE(b); + + auto sharedObjectUnder = [&](tsd::scene::LayerNodeRef parent) { + size_t index = tsd::core::INVALID_INDEX; + layer->traverse(parent, [&](auto &node, int) { + if (index == tsd::core::INVALID_INDEX && node->isObject()) + index = node->getObjectIndex(); + return true; + }); + return index; + }; + + const auto indexA = sharedObjectUnder(a); + const auto indexB = sharedObjectUnder(b); + REQUIRE(indexA != tsd::core::INVALID_INDEX); + REQUIRE(indexA == indexB); + } + } + } +} + +SCENARIO("Per-face material subsets become several Surfaces", "[UsdImport]") +{ + GIVEN("A two-face mesh with one face bound to its own material") + { + StageFixture stage("tsd_test_usd_subsets.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Scope "Looks" + { + def Material "Red" + { + token outputs:surface.connect = + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (1, 0, 0) + token outputs:surface + } + } + + def Material "Blue" + { + token outputs:surface.connect = + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 0, 1) + token outputs:surface + } + } + } + + def Mesh "Strip" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + rel material:binding = + + def GeomSubset "Second" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + rel material:binding = + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("There is one Surface per subset") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) >= 1); + } + + THEN("The subset Surfaces share the mesh's vertex positions") + { + // Every geometry produced for this mesh points at the same + // vertex.position Array; only the index arrays differ. + std::vector positions; + for (size_t i = 0; i < scene.numberOfObjects(ANARI_GEOMETRY); ++i) { + auto geometry = scene.getObject(i); + if (!geometry) + continue; + if (auto *p = geometry->parameterValueAsObject( + "vertex.position")) + positions.push_back(p); + } + REQUIRE(positions.size() >= 2); + for (size_t i = 1; i < positions.size(); ++i) + REQUIRE(positions[i] == positions[0]); + } + } + } +} + +SCENARIO("An unconventionally named UV primvar is still found", "[UsdImport]") +{ + GIVEN("A material whose reader node asks for a primvar not called 'st'") + { + StageFixture stage("tsd_test_usd_uv_primvar.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Textured" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @missing_texture.png@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "map1" + float2 outputs:result + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:map1 = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "vertex" + ) + rel material:binding = + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The named primvar lands on the geometry's first attribute") + { + auto geometry = scene.getObject(0); + REQUIRE(geometry); + auto *uvs = geometry->parameterValueAsObject( + "vertex.attribute0"); + REQUIRE(uvs != nullptr); + REQUIRE(uvs->size() == 4); + } + } + } +} + +SCENARIO( + "A prim with no bound material takes its display colour", "[UsdImport]") +{ + GIVEN("A mesh with display colour and opacity but no material") + { + StageFixture stage("tsd_test_usd_display_color.usda", R"(#usda 1.0 + +def Mesh "Quad" +{ + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + color3f[] primvars:displayColor = [(0.25, 0.5, 0.75)] ( + interpolation = "constant" + ) + float[] primvars:displayOpacity = [0.5] ( + interpolation = "constant" + ) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The Surface's material carries the display values") + { + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + + const auto color = + material->parameterValueAs("color"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(0.25f)); + REQUIRE(color->y == Approx(0.5f)); + REQUIRE(color->z == Approx(0.75f)); + + const auto opacity = material->parameterValueAs("opacity"); + REQUIRE(opacity.has_value()); + REQUIRE(*opacity == Approx(0.5f)); + } + } + } +} + +SCENARIO( + "Light exposure and normalization reach the emitted light", "[UsdImport]") +{ + GIVEN("A sphere light with exposure and normalization set") + { + // intensity 4, exposure 2 -> 4 * 2^2 = 16; normalize divides by the + // sphere's area, 4*pi*r^2 with r = 2 -> 16 / (16*pi). + StageFixture stage("tsd_test_usd_light_radiometry.usda", R"(#usda 1.0 + +def SphereLight "Lamp" +{ + float inputs:intensity = 4 + float inputs:exposure = 2 + bool inputs:normalize = true + float inputs:radius = 2 + color3f inputs:color = (1, 1, 1) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The light's intensity accounts for both") + { + REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::point); + + const auto intensity = light->parameterValueAs("intensity"); + REQUIRE(intensity.has_value()); + const float expected = 16.f / (4.f * float(M_PI) * 4.f); + REQUIRE(*intensity == Approx(expected)); + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO("A shaped sphere light becomes a spot light", "[UsdImport]") +{ + GIVEN("A sphere light carrying shaping attributes") + { + StageFixture stage("tsd_test_usd_spot.usda", R"(#usda 1.0 + +def SphereLight "Spot" ( + prepend apiSchemas = ["ShapingAPI"] +) +{ + float inputs:intensity = 1 + float inputs:radius = 0.5 + float inputs:shaping:cone:angle = 30 + float inputs:shaping:cone:softness = 0.5 +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Spot lighting survives the import") + { + auto light = scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::spot); + + const auto opening = light->parameterValueAs("openingAngle"); + REQUIRE(opening.has_value()); + REQUIRE(*opening == Approx(2.f * 30.f * float(M_PI) / 180.f)); + + const auto falloff = light->parameterValueAs("falloffAngle"); + REQUIRE(falloff.has_value()); + REQUIRE(*falloff == Approx(0.5f * 0.5f * *opening)); + } + } + } +} + +SCENARIO("Analytic quadrics stay analytic", "[UsdImport]") +{ + GIVEN("A Stage with a sphere and a cylinder") + { + StageFixture stage("tsd_test_usd_quadrics.usda", R"(#usda 1.0 + +def Sphere "Ball" +{ + double radius = 2 +} + +def Cylinder "Tube" +{ + double radius = 0.5 + double height = 4 + uniform token axis = "Y" +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("They map onto TSD's native quadric geometry, not meshes") + { + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 2); + + auto ball = scene.getObject(0); + REQUIRE(ball->subtype() == tsd::scene::tokens::geometry::sphere); + REQUIRE(ball->parameterValueAs("radius").value() == Approx(2.f)); + + auto tube = scene.getObject(1); + REQUIRE(tube->subtype() == tsd::scene::tokens::geometry::cylinder); + + // The spine axis is folded into the endpoints rather than a transform. + auto *positions = + tube->parameterValueAsObject("vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->size() == 2); + const auto *p = positions->dataAs(); + REQUIRE(p[0].y == Approx(-2.f)); + REQUIRE(p[1].y == Approx(2.f)); + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO( + "A non-convex polygon tessellates without spurious geometry", "[UsdImport]") +{ + GIVEN("A mesh with one concave five-sided face") + { + StageFixture stage("tsd_test_usd_nonconvex.usda", R"(#usda 1.0 + +def Mesh "Arrow" +{ + int[] faceVertexCounts = [5] + int[] faceVertexIndices = [0, 1, 2, 3, 4] + point3f[] points = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (1, 1, 0), (0, 2, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("It becomes exactly n-2 triangles") + { + auto geometry = scene.getObject(0); + REQUIRE(geometry); + auto *index = geometry->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 3); + } + } + } +} + +SCENARIO("Subdivision surfaces are refined by default", "[UsdImport]") +{ + GIVEN("A cube that explicitly declares a subdivision scheme") + { + StageFixture stage("tsd_test_usd_subdiv.usda", R"(#usda 1.0 + +def Mesh "SubdivCube" +{ + uniform token subdivisionScheme = "catmullClark" + int[] faceVertexCounts = [4, 4, 4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 7, 6, 5, 0, 4, 5, 1, + 1, 5, 6, 2, 2, 6, 7, 3, 3, 7, 4, 0] + point3f[] points = [(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), + (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)] +} + +def Mesh "PolygonCube" +{ + uniform token subdivisionScheme = "none" + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported at the default refinement level") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The subdivision mesh gains vertices and the polygon mesh does not") + { + auto subdiv = scene.getObject(0); + auto polygon = scene.getObject(1); + REQUIRE(subdiv); + REQUIRE(polygon); + + auto vertexCount = [](auto geometry) { + auto *p = + geometry->template parameterValueAsObject( + "vertex.position"); + return p ? p->size() : size_t(0); + }; + + // Two levels of Catmull-Clark on a cube: 8 -> 26 -> 98 vertices. + REQUIRE(vertexCount(subdiv) == 98); + REQUIRE(vertexCount(polygon) == 4); + } + } + + WHEN("Refinement is turned off") + { + tsd::io::UsdImportOptions options; + options.refinementLevel = 0; + tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The subdivision mesh arrives at its authored resolution") + { + auto subdiv = scene.getObject(0); + auto *p = subdiv->parameterValueAsObject( + "vertex.position"); + REQUIRE(p != nullptr); + REQUIRE(p->size() == 8); + } + } + } +} + +SCENARIO("Animation is captured at the times actually authored", "[UsdImport]") +{ + GIVEN("A Stage with transforms keyed on a non-uniform time base") + { + StageFixture stage("tsd_test_usd_time_base.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 10 +) + +def Xform "Mover" +{ + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 1: (1, 0, 0), + 10: (10, 0, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Mesh "Quad" + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The binding's time base mirrors the authored sample spacing") + { + REQUIRE(animMgr.animations().size() == 1); + const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(bindings.size() == 1); + const auto &timeBase = bindings[0].timeBase(); + REQUIRE(timeBase.size() == 3); + REQUIRE(timeBase[0] == Approx(0.0f)); + REQUIRE(timeBase[1] == Approx(0.1f)); + REQUIRE(timeBase[2] == Approx(1.0f)); + } + } + } +} + +SCENARIO("A full turn authored with two keys does not collapse", "[UsdImport]") +{ + GIVEN("A prim rotating 360 degrees between two keyframes") + { + StageFixture stage("tsd_test_usd_full_turn.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 1 +) + +def Xform "Spinner" +{ + float3 xformOp:rotateXYZ.timeSamples = { + 0: (0, 0, 0), + 1: (0, 360, 0), + } + uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] + + def Mesh "Quad" + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Extra samples are inserted so the rotation still animates") + { + REQUIRE(animMgr.animations().size() == 1); + const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(bindings.size() == 1); + REQUIRE(bindings[0].sampleCount() > 2); + } + } + } + + GIVEN("A prim translating between two keyframes") + { + StageFixture stage("tsd_test_usd_small_move.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 1 +) + +def Xform "Slider" +{ + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 1: (5, 0, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("No extra samples are invented") + { + const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(bindings[0].sampleCount() == 2); + } + } + } +} + +SCENARIO("Cameras from a Stage arrive in the camera pool", "[UsdImport]") +{ + GIVEN("A Stage with an animated camera rig") + { + StageFixture stage("tsd_test_usd_camera.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Xform "Rig" +{ + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 2: (0, 0, 10), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Camera "Shot" + { + float focalLength = 50 + float horizontalAperture = 36 + float verticalAperture = 24 + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + const auto camerasBefore = scene.numberOfObjects(ANARI_CAMERA); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The authored viewpoint is available and animated") + { + REQUIRE(scene.numberOfObjects(ANARI_CAMERA) == camerasBefore + 1); + + bool hasCameraAnimation = false; + for (const auto &animation : animMgr.animations()) { + if (!animation.objectParameterBindings().empty()) + hasCameraAnimation = true; + } + REQUIRE(hasCameraAnimation); + } + } + } +} + +SCENARIO( + "Lazily-bound deforming geometry survives save and reload", "[UsdImport]") +{ + GIVEN("A Stage whose mesh points are time-sampled") + { + StageFixture stage("tsd_test_usd_deforming.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Blob" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 1: [(0, 0, 0), (2, 0, 0), (0, 2, 0)], + 2: [(0, 0, 0), (3, 0, 0), (0, 3, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Only one frame is eager; the rest is bound to the Stage") + { + REQUIRE(animMgr.animations().size() == 1); + REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); + REQUIRE( + animMgr.animations()[0].fileBindings()[0]->kind() == "usdGeometry"); + + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->size() == 3); + } + + THEN("Scrubbing pulls a later frame from the Stage") + { + animMgr.setAnimationTime(1.0f); + + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->dataAs()[1].x == Approx(3.f)); + } + + THEN("The binding reconstructs from an Archive") + { + tsd::core::DataTree tree; + REQUIRE( + tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); + + tsd::animation::AnimationManager restored(&scene); + REQUIRE(tsd::io::deserialize_AnimationManagerArchive( + restored, tree.root())); + REQUIRE(restored.animations().size() == 1); + REQUIRE(restored.animations()[0].fileBindings().size() == 1); + REQUIRE(restored.animations()[0].fileBindings()[0]->kind() + == "usdGeometry"); + + restored.setAnimationTime(1.0f); + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions->dataAs()[1].x == Approx(3.f)); + } + } + } +} + +SCENARIO("Claimed dialect prims are handled once and only once", "[UsdImport]") +{ + GIVEN("A Stage whose carrier prims are claimed by the TSD dialect") + { + // The EnSight carrier marker is customData on the carrier's children; the + // claim-and-prune pre-pass must keep the generic path from converting + // them into meaningless geometry. + StageFixture stage("tsd_test_usd_dialect.usda", R"(#usda 1.0 + +def Scope "Dataset" +{ + def Mesh "part_one" ( + customData = { + dictionary ensight = { + string partName = "part_one" + } + } + ) + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} + +def Mesh "Real" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The carrier prim does not also arrive as generic geometry") + { + // Only the ordinary mesh converts: the claimed subtree is pruned from + // the resolved scene entirely. + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE_FALSE(findNode(scene.defaultLayer(), "part_one")); + } + } + } +} + +#endif // TSD_USE_USD From 3d96fa167a534d3f7adc9e723727502b39b3be97 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 24 Jul 2026 18:47:56 -0500 Subject: [PATCH 04/78] fix(usd): correct clocks, sharing, and refinement gaps found in review Correctness: - Cameras rescaled their bindings onto raw USD time codes while transform bindings used the Stage's normalized clock, so a Stage with both animated on different clocks. Both now go through one shared rescale. - Cameras and dome lights read their pose at UsdTimeCode's default rather than the Stage's start of time, reintroducing the very bug this rework set out to remove: values authored only as time samples did not resolve. - The deforming-geometry binding picked its frame off an even grid, undoing the authored spacing it had just stored. It now selects the authored sample the current time falls in. - Prototypes whose internal transforms are animated converted their geometry once per placement. Baking is what an animated Prototype gives up, not sharing: its gprims now convert once and each placement references them. - Transform-stack resets were recorded as an instance parameter nothing reads, so the parent transform still composed. A resetting node now cancels the accumulated ancestor transform, keeping its place in the hierarchy while landing where USD puts it. Completeness: - Subdivision refinement carries face-varying and uniform primvars through the same refinement instead of dropping them, so a textured subdivision mesh keeps its UVs -- and stops reporting a false skip that broke the empty skip-list invariant. Holes are mapped onto the refined faces. - Points, curves, and quadrics take the display-colour fallback that meshes already had, rather than falling back to TSD's default material. - Time-varying visibility and time-varying material values are now reported. Cleanups: one shared clock helper, one shared native-instancing root, one shared material-mode conversion (the Lua binding had grown its own copy), and shared helpers for position baking, width-to-radius conversion, and material binding lookup. Skip-reason counts derive from the enum instead of a parallel list that could silently fall out of date. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/UsdImport.cpp | 28 +- tsd/src/tsd/io/UsdImport.hpp | 9 +- .../io/animation/UsdGeometryFileBinding.cpp | 33 ++- .../io/animation/UsdGeometryFileBinding.hpp | 12 +- .../io/importers/detail/usd/UsdAnimation.cpp | 50 ++-- .../io/importers/detail/usd/UsdAnimation.h | 8 + .../io/importers/detail/usd/UsdGeometry.cpp | 274 +++++++++--------- .../importers/detail/usd/UsdImportContext.h | 7 + .../io/importers/detail/usd/UsdInstancing.cpp | 78 +++-- .../io/importers/detail/usd/UsdInstancing.h | 8 + .../tsd/io/importers/detail/usd/UsdLights.cpp | 20 +- .../tsd/io/importers/detail/usd/UsdLights.h | 6 +- .../io/importers/detail/usd/UsdMaterials.cpp | 24 +- .../importers/detail/usd/UsdSubdivision.cpp | 240 ++++++++++++--- .../io/importers/detail/usd/UsdSubdivision.h | 24 +- tsd/src/tsd/io/importers/import_USD.cpp | 50 +++- tsd/src/tsd/scripting/bindings/IOBindings.cpp | 16 +- tsd/tests/test_UsdImport.cpp | 225 ++++++++++++-- 18 files changed, 783 insertions(+), 329 deletions(-) diff --git a/tsd/src/tsd/io/UsdImport.cpp b/tsd/src/tsd/io/UsdImport.cpp index 2b9978c47..64d030eb2 100644 --- a/tsd/src/tsd/io/UsdImport.cpp +++ b/tsd/src/tsd/io/UsdImport.cpp @@ -8,9 +8,7 @@ namespace tsd::io { -// Helper functions /////////////////////////////////////////////////////////// - -static const char *materialModeToString(UsdMaterialMode mode) +const char *toString(UsdMaterialMode mode) { switch (mode) { case UsdMaterialMode::MATERIALX: @@ -23,11 +21,11 @@ static const char *materialModeToString(UsdMaterialMode mode) } } -static UsdMaterialMode materialModeFromString(const std::string &s) +UsdMaterialMode usdMaterialModeFromString(const std::string &name) { - if (s == "materialx") + if (name == "materialx") return UsdMaterialMode::MATERIALX; - if (s == "mdl") + if (name == "mdl") return UsdMaterialMode::MDL; return UsdMaterialMode::PHYSICALLY_BASED; } @@ -46,7 +44,7 @@ void UsdImportOptions::toDataNode(core::DataNode &node) const for (const auto &context : renderContexts) contextsNode.append() = context; - node["materialMode"] = std::string(materialModeToString(materialMode)); + node["materialMode"] = std::string(toString(materialMode)); node["refinementLevel"] = refinementLevel; node["primPath"] = primPath; } @@ -72,7 +70,7 @@ void UsdImportOptions::fromDataNode(const core::DataNode &node) } if (const auto *n = node.child("materialMode")) - materialMode = materialModeFromString(n->getValueOr("")); + materialMode = usdMaterialModeFromString(n->getValueOr("")); if (const auto *n = node.child("refinementLevel")) refinementLevel = n->getValueOr(refinementLevel); if (const auto *n = node.child("primPath")) @@ -100,6 +98,8 @@ const char *toString(UsdSkipReason reason) return "richer material network available"; case UsdSkipReason::TIME_VARYING_VALUE_DROPPED: return "time-varying value dropped"; + case UsdSkipReason::COUNT: + break; } return "unknown"; } @@ -125,17 +125,9 @@ std::string UsdImportReport::summary() const + std::to_string(skipped.size()) + " skipped"; // Counts by reason, in enum order, omitting reasons that did not occur. - const UsdSkipReason allReasons[] = {UsdSkipReason::PURPOSE_EXCLUDED, - UsdSkipReason::RESOLVED_INVISIBLE, - UsdSkipReason::UNSUPPORTED_PRIM_TYPE, - UsdSkipReason::MATERIAL_RESOLUTION_FAILED, - UsdSkipReason::TEXTURE_LOAD_FAILED, - UsdSkipReason::UNSUPPORTED_LIGHT_TYPE, - UsdSkipReason::RICHER_MATERIAL_AVAILABLE, - UsdSkipReason::TIME_VARYING_VALUE_DROPPED}; - bool first = true; - for (auto reason : allReasons) { + for (int i = 0; i < int(UsdSkipReason::COUNT); ++i) { + const auto reason = UsdSkipReason(i); const auto count = countOf(reason); if (count == 0) continue; diff --git a/tsd/src/tsd/io/UsdImport.hpp b/tsd/src/tsd/io/UsdImport.hpp index ef1fb4251..7f0bf605b 100644 --- a/tsd/src/tsd/io/UsdImport.hpp +++ b/tsd/src/tsd/io/UsdImport.hpp @@ -44,6 +44,9 @@ enum class UsdMaterialMode MDL }; +const char *toString(UsdMaterialMode mode); +UsdMaterialMode usdMaterialModeFromString(const std::string &name); + /* * Typed settings for one USD Stage import. Every field has a default that * makes the common case need no configuration, and the whole value converts to @@ -95,7 +98,11 @@ enum class UsdSkipReason TEXTURE_LOAD_FAILED, UNSUPPORTED_LIGHT_TYPE, RICHER_MATERIAL_AVAILABLE, - TIME_VARYING_VALUE_DROPPED + TIME_VARYING_VALUE_DROPPED, + + // Not a reason: the count of reasons above, so that adding one does not + // require updating a second list. + COUNT }; const char *toString(UsdSkipReason reason); diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp index 0f7ac6475..6d230401a 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -3,6 +3,7 @@ #include "tsd/io/animation/UsdGeometryFileBinding.hpp" // tsd_core +#include "tsd/animation/Interpolation.hpp" #include "tsd/core/DataTree.hpp" #include "tsd/core/Logging.hpp" #include "tsd/scene/objects/Array.hpp" @@ -39,12 +40,14 @@ UsdGeometryFileBinding::UsdGeometryFileBinding(scene::Scene *scene, scene::Geometry *geometry, std::string stageFile, std::string primPath, - std::vector sampleTimes) + std::vector sampleTimes, + std::vector timeBase) : FileBinding(scene), m_geometry(geometry), m_stageFile(std::move(stageFile)), m_primPath(std::move(primPath)), - m_sampleTimes(std::move(sampleTimes)) + m_sampleTimes(std::move(sampleTimes)), + m_timeBase(std::move(timeBase)) {} std::string UsdGeometryFileBinding::kind() const @@ -55,13 +58,17 @@ std::string UsdGeometryFileBinding::kind() const void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const { auto *geometry = m_geometry.get(); - node["targetIndex"] = geometry ? geometry->index() : size_t(-1); + node["targetIndex"] = geometry ? geometry->index() : tsd::core::INVALID_INDEX; node["stageFile"] = m_stageFile; node["primPath"] = m_primPath; auto ×Node = node["sampleTimes"]; for (double t : m_sampleTimes) timesNode.append() = float(t); + + auto &timeBaseNode = node["timeBase"]; + for (float t : m_timeBase) + timeBaseNode.append() = t; } void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) @@ -91,7 +98,8 @@ void UsdGeometryFileBinding::addCallbackToAnimation( UsdGeometryFileBinding *UsdGeometryFileBinding::addToAnimation( tsd::animation::Animation &anim, scene::Scene &scene, core::DataNode &node) { - const auto targetIndex = node["targetIndex"].getValueOr(size_t(-1)); + const auto targetIndex = + node["targetIndex"].getValueOr(tsd::core::INVALID_INDEX); auto *geometry = static_cast( scene.getObject(ANARI_GEOMETRY, targetIndex)); if (!geometry) { @@ -108,11 +116,19 @@ UsdGeometryFileBinding *UsdGeometryFileBinding::addToAnimation( }); } + std::vector timeBase; + if (auto *timeBaseNode = node.child("timeBase")) { + timeBaseNode->foreach_child([&](core::DataNode &n) { + timeBase.push_back(n.getValueOr(0.f)); + }); + } + return &anim.emplaceFileBinding(&scene, geometry, node["stageFile"].getValueOr(""), node["primPath"].getValueOr(""), - std::move(sampleTimes)); + std::move(sampleTimes), + std::move(timeBase)); } #if TSD_USE_USD @@ -122,9 +138,10 @@ void UsdGeometryFileBinding::update(float t) if (m_sampleTimes.empty() || !scene()) return; - const int frameCount = int(m_sampleTimes.size()); - const int frame = - std::clamp(int(std::round(t * float(frameCount - 1))), 0, frameCount - 1); + // Pick the authored sample `t` falls in, so playback follows the authored + // spacing rather than an even grid. + const auto sample = tsd::animation::findTimeSample(m_timeBase, t); + const int frame = int(sample.alpha >= 0.5f ? sample.hi : sample.lo); if (frame == m_currentFrame && m_stage) return; diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp index 2275ede0d..7214329e5 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp @@ -27,11 +27,15 @@ namespace tsd::io { */ struct UsdGeometryFileBinding : public tsd::animation::FileBinding { + // `sampleTimes` are the prim's authored time codes; `timeBase` is those + // same samples on the animation clock, so the frame chosen for a given time + // follows the authored spacing rather than an even grid. UsdGeometryFileBinding(scene::Scene *scene, scene::Geometry *geometry, std::string stageFile, std::string primPath, - std::vector sampleTimes); + std::vector sampleTimes, + std::vector timeBase); // FileBinding interface // @@ -39,9 +43,8 @@ struct UsdGeometryFileBinding : public tsd::animation::FileBinding void toDataNode(tsd::core::DataNode &node) const override; void onDefragment(const scene::IndexRemapper &cb) override; - // Pull the vertex arrays for the sample nearest `t`, which is normalized - // across the binding's authored sample times. No-ops if the frame has not - // changed. + // Pull the vertex arrays for the authored sample `t` falls in. No-ops if + // the frame has not changed. void update(float t) override; size_t frameCount() const; @@ -60,6 +63,7 @@ struct UsdGeometryFileBinding : public tsd::animation::FileBinding std::string m_stageFile; std::string m_primPath; std::vector m_sampleTimes; + std::vector m_timeBase; int m_currentFrame{0}; // Opened lazily on the first update and retained thereafter, so scrubbing diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp index 7f090e0d7..17f44feec 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -24,29 +24,6 @@ namespace { constexpr float MAX_ROTATION_STEP = float(M_PI) * 0.5f; constexpr int MAX_DENSIFY_DEPTH = 6; -// Every animated data source contributes the times it actually authored. TSD's -// time bases are arbitrary rather than uniform, so the only transformation is -// a rescale onto the Stage's time-code range, keeping every binding from one -// import on the same clock. -std::vector normalizeTimes( - const pxr::UsdStageRefPtr &stage, const std::vector ×) -{ - double start = stage->GetStartTimeCode(); - double end = stage->GetEndTimeCode(); - if (!(end > start)) { - start = times.front(); - end = times.back(); - } - const double span = end - start; - - std::vector retval; - retval.reserve(times.size()); - for (double t : times) { - retval.push_back(span > 0.0 ? float((t - start) / span) : 0.f); - } - return retval; -} - // Largest angle any rotation basis vector turns through between two frames. float rotationDelta(const tsd::math::mat4 &a, const tsd::math::mat4 &b) { @@ -107,6 +84,27 @@ void densifyInterval(const TransformSampler &sampler, } // namespace +std::vector normalizeSampleTimes( + const pxr::UsdStageRefPtr &stage, const std::vector ×) +{ + if (times.empty()) + return {}; + + double start = stage->GetStartTimeCode(); + double end = stage->GetEndTimeCode(); + if (!(end > start)) { + start = times.front(); + end = times.back(); + } + const double span = end - start; + + std::vector retval; + retval.reserve(times.size()); + for (double t : times) + retval.push_back(span > 0.0 ? float((t - start) / span) : 0.f); + return retval; +} + void addTransformAnimation( ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node) { @@ -145,7 +143,7 @@ void addTransformAnimation( auto &animation = ctx.animMgr.addAnimation(primPath.GetName()); addTransformStepBinding( - animation, node, frames, normalizeTimes(ctx.stage, times)); + animation, node, frames, normalizeSampleTimes(ctx.stage, times)); } void addDeformingGeometryAnimation( @@ -166,12 +164,14 @@ void addDeformingGeometryAnimation( // One eager frame is already in the Scene; the rest is pulled from the // retained Stage on demand (ADR 0018). + auto timeBase = normalizeSampleTimes(ctx.stage, sampleTimes); auto &animation = ctx.animMgr.addAnimation(primPath.GetName()); animation.emplaceFileBinding(&ctx.scene, geometry.data(), ctx.filePath, primPath.GetString(), - std::move(sampleTimes)); + std::move(sampleTimes), + std::move(timeBase)); } } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h index b777648ff..29051ac08 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h @@ -4,9 +4,17 @@ #pragma once #include "tsd/io/importers/detail/usd/UsdImportContext.h" +// std +#include namespace tsd::io::usd { +// Rescale authored time codes onto the Stage's own time-code range. This is +// the one clock every binding from a single import shares; the relative +// spacing of the authored samples is preserved, so nothing is resampled. +std::vector normalizeSampleTimes( + const pxr::UsdStageRefPtr &stage, const std::vector ×); + // Bind a node's transform to the prim's authored sample times, decomposed into // rotation, translation, and scale. Extra samples are inserted only across // intervals whose rotation a two-key spherical interpolation would collapse. diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 1214b686a..bf4403c75 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -98,14 +98,88 @@ anari::DataType anariTypeOfPrimvar(const pxr::VtValue &value) } /////////////////////////////////////////////////////////////////////////////// -// Mesh conversion //////////////////////////////////////////////////////////// +// Shared conversion helpers ////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// -pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) +// Prototype-internal transforms are baked into vertex data (ADR 0016); +// everything else passes through untouched. +std::vector bakedPositions( + const pxr::VtVec3fArray &source, const tsd::math::mat4 &bakeXform) +{ + const bool bake = bakeXform != tsd::math::IDENTITY_MAT4; + std::vector retval; + retval.reserve(source.size()); + for (const auto &p : source) { + float3 v(p[0], p[1], p[2]); + if (bake) { + const auto t = tsd::math::mul(bakeXform, float4(v.x, v.y, v.z, 1.f)); + v = float3(t.x, t.y, t.z); + } + retval.push_back(v); + } + return retval; +} + +// USD authors widths; TSD geometry takes radii. +void bindRadiiFromWidths(ImportContext &ctx, + GeometryRef &geometry, + const Primvar &widths, + size_t vertexCount) +{ + if (!widths.valid() || !widths.value.IsHolding()) + return; + const auto &w = widths.value.UncheckedGet(); + if (w.empty()) + return; + + std::vector radii; + radii.reserve(vertexCount); + for (size_t i = 0; i < vertexCount; ++i) + radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); + + auto array = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); + array->setData(radii.data(), radii.size()); + geometry->setParameterObject("vertex.radius", *array); +} + +// The material a resolved prim binds, or an empty path when it binds none. +pxr::SdfPath boundMaterialPathOf(const pxr::HdSceneIndexPrim &prim) +{ + auto bindings = pxr::HdMaterialBindingsSchema::GetFromParent(prim.dataSource); + if (auto binding = bindings.GetMaterialBinding()) { + if (auto path = binding.GetPath()) + return path->GetTypedValue(0); + } + return {}; +} + +// A prim with no bound material takes its colour from the display-colour and +// display-opacity primvars, so unmaterialed content looks as it does in a +// reference viewer instead of taking TSD's default. +MaterialRef displayColorMaterial(ImportContext &ctx, + const pxr::SdfPath &primPath, + const Primvar &displayColor, + const Primvar &displayOpacity) { - return source ? source->GetTypedValue(0) : pxr::VtIntArray(); + auto retval = ctx.scene.createObject(tokens::material::matte); + retval->setName((primPath.GetString() + "_displayColor").c_str()); + if (displayColor.valid() + && displayColor.value.IsHolding()) { + const auto &c = displayColor.value.UncheckedGet(); + retval->setParameter("color", float3(c[0][0], c[0][1], c[0][2])); + } + if (displayOpacity.valid() + && displayOpacity.value.IsHolding()) { + const auto &o = displayOpacity.value.UncheckedGet(); + retval->setParameter("opacity", o[0]); + } + return retval; } +/////////////////////////////////////////////////////////////////////////////// +// Mesh conversion //////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + // Expand a uniform (per-face) primvar to per-triangle values using the // triangulation's record of which coarse face each triangle came from. template @@ -168,7 +242,6 @@ namespace { // matches the triangulated topology. void bindMeshPrimvar(ImportContext &ctx, GeometryRef &geometry, - const pxr::SdfPath &primPath, const pxr::HdMeshUtil &meshUtil, const ConvertedMesh &mesh, const Primvar &primvar, @@ -243,13 +316,17 @@ std::vector convertMesh(ImportContext &ctx, : pxr::HdTokens->rightHanded; if (meshWantsRefinement(ctx, primPath)) { - std::vector> vertexPrimvars; + MeshPrimvars sourcePrimvars; for (const auto &[name, primvar] : primvars) { + if (name == pxr::HdPrimvarsSchemaTokens->points.GetString()) + continue; if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->vertex - || primvar.interpolation == pxr::HdPrimvarSchemaTokens->varying) { - if (name != pxr::HdPrimvarsSchemaTokens->points.GetString()) - vertexPrimvars.emplace_back(name, primvar.value); - } + || primvar.interpolation == pxr::HdPrimvarSchemaTokens->varying) + sourcePrimvars.vertex.emplace_back(name, primvar.value); + else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) + sourcePrimvars.faceVarying.emplace_back(name, primvar.value); + else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) + sourcePrimvars.uniform.emplace_back(name, primvar.value); } auto refined = refineMesh(meshSchema, @@ -258,34 +335,21 @@ std::vector convertMesh(ImportContext &ctx, holeIndices, orientation, points.value.UncheckedGet(), - vertexPrimvars, + sourcePrimvars, ctx.options.refinementLevel); if (refined.valid) { faceVertexCounts = refined.faceVertexCounts; faceVertexIndices = refined.faceVertexIndices; - holeIndices = pxr::VtIntArray(); // holes are consumed by refinement + holeIndices = refined.holeIndices; points.value = pxr::VtValue(refined.points); - for (auto &[name, value] : refined.vertexPrimvars) - primvars[name].value = value; - - // Primvars interpolated per face corner do not survive this refinement - // path: their topology changes with the surface. Drop them rather than - // bind values that no longer line up with the refined mesh. - for (auto it = primvars.begin(); it != primvars.end();) { - if (it->second.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying - || it->second.interpolation - == pxr::HdPrimvarSchemaTokens->uniform) { - ctx.reportSkip(primPath, - prim.primType.GetString(), - UsdSkipReason::UNSUPPORTED_PRIM_TYPE, - "primvar '" + it->first - + "' is not carried through subdivision refinement"); - it = primvars.erase(it); - } else { - ++it; - } - } + auto writeBack = [&](const std::vector &group) { + for (const auto &[name, value] : group) + primvars[name].value = value; + }; + writeBack(refined.primvars.vertex); + writeBack(refined.primvars.faceVarying); + writeBack(refined.primvars.uniform); } } @@ -304,31 +368,14 @@ std::vector convertMesh(ImportContext &ctx, return {}; // Resolve the bound material first: it names the UV primvar to bind. - auto materialBindings = - pxr::HdMaterialBindingsSchema::GetFromParent(prim.dataSource); - pxr::SdfPath boundMaterialPath; - if (auto binding = materialBindings.GetMaterialBinding()) { - if (auto path = binding.GetPath()) - boundMaterialPath = path->GetTypedValue(0); - } - const auto resolved = resolveMaterial(ctx, sceneIndex, boundMaterialPath); + const auto resolved = + resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)); auto geometry = ctx.scene.createObject(tokens::geometry::triangle); geometry->setName(primPath.GetText()); - // Vertex positions, with Prototype-internal transforms baked in (ADR 0016). - const auto &sourcePoints = points.value.UncheckedGet(); - std::vector positions; - positions.reserve(sourcePoints.size()); - const bool bake = bakeXform != tsd::math::IDENTITY_MAT4; - for (const auto &p : sourcePoints) { - float3 v(p[0], p[1], p[2]); - if (bake) { - const auto t = tsd::math::mul(bakeXform, float4(v.x, v.y, v.z, 1.f)); - v = float3(t.x, t.y, t.z); - } - positions.push_back(v); - } + const auto positions = + bakedPositions(points.value.UncheckedGet(), bakeXform); mesh.vertexPosition = ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); mesh.vertexPosition->setData(positions.data(), positions.size()); @@ -344,19 +391,18 @@ std::vector convertMesh(ImportContext &ctx, // name order so the attribute assignment is deterministic. const auto normals = lookup(pxr::HdPrimvarsSchemaTokens->normals.GetString()); if (normals.valid()) - bindMeshPrimvar(ctx, geometry, primPath, meshUtil, mesh, normals, "normal"); + bindMeshPrimvar(ctx, geometry, meshUtil, mesh, normals, "normal"); const std::string uvName = resolved.uvPrimvarName.empty() ? "st" : resolved.uvPrimvarName; const auto uvs = lookup(uvName); if (uvs.valid()) - bindMeshPrimvar(ctx, geometry, primPath, meshUtil, mesh, uvs, "attribute0"); + bindMeshPrimvar(ctx, geometry, meshUtil, mesh, uvs, "attribute0"); const auto displayColor = lookup(pxr::HdTokens->displayColor.GetString()); if (displayColor.valid() && displayColor.interpolation != pxr::HdPrimvarSchemaTokens->constant) { - bindMeshPrimvar( - ctx, geometry, primPath, meshUtil, mesh, displayColor, "color"); + bindMeshPrimvar(ctx, geometry, meshUtil, mesh, displayColor, "color"); } int nextAttribute = 1; @@ -374,7 +420,6 @@ std::vector convertMesh(ImportContext &ctx, continue; bindMeshPrimvar(ctx, geometry, - primPath, meshUtil, mesh, primvar, @@ -385,23 +430,10 @@ std::vector convertMesh(ImportContext &ctx, auto material = resolved.material; if (!material) { - // No bound material: take colour from the display-colour primvars so that - // unmaterialed content looks as it does in a reference viewer instead of - // taking TSD's default. - material = ctx.scene.createObject(tokens::material::matte); - material->setName((primPath.GetString() + "_displayColor").c_str()); - if (displayColor.valid() - && displayColor.value.IsHolding()) { - const auto &c = displayColor.value.UncheckedGet(); - material->setParameter("color", float3(c[0][0], c[0][1], c[0][2])); - } - const auto displayOpacity = - lookup(pxr::HdTokens->displayOpacity.GetString()); - if (displayOpacity.valid() - && displayOpacity.value.IsHolding()) { - const auto &o = displayOpacity.value.UncheckedGet(); - material->setParameter("opacity", o[0]); - } + material = displayColorMaterial(ctx, + primPath, + displayColor, + lookup(pxr::HdTokens->displayOpacity.GetString())); } // Per-face material subsets each become their own Surface over their own @@ -469,15 +501,9 @@ std::vector convertMesh(ImportContext &ctx, subsetGeometry->setParameterObject(Token(name), *array); } - auto subsetBindings = - pxr::HdMaterialBindingsSchema::GetFromParent(subsetPrim.dataSource); - pxr::SdfPath subsetMaterialPath; - if (auto binding = subsetBindings.GetMaterialBinding()) { - if (auto path = binding.GetPath()) - subsetMaterialPath = path->GetTypedValue(0); - } auto subsetMaterial = - resolveMaterial(ctx, sceneIndex, subsetMaterialPath).material; + resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(subsetPrim)) + .material; retval.push_back(ctx.scene.createSurface(subsetPath.GetText(), subsetGeometry, @@ -496,35 +522,22 @@ std::vector convertMesh(ImportContext &ctx, // Points and curves ////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// -std::vector bakedPositions( - const pxr::VtVec3fArray &source, const tsd::math::mat4 &bakeXform) -{ - const bool bake = bakeXform != tsd::math::IDENTITY_MAT4; - std::vector retval; - retval.reserve(source.size()); - for (const auto &p : source) { - float3 v(p[0], p[1], p[2]); - if (bake) { - const auto t = tsd::math::mul(bakeXform, float4(v.x, v.y, v.z, 1.f)); - v = float3(t.x, t.y, t.z); - } - retval.push_back(v); - } - return retval; -} - +// Points, curves, and quadrics resolve their material the same way a mesh +// does, including the display-colour fallback for unmaterialed prims. MaterialRef materialForPrim(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, const pxr::HdSceneIndexPrim &prim) { - auto bindings = pxr::HdMaterialBindingsSchema::GetFromParent(prim.dataSource); - pxr::SdfPath path; - if (auto binding = bindings.GetMaterialBinding()) { - if (auto p = binding.GetPath()) - path = p->GetTypedValue(0); - } - auto material = resolveMaterial(ctx, sceneIndex, path).material; - return material ? material : ctx.scene.defaultMaterial(); + if (auto material = + resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)).material) + return material; + + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + return displayColorMaterial(ctx, + primPath, + readPrimvar(primvars, pxr::HdTokens->displayColor), + readPrimvar(primvars, pxr::HdTokens->displayOpacity)); } std::vector convertPoints(ImportContext &ctx, @@ -549,21 +562,14 @@ std::vector convertPoints(ImportContext &ctx, positionArray->setData(positions.data(), positions.size()); geometry->setParameterObject("vertex.position", *positionArray); - const auto widths = - readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths); - if (widths.valid() && widths.value.IsHolding()) { - const auto &w = widths.value.UncheckedGet(); - std::vector radii; - radii.reserve(positions.size()); - for (size_t i = 0; i < positions.size(); ++i) - radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); - auto radiusArray = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); - radiusArray->setData(radii.data(), radii.size()); - geometry->setParameterObject("vertex.radius", *radiusArray); - } + bindRadiiFromWidths(ctx, + geometry, + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), + positions.size()); - return {ctx.scene.createSurface( - primPath.GetText(), geometry, materialForPrim(ctx, sceneIndex, prim))}; + return {ctx.scene.createSurface(primPath.GetText(), + geometry, + materialForPrim(ctx, sceneIndex, primPath, prim))}; } std::vector convertCurves(ImportContext &ctx, @@ -609,21 +615,14 @@ std::vector convertCurves(ImportContext &ctx, geometry->setParameterObject("primitive.index", *indexArray); } - const auto widths = - readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths); - if (widths.valid() && widths.value.IsHolding()) { - const auto &w = widths.value.UncheckedGet(); - std::vector radii; - radii.reserve(positions.size()); - for (size_t i = 0; i < positions.size(); ++i) - radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); - auto radiusArray = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); - radiusArray->setData(radii.data(), radii.size()); - geometry->setParameterObject("vertex.radius", *radiusArray); - } + bindRadiiFromWidths(ctx, + geometry, + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), + positions.size()); - return {ctx.scene.createSurface( - primPath.GetText(), geometry, materialForPrim(ctx, sceneIndex, prim))}; + return {ctx.scene.createSurface(primPath.GetText(), + geometry, + materialForPrim(ctx, sceneIndex, primPath, prim))}; } /////////////////////////////////////////////////////////////////////////////// @@ -712,8 +711,9 @@ std::vector convertQuadric(ImportContext &ctx, return {}; geometry->setName(primPath.GetText()); - return {ctx.scene.createSurface( - primPath.GetText(), geometry, materialForPrim(ctx, sceneIndex, prim))}; + return {ctx.scene.createSurface(primPath.GetText(), + geometry, + materialForPrim(ctx, sceneIndex, primPath, prim))}; } } // namespace diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index 3f4e7c6ce..09c7f35ba 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -10,6 +10,7 @@ #include "tsd/scene/Scene.hpp" // usd #include +#include #include #include // std @@ -65,6 +66,7 @@ struct ImportContext // Small conversions shared by every converter ///////////////////////////////// tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m); +pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source); // Inlined definitions //////////////////////////////////////////////////////// @@ -81,6 +83,11 @@ inline void ImportContext::reportSkip(const pxr::SdfPath &primPath, detail.c_str()); } +inline pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) +{ + return source ? source->GetTypedValue(0) : pxr::VtIntArray(); +} + inline tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m) { tsd::math::mat4 retval; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index b8a68a74f..a079396fe 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -23,8 +23,6 @@ using namespace tsd::core; namespace { -const char *NATIVE_INSTANCING_ROOT = "/UsdNiPropagatedPrototypes"; - tsd::math::mat4 flattenedXformOf( const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath) { @@ -83,21 +81,26 @@ std::shared_ptr convertPrototype(ImportContext &ctx, content->internalTransformsAnimated = subtreeHasAnimatedTransforms(ctx, sceneIndex, prototypeRoot); - if (!content->internalTransformsAnimated) { - const auto rootXform = flattenedXformOf(sceneIndex, prototypeRoot); - const auto inverseRoot = tsd::math::inverse(rootXform); - for (const pxr::SdfPath &path : - pxr::HdSceneIndexPrimView(sceneIndex, prototypeRoot)) { - auto prim = sceneIndex->GetPrim(path); - if (!isGeometryPrimType(prim.primType)) - continue; - const auto bake = - tsd::math::mul(inverseRoot, flattenedXformOf(sceneIndex, path)); - for (auto &surface : convertGeometry(ctx, sceneIndex, path, prim, bake)) - content->surfaces.push_back(surface); - } - ctx.report.convertedPrims += content->surfaces.size(); + // Either way the Prototype's gprims convert exactly once and are shared by + // every placement. Baking is what an animated Prototype gives up, not + // sharing: its gprims keep their own transforms and are expanded per + // placement as Layer nodes referencing these same objects. + const auto rootXform = flattenedXformOf(sceneIndex, prototypeRoot); + const auto inverseRoot = tsd::math::inverse(rootXform); + for (const pxr::SdfPath &path : + pxr::HdSceneIndexPrimView(sceneIndex, prototypeRoot)) { + auto prim = sceneIndex->GetPrim(path); + if (!isGeometryPrimType(prim.primType)) + continue; + const auto bake = content->internalTransformsAnimated + ? tsd::math::IDENTITY_MAT4 + : tsd::math::mul(inverseRoot, flattenedXformOf(sceneIndex, path)); + for (auto &surface : convertGeometry(ctx, sceneIndex, path, prim, bake)) + content->surfaces.push_back(surface); + if (content->internalTransformsAnimated) + content->gprimPaths.push_back(path); } + ctx.report.convertedPrims += content->surfaces.size(); registry.prototypes[key] = content; return content; @@ -105,29 +108,27 @@ std::shared_ptr convertPrototype(ImportContext &ctx, // Expanded fallback for Prototypes whose internal transforms are animated and // so cannot be baked: mirror the Prototype subtree beneath each placement, -// still sharing the converted objects. +// still referencing the objects converted once by convertPrototype(). void expandPrototype(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &prototypeRoot, - const pxr::SdfPath &primPath, - const tsd::math::mat4 &parentXform, + const PrototypeContent &content, LayerNodeRef parent) { - auto prim = sceneIndex->GetPrim(primPath); - const auto flattened = flattenedXformOf(sceneIndex, primPath); - const auto local = tsd::math::mul(tsd::math::inverse(parentXform), flattened); - - auto node = ctx.scene.insertChildTransformNode( - parent, local, primPath.GetName().c_str()); - - if (isGeometryPrimType(prim.primType)) { - for (auto &surface : convertGeometry( - ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4)) - ctx.scene.insertChildObjectNode(node, surface, surface->name().c_str()); + const auto inverseRoot = + tsd::math::inverse(flattenedXformOf(sceneIndex, prototypeRoot)); + + for (size_t i = 0; + i < content.surfaces.size() && i < content.gprimPaths.size(); + ++i) { + const auto &path = content.gprimPaths[i]; + const auto local = + tsd::math::mul(inverseRoot, flattenedXformOf(sceneIndex, path)); + auto node = ctx.scene.insertChildTransformNode( + parent, local, path.GetName().c_str()); + ctx.scene.insertChildObjectNode( + node, content.surfaces[i], content.surfaces[i]->name().c_str()); } - - for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) - expandPrototype(ctx, sceneIndex, prototypeRoot, childPath, flattened, node); } // The per-instance transforms an instancer carries, either directly as @@ -301,8 +302,7 @@ void convertInstancer(ImportContext &ctx, expandPrototype(ctx, sceneIndex, topology.prototypes[protoIndex], - topology.prototypes[protoIndex], - flattenedXformOf(sceneIndex, topology.prototypes[protoIndex]), + *content, placementNode); } continue; @@ -365,12 +365,8 @@ void attachNativeInstances(ImportContext &ctx, found != registry.nodeForPrimPath.end() ? found->second : importRoot; if (content->internalTransformsAnimated) { - expandPrototype(ctx, - sceneIndex, - topology.prototypes[0], - topology.prototypes[0], - flattenedXformOf(sceneIndex, topology.prototypes[0]), - placementNode); + expandPrototype( + ctx, sceneIndex, topology.prototypes[0], *content, placementNode); } else { for (auto &surface : content->surfaces) { ctx.scene.insertChildObjectNode( diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h index c6485940c..6400246c9 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -14,6 +14,10 @@ namespace tsd::io::usd { +// The root of the subtree UsdImaging synthesises for native instancing. Its +// contents reach the Scene through their instancers, never directly. +constexpr const char *NATIVE_INSTANCING_ROOT = "/UsdNiPropagatedPrototypes"; + // One Prototype converted once, as a flat set of Surfaces with each gprim's // Prototype-root-relative transform baked into its vertex data (ADR 0016). // Empty `surfaces` with `internalTransformsAnimated` set means the Prototype @@ -21,6 +25,10 @@ namespace tsd::io::usd { struct PrototypeContent { std::vector surfaces; + // Populated only when the Prototype could not be baked: the resolved path of + // the gprim each Surface came from, so placements can be expanded with the + // gprim's own transform while still sharing the Surface. + std::vector gprimPaths; bool internalTransformsAnimated{false}; }; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index 61a413d4c..6a3df784f 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -4,6 +4,7 @@ #include "tsd/io/importers/detail/usd/UsdLights.h" #include "tsd/animation/AnimationManager.hpp" #include "tsd/io/importers/detail/HDRImage.h" +#include "tsd/io/importers/detail/usd/UsdAnimation.h" // usd #include #include @@ -187,7 +188,6 @@ bool isLightPrimType(const pxr::TfToken &primType) } LightRef convertLight(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, const pxr::HdSceneIndexPrim &prim) { @@ -276,7 +276,7 @@ LightRef convertLight(ImportContext &ctx, // Dome orientation is baked into the light's own direction and up rather // than left to a transform, because devices mishandle transformed dome // lights (and no corrective root transform is inserted for up-axis). - pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + pxr::UsdGeomXformCache xformCache(ctx.importTime); auto worldXform = xformCache.GetLocalToWorldTransform(usdPrim); auto orientation = pxr::GfMatrix4d( // clang-format off @@ -314,10 +314,7 @@ LightRef convertLight(ImportContext &ctx, // Cameras //////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// -void convertCamera(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim) +void convertCamera(ImportContext &ctx, const pxr::SdfPath &primPath) { auto usdPrim = ctx.stage->GetPrimAtPath(primPath); if (!usdPrim) @@ -328,7 +325,7 @@ void convertCamera(ImportContext &ctx, return; const auto name = primPath.GetName(); - const auto defaultCamera = usdCamera.GetCamera(pxr::UsdTimeCode::Default()); + const auto defaultCamera = usdCamera.GetCamera(ctx.importTime); const bool isPerspective = defaultCamera.GetProjection() == pxr::GfCamera::Perspective; @@ -366,7 +363,7 @@ void convertCamera(ImportContext &ctx, }; { - pxr::UsdGeomXformCache cache(pxr::UsdTimeCode::Default()); + pxr::UsdGeomXformCache cache(ctx.importTime); auto [position, direction, up] = poseAt(cache); camera->setParameter("position", position); camera->setParameter("direction", direction); @@ -463,9 +460,10 @@ void convertCamera(ImportContext &ctx, parameterArrays.push_back(aspects); } - // The authored times are the binding's own time base; TSD's time bases are - // arbitrary rather than uniform, so nothing is resampled. - std::vector timeBase(sampleTimes.begin(), sampleTimes.end()); + // The authored times are the binding's own time base, rescaled onto the + // Stage's clock so this camera shares one clock with every other binding + // from the same import. Nothing is resampled. + const auto timeBase = normalizeSampleTimes(ctx.stage, sampleTimes); auto &animation = ctx.animMgr.addAnimation(name); addValueTimeStepBindings(animation, camera.data(), diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.h b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h index 88ffdd1ba..bd746a80b 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h @@ -14,15 +14,11 @@ bool isLightPrimType(const pxr::TfToken &primType); // Convert one resolved light prim. Returns a null ref for light types TSD // cannot represent, which the caller reports rather than dropping silently. LightRef convertLight(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, const pxr::HdSceneIndexPrim &prim); // Cameras import into the Scene's camera pool with their world transform, // animated through bindings where a rig authors motion anywhere above them. -void convertCamera(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim); +void convertCamera(ImportContext &ctx, const pxr::SdfPath &primPath); } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 8aaae785a..8a0f10b6e 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -311,6 +311,26 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, "reading a portable mapping instead"); } + // Material values are imported at one time, so say when the Stage animates + // them rather than leaving the difference to be noticed. + if (auto usdPrim = ctx.stage->GetPrimAtPath(materialPath)) { + for (const auto &descendant : usdPrim.GetDescendants()) { + pxr::UsdShadeShader shader(descendant); + if (!shader) + continue; + bool animated = false; + for (const auto &input : shader.GetInputs()) + animated = animated || input.GetAttr().GetNumTimeSamples() > 0; + if (animated) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::TIME_VARYING_VALUE_DROPPED, + "shader inputs are time-sampled; imported at one time"); + break; + } + } + } + auto materialSchema = pxr::HdMaterialSchema::GetFromParent(prim.dataSource); if (!materialSchema) { ctx.reportSkip(materialPath, @@ -428,8 +448,8 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, if (!emissive.IsEmpty()) material->setParameter("emissive", asFloat3(emissive, float3(0.f))); } - if (!bindTexture("normal", "normal", false)) - ; // no normal map authored + // A normal map is optional; nothing else stands in for it. + bindTexture("normal", "normal", false); if (!bindTexture("metallic", "metallic", false)) setFloatIfPresent("metallic", "metallic"); if (!bindTexture("roughness", "roughness", false)) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp index fd7295baa..6eb11a501 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp @@ -13,7 +13,9 @@ #include #include // std +#include #include +#include #include namespace tsd::io::usd { @@ -50,7 +52,7 @@ void FloatTuple::AddWithWeight(const FloatTuple &src, float weight) // Refine one buffer of N-component floats through every level of `refiner`, // returning the values at the last level. template -std::vector> refineBuffer( +std::vector> refineVertexBuffer( OpenSubdiv::Far::TopologyRefiner &refiner, const float *source, size_t count) @@ -76,30 +78,159 @@ std::vector> refineBuffer( return std::vector>(src, src + lastCount); } -template -bool refineTypedPrimvar(OpenSubdiv::Far::TopologyRefiner &refiner, - const pxr::VtValue &value, - pxr::VtValue *out) +// Refine one buffer of N-component floats through the face-varying channel +// `channel`, returning the values at the last level. +template +std::vector> refineFaceVaryingBuffer( + OpenSubdiv::Far::TopologyRefiner &refiner, + const float *source, + size_t count, + int channel) { - if (!value.IsHolding()) - return false; - const auto &source = value.UncheckedGet(); - if (source.empty()) - return false; + const int maxLevel = refiner.GetMaxLevel(); - const auto refined = refineBuffer( - refiner, reinterpret_cast(source.cdata()), source.size()); + size_t total = 0; + for (int level = 0; level <= maxLevel; ++level) + total += size_t(refiner.GetLevel(level).GetNumFVarValues(channel)); - VtArrayT result(refined.size()); - std::memcpy( - result.data(), refined.data(), sizeof(float) * N * refined.size()); - *out = pxr::VtValue(result); - return true; + std::vector> buffer(total); + std::memcpy(buffer.data(), source, sizeof(float) * N * count); + + OpenSubdiv::Far::PrimvarRefiner primvarRefiner(refiner); + FloatTuple *src = buffer.data(); + for (int level = 1; level <= maxLevel; ++level) { + FloatTuple *dst = + src + refiner.GetLevel(level - 1).GetNumFVarValues(channel); + primvarRefiner.InterpolateFaceVarying(level, src, dst, channel); + src = dst; + } + + const int lastCount = refiner.GetLevel(maxLevel).GetNumFVarValues(channel); + return std::vector>(src, src + lastCount); } -pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) +// Refinement strategies, as functors rather than lambdas because they carry a +// member template that a local class may not declare. +struct VertexRefiner +{ + OpenSubdiv::Far::TopologyRefiner &refiner; + + template + std::vector> operator()(const float *source, size_t count) const + { + return refineVertexBuffer(refiner, source, count); + } +}; + +struct FaceVaryingRefiner { - return source ? source->GetTypedValue(0) : pxr::VtIntArray(); + OpenSubdiv::Far::TopologyRefiner &refiner; + int channel; + + template + std::vector> operator()(const float *source, size_t count) const + { + return refineFaceVaryingBuffer(refiner, source, count, channel); + } +}; + +// Apply `refine` to whichever float-typed array `value` holds, writing the +// result back as the same array type. +template +bool refineFloatArray( + const pxr::VtValue &value, pxr::VtValue *out, REFINE_FCN &&refine) +{ + auto tryType = [&](auto tag, auto componentCount) { + using VtArrayT = decltype(tag); + constexpr int N = decltype(componentCount)::value; + if (!value.IsHolding()) + return false; + const auto &source = value.UncheckedGet(); + if (source.empty()) + return false; + + const auto refined = refine.template operator()( + reinterpret_cast(source.cdata()), source.size()); + + VtArrayT result(refined.size()); + std::memcpy( + result.data(), refined.data(), sizeof(float) * N * refined.size()); + *out = pxr::VtValue(result); + return true; + }; + + return tryType(pxr::VtFloatArray(), std::integral_constant()) + || tryType(pxr::VtVec2fArray(), std::integral_constant()) + || tryType(pxr::VtVec3fArray(), std::integral_constant()) + || tryType(pxr::VtVec4fArray(), std::integral_constant()); +} + +// Expand a value indexed by a channel's face-varying indices into face-corner +// order, which is what the triangulator downstream expects. +template +pxr::VtValue expandToFaceCorners(const VtArrayT &values, + const OpenSubdiv::Far::TopologyLevel &level, + int channel) +{ + VtArrayT retval; + for (int face = 0; face < level.GetNumFaces(); ++face) { + const auto indices = level.GetFaceFVarValues(face, channel); + for (int i = 0; i < indices.size(); ++i) + retval.push_back(values[indices[i]]); + } + return pxr::VtValue(retval); +} + +pxr::VtValue expandValueToFaceCorners(const pxr::VtValue &value, + const OpenSubdiv::Far::TopologyLevel &level, + int channel) +{ + if (value.IsHolding()) + return expandToFaceCorners( + value.UncheckedGet(), level, channel); + if (value.IsHolding()) + return expandToFaceCorners( + value.UncheckedGet(), level, channel); + if (value.IsHolding()) + return expandToFaceCorners( + value.UncheckedGet(), level, channel); + if (value.IsHolding()) + return expandToFaceCorners( + value.UncheckedGet(), level, channel); + return {}; +} + +// Replicate a per-face value onto every refined face descended from it. +template +pxr::VtValue replicateToRefinedFaces( + const VtArrayT &values, const std::vector &coarseFaceOfRefinedFace) +{ + VtArrayT retval; + retval.reserve(coarseFaceOfRefinedFace.size()); + for (int coarse : coarseFaceOfRefinedFace) { + retval.push_back( + values[size_t(coarse) < values.size() ? size_t(coarse) + : values.size() - 1]); + } + return pxr::VtValue(retval); +} + +pxr::VtValue replicateValueToRefinedFaces( + const pxr::VtValue &value, const std::vector &coarseFaceOfRefinedFace) +{ + if (value.IsHolding()) + return replicateToRefinedFaces( + value.UncheckedGet(), coarseFaceOfRefinedFace); + if (value.IsHolding()) + return replicateToRefinedFaces( + value.UncheckedGet(), coarseFaceOfRefinedFace); + if (value.IsHolding()) + return replicateToRefinedFaces( + value.UncheckedGet(), coarseFaceOfRefinedFace); + if (value.IsHolding()) + return replicateToRefinedFaces( + value.UncheckedGet(), coarseFaceOfRefinedFace); + return {}; } pxr::VtFloatArray floatArrayOf(const pxr::HdFloatArrayDataSourceHandle &source) @@ -162,7 +293,7 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, const pxr::VtIntArray &holeIndices, const pxr::TfToken &orientation, const pxr::VtVec3fArray &points, - const std::vector> &vertexPrimvars, + const MeshPrimvars &primvars, int refinementLevel) { RefinedMesh retval; @@ -178,7 +309,18 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, holeIndices, readSubdivTags(meshSchema)); - auto refiner = pxr::PxOsdRefinerFactory::Create(topology); + // Face-varying primvars arrive already flattened, one value per face corner, + // so each gets a channel whose topology is simply the corner order. + const size_t cornerCount = faceVertexIndices.size(); + pxr::VtIntArray cornerOrder(cornerCount); + for (size_t i = 0; i < cornerCount; ++i) + cornerOrder[i] = int(i); + std::vector faceVaryingTopologies( + primvars.faceVarying.size(), cornerOrder); + + auto refiner = faceVaryingTopologies.empty() + ? pxr::PxOsdRefinerFactory::Create(topology) + : pxr::PxOsdRefinerFactory::Create(topology, faceVaryingTopologies); if (!refiner) return retval; @@ -186,7 +328,8 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, options.fullTopologyInLastLevel = true; refiner->RefineUniform(options); - const auto &lastLevel = refiner->GetLevel(refiner->GetMaxLevel()); + const int maxLevel = refiner->GetMaxLevel(); + const auto &lastLevel = refiner->GetLevel(maxLevel); if (lastLevel.GetNumFaces() == 0 || lastLevel.GetNumVertices() == 0) return retval; @@ -199,24 +342,55 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, retval.faceVertexIndices.push_back(vertices[i]); } - // Points, and every vertex-interpolated primvar through the same refinement. + // Which coarse face each refined face descends from, which is what carries + // per-face data and hole tags down to the refined level. + std::vector coarseFaceOfRefinedFace(lastLevel.GetNumFaces(), 0); + for (int face = 0; face < lastLevel.GetNumFaces(); ++face) { + int current = face; + for (int level = maxLevel; level > 0; --level) + current = refiner->GetLevel(level).GetFaceParentFace(current); + coarseFaceOfRefinedFace[size_t(face)] = current; + } + + for (int face = 0; face < lastLevel.GetNumFaces(); ++face) { + const int coarse = coarseFaceOfRefinedFace[size_t(face)]; + if (std::find(holeIndices.begin(), holeIndices.end(), coarse) + != holeIndices.end()) + retval.holeIndices.push_back(face); + } + + const VertexRefiner vertexRefiner{*refiner}; + { pxr::VtValue refinedPoints; - if (!refineTypedPrimvar<3, pxr::VtVec3fArray>( - *refiner, pxr::VtValue(points), &refinedPoints)) + if (!refineFloatArray(pxr::VtValue(points), &refinedPoints, vertexRefiner)) return retval; retval.points = refinedPoints.UncheckedGet(); } - for (const auto &[name, value] : vertexPrimvars) { + for (const auto &[name, value] : primvars.vertex) { pxr::VtValue refined; - const bool ok = - refineTypedPrimvar<1, pxr::VtFloatArray>(*refiner, value, &refined) - || refineTypedPrimvar<2, pxr::VtVec2fArray>(*refiner, value, &refined) - || refineTypedPrimvar<3, pxr::VtVec3fArray>(*refiner, value, &refined) - || refineTypedPrimvar<4, pxr::VtVec4fArray>(*refiner, value, &refined); - if (ok) - retval.vertexPrimvars.emplace_back(name, refined); + if (refineFloatArray(value, &refined, vertexRefiner)) + retval.primvars.vertex.emplace_back(name, refined); + } + + for (size_t channel = 0; channel < primvars.faceVarying.size(); ++channel) { + const auto &[name, value] = primvars.faceVarying[channel]; + const FaceVaryingRefiner faceVaryingRefiner{*refiner, int(channel)}; + + pxr::VtValue refined; + if (!refineFloatArray(value, &refined, faceVaryingRefiner)) + continue; + auto expanded = expandValueToFaceCorners(refined, lastLevel, int(channel)); + if (!expanded.IsEmpty()) + retval.primvars.faceVarying.emplace_back(name, expanded); + } + + for (const auto &[name, value] : primvars.uniform) { + auto replicated = + replicateValueToRefinedFaces(value, coarseFaceOfRefinedFace); + if (!replicated.IsEmpty()) + retval.primvars.uniform.emplace_back(name, replicated); } retval.valid = true; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h index 3525972ba..a5ea19912 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h @@ -11,15 +11,29 @@ #include // std #include +#include #include namespace tsd::io::usd { +// A named primvar value, flattened out of any indexing. +using NamedPrimvar = std::pair; + +/* + * The primvars of one mesh, split by the interpolation that decides how + * OpenSubdiv must carry them through refinement. + */ +struct MeshPrimvars +{ + std::vector vertex; // vertex and varying + std::vector faceVarying; // one value per face corner + std::vector uniform; // one value per face +}; + /* * A mesh after OpenSubdiv refinement: the limit-level topology together with - * every vertex-interpolated primvar carried through the same refinement, so - * that smooth assets do not arrive faceted and their attributes stay aligned - * with their points. + * every primvar carried through the same refinement, so that smooth assets do + * not arrive faceted and their attributes stay aligned with their points. */ struct RefinedMesh { @@ -28,7 +42,7 @@ struct RefinedMesh pxr::VtIntArray faceVertexIndices; pxr::VtIntArray holeIndices; pxr::VtVec3fArray points; - std::vector> vertexPrimvars; + MeshPrimvars primvars; }; // True when this mesh should be refined: the Stage explicitly declares a @@ -46,7 +60,7 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, const pxr::VtIntArray &holeIndices, const pxr::TfToken &orientation, const pxr::VtVec3fArray &points, - const std::vector> &vertexPrimvars, + const MeshPrimvars &primvars, int refinementLevel); } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 80a767dd8..b2c85b11f 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -79,10 +79,6 @@ pxr::HdSceneIndexBaseRefPtr buildFilterChain(pxr::HdSceneIndexBaseRefPtr input) // Traversal ////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// -// The root of the subtree UsdImaging synthesises for native instancing. Its -// contents reach the Scene through their instancers, never directly. -const char *NATIVE_INSTANCING_ROOT = "/UsdNiPropagatedPrototypes"; - bool purposeIsIncluded( const pxr::TfToken &purpose, const UsdPurposeSelection &selection) { @@ -117,7 +113,10 @@ struct Traversal pxr::HdSceneIndexBaseRefPtr sceneIndex; InstancerRegistry &instancers; - void visit(const pxr::SdfPath &primPath, LayerNodeRef parent, bool hidden); + void visit(const pxr::SdfPath &primPath, + LayerNodeRef parent, + bool hidden, + const tsd::math::mat4 &parentXform); tsd::math::mat4 localTransformOf( const pxr::SdfPath &primPath, bool *resetsXformStack) const; @@ -150,8 +149,10 @@ bool Traversal::isHierarchyPrim(const pxr::SdfPath &primPath) const return !prim || bool(pxr::UsdGeomImageable(prim)); } -void Traversal::visit( - const pxr::SdfPath &primPath, LayerNodeRef parent, bool hidden) +void Traversal::visit(const pxr::SdfPath &primPath, + LayerNodeRef parent, + bool hidden, + const tsd::math::mat4 &parentXform) { auto prim = sceneIndex->GetPrim(primPath); @@ -191,14 +192,37 @@ void Traversal::visit( ctx.reportSkip( primPath, prim.primType.GetString(), UsdSkipReason::RESOLVED_INVISIBLE); } + + // Visibility is imported as one static enabled/disabled state, so say when + // the Stage animates it rather than leaving the difference to be noticed. + if (auto imageable = + pxr::UsdGeomImageable(ctx.stage->GetPrimAtPath(primPath))) { + if (auto attribute = imageable.GetVisibilityAttr(); + attribute && attribute.GetNumTimeSamples() > 0) { + ctx.reportSkip(primPath, + prim.primType.GetString(), + UsdSkipReason::TIME_VARYING_VALUE_DROPPED, + "visibility is time-sampled; imported at the Stage's start of time"); + } + } const bool subtreeHidden = hidden || !visible; // Node for this prim // bool resetsXformStack = false; const auto localXform = localTransformOf(primPath, &resetsXformStack); + + // A prim that resets the transform stack ignores its ancestors in USD. The + // node stays where its name belongs in the hierarchy and cancels the + // accumulated ancestor transform instead, so TSD's own composition lands on + // the same place USD does. + const auto nodeXform = resetsXformStack + ? tsd::math::mul(tsd::math::inverse(parentXform), localXform) + : localXform; + const auto accumulatedXform = tsd::math::mul(parentXform, nodeXform); + auto node = ctx.scene.insertChildTransformNode( - parent, localXform, primPath.GetName().c_str()); + parent, nodeXform, primPath.GetName().c_str()); if (resetsXformStack) (*node)->setInstanceParameter("usd:resetXformStack", Any(true)); if (subtreeHidden) @@ -230,7 +254,7 @@ void Traversal::visit( convertInstancer(ctx, sceneIndex, primPath, prim, node, instancers); converted = true; } else if (isLightPrimType(prim.primType)) { - if (auto light = convertLight(ctx, sceneIndex, primPath, prim)) { + if (auto light = convertLight(ctx, primPath, prim)) { ctx.scene.insertChildObjectNode(node, light, primPath.GetName().c_str()); converted = true; } else { @@ -240,7 +264,7 @@ void Traversal::visit( return; } } else if (prim.primType == pxr::HdPrimTypeTokens->camera) { - convertCamera(ctx, sceneIndex, primPath, prim); + convertCamera(ctx, primPath); converted = true; } else if (prim.primType == pxr::HdPrimTypeTokens->material || prim.primType == pxr::HdPrimTypeTokens->geomSubset) { @@ -273,7 +297,7 @@ void Traversal::visit( addTransformAnimation(ctx, primPath, node); for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) - visit(childPath, node, subtreeHidden); + visit(childPath, node, subtreeHidden, accumulatedXform); } } // namespace @@ -338,10 +362,10 @@ UsdImportReport import_USD(Scene &scene, for (const auto &childPath : sceneIndex->GetChildPrimPaths(scopeRoot)) { if (childPath.GetString() == NATIVE_INSTANCING_ROOT) continue; - traversal.visit(childPath, root, false); + traversal.visit(childPath, root, false, tsd::math::IDENTITY_MAT4); } } else { - traversal.visit(scopeRoot, root, false); + traversal.visit(scopeRoot, root, false, tsd::math::IDENTITY_MAT4); } // Native-instance placements live outside the mirrored hierarchy; attach diff --git a/tsd/src/tsd/scripting/bindings/IOBindings.cpp b/tsd/src/tsd/scripting/bindings/IOBindings.cpp index 2390f4a34..08702d073 100644 --- a/tsd/src/tsd/scripting/bindings/IOBindings.cpp +++ b/tsd/src/tsd/scripting/bindings/IOBindings.cpp @@ -16,9 +16,11 @@ namespace tsd::scripting { +namespace { + // Read USD import settings out of a Lua table. Absent keys keep their // defaults, so the common case stays `tsd.io.importUSD(scene, anim, file)`. -static tsd::io::UsdImportOptions usdImportOptionsFromLuaTable( +tsd::io::UsdImportOptions usdImportOptionsFromLuaTable( const sol::table &settings) { tsd::io::UsdImportOptions retval; @@ -42,14 +44,8 @@ static tsd::io::UsdImportOptions usdImportOptionsFromLuaTable( } } - if (sol::optional mode = settings["materialMode"]) { - if (*mode == "materialx") - retval.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - else if (*mode == "mdl") - retval.materialMode = tsd::io::UsdMaterialMode::MDL; - else - retval.materialMode = tsd::io::UsdMaterialMode::PHYSICALLY_BASED; - } + if (sol::optional mode = settings["materialMode"]) + retval.materialMode = tsd::io::usdMaterialModeFromString(*mode); if (sol::optional level = settings["refinementLevel"]) retval.refinementLevel = *level; @@ -59,6 +55,8 @@ static tsd::io::UsdImportOptions usdImportOptionsFromLuaTable( return retval; } +} // namespace + #define TSD_LUA_IMPORT_WRAP(import_call, filename) \ try { \ import_call; \ diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index a4f5b43ab..fac7a6ec5 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -91,28 +91,35 @@ namespace { // assertion it supports and keeps binary assets out of the repository. struct StageFixture { - StageFixture(const char *name, const std::string &contents) - : m_path(std::filesystem::temp_directory_path() / name) - { - std::ofstream file(m_path); - file << contents; - } + StageFixture(const char *name, const std::string &contents); + ~StageFixture(); - ~StageFixture() - { - std::error_code ec; - std::filesystem::remove(m_path, ec); - } - - std::string path() const - { - return m_path.string(); - } + std::string path() const; private: std::filesystem::path m_path; }; +// Inlined definitions //////////////////////////////////////////////////////// + +inline StageFixture::StageFixture(const char *name, const std::string &contents) + : m_path(std::filesystem::temp_directory_path() / name) +{ + std::ofstream file(m_path); + file << contents; +} + +inline StageFixture::~StageFixture() +{ + std::error_code ec; + std::filesystem::remove(m_path, ec); +} + +inline std::string StageFixture::path() const +{ + return m_path.string(); +} + // Depth-first search for the first node whose name matches. tsd::scene::LayerNodeRef findNode(tsd::scene::Layer *layer, const char *name) { @@ -125,7 +132,7 @@ tsd::scene::LayerNodeRef findNode(tsd::scene::Layer *layer, const char *name) return found; } -const char *QUAD_MESH_BODY = R"( +constexpr const char *QUAD_MESH_BODY = R"( int[] faceVertexCounts = [4] int[] faceVertexIndices = [0, 1, 2, 3] point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] @@ -1306,6 +1313,190 @@ def Xform "Rig" } } +SCENARIO( + "Refinement carries face-varying primvars with the surface", "[UsdImport]") +{ + GIVEN("A subdivision mesh whose UVs are authored per face corner") + { + StageFixture stage("tsd_test_usd_subdiv_uvs.usda", R"(#usda 1.0 + +def Mesh "SubdivQuad" +{ + uniform token subdivisionScheme = "catmullClark" + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "faceVarying" + ) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The UVs survive refinement rather than being dropped") + { + auto geometry = scene.getObject(0); + REQUIRE(geometry); + auto *uvs = geometry->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(uvs != nullptr); + REQUIRE(uvs->size() > 4); + } + + THEN("Nothing is reported as lost") + { + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO("Analytic prims without a material take their display colour", + "[UsdImport]") +{ + GIVEN("A point cloud carrying only display colour") + { + StageFixture stage("tsd_test_usd_points_display_color.usda", R"(#usda 1.0 + +def Points "Cloud" +{ + point3f[] points = [(0, 0, 0), (1, 0, 0)] + float[] widths = [0.2, 0.4] + color3f[] primvars:displayColor = [(1, 0, 0)] ( + interpolation = "constant" + ) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Its material carries the display colour, not TSD's default") + { + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + REQUIRE(material != scene.defaultMaterial().data()); + + const auto color = + material->parameterValueAs("color"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(1.f)); + REQUIRE(color->y == Approx(0.f)); + } + + THEN("Authored widths become per-point radii") + { + auto geometry = scene.getObject(0); + auto *radii = geometry->parameterValueAsObject( + "vertex.radius"); + REQUIRE(radii != nullptr); + REQUIRE(radii->size() == 2); + REQUIRE(radii->dataAs()[1] == Approx(0.2f)); + } + } + } +} + +SCENARIO("A prim that resets the transform stack ignores its ancestors", + "[UsdImport]") +{ + GIVEN("A child that resets the transform stack under a moved parent") + { + StageFixture stage("tsd_test_usd_xform_reset.usda", R"(#usda 1.0 + +def Xform "Parent" +{ + double3 xformOp:translate = (10, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Mesh "Detached" + { + double3 xformOp:translate = (1, 2, 3) + uniform token[] xformOpOrder = ["!resetXformStack!", "xformOp:translate"] + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Composing parent and child lands where USD puts the child") + { + auto *layer = scene.defaultLayer(); + auto parent = findNode(layer, "Parent"); + auto detached = findNode(layer, "Detached"); + REQUIRE(parent); + REQUIRE(detached); + + const auto composed = tsd::math::mul( + (*parent)->getTransform(), (*detached)->getTransform()); + REQUIRE(composed[3].x == Approx(1.f)); + REQUIRE(composed[3].y == Approx(2.f)); + REQUIRE(composed[3].z == Approx(3.f)); + } + } + } +} + +SCENARIO("Time-varying visibility is reported rather than lost", "[UsdImport]") +{ + GIVEN("A mesh whose visibility is animated") + { + StageFixture stage("tsd_test_usd_animated_visibility.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Blinker" +{ + token visibility.timeSamples = { + 0: "inherited", + 1: "invisible", + } + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The caller is told the animation was not represented") + { + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) + == 1); + } + } + } +} + SCENARIO( "Lazily-bound deforming geometry survives save and reload", "[UsdImport]") { From 2aacbc5353611f4b4ae9591ab30e797097499be2 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 24 Jul 2026 19:16:41 -0500 Subject: [PATCH 05/78] feat(usd): emit native MaterialX materials when OpenUSD can convert them The OpenUSD install now ships hdMtlx, so the MaterialX emission mode does the thing the spec asked for: the document is generated from the resolved network by OpenUSD's own conversion rather than rebuilt by hand. The mode is gated on what the OpenUSD build actually has. CMake looks for the hdMtlx header and the MaterialX package and sets TSD_USD_HAS_MATERIALX accordingly; where either is missing the importer keeps reporting the mode as unavailable and emits portable materials, so installs without MaterialX still build. The flag is public for the same reason TSD_USE_USD is: the test guards itself on it. pxrConfig already points MaterialX_DIR at the build OpenUSD was linked against, so no extra hint is needed. One thing the implementation had to learn: MaterialX has no node definition for UsdPreviewSurface, and asking hdMtlx to convert such a terminal anyway produces a document that fails MaterialX's own validation -- a material node with no category. The converter now checks for a node definition first and falls back to the portable mapping, reporting that it did, rather than emitting a document no renderer can consume. Tests cover both paths: an authored MaterialX network passes through as an inline document whose material name selects into it, and a preview-surface network falls back with the fallback named in the Import Report. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/CMakeLists.txt | 19 ++ .../io/importers/detail/usd/UsdMaterials.cpp | 112 ++++++++++- tsd/tests/test_UsdImport.cpp | 183 ++++++++++++++++++ 3 files changed, 306 insertions(+), 8 deletions(-) diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index a0603f110..45548f87d 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -169,6 +169,25 @@ if (TSD_USE_USD) project_compile_definitions(PUBLIC -DTSD_USE_USD=1) project_link_libraries(PRIVATE ${PXR_LIBRARIES} OpenSubdiv::osdCPU) + # MaterialX passthrough needs OpenUSD's own HdMtlx document conversion, which + # is only present when OpenUSD was built with PXR_ENABLE_MATERIALX_SUPPORT. + # Detect it rather than requiring it, so installs without it still build -- + # the importer reports the mode as unavailable and emits portable materials. + # pxrConfig already points MaterialX_DIR at the build OpenUSD was linked + # against, so no extra hint is normally needed. + find_package(MaterialX QUIET) + if (MaterialX_FOUND + AND EXISTS "${PXR_INCLUDE_DIRS}/pxr/imaging/hdMtlx/hdMtlx.h") + project_compile_definitions(PUBLIC -DTSD_USD_HAS_MATERIALX=1) + project_link_libraries(PRIVATE MaterialXCore MaterialXFormat) + message(STATUS "TSD: USD import has native MaterialX passthrough") + else() + project_compile_definitions(PUBLIC -DTSD_USD_HAS_MATERIALX=0) + message(STATUS + "TSD: OpenUSD lacks hdMtlx -- USD import will report MaterialX" + " passthrough as unavailable and emit portable materials instead") + endif() + # The USD importer's converters, which have no meaning without OpenUSD. project_sources( PRIVATE diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 8a0f10b6e..efd64a158 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -13,6 +13,12 @@ #include #include #include +#if TSD_USD_HAS_MATERIALX +#include +#include +#include +#include +#endif // std #include #include @@ -29,6 +35,9 @@ const pxr::TfToken UV_TEXTURE_ID("UsdUVTexture"); const pxr::TfToken PRIMVAR_READER_ID("UsdPrimvarReader_float2"); const pxr::TfToken TRANSFORM_2D_ID("UsdTransform2d"); +// The Render Context OpenUSD publishes MaterialX networks under. +const pxr::TfToken MATERIALX_CONTEXT("mtlx"); + // One resolved UsdPreviewSurface network, walked lazily out of the Hydra // material network container. struct NetworkWalker @@ -247,6 +256,72 @@ MaterialRef tryMdlPassthrough( return {}; } +#if TSD_USD_HAS_MATERIALX + +// Native MaterialX passthrough. The document is generated from the resolved +// network by OpenUSD's own conversion, so a MaterialX network passes through +// intact and a preview-surface network converts through its MaterialX node +// definitions. Returns a null ref when no network converts, so the caller can +// fall back to a portable mapping. +MaterialRef tryMaterialXPassthrough(ImportContext &ctx, + const pxr::SdfPath &materialPath, + const pxr::HdSceneIndexPrim &prim, + const pxr::HdMaterialNetworkSchema &network) +{ + if (!network || !network.GetNodes()) + return {}; + + auto surfaceTerminal = + network.GetTerminals().Get(pxr::HdMaterialTerminalTokens->surface); + if (!surfaceTerminal) + return {}; + auto terminalPathSource = surfaceTerminal.GetUpstreamNodePath(); + if (!terminalPathSource) + return {}; + const auto terminalNode = terminalPathSource->GetTypedValue(0); + + pxr::HdDataSourceMaterialNetworkInterface networkInterface( + materialPath, network.GetContainer(), prim.dataSource); + + // Only a terminal MaterialX itself defines can be converted. A + // UsdPreviewSurface terminal has no MaterialX node definition, and asking + // for one anyway yields a document that fails MaterialX's own validation. + if (!pxr::HdMtlxGetNodeDef(networkInterface.GetNodeType(terminalNode), + pxr::HdMtlxStdLibraries())) + return {}; + + auto document = pxr::HdMtlxCreateMtlxDocumentFromHdMaterialNetworkInterface( + &networkInterface, + terminalNode, + networkInterface.GetNodeInputConnectionNames(terminalNode), + pxr::HdMtlxStdLibraries()); + if (!document) + return {}; + + // The document names its own surface material node; TSD selects by that + // name rather than assuming one derived from the prim path. + std::string materialName; + for (const auto &node : document->getMaterialNodes()) { + materialName = node->getName(); + break; + } + if (materialName.empty()) + return {}; + + const auto xml = MaterialX::writeToXmlString(document); + if (xml.empty()) + return {}; + + auto retval = ctx.scene.createObject(tokens::material::materialx); + retval->setName(materialPath.GetString().c_str()); + retval->setParameter("sourceType", "documentInline"); + retval->setParameter("source", xml.c_str()); + retval->setParameter("materialName", materialName.c_str()); + return retval; +} + +#endif + // Try each Render Context in the caller's preference order, falling back per // material so a Stage mixing network flavours resolves completely either way. pxr::HdMaterialNetworkSchema selectNetwork( @@ -286,22 +361,43 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, } auto prim = sceneIndex->GetPrim(materialPath); + auto materialSchema = pxr::HdMaterialSchema::GetFromParent(prim.dataSource); + + auto cacheAndReturn = [&](MaterialRef material) { + ctx.materialCache[key] = material; + ctx.uvPrimvarCache[key] = std::string(); + ResolvedMaterial retval; + retval.material = material; + return retval; + }; // Native passthrough modes are opt-in; each falls back to the portable // mapping, saying so, rather than dropping the material. if (ctx.options.materialMode == UsdMaterialMode::MDL) { - if (auto material = tryMdlPassthrough(ctx, materialPath)) { - ctx.materialCache[key] = material; - ctx.uvPrimvarCache[key] = std::string(); - ResolvedMaterial retval; - retval.material = material; - return retval; - } + if (auto material = tryMdlPassthrough(ctx, materialPath)) + return cacheAndReturn(material); ctx.reportSkip(materialPath, prim.primType.GetString(), UsdSkipReason::RICHER_MATERIAL_AVAILABLE, "no MDL network authored; reading a portable mapping instead"); } else if (ctx.options.materialMode == UsdMaterialMode::MATERIALX) { +#if TSD_USD_HAS_MATERIALX + // Prefer an authored MaterialX network, but a preview-surface network also + // converts through its own MaterialX node definitions. + if (materialSchema) { + auto network = materialSchema.GetMaterialNetwork(MATERIALX_CONTEXT); + if (!network || !network.GetNodes()) + network = selectNetwork(materialSchema, ctx.options.renderContexts); + if (auto material = + tryMaterialXPassthrough(ctx, materialPath, prim, network)) + return cacheAndReturn(material); + } + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "no network could be converted to a MaterialX document; reading a" + " portable mapping instead"); +#else // MaterialX passthrough needs OpenUSD's HdMtlx document conversion, which // this build of OpenUSD does not ship. ctx.reportSkip(materialPath, @@ -309,6 +405,7 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, UsdSkipReason::RICHER_MATERIAL_AVAILABLE, "MaterialX passthrough is unavailable in this OpenUSD build; " "reading a portable mapping instead"); +#endif } // Material values are imported at one time, so say when the Stage animates @@ -331,7 +428,6 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, } } - auto materialSchema = pxr::HdMaterialSchema::GetFromParent(prim.dataSource); if (!materialSchema) { ctx.reportSkip(materialPath, prim.primType.GetString(), diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index fac7a6ec5..635afbe01 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -843,6 +843,189 @@ def Xform "World" } } +SCENARIO("Native material passthrough is opt-in", "[UsdImport]") +{ + // The material a Surface actually uses, rather than whatever happens to sit + // at index 0 of the pool (which is the Scene's own default material). + auto boundMaterial = [](tsd::scene::Scene &scene) { + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + return material; + }; + + auto stringParameter = [](tsd::scene::Material *material, const char *name) { + auto *p = material->parameter(name); + return p ? p->value().getString() : std::string(); + }; + + GIVEN("A Stage whose material is an ordinary preview surface") + { + StageFixture stage("tsd_test_usd_preview_material.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:surface.connect = + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.8, 0.2, 0.1) + float inputs:roughness = 0.4 + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("A portable physically-based material is emitted") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE(report.skipped.empty()); + } + } + + WHEN("A native passthrough is asked for that this material cannot give") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MDL; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The fallback to a portable mapping is reported, not silent") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + == 1); + } + } + +#if TSD_USD_HAS_MATERIALX + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("A preview surface falls back rather than emitting a bad document") + { + // MaterialX has no node definition for UsdPreviewSurface, so there is + // nothing to pass through; the portable mapping is used and said so. + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + == 1); + } + } +#endif + } + +#if TSD_USD_HAS_MATERIALX + GIVEN("A Stage with an authored MaterialX network") + { + StageFixture stage("tsd_test_usd_materialx.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + color3f inputs:base_color = (0.8, 0.2, 0.1) + float inputs:specular_roughness = 0.4 + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The network passes through as an inline MaterialX document") + { + auto *material = boundMaterial(scene); + REQUIRE(material->subtype() == tsd::scene::tokens::material::materialx); + REQUIRE(stringParameter(material, "sourceType") == "documentInline"); + + const auto source = stringParameter(material, "source"); + REQUIRE(source.find("subtype() + != tsd::scene::tokens::material::materialx); + } + } + } +#endif +} + SCENARIO( "A prim with no bound material takes its display colour", "[UsdImport]") { From 6275e1866623855579bbd724d3a3b4907eef9763 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sat, 25 Jul 2026 21:19:51 -0500 Subject: [PATCH 06/78] feat(usd): add a USD_MATX Importer Type for native MaterialX import The importer has had a MaterialX emission mode since the previous commit, but nothing an application could reach set it: every import path in the tree calls import_USD with default options, and the only thing that could pass anything else was the Lua binding. A Stage whose materials are authored as MaterialX therefore imported as untextured defaults -- the OpenPBR terminal shares no input names with UsdPreviewSurface, so the portable mapping had nothing to read and every material fell back to a flat base colour. USD_MATX sits beside USD the way ASSIMP_FLAT sits beside ASSIMP: a second Importer Type naming the same reader with one option set. It is exposed wherever an Importer Type is named -- the -usd_matx command-line flag, both import dialogs, and SciVis Studio's project-file mapping so a project that loaded one reloads as one. Fixing the viewer's import dialog was a prerequisite rather than a bonus. It cast the combo index straight to ImporterType against a hand-maintained list of labels that had fallen one entry short of the enum, so every choice from SWC_SDF on named one importer and ran another -- picking "USD" imported TRK. Adding an entry would have shifted the skew rather than fixed it, so the labels now carry the type they select. The trailing "TSD" label went with it: no Importer Type backed it, it ran VOLUME_ANIMATION, and Scene Archives load through their own path. Two neighbouring cases were missing from SciVis Studio's toString, which is also what importerTypeFromString round-trips through: PBRT and SWC_SDF serialized as "NONE" and could not be reloaded. Tests pin the dispatch rather than only the option it sets, since the dispatch is the part that was missing. Co-Authored-By: Claude Opus 5 --- .../scivisStudio/ProjectContext.cpp | 6 ++ .../modals/AddStaticDatasetDialog.cpp | 3 +- tsd/apps/interactive/viewer/README.md | 2 + tsd/apps/tools/README.md | 1 + tsd/apps/tools/tsdOffline.cpp | 2 + tsd/src/tsd/app/Context.cpp | 2 + tsd/src/tsd/io/importers.hpp | 1 + tsd/src/tsd/io/importers/import_file.cpp | 6 +- .../tsd/ui/imgui/modals/ImportFileDialog.cpp | 90 +++++++++++-------- tsd/tests/test_UsdImport.cpp | 33 +++++++ 10 files changed, 109 insertions(+), 37 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 356beaef8..2d4aa9b85 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -1795,6 +1795,8 @@ const char *toString(tsd::io::ImporterType importerType) return "OBJ"; case tsd::io::ImporterType::PDB: return "PDB"; + case tsd::io::ImporterType::PBRT: + return "PBRT"; case tsd::io::ImporterType::PLY: return "PLY"; case tsd::io::ImporterType::POINTSBIN_MULTIFILE: @@ -1809,10 +1811,14 @@ const char *toString(tsd::io::ImporterType importerType) return "SMESH_ANIMATION"; case tsd::io::ImporterType::SWC: return "SWC"; + case tsd::io::ImporterType::SWC_SDF: + return "SWC_SDF"; case tsd::io::ImporterType::TRK: return "TRK"; case tsd::io::ImporterType::USD: return "USD"; + case tsd::io::ImporterType::USD_MATX: + return "USD_MATX"; case tsd::io::ImporterType::VTP: return "VTP"; case tsd::io::ImporterType::VTU: diff --git a/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp index 1612928fa..f33483b2f 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp @@ -27,7 +27,7 @@ struct DatasetSourceChoice bool subtree = false; }; -constexpr std::array SOURCES = {{ +constexpr std::array SOURCES = {{ {"AGX", tsd::io::ImporterType::AGX}, {"ASSIMP", tsd::io::ImporterType::ASSIMP}, {"ASSIMP_FLAT", tsd::io::ImporterType::ASSIMP_FLAT}, @@ -49,6 +49,7 @@ constexpr std::array SOURCES = {{ {"SWC", tsd::io::ImporterType::SWC}, {"TRK", tsd::io::ImporterType::TRK}, {"USD", tsd::io::ImporterType::USD}, + {"USD_MATX", tsd::io::ImporterType::USD_MATX}, {"VTP", tsd::io::ImporterType::VTP}, {"VTU", tsd::io::ImporterType::VTU}, {"XYZDP", tsd::io::ImporterType::XYZDP}, diff --git a/tsd/apps/interactive/viewer/README.md b/tsd/apps/interactive/viewer/README.md index 1d9e83b7d..d3fea93b0 100644 --- a/tsd/apps/interactive/viewer/README.md +++ b/tsd/apps/interactive/viewer/README.md @@ -78,6 +78,8 @@ Use one of these flags before filenames to select how those files are loaded: - `-swc` - `-trk` - `-usd` +- `-usd_matx` (USD, emitting native MaterialX materials rather than a portable + mapping; falls back per material where no MaterialX network converts) - `-vtp` - `-vtu` - `-xyzdp` diff --git a/tsd/apps/tools/README.md b/tsd/apps/tools/README.md index 2ec20bc04..a5ca5538b 100644 --- a/tsd/apps/tools/README.md +++ b/tsd/apps/tools/README.md @@ -88,6 +88,7 @@ override flags (`--campos`, `--lookpos`, `--upvec`, `--fovy`). Use `-tsd ` to load a Scene Archive. Foreign-format importer flags include `-gltf`, `-obj`, `-ply`, `-volume`, `-hdri`, `-silo`, `-usd`, +`-usd_matx`, `-assimp`, `-axyz`, `-e57xyz`, `-pdb`, `-swc`, `-trk`, `-nbody`, and `-l`/`--layer`. diff --git a/tsd/apps/tools/tsdOffline.cpp b/tsd/apps/tools/tsdOffline.cpp index 4854ff768..f0f9cfb3a 100644 --- a/tsd/apps/tools/tsdOffline.cpp +++ b/tsd/apps/tools/tsdOffline.cpp @@ -145,6 +145,8 @@ static void printUsage(const char *programName) std::cout << " -hdri Set HDRI environment map\n"; std::cout << " -silo Import Silo files\n"; std::cout << " -usd Import USD files\n"; + std::cout + << " -usd_matx Import USD files as native MaterialX\n"; std::cout << " -l, --layer Specify layer name for following imports\n"; std::cout diff --git a/tsd/src/tsd/app/Context.cpp b/tsd/src/tsd/app/Context.cpp index be80642d4..0e80a22c9 100644 --- a/tsd/src/tsd/app/Context.cpp +++ b/tsd/src/tsd/app/Context.cpp @@ -130,6 +130,8 @@ void Context::parseCommandLine(std::vector &args) importerType = tsd::io::ImporterType::TRK; else if (arg == "-usd") importerType = tsd::io::ImporterType::USD; + else if (arg == "-usd_matx") + importerType = tsd::io::ImporterType::USD_MATX; else if (arg == "-vtp") importerType = tsd::io::ImporterType::VTP; else if (arg == "-vtu") diff --git a/tsd/src/tsd/io/importers.hpp b/tsd/src/tsd/io/importers.hpp index ef78e259d..1583439a9 100644 --- a/tsd/src/tsd/io/importers.hpp +++ b/tsd/src/tsd/io/importers.hpp @@ -162,6 +162,7 @@ enum class ImporterType SWC_SDF, TRK, USD, + USD_MATX, // native MaterialX materials instead of a portable mapping VTP, VTU, XYZDP, diff --git a/tsd/src/tsd/io/importers/import_file.cpp b/tsd/src/tsd/io/importers/import_file.cpp index e9c76380a..2b0361a82 100644 --- a/tsd/src/tsd/io/importers/import_file.cpp +++ b/tsd/src/tsd/io/importers/import_file.cpp @@ -108,7 +108,11 @@ void import_file(Scene &scene, tsd::io::import_TRK(scene, animMgr, file.c_str(), root); else if (f.first == ImporterType::USD) tsd::io::import_USD(scene, animMgr, file.c_str(), root); - else if (f.first == ImporterType::VTP) + else if (f.first == ImporterType::USD_MATX) { + UsdImportOptions options; + options.materialMode = UsdMaterialMode::MATERIALX; + tsd::io::import_USD(scene, animMgr, file.c_str(), root, options); + } else if (f.first == ImporterType::VTP) tsd::io::import_VTP(scene, animMgr, file.c_str(), root); else if (f.first == ImporterType::VTU) { std::optional prop; diff --git a/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp b/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp index fe011be1a..9ddd1049a 100644 --- a/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp +++ b/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp @@ -13,6 +13,53 @@ namespace tsd::ui::imgui { +namespace { + +// The label shown in the combo, paired with the type it selects. Pairing them +// is what keeps the two in step: the combo index used to be cast straight to +// ImporterType, so every entry after the first gap named one importer and ran +// another. +struct ImporterChoice +{ + const char *name; + tsd::io::ImporterType type; +}; + +constexpr ImporterChoice IMPORTERS[] = { + {"AGX", tsd::io::ImporterType::AGX}, + {"ASSIMP", tsd::io::ImporterType::ASSIMP}, + {"ASSIMP_FLAT", tsd::io::ImporterType::ASSIMP_FLAT}, + {"AXYZ", tsd::io::ImporterType::AXYZ}, + {"DLAF", tsd::io::ImporterType::DLAF}, + {"E57XYZ", tsd::io::ImporterType::E57XYZ}, + {"ENSIGHT", tsd::io::ImporterType::ENSIGHT}, + {"GLTF", tsd::io::ImporterType::GLTF}, + {"HDRI", tsd::io::ImporterType::HDRI}, + {"HSMESH", tsd::io::ImporterType::HSMESH}, + {"NBODY", tsd::io::ImporterType::NBODY}, + {"OBJ", tsd::io::ImporterType::OBJ}, + {"PDB", tsd::io::ImporterType::PDB}, + {"PBRT", tsd::io::ImporterType::PBRT}, + {"PLY", tsd::io::ImporterType::PLY}, + {"POINTSBIN_MULTIFILE", tsd::io::ImporterType::POINTSBIN_MULTIFILE}, + {"PT (neural)", tsd::io::ImporterType::PT}, + {"SILO", tsd::io::ImporterType::SILO}, + {"SMESH", tsd::io::ImporterType::SMESH}, + {"SMESH_ANIMATION", tsd::io::ImporterType::SMESH_ANIMATION}, + {"SWC", tsd::io::ImporterType::SWC}, + {"SWC_SDF", tsd::io::ImporterType::SWC_SDF}, + {"TRK", tsd::io::ImporterType::TRK}, + {"USD", tsd::io::ImporterType::USD}, + {"USD_MATX", tsd::io::ImporterType::USD_MATX}, + {"VTP", tsd::io::ImporterType::VTP}, + {"VTU", tsd::io::ImporterType::VTU}, + {"XYZDP", tsd::io::ImporterType::XYZDP}, + {"VOLUME", tsd::io::ImporterType::VOLUME}, + {"VOLUME_ANIMATION", tsd::io::ImporterType::VOLUME_ANIMATION}, +}; + +} // namespace + ImportFileDialog::ImportFileDialog(Application *app) : Modal(app, "ImportFileDialog") {} @@ -24,40 +71,14 @@ void ImportFileDialog::buildUI() constexpr int MAX_LENGTH = 2000; m_filename.reserve(MAX_LENGTH); - const char *importers[] = { - "AGX", - "ASSIMP", - "ASSIMP_FLAT", - "AXYZ", - "DLAF", - "E57XYZ", - "ENSIGHT", - "GLTF", - "HDRI", - "HSMESH", - "NBODY", - "OBJ", - "PDB", - "PBRT", - "PLY", - "POINTSBIN_MULTIFILE", - "PT (neural)", - "SILO", - "SMESH", - "SMESH_ANIMATION", - "SWC", - "TRK", - "USD", - "VTP", - "VTU", - "XYZDP", - "VOLUME", - "VOLUME_ANIMATION", - "TSD", - }; + const char *importerNames[std::size(IMPORTERS)] = {}; + for (size_t i = 0; i < std::size(IMPORTERS); i++) + importerNames[i] = IMPORTERS[i].name; - ImGui::Combo( - "importer type", &m_selectedFileType, importers, std::size(importers)); + ImGui::Combo("importer type", + &m_selectedFileType, + importerNames, + std::size(importerNames)); static std::string outPath; if (ImGui::Button("...")) { @@ -105,8 +126,7 @@ void ImportFileDialog::buildUI() auto importRoot = ctx->getFirstSelected(); if (!importRoot.valid()) importRoot = layer->root(); - tsd::io::ImportFile file{ - static_cast(m_selectedFileType), m_filename}; + tsd::io::ImportFile file{IMPORTERS[m_selectedFileType].type, m_filename}; tsd::io::import_file(scene, ctx->tsd.animationMgr, file, importRoot); scene.signalLayerStructureChanged(layer); }; diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 635afbe01..81d48ae5f 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -1022,6 +1022,39 @@ def Xform "World" != tsd::scene::tokens::material::materialx); } } + + // The importer's MaterialX mode is only reachable from an application + // through an Importer Type, so the dispatch is worth pinning separately + // from the option it sets. + WHEN("The file is imported through the USD_MATX Importer Type") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_file(scene, + animMgr, + {tsd::io::ImporterType::USD_MATX, stage.path()}); + + THEN("MaterialX materials arrive without asking for options") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::materialx); + } + } + + WHEN("The file is imported through the plain USD Importer Type") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_file(scene, + animMgr, + {tsd::io::ImporterType::USD, stage.path()}); + + THEN("The portable mapping is what arrives") + { + REQUIRE(boundMaterial(scene)->subtype() + != tsd::scene::tokens::material::materialx); + } + } } #endif } From febd2a09828c82c2b22771c4e27ecee74975308e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sat, 25 Jul 2026 21:47:41 -0500 Subject: [PATCH 07/78] fix(usd): anchor MaterialX texture paths before the document leaves TSD OpenUSD's conversion writes each texture filename exactly as it was authored -- SdfAssetPath::GetAssetPath(), not the resolved path -- and hands back the texture nodes it wrote so the consumer can finish the job. Every Hydra render delegate does finish it; TSD did not. The document then travelled to the device as inline text, which has no file of its own for a relative path to be relative to, so a filename like "textures/wood.png" named nothing at all. The anchor is the one the rest of this importer already uses for textures: the resolved path where the Stage's resolver produced one, and the Stage's own directory otherwise. The fallback is not a nicety -- a UDIM path names a set of tiles rather than a file, so no resolver ever resolves it, and in the asset this was found with that is 92 of 117 texture inputs. A path that survives anchoring and still names nothing is now reported as TEXTURE_LOAD_FAILED against the material that asked for it. The device that opens the document later cannot say which prim wanted the file, and silent loss is the failure mode this whole area keeps producing. This is necessary but not yet sufficient: the paths that reach the device are now correct and absolute, verified against the asset, but the device's MDL resource resolution does not accept absolute host paths without a matching mdlResourceSearchPaths root, and has no UDIM expansion. Those are device-side and unaddressed here. Co-Authored-By: Claude Opus 5 --- .../io/importers/detail/usd/UsdMaterials.cpp | 88 ++++++++- tsd/tests/test_UsdImport.cpp | 173 ++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index efd64a158..38369eaf7 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -20,6 +20,7 @@ #include #endif // std +#include #include #include @@ -258,6 +259,86 @@ MaterialRef tryMdlPassthrough( #if TSD_USD_HAS_MATERIALX +// The node a generated document holds under `name`, which OpenUSD's conversion +// places inside a node graph rather than at the document's top level. +MaterialX::NodePtr documentNode( + const MaterialX::DocumentPtr &document, const std::string &name) +{ + if (auto node = document->getNode(name)) + return node; + for (const auto &graph : document->getNodeGraphs()) { + if (auto node = graph->getNode(name)) + return node; + } + return {}; +} + +// Rewrite the document's texture filenames to absolute paths. +// +// OpenUSD's conversion writes SdfAssetPath::GetAssetPath() -- the path exactly +// as authored -- and leaves resolution to whoever consumes the document, which +// is why it also hands back the texture nodes it wrote. That contract does not +// survive TSD's handoff: the document travels to the device as inline text, +// with no file of its own for a relative path to be relative to. So the paths +// have to be absolute before they leave here. +// +// The anchor is the same one the rest of this importer uses for textures: the +// resolved path when the Stage's resolver produced one, and the Stage's own +// directory otherwise. The fallback is what carries UDIM sets, whose paths +// name no file that a resolver could have resolved. +void resolveTexturePaths(ImportContext &ctx, + const pxr::SdfPath &materialPath, + const std::string &primType, + const MaterialX::DocumentPtr &document, + const pxr::HdMtlxTexturePrimvarData &textures, + pxr::HdDataSourceMaterialNetworkInterface &networkInterface) +{ + for (const auto &nodePath : textures.hdTextureNodes) { + const auto nodeName = pxr::HdMtlxCreateNameFromPath(nodePath); + auto inputNames = textures.mxHdTextureMap.find(nodeName); + if (inputNames == textures.mxHdTextureMap.end()) + continue; + + auto node = documentNode(document, nodeName); + if (!node) + continue; + + for (const auto &inputName : inputNames->second) { + auto input = node->getInput(inputName); + if (!input) + continue; + + const auto value = networkInterface.GetNodeParameterValue( + pxr::TfToken(nodePath.GetString()), pxr::TfToken(inputName)); + if (!value.IsHolding()) + continue; + + const auto assetPath = value.UncheckedGet(); + auto file = assetPath.GetResolvedPath(); + if (file.empty()) + file = assetPath.GetAssetPath(); + if (file.empty()) + continue; + if (!isAbsolute(file)) + file = ctx.basePath + file; + + input->setValueString(file); + + // A path holding a MaterialX token names a set of tiles rather than a + // file, so there is nothing to look for; anything else that is missing + // now is worth saying, because the device that opens it later cannot + // say which Stage prim asked for it. + if (file.find('<') == std::string::npos + && !std::filesystem::exists(file)) { + ctx.reportSkip(materialPath, + primType, + UsdSkipReason::TEXTURE_LOAD_FAILED, + file); + } + } + } +} + // Native MaterialX passthrough. The document is generated from the resolved // network by OpenUSD's own conversion, so a MaterialX network passes through // intact and a preview-surface network converts through its MaterialX node @@ -290,14 +371,19 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, pxr::HdMtlxStdLibraries())) return {}; + pxr::HdMtlxTexturePrimvarData textures; auto document = pxr::HdMtlxCreateMtlxDocumentFromHdMaterialNetworkInterface( &networkInterface, terminalNode, networkInterface.GetNodeInputConnectionNames(terminalNode), - pxr::HdMtlxStdLibraries()); + pxr::HdMtlxStdLibraries(), + &textures); if (!document) return {}; + resolveTexturePaths( + ctx, materialPath, prim.primType.GetString(), document, textures, networkInterface); + // The document names its own surface material node; TSD selects by that // name rather than assuming one derived from the prim path. std::string materialName; diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 81d48ae5f..0a9cd21be 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -120,6 +120,37 @@ inline std::string StageFixture::path() const return m_path.string(); } +// A file standing where a texture would be. Only its existence is under test +// -- nothing in the import opens it -- so its contents do not matter. +struct TextureFixture +{ + explicit TextureFixture(const char *name); + ~TextureFixture(); + + std::string path() const; + + private: + std::filesystem::path m_path; +}; + +inline TextureFixture::TextureFixture(const char *name) + : m_path(std::filesystem::temp_directory_path() / name) +{ + std::ofstream file(m_path); + file << "not really a texture"; +} + +inline TextureFixture::~TextureFixture() +{ + std::error_code ec; + std::filesystem::remove(m_path, ec); +} + +inline std::string TextureFixture::path() const +{ + return m_path.string(); +} + // Depth-first search for the first node whose name matches. tsd::scene::LayerNodeRef findNode(tsd::scene::Layer *layer, const char *name) { @@ -1056,6 +1087,148 @@ def Xform "World" } } } + + // An inline document has no file of its own for a relative path to be + // relative to, so a texture that stays relative is a texture the device + // cannot open. + GIVEN("A MaterialX network reading textures by relative path") + { + TextureFixture present("tsd_test_usd_mtlx_present.png"); + + StageFixture stage("tsd_test_usd_materialx_textures.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Present" + { + uniform token info:id = "ND_image_color3" + asset inputs:file = @tsd_test_usd_mtlx_present.png@ + color3f outputs:out + } + + def Shader "Tiled" + { + uniform token info:id = "ND_image_color3" + asset inputs:file = @tiles/tsd_test_tile..png@ + color3f outputs:out + } + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + color3f inputs:base_color.connect = + color3f inputs:coat_color.connect = + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + const auto source = + stringParameter(boundMaterial(scene), "source"); + + THEN("Texture paths leave as absolute paths") + { + REQUIRE(source.find(present.path()) != std::string::npos); + REQUIRE(source.find("\"tsd_test_usd_mtlx_present.png\"") + == std::string::npos); + } + + THEN("A tile set is anchored without losing its token") + { + const auto tiled = + (std::filesystem::temp_directory_path() / "tiles").string(); + REQUIRE(source.find(tiled) != std::string::npos); + REQUIRE(source.find("") != std::string::npos); + } + + THEN("The texture that exists is not reported as missing") + { + for (const auto &skip : report.skipped) { + const bool missedThisOne = + skip.reason == tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED + && skip.detail == present.path(); + REQUIRE_FALSE(missedThisOne); + } + } + } + } + + GIVEN("A MaterialX network naming a texture that is not there") + { + StageFixture stage("tsd_test_usd_materialx_missing.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Missing" + { + uniform token info:id = "ND_image_color3" + asset inputs:file = @tsd_test_usd_absent.png@ + color3f outputs:out + } + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + color3f inputs:base_color.connect = + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The Import Report names it rather than leaving it to the device") + { + REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + } + } + } #endif } From 16b390c4d512396ec6ee95e0310e1f0281d2cb0c Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sat, 25 Jul 2026 22:26:12 -0500 Subject: [PATCH 08/78] feat(usd): bind samplers to MaterialX texture inputs Correct paths in the document turned out not to be enough, because the device does not open the files at all. Its MaterialX material publishes every `filename` input under the input's document path and reads texels from an ANARI sampler bound to that name -- the device's own test says so plainly: "The file input carries no resolvable path; the bound sampler supplies the texels." Nothing bound one, so a MaterialX import produced no samplers at all while a preview-surface import of the same Stage produced them normally. The import now loads each texture through the same importTexture and cache the preview-surface path uses, so a texture shared between materials is read once, and binds the result under the input's MaterialX element path. The colour space comes from the document rather than being assumed: MaterialX names the encoding per input, and only an sRGB encoding is de-gamma'd. The absolute paths stay. They cost nothing, they are what any consumer that does resolve files would need, and for a tile set -- where no sampler can be bound -- they are all the document carries. Two limits are now reported rather than silent, both of which this asset hits: a tiled (UDIM) path names a set rather than a file and nothing in the stack expands one, and TIFF is not a format the image loader decodes. Both arrive as TEXTURE_LOAD_FAILED naming the file and the material that wanted it. Verified against the asset this started with: the device accepts every binding (no "sampler not bound" warnings), and of its 117 texture inputs 21 now bind, 89 are reported as tiled, and 7 as TIFF. Co-Authored-By: Claude Opus 5 --- .../io/importers/detail/usd/UsdMaterials.cpp | 69 +++++++++++++++++-- tsd/tests/test_UsdImport.cpp | 57 +++++++++++++-- 2 files changed, 112 insertions(+), 14 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 38369eaf7..da67947ea 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -273,7 +273,28 @@ MaterialX::NodePtr documentNode( return {}; } -// Rewrite the document's texture filenames to absolute paths. +// One texture a generated document reads, found on the way through. +struct DocumentTexture +{ + // The document-relative path of the `filename` input, which is the name the + // device publishes the input under and expects a sampler bound to. + std::string inputPath; + std::string file; + bool isLinear{true}; +}; + +// Whether a texture carries colour rather than data, and so must be de-gamma'd +// on load. The document says so per input; MaterialX names the encoding, not +// the file format, so anything that is not an sRGB encoding is data. +bool inputIsLinear(const MaterialX::InputPtr &input) +{ + const auto colorSpace = input->getActiveColorSpace(); + return colorSpace != "srgb_texture" && colorSpace != "srgb_tx" + && colorSpace != "sRGB"; +} + +// Rewrite the document's texture filenames to absolute paths, and collect what +// was found so the caller can bind samplers to it. // // OpenUSD's conversion writes SdfAssetPath::GetAssetPath() -- the path exactly // as authored -- and leaves resolution to whoever consumes the document, which @@ -286,13 +307,14 @@ MaterialX::NodePtr documentNode( // resolved path when the Stage's resolver produced one, and the Stage's own // directory otherwise. The fallback is what carries UDIM sets, whose paths // name no file that a resolver could have resolved. -void resolveTexturePaths(ImportContext &ctx, +std::vector resolveTexturePaths(ImportContext &ctx, const pxr::SdfPath &materialPath, const std::string &primType, const MaterialX::DocumentPtr &document, const pxr::HdMtlxTexturePrimvarData &textures, pxr::HdDataSourceMaterialNetworkInterface &networkInterface) { + std::vector retval; for (const auto &nodePath : textures.hdTextureNodes) { const auto nodeName = pxr::HdMtlxCreateNameFromPath(nodePath); auto inputNames = textures.mxHdTextureMap.find(nodeName); @@ -328,15 +350,24 @@ void resolveTexturePaths(ImportContext &ctx, // file, so there is nothing to look for; anything else that is missing // now is worth saying, because the device that opens it later cannot // say which Stage prim asked for it. - if (file.find('<') == std::string::npos - && !std::filesystem::exists(file)) { + if (file.find('<') != std::string::npos) { ctx.reportSkip(materialPath, primType, UsdSkipReason::TEXTURE_LOAD_FAILED, - file); + file + " (tiled texture sets are not supported)"); + continue; + } + if (!std::filesystem::exists(file)) { + ctx.reportSkip( + materialPath, primType, UsdSkipReason::TEXTURE_LOAD_FAILED, file); + continue; } + + retval.push_back({input->getNamePath(), file, inputIsLinear(input)}); } } + + return retval; } // Native MaterialX passthrough. The document is generated from the resolved @@ -381,8 +412,12 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, if (!document) return {}; - resolveTexturePaths( - ctx, materialPath, prim.primType.GetString(), document, textures, networkInterface); + const auto documentTextures = resolveTexturePaths(ctx, + materialPath, + prim.primType.GetString(), + document, + textures, + networkInterface); // The document names its own surface material node; TSD selects by that // name rather than assuming one derived from the prim path. @@ -403,6 +438,26 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, retval->setParameter("sourceType", "documentInline"); retval->setParameter("source", xml.c_str()); retval->setParameter("materialName", materialName.c_str()); + + // A device reads the document's texels from samplers bound to the `filename` + // inputs by their document path, not by opening the files itself -- the + // document is inline text and names no search root a renderer could resolve + // against. TSD loads them here for the same reason it does for a preview + // surface, and through the same cache, so a texture shared between materials + // is read once. + for (const auto &texture : documentTextures) { + auto sampler = importTexture( + ctx.scene, texture.file, ctx.textureCache, texture.isLinear); + if (!sampler) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::TEXTURE_LOAD_FAILED, + texture.file); + continue; + } + retval->setParameterObject(Token(texture.inputPath.c_str()), *sampler); + } + return retval; } diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 0a9cd21be..3cbca3fd1 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -120,8 +120,10 @@ inline std::string StageFixture::path() const return m_path.string(); } -// A file standing where a texture would be. Only its existence is under test -// -- nothing in the import opens it -- so its contents do not matter. +// A real, decodable texture for the lifetime of one scenario. The import binds +// samplers by loading these, so a stand-in file with arbitrary bytes would not +// exercise the path -- this is a 1x1 uncompressed true-colour TGA, the +// smallest thing the image loader accepts that can be written by hand. struct TextureFixture { explicit TextureFixture(const char *name); @@ -136,8 +138,20 @@ struct TextureFixture inline TextureFixture::TextureFixture(const char *name) : m_path(std::filesystem::temp_directory_path() / name) { - std::ofstream file(m_path); - file << "not really a texture"; + const unsigned char tga[] = { + 0, // no image ID + 0, // no colour map + 2, // uncompressed true-colour + 0, 0, 0, 0, 0, // empty colour map spec + 0, 0, 0, 0, // origin + 1, 0, // width + 1, 0, // height + 24, // bits per pixel + 0, // descriptor + 0x20, 0x40, 0x60 // one BGR pixel + }; + std::ofstream file(m_path, std::ios::binary); + file.write(reinterpret_cast(tga), sizeof(tga)); } inline TextureFixture::~TextureFixture() @@ -1093,7 +1107,7 @@ def Xform "World" // cannot open. GIVEN("A MaterialX network reading textures by relative path") { - TextureFixture present("tsd_test_usd_mtlx_present.png"); + TextureFixture present("tsd_test_usd_mtlx_present.tga"); StageFixture stage("tsd_test_usd_materialx_textures.usda", R"(#usda 1.0 @@ -1106,7 +1120,7 @@ def Xform "World" def Shader "Present" { uniform token info:id = "ND_image_color3" - asset inputs:file = @tsd_test_usd_mtlx_present.png@ + asset inputs:file = @tsd_test_usd_mtlx_present.tga@ color3f outputs:out } @@ -1153,7 +1167,7 @@ def Xform "World" THEN("Texture paths leave as absolute paths") { REQUIRE(source.find(present.path()) != std::string::npos); - REQUIRE(source.find("\"tsd_test_usd_mtlx_present.png\"") + REQUIRE(source.find("\"tsd_test_usd_mtlx_present.tga\"") == std::string::npos); } @@ -1174,6 +1188,35 @@ def Xform "World" REQUIRE_FALSE(missedThisOne); } } + + // The device reads texels from samplers bound to the document's + // `filename` inputs by their document path, not by opening the files + // itself, so a material without them renders untextured however correct + // its paths are. + THEN("A sampler is bound to the input by its document path") + { + REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); + + // The name is the contract: the device publishes each textured input + // under its MaterialX element path. + auto *material = boundMaterial(scene); + std::string boundName; + for (size_t i = 0; i < material->numParameters(); i++) { + if (material->parameterAt(i).value().type() == ANARI_SAMPLER) + boundName = material->parameterNameAt(i); + } + REQUIRE_FALSE(boundName.empty()); + // The document path, node graph included -- the same string the device's + // shader generator reports as the port's path. + REQUIRE(boundName == "_/Present/file"); + } + + THEN("A tile set binds nothing, and says so") + { + REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + // Only the one loadable texture became a sampler. + REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); + } } } From ce96a389088246af4e2c52786c01d72491039346 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sun, 26 Jul 2026 16:45:40 -0500 Subject: [PATCH 09/78] feat(io): decode TIFF textures via optional OpenImageIO stb has no TIFF decoder, so `importTexture` fell through to it for `.tif`/`.tiff` and every such texture failed to load. On the OpenPBR Shader Playground asset that cost 7 of 117 MaterialX texture inputs (the `walls`, `iceCube` and `iceCubesInner` materials). Rather than take on a format-specific decoder, dispatch to OpenImageIO, which is already installed alongside the OpenUSD builds this importer targets and covers every other format it has a reader for. It is gated behind `TSD_USE_OIIO` (default OFF) like the other optional dependencies; with it off, `.tif` now reports that TSD was built without a decoder instead of surfacing an opaque stb decode failure. OIIO hands back raw normalized values where stb applies the sRGB transfer function via `stbi_ldr_to_hdr_gamma()`, so the decode is applied here, alpha untouched, to keep all texture paths on one contract. `TSD_USE_OIIO` is a PUBLIC definition so the test can assert the decoded path only where a decoder exists -- the same widening `TSD_USE_USD` already makes. Measured on the reference asset, with `TSD_USE_OIIO=ON`: skipped prims went from 96 texture-load failures to 89, and every one that remains is a `` tile set, which is a separate gap. Zero `sampler not bound` warnings from the device. Full suite 30/30. --- tsd/CMakeLists.txt | 2 + tsd/src/tsd/io/CMakeLists.txt | 10 ++ .../io/importers/detail/importer_common.cpp | 90 ++++++++++++++ tsd/tests/test_Importers.cpp | 111 ++++++++++++++++++ 4 files changed, 213 insertions(+) diff --git a/tsd/CMakeLists.txt b/tsd/CMakeLists.txt index 16b14c04c..fc985f4ee 100644 --- a/tsd/CMakeLists.txt +++ b/tsd/CMakeLists.txt @@ -58,6 +58,8 @@ option(TSD_USE_USD "Use OpenUSD where relevant" OFF) option(TSD_USE_TORCH "Use PyTorch for importing neural geometries" OFF) option(TSD_USE_VTK "Use VTK for importing VTK file formats" OFF) option(TSD_USE_SILO "Use Silo for importing Silo file formats" OFF) +option(TSD_USE_OIIO + "Use OpenImageIO to decode texture formats stb cannot (e.g. TIFF)" OFF) option(TSD_USE_MPI "Enable MPI support" OFF) option(TSD_USE_NETWORKING "Enable networking support via boost.asio" OFF) option(TSD_NANOVDB_SKIP_INVALID_VOLUMES diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 45548f87d..e216e461a 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -128,6 +128,16 @@ else() project_compile_definitions(PRIVATE -DTSD_USE_HDF5=0) endif() +# PUBLIC so the test target can assert on the TIFF path only where a decoder +# exists, the same widening TSD_USE_USD makes for the same reason. +if (TSD_USE_OIIO) + find_package(OpenImageIO REQUIRED) + project_compile_definitions(PUBLIC -DTSD_USE_OIIO=1) + project_link_libraries(PRIVATE OpenImageIO::OpenImageIO) +else() + project_compile_definitions(PUBLIC -DTSD_USE_OIIO=0) +endif() + if(TSD_USE_TORCH) find_package(Torch REQUIRED) if (MSVC) diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 98a351ba3..64772dde1 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -16,6 +16,10 @@ #ifndef _WIN32 #include "tinyexr.h" #endif +#if TSD_USE_OIIO +// OpenImageIO +#include +#endif // anari #include // std @@ -432,6 +436,81 @@ static SamplerRef importExrTexture( } #endif +#if TSD_USE_OIIO +// stb decodes LDR files to float and applies the sRGB transfer function via +// stbi_ldr_to_hdr_gamma(); OpenImageIO hands back the raw normalized values, so +// the decode is applied here to keep every texture path on the same contract. +// Alpha stays untouched, matching stb. +static void srgbToLinearInPlace(float *texels, size_t numTexels, int numChannels) +{ + const int numColorChannels = std::min(numChannels, 3); + for (size_t t = 0; t < numTexels; t++) { + float *texel = texels + t * numChannels; + for (int c = 0; c < numColorChannels; c++) + texel[c] = std::pow(texel[c], 2.2f); + } +} + +// TIFF is the format the USD/MaterialX assets reach for that stb cannot decode. +// OpenImageIO covers it (and everything else it has a reader for) without TSD +// taking on a format-specific decoder. +static SamplerRef importOiioTexture( + Scene &scene, const std::string &filepath, TextureCache &cache, bool isLinear) +{ + auto cacheKey = makeTextureCacheKey(filepath, isLinear); + if (auto dataArray = cache[cacheKey]; dataArray.valid()) + return makeTextureSampler(scene, dataArray, filepath); + + auto image = OIIO::ImageInput::open(filepath); + if (!image) { + logError("[importTexture] failed to open texture '%s': %s", + filepath.c_str(), + OIIO::geterror().c_str()); + return {}; + } + + const auto &spec = image->spec(); + const int width = spec.width; + const int height = spec.height; + const int numChannels = spec.nchannels; + if (width < 1 || height < 1 || numChannels < 1 || numChannels > 4) { + logWarning("[importTexture] texture '%s' with %i channels not imported", + filepath.c_str(), + numChannels); + image->close(); + return {}; + } + + std::vector texels(size_t(width) * height * numChannels); + if (!image->read_image( + 0, 0, 0, numChannels, OIIO::TypeDesc::FLOAT, texels.data())) { + logError("[importTexture] failed to decode texture '%s': %s", + filepath.c_str(), + image->geterror().c_str()); + image->close(); + return {}; + } + image->close(); + + if (!isLinear) + srgbToLinearInPlace(texels.data(), size_t(width) * height, numChannels); + + int texelType = ANARI_FLOAT32_VEC4; + if (numChannels == 3) + texelType = ANARI_FLOAT32_VEC3; + else if (numChannels == 2) + texelType = ANARI_FLOAT32_VEC2; + else if (numChannels == 1) + texelType = ANARI_FLOAT32; + + auto dataArray = scene.createArray(texelType, width, height); + dataArray->setData(texels.data()); + cache[cacheKey] = dataArray; + + return makeTextureSampler(scene, dataArray, filepath); +} +#endif + SamplerRef importTexture( Scene &scene, std::string filepath, TextureCache &cache, bool isLinear) { @@ -451,6 +530,17 @@ SamplerRef importTexture( #ifndef _WIN32 } else if (ext == ".exr") { tex = importExrTexture(scene, filepath, cache); +#endif + } else if (ext == ".tif" || ext == ".tiff") { +#if TSD_USE_OIIO + tex = importOiioTexture(scene, filepath, cache, isLinear); +#else + // Falling through to stb would fail with a decode error that says nothing + // about the actual cause, which is that TSD was built without OpenImageIO. + logError( + "[importTexture] cannot decode TIFF texture '%s': TSD was built without" + " OpenImageIO (set TSD_USE_OIIO=ON)", + filepath.c_str()); #endif } else { tex = importStbTexture(scene, filepath, cache, isLinear); diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index ab31cd41c..fa26fc3db 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -5,12 +5,15 @@ #include "catch.hpp" // tsd #include "tsd/animation/AnimationManager.hpp" +#include "tsd/core/TSDMath.hpp" #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/importer_common.hpp" #include "tsd/scene/Scene.hpp" // std +#include #include #include +#include SCENARIO( "Volume transfer functions reject missing control points", "[Importers]") @@ -64,3 +67,111 @@ SCENARIO( std::filesystem::remove(path); } + +namespace { + +// A 1x1 uncompressed RGB8 TIFF, little-endian, written by hand: stb has no +// TIFF decoder, so the fixture has to be a genuinely decodable file for the +// OpenImageIO branch to be exercised at all. Layout is header(8) + a 9-entry +// IFD(114) + the BitsPerSample triple(6) + one contiguous RGB texel(3). +struct TiffFixture +{ + explicit TiffFixture(const char *name) + : m_path(std::filesystem::temp_directory_path() / name) + { + const unsigned char tiff[] = { + // clang-format off + 'I', 'I', 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, // header, IFD at 8 + 0x09, 0x00, // 9 IFD entries + 0x00, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // width = 1 + 0x01, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // height = 1 + 0x02, 0x01, 0x03, 0x00, 0x03, 0, 0, 0, 0x7a, 0x00, 0, 0, // bits @ 122 + 0x03, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // no compress + 0x06, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x02, 0x00, 0, 0, // RGB + 0x11, 0x01, 0x04, 0x00, 0x01, 0, 0, 0, 0x80, 0x00, 0, 0, // strip @ 128 + 0x15, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x03, 0x00, 0, 0, // 3 samples + 0x16, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // 1 row/strip + 0x17, 0x01, 0x04, 0x00, 0x01, 0, 0, 0, 0x03, 0x00, 0, 0, // 3 bytes + 0x00, 0x00, 0x00, 0x00, // no next IFD + 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, // BitsPerSample = [8, 8, 8] + 0x60, 0x40, 0x20 // one RGB texel + // clang-format on + }; + std::ofstream file(m_path, std::ios::binary); + file.write(reinterpret_cast(tiff), sizeof(tiff)); + } + + ~TiffFixture() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + std::string path() const + { + return m_path.string(); + } + + private: + std::filesystem::path m_path; +}; + +} // namespace + +SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") +{ + GIVEN("A 1x1 RGB8 TIFF file") + { + TiffFixture tiff("tsd_test_1x1_rgb8.tif"); + + tsd::scene::Scene scene; + tsd::io::TextureCache cache; + + WHEN("It is imported as a linear texture") + { + auto sampler = tsd::io::importTexture( + scene, tiff.path(), cache, /*isLinear=*/true); + +#if TSD_USE_OIIO + THEN("The sampler carries the file's texels untransformed") + { + REQUIRE(sampler); + auto *image = + sampler->parameterValueAsObject("image"); + REQUIRE(image != nullptr); + REQUIRE(image->elementType() == ANARI_FLOAT32_VEC3); + REQUIRE(image->size() == 1); + const auto *texels = image->dataAs(); + REQUIRE(texels[0].x == Approx(0x60 / 255.f)); + REQUIRE(texels[0].y == Approx(0x40 / 255.f)); + REQUIRE(texels[0].z == Approx(0x20 / 255.f)); + } +#else + THEN("No sampler is produced, because no decoder is available") + { + REQUIRE(!sampler); + } +#endif + } + +#if TSD_USE_OIIO + WHEN("It is imported as an sRGB texture") + { + auto sampler = tsd::io::importTexture( + scene, tiff.path(), cache, /*isLinear=*/false); + + THEN("The texels are decoded to linear, matching the stb-backed paths") + { + REQUIRE(sampler); + auto *image = + sampler->parameterValueAsObject("image"); + REQUIRE(image != nullptr); + const auto *texels = image->dataAs(); + REQUIRE(texels[0].x == Approx(std::pow(0x60 / 255.f, 2.2f))); + REQUIRE(texels[0].y == Approx(std::pow(0x40 / 255.f, 2.2f))); + REQUIRE(texels[0].z == Approx(std::pow(0x20 / 255.f, 2.2f))); + } + } +#endif + } +} From 253118a59d7a5b67a58537a8135c5917876d6812 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sun, 26 Jul 2026 16:54:47 -0500 Subject: [PATCH 10/78] fix(core): teach tsd::Any that std::string is ANARI_STRING anari_cpp maps only `const char *` onto ANARI_STRING, so `ANARITypeFor::value` is ANARI_UNKNOWN and every generic Any template silently missed strings: `is()` reduced to `is(ANARI_UNKNOWN)` and was false for every value ever held, and `Any a = std::string("hello")` did not compile at all -- despite being the example in Any.hpp's own header comment. The visible symptom was Lua: `ref:getParameter()` returned nil for every ANARI_STRING parameter, because ParameterHelpers reaches for `val.is()`. That made scripted inspection of string parameters -- `source` and `materialName` on imported USD materials among them -- impossible. Fix it at the root, in Any, so the two RenderBindings call sites with the same shape are covered too. The specializations are local to Any rather than a global ANARI_TYPEFOR_SPECIALIZATION, which would leak the mapping into anari_cpp calls that would then memcpy a std::string into device storage. `get()` routes through `getString()`, since `getAs<>()` static_asserts on ANARI_STRING for exactly that reason. Verified against a freshly built `tsdLua`: the new `ref:setParameter with string` case fails on the pre-fix binary and passes after. Unit coverage added at the Any level. Full suite 30/30. --- tsd/scripts/test_lua_api.lua | 7 ++++++ tsd/src/tsd/core/Any.hpp | 31 +++++++++++++++++++++++ tsd/tests/test_Parameter.cpp | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/tsd/scripts/test_lua_api.lua b/tsd/scripts/test_lua_api.lua index d05891e3c..86a575d19 100644 --- a/tsd/scripts/test_lua_api.lua +++ b/tsd/scripts/test_lua_api.lua @@ -276,6 +276,13 @@ test("ref:setParameter with bool", function() assert(val == true, "caps should be true") end) +test("ref:setParameter with string", function() + local mat = scene:createMaterial("matte") + mat:setParameter("alphaMode", "blend") + local val = mat:getParameter("alphaMode") + assert(val == "blend", "alphaMode should round-trip as a string") +end) + test("ref.name property", function() local geom = scene:createGeometry("sphere") geom.name = "myGeometry" diff --git a/tsd/src/tsd/core/Any.hpp b/tsd/src/tsd/core/Any.hpp index 5470daaa8..7d3713c14 100644 --- a/tsd/src/tsd/core/Any.hpp +++ b/tsd/src/tsd/core/Any.hpp @@ -126,6 +126,18 @@ inline Any::Any(T value) : Any() m_type = type; } +// anari::ANARITypeFor is ANARI_UNKNOWN -- anari_cpp only maps +// `const char *` onto ANARI_STRING -- so every generic template above silently +// misses std::string. Specializing here rather than adding a global +// ANARI_TYPEFOR_SPECIALIZATION keeps the mapping from leaking into anari_cpp +// calls that would then memcpy a std::string into device storage. +template <> +inline Any::Any(std::string value) : Any() +{ + m_string = std::move(value); + m_type = ANARI_STRING; +} + inline Any::Any(bool value) { uint32_t v = value; @@ -222,6 +234,19 @@ inline T Any::get() const return getAs(type()); } +// getAs<>() static_asserts on ANARI_STRING because strings live outside the +// fixed-size storage getAs<>() memcpys from; route through getString() instead. +template <> +inline std::string Any::get() const +{ + // is(ANARI_STRING) rather than is(), whose specialization is + // declared further down: using it here would instantiate the primary + // template first and make that specialization ill-formed. + if (!is(ANARI_STRING)) + throw std::runtime_error("get() called with invalid type on tsd::Any"); + return getString(); +} + template inline T Any::getAs(anari::DataType expectedType) const { @@ -276,6 +301,12 @@ inline bool Any::is() const return is(ANARI_BOOL); } +template <> +inline bool Any::is() const +{ + return is(ANARI_STRING); +} + inline bool Any::is(anari::DataType t) const { return type() == t; diff --git a/tsd/tests/test_Parameter.cpp b/tsd/tests/test_Parameter.cpp index 816b49fa5..14066e999 100644 --- a/tsd/tests/test_Parameter.cpp +++ b/tsd/tests/test_Parameter.cpp @@ -5,6 +5,8 @@ #include "catch.hpp" // tsd #include "tsd/scene/Parameter.hpp" +// std +#include namespace { @@ -116,3 +118,49 @@ SCENARIO("tsd::scene::Parameter interface", "[Parameter]") } } } + +SCENARIO("tsd::core::Any recognizes strings through the generic accessors", + "[Parameter]") +{ + // ANARITypeFor is ANARI_UNKNOWN, so the generic is<>/get<> + // templates silently miss ANARI_STRING unless Any specializes them. Callers + // that reach for is() -- the Lua bindings among them -- then + // treat every string parameter as absent. + GIVEN("An Any holding a string") + { + tsd::core::Any value = std::string("hello"); + + THEN("Its type is ANARI_STRING") + { + REQUIRE(value.type() == ANARI_STRING); + } + + THEN("is() reports the string") + { + REQUIRE(value.is()); + REQUIRE(!value.is()); + REQUIRE(!value.is()); + } + + THEN("get() returns the string") + { + REQUIRE(value.get() == "hello"); + } + + THEN("getValueOr() returns the string") + { + REQUIRE(value.getValueOr("fallback") == "hello"); + } + } + + GIVEN("An Any holding a non-string") + { + tsd::core::Any value = 5; + + THEN("It is not mistaken for a string") + { + REQUIRE(!value.is()); + REQUIRE(value.getValueOr("fallback") == "fallback"); + } + } +} From d6ae9394909882dba82c540f7536c44a4bd26e6f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sun, 26 Jul 2026 17:06:03 -0500 Subject: [PATCH 11/78] docs(adr): record why UDIM tile sets are reported, not approximated 89 of the OpenPBR Shader Playground asset's texture inputs are `` tile sets, and after TIFF decoding landed they are the entirety of the importer's remaining texture failures. The obvious next move -- lean on MDL's native `` support and let the device resolve the tiles -- does not work, and the reason is not visible from TSD, so record it. VisRTX does load MDL texture resources from disk (`SamplerRegistry::loadFromImage` stb-loads `textureDesc.url`, which corrects the "the device does not open texture files" reading -- that holds only for the sampler-bound MaterialX path). But `libmdl::Core::resolveResource` returns `get_element(0)->get_filename(0)`, one filename, while MDL's entity resolver returns one element per tile; all but the first are discarded, and `loadFromImage` then builds exactly one `Image2D`. Nothing under `devices/` carries a UDIM concept at all. So `mdlResourceSearchPaths` -- the seam that would clear the cosmetic `Failed to resolve texture resource` log noise -- would not change the outcome, and TSD-side tile expansion has nowhere to send the texels either: an ANARI sampler is a single image. Binding tile 1001 and dropping the rest was considered and rejected; it turns a reported gap into a silently wrong render for any mesh spanning more than one tile. The gap stays reported, with the anchored absolute path still written into the document so a consumer that gains UDIM support finds it well-formed. Revisit when ANARI grows a tiled or array sampler, or when the MDL runtime grows tile-indexed lookup. --- ...19-report-udim-tile-sets-as-unsupported.md | 44 +++++++++++++++++++ .../io/importers/detail/usd/UsdMaterials.cpp | 5 ++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md diff --git a/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md b/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md new file mode 100644 index 000000000..7a99791b7 --- /dev/null +++ b/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md @@ -0,0 +1,44 @@ +# Report UDIM tile sets as unsupported rather than approximate them + +A MaterialX `filename` input whose path contains a `` marker names a set +of tiles, not a file. `UsdMaterials.cpp` reports such an input as +`TEXTURE_LOAD_FAILED` with `(tiled texture sets are not supported)` and binds no +sampler, while still writing the anchored absolute path into the generated +document. On the OpenPBR Shader Playground reference asset that accounts for 89 +of the importer's 100 skipped prims -- every remaining texture failure after +TIFF decoding landed. + +Two routes were investigated and both are closed at the level TSD can reach. + +The device cannot resolve the tile set on TSD's behalf. VisRTX does load MDL +texture resources from disk -- `SamplerRegistry::loadFromImage` reads +`textureDesc.url` with stb -- but `libmdl::Core::resolveResource` returns +`get_element(0)->get_filename(0)`, a single filename. MDL's entity resolver +returns one element per tile for a `` resource; VisRTX discards all but +the first, and `loadFromImage` then builds exactly one `Image2D`. Nothing in +`devices/` carries a UDIM concept. So MDL's native `` support, which is +real, is not plumbed through this device at all, and setting +`mdlResourceSearchPaths` -- the seam that would fix the cosmetic +`Failed to resolve texture resource` log noise -- would not change the outcome. +Enabling it properly means multi-tile resolution in libmdl, a tile-indexed +texture in the MDL runtime PTX, and a representation for tiled textures in +ANARI, which has none: an ANARI sampler is a single image. + +TSD cannot expand the tiles either, for the same reason. Rewriting the +MaterialX network into per-tile branches is a large amount of machinery, and +the obvious shortcut -- binding tile 1001 and dropping the rest -- is wrong for +every mesh that actually spans more than one tile, and worse, it converts a +reported gap into a silently incorrect render. + +The gap is therefore left reported rather than approximated. Two properties +make that a deliberate stance and not neglect: the skip is counted in the +import report with a reason, so the cost is visible, and the anchored absolute +path is still written into the document, so a consumer that gains UDIM support +finds a well-formed path waiting. `SdfAssetPath::GetResolvedPath()` is empty +for every UDIM path -- a `` path names no file, so no resolver resolves +it -- which is why that path comes from the Stage-directory fallback anchor +(ADR-adjacent, see commit `0ed23586`) rather than from USD. + +Revisit this when ANARI gains a tiled-texture or texture-array sampler, or when +VisRTX's MDL runtime grows tile-indexed lookup. Until one of those exists, +"supporting UDIM in TSD" has nowhere to send the texels. diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index da67947ea..5294e443a 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -349,7 +349,10 @@ std::vector resolveTexturePaths(ImportContext &ctx, // A path holding a MaterialX token names a set of tiles rather than a // file, so there is nothing to look for; anything else that is missing // now is worth saying, because the device that opens it later cannot - // say which Stage prim asked for it. + // say which Stage prim asked for it. Reporting the tile set rather than + // approximating it is a deliberate stance -- an ANARI sampler is a + // single image and neither TSD nor the device has anywhere to send the + // remaining tiles' texels. See ADR 0019. if (file.find('<') != std::string::npos) { ctx.reportSkip(materialPath, primType, From 19904db309a92a631b49c5521c333ac8206a497f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sun, 26 Jul 2026 17:23:39 -0500 Subject: [PATCH 12/78] fix(io): match stb's texel contract in the OpenImageIO path Review of fca25ddd turned up three ways the OpenImageIO decode diverged from the stb paths it claimed to match, each of which would have shown up only on a TIFF with alpha or a float TIFF -- shapes the 1x1 RGB8 fixture never exercised. Alpha was being gamma-corrected on 2-channel images. stb's rule is `if (comp & 1) n = comp; else n = comp-1` (stb_image.h:1573), so an even channel count ends in alpha and an odd one is all colour; `min(n, 3)` agrees for 1, 3 and 4 channels but not for grey+alpha. The comment claiming "alpha stays untouched, matching stb" was therefore false for exactly the case it was written about. OpenImageIO also premultiplies unassociated alpha into the colour channels by default, which stb never does; the new grey+alpha fixture caught it. Ask for the file's own values with `oiio:UnassociatedAlpha`. Third, float and half TIFFs already carry linear values, and stb only ever gamma-decodes integer input, so the curve is now applied only when the file's format is integral. Also from review: rename `srgbToLinearInPlace` to `applyGamma22InPlace`, since it applies pow(x, 2.2) rather than the true sRGB EOTF -- matching stb is right, the old name overclaimed; extract the channel-count to ANARI-type cascade, which was duplicated verbatim between the stb and OIIO decoders; drop three redundant `image->close()` calls, as the unique_ptr's deleter closes on every exit path; reorder the Any specializations so `is()` precedes `get()`, which removes an ordering hazard and the comment explaining it; record in io/CMakeLists.txt why OpenUSD's Hio was rejected in favour of depending on OpenImageIO directly; and list `TSD_USE_OIIO` in AGENTS.md alongside the other optional flags. Adds docs/usd-materialx-known-gaps.md for the MaterialX import observations that are understood but undiagnosed -- the single 'Surface' nodedef transcode failure, the geometry_opacity version skew, and the cosmetic MDL resolve noise -- so they survive the handoff. Full suite 30/30, 74 Lua cases pass, and the reference asset is unchanged: 89 skips, all UDIM, zero `sampler not bound`. --- tsd/AGENTS.md | 2 +- ...19-report-udim-tile-sets-as-unsupported.md | 4 +- tsd/docs/usd-materialx-known-gaps.md | 53 +++++++++++++ tsd/src/tsd/core/Any.hpp | 23 +++--- tsd/src/tsd/io/CMakeLists.txt | 7 ++ .../io/importers/detail/importer_common.cpp | 72 ++++++++++-------- tsd/tests/test_Importers.cpp | 74 +++++++++++++++++++ 7 files changed, 189 insertions(+), 46 deletions(-) create mode 100644 tsd/docs/usd-materialx-known-gaps.md diff --git a/tsd/AGENTS.md b/tsd/AGENTS.md index 5fdc3273b..e6df92e28 100644 --- a/tsd/AGENTS.md +++ b/tsd/AGENTS.md @@ -37,7 +37,7 @@ Requires ANARI-SDK 0.15.0+ (`find_package(anari)` must succeed). cmake -DVISRTX_BUILD_TSD=ON -DTSD_BUILD_APPS=ON ... ``` -Key optional CMake flags: `TSD_USE_LUA`, `TSD_USE_ASSIMP`, `TSD_USE_HDF5`, `TSD_USE_MPI`, `TSD_USE_NETWORKING`, `TSD_USE_VTK`, `TSD_USE_SILO`, `TSD_USE_USD`. +Key optional CMake flags: `TSD_USE_LUA`, `TSD_USE_ASSIMP`, `TSD_USE_HDF5`, `TSD_USE_MPI`, `TSD_USE_NETWORKING`, `TSD_USE_VTK`, `TSD_USE_SILO`, `TSD_USE_USD`, `TSD_USE_OIIO`. ## Tests diff --git a/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md b/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md index 7a99791b7..ba016a2ee 100644 --- a/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md +++ b/tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md @@ -36,8 +36,8 @@ import report with a reason, so the cost is visible, and the anchored absolute path is still written into the document, so a consumer that gains UDIM support finds a well-formed path waiting. `SdfAssetPath::GetResolvedPath()` is empty for every UDIM path -- a `` path names no file, so no resolver resolves -it -- which is why that path comes from the Stage-directory fallback anchor -(ADR-adjacent, see commit `0ed23586`) rather than from USD. +it -- which is why that path comes from `UsdMaterials.cpp`'s Stage-directory +fallback anchor rather than from USD's own resolution. Revisit this when ANARI gains a tiled-texture or texture-array sampler, or when VisRTX's MDL runtime grows tile-indexed lookup. Until one of those exists, diff --git a/tsd/docs/usd-materialx-known-gaps.md b/tsd/docs/usd-materialx-known-gaps.md new file mode 100644 index 000000000..3fcef4c37 --- /dev/null +++ b/tsd/docs/usd-materialx-known-gaps.md @@ -0,0 +1,53 @@ +# USD MaterialX import — known gaps + +Observations from importing MaterialX USD Stages that are understood well enough +to record but not yet diagnosed. Measured against +`OpenPBRShaderPlayground-1.0/ShdrPlygrnd/ShdrPlygrnd_OpenPBR.usda` — 55 +MaterialX materials, 117 texture inputs. + +**UDIM tile sets are reported, not imported.** 89 of the 117 texture inputs. +This one *is* decided; see +[ADR 0019](adr/0019-report-udim-tile-sets-as-unsupported.md). + +**One material fails to transcode.** The device logs +`MaterialX: failed to transcode '': Could not find a nodedef +for node 'Surface'`, then an MDL compile error, then falls back to the default +material. Exactly one material out of 55. The error comes from MaterialX's own +`ShaderGraph.cpp`, so shader generation inside the device is what fails, not +TSD's emission. No prim named `Surface` exists on the composed Stage and none of +the 55 `.mtlx` files contains a node by that name, so the name is synthesized +somewhere between the Hydra material network and MaterialX codegen. Diagnosing +it needs the failing inline document dumped from the device. + +**hdMtlx validation warning on every material.** `Input 'geometry_opacity' +doesn't match declaration: `. OpenPBR nodedef version skew +between the asset (MaterialX 1.39) and the locally built MaterialX. Non-fatal: +documents still generate and render. + +**MDL logs a resolve failure for every texture, including bound ones.** +`Failed to resolve texture resource `. MDL treats a leading `/` as +root-relative to a registered resource search root rather than as a host path, +and TSD never sets the device's `mdlResourceSearchPaths` parameter +(`ANARIDeviceManager::initialDeviceParams` is the existing seam, with no caller +populating it). Cosmetic for any input that has a sampler bound, since the +sampler supplies the texels — but it is noise that hides real failures. + +## Measuring + +`tsdOffline` defaults to the `visrtx_matx` library and prompts for a camera: + +```bash +F=.../ShdrPlygrnd/ShdrPlygrnd_OpenPBR.usda +echo 8 | ./tsdOffline -usd_matx $F -o /tmp/out.png -s 1 -w 128 -h 96 > /tmp/run.log 2>&1 +``` + +Redirect with `> log 2>&1`, not `2>&1 > log`, or the ANARI errors miss the file. +`-s 1 -w 128 -h 96` collects device errors without waiting on a real render. +Grep the log for `sampler not bound`, `failed to transcode`, +`Failed to resolve texture resource`, and `texture load failed`. + +Renders are a poor oracle here — the asset's default lighting is dark and noisy, +so "did the texture bind" cannot be judged by eye. Assert in the suite instead; +`tests/test_UsdImport.cpp` and `tests/test_Importers.cpp` carry the fixture +patterns, both hand-writing decodable 1x1 files because the decoders need real +ones. diff --git a/tsd/src/tsd/core/Any.hpp b/tsd/src/tsd/core/Any.hpp index 7d3713c14..7e38360ae 100644 --- a/tsd/src/tsd/core/Any.hpp +++ b/tsd/src/tsd/core/Any.hpp @@ -234,19 +234,6 @@ inline T Any::get() const return getAs(type()); } -// getAs<>() static_asserts on ANARI_STRING because strings live outside the -// fixed-size storage getAs<>() memcpys from; route through getString() instead. -template <> -inline std::string Any::get() const -{ - // is(ANARI_STRING) rather than is(), whose specialization is - // declared further down: using it here would instantiate the primary - // template first and make that specialization ill-formed. - if (!is(ANARI_STRING)) - throw std::runtime_error("get() called with invalid type on tsd::Any"); - return getString(); -} - template inline T Any::getAs(anari::DataType expectedType) const { @@ -307,6 +294,16 @@ inline bool Any::is() const return is(ANARI_STRING); } +// getAs<>() static_asserts on ANARI_STRING because strings live outside the +// fixed-size storage it memcpys from; route through getString() instead. +template <> +inline std::string Any::get() const +{ + if (!is()) + throw std::runtime_error("get() called with invalid type on tsd::Any"); + return getString(); +} + inline bool Any::is(anari::DataType t) const { return type() == t; diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index e216e461a..a223c8e31 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -128,6 +128,13 @@ else() project_compile_definitions(PRIVATE -DTSD_USE_HDF5=0) endif() +# OpenUSD's Hio was the alternative here, since a USD-enabled build already +# links it -- but base Hio decodes TIFF only through its hioOiio plugin, which +# is not built in the OpenUSD installs this importer targets (they ship +# hioOpenEXR and hioAvif). Going via Hio would therefore still require +# OpenImageIO, with an extra plugin indirection in front of it, so depend on +# OpenImageIO directly and keep the TIFF path available to non-USD builds too. +# # PUBLIC so the test target can assert on the TIFF path only where a decoder # exists, the same widening TSD_USE_USD makes for the same reason. if (TSD_USE_OIIO) diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 64772dde1..ae6cf0899 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -325,6 +325,21 @@ SamplerRef importDdsTexture( : SamplerRef{}; } +// Decoded texels are always float, so channel count alone picks the type. +static int texelTypeForChannelCount(int numChannels) +{ + switch (numChannels) { + case 1: + return ANARI_FLOAT32; + case 2: + return ANARI_FLOAT32_VEC2; + case 3: + return ANARI_FLOAT32_VEC3; + default: + return ANARI_FLOAT32_VEC4; + } +} + static ArrayRef importStbTextureArray(Scene &scene, const void *data, size_t numBytes, @@ -362,15 +377,8 @@ static ArrayRef importStbTextureArray(Scene &scene, return {}; } - int texelType = ANARI_FLOAT32_VEC4; - if (n == 3) - texelType = ANARI_FLOAT32_VEC3; - else if (n == 2) - texelType = ANARI_FLOAT32_VEC2; - else if (n == 1) - texelType = ANARI_FLOAT32; - - dataArray = scene.createArray(texelType, width, height); + dataArray = + scene.createArray(texelTypeForChannelCount(n), width, height); dataArray->setData(decodedData); cache[textureId] = dataArray; @@ -437,13 +445,17 @@ static SamplerRef importExrTexture( #endif #if TSD_USE_OIIO -// stb decodes LDR files to float and applies the sRGB transfer function via -// stbi_ldr_to_hdr_gamma(); OpenImageIO hands back the raw normalized values, so -// the decode is applied here to keep every texture path on the same contract. -// Alpha stays untouched, matching stb. -static void srgbToLinearInPlace(float *texels, size_t numTexels, int numChannels) +// stb decodes LDR files to float through stbi_ldr_to_hdr_gamma(), a plain +// pow(x, 2.2) rather than the true sRGB EOTF; OpenImageIO hands back the raw +// normalized values, so the same curve is applied here to keep every texture +// path on one contract. stb takes an odd channel count to be all-color and an +// even one to end in alpha (stb_image.h: `if (comp & 1) n = comp; else +// n = comp-1`), which is what leaves alpha linear -- match that exactly, or a +// 2-channel grey+alpha image gets its alpha gamma-corrected. +static void applyGamma22InPlace(float *texels, size_t numTexels, int numChannels) { - const int numColorChannels = std::min(numChannels, 3); + const int numColorChannels = + (numChannels & 1) ? numChannels : numChannels - 1; for (size_t t = 0; t < numTexels; t++) { float *texel = texels + t * numChannels; for (int c = 0; c < numColorChannels; c++) @@ -461,7 +473,14 @@ static SamplerRef importOiioTexture( if (auto dataArray = cache[cacheKey]; dataArray.valid()) return makeTextureSampler(scene, dataArray, filepath); - auto image = OIIO::ImageInput::open(filepath); + // OpenImageIO premultiplies unassociated alpha into the colour channels by + // default; stb never does. Ask for the file's own values so a TIFF with + // alpha lands on the same contract as every other texture path. + OIIO::ImageSpec config; + config.attribute("oiio:UnassociatedAlpha", 1); + + // The returned unique_ptr's deleter closes the file on every exit path. + auto image = OIIO::ImageInput::open(filepath, &config); if (!image) { logError("[importTexture] failed to open texture '%s': %s", filepath.c_str(), @@ -477,9 +496,11 @@ static SamplerRef importOiioTexture( logWarning("[importTexture] texture '%s' with %i channels not imported", filepath.c_str(), numChannels); - image->close(); return {}; } + // stb only ever gamma-decodes integer input; a float or half TIFF already + // carries linear values, so applying the curve to it would darken the image. + const bool fileIsIntegral = !spec.format.is_floating_point(); std::vector texels(size_t(width) * height * numChannels); if (!image->read_image( @@ -487,23 +508,14 @@ static SamplerRef importOiioTexture( logError("[importTexture] failed to decode texture '%s': %s", filepath.c_str(), image->geterror().c_str()); - image->close(); return {}; } - image->close(); - - if (!isLinear) - srgbToLinearInPlace(texels.data(), size_t(width) * height, numChannels); - int texelType = ANARI_FLOAT32_VEC4; - if (numChannels == 3) - texelType = ANARI_FLOAT32_VEC3; - else if (numChannels == 2) - texelType = ANARI_FLOAT32_VEC2; - else if (numChannels == 1) - texelType = ANARI_FLOAT32; + if (!isLinear && fileIsIntegral) + applyGamma22InPlace(texels.data(), size_t(width) * height, numChannels); - auto dataArray = scene.createArray(texelType, width, height); + auto dataArray = scene.createArray( + texelTypeForChannelCount(numChannels), width, height); dataArray->setData(texels.data()); cache[cacheKey] = dataArray; diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index fa26fc3db..7de534f9d 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -70,6 +70,52 @@ SCENARIO( namespace { +// A 1x1 uncompressed grey+alpha 8-bit TIFF. Two channels is the case where +// stb's rule -- an even channel count ends in alpha, an odd one is all colour +// -- diverges from "the first three channels are colour", so it is the only +// shape that catches alpha being gamma-corrected. +struct GreyAlphaTiffFixture +{ + explicit GreyAlphaTiffFixture(const char *name) + : m_path(std::filesystem::temp_directory_path() / name) + { + const unsigned char tiff[] = { + // clang-format off + 'I', 'I', 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, // header, IFD at 8 + 0x0a, 0x00, // 10 IFD entries + 0x00, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // width = 1 + 0x01, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // height = 1 + 0x02, 0x01, 0x03, 0x00, 0x02, 0, 0, 0, 0x08, 0x00, 0x08, 0x00, // bits + 0x03, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // no compress + 0x06, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // black-is-0 + 0x11, 0x01, 0x04, 0x00, 0x01, 0, 0, 0, 0x86, 0x00, 0, 0, // strip @ 134 + 0x15, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x02, 0x00, 0, 0, // 2 samples + 0x16, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x01, 0x00, 0, 0, // 1 row/strip + 0x17, 0x01, 0x04, 0x00, 0x01, 0, 0, 0, 0x02, 0x00, 0, 0, // 2 bytes + 0x52, 0x01, 0x03, 0x00, 0x01, 0, 0, 0, 0x02, 0x00, 0, 0, // unassoc a + 0x00, 0x00, 0x00, 0x00, // no next IFD + 0x60, 0x40 // one grey + alpha texel + // clang-format on + }; + std::ofstream file(m_path, std::ios::binary); + file.write(reinterpret_cast(tiff), sizeof(tiff)); + } + + ~GreyAlphaTiffFixture() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + std::string path() const + { + return m_path.string(); + } + + private: + std::filesystem::path m_path; +}; + // A 1x1 uncompressed RGB8 TIFF, little-endian, written by hand: stb has no // TIFF decoder, so the fixture has to be a genuinely decodable file for the // OpenImageIO branch to be exercised at all. Layout is header(8) + a 9-entry @@ -174,4 +220,32 @@ SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") } #endif } + +#if TSD_USE_OIIO + GIVEN("A 1x1 grey+alpha TIFF file") + { + GreyAlphaTiffFixture tiff("tsd_test_1x1_greyalpha.tif"); + + tsd::scene::Scene scene; + tsd::io::TextureCache cache; + + WHEN("It is imported as an sRGB texture") + { + auto sampler = tsd::io::importTexture( + scene, tiff.path(), cache, /*isLinear=*/false); + + THEN("Only the grey channel is gamma-decoded, leaving alpha linear") + { + REQUIRE(sampler); + auto *image = + sampler->parameterValueAsObject("image"); + REQUIRE(image != nullptr); + REQUIRE(image->elementType() == ANARI_FLOAT32_VEC2); + const auto *texels = image->dataAs(); + REQUIRE(texels[0].x == Approx(std::pow(0x60 / 255.f, 2.2f))); + REQUIRE(texels[0].y == Approx(0x40 / 255.f)); + } + } + } +#endif } From 274304ab5f3588ef00b04344df0a1514d1e563d3 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 27 Jul 2026 08:19:01 -0500 Subject: [PATCH 13/78] docs(usd): record why mdlResourceSearchPaths cannot silence the MDL noise Setting the parameter was tried against the OpenPBR Shader Playground asset and is blocked from both ends. Anchoring on the texture directories the importer already knows is a measured no-op -- MDL resolves a leading `/` root-relative, so the only root that can match a fully absolute host path is `/` itself. Registering `/` does silence all 109 failures, but it also resolves the 85 UDIM paths, and libmdl hands back tile 1001, which the device then binds: the render changes while the import report still claims the tile sets were skipped. That is the outcome ADR 0019 rejects. No code change -- the gap stays open, now with the routes out of it named and the dead one ruled out by measurement rather than left to be retried. Co-Authored-By: Claude Opus 5 --- tsd/docs/usd-materialx-known-gaps.md | 51 +++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tsd/docs/usd-materialx-known-gaps.md b/tsd/docs/usd-materialx-known-gaps.md index 3fcef4c37..eaefe8af0 100644 --- a/tsd/docs/usd-materialx-known-gaps.md +++ b/tsd/docs/usd-materialx-known-gaps.md @@ -32,8 +32,55 @@ and TSD never sets the device's `mdlResourceSearchPaths` parameter populating it). Cosmetic for any input that has a sampler bound, since the sampler supplies the texels — but it is noise that hides real failures. +Setting that parameter was tried and is **blocked**, not merely undone. Measured +on the reference asset, by setting `mdlResourceSearchPaths` on the device right +after `anari::newDevice` in `tsdOffline`: + +| value | resolve failures | distinct unresolved (UDIM) | render | +|---|---|---|---| +| unset | 109 | 103 (85) | baseline | +| `.../ShdrPlygrnd/textures` | 109 | 103 (85) | identical to baseline | +| `/` | 0 | 0 (0) | differs from baseline | + +Two conclusions, and they close off the obvious fix from both ends. + +Anchoring on the directories the importer already knows is a no-op, and no set +of per-texture anchors can ever be anything else. Root-relative means resource +`/a/b/c.tif` under registered root `R` is looked for at `R/a/b/c.tif`, so with +the fully absolute host paths `UsdMaterials.cpp` writes into the document, the +only root that can match is `/` itself. + +Registering `/` does silence every failure, and regresses +[ADR 0019](adr/0019-report-udim-tile-sets-as-unsupported.md) while doing it. The +85 UDIM paths resolve too; `libmdl::Core::resolveResource` returns +`get_element(0)->get_filename(0)`, which is tile 1001, and +`SamplerRegistry::loadFromImage` binds it. The render visibly changes. TSD's own +`texture load failed` count stays at 90, so the import report would go on +claiming the tile sets were skipped while the device quietly draws one tile of +each — the exact "reported gap becomes a silently incorrect render" outcome that +ADR 0019 rejects. + +So silencing the noise needs a decision that has not been made. Three routes are +open: stop writing the absolute path into the document for UDIM inputs, which +buys `/` at the cost of the well-formed-path property ADR 0019 deliberately +keeps; or fix it device-side, by not attempting resolution for an input that +already has a sampler bound, which is where the noise is genuinely cosmetic; or +accept the noise and record why. The first two both need an ADR. + +**Setting `mdlResourceSearchPaths` as a string array segfaults the device.** +It is a `:`-separated `ANARI_STRING`; passing it via +`anari::setParameterArray1D` with `ANARI_STRING` crashes in +`helium::BaseDevice::unmapArray` rather than being rejected. Found while probing +the above. Unrelated to USD import, and in `devices/`, not TSD. + ## Measuring +Reproducing any of this needs three things that are not in the repo: the +reference asset, a TSD build with `TSD_USE_USD=ON` and `TSD_USE_OIIO=ON` (the +asset's textures are TIFF), and a VisRTX build with both MDL and MaterialX +enabled, installed where `anari::loadLibrary` finds `visrtx_matx`. A standalone +TSD build is enough for TSD's own side; the device comes from the parent repo. + `tsdOffline` defaults to the `visrtx_matx` library and prompts for a camera: ```bash @@ -47,7 +94,9 @@ Grep the log for `sampler not bound`, `failed to transcode`, `Failed to resolve texture resource`, and `texture load failed`. Renders are a poor oracle here — the asset's default lighting is dark and noisy, -so "did the texture bind" cannot be judged by eye. Assert in the suite instead; +so "did the texture bind" cannot be judged by eye. Byte-comparing two PNGs from +otherwise identical runs is a usable oracle for "did anything change at all", +which is how the UDIM regression above was caught. Assert in the suite instead; `tests/test_UsdImport.cpp` and `tests/test_Importers.cpp` carry the fixture patterns, both hand-writing decodable 1x1 files because the decoders need real ones. From 404a8e217ac76bef89703d1971dd00b9b962f93d Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 27 Jul 2026 11:53:34 -0500 Subject: [PATCH 14/78] fix(usd): reject MaterialX documents the device cannot compile One material out of 55 on the reference asset failed shader generation inside the device with `Could not find a nodedef for node 'Surface'`, then fell back to the default material with nothing in the Import Report to say so. `Surface` was a red herring: hdMtlx names the surface node after the shader prim, so every emitted document has one. The material is `/World/Looks/OJfoam`, and the defect is in the asset -- it connects `geometry_opacity`, declared `float` on `open_pbr_surface`, to a `color3` colorcorrect node. MaterialX matches a node to its definition on category, type and the exact set of inputs, so one mistyped port leaves the surface node resolving to no nodedef at all. MaterialX 1.39.6 rejects the source `.mtlx` standalone, so nothing between the Stage and codegen introduced it. This also reclassifies the `geometry_opacity` validation warning that was recorded as benign OpenPBR version skew. It is neither: it is MaterialX reporting this exact port, on the materials that author it rather than on all of them. TSD cannot fix the asset, so `documentResolves` checks every node against the standard libraries before emission and reports a failure as MATERIAL_RESOLUTION_FAILED naming the offending port, falling back to the portable mapping. The check sits after the texture pass and before sampler creation, which is load-bearing: checking first drops the material's tile-set reports, since the fallback reads the network by UsdPreviewSurface names and reports none of them -- ADR 0019's failure mode by the back door. Also adds `TSD_USD_MATERIALX_DUMP_DIR`, which writes each generated document out named after its material prim. That is what isolated this, and it is the tool to reach for when a device rejects a document. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/docs/usd-materialx-known-gaps.md | 55 ++++++-- .../io/importers/detail/usd/UsdMaterials.cpp | 127 +++++++++++++++++- tsd/tests/test_UsdImport.cpp | 65 +++++++++ 3 files changed, 227 insertions(+), 20 deletions(-) diff --git a/tsd/docs/usd-materialx-known-gaps.md b/tsd/docs/usd-materialx-known-gaps.md index eaefe8af0..a1303eeb3 100644 --- a/tsd/docs/usd-materialx-known-gaps.md +++ b/tsd/docs/usd-materialx-known-gaps.md @@ -9,20 +9,47 @@ MaterialX materials, 117 texture inputs. This one *is* decided; see [ADR 0019](adr/0019-report-udim-tile-sets-as-unsupported.md). -**One material fails to transcode.** The device logs +**One material fails to transcode.** *Diagnosed and closed.* The device logged `MaterialX: failed to transcode '': Could not find a nodedef -for node 'Surface'`, then an MDL compile error, then falls back to the default -material. Exactly one material out of 55. The error comes from MaterialX's own -`ShaderGraph.cpp`, so shader generation inside the device is what fails, not -TSD's emission. No prim named `Surface` exists on the composed Stage and none of -the 55 `.mtlx` files contains a node by that name, so the name is synthesized -somewhere between the Hydra material network and MaterialX codegen. Diagnosing -it needs the failing inline document dumped from the device. +for node 'Surface'`, then an MDL compile error, then fell back to the default +material, for exactly one material out of 55. + +`Surface` turned out to be a red herring: hdMtlx names the surface node after +the shader prim, so all 54 emitted documents contain a node by that name. The +material is `/World/Looks/OJfoam`, and the defect is in the asset. +`materials/OJfoam.mtlx` connects `geometry_opacity` — declared `float` on +`open_pbr_surface` — to `mtlxcolorcorrect2`, a `color3` node. MaterialX matches +a node to its definition on category, type *and* the exact set of inputs, so +the mistyped input leaves the surface node resolving to no nodedef at all. +MaterialX 1.39.6 rejects the source `.mtlx` standalone with `Mismatched types in +port connection`, so nothing between the Stage and codegen introduced it. + +TSD cannot fix the asset, but it no longer emits a document the device cannot +compile. `documentResolves` in `UsdMaterials.cpp` checks every node against the +standard libraries before emission; a document that fails is reported as +`MATERIAL_RESOLUTION_FAILED` naming the offending port, and the material falls +back to the portable preview-surface mapping. Regression coverage is the +`color3`-into-`float` scenario in `tests/test_UsdImport.cpp`. + +The check sits *after* the texture pass and *before* sampler creation, which is +load-bearing: the fallback mapping reads the network by UsdPreviewSurface +names, so it reports none of the tile sets the MaterialX path found. Checking +first silently dropped `OJfoam`'s two tile sets from the report — 89 texture +load failures became 87 — which is the ADR 0019 failure mode arriving by the +back door. The reported counts are unchanged from baseline; what changed is 6 +fewer ANARI errors and one material that now says why it fell back. The render +is byte-identical, since `OJfoam` is not visible from `renderCam_CU_meetMATandTube`. + +Setting `TSD_USD_MATERIALX_DUMP_DIR` writes each generated document there, +named after the material prim. That is how the above was isolated, and it is +the tool to reach for when a device rejects a document. **hdMtlx validation warning on every material.** `Input 'geometry_opacity' -doesn't match declaration: `. OpenPBR nodedef version skew -between the asset (MaterialX 1.39) and the locally built MaterialX. Non-fatal: -documents still generate and render. +doesn't match declaration: `. Not version skew, as +previously recorded, and not benign — it is MaterialX reporting the mistyped +port above, and the warning fires for the materials that author +`geometry_opacity`, not for all 55. Three of the four author it as `float` and +generate fine; `OJfoam` is the one that does not. **MDL logs a resolve failure for every texture, including bound ones.** `Failed to resolve texture resource `. MDL treats a leading `/` as @@ -81,11 +108,13 @@ asset's textures are TIFF), and a VisRTX build with both MDL and MaterialX enabled, installed where `anari::loadLibrary` finds `visrtx_matx`. A standalone TSD build is enough for TSD's own side; the device comes from the parent repo. -`tsdOffline` defaults to the `visrtx_matx` library and prompts for a camera: +`tsdOffline` defaults to the `visrtx` library, which has no MaterialX shader +generation and so reports none of the failures above -- pass `--lib +visrtx_matx` explicitly. It also prompts for a camera: ```bash F=.../ShdrPlygrnd/ShdrPlygrnd_OpenPBR.usda -echo 8 | ./tsdOffline -usd_matx $F -o /tmp/out.png -s 1 -w 128 -h 96 > /tmp/run.log 2>&1 +echo 8 | ./tsdOffline --lib visrtx_matx -usd_matx $F -o /tmp/out.png -s 1 -w 128 -h 96 > /tmp/run.log 2>&1 ``` Redirect with `> log 2>&1`, not `2>&1 > log`, or the ANARI errors miss the file. diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 5294e443a..eeeaccc39 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -15,12 +15,15 @@ #include #if TSD_USD_HAS_MATERIALX #include +#include #include #include #include #endif // std +#include #include +#include #include #include @@ -373,6 +376,87 @@ std::vector resolveTexturePaths(ImportContext &ctx, return retval; } +// Write the generated document to `TSD_USD_MATERIALX_DUMP_DIR` when that is +// set, named after the material prim. +// +// The documents TSD emits are inline text handed straight to a device, so when +// one of them fails the device's shader generation there is otherwise nothing +// to look at: the error names a node inside a document nobody kept. Dumping +// here rather than device-side is deliberate -- the XML exists in full at this +// point, and TSD is where the node names being complained about are minted. +void dumpDocument(const pxr::SdfPath &materialPath, const std::string &xml) +{ + const char *dir = std::getenv("TSD_USD_MATERIALX_DUMP_DIR"); + if (dir == nullptr || *dir == '\0') + return; + + std::error_code ec; + std::filesystem::create_directories(dir, ec); + + auto name = materialPath.GetString(); + for (auto &c : name) { + if (c == '/' || c == ':') + c = '_'; + } + const auto file = (std::filesystem::path(dir) / (name + ".mtlx")).string(); + + std::ofstream out(file, std::ios::binary | std::ios::trunc); + if (!out) { + core::logWarning( + "[import_USD] could not open '%s' to dump MaterialX document", + file.c_str()); + return; + } + out << xml; + core::logStatus("[import_USD] %s: MaterialX document dumped to %s", + materialPath.GetText(), + file.c_str()); +} + +// Whether every node in the document resolves to a MaterialX node definition, +// which is what a device's shader generator needs to compile it. +// +// MaterialX matches a node to its definition on category, type and the exact +// set of inputs, so a network that connects an input to an upstream output of +// a different type resolves to nothing. The document still writes out, and the +// failure surfaces only once it reaches the device, as `Could not find a +// nodedef for node ''` -- after which shader generation stops and the +// prim silently renders with the default material. Checking here turns that +// into a reported skip and a portable-mapping fallback. +// +// The check runs on a copy, because resolution needs the standard libraries +// present in the document and importing them into the document TSD emits would +// inline the whole of MaterialX into the XML that travels to the device. +bool documentResolves(const MaterialX::DocumentPtr &document, std::string &why) +{ + auto probe = MaterialX::createDocument(); + probe->copyContentFrom(document); + probe->importLibrary(pxr::HdMtlxStdLibraries()); + + std::string unresolved; + for (const auto &element : probe->traverseTree()) { + auto node = element->asA(); + if (node && !node->getNodeDef()) { + unresolved += (unresolved.empty() ? "" : ", ") + node->getName() + " <" + + node->getCategory() + ">"; + } + } + if (unresolved.empty()) + return true; + + // MaterialX's own validation says which port is at fault, where the failed + // lookup only says which node it gave up on. Report both. + std::string message; + probe->validate(&message); + if (const auto end = message.find('\n'); end != std::string::npos) + message.resize(end); + + why = "no MaterialX node definition for " + unresolved; + if (!message.empty()) + why += " -- " + message; + return false; +} + // Native MaterialX passthrough. The document is generated from the resolved // network by OpenUSD's own conversion, so a MaterialX network passes through // intact and a preview-surface network converts through its MaterialX node @@ -424,16 +508,45 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, // The document names its own surface material node; TSD selects by that // name rather than assuming one derived from the prim path. + const auto materialNodes = document->getMaterialNodes(); std::string materialName; - for (const auto &node : document->getMaterialNodes()) { - materialName = node->getName(); - break; - } - if (materialName.empty()) - return {}; + if (!materialNodes.empty()) + materialName = materialNodes.front()->getName(); const auto xml = MaterialX::writeToXmlString(document); - if (xml.empty()) + if (!xml.empty()) + dumpDocument(materialPath, xml); + + // Checked after the texture pass rather than before it, so that a document + // being discarded does not take its tile-set and missing-texture reports + // down with it -- the fallback mapping reads the network by + // UsdPreviewSurface names and would report none of them. Checked before any + // sampler is created, so nothing is bound to a document that is thrown away. + if (std::string why; !documentResolves(document, why)) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::MATERIAL_RESOLUTION_FAILED, + why); + return {}; + } + + // One network converts to one material node in the normal case. More than + // one means the name picked above is a guess, so say which names were on + // offer rather than let a silently wrong pick reach the device. + if (materialNodes.size() > 1) { + std::string names; + for (const auto &node : materialNodes) + names += (names.empty() ? "" : ", ") + node->getName(); + core::logWarning( + "[import_USD] %s: MaterialX document has %zu material nodes (%s); " + "using '%s'", + materialPath.GetText(), + materialNodes.size(), + names.c_str(), + materialName.c_str()); + } + + if (materialName.empty() || xml.empty()) return {}; auto retval = ctx.scene.createObject(tokens::material::materialx); diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 3cbca3fd1..e0a68fdca 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -1272,6 +1272,71 @@ def Xform "World" } } } + + // MaterialX matches a node to its definition on the exact set of inputs, so + // a connection between mismatched types leaves the surface node resolving to + // nothing. Emitting it anyway puts the failure inside the device, where it + // reads as `Could not find a nodedef for node 'Surface'` and the prim + // silently renders with the default material. + GIVEN("A MaterialX network connecting a color3 output to a float input") + { + StageFixture stage("tsd_test_usd_materialx_mistyped.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Tint" + { + uniform token info:id = "ND_constant_color3" + color3f inputs:value = (0.25, 0.5, 0.75) + color3f outputs:out + } + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + float inputs:specular_roughness.connect = + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The Import Report names it rather than leaving it to the device") + { + REQUIRE( + report.contains(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED)); + } + + THEN("The portable mapping is what arrives, not a document") + { + REQUIRE(boundMaterial(scene)->subtype() + != tsd::scene::tokens::material::materialx); + } + } + } #endif } From 56ac4b080f7212b45ecb813b9885954f5a9edb22 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 08:51:48 -0500 Subject: [PATCH 15/78] fix(usd): give GeomSubsets the face-varying attributes they draw A Mesh with GeomSubset children converted to one Surface per subset, and each subset borrowed the parent's attributes through a hard-coded list of vertex.normal, vertex.attribute0, and vertex.color. Face-varying data is indexed by 3*triangle+corner against the mesh's whole triangulation, so a subset -- drawing only some of those triangles -- cannot borrow it. USD assets overwhelmingly author texture coordinates as faceVarying, so the subsets that actually render carried no UVs at all, and samplers read whatever attribute0 defaults to. Uniform data was lost the same way. Attributes are now expanded onto the triangulation once per mesh and gathered per Surface for the triangles that Surface draws. Vertex data still shares one Array across every Surface, since for it the gather is the identity. Two consequences of binding per Surface, both intended: - A subset resolves its own material's UV primvar name instead of taking whatever the mesh-level binding chose, and falls back to the mesh's answer and then to "st". - Faces no subset claims become a Surface under the mesh's own material. They were previously built into a parent geometry that was never surfaced: invisible, and dead objects in the scene. ADR 0020 records the design, including the pre-existing coarse-versus- refined face mismatch that subsets on subdivision meshes still have. Co-Authored-By: Claude Opus 5 (1M context) --- .../0020-bind-mesh-attributes-per-surface.md | 57 +++ .../io/importers/detail/usd/UsdGeometry.cpp | 382 ++++++++++++------ tsd/tests/test_UsdImport.cpp | 265 ++++++++++++ 3 files changed, 584 insertions(+), 120 deletions(-) create mode 100644 tsd/docs/adr/0020-bind-mesh-attributes-per-surface.md diff --git a/tsd/docs/adr/0020-bind-mesh-attributes-per-surface.md b/tsd/docs/adr/0020-bind-mesh-attributes-per-surface.md new file mode 100644 index 000000000..bd5cae1e5 --- /dev/null +++ b/tsd/docs/adr/0020-bind-mesh-attributes-per-surface.md @@ -0,0 +1,57 @@ +# Bind mesh attributes per Surface, not once per mesh + +A USD Mesh with `GeomSubset` children converts to one TSD Surface per subset. +`UsdGeometry.cpp` used to build the attribute set once, on the parent mesh, and +let each subset borrow it through a hard-coded list of `vertex.normal`, +`vertex.attribute0`, and `vertex.color`. That works only for vertex-interpolated +data, which is indexed by the same vertex indices the subset already carries. + +Face-varying data is not. It is indexed by `3 * triangle + corner` against the +mesh's full triangulation, so a subset -- which draws a chosen subset of those +triangles -- cannot point at the parent array; the corners it wants are not +contiguous and not at the offsets its own primitives imply. The same is true of +uniform data, which the importer expands to one value per triangle. Sharing was +therefore not merely incomplete, it was unavailable: assets authoring +`interpolation = "faceVarying"` texture coordinates -- which is what USD assets +overwhelmingly do -- rendered their subsets with no UVs at all. + +Attributes are now expanded onto the triangulation once per mesh +(`triangulatePrimvars`) and then *gathered* per Surface for the triangles that +Surface draws (`buildTriangleGeometry`). Vertex-interpolated primvars still +create a single Array shared by every Surface, because for them the gather is +the identity; only per-triangle and per-corner data is copied per subset. The +cost of the copy is the price of correctness, and it is bounded by the size of +the mesh however many subsets divide it. + +Two consequences follow from moving the binding to the Surface. + +A subset can resolve its own material's UV primvar name. `ResolvedMaterial` +carries the primvar its texture reader asked for, and that answer now reaches +the geometry the material is bound to, instead of the mesh-level binding +deciding `attribute0` for subsets it knows nothing about. A subset without its +own answer falls back to the mesh's, then to the conventional `st`. + +Faces that no subset claims become their own Surface under the mesh's material. +Previously the parent geometry was built, populated, and then never surfaced +when subsets existed -- unassigned faces were invisible and the objects were +dead weight in the scene. USD's own model is that a face outside every +`materialBind` subset keeps the mesh's binding, so that is what it gets. + +One older gap is now easier to see and is deliberately left alone. `GeomSubset` +indices name coarse faces, but when a mesh is refined (ADR 0017 leaves +`refinementLevel` at 2) the triangulation this code selects from describes +*refined* faces, so a subset over coarse face 1 of a `catmullClark` quad pair +picks up refined face 1 -- 2 triangles rather than the 32 that face became. +Correcting it means carrying OpenSubdiv's refined-to-coarse face mapping out of +`refineMesh`, which is its own change; it is called out here so the leftover +Surface's suddenly-visible triangle count is not read as a regression from this +one. + +The appeal to `materialBind` above is approximate in one respect, also unchanged +by this work: the importer treats every `geomSubset` child as a material subset, +because `HdGeomSubsetSchema` exposes only `type` and `indices` -- the scene +index does not carry `familyName`. A face claimed by a subset from some other +family is therefore counted as claimed and stays out of the leftover Surface. +Assets in the wild author `materialBind` subsets under a Mesh; if one appears +that does not, the family will have to be recovered from the Stage rather than +from the scene index. diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index bf4403c75..838544eb5 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -23,6 +23,7 @@ // std #include #include +#include #include #include #include @@ -180,6 +181,23 @@ MaterialRef displayColorMaterial(ImportContext &ctx, // Mesh conversion //////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// +// Apply a transform to the float-typed array a primvar holds, whatever its +// component count. Anything else has no ANARI attribute slot and so yields an +// empty value. +template +pxr::VtValue transformFloatArray(const pxr::VtValue &value, Fn &&fn) +{ + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + return {}; +} + // Expand a uniform (per-face) primvar to per-triangle values using the // triangulation's record of which coarse face each triangle came from. template @@ -199,31 +217,72 @@ pxr::VtArray expandUniform( pxr::VtValue expandUniformValue( const pxr::VtValue &value, const pxr::VtIntArray &primitiveParams) { - if (value.IsHolding()) - return pxr::VtValue(expandUniform( - value.UncheckedGet(), primitiveParams)); - if (value.IsHolding()) - return pxr::VtValue(expandUniform( - value.UncheckedGet(), primitiveParams)); - if (value.IsHolding()) - return pxr::VtValue(expandUniform( - value.UncheckedGet(), primitiveParams)); - if (value.IsHolding()) - return pxr::VtValue(expandUniform( - value.UncheckedGet(), primitiveParams)); - return {}; + return transformFloatArray(value, [&](const auto &source) { + return expandUniform(source, primitiveParams); + }); } -// Everything one converted mesh needs to hand to the Surface builder. The -// vertex arrays are created once and shared by every material subset. +// Select the values belonging to a chosen set of triangles out of an array +// laid out in triangle order -- one value per triangle for per-primitive data, +// three for per-corner data. +template +pxr::VtArray gatherTriangles(const pxr::VtArray &source, + const std::vector &triangles, + size_t valuesPerTriangle) +{ + pxr::VtArray retval; + retval.reserve(triangles.size() * valuesPerTriangle); + for (uint32_t triangle : triangles) { + const size_t base = size_t(triangle) * valuesPerTriangle; + for (size_t i = 0; i < valuesPerTriangle; ++i) + retval.push_back(source[base + i]); + } + return retval; +} + +pxr::VtValue gatherTrianglesValue(const pxr::VtValue &value, + const std::vector &triangles, + size_t valuesPerTriangle) +{ + return transformFloatArray(value, [&](const auto &source) { + return gatherTriangles(source, triangles, valuesPerTriangle); + }); +} + +// Everything one converted mesh needs to hand to the Surface builder. struct ConvertedMesh { ArrayRef vertexPosition; - std::vector> sharedParameters; pxr::VtVec3iArray triangleIndices; pxr::VtIntArray primitiveParams; }; +// A primvar expanded onto the triangulated topology and ready to bind. Vertex +// data stays as authored and is indexed by the triangle indices, so its Array +// is created once and shared by every Surface built from the mesh; uniform and +// face-varying data are laid out in triangle order and have to be gathered per +// Surface, because a subset draws only some of the triangles. +struct TriangulatedPrimvar +{ + pxr::VtValue value; + const char *prefix{nullptr}; + size_t valuesPerTriangle{0}; + ArrayRef sharedArray; + + // Vertex data is the only kind every Surface can point at unchanged. + bool isShared() const; +}; + +bool TriangulatedPrimvar::isShared() const +{ + return valuesPerTriangle == 0; +} + +// Kept sorted by name: the order primvars are visited decides which of them +// takes each spare attribute slot, and that has to be stable across runs. This +// is why the mesh converter reaches for std::map rather than FlatMap. +using TriangulatedPrimvars = std::map; + } // namespace bool isGeometryPrimType(const pxr::TfToken &primType) @@ -238,42 +297,147 @@ bool isGeometryPrimType(const pxr::TfToken &primType) namespace { -// Bind one primvar onto a geometry, tessellating or expanding it so that it -// matches the triangulated topology. -void bindMeshPrimvar(ImportContext &ctx, - GeometryRef &geometry, - const pxr::HdMeshUtil &meshUtil, +// Expand every primvar that has an attribute slot onto the triangulated +// topology, once, so that each Surface built from the mesh only has to select +// the values for its own triangles. Primvars whose expansion fails or comes up +// short of the triangulation are left out rather than bound partially. +TriangulatedPrimvars triangulatePrimvars(const pxr::HdMeshUtil &meshUtil, const ConvertedMesh &mesh, - const Primvar &primvar, - const std::string &tsdName) + const std::map &primvars) { - const char *prefix = prefixForInterpolation(primvar.interpolation); - if (!prefix) - return; + TriangulatedPrimvars retval; + for (const auto &[name, primvar] : primvars) { + if (name == pxr::HdPrimvarsSchemaTokens->points.GetString() + || name == pxr::HdTokens->displayOpacity.GetString()) + continue; + + TriangulatedPrimvar attribute; + attribute.prefix = prefixForInterpolation(primvar.interpolation); + if (!attribute.prefix || anariTypeOfPrimvar(primvar.value) == ANARI_UNKNOWN) + continue; + + if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) { + attribute.value = expandUniformValue(primvar.value, mesh.primitiveParams); + attribute.valuesPerTriangle = 1; + } else if (primvar.interpolation + == pxr::HdPrimvarSchemaTokens->faceVarying) { + const auto result = meshUtil.ComputeTriangulatedFaceVaryingPrimvar( + pxr::HdGetValueData(primvar.value), + int(primvar.value.GetArraySize()), + pxr::HdGetValueTupleType(primvar.value).type, + &attribute.value); + if (result != pxr::HdMeshComputationResult::Success) + continue; + attribute.valuesPerTriangle = 3; + } else { + attribute.value = primvar.value; + } - pxr::VtValue value = primvar.value; - if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) - value = expandUniformValue(value, mesh.primitiveParams); - else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) { - pxr::VtValue triangulated; - const auto result = meshUtil.ComputeTriangulatedFaceVaryingPrimvar( - pxr::HdGetValueData(primvar.value), - int(primvar.value.GetArraySize()), - pxr::HdGetValueTupleType(primvar.value).type, - &triangulated); - if (result != pxr::HdMeshComputationResult::Success) + // Whatever will be gathered has to cover the whole triangulation, since + // any subset may ask for any triangle. Vertex data is bound as authored + // and indexed by the triangle indices, so there is nothing to check here. + if (!attribute.value.IsArrayValued() || attribute.value.GetArraySize() == 0 + || attribute.value.GetArraySize() + < mesh.triangleIndices.size() * attribute.valuesPerTriangle) + continue; + + retval.emplace(name, std::move(attribute)); + } + return retval; +} + +// Bind one expanded primvar onto a geometry drawing `triangles`. +void bindTrianglePrimvar(ImportContext &ctx, + GeometryRef &geometry, + TriangulatedPrimvar &primvar, + const std::vector &triangles, + const std::string &tsdName) +{ + ArrayRef array; + if (primvar.isShared()) { + if (!primvar.sharedArray) { + primvar.sharedArray = ctx.scene.createArray( + anariTypeOfPrimvar(primvar.value), primvar.value.GetArraySize()); + primvar.sharedArray->setData(pxr::HdGetValueData(primvar.value)); + } + array = primvar.sharedArray; + } else { + const auto selected = gatherTrianglesValue( + primvar.value, triangles, primvar.valuesPerTriangle); + if (!selected.IsArrayValued() || selected.GetArraySize() == 0) return; - value = triangulated; + array = ctx.scene.createArray( + anariTypeOfPrimvar(selected), selected.GetArraySize()); + array->setData(pxr::HdGetValueData(selected)); } - const auto type = anariTypeOfPrimvar(value); - if (type == ANARI_UNKNOWN || !value.IsArrayValued() - || value.GetArraySize() == 0) - return; + geometry->setParameterObject( + Token((primvar.prefix + tsdName).c_str()), *array); +} - auto array = ctx.scene.createArray(type, value.GetArraySize()); - array->setData(pxr::HdGetValueData(value)); - geometry->setParameterObject(Token((prefix + tsdName).c_str()), *array); +// Append the triangles one coarse face produced to a Surface's selection. +void appendTrianglesOfFace(std::vector &selection, + const std::vector> &trianglesOfFace, + size_t face) +{ + const auto &triangles = trianglesOfFace[face]; + selection.insert(selection.end(), triangles.begin(), triangles.end()); +} + +// One Surface's worth of the mesh: the triangles it draws, with every primvar +// re-indexed to match. `uvName` is whichever primvar this Surface's own +// material reads, which is why the attribute slots cannot be assigned once for +// the whole mesh. +GeometryRef buildTriangleGeometry(ImportContext &ctx, + const ConvertedMesh &mesh, + TriangulatedPrimvars &attributes, + const std::vector &triangles, + const std::string &uvName, + const char *name) +{ + auto geometry = ctx.scene.createObject(tokens::geometry::triangle); + geometry->setName(name); + geometry->setParameterObject("vertex.position", *mesh.vertexPosition); + + std::vector indices; + indices.reserve(triangles.size()); + for (uint32_t triangle : triangles) { + const auto &t = mesh.triangleIndices[triangle]; + indices.push_back(uint3(t[0], t[1], t[2])); + } + auto indexArray = ctx.scene.createArray(ANARI_UINT32_VEC3, indices.size()); + indexArray->setData(indices.data(), indices.size()); + geometry->setParameterObject("primitive.index", *indexArray); + + auto bind = [&](const std::string &primvarName, const std::string &tsdName) { + auto found = attributes.find(primvarName); + if (found != attributes.end()) + bindTrianglePrimvar(ctx, geometry, found->second, triangles, tsdName); + }; + + // Normals, UVs, display colour, then any remaining primvars in name order so + // the attribute assignment is deterministic. + const auto normalsName = pxr::HdPrimvarsSchemaTokens->normals.GetString(); + const auto colorName = pxr::HdTokens->displayColor.GetString(); + bind(normalsName, "normal"); + bind(uvName, "attribute0"); + bind(colorName, "color"); + + int nextAttribute = 1; + for (auto &[primvarName, primvar] : attributes) { + if (nextAttribute > 3) + break; + if (primvarName == normalsName || primvarName == colorName + || primvarName == uvName) + continue; + bindTrianglePrimvar(ctx, + geometry, + primvar, + triangles, + "attribute" + std::to_string(nextAttribute++)); + } + + return geometry; } std::vector convertMesh(ImportContext &ctx, @@ -371,60 +535,13 @@ std::vector convertMesh(ImportContext &ctx, const auto resolved = resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)); - auto geometry = ctx.scene.createObject(tokens::geometry::triangle); - geometry->setName(primPath.GetText()); - const auto positions = bakedPositions(points.value.UncheckedGet(), bakeXform); mesh.vertexPosition = ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); mesh.vertexPosition->setData(positions.data(), positions.size()); - geometry->setParameterObject("vertex.position", *mesh.vertexPosition); - auto indexArray = - ctx.scene.createArray(ANARI_UINT32_VEC3, mesh.triangleIndices.size()); - indexArray->setData( - (const uint3 *)mesh.triangleIndices.data(), mesh.triangleIndices.size()); - geometry->setParameterObject("primitive.index", *indexArray); - - // Normals, UVs, display colour, then any remaining float-typed primvars in - // name order so the attribute assignment is deterministic. - const auto normals = lookup(pxr::HdPrimvarsSchemaTokens->normals.GetString()); - if (normals.valid()) - bindMeshPrimvar(ctx, geometry, meshUtil, mesh, normals, "normal"); - - const std::string uvName = - resolved.uvPrimvarName.empty() ? "st" : resolved.uvPrimvarName; - const auto uvs = lookup(uvName); - if (uvs.valid()) - bindMeshPrimvar(ctx, geometry, meshUtil, mesh, uvs, "attribute0"); - - const auto displayColor = lookup(pxr::HdTokens->displayColor.GetString()); - if (displayColor.valid() - && displayColor.interpolation != pxr::HdPrimvarSchemaTokens->constant) { - bindMeshPrimvar(ctx, geometry, meshUtil, mesh, displayColor, "color"); - } - - int nextAttribute = 1; - for (const auto &[name, primvar] : primvars) { - if (nextAttribute > 3) - break; - if (name == pxr::HdPrimvarsSchemaTokens->points.GetString() - || name == pxr::HdPrimvarsSchemaTokens->normals.GetString() - || name == pxr::HdTokens->displayColor.GetString() - || name == pxr::HdTokens->displayOpacity.GetString() || name == uvName) - continue; - if (anariTypeOfPrimvar(primvar.value) == ANARI_UNKNOWN) - continue; - if (!prefixForInterpolation(primvar.interpolation)) - continue; - bindMeshPrimvar(ctx, - geometry, - meshUtil, - mesh, - primvar, - "attribute" + std::to_string(nextAttribute++)); - } + auto attributes = triangulatePrimvars(meshUtil, mesh, primvars); // Material // @@ -432,12 +549,21 @@ std::vector convertMesh(ImportContext &ctx, if (!material) { material = displayColorMaterial(ctx, primPath, - displayColor, + lookup(pxr::HdTokens->displayColor.GetString()), lookup(pxr::HdTokens->displayOpacity.GetString())); } + // A material names the primvar its texture reader wants. A subset without a + // material of its own falls back to this one, and this one to the + // conventional name. + const std::string meshUvName = + resolved.uvPrimvarName.empty() ? "st" : resolved.uvPrimvarName; + + std::vector allTriangles(mesh.triangleIndices.size()); + std::iota(allTriangles.begin(), allTriangles.end(), 0u); + // Per-face material subsets each become their own Surface over their own - // index array, sharing this mesh's vertex arrays. + // triangles, sharing this mesh's vertex arrays. std::vector retval; std::vector subsetPaths; for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) { @@ -447,6 +573,8 @@ std::vector convertMesh(ImportContext &ctx, } if (subsetPaths.empty()) { + auto geometry = buildTriangleGeometry( + ctx, mesh, attributes, allTriangles, meshUvName, primPath.GetText()); retval.push_back( ctx.scene.createSurface(primPath.GetText(), geometry, material)); return retval; @@ -465,6 +593,8 @@ std::vector convertMesh(ImportContext &ctx, trianglesOfFace[size_t(face)].push_back(uint32_t(i)); } + std::vector faceIsClaimed(trianglesOfFace.size(), false); + for (const auto &subsetPath : subsetPaths) { auto subsetPrim = sceneIndex->GetPrim(subsetPath); auto subsetSchema = @@ -473,44 +603,56 @@ std::vector convertMesh(ImportContext &ctx, if (faceIndices.empty()) continue; - std::vector subsetTriangles; + std::vector subsetTriangles; for (int face : faceIndices) { if (face < 0 || size_t(face) >= trianglesOfFace.size()) continue; - for (uint32_t triangle : trianglesOfFace[size_t(face)]) { - const auto &t = mesh.triangleIndices[triangle]; - subsetTriangles.push_back(uint3(t[0], t[1], t[2])); - } + faceIsClaimed[size_t(face)] = true; + appendTrianglesOfFace(subsetTriangles, trianglesOfFace, size_t(face)); } if (subsetTriangles.empty()) continue; - auto subsetGeometry = - ctx.scene.createObject(tokens::geometry::triangle); - subsetGeometry->setName(subsetPath.GetText()); - subsetGeometry->setParameterObject("vertex.position", *mesh.vertexPosition); - auto subsetIndex = - ctx.scene.createArray(ANARI_UINT32_VEC3, subsetTriangles.size()); - subsetIndex->setData(subsetTriangles.data(), subsetTriangles.size()); - subsetGeometry->setParameterObject("primitive.index", *subsetIndex); - - // Share every vertex-interpolated attribute the parent mesh carries. - for (const auto &name : - {"vertex.normal", "vertex.attribute0", "vertex.color"}) { - if (auto *array = geometry->parameterValueAsObject(name)) - subsetGeometry->setParameterObject(Token(name), *array); - } + // A subset resolves its own material, which may read a different UV + // primvar than the mesh's does, so its attributes are bound to suit it. + const auto subsetResolved = + resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(subsetPrim)); + const std::string &subsetUvName = subsetResolved.uvPrimvarName.empty() + ? meshUvName + : subsetResolved.uvPrimvarName; - auto subsetMaterial = - resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(subsetPrim)) - .material; + auto subsetGeometry = buildTriangleGeometry(ctx, + mesh, + attributes, + subsetTriangles, + subsetUvName, + subsetPath.GetText()); retval.push_back(ctx.scene.createSurface(subsetPath.GetText(), subsetGeometry, - subsetMaterial ? subsetMaterial : material)); + subsetResolved.material ? subsetResolved.material : material)); } - if (retval.empty()) { + // Faces no subset claimed keep the mesh's own binding rather than going + // missing with the geometry that no Surface would have drawn. + std::vector unclaimedTriangles; + for (size_t face = 0; face < trianglesOfFace.size(); ++face) { + if (!faceIsClaimed[face]) + appendTrianglesOfFace(unclaimedTriangles, trianglesOfFace, face); + } + + // No subset drew anything -- with nothing to divide the mesh up, draw all of + // it, including any triangle whose coarse face could not be identified. + if (retval.empty()) + unclaimedTriangles = allTriangles; + + if (!unclaimedTriangles.empty()) { + auto geometry = buildTriangleGeometry(ctx, + mesh, + attributes, + unclaimedTriangles, + meshUvName, + primPath.GetText()); retval.push_back( ctx.scene.createSurface(primPath.GetText(), geometry, material)); } diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index e0a68fdca..1fe16b536 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -177,6 +177,25 @@ tsd::scene::LayerNodeRef findNode(tsd::scene::Layer *layer, const char *name) return found; } +// The converted object a prim produced, found by the prim path the importer +// names it after. +template +tsd::core::ObjectPoolRef findObject( + tsd::scene::Scene &scene, anari::DataType type, const char *name) +{ + for (size_t i = 0; i < scene.numberOfObjects(type); ++i) { + auto object = scene.getObject(i); + if (object && object->name() == name) + return object; + } + return {}; +} + +tsd::scene::GeometryRef findGeometry(tsd::scene::Scene &scene, const char *name) +{ + return findObject(scene, ANARI_GEOMETRY, name); +} + constexpr const char *QUAD_MESH_BODY = R"( int[] faceVertexCounts = [4] int[] faceVertexIndices = [0, 1, 2, 3] @@ -797,6 +816,19 @@ def Xform "World" REQUIRE(scene.numberOfObjects(ANARI_SURFACE) >= 1); } + THEN("The faces no subset claims are still drawn, by the mesh's material") + { + // The first face belongs to no subset, so it stays with the mesh's own + // binding instead of vanishing with the un-surfaced parent geometry. + auto leftover = findGeometry(scene, "/World/Strip"); + REQUIRE(leftover); + auto *index = leftover->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 2); + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + } + THEN("The subset Surfaces share the mesh's vertex positions") { // Every geometry produced for this mesh points at the same @@ -818,6 +850,238 @@ def Xform "World" } } +SCENARIO("Face-varying UVs follow each material subset", "[UsdImport]") +{ + GIVEN("A two-face mesh with per-corner UVs and a subset over each face") + { + StageFixture stage("tsd_test_usd_subset_facevarying.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Quad" + { + int[] faceVertexCounts = [4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (0.25, 0), (0.25, 0.25), (0, 0.25), + (0.5, 0), (0.75, 0), (0.75, 0.75), + (0.5, 0.75)] ( + interpolation = "faceVarying" + ) + normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1), + (0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)] ( + interpolation = "vertex" + ) + + def GeomSubset "Left" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0] + } + + def GeomSubset "Right" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Each subset carries the corners of the faces it selected") + { + // Face-varying data is indexed by triangle corner, so a subset cannot + // share the parent array the way vertex data can -- it must gather the + // corners of its own triangles. Each quad triangulates to two + // triangles, hence six corners, and the two faces' UVs are authored + // into disjoint halves of the unit square so a mis-gather shows up. + auto left = findGeometry(scene, "/World/Quad/Left"); + auto right = findGeometry(scene, "/World/Quad/Right"); + REQUIRE(left); + REQUIRE(right); + + auto *leftUVs = left->parameterValueAsObject( + "faceVarying.attribute0"); + auto *rightUVs = right->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(leftUVs != nullptr); + REQUIRE(rightUVs != nullptr); + REQUIRE(leftUVs->size() == 6); + REQUIRE(rightUVs->size() == 6); + + const auto *l = leftUVs->dataAs(); + const auto *r = rightUVs->dataAs(); + for (size_t i = 0; i < 6; ++i) { + REQUIRE(l[i].x < 0.5f); + REQUIRE(r[i].x >= 0.5f); + } + } + + THEN("Vertex-interpolated attributes are still shared, not copied") + { + // Only per-corner and per-triangle data has to be gathered; vertex + // data is indexed by the indices each subset already carries, so one + // Array serves every Surface. + auto left = findGeometry(scene, "/World/Quad/Left"); + auto right = findGeometry(scene, "/World/Quad/Right"); + REQUIRE(left); + REQUIRE(right); + + auto *leftNormals = + left->parameterValueAsObject("vertex.normal"); + REQUIRE(leftNormals != nullptr); + REQUIRE(leftNormals + == right->parameterValueAsObject( + "vertex.normal")); + } + } + } +} + +SCENARIO("A subset binds the UV primvar its own material reads", "[UsdImport]") +{ + GIVEN("Two subsets whose materials read differently named UV primvars") + { + StageFixture stage("tsd_test_usd_subset_uv_primvar.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Scope "Looks" + { + def Material "ReadsMapOne" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @missing_texture.png@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "map1" + float2 outputs:result + } + } + + def Material "ReadsMapTwo" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @missing_texture.png@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "map2" + float2 outputs:result + } + } + } + + def Mesh "Quad" + { + int[] faceVertexCounts = [4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + texCoord2f[] primvars:map1 = [(0.25, 0), (0.25, 0), (0.25, 0), + (0.25, 0), (0.25, 0), (0.25, 0), + (0.25, 0), (0.25, 0)] ( + interpolation = "faceVarying" + ) + texCoord2f[] primvars:map2 = [(0.75, 0), (0.75, 0), (0.75, 0), + (0.75, 0), (0.75, 0), (0.75, 0), + (0.75, 0), (0.75, 0)] ( + interpolation = "faceVarying" + ) + + def GeomSubset "Left" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0] + rel material:binding = + } + + def GeomSubset "Right" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + rel material:binding = + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Each subset's own primvar lands on its first attribute") + { + // The mesh itself binds no material, so the UV name cannot be decided + // once for the whole mesh: each subset's material names its own. + auto left = findGeometry(scene, "/World/Quad/Left"); + auto right = findGeometry(scene, "/World/Quad/Right"); + REQUIRE(left); + REQUIRE(right); + + auto *leftUVs = left->parameterValueAsObject( + "faceVarying.attribute0"); + auto *rightUVs = right->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(leftUVs != nullptr); + REQUIRE(rightUVs != nullptr); + REQUIRE(leftUVs->dataAs()[0].x == Approx(0.25f)); + REQUIRE(rightUVs->dataAs()[0].x == Approx(0.75f)); + } + } + } +} + SCENARIO("An unconventionally named UV primvar is still found", "[UsdImport]") { GIVEN("A material whose reader node asks for a primvar not called 'st'") @@ -2125,3 +2389,4 @@ def Mesh "Real" } #endif // TSD_USE_USD + From 25df87e027368727950eddc2da1ad3b4883f688a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 13:09:00 -0500 Subject: [PATCH 16/78] fix(usd): keep face-varying primvars on already-triangulated meshes HdMeshUtil::ComputeTriangulatedFaceVaryingPrimvar reports Unchanged, not Success, when every face is already a triangle, leaving the output value untouched. Treating that as failure silently dropped every face-varying primvar (UVs, normals) on pre-triangulated exports. Unchanged means the flattened input is already one value per triangle corner, so bind it directly. Co-Authored-By: Claude Fable 5 --- .../io/importers/detail/usd/UsdGeometry.cpp | 6 +- tsd/tests/test_UsdImport.cpp | 68 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 838544eb5..5701c3036 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -326,7 +326,11 @@ TriangulatedPrimvars triangulatePrimvars(const pxr::HdMeshUtil &meshUtil, int(primvar.value.GetArraySize()), pxr::HdGetValueTupleType(primvar.value).type, &attribute.value); - if (result != pxr::HdMeshComputationResult::Success) + // Unchanged means the mesh is already all triangles: the flattened + // input is already one value per triangle corner, in triangle order. + if (result == pxr::HdMeshComputationResult::Unchanged) + attribute.value = primvar.value; + else if (result != pxr::HdMeshComputationResult::Success) continue; attribute.valuesPerTriangle = 3; } else { diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 1fe16b536..66e5c191e 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -948,6 +948,74 @@ def Xform "World" } } +SCENARIO( + "Face-varying primvars survive an already-triangulated mesh", "[UsdImport]") +{ + GIVEN("An all-triangle mesh with indexed face-varying UVs and normals") + { + // Hydra's triangulator reports this topology as Unchanged rather than + // producing a copy of the input, a distinct result from Success that the + // conversion must not mistake for failure -- pre-triangulated exports + // carry every face-varying primvar down this path. + StageFixture stage("tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Triangles" + { + int[] faceVertexCounts = [3, 3] + int[] faceVertexIndices = [0, 1, 2, 0, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "faceVarying" + ) + int[] primvars:st:indices = [0, 1, 2, 0, 2, 3] + normal3f[] primvars:normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), + (0, 0, 1), (0, 0, 1), (0, 0, 1)] ( + interpolation = "faceVarying" + ) + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The UVs arrive flattened, one value per triangle corner") + { + auto geometry = findGeometry(scene, "/World/Triangles"); + REQUIRE(geometry); + + auto *uvs = geometry->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(uvs != nullptr); + REQUIRE(uvs->size() == 6); + + const auto *uv = uvs->dataAs(); + REQUIRE(uv[3].x == Approx(0.0f)); // second triangle's first corner + REQUIRE(uv[4].x == Approx(1.0f)); + REQUIRE(uv[5].x == Approx(0.0f)); + REQUIRE(uv[5].y == Approx(1.0f)); + } + + THEN("The normals arrive too") + { + auto geometry = findGeometry(scene, "/World/Triangles"); + REQUIRE(geometry); + + auto *normals = geometry->parameterValueAsObject( + "faceVarying.normal"); + REQUIRE(normals != nullptr); + REQUIRE(normals->size() == 6); + } + } + } +} + SCENARIO("A subset binds the UV primvar its own material reads", "[UsdImport]") { GIVEN("Two subsets whose materials read differently named UV primvars") From fe8eaeca7e0241393e3912f5bcb9f071f7ec1f2b Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 13:29:00 -0500 Subject: [PATCH 17/78] docs(style): require non-owning members be pointers, not references TSD already stores every non-owning scene back-reference as a raw pointer -- Layer, AnariHandleCache, AnyObjectUsePtr, and the network messages all use `Scene *m_scene{nullptr}` -- but the convention was only implicit in the code. Write it down, with the lifetime rationale that motivates it: a reference member deletes assignment and forces construction-time binding, which conflicts with the movable-not-copyable lifetime declared everywhere else. Scopes the rule to stored members so it does not contradict the File I/O section, whose importer and exporter signatures take `Scene &` deliberately. Co-Authored-By: Claude Fable 5 --- tsd/STYLEGUIDE.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tsd/STYLEGUIDE.md b/tsd/STYLEGUIDE.md index a0aa3b597..54767ec01 100644 --- a/tsd/STYLEGUIDE.md +++ b/tsd/STYLEGUIDE.md @@ -19,6 +19,43 @@ scratch, check whether a TSD primitive already fits: --- +## Store Non-Owning Members as Pointers, Never References + +Parent §6 allows raw pointers or ref wrappers for non-owning references; in TSD +a class member that refers to something it does not own is **always a raw +pointer**, default-initialized to `nullptr`: + +```cpp +class ImageCache +{ + public: + ImageCache(Scene *scene); + Scene *scene() const; + + private: + Scene *m_scene{nullptr}; // not Scene & +}; +``` + +This is the established shape — `Layer::m_scene`, `AnariHandleCache::m_scene`, +`AnyObjectUsePtr::m_scene`, and every `tsd/network/messages/` type store the +scene this way. A reference member silently deletes assignment and forces the +binding at construction, which breaks the movable-not-copyable lifetime that +`TSD_DEFAULT_MOVEABLE` and `DECLARE_OBJECT_DEFAULT_LIFETIME` declare +everywhere else. + +The cost is that null becomes representable. Handle it at the boundary rather +than pushing the check onto callers: return the type's existing empty/failure +value (see Fallible Returns in parent §8), the way `self()` does in the object +skeleton below. + +**This rule is about stored members only.** Function parameters stay +references where the argument is required and non-null — the importer and +exporter signatures in File I/O below take `Scene &` deliberately, and that +does not change. + +--- + ## Scene Mutation and Notification - Subclass `BaseUpdateDelegate` for any consumer that needs to react to scene From bb15951c8cfc130ee7a54e9643e09030929c4b99 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 13:39:36 -0500 Subject: [PATCH 18/78] docs(io): propose one image-import path with a stated orientation Seven decode paths in tsd_io each carry a private assumption about which row of a decoded image is row 0. They cancel for glTF, ASSIMP, and PBRT and do not cancel for OBJ and USD, whose textures render mirrored. Record the survey, the ANARI-orientation contract, the ImageCache API, the importer changes that must land with the flip, and the sequencing. Co-Authored-By: Claude Fable 5 --- tsd/docs/tsd-io-image-import.md | 296 ++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 tsd/docs/tsd-io-image-import.md diff --git a/tsd/docs/tsd-io-image-import.md b/tsd/docs/tsd-io-image-import.md new file mode 100644 index 000000000..3e7d41861 --- /dev/null +++ b/tsd/docs/tsd-io-image-import.md @@ -0,0 +1,296 @@ +# Central image import for tsd_io + +## Summary + +Every importer that reads texels reaches a different decoder, and each one +carries a private, undocumented assumption about which row of the decoded +image is row 0. The assumptions cancel out for glTF, ASSIMP, and PBRT and do +not cancel for OBJ and USD, which is why textures from those two formats come +out vertically mirrored. Nothing downstream corrects it: no render index or +sampler code in TSD flips anything. + +This proposes one image-import component with a single stated orientation +contract — decoded texels are stored in ANARI orientation, row 0 is the bottom +row — plus a scene-scoped cache so a texture referenced from many places is +decoded once. + +## Survey + +### Decode paths in the tree today + +| # | Path | Entry point | Decoder | Row 0 | +|---|---|---|---|---| +| 1 | shared, from file | `importTexture` (`detail/importer_common.cpp:526`) | stb / tinyexr / OIIO / DDS by extension | top | +| 2 | shared, from memory | `importTextureFromMemory` (`:564`) | stb or DDS by format hint | top | +| 3 | shared, pre-decoded | `importRawTexture2D` (`:593`) | `memcpy` of RGBA8 | caller's | +| 4 | glTF | `importGLTFTexture` (`import_GLTF.cpp:104`) | tinygltf's own decode | top | +| 5 | PBRT height→normal | `importHeightAsNormalMap` (`import_PBRT.cpp:1212`) | private `stbi_loadf` | top | +| 6 | HDRI environment | `HDRImage::import` (`detail/HDRImage.cpp:171`) | stb with `stbi_set_flip_vertically_on_load(1)`, plus a hand-written flip in the EXR branch (`:81`) | **bottom** | +| 7 | PBRT equirect resample | `import_PBRT.cpp:125` | consumes #6's buffer | **bottom** | + +Path 6 is the only producer that flips. Its comment at `HDRImage.cpp:173` +("Restore default top-down orientation") is the only place in the tree that +names an orientation at all, and it names the *opposite* of what paths 1–5 +produce. + +Paths 4 and 5 duplicate cache-key construction, `Array` creation, and sampler +construction that paths 1–3 already have. Path 4 additionally does something +paths 1–3 do *not*: it preserves the file's integer element type and uses +ANARI's `*_SRGB` formats, where the shared path expands everything to +`ANARI_FLOAT32*` and applies `pow(x, 2.2)` in software. + +`importGLTFTexture` also takes a `flipNormalMapY` parameter that is only ever +folded into the cache key (`import_GLTF.cpp:134`) and never applied to the +texels; no caller passes it. + +### Orientation, end to end + +Sampling comes out right when the row order of the stored array and the `v` +convention of the texture coordinates agree. Today they agree by accident in +three importers and disagree in two: + +| Importer | Source `v` convention | UV handling at import | Array row 0 | Result | +|---|---|---|---|---| +| glTF | v-down (spec) | passed through (`import_GLTF.cpp:1053`) | top | correct | +| ASSIMP | v-up (assimp default) | `aiProcess_FlipUVs` → v-down (`import_ASSIMP.cpp:695`) | top | correct | +| PBRT | v-up | `v = 1 - v` (`import_PBRT.cpp:221`, `:429`) → v-down | top | correct | +| OBJ | v-up (`vt` spec) | passed through (`import_OBJ.cpp:132`) | top | **mirrored** | +| USD | v-up (UsdPreviewSurface / MaterialX `st`) | passed through | top | **mirrored** | + +So the working importers are all on the GL contract — top-down image, v-down +coordinates — and the two that hand ANARI genuinely v-up coordinates are +broken. `import_PBRT.cpp:909` states this explicitly and calls the v-down +convention "ANARI's", which is backwards; the same comment then documents the +`(1 - vs - vd)` term in `applyPbrtUvTransform` as compensation for the double +flip. + +Two further consequences of storing every array upside down relative to +ANARI's definition: + +- Anything that reads a scene's texture array back *as an image* sees it + flipped. `SceneToUSD.cpp:167`/`:195` writes arrays straight to EXR and PNG, + both of which are top-down formats — correct today only because the arrays + are top-down too. +- `calcTangentsForTriangleMesh`'s `flipTexCoordY` parameter + (`importer_common.cpp:641`, defaulting to `true`) exists solely to undo the + v-down convention before handing coordinates to mikktspace. + +### Cache + +`TextureCache` is `unordered_map` +(`importer_common.hpp:31`), keyed by `path + "_linear"|"_srgb"` +(`makeTextureCacheKey`). Every importer constructs its own and drops it when +the import returns: + +- `import_OBJ.cpp:59`, `import_ASSIMP.cpp:246`, `import_GLTF.cpp:307`, + `import_PBRT.cpp:2321` — function-local +- `UsdImportContext.h:56` — per-import context +- `import_HDRI` — none at all + +Reuse therefore exists *within* one importer call and nowhere else. Importing +two assets that share a texture decodes it twice; a USD stage that references +an OBJ decodes it twice. + +The cache also has no tie to the `Scene` whose arrays it holds. Nothing +structurally prevents handing scene A's `ArrayRef` to scene B, or outliving +the scene entirely. + +Key construction is inconsistent: paths 1–3 use `makeTextureCacheKey`, glTF +hand-rolls `name + "_srgb" + "_yflip"` (`import_GLTF.cpp:128-137`), and PBRT's +height map uses `path + "::normal"` (`import_PBRT.cpp:1219`). + +## Proposal + +### The contract + +> A decoded image resident in a TSD scene is stored in ANARI orientation: the +> array's row 0 is the bottom row of the picture, so texture coordinate +> `(0, 0)` addresses the image's lower-left corner. Importers hand ANARI +> texture coordinates in ANARI's convention, converting from the source +> format's convention where they differ. + +Decoders declare the row order their library produces; the import layer +normalizes. No importer flips anything itself. + +### Component + +A new `src/tsd/io/images/` alongside `importers/`, since this is shared by +importers and exporters both: + +```cpp +namespace tsd::io { + +enum class ColorSpace { SRGB, LINEAR }; + +// The row order a decoder produced. Declared by decoders, never by importers. +enum class RowOrder { TOP_DOWN, BOTTOM_UP }; + +// Identifies texel content — not the sampler built from it. Two materials +// binding the same file at the same color space share one Image. +struct ImageSource +{ + std::string id; // resolved path, or an importer-scoped stable id + std::string displayName; // sampler name; defaults to fileOf(id) + ColorSpace colorSpace = ColorSpace::SRGB; +}; + +// A decoded image resident in a Scene, in ANARI orientation. +struct Image +{ + tsd::scene::ArrayRef texels; + bool blockCompressed = false; + explicit operator bool() const { return texels.valid(); } +}; + +// Owns decoded images for one Scene. Holds the Scene it caches for so a +// cached ArrayRef can never reach a different Scene; it must not outlive +// that Scene. Follows the `Scene *m_scene{nullptr}` member convention used +// by Layer, AnariHandleCache, and the network messages. +class ImageCache +{ + public: + ImageCache(tsd::scene::Scene *scene); + + tsd::scene::Scene *scene() const; + + Image acquire(const ImageSource &source); + Image acquire(const ImageSource &source, + const void *data, + size_t numBytes, + const std::string &formatHint = ""); + Image acquireDecoded(const ImageSource &source, + anari::DataType elementType, + size_t width, + size_t height, + RowOrder rowOrder, + const void *texels); + + void clear(); + size_t size() const; + + private: + tsd::scene::Scene *m_scene{nullptr}; + // ... +}; + +struct SamplerSettings +{ + const char *inAttribute = "attribute0"; + const char *wrapMode1 = "repeat"; + const char *wrapMode2 = "repeat"; + const char *filter = "linear"; +}; + +tsd::scene::SamplerRef makeImageSampler(tsd::scene::Scene &scene, + const Image &image, + const std::string &displayName, + const SamplerSettings &settings = {}); + +} // namespace tsd::io +``` + +Caching stays at the array level, as it is today: samplers are cheap and their +wrap/filter/`inAttribute`/`inTransform` differ per binding, so they are built +fresh. `Image` is the unit of sharing. + +`acquireDecoded` is what lets glTF and any future format that arrives +pre-decoded (tinygltf, an embedded DDS, a procedural buffer) join the shared +path — it declares its row order and gets the same normalization, keying, and +lifetime as a file-backed image. + +`importTexture` / `importTextureFromMemory` / `importRawTexture2D` survive as +thin wrappers so the ~20 call sites don't churn in the same commit as the +behavior change. + +### Where the flip happens + +One place: `ImageCache`'s store step, between decode and `Array::setData`. Each +decoder reports `RowOrder`; stb, tinyexr, OIIO, and tinygltf report +`TOP_DOWN`, and the cache reverses rows before the texels reach the scene. +`HDRImage` stops flipping and reports `TOP_DOWN` like everything else. + +**Block-compressed DDS is the one exception.** BC blocks are 4×4, so a +vertical flip requires decode and re-encode, which defeats the point of +`compressedImage2D`. Recommendation: keep DDS texels as authored and mark the +`Image` so `makeImageSampler` folds a `v`-flip into that sampler's +`inTransform`/`inOffset` (`diag(1, -1, 1, 1)`, offset `(0, 1, 0, 0)`). This is +exact and costs nothing at runtime. Callers that set their own `inTransform` +(USD's `uvTransform`, PBRT's `uscale`/`vscale`, glTF's `KHR_texture_transform`) +must compose rather than overwrite — a `composeVFlip(mat4 &, float4 &)` helper +keeps that honest. The alternative, decoding DDS to RGBA and flipping, is +simpler but throws away the compression. + +### Importer changes that must land with the flip + +Flipping the arrays without these is a regression, so they belong in one +commit: + +| Importer | Change | +|---|---| +| glTF | flip `v` when building `vertex.attributeN` (`import_GLTF.cpp:1053`), since glTF is v-down | +| ASSIMP | drop `aiProcess_FlipUVs` (`import_ASSIMP.cpp:695`); assimp's default output is already v-up | +| PBRT | drop `v = 1 - v` at `:221` and `:429`; simplify `applyPbrtUvTransform` (`:913`) to plain `(us*u + ud, vs*v + vd)` and delete the compensating comment at `:906` | +| OBJ | none — becomes correct | +| USD | none — becomes correct | +| `SceneToUSD` | flip rows when writing PNG (`:195`) and EXR (`:167`), which are top-down formats | +| `calcTangentsForTriangleMesh` | default `flipTexCoordY` to `false`; both callers (`import_ASSIMP.cpp:217`, `import_GLTF.cpp:1213`) pass `false` | + +Normal maps are unaffected. mikktspace is fed v-up coordinates in both the old +and new schemes — today via `flipTexCoordY = true` undoing the v-down +convention, afterward because the coordinates are already v-up — so the tangent +basis is invariant, and the fetch coordinates and row order change together. + +### Cache lifetime + +`ImageCache` is a value type the caller owns, scoped to the `Scene` it points +at: + +- `import_file()` creates one per call and threads it to whichever importer it + dispatches to. That is the "temporary" scope requested: a scene referencing + the same texture from many materials, or from a nested asset in another + format, decodes it once. +- An overload taking an existing `ImageCache &` lets an application that + imports many files as one operation (a SciVis Studio project load) own one + cache across all of them. +- Nothing caches across unrelated user actions, so a texture edited on disk is + picked up on the next import without invalidation machinery. + +`ImageSource::id` is the resolved absolute path for file-backed images and an +importer-scoped stable string otherwise (`"gltf::image"`, +`"assimp::embedded"`, `"pbrt:::normal"`). The cache key is +`(id, colorSpace)`, replacing the three key-construction schemes in the tree. + +### Follow-on: preserve element types + +The glTF path already keeps the file's integer type and uses ANARI's `*_SRGB` +element formats; the shared path expands to `ANARI_FLOAT32*` and applies +`pow(x, 2.2)` in software (`importer_common.cpp:455`). Moving the shared path +onto native types would cut texture memory 4× for the common 8-bit case and +replace the 2.2 gamma approximation with the true sRGB EOTF the device +applies, deleting `applyGamma22InPlace` and the comment at `:448` explaining +why the OIIO path has to imitate stb's approximation. This is worth doing but +is a separable change; it should not ride along with the orientation fix. + +## Sequencing + +0. **Characterize.** One tiny asset per format (glTF, OBJ, USD, PBRT, ASSIMP, + DDS, EXR, TIFF, HDRI) with a distinguishable corner, and a test in + `tests/test_Importers.cpp` asserting the texel that the material's uv `(0,0)` + resolves to. This is what makes the flip safe, and it fails for OBJ and USD + before any code moves. +1. **Introduce `tsd/io/images`** with the contract, `ImageCache`, and the + decoders moved over unchanged. `importTexture` and friends become shims. + No behavior change. +2. **Flip**, together with every importer change in the table above. Behavior + change, gated by step 0. +3. **Fold in the stragglers** — `importGLTFTexture` onto `acquireDecoded`, + `importHeightAsNormalMap` and `HDRImage` onto the cache. Delete the dead + `flipNormalMapY` parameter. +4. **Native element types** (follow-on above). + +Steps 1–3 are mechanical once step 0 exists. Step 2 is the only one that can +regress a scene, and it is the whole point. + +An ADR recording the orientation contract belongs with step 2, since it is the +kind of decision `docs/adr/` exists for and every future decoder needs to +find it. From f9ebb0d86370ba43faa3b62e929824ffde3873e6 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 13:45:31 -0500 Subject: [PATCH 19/78] test(io): characterize image orientation across the importers Assert the two halves of a correct texture fetch separately: that a decoded array's row 0 is the picture's bottom row, and that the mesh corner at the top of the picture addresses the picture's top row. The suite is red on purpose. It confirms the survey in docs/tsd-io-image-import.md from local evidence rather than from source reading: glTF and PBRT pass the end-to-end assertion, OBJ and USD fail it, and the storage assertion fails everywhere because every decode path stores top-down today. The following commits make it green. Fixtures are synthesized into the temp directory, following the TGA in tests/test_UsdImport.cpp and the TIFF in tests/test_Importers.cpp. One 1x2 TGA serves every importer; its rows are stored bottom-up, so the storage assertion cannot pass by a decoder's byte order happening to agree with the contract. Co-Authored-By: Claude Fable 5 --- tsd/tests/CMakeLists.txt | 2 + tsd/tests/test_ImageImport.cpp | 473 +++++++++++++++++++++++++++++++++ 2 files changed, 475 insertions(+) create mode 100644 tsd/tests/test_ImageImport.cpp diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index ea7ce4216..01a4400a2 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -21,6 +21,7 @@ project_add_executable( test_FlatMap.cpp test_Forest.cpp test_Geometry.cpp + test_ImageImport.cpp test_Importers.cpp test_LayerSubtreeArchive.cpp test_Manipulator.cpp @@ -74,6 +75,7 @@ add_test(NAME tsd::DataTree COMMAND ${PROJECT_NAME} "[DataTree]" ) add_test(NAME tsd::FlatMap COMMAND ${PROJECT_NAME} "[FlatMap]" ) add_test(NAME tsd::Forest COMMAND ${PROJECT_NAME} "[Forest]" ) add_test(NAME tsd::Geometry COMMAND ${PROJECT_NAME} "[Geometry]" ) +add_test(NAME tsd::ImageImport COMMAND ${PROJECT_NAME} "[ImageImport]" ) add_test(NAME tsd::Importers COMMAND ${PROJECT_NAME} "[Importers]" ) add_test(NAME tsd::LayerSubtreeArchive COMMAND ${PROJECT_NAME} "[LayerSubtreeArchive]") diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp new file mode 100644 index 000000000..4bd040bdb --- /dev/null +++ b/tsd/tests/test_ImageImport.cpp @@ -0,0 +1,473 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/core/TSDMath.hpp" +#include "tsd/io/importers.hpp" +#include "tsd/io/importers/detail/importer_common.hpp" +#include "tsd/scene/Scene.hpp" +// std +#include +#include +#include +#include +#include +#include + +// These tests characterize where a decoded image's rows land and which way an +// importer's `v` runs, because the two only produce a correct picture when +// they agree. See docs/tsd-io-image-import.md for the survey they came from. +// +// Fixtures are synthesized into the temp directory rather than checked in, +// following tests/test_UsdImport.cpp's TextureFixture and +// tests/test_Importers.cpp's TiffFixture. + +namespace { + +using namespace tsd::core::math; + +// The two rows of every fixture image. Chosen far apart in every channel so +// no colour-space handling on any decode path can confuse one for the other. +constexpr float3 TOP_ROW_COLOR(1.f, 0.f, 0.f); +constexpr float3 BOTTOM_ROW_COLOR(0.f, 0.f, 1.f); + +bool isTopRowColor(const float3 &c) +{ + return c.x > c.z; +} + +bool isBottomRowColor(const float3 &c) +{ + return c.z > c.x; +} + +// A file that exists for the lifetime of one scenario and removes itself +// after. Every fixture below is one of these. +struct TempFile +{ + TempFile(const char *name, const std::string &contents) + : m_path(std::filesystem::temp_directory_path() / name) + { + std::ofstream file(m_path, std::ios::binary); + file.write(contents.data(), std::streamsize(contents.size())); + } + + ~TempFile() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + std::string path() const + { + return m_path.string(); + } + + TempFile(const TempFile &) = delete; + TempFile &operator=(const TempFile &) = delete; + + private: + std::filesystem::path m_path; +}; + +// A 1x2 uncompressed true-colour TGA: red on top, blue on the bottom. TGA is +// the format test_UsdImport.cpp already hand-writes, and it is the one every +// stb-backed path in the tree can decode, so one fixture serves the OBJ, +// glTF, USD, and PBRT importers alike. +// +// A TGA with descriptor bit 5 clear stores its rows bottom-up, so the first +// texel in the file is the *bottom* row. That is deliberate: it means this +// fixture only reports "row 0 is the bottom row" if the import layer actually +// establishes the contract, rather than passing a decoder's byte order +// through and happening to agree with it. +std::string tgaFixtureContents() +{ + const unsigned char tga[] = { + // clang-format off + 0, // no image ID + 0, // no colour map + 2, // uncompressed true-colour + 0, 0, 0, 0, 0, // empty colour map spec + 0, 0, 0, 0, // origin + 1, 0, // width = 1 + 2, 0, // height = 2 + 24, // bits per pixel + 0, // descriptor: origin lower-left, so rows are stored bottom-up + 0xff, 0x00, 0x00, // bottom row, BGR: blue + 0x00, 0x00, 0xff // top row, BGR: red + // clang-format on + }; + return std::string(reinterpret_cast(tga), sizeof(tga)); +} + +// Texel readers /////////////////////////////////////////////////////////////// + +// Importers do not agree on an element type -- the shared path expands to +// ANARI_FLOAT32_*, glTF keeps the file's 8-bit type and asks for an sRGB +// format -- so orientation assertions have to read either. +float3 texelAsFloat3(const tsd::scene::Array *image, size_t index) +{ + const auto type = image->elementType(); + switch (type) { + case ANARI_FLOAT32_VEC3: + return image->dataAs()[index]; + case ANARI_FLOAT32_VEC4: { + const auto t = image->dataAs()[index]; + return float3(t.x, t.y, t.z); + } + case ANARI_UFIXED8_VEC3: + case ANARI_UFIXED8_RGB_SRGB: { + const auto *t = image->dataAs() + index * 3; + return float3(t[0] / 255.f, t[1] / 255.f, t[2] / 255.f); + } + case ANARI_UFIXED8_VEC4: + case ANARI_UFIXED8_RGBA_SRGB: { + const auto *t = image->dataAs() + index * 4; + return float3(t[0] / 255.f, t[1] / 255.f, t[2] / 255.f); + } + default: + FAIL("unhandled texel element type " << type); + return float3(0.f); + } +} + +// The fixture image a scene imported, found by its shape rather than by the +// material parameter it hangs off, which differs per importer. +const tsd::scene::Array *fixtureImage(tsd::scene::Scene &scene) +{ + for (size_t i = 0; i < scene.numberOfObjects(ANARI_SAMPLER); ++i) { + auto sampler = scene.getObject(i); + if (!sampler) + continue; + auto *image = sampler->parameterValueAsObject("image"); + if (image && image->dim(0) == 1 && image->dim(1) == 2) + return image; + } + return nullptr; +} + +// Resolve a texture coordinate the way ANARI resolves it: `v` runs up the +// array, so `v = 0` addresses row 0. +float3 sampleAsAnari(const tsd::scene::Array *image, const float2 &uv) +{ + const auto height = image->dim(1); + auto row = size_t(uv.y * float(height)); + if (row >= height) + row = height - 1; + return texelAsFloat3(image, row * image->dim(0)); +} + +// The texture coordinate the top corner of the fixture quad carries. Every +// fixture below is the same unit quad in the XY plane, so the vertex with the +// greatest `y` is unambiguously the one at the top of the picture. +float2 uvAtTopOfQuad(tsd::scene::Scene &scene) +{ + for (size_t i = 0; i < scene.numberOfObjects(ANARI_GEOMETRY); ++i) { + auto geometry = scene.getObject(i); + if (!geometry) + continue; + auto *positions = + geometry->parameterValueAsObject("vertex.position"); + auto *uvs = geometry->parameterValueAsObject( + "vertex.attribute0"); + if (!positions || !uvs || uvs->size() != positions->size()) + continue; + + const auto *p = positions->dataAs(); + const auto *t = uvs->dataAs(); + size_t top = 0; + for (size_t v = 1; v < positions->size(); ++v) { + if (p[v].y > p[top].y) + top = v; + } + return t[top]; + } + FAIL("no textured geometry in the imported scene"); + return float2(0.f); +} + +// Fixture scenes ////////////////////////////////////////////////////////////// + +// A unit quad in the XY plane with the fixture texture as its base colour. +// The `v` each format assigns to the quad's top corner differs, because the +// formats' `v` conventions differ; each fixture spells its own out. + +std::string objContents(const std::string &mtlName) +{ + // OBJ `vt` is v-up per the spec, so the top of the quad carries v = 1. + return "mtllib " + mtlName + + "\n" + "v 0 0 0\n" + "v 1 0 0\n" + "v 1 1 0\n" + "v 0 1 0\n" + "vt 0 0\n" + "vt 1 0\n" + "vt 1 1\n" + "vt 0 1\n" + "usemtl textured\n" + "f 1/1 2/2 3/3\n" + "f 1/1 3/3 4/4\n"; +} + +std::string mtlContents(const std::string &textureName) +{ + return "newmtl textured\n" + "Kd 1 1 1\n" + "map_Kd " + + textureName + "\n"; +} + +std::string gltfContents(const std::string &binName, + const std::string &textureName) +{ + // glTF's `v` runs down the image per the spec, so the top of the quad + // carries v = 0. + return R"({ + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{ + "attributes": {"POSITION": 0, "TEXCOORD_0": 1}, + "indices": 2, + "material": 0 + }]}], + "materials": [{"pbrMetallicRoughness": {"baseColorTexture": {"index": 0}}}], + "textures": [{"source": 0}], + "images": [{"uri": ")" + + textureName + R"("}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3", + "min": [0, 0, 0], "max": [1, 1, 0]}, + {"bufferView": 1, "componentType": 5126, "count": 4, "type": "VEC2"}, + {"bufferView": 2, "componentType": 5123, "count": 6, "type": "SCALAR"} + ], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 48}, + {"buffer": 0, "byteOffset": 48, "byteLength": 32}, + {"buffer": 0, "byteOffset": 80, "byteLength": 12} + ], + "buffers": [{"byteLength": 92, "uri": ")" + + binName + R"("}] +})"; +} + +std::string gltfBufferContents() +{ + const float positions[] = { + 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 1.f, 0.f, 0.f, 1.f, 0.f}; + const float texCoords[] = {0.f, 1.f, 1.f, 1.f, 1.f, 0.f, 0.f, 0.f}; + const uint16_t indices[] = {0, 1, 2, 0, 2, 3}; + + std::string bytes; + bytes.append(reinterpret_cast(positions), sizeof(positions)); + bytes.append(reinterpret_cast(texCoords), sizeof(texCoords)); + bytes.append(reinterpret_cast(indices), sizeof(indices)); + return bytes; +} + +std::string usdContents(const std::string &textureName) +{ + // UsdPreviewSurface's `st` is v-up, so the top of the quad carries v = 1. + return R"(#usda 1.0 + +def Xform "World" +{ + def Material "Textured" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @)" + + textureName + R"(@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "st" + float2 outputs:result + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "vertex" + ) + rel material:binding = + } +} +)"; +} + +std::string pbrtContents(const std::string &textureName) +{ + // PBRT's `v` is v-up, matching OBJ, so the top of the quad carries v = 1. + return R"(WorldBegin +Texture "fixture" "spectrum" "imagemap" "string filename" [")" + + textureName + R"("] +AttributeBegin +Material "diffuse" "texture reflectance" "fixture" +Shape "trianglemesh" + "integer indices" [0 1 2 0 2 3] + "point3 P" [0 0 0 1 0 0 1 1 0 0 1 0] + "point2 uv" [0 0 1 0 1 1 0 1] +AttributeEnd +WorldEnd +)"; +} + +} // namespace + +// Decoder-level contract ////////////////////////////////////////////////////// + +SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") +{ + GIVEN("A 1x2 image, red on top and blue on the bottom") + { + TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); + + tsd::scene::Scene scene; + tsd::io::TextureCache cache; + + WHEN("It is imported through the shared texture path") + { + auto sampler = tsd::io::importTexture( + scene, texture.path(), cache, /*isLinear=*/true); + + THEN("Row 0 of the array is the bottom row of the picture") + { + REQUIRE(sampler); + auto *image = + sampler->parameterValueAsObject("image"); + REQUIRE(image != nullptr); + REQUIRE(image->dim(0) == 1); + REQUIRE(image->dim(1) == 2); + REQUIRE(isBottomRowColor(texelAsFloat3(image, 0))); + REQUIRE(isTopRowColor(texelAsFloat3(image, 1))); + } + } + } +} + +// Importer-level contract ///////////////////////////////////////////////////// + +// The property that has to hold whatever the storage convention is: the corner +// of the mesh at the top of the picture must address the picture's top row. +// It holds today for glTF and PBRT and fails for OBJ and USD. + +SCENARIO("An imported quad's top corner addresses the image's top row", + "[ImageImport]") +{ + GIVEN("An OBJ quad textured with the fixture image") + { + TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); + TempFile mtl( + "tsd_test_orient.mtl", mtlContents("tsd_test_orient.tga")); + TempFile obj("tsd_test_orient.obj", objContents("tsd_test_orient.mtl")); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("It is imported") + { + tsd::io::import_OBJ(scene, animMgr, obj.path().c_str()); + + THEN("The top corner samples the top row") + { + const auto *image = fixtureImage(scene); + REQUIRE(image != nullptr); + REQUIRE(isTopRowColor(sampleAsAnari(image, uvAtTopOfQuad(scene)))); + } + } + } + + GIVEN("A glTF quad textured with the fixture image") + { + TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); + TempFile bin("tsd_test_orient.bin", gltfBufferContents()); + TempFile gltf("tsd_test_orient.gltf", + gltfContents("tsd_test_orient.bin", "tsd_test_orient.tga")); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("It is imported") + { + tsd::io::import_GLTF(scene, animMgr, gltf.path().c_str()); + + THEN("The top corner samples the top row") + { + const auto *image = fixtureImage(scene); + REQUIRE(image != nullptr); + REQUIRE(isTopRowColor(sampleAsAnari(image, uvAtTopOfQuad(scene)))); + } + } + } + + GIVEN("A PBRT quad textured with the fixture image") + { + TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); + TempFile pbrt( + "tsd_test_orient.pbrt", pbrtContents("tsd_test_orient.tga")); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("It is imported") + { + tsd::io::import_PBRT(scene, animMgr, pbrt.path().c_str()); + + THEN("The top corner samples the top row") + { + const auto *image = fixtureImage(scene); + REQUIRE(image != nullptr); + REQUIRE(isTopRowColor(sampleAsAnari(image, uvAtTopOfQuad(scene)))); + } + } + } + +#if TSD_USE_USD + GIVEN("A USD quad textured with the fixture image") + { + TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); + TempFile stage("tsd_test_orient.usda", usdContents("tsd_test_orient.tga")); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("It is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The top corner samples the top row") + { + const auto *image = fixtureImage(scene); + REQUIRE(image != nullptr); + REQUIRE(isTopRowColor(sampleAsAnari(image, uvAtTopOfQuad(scene)))); + } + } + } +#endif +} From 98b536a24ae9eacaa956bcde03224ec5e516b494 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 14:01:51 -0500 Subject: [PATCH 20/78] refactor(io): put every image decode behind one ImageCache Seven decode paths each did their own keying, Array creation, sampler construction, and orientation assumption. Move all of them into src/tsd/io/images: decoders report the row order their library produced, ImageCache normalizes and stores, and makeImageSampler builds the sampler. One cache key -- (id, colorSpace) -- replaces the three schemes in the tree, and the cache holds the Scene it caches for so a cached ArrayRef cannot reach a different one. No behavior change: SCENE_ROW_ORDER is TOP_DOWN, which is what all seven paths produced. The characterization suite fails in exactly the same three places as before this commit. Folded in while here, because they blocked the type change: - glTF's private decode path becomes acquireDecoded, keeping its native element types and per-texture sampler settings. Its dead flipNormalMapY parameter, which only ever reached the cache key, is gone from all 18 call sites. - PBRT's height-to-normal map stores through acquireDecoded. - ASSIMP's embedded textures key off the texture id directly, so makeTextureCacheKey has no callers left. ImageSource drops the displayName the proposal sketched: makeImageSampler already takes the sampler's name, and ImageSource identifies content. Co-Authored-By: Claude Fable 5 --- tsd/src/tsd/io/CMakeLists.txt | 2 + tsd/src/tsd/io/images.hpp | 6 + tsd/src/tsd/io/images/ImageCache.cpp | 212 ++++++++ tsd/src/tsd/io/images/ImageCache.hpp | 124 +++++ tsd/src/tsd/io/images/detail/decoders.cpp | 391 ++++++++++++++ tsd/src/tsd/io/images/detail/decoders.hpp | 54 ++ .../io/importers/detail/importer_common.cpp | 498 +----------------- .../io/importers/detail/importer_common.hpp | 12 +- .../importers/detail/usd/UsdImportContext.h | 2 +- tsd/src/tsd/io/importers/import_ASSIMP.cpp | 9 +- tsd/src/tsd/io/importers/import_GLTF.cpp | 277 ++++------ tsd/src/tsd/io/importers/import_OBJ.cpp | 2 +- tsd/src/tsd/io/importers/import_PBRT.cpp | 116 ++-- tsd/tests/test_ImageImport.cpp | 2 +- tsd/tests/test_Importers.cpp | 4 +- 15 files changed, 988 insertions(+), 723 deletions(-) create mode 100644 tsd/src/tsd/io/images.hpp create mode 100644 tsd/src/tsd/io/images/ImageCache.cpp create mode 100644 tsd/src/tsd/io/images/ImageCache.hpp create mode 100644 tsd/src/tsd/io/images/detail/decoders.cpp create mode 100644 tsd/src/tsd/io/images/detail/decoders.hpp diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index a223c8e31..a64d12647 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -23,6 +23,8 @@ PRIVATE animation/EnSightFileBinding.cpp animation/SpatialFieldFileBinding.cpp UsdImport.cpp + images/ImageCache.cpp + images/detail/decoders.cpp exporters/NanoVdbSidecar.cpp exporters/SceneToUSD.cpp exporters/StructuredVolumeToNanoVDB.cpp diff --git a/tsd/src/tsd/io/images.hpp b/tsd/src/tsd/io/images.hpp new file mode 100644 index 000000000..6e021be71 --- /dev/null +++ b/tsd/src/tsd/io/images.hpp @@ -0,0 +1,6 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/images/ImageCache.hpp" diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp new file mode 100644 index 000000000..298af3ff4 --- /dev/null +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -0,0 +1,212 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/images/ImageCache.hpp" +// tsd_core +#include "tsd/core/Logging.hpp" +// tsd_io +#include "tsd/io/images/detail/decoders.hpp" +#include "tsd/io/importers/detail/importer_common.hpp" +// std +#include +#include +#include + +using U64Vec2 = tsd::math::vec; +namespace anari { +ANARI_TYPEFOR_SPECIALIZATION(U64Vec2, ANARI_UINT64_VEC2); +} + +namespace tsd::io { + +using namespace tsd::core; +using namespace tsd::scene; + +namespace { + +// The row order a decoded image is stored in once it is resident in a Scene. +// +// This is what every decoder is normalized to and the only place in the tree +// that decides orientation. It is TOP_DOWN here because that is what all seven +// decode paths produced before this component existed; docs/adr/ records the +// move to ANARI orientation. +constexpr RowOrder SCENE_ROW_ORDER = RowOrder::TOP_DOWN; + +std::string keyOf(const ImageSource &source) +{ + return source.id + + (source.colorSpace == ColorSpace::LINEAR ? "_linear" : "_srgb"); +} + +// Reverse the image's rows in place so it lands in SCENE_ROW_ORDER. +void normalizeRowOrder(detail::DecodedImage &image) +{ + if (image.rowOrder == SCENE_ROW_ORDER || image.height < 2) + return; + + if (image.blockCompressed) { + // BC blocks cover 4x4 texels, so reversing rows would mean decoding and + // re-encoding, which is the whole cost compressedImage2D exists to avoid. + // makeImageSampler compensates in the sampler's transform instead. + return; + } + + const size_t rowBytes = image.texels.size() / image.height; + auto *first = image.texels.data(); + for (size_t r = 0; r < image.height / 2; ++r) { + std::swap_ranges(first + r * rowBytes, + first + (r + 1) * rowBytes, + first + (image.height - 1 - r) * rowBytes); + } + image.rowOrder = SCENE_ROW_ORDER; +} + +} // namespace + +ImageCache::ImageCache(Scene *scene) : m_scene(scene) {} + +Scene *ImageCache::scene() const +{ + return m_scene; +} + +Image ImageCache::acquire(const ImageSource &source) +{ + auto resolved = source; + resolved.colorSpace = + detail::colorSpaceForFile(source.id, source.colorSpace); + + if (auto *cached = lookup(resolved)) + return *cached; + + return store(resolved, + detail::decodeImageFile(resolved.id, resolved.colorSpace)); +} + +Image ImageCache::acquire(const ImageSource &source, + const void *data, + size_t numBytes, + const std::string &formatHint) +{ + auto resolved = source; + resolved.colorSpace = + detail::colorSpaceForFormatHint(formatHint, source.colorSpace); + + if (auto *cached = lookup(resolved)) + return *cached; + + return store(resolved, + detail::decodeImageFromMemory( + data, numBytes, resolved.colorSpace, formatHint, resolved.id)); +} + +Image ImageCache::acquireDecoded(const ImageSource &source, + anari::DataType elementType, + size_t width, + size_t height, + RowOrder rowOrder, + const void *texels) +{ + if (auto *cached = lookup(source)) + return *cached; + + detail::DecodedImage decoded; + decoded.elementType = elementType; + decoded.width = width; + decoded.height = height; + decoded.rowOrder = rowOrder; + const auto numBytes = width * height * anari::sizeOf(elementType); + const auto *bytes = static_cast(texels); + decoded.texels.assign(bytes, bytes + numBytes); + + return store(source, std::move(decoded)); +} + +Image ImageCache::find(const ImageSource &source) const +{ + auto found = m_images.find(keyOf(source)); + return found == m_images.end() ? Image{} : found->second; +} + +void ImageCache::clear() +{ + m_images.clear(); +} + +size_t ImageCache::size() const +{ + return m_images.size(); +} + +Image *ImageCache::lookup(const ImageSource &source) +{ + auto found = m_images.find(keyOf(source)); + return found == m_images.end() ? nullptr : &found->second; +} + +Image ImageCache::store( + const ImageSource &source, detail::DecodedImage &&decoded) +{ + if (!decoded) + return {}; + + if (!m_scene) { + logError("[ImageCache] no scene to store image '%s' in", source.id.c_str()); + return {}; + } + + normalizeRowOrder(decoded); + + Image image; + image.blockCompressed = decoded.blockCompressed; + if (decoded.blockCompressed) { + image.texels = m_scene->createArray(ANARI_INT8, decoded.texels.size()); + image.texels->setData(decoded.texels.data()); + image.texels->setMetadataValue( + "compressedFormat", decoded.compressedFormat.value()); + image.texels->setMetadataValue( + "imageSize", U64Vec2(decoded.width, decoded.height)); + } else { + image.texels = m_scene->createArray( + decoded.elementType, decoded.width, decoded.height); + image.texels->setData(decoded.texels.data()); + } + + m_images[keyOf(source)] = image; + return image; +} + +SamplerRef makeImageSampler(Scene &scene, + const Image &image, + const std::string &displayName, + const SamplerSettings &settings) +{ + if (!image) + return {}; + + auto sampler = scene.createObject(image.blockCompressed + ? tokens::sampler::compressedImage2D + : tokens::sampler::image2D); + + sampler->setParameterObject("image", *image.texels); + if (image.blockCompressed) { + const auto compressedFormat = + image.texels->getMetadataValue("compressedFormat").getString(); + sampler->setParameter("format", compressedFormat.c_str()); + sampler->setParameter( + "size", image.texels->getMetadataValue("imageSize").get()); + } + sampler->setParameter("inAttribute", settings.inAttribute); + sampler->setParameter("wrapMode1", settings.wrapMode1); + sampler->setParameter("wrapMode2", settings.wrapMode2); + sampler->setParameter("filter", settings.filter); + if (settings.hasUvTransform) { + sampler->setParameter("inTransform", settings.uvTransform); + sampler->setParameter("inOffset", settings.uvOffset); + } + sampler->setName(fileOf(displayName).c_str()); + + return sampler; +} + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/images/ImageCache.hpp b/tsd/src/tsd/io/images/ImageCache.hpp new file mode 100644 index 000000000..83e0f4f15 --- /dev/null +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -0,0 +1,124 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/scene/Scene.hpp" +// std +#include +#include +#include + +namespace tsd::io { + +namespace detail { +struct DecodedImage; +} // namespace detail + +// How a file's values relate to the linear values a renderer wants. Files that +// carry an encoding of their own (EXR, DDS) ignore this. +enum class ColorSpace +{ + SRGB, + LINEAR +}; + +// The row order a decoder produced. Declared by decoders, never by importers. +enum class RowOrder +{ + TOP_DOWN, + BOTTOM_UP +}; + +// Identifies texel content -- not the sampler built from it. Two materials +// binding the same file at the same color space share one Image. +struct ImageSource +{ + // A resolved absolute path for file-backed images, and an importer-scoped + // stable string otherwise ("gltf::image", + // "assimp://embedded/", "pbrt:::normal"). + std::string id; + ColorSpace colorSpace{ColorSpace::SRGB}; +}; + +// A decoded image resident in a Scene. +struct Image +{ + tsd::scene::ArrayRef texels; + // Block-compressed texels are the authored block stream rather than a + // texel grid, so they cannot be reordered; makeImageSampler compensates. + bool blockCompressed{false}; + + explicit operator bool() const + { + return texels.valid(); + } +}; + +// Owns decoded images for one Scene. Holds the Scene it caches for so a cached +// ArrayRef can never reach a different Scene; it must not outlive that Scene. +class ImageCache +{ + public: + ImageCache() = default; + explicit ImageCache(tsd::scene::Scene *scene); + + tsd::scene::Scene *scene() const; + + // Decode `source.id` as a file path. + Image acquire(const ImageSource &source); + // Decode an encoded image already in memory. `formatHint` names the + // container ("dds", "png", ...) when the caller knows it; when it is empty + // the decoder sniffs the bytes. + Image acquire(const ImageSource &source, + const void *data, + size_t numBytes, + const std::string &formatHint = ""); + // Adopt texels a caller decoded itself, declaring the row order they are in. + Image acquireDecoded(const ImageSource &source, + anari::DataType elementType, + size_t width, + size_t height, + RowOrder rowOrder, + const void *texels); + + // The image already held for `source`, or an invalid Image. For callers + // that synthesize texels expensively and want to skip the work on a hit. + Image find(const ImageSource &source) const; + + void clear(); + size_t size() const; + + private: + Image *lookup(const ImageSource &source); + Image store(const ImageSource &source, detail::DecodedImage &&decoded); + + tsd::scene::Scene *m_scene{nullptr}; + std::unordered_map m_images; +}; + +// How a sampler reads the image it is bound to. Everything a binding can vary +// lives here, including the importer's own uv transform: `makeImageSampler` +// owns the sampler's `inTransform`/`inOffset` outright, because a +// block-compressed image needs a v-flip composed into them and a caller that +// set them afterwards would silently drop it. +struct SamplerSettings +{ + const char *inAttribute{"attribute0"}; + const char *wrapMode1{"repeat"}; + const char *wrapMode2{"repeat"}; + const char *filter{"linear"}; + // The importer's own uv transform, in the same form ANARI takes it. + tsd::math::mat4 uvTransform{tsd::math::IDENTITY_MAT4}; + tsd::math::float4 uvOffset{0.f, 0.f, 0.f, 0.f}; + // Set only when the importer authored a transform, so a sampler that wants + // none is left without the parameters entirely rather than with an identity. + bool hasUvTransform{false}; +}; + +tsd::scene::SamplerRef makeImageSampler(tsd::scene::Scene &scene, + const Image &image, + const std::string &displayName, + const SamplerSettings &settings = {}); + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/images/detail/decoders.cpp b/tsd/src/tsd/io/images/detail/decoders.cpp new file mode 100644 index 000000000..bc5e9f92c --- /dev/null +++ b/tsd/src/tsd/io/images/detail/decoders.cpp @@ -0,0 +1,391 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/images/detail/decoders.hpp" +// tsd_core +#include "tsd/core/Logging.hpp" +// tsd_io +#include "tsd/io/importers/detail/dds.h" +#include "tsd/io/importers/detail/importer_common.hpp" +// stb_image +#include "stb_image.h" +#ifndef _WIN32 +#include "tinyexr.h" +#endif +#if TSD_USE_OIIO +// OpenImageIO +#include +#endif +// std +#include +#include +#include +#include +#include +#include +#include + +namespace tsd::io::detail { + +using namespace tsd::core; + +namespace { + +std::string lowered(std::string s) +{ + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return std::tolower(c); + }); + return s; +} + +// Decoded texels are always float, so channel count alone picks the type. +anari::DataType texelTypeForChannelCount(int numChannels) +{ + switch (numChannels) { + case 1: + return ANARI_FLOAT32; + case 2: + return ANARI_FLOAT32_VEC2; + case 3: + return ANARI_FLOAT32_VEC3; + default: + return ANARI_FLOAT32_VEC4; + } +} + +DecodedImage decodeStb( + const void *data, size_t numBytes, ColorSpace colorSpace, const char *id) +{ + int width = 0; + int height = 0; + int n = 0; + + stbi_ldr_to_hdr_scale(1.0f); + stbi_ldr_to_hdr_gamma(colorSpace == ColorSpace::LINEAR ? 1.0f : 2.2f); + + float *decoded = stbi_loadf_from_memory( + static_cast(data), int(numBytes), &width, &height, &n, 0); + + if (!decoded) { + logError("[decodeImage] failed to decode image '%s'", id); + return {}; + } + if (n < 1) { + logWarning("[decodeImage] image '%s' with %i channels not imported", id, n); + stbi_image_free(decoded); + return {}; + } + + DecodedImage image; + image.elementType = texelTypeForChannelCount(n); + image.width = size_t(width); + image.height = size_t(height); + // stb hands back the picture's first row first, whatever the container's own + // storage order was -- it undoes BMP's and TGA's bottom-up layouts itself. + image.rowOrder = RowOrder::TOP_DOWN; + const size_t numBytesOut = size_t(width) * size_t(height) * size_t(n) + * sizeof(float); + image.texels.assign(reinterpret_cast(decoded), + reinterpret_cast(decoded) + numBytesOut); + + stbi_image_free(decoded); + return image; +} + +DecodedImage decodeDds(const void *data, size_t numBytes, const char *id) +{ + if (numBytes < sizeof(dds::DdsFile)) { + logError("[decodeImage] invalid DDS buffer '%s'", id); + return {}; + } + + auto *file = reinterpret_cast(data); + if (file->magic != dds::DDS_MAGIC + || file->header.size != sizeof(dds::DdsHeader)) { + logError("[decodeImage] invalid DDS buffer '%s'", id); + return {}; + } + + constexpr auto baseReqFlags = dds::DDSD_CAPS | dds::DDSD_HEIGHT + | dds::DDSD_WIDTH | dds::DDSD_PIXELFORMAT; + if ((file->header.flags & baseReqFlags) != baseReqFlags) { + logError("[decodeImage] invalid DDS buffer '%s'", id); + return {}; + } + + if ((file->header.caps & dds::DDSCAPS_TEXTURE) != dds::DDSCAPS_TEXTURE) { + logError("[decodeImage] invalid DDS buffer '%s'", id); + return {}; + } + + const bool alpha = file->header.pixelFormat.flags & dds::DDPF_ALPHAPIXELS; + Token compressedFormat = {}; + switch (dds::getDxgiFormat(file)) { + case dds::DXGI_FORMAT_BC1_UNORM: + // BC1: RGB/RGBA, 1bit alpha + compressedFormat = alpha ? "BC1_RGBA" : "BC1_RGB"; + break; + case dds::DXGI_FORMAT_BC1_UNORM_SRGB: + compressedFormat = alpha ? "BC1_RGBA_SRGB" : "BC1_RGB_SRGB"; + break; + case dds::DXGI_FORMAT_BC2_UNORM: + compressedFormat = "BC2"; + break; + case dds::DXGI_FORMAT_BC2_UNORM_SRGB: + compressedFormat = "BC2_SRGB"; + break; + case dds::DXGI_FORMAT_BC3_UNORM: + compressedFormat = "BC3"; + break; + case dds::DXGI_FORMAT_BC3_UNORM_SRGB: + compressedFormat = "BC3_SRGB"; + break; + case dds::DXGI_FORMAT_BC4_UNORM: + compressedFormat = "BC4"; + break; + case dds::DXGI_FORMAT_BC4_SNORM: + compressedFormat = "BC4_SNORM"; + break; + case dds::DXGI_FORMAT_BC5_UNORM: + compressedFormat = "BC5"; + break; + case dds::DXGI_FORMAT_BC5_SNORM: + compressedFormat = "BC5_SNORM"; + break; + case dds::DXGI_FORMAT_BC6H_UF16: + compressedFormat = "BC6H_UFLOAT"; + break; + case dds::DXGI_FORMAT_BC6H_SF16: + compressedFormat = "BC6H_SFLOAT"; + break; + case dds::DXGI_FORMAT_BC7_UNORM: + compressedFormat = "BC7"; + break; + case dds::DXGI_FORMAT_BC7_UNORM_SRGB: + compressedFormat = "BC7_SRGB"; + break; + default: + logError("[decodeImage] unsupported DDS format '%c%c%c%c' for '%s'", + file->header.pixelFormat.fourCC & 0xff, + (file->header.pixelFormat.fourCC >> 8) & 0xff, + (file->header.pixelFormat.fourCC >> 16) & 0xff, + (file->header.pixelFormat.fourCC >> 24) & 0xff, + id); + return {}; + } + + // Simple implementation that only handles single level mipmaps and + // non-cubemap textures. + const auto linearSize = dds::computeLinearSize(file); + if ((file->header.flags & dds::DDSD_LINEARSIZE) + && (linearSize != file->header.pitchOrLinearSize)) { + logError( + "[decodeImage] ignoring invalid linear size %u (should be %u) for compressed texture '%s'", + file->header.pitchOrLinearSize, + linearSize, + id); + } + + DecodedImage image; + image.elementType = ANARI_INT8; + image.width = file->header.width; + image.height = file->header.height; + image.blockCompressed = true; + image.compressedFormat = compressedFormat; + // Block-compressed rows come in 4x4 groups, so this is the order the file + // authored and the only order the blocks can be handed on in. + image.rowOrder = RowOrder::TOP_DOWN; + const auto *blocks = + reinterpret_cast(dds::getDataPointer(file)); + image.texels.assign(blocks, blocks + linearSize); + + return image; +} + +#ifndef _WIN32 +// Follow actual HDRI importer: tinyexr is excluded on Windows; to be +// investigated. +DecodedImage decodeExr(const std::string &path) +{ + float *rgba = nullptr; + int width = 0; + int height = 0; + const char *err = nullptr; + + if (LoadEXR(&rgba, &width, &height, path.c_str(), &err) != TINYEXR_SUCCESS) { + logError("[decodeImage] failed to load EXR '%s': %s", + path.c_str(), + err ? err : "unknown error"); + if (err) + FreeEXRErrorMessage(err); + return {}; + } + + DecodedImage image; + image.elementType = ANARI_FLOAT32_VEC4; + image.width = size_t(width); + image.height = size_t(height); + image.rowOrder = RowOrder::TOP_DOWN; + const auto *bytes = reinterpret_cast(rgba); + image.texels.assign( + bytes, bytes + size_t(width) * size_t(height) * 4 * sizeof(float)); + + free(rgba); + return image; +} +#endif + +#if TSD_USE_OIIO +// stb decodes LDR files to float through stbi_ldr_to_hdr_gamma(), a plain +// pow(x, 2.2) rather than the true sRGB EOTF; OpenImageIO hands back the raw +// normalized values, so the same curve is applied here to keep every texture +// path on one contract. stb takes an odd channel count to be all-color and an +// even one to end in alpha (stb_image.h: `if (comp & 1) n = comp; else +// n = comp-1`), which is what leaves alpha linear -- match that exactly, or a +// 2-channel grey+alpha image gets its alpha gamma-corrected. +void applyGamma22InPlace(float *texels, size_t numTexels, int numChannels) +{ + const int numColorChannels = + (numChannels & 1) ? numChannels : numChannels - 1; + for (size_t t = 0; t < numTexels; t++) { + float *texel = texels + t * numChannels; + for (int c = 0; c < numColorChannels; c++) + texel[c] = std::pow(texel[c], 2.2f); + } +} + +// TIFF is the format the USD/MaterialX assets reach for that stb cannot decode. +// OpenImageIO covers it (and everything else it has a reader for) without TSD +// taking on a format-specific decoder. +DecodedImage decodeOiio(const std::string &path, ColorSpace colorSpace) +{ + // OpenImageIO premultiplies unassociated alpha into the colour channels by + // default; stb never does. Ask for the file's own values so a TIFF with + // alpha lands on the same contract as every other texture path. + OIIO::ImageSpec config; + config.attribute("oiio:UnassociatedAlpha", 1); + + // The returned unique_ptr's deleter closes the file on every exit path. + auto input = OIIO::ImageInput::open(path, &config); + if (!input) { + logError("[decodeImage] failed to open image '%s': %s", + path.c_str(), + OIIO::geterror().c_str()); + return {}; + } + + const auto &spec = input->spec(); + const int width = spec.width; + const int height = spec.height; + const int numChannels = spec.nchannels; + if (width < 1 || height < 1 || numChannels < 1 || numChannels > 4) { + logWarning("[decodeImage] image '%s' with %i channels not imported", + path.c_str(), + numChannels); + return {}; + } + // stb only ever gamma-decodes integer input; a float or half TIFF already + // carries linear values, so applying the curve to it would darken the image. + const bool fileIsIntegral = !spec.format.is_floating_point(); + + DecodedImage image; + image.elementType = texelTypeForChannelCount(numChannels); + image.width = size_t(width); + image.height = size_t(height); + // OpenImageIO's scanline order runs from the picture's top. + image.rowOrder = RowOrder::TOP_DOWN; + image.texels.resize( + size_t(width) * size_t(height) * size_t(numChannels) * sizeof(float)); + + auto *texels = reinterpret_cast(image.texels.data()); + if (!input->read_image(0, 0, 0, numChannels, OIIO::TypeDesc::FLOAT, texels)) { + logError("[decodeImage] failed to decode image '%s': %s", + path.c_str(), + input->geterror().c_str()); + return {}; + } + + if (colorSpace == ColorSpace::SRGB && fileIsIntegral) { + applyGamma22InPlace( + texels, size_t(width) * size_t(height), numChannels); + } + + return image; +} +#endif + +std::vector readWholeFile(const std::string &path) +{ + std::ifstream ifs(path, std::ios::in | std::ios::binary); + if (!ifs.is_open()) { + logError("[decodeImage] failed to open image '%s'", path.c_str()); + return {}; + } + return std::vector( + (std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); +} + +} // namespace + +ColorSpace colorSpaceForFile(const std::string &path, ColorSpace requested) +{ + const auto ext = lowered(extensionOf(path)); + if (ext == ".exr" || ext == ".dds") + return ColorSpace::LINEAR; + return requested; +} + +ColorSpace colorSpaceForFormatHint( + const std::string &formatHint, ColorSpace requested) +{ + return lowered(formatHint) == "dds" ? ColorSpace::LINEAR : requested; +} + +DecodedImage decodeImageFile(const std::string &path, ColorSpace colorSpace) +{ + const auto ext = lowered(extensionOf(path)); + + if (ext == ".dds") { + const auto bytes = readWholeFile(path); + return bytes.empty() ? DecodedImage{} + : decodeDds(bytes.data(), bytes.size(), path.c_str()); + } + +#ifndef _WIN32 + if (ext == ".exr") + return decodeExr(path); +#endif + + if (ext == ".tif" || ext == ".tiff") { +#if TSD_USE_OIIO + return decodeOiio(path, colorSpace); +#else + // Falling through to stb would fail with a decode error that says nothing + // about the actual cause, which is that TSD was built without OpenImageIO. + logError( + "[decodeImage] cannot decode TIFF image '%s': TSD was built without" + " OpenImageIO (set TSD_USE_OIIO=ON)", + path.c_str()); + return {}; +#endif + } + + const auto bytes = readWholeFile(path); + return bytes.empty() + ? DecodedImage{} + : decodeStb(bytes.data(), bytes.size(), colorSpace, path.c_str()); +} + +DecodedImage decodeImageFromMemory(const void *data, + size_t numBytes, + ColorSpace colorSpace, + const std::string &formatHint, + const std::string &id) +{ + if (lowered(formatHint) == "dds") + return decodeDds(data, numBytes, id.c_str()); + return decodeStb(data, numBytes, colorSpace, id.c_str()); +} + +} // namespace tsd::io::detail diff --git a/tsd/src/tsd/io/images/detail/decoders.hpp b/tsd/src/tsd/io/images/detail/decoders.hpp new file mode 100644 index 000000000..bc3913b14 --- /dev/null +++ b/tsd/src/tsd/io/images/detail/decoders.hpp @@ -0,0 +1,54 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/Token.hpp" +#include "tsd/io/images/ImageCache.hpp" +// std +#include +#include +#include + +namespace tsd::io::detail { + +// What a decoder produced, before it is normalized and handed to a Scene. +// Decoders fill this in and declare the row order they wrote; nothing outside +// this file decides orientation for them. +struct DecodedImage +{ + std::vector texels; + anari::DataType elementType{ANARI_UNKNOWN}; + size_t width{0}; + size_t height{0}; + RowOrder rowOrder{RowOrder::TOP_DOWN}; + + // A block-compressed payload is opaque: `texels` is the authored block + // stream rather than a texel grid, `elementType` is ANARI_INT8, and + // `width`/`height` describe the picture the blocks encode. + bool blockCompressed{false}; + tsd::core::Token compressedFormat; + + explicit operator bool() const + { + return elementType != ANARI_UNKNOWN && !texels.empty(); + } +}; + +// Whether a file's own encoding overrides the color space a caller asked for. +// EXR carries linear values and DDS carries its encoding in the block format, +// so both collapse onto LINEAR -- which also keeps one file from being decoded +// once per color-space bucket. +ColorSpace colorSpaceForFile(const std::string &path, ColorSpace requested); +ColorSpace colorSpaceForFormatHint( + const std::string &formatHint, ColorSpace requested); + +// `id` names the image in diagnostics only. +DecodedImage decodeImageFile(const std::string &path, ColorSpace colorSpace); +DecodedImage decodeImageFromMemory(const void *data, + size_t numBytes, + ColorSpace colorSpace, + const std::string &formatHint, + const std::string &id); + +} // namespace tsd::io::detail diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index ae6cf0899..06978954c 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -8,18 +8,8 @@ #include "tsd/core/Token.hpp" // tsd_io #include "tsd/io/importers.hpp" -#include "tsd/io/importers/detail/dds.h" // mikktspace #include "mikktspace.h" -// stb_image -#include "stb_image.h" -#ifndef _WIN32 -#include "tinyexr.h" -#endif -#if TSD_USE_OIIO -// OpenImageIO -#include -#endif // anari #include // std @@ -35,11 +25,6 @@ #include #include -using U64Vec2 = tsd::math::vec; -namespace anari { -ANARI_TYPEFOR_SPECIALIZATION(U64Vec2, ANARI_UINT64_VEC2); -} - namespace tsd::io { using namespace tsd::core; @@ -98,11 +83,6 @@ std::vector splitString(const std::string &s, char delim) return result; } -std::string makeTextureCacheKey(const std::string &textureId, bool isLinear) -{ - return textureId + (isLinear ? "_linear" : "_srgb"); -} - tsd::scene::ArrayRef readArray( tsd::scene::Scene &scene, anari::DataType elementType, std::FILE *fp) { @@ -121,444 +101,31 @@ tsd::scene::ArrayRef readArray( return retval; } -static SamplerRef makeTextureSampler( - Scene &scene, ArrayRef dataArray, const std::string &displayName) -{ - auto tex = scene.createObject(tokens::sampler::image2D); - - tex->setParameterObject("image", *dataArray); - tex->setParameter("inAttribute", "attribute0"); - tex->setParameter("wrapMode1", "repeat"); - tex->setParameter("wrapMode2", "repeat"); - tex->setParameter("filter", "linear"); - tex->setName(fileOf(displayName).c_str()); - - return tex; -} - -static SamplerRef makeCompressedTextureSampler( - Scene &scene, ArrayRef dataArray, const std::string &displayName) -{ - auto compressedFormat = - dataArray->getMetadataValue("compressedFormat").getString(); - - auto tex = scene.createObject(tokens::sampler::compressedImage2D); - tex->setParameterObject("image", *dataArray); - tex->setParameter("format", compressedFormat.c_str()); - tex->setParameter( - "size", dataArray->getMetadataValue("imageSize").get()); - tex->setParameter("inAttribute", "attribute0"); - tex->setParameter("wrapMode1", "repeat"); - tex->setParameter("wrapMode2", "repeat"); - tex->setParameter("filter", "linear"); - tex->setName(fileOf(displayName).c_str()); - - return tex; -} - -static ArrayRef importDdsTextureArray(Scene &scene, - const void *data, - size_t numBytes, - const std::string &textureId, - TextureCache &cache) -{ - auto dataArray = cache[textureId]; - if (!dataArray.valid()) { - if (numBytes < sizeof(dds::DdsFile)) { - logError("[importDdsTexture] invalid DDS buffer '%s'", textureId.c_str()); - return {}; - } - - auto dds = reinterpret_cast(data); - if (dds->magic != dds::DDS_MAGIC - || dds->header.size != sizeof(dds::DdsHeader)) { - logError("[importDdsTexture] invalid DDS buffer '%s'", textureId.c_str()); - return {}; - } - - // Check if we have a dxt10 header - constexpr const auto baseReqFlags = dds::DDSD_CAPS | dds::DDSD_HEIGHT - | dds::DDSD_WIDTH | dds::DDSD_PIXELFORMAT; - if ((dds->header.flags & baseReqFlags) != baseReqFlags) { - logError("[importDdsTexture] invalid DDS buffer '%s'", textureId.c_str()); - return {}; - } - - constexpr const auto textureReqFlags = dds::DDSCAPS_TEXTURE; - if ((dds->header.caps & textureReqFlags) != textureReqFlags) { - logError("[importDdsTexture] invalid DDS buffer '%s'", textureId.c_str()); - return {}; - } - - Token compressedFormat = {}; - Token format = {}; - bool alpha = dds->header.pixelFormat.flags & dds::DDPF_ALPHAPIXELS; - switch (dds::getDxgiFormat(dds)) { - case dds::DXGI_FORMAT_BC1_UNORM: { - // BC1: RGB/RGBA, 1bit alpha - compressedFormat = alpha ? "BC1_RGBA" : "BC1_RGB"; - break; - } - case dds::DXGI_FORMAT_BC1_UNORM_SRGB: { - // BC1: RGB/RGBA, 1bit alpha - compressedFormat = alpha ? "BC1_RGBA_SRGB" : "BC1_RGB_SRGB"; - break; - } - case dds::DXGI_FORMAT_BC2_UNORM: { - // BC2: RGB/RGBA, 4bit alpha - compressedFormat = "BC2"; - break; - } - case dds::DXGI_FORMAT_BC2_UNORM_SRGB: { - // BC2: RGB/RGBA, 4bit alpha - compressedFormat = "BC2_SRGB"; - break; - } - case dds::DXGI_FORMAT_BC3_UNORM: { - // BC3: RGB/RGBA, 8bit alpha - compressedFormat = "BC3"; - break; - } - case dds::DXGI_FORMAT_BC3_UNORM_SRGB: { - // BC3: RGB/RGBA, 8bit alpha - compressedFormat = "BC3_SRGB"; - break; - } - case dds::DXGI_FORMAT_BC4_UNORM: { - // BC4: R/RG - compressedFormat = "BC4"; - break; - } - case dds::DXGI_FORMAT_BC4_SNORM: { - // BC4: R/RG - compressedFormat = "BC4_SNORM"; - break; - } - case dds::DXGI_FORMAT_BC5_UNORM: { - // BC5: RG/RGBA - compressedFormat = "BC5"; - break; - } - case dds::DXGI_FORMAT_BC5_SNORM: { - // BC5: RG/RGBA - compressedFormat = "BC5_SNORM"; - break; - } - case dds::DXGI_FORMAT_BC6H_UF16: { - // BC6H: RGB - compressedFormat = "BC6H_UFLOAT"; - break; - } - case dds::DXGI_FORMAT_BC6H_SF16: { - // BC6H: RGB - compressedFormat = "BC6H_SFLOAT"; - break; - } - case dds::DXGI_FORMAT_BC7_UNORM: { - // BC7: RGB/RGBA - compressedFormat = "BC7"; - break; - } - case dds::DXGI_FORMAT_BC7_UNORM_SRGB: { - // BC7: RGB/RGBA - compressedFormat = "BC7_SRGB"; - break; - } - - default: { - logError("[importDdsTexture] unsupported DDS format '%c%c%c%c' for '%s'", - dds->header.pixelFormat.fourCC & 0xff, - (dds->header.pixelFormat.fourCC >> 8) & 0xff, - (dds->header.pixelFormat.fourCC >> 16) & 0xff, - (dds->header.pixelFormat.fourCC >> 24) & 0xff, - textureId.c_str()); - break; - } - } - - if (compressedFormat) { - // Simple implementation that only handling single level mipmaps - // and non cubemap textures. - auto linearSize = dds::computeLinearSize(dds); - - if ((dds->header.flags & dds::DDSD_LINEARSIZE) - && (linearSize != dds->header.pitchOrLinearSize)) { - logError( - "[importDdsTexture] ignoring invalid linear size %u (should be %u) for compressed texture '%s'", - dds->header.pitchOrLinearSize, - linearSize, - textureId.c_str()); - } - - dataArray = scene.createArray(ANARI_INT8, linearSize); - dataArray->setData(dds::getDataPointer(dds)); - dataArray->setMetadataValue("compressedFormat", compressedFormat.value()); - dataArray->setMetadataValue( - "imageSize", U64Vec2(dds->header.width, dds->header.height)); - cache[textureId] = dataArray; - } else { - logError("Unspported texture format for '%s'", textureId.c_str()); - return {}; - } - } - - return dataArray; -} - -SamplerRef importDdsTexture( - Scene &scene, std::string filepath, TextureCache &cache) -{ - if (auto dataArray = cache[filepath]; dataArray.valid()) - return makeCompressedTextureSampler(scene, dataArray, filepath); - - std::ifstream ifs(filepath, std::ios::in | std::ios::binary); - if (!ifs.is_open()) { - logError("[importDdsTexture] failed to open file '%s'", filepath.c_str()); - return {}; - } - - std::vector buffer( - (std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); - auto dataArray = importDdsTextureArray( - scene, buffer.data(), buffer.size(), filepath, cache); - return dataArray ? makeCompressedTextureSampler(scene, dataArray, filepath) - : SamplerRef{}; -} - -// Decoded texels are always float, so channel count alone picks the type. -static int texelTypeForChannelCount(int numChannels) -{ - switch (numChannels) { - case 1: - return ANARI_FLOAT32; - case 2: - return ANARI_FLOAT32_VEC2; - case 3: - return ANARI_FLOAT32_VEC3; - default: - return ANARI_FLOAT32_VEC4; - } -} - -static ArrayRef importStbTextureArray(Scene &scene, - const void *data, - size_t numBytes, - const std::string &textureId, - TextureCache &cache, - bool isLinear) -{ - auto dataArray = cache[textureId]; - if (!dataArray.valid()) { - int width, height, n; - if (isLinear) { - stbi_ldr_to_hdr_scale(1.0f); - stbi_ldr_to_hdr_gamma(1.0f); - } else { - stbi_ldr_to_hdr_scale(1.0f); - stbi_ldr_to_hdr_gamma(2.2f); - } - void *decodedData = - stbi_loadf_from_memory(static_cast(data), - int(numBytes), - &width, - &height, - &n, - 0); - - if (!decodedData || n < 1) { - if (!decodedData) { - logError( - "[importTexture] failed to import texture '%s'", textureId.c_str()); - } else { - logWarning("[importTexture] texture '%s' with %i channels not imported", - textureId.c_str(), - n); - } - return {}; - } +// Texture import shims ///////////////////////////////////////////////////////// - dataArray = - scene.createArray(texelTypeForChannelCount(n), width, height); - dataArray->setData(decodedData); - cache[textureId] = dataArray; +// These forward to tsd::io::images, which owns decoding, orientation, keying, +// and lifetime for every image in the tree. They exist so the ~20 importer +// call sites keep one signature; new code should use ImageCache directly. - stbi_image_free(decodedData); - } - - return dataArray; -} +namespace { -SamplerRef importStbTexture( - Scene &scene, std::string filepath, TextureCache &cache, bool isLinear) +ColorSpace colorSpaceOf(bool isLinear) { - auto cacheKey = makeTextureCacheKey(filepath, isLinear); - if (auto dataArray = cache[cacheKey]; dataArray.valid()) - return makeTextureSampler(scene, dataArray, filepath); - - std::ifstream ifs(filepath, std::ios::in | std::ios::binary); - if (!ifs.is_open()) { - logError("[importTexture] failed to open texture '%s'", filepath.c_str()); - return {}; - } - - std::vector buffer( - (std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); - auto dataArray = importStbTextureArray( - scene, buffer.data(), buffer.size(), cacheKey, cache, isLinear); - return dataArray ? makeTextureSampler(scene, dataArray, filepath) - : SamplerRef{}; -} - -#ifndef _WIN32 -// Follow actual HDRI importer: tinyexr is excluded on Windows; to be -// investigated. -static SamplerRef importExrTexture( - Scene &scene, const std::string &filepath, TextureCache &cache) -{ - // EXR is always linear (no sRGB encoding); collapse both cache buckets onto - // the linear key so a .exr can't be imported twice as srgb vs linear. - auto cacheKey = makeTextureCacheKey(filepath, /*isLinear=*/true); - if (auto dataArray = cache[cacheKey]; dataArray.valid()) - return makeTextureSampler(scene, dataArray, filepath); - - float *rgba = nullptr; - int width = 0; - int height = 0; - const char *err = nullptr; - int ret = LoadEXR(&rgba, &width, &height, filepath.c_str(), &err); - if (ret != TINYEXR_SUCCESS) { - logError("[importTexture] failed to load EXR '%s': %s", - filepath.c_str(), - err ? err : "unknown error"); - if (err) - FreeEXRErrorMessage(err); - return {}; - } - - auto dataArray = scene.createArray(ANARI_FLOAT32_VEC4, width, height); - dataArray->setData(rgba); - cache[cacheKey] = dataArray; - free(rgba); - - return makeTextureSampler(scene, dataArray, filepath); + return isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB; } -#endif -#if TSD_USE_OIIO -// stb decodes LDR files to float through stbi_ldr_to_hdr_gamma(), a plain -// pow(x, 2.2) rather than the true sRGB EOTF; OpenImageIO hands back the raw -// normalized values, so the same curve is applied here to keep every texture -// path on one contract. stb takes an odd channel count to be all-color and an -// even one to end in alpha (stb_image.h: `if (comp & 1) n = comp; else -// n = comp-1`), which is what leaves alpha linear -- match that exactly, or a -// 2-channel grey+alpha image gets its alpha gamma-corrected. -static void applyGamma22InPlace(float *texels, size_t numTexels, int numChannels) -{ - const int numColorChannels = - (numChannels & 1) ? numChannels : numChannels - 1; - for (size_t t = 0; t < numTexels; t++) { - float *texel = texels + t * numChannels; - for (int c = 0; c < numColorChannels; c++) - texel[c] = std::pow(texel[c], 2.2f); - } -} - -// TIFF is the format the USD/MaterialX assets reach for that stb cannot decode. -// OpenImageIO covers it (and everything else it has a reader for) without TSD -// taking on a format-specific decoder. -static SamplerRef importOiioTexture( - Scene &scene, const std::string &filepath, TextureCache &cache, bool isLinear) -{ - auto cacheKey = makeTextureCacheKey(filepath, isLinear); - if (auto dataArray = cache[cacheKey]; dataArray.valid()) - return makeTextureSampler(scene, dataArray, filepath); - - // OpenImageIO premultiplies unassociated alpha into the colour channels by - // default; stb never does. Ask for the file's own values so a TIFF with - // alpha lands on the same contract as every other texture path. - OIIO::ImageSpec config; - config.attribute("oiio:UnassociatedAlpha", 1); - - // The returned unique_ptr's deleter closes the file on every exit path. - auto image = OIIO::ImageInput::open(filepath, &config); - if (!image) { - logError("[importTexture] failed to open texture '%s': %s", - filepath.c_str(), - OIIO::geterror().c_str()); - return {}; - } - - const auto &spec = image->spec(); - const int width = spec.width; - const int height = spec.height; - const int numChannels = spec.nchannels; - if (width < 1 || height < 1 || numChannels < 1 || numChannels > 4) { - logWarning("[importTexture] texture '%s' with %i channels not imported", - filepath.c_str(), - numChannels); - return {}; - } - // stb only ever gamma-decodes integer input; a float or half TIFF already - // carries linear values, so applying the curve to it would darken the image. - const bool fileIsIntegral = !spec.format.is_floating_point(); - - std::vector texels(size_t(width) * height * numChannels); - if (!image->read_image( - 0, 0, 0, numChannels, OIIO::TypeDesc::FLOAT, texels.data())) { - logError("[importTexture] failed to decode texture '%s': %s", - filepath.c_str(), - image->geterror().c_str()); - return {}; - } - - if (!isLinear && fileIsIntegral) - applyGamma22InPlace(texels.data(), size_t(width) * height, numChannels); - - auto dataArray = scene.createArray( - texelTypeForChannelCount(numChannels), width, height); - dataArray->setData(texels.data()); - cache[cacheKey] = dataArray; - - return makeTextureSampler(scene, dataArray, filepath); -} -#endif +} // namespace SamplerRef importTexture( - Scene &scene, std::string filepath, TextureCache &cache, bool isLinear) + Scene &scene, std::string filepath, ImageCache &cache, bool isLinear) { std::transform( filepath.begin(), filepath.end(), filepath.begin(), [](char c) { return c == '\\' ? '/' : c; }); - auto ext = extensionOf(filepath); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { - return std::tolower(c); - }); - - SamplerRef tex; - if (ext == ".dds") { - tex = importDdsTexture(scene, filepath, cache); -#ifndef _WIN32 - } else if (ext == ".exr") { - tex = importExrTexture(scene, filepath, cache); -#endif - } else if (ext == ".tif" || ext == ".tiff") { -#if TSD_USE_OIIO - tex = importOiioTexture(scene, filepath, cache, isLinear); -#else - // Falling through to stb would fail with a decode error that says nothing - // about the actual cause, which is that TSD was built without OpenImageIO. - logError( - "[importTexture] cannot decode TIFF texture '%s': TSD was built without" - " OpenImageIO (set TSD_USE_OIIO=ON)", - filepath.c_str()); -#endif - } else { - tex = importStbTexture(scene, filepath, cache, isLinear); - } - - return tex; + auto image = cache.acquire({filepath, colorSpaceOf(isLinear)}); + return makeImageSampler(scene, image, filepath); } SamplerRef importTextureFromMemory(Scene &scene, @@ -566,28 +133,13 @@ SamplerRef importTextureFromMemory(Scene &scene, const std::string &displayName, const void *data, size_t numBytes, - TextureCache &cache, + ImageCache &cache, bool isLinear, const std::string &formatHint) { - std::string format = formatHint; - std::transform( - format.begin(), format.end(), format.begin(), [](unsigned char c) { - return std::tolower(c); - }); - - if (format == "dds") { - auto dataArray = - importDdsTextureArray(scene, data, numBytes, cacheKey, cache); - return dataArray - ? makeCompressedTextureSampler(scene, dataArray, displayName) - : SamplerRef{}; - } - - auto dataArray = - importStbTextureArray(scene, data, numBytes, cacheKey, cache, isLinear); - return dataArray ? makeTextureSampler(scene, dataArray, displayName) - : SamplerRef{}; + auto image = cache.acquire( + {cacheKey, colorSpaceOf(isLinear)}, data, numBytes, formatHint); + return makeImageSampler(scene, image, displayName); } SamplerRef importRawTexture2D(Scene &scene, @@ -596,21 +148,19 @@ SamplerRef importRawTexture2D(Scene &scene, const void *data, size_t width, size_t height, - TextureCache &cache, + ImageCache &cache, bool isLinear) { - auto dataArray = cache[cacheKey]; - - if (!dataArray.valid()) { - auto format = isLinear ? ANARI_UFIXED8_VEC4 : ANARI_UFIXED8_RGBA_SRGB; - dataArray = scene.createArray(format, width, height); - dataArray->setData(data); - cache[cacheKey] = dataArray; - } - - return makeTextureSampler(scene, dataArray, displayName); + auto image = cache.acquireDecoded({cacheKey, colorSpaceOf(isLinear)}, + isLinear ? ANARI_UFIXED8_VEC4 : ANARI_UFIXED8_RGBA_SRGB, + width, + height, + RowOrder::TOP_DOWN, + data); + return makeImageSampler(scene, image, displayName); } + SamplerRef makeDefaultColorMapSampler(Scene &scene, const float2 &range) { auto samplerImageArray = scene.createArray(ANARI_FLOAT32_VEC4, 3); diff --git a/tsd/src/tsd/io/importers/detail/importer_common.hpp b/tsd/src/tsd/io/importers/detail/importer_common.hpp index f399ac592..7bb7c43f2 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.hpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.hpp @@ -5,11 +5,11 @@ #include "tsd/animation/Animation.hpp" #include "tsd/core/ColorMapUtil.hpp" +#include "tsd/io/images.hpp" #include "tsd/scene/Scene.hpp" // std #include #include -#include #include #if TSD_USE_VTK // vtk @@ -28,19 +28,17 @@ std::vector splitString(const std::string &s, char delim); tsd::scene::ArrayRef readArray( tsd::scene::Scene &scene, anari::DataType elementType, std::FILE *fp); -using TextureCache = std::unordered_map; -std::string makeTextureCacheKey( - const std::string &textureId, bool isLinear = false); +// Thin shims over tsd::io::images; see the note above their definitions. tsd::scene::SamplerRef importTexture(tsd::scene::Scene &scene, std::string filepath, - TextureCache &cache, + ImageCache &cache, bool isLinear = false); tsd::scene::SamplerRef importTextureFromMemory(tsd::scene::Scene &scene, const std::string &cacheKey, const std::string &displayName, const void *data, size_t numBytes, - TextureCache &cache, + ImageCache &cache, bool isLinear = false, const std::string &formatHint = ""); tsd::scene::SamplerRef importRawTexture2D(tsd::scene::Scene &scene, @@ -49,7 +47,7 @@ tsd::scene::SamplerRef importRawTexture2D(tsd::scene::Scene &scene, const void *data, size_t width, size_t height, - TextureCache &cache, + ImageCache &cache, bool isLinear = false); tsd::scene::SamplerRef makeDefaultColorMapSampler( diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index 09c7f35ba..0f34266d1 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -53,7 +53,7 @@ struct ImportContext pxr::UsdTimeCode importTime{pxr::UsdTimeCode::EarliestTime()}; // Caches keyed by resolved prim path, so shared content converts once. - TextureCache textureCache; + ImageCache textureCache{&scene}; std::unordered_map materialCache; std::unordered_map uvPrimvarCache; diff --git a/tsd/src/tsd/io/importers/import_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index 3a21cbd68..60d9818c7 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -27,14 +27,13 @@ static SamplerRef importEmbeddedTexture( Scene &scene, const aiTexture *embeddedTexture, int embeddedTextureIndex, - TextureCache &cache, + ImageCache &cache, bool isLinear) { const std::string filename = embeddedTexture->mFilename.C_Str(); const std::string textureId = embeddedTextureIndex >= 0 ? "assimp://embedded/" + std::to_string(embeddedTextureIndex) : "assimp://embedded-name/" + filename; - const std::string cacheKey = makeTextureCacheKey(textureId, isLinear); const std::string displayName = filename.empty() ? (embeddedTextureIndex >= 0 ? "embedded_" + std::to_string(embeddedTextureIndex) : "embedded_texture") @@ -55,7 +54,7 @@ static SamplerRef importEmbeddedTexture( if (embeddedTexture->mHeight == 0) { auto tex = importTextureFromMemory(scene, - cacheKey, + textureId, displayName, embeddedTexture->pcData, embeddedTexture->mWidth, @@ -82,7 +81,7 @@ static SamplerRef importEmbeddedTexture( } return importRawTexture2D(scene, - cacheKey, + textureId, displayName, rgba.data(), embeddedTexture->mWidth, @@ -243,7 +242,7 @@ static std::vector importASSIMPMaterials( { std::vector materials; - TextureCache cache; + ImageCache cache(&scene); std::string basePath = pathOf(filename); diff --git a/tsd/src/tsd/io/importers/import_GLTF.cpp b/tsd/src/tsd/io/importers/import_GLTF.cpp index 369548ba7..f01f7c75a 100644 --- a/tsd/src/tsd/io/importers/import_GLTF.cpp +++ b/tsd/src/tsd/io/importers/import_GLTF.cpp @@ -101,12 +101,88 @@ static std::string attributeNameForTexCoord(int texCoord) return "attribute"s + std::to_string(texCoord); } +// The ANARI element type that keeps a glTF image's own component type and +// channel count. tinygltf hands back decoded texels, so unlike the shared +// decode path nothing here expands to float or applies a gamma curve in +// software -- the *_SRGB formats let the device apply the true sRGB EOTF. +static anari::DataType gltfTexelType( + const tinygltf::Image &image, bool isLinear, int textureIndex) +{ + const int channels = image.component - 1; + switch (image.pixel_type) { + case TINYGLTF_COMPONENT_TYPE_BYTE: + if (!isLinear) + logWarning("[import_GLTF] signed byte textures not supported in sRGB"); + return ANARI_FIXED8 + channels; + case TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE: + return isLinear ? ANARI_UFIXED8 + channels + : ANARI_UFIXED8_R_SRGB + channels; + case TINYGLTF_COMPONENT_TYPE_SHORT: + if (!isLinear) + logWarning("[import_GLTF] signed short textures not supported in sRGB"); + return ANARI_FIXED16 + channels; + case TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT: + if (!isLinear) + logWarning("[import_GLTF] unsigned short textures not supported in sRGB"); + return ANARI_UFIXED16 + channels; + case TINYGLTF_COMPONENT_TYPE_INT: + if (!isLinear) + logWarning("[import_GLTF] signed int textures not supported in sRGB"); + return ANARI_FIXED32 + channels; + case TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT: + if (!isLinear) + logWarning("[import_GLTF] unsigned int textures not supported in sRGB"); + return ANARI_UFIXED32 + channels; + case TINYGLTF_COMPONENT_TYPE_FLOAT: + if (!isLinear) + logWarning("[import_GLTF] float textures not supported in sRGB"); + return ANARI_FLOAT32 + channels; + case TINYGLTF_COMPONENT_TYPE_DOUBLE: + if (!isLinear) + logWarning("[import_GLTF] double textures not supported in sRGB"); + return ANARI_FLOAT64 + channels; + default: + logWarning("[import_GLTF] unsupported image component type texture: %d", + image.pixel_type); + return ANARI_UNKNOWN; + } +} + +static SamplerSettings gltfSamplerSettings( + const tinygltf::Model &model, const tinygltf::Texture &texture) +{ + SamplerSettings settings; + if (texture.sampler < 0 || texture.sampler >= model.samplers.size()) + return settings; + + const auto &gltfSampler = model.samplers[texture.sampler]; + + auto wrapMode = [](int mode) -> const char * { + switch (mode) { + case TINYGLTF_TEXTURE_WRAP_CLAMP_TO_EDGE: + return "clampToEdge"; + case TINYGLTF_TEXTURE_WRAP_MIRRORED_REPEAT: + return "mirror"; + default: + return "repeat"; + } + }; + + settings.wrapMode1 = wrapMode(gltfSampler.wrapS); + settings.wrapMode2 = wrapMode(gltfSampler.wrapT); + settings.filter = + (gltfSampler.magFilter == TINYGLTF_TEXTURE_FILTER_NEAREST + || gltfSampler.minFilter == TINYGLTF_TEXTURE_FILTER_NEAREST) + ? "nearest" + : "linear"; + return settings; +} + static SamplerRef importGLTFTexture(Scene &scene, const tinygltf::Model &model, int textureIndex, - TextureCache &cache, + ImageCache &cache, bool isLinear = false, - bool flipNormalMapY = false, const char *samplerName = nullptr, int texCoord = 0) { @@ -118,169 +194,42 @@ static SamplerRef importGLTFTexture(Scene &scene, return {}; const auto &image = model.images[texture.source]; - - std::string cacheKey = image.name.empty() + const std::string imageId = image.name.empty() ? "texture_"s + std::to_string(texture.source) : image.name; - // Include linear/sRGB info in cache key to avoid conflicts - if (isLinear) { - cacheKey += "_linear"; - } else { - cacheKey += "_srgb"; - } - - // Include normal map Y flip info in cache key - if (flipNormalMapY) { - cacheKey += "_yflip"; + if (image.image.empty()) { + logWarning("[import_GLTF] empty image data for texture %d", textureIndex); + return {}; } - auto dataArray = cache[cacheKey]; - - if (!dataArray.valid()) { - if (image.image.empty()) { - logWarning("[import_GLTF] empty image data for texture %d", textureIndex); - return {}; - } - - switch (image.pixel_type) { - case TINYGLTF_COMPONENT_TYPE_BYTE: { - if (!isLinear) - logWarning("[import_GLTF] signed byte textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_FIXED8 + (image.component - 1), image.width, image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE: { - if (isLinear) - dataArray = scene.createArray( - ANARI_UFIXED8 + (image.component - 1), image.width, image.height); - else - dataArray = - scene.createArray(ANARI_UFIXED8_R_SRGB + (image.component - 1), - image.width, - image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_SHORT: { - if (!isLinear) - logWarning("[import_GLTF] signed short textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_FIXED16 + (image.component - 1), image.width, image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT: { - if (!isLinear) - logWarning( - "[import_GLTF] unsigned short textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_UFIXED16 + (image.component - 1), image.width, image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_INT: { - if (!isLinear) - logWarning("[import_GLTF] signed int textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_FIXED32 + (image.component - 1), image.width, image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT: { - if (!isLinear) - logWarning("[import_GLTF] unsigned int textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_UFIXED32 + (image.component - 1), image.width, image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_FLOAT: { - if (!isLinear) - logWarning("[import_GLTF] float textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_FLOAT32 + (image.component - 1), image.width, image.height); - break; - } - case TINYGLTF_COMPONENT_TYPE_DOUBLE: { - if (!isLinear) - logWarning("[import_GLTF] double textures not supported in sRGB"); - dataArray = scene.createArray( - ANARI_FLOAT64 + (image.component - 1), image.width, image.height); - break; - } - default: { - logWarning("[import_GLTF] unsupported image component type texture: %d", - image.pixel_type); - return {}; - } - } - - auto *outData = dataArray->map(); - std::memcpy(outData, image.image.data(), image.image.size()); - dataArray->unmap(); + const auto elementType = gltfTexelType(image, isLinear, textureIndex); + if (elementType == ANARI_UNKNOWN) + return {}; - cache[cacheKey] = dataArray; - } + const ImageSource source{"gltf:"s + imageId, + isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB}; + // tinygltf decodes through stb, which hands back the picture's first row + // first whatever the container stored. + auto decoded = cache.acquireDecoded(source, + elementType, + size_t(image.width), + size_t(image.height), + RowOrder::TOP_DOWN, + image.image.data()); + if (!decoded) + return {}; - auto sampler = scene.createObject(tokens::sampler::image2D); - sampler->setParameterObject("image", *dataArray); + auto settings = gltfSamplerSettings(model, texture); const auto inAttribute = attributeNameForTexCoord(supportedTexCoordSet(texCoord, samplerName)); - sampler->setParameter("inAttribute", inAttribute.c_str()); - - // Apply sampler settings if available - if (texture.sampler >= 0 && texture.sampler < model.samplers.size()) { - const auto &gltfSampler = model.samplers[texture.sampler]; + settings.inAttribute = inAttribute.c_str(); - // Wrap mode - const char *wrapS = "repeat"; - const char *wrapT = "repeat"; + const std::string displayName = samplerName && samplerName[0] != '\0' + ? std::string(samplerName) + ":" + imageId + : imageId; - switch (gltfSampler.wrapS) { - case TINYGLTF_TEXTURE_WRAP_CLAMP_TO_EDGE: - wrapS = "clampToEdge"; - break; - case TINYGLTF_TEXTURE_WRAP_MIRRORED_REPEAT: - wrapS = "mirror"; - break; - case TINYGLTF_TEXTURE_WRAP_REPEAT: - wrapS = "repeat"; - break; - } - - switch (gltfSampler.wrapT) { - case TINYGLTF_TEXTURE_WRAP_CLAMP_TO_EDGE: - wrapT = "clampToEdge"; - break; - case TINYGLTF_TEXTURE_WRAP_MIRRORED_REPEAT: - wrapT = "mirror"; - break; - case TINYGLTF_TEXTURE_WRAP_REPEAT: - wrapT = "repeat"; - break; - } - - sampler->setParameter("wrapMode1", wrapS); - sampler->setParameter("wrapMode2", wrapT); - - // Filter mode - const char *filter = "linear"; - if (gltfSampler.magFilter == TINYGLTF_TEXTURE_FILTER_NEAREST - || gltfSampler.minFilter == TINYGLTF_TEXTURE_FILTER_NEAREST) { - filter = "nearest"; - } - sampler->setParameter("filter", filter); - } else { - sampler->setParameter("wrapMode1", "repeat"); - sampler->setParameter("wrapMode2", "repeat"); - sampler->setParameter("filter", "linear"); - } - - // Set sampler name to reflect the input type if provided - if (samplerName && samplerName[0] != '\0') { - std::string fullName = std::string(samplerName) + ":" + cacheKey; - sampler->setName(fullName.c_str()); - } else { - sampler->setName(cacheKey.c_str()); - } - return sampler; + return makeImageSampler(scene, decoded, displayName, settings); } static void applyNormalTextureScale(SamplerRef sampler, float scale) @@ -304,7 +253,7 @@ static std::vector importGLTFMaterials( // - KHR_materials_iridescence: iridescence factor, IOR, thickness std::vector materials; - TextureCache cache; + ImageCache cache(&scene); for (const auto &gltfMaterial : model.materials) { MaterialRef material; @@ -324,7 +273,6 @@ static std::vector importGLTFMaterials( pbr.baseColorTexture.index, cache, false, - false, "baseColor", pbr.baseColorTexture.texCoord)) { // Make this an opaque color. Opacity is handled below. @@ -345,7 +293,6 @@ static std::vector importGLTFMaterials( pbr.baseColorTexture.index, cache, true, - false, "opacity", pbr.baseColorTexture.texCoord)) { sampler->setParameter("outTransform", @@ -365,7 +312,6 @@ static std::vector importGLTFMaterials( pbr.metallicRoughnessTexture.index, cache, true, - false, "metallic", pbr.metallicRoughnessTexture.texCoord)) { // Metallic is in the blue channel for glTF @@ -386,7 +332,6 @@ static std::vector importGLTFMaterials( pbr.metallicRoughnessTexture.index, cache, true, - false, "roughness", pbr.metallicRoughnessTexture.texCoord)) { // Roughness is in the green channel for glTF @@ -406,7 +351,6 @@ static std::vector importGLTFMaterials( gltfMaterial.normalTexture.index, cache, true, - false, "normal", gltfMaterial.normalTexture.texCoord)) { float normalScale = gltfMaterial.normalTexture.scale; @@ -420,7 +364,6 @@ static std::vector importGLTFMaterials( gltfMaterial.occlusionTexture.index, cache, true, - false, "occlusion", gltfMaterial.occlusionTexture.texCoord)) { material->setParameterObject("occlusion", *sampler); @@ -445,7 +388,6 @@ static std::vector importGLTFMaterials( gltfMaterial.emissiveTexture.index, cache, false, - false, "emissive", gltfMaterial.emissiveTexture.texCoord)) { sampler->setParameter("outTransform", @@ -492,7 +434,6 @@ static std::vector importGLTFMaterials( transmissionTextureIndex, cache, true, - false, "transmission", transmissionTexCoord)) { sampler->setParameter("outTransform", @@ -539,7 +480,6 @@ static std::vector importGLTFMaterials( thicknessTextureIndex, cache, true, - false, "thickness", thicknessTexCoord)) { sampler->setParameter("outTransform", @@ -589,7 +529,6 @@ static std::vector importGLTFMaterials( clearcoatTextureIndex, cache, true, - false, "clearcoat", clearcoatTexCoord)) { sampler->setParameter("outTransform", @@ -616,7 +555,6 @@ static std::vector importGLTFMaterials( clearcoatRoughnessTextureIndex, cache, true, - false, "clearcoatRoughness", clearcoatRoughnessTexCoord)) { sampler->setParameter("outTransform", @@ -641,7 +579,6 @@ static std::vector importGLTFMaterials( clearcoatNormalTextureIndex, cache, true, - false, "clearcoatNormal", clearcoatNormalTexCoord)) { applyNormalTextureScale(sampler, clearcoatNormalScale); @@ -672,7 +609,6 @@ static std::vector importGLTFMaterials( specularTextureIndex, cache, true, - false, "specular", specularTexCoord)) { sampler->setParameter("outTransform", @@ -699,7 +635,6 @@ static std::vector importGLTFMaterials( specularColorTextureIndex, cache, false, - false, "specularColor", specularColorTexCoord)) { sampler->setParameter("outTransform", @@ -737,7 +672,6 @@ static std::vector importGLTFMaterials( sheenColorTextureIndex, cache, false, - false, "sheenColor", sheenColorTexCoord)) { sampler->setParameter("outTransform", @@ -764,7 +698,6 @@ static std::vector importGLTFMaterials( sheenRoughnessTextureIndex, cache, true, - false, "sheenRoughness", sheenRoughnessTexCoord)) { sampler->setParameter("outTransform", @@ -802,7 +735,6 @@ static std::vector importGLTFMaterials( iridescenceTextureIndex, cache, true, - false, "iridescence", iridescenceTexCoord)) { sampler->setParameter("outTransform", @@ -838,7 +770,6 @@ static std::vector importGLTFMaterials( iridescenceThicknessTextureIndex, cache, true, - false, "iridescenceThickness", iridescenceThicknessTexCoord)) { sampler->setParameter("outTransform", diff --git a/tsd/src/tsd/io/importers/import_OBJ.cpp b/tsd/src/tsd/io/importers/import_OBJ.cpp index a17dd2669..b1837f8a0 100644 --- a/tsd/src/tsd/io/importers/import_OBJ.cpp +++ b/tsd/src/tsd/io/importers/import_OBJ.cpp @@ -56,7 +56,7 @@ void import_OBJ(Scene &scene, std::vector materials; materials.resize(objdata.materials.size()); - TextureCache cache; + ImageCache cache(&scene); auto getMaterial = [&](size_t i) -> MaterialRef { auto &m = materials[i]; diff --git a/tsd/src/tsd/io/importers/import_PBRT.cpp b/tsd/src/tsd/io/importers/import_PBRT.cpp index 2447ef094..5d75a96d4 100644 --- a/tsd/src/tsd/io/importers/import_PBRT.cpp +++ b/tsd/src/tsd/io/importers/import_PBRT.cpp @@ -931,7 +931,7 @@ static BakedTexture bakeTexture(Scene &scene, const pbrt::Scene &pbrtScene, const std::string &textureName, const std::string &basePath, - TextureCache &texCache); + ImageCache &texCache); // Resolve a PBRT texture-or-constant slot ("rgb tex1" / "float tex1" / // "texture tex1"). Used by both `scale` and `mix`. @@ -940,7 +940,7 @@ static BakedTexture bakeTextureSlot(Scene &scene, const std::string ¶mName, const pbrt::Scene &pbrtScene, const std::string &basePath, - TextureCache &texCache) + ImageCache &texCache) { auto it = params.values.find(paramName); if (it == params.values.end()) { @@ -989,7 +989,7 @@ static BakedTexture bakeTexture(Scene &scene, const pbrt::Scene &pbrtScene, const std::string &textureName, const std::string &basePath, - TextureCache &texCache) + ImageCache &texCache) { auto texIt = pbrtScene.textures.find(textureName); if (texIt == pbrtScene.textures.end()) { @@ -1116,7 +1116,7 @@ static void resolveTexture(Scene &scene, const pbrt::MaterialDef &matDef, const pbrt::Scene &pbrtScene, const std::string &basePath, - TextureCache &texCache, + ImageCache &texCache, anari::DataType paramType = ANARI_FLOAT32_VEC3) { auto texName = matDef.params.getString(texParamName); @@ -1212,63 +1212,61 @@ static bool resolveImagemapChain(const pbrt::Scene &pbrtScene, static SamplerRef importHeightAsNormalMap(Scene &scene, const std::string &filepath, float heightScale, - TextureCache &texCache) + ImageCache &texCache) { - // Cache under a separate key so we don't collide with any value-domain + // Key under a separate id so this doesn't collide with any value-domain // sampler that may already exist for the same file. - const std::string cacheKey = filepath + "::normal"; - auto cached = texCache[cacheKey]; - - if (!cached.valid()) { - int w = 0, h = 0, channels = 0; - stbi_ldr_to_hdr_scale(1.f); - stbi_ldr_to_hdr_gamma(1.f); - float *raw = stbi_loadf(filepath.c_str(), &w, &h, &channels, 1); - if (!raw) { - logWarning( - "[import_PBRT] displacement: failed to load '%s'", filepath.c_str()); - return {}; - } + const ImageSource source{"pbrt:" + filepath + "::normal", + ColorSpace::LINEAR}; - constexpr float kBumpStrength = 16.f; - const float k = heightScale * kBumpStrength; - - auto arr = scene.createArray(ANARI_FLOAT32_VEC4, size_t(w), size_t(h)); - auto *out = arr->mapAs(); - for (int y = 0; y < h; ++y) { - for (int x = 0; x < w; ++x) { - const int xp = (x + 1) % w; - const int xm = (x - 1 + w) % w; - const int yp = std::min(y + 1, h - 1); - const int ym = std::max(y - 1, 0); - const float hx = raw[y * w + xp] - raw[y * w + xm]; - const float hy = raw[yp * w + x] - raw[ym * w + x]; - const float nx = -hx * k; - const float ny = -hy * k; - const float nz = 1.f; - const float invLen = 1.f / std::sqrt(nx * nx + ny * ny + nz * nz); - // Pack [-1,1] -> [0,1] (glTF normal-map convention). - out[size_t(y) * w + x] = float4(nx * invLen * 0.5f + 0.5f, - ny * invLen * 0.5f + 0.5f, - nz * invLen * 0.5f + 0.5f, - 1.f); - } - } - arr->unmap(); - stbi_image_free(raw); + if (auto cached = texCache.find(source)) + return makeImageSampler(scene, cached, fileOf(filepath) + "_bump"); - cached = arr; - texCache[cacheKey] = cached; + int w = 0, h = 0, channels = 0; + stbi_ldr_to_hdr_scale(1.f); + stbi_ldr_to_hdr_gamma(1.f); + float *raw = stbi_loadf(filepath.c_str(), &w, &h, &channels, 1); + if (!raw) { + logWarning( + "[import_PBRT] displacement: failed to load '%s'", filepath.c_str()); + return {}; } - auto sampler = scene.createObject(tokens::sampler::image2D); - sampler->setParameterObject("image", *cached); - sampler->setParameter("inAttribute", "attribute0"); - sampler->setParameter("wrapMode1", "repeat"); - sampler->setParameter("wrapMode2", "repeat"); - sampler->setParameter("filter", "linear"); - sampler->setName((fileOf(filepath) + "_bump").c_str()); - return sampler; + constexpr float kBumpStrength = 16.f; + const float k = heightScale * kBumpStrength; + + std::vector texels(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const int xp = (x + 1) % w; + const int xm = (x - 1 + w) % w; + const int yp = std::min(y + 1, h - 1); + const int ym = std::max(y - 1, 0); + const float hx = raw[y * w + xp] - raw[y * w + xm]; + const float hy = raw[yp * w + x] - raw[ym * w + x]; + const float nx = -hx * k; + const float ny = -hy * k; + const float nz = 1.f; + const float invLen = 1.f / std::sqrt(nx * nx + ny * ny + nz * nz); + // Pack [-1,1] -> [0,1] (glTF normal-map convention). + texels[size_t(y) * w + x] = float4(nx * invLen * 0.5f + 0.5f, + ny * invLen * 0.5f + 0.5f, + nz * invLen * 0.5f + 0.5f, + 1.f); + } + } + stbi_image_free(raw); + + // stb hands back the picture's first row first, and the gradient above was + // taken over that row order. + auto image = texCache.acquireDecoded(source, + ANARI_FLOAT32_VEC4, + size_t(w), + size_t(h), + RowOrder::TOP_DOWN, + texels.data()); + + return makeImageSampler(scene, image, fileOf(filepath) + "_bump"); } // Approximate normal-incidence reflectance for common PBRT named metal spectra. @@ -1431,7 +1429,7 @@ static MaterialRef convertMaterial(Scene &scene, const std::string &materialName, const std::string &interiorMedium, const std::string &basePath, - TextureCache &texCache, + ImageCache &texCache, std::map &matCache) { if (materialName.empty()) @@ -1898,7 +1896,7 @@ static MaterialRef applyShapeAlpha(Scene &scene, const pbrt::Shape &shape, const pbrt::Scene &pbrtScene, const std::string &basePath, - TextureCache &texCache) + ImageCache &texCache) { // PBRT v4: shape "alpha" can be a float (uniform cutoff/blend) or a // texture reference. The parser stores floats as a vector and @@ -2114,7 +2112,7 @@ static void convertLight(Scene &scene, const pbrt::LightDef &lightDef, LayerNodeRef parent, const std::string &basePath, - TextureCache &texCache, + ImageCache &texCache, float exposureScale = 1.f) { (void)texCache; @@ -2318,7 +2316,7 @@ void import_PBRT(Scene &scene, auto root = scene.insertChildNode( location ? location : scene.defaultLayer()->root(), file.c_str()); - TextureCache texCache; + ImageCache texCache(&scene); std::map matCache; std::map volumeFieldCache; diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 4bd040bdb..0bf61b0b4 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -349,7 +349,7 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); tsd::scene::Scene scene; - tsd::io::TextureCache cache; + tsd::io::ImageCache cache(&scene); WHEN("It is imported through the shared texture path") { diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index 7de534f9d..c76c6eeb1 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -171,7 +171,7 @@ SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") TiffFixture tiff("tsd_test_1x1_rgb8.tif"); tsd::scene::Scene scene; - tsd::io::TextureCache cache; + tsd::io::ImageCache cache(&scene); WHEN("It is imported as a linear texture") { @@ -227,7 +227,7 @@ SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") GreyAlphaTiffFixture tiff("tsd_test_1x1_greyalpha.tif"); tsd::scene::Scene scene; - tsd::io::TextureCache cache; + tsd::io::ImageCache cache(&scene); WHEN("It is imported as an sRGB texture") { From 82d68e1987c1effacac6c0e46930edb21192b357 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 16:36:38 -0500 Subject: [PATCH 21/78] fix(io): store decoded images in ANARI orientation Row 0 of a decoded array is now the picture's bottom row, so texture coordinate (0, 0) addresses the lower-left corner, and importers hand ANARI texture coordinates in ANARI's own convention. This is the commit that fixes OBJ and USD textures rendering vertically mirrored. Every part of it has to land together, because flipping the arrays without the uv changes would just move the bug: - ImageCache stores BOTTOM_UP. - glTF flips v on vertex.attributeN, and on the raw accessor data it re-reads for tangent generation. - ASSIMP drops aiProcess_FlipUVs; its own output is already v-up. - PBRT drops `v = 1 - v` on trianglemesh and plymesh, and its uv transform loses the compensating `(1 - vs - vd)` term. - OBJ and USD change nothing and become correct. - SceneToUSD reverses rows writing PNG and EXR, both top-down formats. - calcTangentsForTriangleMesh defaults flipTexCoordY to false. Block-compressed DDS is the exception: 4x4 blocks cannot be row-reversed without decoding and re-encoding, so its texels stay as authored and makeImageSampler composes a v-flip into the sampler's inTransform and inOffset. That is why makeImageSampler now owns those two parameters outright and takes the importer's uv transform through SamplerSettings -- ASSIMP's 14 sites, USD's uvTransform, and PBRT's uscale/vscale all set them afterwards before, which would have silently dropped the flip. PBRT's height-derived normal map negates its v gradient, since the row axis it was taken over is now reversed relative to the fetching v. The characterization suite is green. It also gained the two cases this commit created the need for: the block-compressed flip, including that it composes onto a caller's transform rather than replacing it, and ASSIMP, which had no coverage at all and whose uv handling changed here. TSD_USE_ASSIMP becomes PUBLIC so the test target can see it, matching TSD_USE_OIIO and TSD_USE_USD. Co-Authored-By: Claude Fable 5 --- .../0014-store-images-in-anari-orientation.md | 19 ++ tsd/src/tsd/io/CMakeLists.txt | 6 +- tsd/src/tsd/io/exporters/SceneToUSD.cpp | 84 ++++--- tsd/src/tsd/io/images/ImageCache.cpp | 48 +++- tsd/src/tsd/io/images/detail/decoders.cpp | 15 +- .../io/importers/detail/importer_common.cpp | 43 ++-- .../io/importers/detail/importer_common.hpp | 14 +- .../io/importers/detail/usd/UsdMaterials.cpp | 24 +- tsd/src/tsd/io/importers/import_ASSIMP.cpp | 208 +++++++++--------- tsd/src/tsd/io/importers/import_GLTF.cpp | 13 +- tsd/src/tsd/io/importers/import_PBRT.cpp | 69 +++--- tsd/tests/test_ImageImport.cpp | 142 +++++++++++- 12 files changed, 461 insertions(+), 224 deletions(-) create mode 100644 tsd/docs/adr/0014-store-images-in-anari-orientation.md diff --git a/tsd/docs/adr/0014-store-images-in-anari-orientation.md b/tsd/docs/adr/0014-store-images-in-anari-orientation.md new file mode 100644 index 000000000..5c1b043c4 --- /dev/null +++ b/tsd/docs/adr/0014-store-images-in-anari-orientation.md @@ -0,0 +1,19 @@ +# Store images in ANARI orientation + +A decoded image resident in a TSD Scene is stored in ANARI orientation: the +array's row 0 is the bottom row of the picture, so texture coordinate `(0, 0)` +addresses the lower-left corner. Importers hand ANARI texture coordinates in +ANARI's convention, converting from the source format's where they differ — +glTF and PBRT flip `v`, and ASSIMP simply stops asking for `aiProcess_FlipUVs`. +Decoders declare the row order their library produced and `ImageCache` +normalizes; no importer flips texels itself. Previously each of seven decode +paths carried its own unstated assumption, and the assumptions cancelled for +glTF, ASSIMP, and PBRT but not for OBJ and USD, whose textures rendered +mirrored. Two consequences follow from the contract: anything writing a scene +array back out as a top-down image format must reverse its rows, which +`SceneToUSD` now does for PNG and EXR; and block-compressed DDS, whose 4×4 +blocks cannot be row-reversed without decoding and re-encoding, stays as +authored and instead gets a `v`-flip composed into its sampler's +`inTransform`/`inOffset` — which is why `makeImageSampler` owns those two +parameters outright and takes the importer's own uv transform through +`SamplerSettings`. diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index a64d12647..43e9b1774 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -114,12 +114,14 @@ project_link_libraries(PRIVATE tsd::nanovdb) ## Setup optional dependencies ## +# PUBLIC so the test target can assert on the ASSIMP importer only where it +# exists, the same widening TSD_USE_OIIO and TSD_USE_USD make below. if (TSD_USE_ASSIMP) find_package(assimp REQUIRED) - project_compile_definitions(PRIVATE -DTSD_USE_ASSIMP=1) + project_compile_definitions(PUBLIC -DTSD_USE_ASSIMP=1) project_link_libraries(PRIVATE assimp::assimp) else() - project_compile_definitions(PRIVATE -DTSD_USE_ASSIMP=0) + project_compile_definitions(PUBLIC -DTSD_USE_ASSIMP=0) endif() if (TSD_USE_HDF5) diff --git a/tsd/src/tsd/io/exporters/SceneToUSD.cpp b/tsd/src/tsd/io/exporters/SceneToUSD.cpp index 77f383bd0..0b36784d5 100644 --- a/tsd/src/tsd/io/exporters/SceneToUSD.cpp +++ b/tsd/src/tsd/io/exporters/SceneToUSD.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,25 @@ pxr::SdfPath allocateUniquePath( return newPath; } +// Scene arrays are in ANARI orientation -- row 0 is the picture's bottom row +// -- while PNG and EXR are both top-down formats, so a texture written +// straight out would come back mirrored. See +// docs/adr/0014-store-images-in-anari-orientation.md. +static std::vector topDownRowsOf(const Array *image) +{ + const auto width = image->dim(0); + const auto height = image->dim(1); + const auto rowBytes = width * image->elementSize(); + + std::vector rows(rowBytes * height); + const auto *src = static_cast(image->data()); + for (size_t r = 0; r < height; ++r) + std::memcpy(rows.data() + r * rowBytes, + src + (height - 1 - r) * rowBytes, + rowBytes); + return rows; +} + static std::filesystem::path tsdSamplerToFile( pxr::UsdStageRefPtr &stage, const Sampler *sampler) { @@ -160,24 +180,25 @@ static std::filesystem::path tsdSamplerToFile( outputBasePath += ".exr"; int channels = (format == ANARI_FLOAT32_VEC3) ? 3 : 4; - textureWriteQueue.push_back(std::async([=]() -> std::string { - const char *err = nullptr; - int ret = SaveEXR(reinterpret_cast(image->data()), - width, - height, - channels, - 0, - outputBasePath.string().c_str(), - &err); - if (ret != TINYEXR_SUCCESS && err) { - auto result = - "Failed to save "s + outputBasePath.string() + " : " + err; - FreeEXRErrorMessage(err); - return result; - } else { - return "Saved "s + outputBasePath.string(); - } - })); + textureWriteQueue.push_back( + std::async([=, rows = topDownRowsOf(image)]() -> std::string { + const char *err = nullptr; + int ret = SaveEXR(reinterpret_cast(rows.data()), + width, + height, + channels, + 0, + outputBasePath.string().c_str(), + &err); + if (ret != TINYEXR_SUCCESS && err) { + auto result = + "Failed to save "s + outputBasePath.string() + " : " + err; + FreeEXRErrorMessage(err); + return result; + } else { + return "Saved "s + outputBasePath.string(); + } + })); } else { // Write as PNG for LDR data outputBasePath += ".png"; @@ -191,19 +212,20 @@ static std::filesystem::path tsdSamplerToFile( channels = 1; if (channels > 0) { - textureWriteQueue.push_back(std::async([=]() -> std::string { - int ret = stbi_write_png(outputBasePath.string().c_str(), - width, - height, - channels, - image->data(), - width * channels); - if (!ret) { - return "Failed to save "s + outputBasePath.string(); - } else { - return "Saved "s + outputBasePath.string(); - } - })); + textureWriteQueue.push_back( + std::async([=, rows = topDownRowsOf(image)]() -> std::string { + int ret = stbi_write_png(outputBasePath.string().c_str(), + width, + height, + channels, + rows.data(), + width * channels); + if (!ret) { + return "Failed to save "s + outputBasePath.string(); + } else { + return "Saved "s + outputBasePath.string(); + } + })); } } } else if (sampler->subtype() == tokens::sampler::compressedImage2D) { diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp index 298af3ff4..52a840835 100644 --- a/tsd/src/tsd/io/images/ImageCache.cpp +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -26,11 +26,11 @@ namespace { // The row order a decoded image is stored in once it is resident in a Scene. // -// This is what every decoder is normalized to and the only place in the tree -// that decides orientation. It is TOP_DOWN here because that is what all seven -// decode paths produced before this component existed; docs/adr/ records the -// move to ANARI orientation. -constexpr RowOrder SCENE_ROW_ORDER = RowOrder::TOP_DOWN; +// ANARI orientation: row 0 is the bottom row of the picture, so texture +// coordinate (0, 0) addresses the lower-left corner. This is what every +// decoder is normalized to and the only place in the tree that decides +// orientation. See docs/adr/0014-store-images-in-anari-orientation.md. +constexpr RowOrder SCENE_ROW_ORDER = RowOrder::BOTTOM_UP; std::string keyOf(const ImageSource &source) { @@ -61,6 +61,20 @@ void normalizeRowOrder(detail::DecodedImage &image) image.rowOrder = SCENE_ROW_ORDER; } +// Compose `v -> 1 - v` onto a sampler's uv transform, applied after whatever +// the importer authored: the fetch becomes flip(T*uv + offset). Premultiplying +// by diag(1, -1, 1, 1) negates the transform's v row whatever the caller put +// there, and the +1 lands in the offset. +void composeVFlip(tsd::math::mat4 &transform, tsd::math::float4 &offset) +{ + const tsd::math::mat4 flip{tsd::math::float4(1.f, 0.f, 0.f, 0.f), + tsd::math::float4(0.f, -1.f, 0.f, 0.f), + tsd::math::float4(0.f, 0.f, 1.f, 0.f), + tsd::math::float4(0.f, 0.f, 0.f, 1.f)}; + transform = tsd::math::mul(flip, transform); + offset = tsd::math::mul(flip, offset) + tsd::math::float4(0.f, 1.f, 0.f, 0.f); +} + } // namespace ImageCache::ImageCache(Scene *scene) : m_scene(scene) {} @@ -73,14 +87,13 @@ Scene *ImageCache::scene() const Image ImageCache::acquire(const ImageSource &source) { auto resolved = source; - resolved.colorSpace = - detail::colorSpaceForFile(source.id, source.colorSpace); + resolved.colorSpace = detail::colorSpaceForFile(source.id, source.colorSpace); if (auto *cached = lookup(resolved)) return *cached; - return store(resolved, - detail::decodeImageFile(resolved.id, resolved.colorSpace)); + return store( + resolved, detail::decodeImageFile(resolved.id, resolved.colorSpace)); } Image ImageCache::acquire(const ImageSource &source, @@ -200,9 +213,20 @@ SamplerRef makeImageSampler(Scene &scene, sampler->setParameter("wrapMode1", settings.wrapMode1); sampler->setParameter("wrapMode2", settings.wrapMode2); sampler->setParameter("filter", settings.filter); - if (settings.hasUvTransform) { - sampler->setParameter("inTransform", settings.uvTransform); - sampler->setParameter("inOffset", settings.uvOffset); + + // Block-compressed texels are the one thing normalizeRowOrder cannot + // reverse, so they are still stored as the file authored them -- top-down, + // against the bottom-up coordinates every importer now hands ANARI. Undo + // that here, composed onto the caller's own transform rather than replacing + // it, which is why makeImageSampler owns inTransform/inOffset outright. + auto uvTransform = settings.uvTransform; + auto uvOffset = settings.uvOffset; + if (image.blockCompressed) + composeVFlip(uvTransform, uvOffset); + + if (settings.hasUvTransform || image.blockCompressed) { + sampler->setParameter("inTransform", uvTransform); + sampler->setParameter("inOffset", uvOffset); } sampler->setName(fileOf(displayName).c_str()); diff --git a/tsd/src/tsd/io/images/detail/decoders.cpp b/tsd/src/tsd/io/images/detail/decoders.cpp index bc5e9f92c..734879b85 100644 --- a/tsd/src/tsd/io/images/detail/decoders.cpp +++ b/tsd/src/tsd/io/images/detail/decoders.cpp @@ -64,8 +64,12 @@ DecodedImage decodeStb( stbi_ldr_to_hdr_scale(1.0f); stbi_ldr_to_hdr_gamma(colorSpace == ColorSpace::LINEAR ? 1.0f : 2.2f); - float *decoded = stbi_loadf_from_memory( - static_cast(data), int(numBytes), &width, &height, &n, 0); + float *decoded = stbi_loadf_from_memory(static_cast(data), + int(numBytes), + &width, + &height, + &n, + 0); if (!decoded) { logError("[decodeImage] failed to decode image '%s'", id); @@ -84,8 +88,8 @@ DecodedImage decodeStb( // stb hands back the picture's first row first, whatever the container's own // storage order was -- it undoes BMP's and TGA's bottom-up layouts itself. image.rowOrder = RowOrder::TOP_DOWN; - const size_t numBytesOut = size_t(width) * size_t(height) * size_t(n) - * sizeof(float); + const size_t numBytesOut = + size_t(width) * size_t(height) * size_t(n) * sizeof(float); image.texels.assign(reinterpret_cast(decoded), reinterpret_cast(decoded) + numBytesOut); @@ -307,8 +311,7 @@ DecodedImage decodeOiio(const std::string &path, ColorSpace colorSpace) } if (colorSpace == ColorSpace::SRGB && fileIsIntegral) { - applyGamma22InPlace( - texels, size_t(width) * size_t(height), numChannels); + applyGamma22InPlace(texels, size_t(width) * size_t(height), numChannels); } return image; diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 06978954c..5b2309d59 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -101,7 +101,7 @@ tsd::scene::ArrayRef readArray( return retval; } -// Texture import shims ///////////////////////////////////////////////////////// +// Texture import shims /////////////////////////////////////////////////////// // These forward to tsd::io::images, which owns decoding, orientation, keying, // and lifetime for every image in the tree. They exist so the ~20 importer @@ -116,8 +116,11 @@ ColorSpace colorSpaceOf(bool isLinear) } // namespace -SamplerRef importTexture( - Scene &scene, std::string filepath, ImageCache &cache, bool isLinear) +SamplerRef importTexture(Scene &scene, + std::string filepath, + ImageCache &cache, + bool isLinear, + const SamplerSettings &settings) { std::transform( filepath.begin(), filepath.end(), filepath.begin(), [](char c) { @@ -125,7 +128,7 @@ SamplerRef importTexture( }); auto image = cache.acquire({filepath, colorSpaceOf(isLinear)}); - return makeImageSampler(scene, image, filepath); + return makeImageSampler(scene, image, filepath, settings); } SamplerRef importTextureFromMemory(Scene &scene, @@ -135,11 +138,12 @@ SamplerRef importTextureFromMemory(Scene &scene, size_t numBytes, ImageCache &cache, bool isLinear, - const std::string &formatHint) + const std::string &formatHint, + const SamplerSettings &settings) { auto image = cache.acquire( {cacheKey, colorSpaceOf(isLinear)}, data, numBytes, formatHint); - return makeImageSampler(scene, image, displayName); + return makeImageSampler(scene, image, displayName, settings); } SamplerRef importRawTexture2D(Scene &scene, @@ -149,7 +153,8 @@ SamplerRef importRawTexture2D(Scene &scene, size_t width, size_t height, ImageCache &cache, - bool isLinear) + bool isLinear, + const SamplerSettings &settings) { auto image = cache.acquireDecoded({cacheKey, colorSpaceOf(isLinear)}, isLinear ? ANARI_UFIXED8_VEC4 : ANARI_UFIXED8_RGBA_SRGB, @@ -157,10 +162,9 @@ SamplerRef importRawTexture2D(Scene &scene, height, RowOrder::TOP_DOWN, data); - return makeImageSampler(scene, image, displayName); + return makeImageSampler(scene, image, displayName, settings); } - SamplerRef makeDefaultColorMapSampler(Scene &scene, const float2 &range) { auto samplerImageArray = scene.createArray(ANARI_FLOAT32_VEC4, 3); @@ -394,7 +398,7 @@ static core::TransferFunction importParaViewTransferFunction( filepath.c_str()); return {}; } else if (const auto arrayStart = jsonContent.find("[", rgbPointsPos); - arrayStart == std::string::npos) { + arrayStart == std::string::npos) { logError( "[importParaViewTransferFunction] Invalid RGBPoints format in file: %s", filepath.c_str()); @@ -479,7 +483,7 @@ static core::TransferFunction importParaViewTransferFunction( std::istringstream opacitySS(opacityContent); for (std::string opacityToken; - std::getline(opacitySS, opacityToken, ',');) { + std::getline(opacitySS, opacityToken, ',');) { // Trim whitespace if (const auto first = opacityToken.find_first_not_of(" \t\n\r"); first != std::string::npos) { @@ -607,7 +611,7 @@ std::vector loadUserColorMaps( std::vector files; for (fs::directory_iterator it(directory, ec), end; !ec && it != end; - it.increment(ec)) { + it.increment(ec)) { const auto &entry = *it; if (!entry.is_regular_file(ec)) continue; @@ -624,17 +628,17 @@ std::vector loadUserColorMaps( ec.message().c_str()); } - std::sort(files.begin(), files.end(), [](const fs::path &a, - const fs::path &b) { - return a.stem().string() < b.stem().string(); - }); + std::sort( + files.begin(), files.end(), [](const fs::path &a, const fs::path &b) { + return a.stem().string() < b.stem().string(); + }); std::vector colorMaps; for (const auto &file : files) { auto tfn = importTransferFunction(file.string()); if (tfn.colorPoints.size() < 2) { - logWarning("[loadUserColorMaps] Skipping color map '%s'", - file.string().c_str()); + logWarning( + "[loadUserColorMaps] Skipping color map '%s'", file.string().c_str()); continue; } @@ -643,7 +647,8 @@ std::vector loadUserColorMaps( colorMap.path = file; colorMap.colorPoints = std::move(tfn.colorPoints); - auto existing = std::find_if(colorMaps.begin(), colorMaps.end(), + auto existing = std::find_if(colorMaps.begin(), + colorMaps.end(), [&](const UserColorMap &other) { return other.name == colorMap.name; }); if (existing != colorMaps.end()) { logStatus("[loadUserColorMaps] Replaced color map '%s' from '%s'", diff --git a/tsd/src/tsd/io/importers/detail/importer_common.hpp b/tsd/src/tsd/io/importers/detail/importer_common.hpp index 7bb7c43f2..487f998cc 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.hpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.hpp @@ -32,7 +32,8 @@ tsd::scene::ArrayRef readArray( tsd::scene::SamplerRef importTexture(tsd::scene::Scene &scene, std::string filepath, ImageCache &cache, - bool isLinear = false); + bool isLinear = false, + const SamplerSettings &settings = {}); tsd::scene::SamplerRef importTextureFromMemory(tsd::scene::Scene &scene, const std::string &cacheKey, const std::string &displayName, @@ -40,7 +41,8 @@ tsd::scene::SamplerRef importTextureFromMemory(tsd::scene::Scene &scene, size_t numBytes, ImageCache &cache, bool isLinear = false, - const std::string &formatHint = ""); + const std::string &formatHint = "", + const SamplerSettings &settings = {}); tsd::scene::SamplerRef importRawTexture2D(tsd::scene::Scene &scene, const std::string &cacheKey, const std::string &displayName, @@ -48,7 +50,8 @@ tsd::scene::SamplerRef importRawTexture2D(tsd::scene::Scene &scene, size_t width, size_t height, ImageCache &cache, - bool isLinear = false); + bool isLinear = false, + const SamplerSettings &settings = {}); tsd::scene::SamplerRef makeDefaultColorMapSampler( tsd::scene::Scene &scene, const tsd::math::float2 &range); @@ -69,7 +72,10 @@ bool calcTangentsForTriangleMesh(const tsd::math::uint3 *indices, tsd::math::float4 *tangents, size_t numIndices, size_t numVertices, - bool flipTexCoordY = true, + // Importers hand ANARI v-up coordinates, which is what mikktspace wants, + // so this only exists for a caller reading a source format's raw v-down + // coordinates directly. + bool flipTexCoordY = false, bool faceVaryingTangents = false); #if TSD_USE_VTK diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index eeeaccc39..65b246556 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -768,8 +768,21 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, if (!isAbsolute(file)) file = ctx.basePath + file; + // Everything the binding varies goes in before the sampler is built: + // makeImageSampler owns inTransform/inOffset, because a block-compressed + // image needs a v-flip composed into them that a later setParameter here + // would drop. + const auto wrapS = wrapModeOf(walker, texturePath, "wrapS"); + const auto wrapT = wrapModeOf(walker, texturePath, "wrapT"); + SamplerSettings settings; + settings.wrapMode1 = wrapS.c_str(); + settings.wrapMode2 = wrapT.c_str(); + settings.uvTransform = uvTransformOfTexture(walker, texturePath); + settings.hasUvTransform = settings.uvTransform != math::IDENTITY_MAT4; + const bool isLinear = textureIsLinear(walker, texturePath, colorRole); - auto sampler = importTexture(ctx.scene, file, ctx.textureCache, isLinear); + auto sampler = + importTexture(ctx.scene, file, ctx.textureCache, isLinear, settings); if (!sampler) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -778,15 +791,6 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, return false; } - sampler->setParameter( - "wrapMode1", wrapModeOf(walker, texturePath, "wrapS").c_str()); - sampler->setParameter( - "wrapMode2", wrapModeOf(walker, texturePath, "wrapT").c_str()); - sampler->setParameter("inAttribute", "attribute0"); - const auto uvTransform = uvTransformOfTexture(walker, texturePath); - if (uvTransform != math::IDENTITY_MAT4) - sampler->setParameter("inTransform", uvTransform); - material->setParameterObject(Token(tsdName), *sampler); return true; }; diff --git a/tsd/src/tsd/io/importers/import_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index 60d9818c7..8d3619146 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -5,10 +5,10 @@ #define TSD_USE_ASSIMP 1 #endif +#include #include "tsd/core/Logging.hpp" #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/importer_common.hpp" -#include #if TSD_USE_ASSIMP // assimp #include @@ -23,20 +23,21 @@ using namespace tsd::core; #if TSD_USE_ASSIMP -static SamplerRef importEmbeddedTexture( - Scene &scene, +static SamplerRef importEmbeddedTexture(Scene &scene, const aiTexture *embeddedTexture, int embeddedTextureIndex, ImageCache &cache, - bool isLinear) + bool isLinear, + const SamplerSettings &settings) { const std::string filename = embeddedTexture->mFilename.C_Str(); const std::string textureId = embeddedTextureIndex >= 0 ? "assimp://embedded/" + std::to_string(embeddedTextureIndex) : "assimp://embedded-name/" + filename; const std::string displayName = filename.empty() - ? (embeddedTextureIndex >= 0 ? "embedded_" + std::to_string(embeddedTextureIndex) - : "embedded_texture") + ? (embeddedTextureIndex >= 0 + ? "embedded_" + std::to_string(embeddedTextureIndex) + : "embedded_texture") : filename; const bool validTexture = embeddedTexture->pcData != nullptr; logDebug( @@ -47,8 +48,8 @@ static SamplerRef importEmbeddedTexture( embeddedTexture->achFormatHint); if (!validTexture) { - logWarning("[import_ASSIMP] invalid embedded texture '%s'", - displayName.c_str()); + logWarning( + "[import_ASSIMP] invalid embedded texture '%s'", displayName.c_str()); return {}; } @@ -60,9 +61,11 @@ static SamplerRef importEmbeddedTexture( embeddedTexture->mWidth, cache, isLinear, - embeddedTexture->achFormatHint); + embeddedTexture->achFormatHint, + settings); if (!tex) { - logWarning("[import_ASSIMP] failed to decode embedded texture '%s' (hint: %s)", + logWarning( + "[import_ASSIMP] failed to decode embedded texture '%s' (hint: %s)", displayName.c_str(), embeddedTexture->achFormatHint); } @@ -71,7 +74,8 @@ static SamplerRef importEmbeddedTexture( std::vector rgba( size_t(embeddedTexture->mWidth) * size_t(embeddedTexture->mHeight) * 4); - for (size_t i = 0; i < size_t(embeddedTexture->mWidth) * embeddedTexture->mHeight; + for (size_t i = 0; + i < size_t(embeddedTexture->mWidth) * embeddedTexture->mHeight; ++i) { const auto &src = embeddedTexture->pcData[i]; rgba[i * 4 + 0] = src.r; @@ -87,7 +91,8 @@ static SamplerRef importEmbeddedTexture( embeddedTexture->mWidth, embeddedTexture->mHeight, cache, - isLinear); + isLinear, + settings); } static std::vector importASSIMPSurfaces(Scene &scene, @@ -109,29 +114,27 @@ static std::vector importASSIMPSurfaces(Scene &scene, auto vertexNormalArray = mesh->HasNormals() ? scene.createArray(ANARI_FLOAT32_VEC3, numVertices) : ArrayRef{}; - float3 *outNormals = vertexNormalArray ? vertexNormalArray->mapAs() - : nullptr; + float3 *outNormals = + vertexNormalArray ? vertexNormalArray->mapAs() : nullptr; auto vertexTexCoordArray = mesh->HasTextureCoords(0 /*texcord set*/) ? scene.createArray(ANARI_FLOAT32_VEC2, numVertices) : ArrayRef{}; - float2 *outTexCoords = vertexTexCoordArray - ? vertexTexCoordArray->mapAs() - : nullptr; + float2 *outTexCoords = + vertexTexCoordArray ? vertexTexCoordArray->mapAs() : nullptr; auto vertexTangentArray = mesh->HasTangentsAndBitangents() ? scene.createArray(ANARI_FLOAT32_VEC4, numVertices) : ArrayRef{}; - float4 *outTangents = vertexTangentArray - ? vertexTangentArray->mapAs() - : nullptr; + float4 *outTangents = + vertexTangentArray ? vertexTangentArray->mapAs() : nullptr; // TODO: test for AI_MAX_NUMBER_OF_COLOR_SETS, import all.. auto vertexColorArray = mesh->mColors[0] ? scene.createArray(ANARI_FLOAT32_VEC4, numVertices) : ArrayRef{}; - float4 *outColors = vertexColorArray ? vertexColorArray->mapAs() - : nullptr; + float4 *outColors = + vertexColorArray ? vertexColorArray->mapAs() : nullptr; for (unsigned j = 0; j < mesh->mNumVertices; ++j) { aiVector3D v = mesh->mVertices[j]; @@ -254,39 +257,47 @@ static std::vector importASSIMPMaterials( MaterialRef m; auto loadTexture = [&](const aiString &texName, - bool isLinear = false) -> SamplerRef { + bool isLinear = false, + const SamplerSettings &settings = {}) -> SamplerRef { SamplerRef tex; if (texName.length != 0) { auto [embeddedTexture, embeddedTextureIndex] = a_scene->GetEmbeddedTextureAndIndex(texName.C_Str()); if (embeddedTexture) { - tex = importEmbeddedTexture( - scene, embeddedTexture, embeddedTextureIndex, cache, isLinear); + tex = importEmbeddedTexture(scene, + embeddedTexture, + embeddedTextureIndex, + cache, + isLinear, + settings); + } else { + tex = importTexture( + scene, basePath + texName.C_Str(), cache, isLinear, settings); } - else - tex = - importTexture(scene, basePath + texName.C_Str(), cache, isLinear); } return tex; }; - auto getTextureUVTransform = [&](const char *pKey, - unsigned int type, - unsigned int index = 0) -> mat4 { + // The uv transform goes to loadTexture rather than onto the returned + // sampler, because makeImageSampler owns inTransform/inOffset: a + // block-compressed image needs a v-flip composed into them, and setting + // them here afterwards would drop it. + auto getTextureUVSettings = [&](const char *pKey, + unsigned int type, + unsigned int index = 0) -> SamplerSettings { + SamplerSettings settings; aiUVTransform uvTransform; if (aiGetMaterialUVTransform(assimpMat, pKey, type, index, &uvTransform) == AI_SUCCESS) { - return mat4( - {uvTransform.mScaling.x, 0.f, 0.f, uvTransform.mTranslation.x}, - {0.f, uvTransform.mScaling.y, 0.f, uvTransform.mTranslation.y}, - {0.f, 0.f, 1.f, 0.f}, - {0.0f, 0.0f, 0.f, 1.f}); + settings.uvTransform = + mat4({uvTransform.mScaling.x, 0.f, 0.f, uvTransform.mTranslation.x}, + {0.f, uvTransform.mScaling.y, 0.f, uvTransform.mTranslation.y}, + {0.f, 0.f, 1.f, 0.f}, + {0.0f, 0.0f, 0.f, 1.f}); } - return {{1.0f, 0.0f, 0.0f, 0.0f}, - {0.0f, 1.0f, 0.0f, 0.0f}, - {0.0f, 0.0f, 1.0f, 0.0f}, - {0.0f, 0.0f, 0.0f, 1.0f}}; + settings.hasUvTransform = true; + return settings; }; if (matType == aiShadingMode_PBR_BRDF) { @@ -296,10 +307,10 @@ static std::vector importASSIMPMaterials( if (aiString baseColorTexture; assimpMat->GetTexture(AI_MATKEY_BASE_COLOR_TEXTURE, &baseColorTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(baseColorTexture); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_BASE_COLOR, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_BASE_COLOR, 0)); + if (auto sampler = loadTexture(baseColorTexture, false, settings); + sampler) { m->setParameterObject("baseColor", *sampler); } } else if (aiColor3D baseColor; @@ -312,10 +323,10 @@ static std::vector importASSIMPMaterials( if (aiString metallicTexture; assimpMat->GetTexture(AI_MATKEY_METALLIC_TEXTURE, &metallicTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(metallicTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_METALNESS, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_METALNESS, 0)); + if (auto sampler = loadTexture(metallicTexture, true, settings); + sampler) { // - Metallic is blue sampler->setParameter("outTransform", mat4({0, 0, 0, 0}, {0, 0, 0, 0}, {1, 0, 0, 0}, {0, 0, 0, 1})); @@ -330,11 +341,11 @@ static std::vector importASSIMPMaterials( if (aiString roughnessTexture; assimpMat->GetTexture(AI_MATKEY_ROUGHNESS_TEXTURE, &roughnessTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(roughnessTexture, true); sampler) { + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_DIFFUSE_ROUGHNESS, 0)); + if (auto sampler = loadTexture(roughnessTexture, true, settings); + sampler) { // Map red to red/blue as expected by our gltf pbr implementation - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_DIFFUSE_ROUGHNESS, 0)); - sampler->setParameter("inTransform", tx); // - Roughness is green sampler->setParameter("outTransform", mat4({0, 0, 0, 0}, {1, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 1})); @@ -352,24 +363,24 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( AI_MATKEY_ANISOTROPY_TEXTURE, &anisotropyTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(anisotropyTexture, true); sampler) { + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_ANISOTROPY, 0)); + if (auto sampler = loadTexture(anisotropyTexture, true, settings); + sampler) { // Map red to red/green/blue as expected by our gltf pbr // implementation - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_ANISOTROPY, 0)); - sampler->setParameter("inTransform", tx); // - Tangent/bitangent Direction is red/green // and remap from [0:1] to [-1:1] sampler->setParameter("outTransform", mat4({2, 0, 0, 0}, {0, 2, 0, 0}, {0, 0, 0, 0}, {-1, -1, 0, 1})); m->setParameterObject("anisotropyDirection", *sampler); } - if (auto sampler = loadTexture(anisotropyTexture, true); sampler) { + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_ANISOTROPY, 0)); + if (auto sampler = loadTexture(anisotropyTexture, true, settings); + sampler) { // Map red to red/green/blue as expected by our gltf pbr // implementation - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_ANISOTROPY, 0)); - sampler->setParameter("inTransform", tx); // - Strength is blue sampler->setParameter("outTransform", mat4({0, 0, 0, 0}, {0, 0, 0, 0}, {1, 0, 0, 0}, {0, 0, 0, 1})); @@ -410,10 +421,10 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( AI_MATKEY_SHEEN_COLOR_TEXTURE, &sheenColorTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(sheenColorTexture); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_SHEEN, 0)); - sampler->setParameter("inTransform", tx); + auto settings = + getTextureUVSettings(AI_MATKEY_UVTRANSFORM(aiTextureType_SHEEN, 0)); + if (auto sampler = loadTexture(sheenColorTexture, false, settings); + sampler) { m->setParameterObject("sheenColor", *sampler); } } else if (aiColor3D sheenColor; @@ -426,10 +437,10 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( AI_MATKEY_SHEEN_ROUGHNESS_TEXTURE, &sheenRoughnessTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(sheenRoughnessTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_SHEEN, 1)); - sampler->setParameter("inTransform", tx); + auto settings = + getTextureUVSettings(AI_MATKEY_UVTRANSFORM(aiTextureType_SHEEN, 1)); + if (auto sampler = loadTexture(sheenRoughnessTexture, true, settings); + sampler) { m->setParameterObject("sheenRoughness", *sampler); } } else if (ai_real sheenRoughnessFactor; @@ -443,10 +454,10 @@ static std::vector importASSIMPMaterials( if (aiString clearcoatTexture; assimpMat->GetTexture(AI_MATKEY_CLEARCOAT_TEXTURE, &clearcoatTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(clearcoatTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_CLEARCOAT, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_CLEARCOAT, 0)); + if (auto sampler = loadTexture(clearcoatTexture, true, settings); + sampler) { m->setParameterObject("clearcoat", *sampler); } } else if (ai_real clearcoatFactor; @@ -459,11 +470,11 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( AI_MATKEY_CLEARCOAT_ROUGHNESS_TEXTURE, &clearcoatRoughnessTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(clearcoatRoughnessTexture, true); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_CLEARCOAT, 1)); + if (auto sampler = + loadTexture(clearcoatRoughnessTexture, true, settings); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_CLEARCOAT, 1)); - sampler->setParameter("inTransform", tx); m->setParameterObject("clearcoatRoughness", *sampler); } } else if (ai_real clearcoatRoughnessFactor; @@ -478,10 +489,10 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( AI_MATKEY_CLEARCOAT_NORMAL_TEXTURE, &clearcoatNormalTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(clearcoatNormalTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_CLEARCOAT, 2)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_CLEARCOAT, 2)); + if (auto sampler = loadTexture(clearcoatNormalTexture, true, settings); + sampler) { m->setParameterObject("clearcoatNormal", *sampler); } } @@ -490,10 +501,10 @@ static std::vector importASSIMPMaterials( if (aiString emissiveTexture; assimpMat->GetTexture(aiTextureType_EMISSIVE, 0, &emissiveTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(emissiveTexture); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_EMISSIVE, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_EMISSIVE, 0)); + if (auto sampler = loadTexture(emissiveTexture, false, settings); + sampler) { m->setParameterObject("emissive", *sampler); } } else if (aiColor3D emissiveColor; @@ -513,10 +524,10 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( aiTextureType_AMBIENT_OCCLUSION, 0, &occlusionTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(occlusionTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_AMBIENT_OCCLUSION, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_AMBIENT_OCCLUSION, 0)); + if (auto sampler = loadTexture(occlusionTexture, true, settings); + sampler) { m->setParameterObject("occlusion", *sampler); } } @@ -525,10 +536,10 @@ static std::vector importASSIMPMaterials( if (aiString normalTexture; assimpMat->GetTexture(aiTextureType_NORMALS, 0, &normalTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(normalTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_NORMALS, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_NORMALS, 0)); + if (auto sampler = loadTexture(normalTexture, true, settings); + sampler) { m->setParameterObject("normal", *sampler); } } @@ -543,10 +554,10 @@ static std::vector importASSIMPMaterials( assimpMat->GetTexture( AI_MATKEY_TRANSMISSION_TEXTURE, &transmissionTexture) == AI_SUCCESS) { - if (auto sampler = loadTexture(transmissionTexture, true); sampler) { - auto tx = getTextureUVTransform( - AI_MATKEY_UVTRANSFORM(aiTextureType_TRANSMISSION, 0)); - sampler->setParameter("inTransform", tx); + auto settings = getTextureUVSettings( + AI_MATKEY_UVTRANSFORM(aiTextureType_TRANSMISSION, 0)); + if (auto sampler = loadTexture(transmissionTexture, true, settings); + sampler) { m->setParameterObject("transmission", *sampler); } } @@ -690,8 +701,9 @@ void import_ASSIMP(Scene &scene, Assimp::Importer importer; - auto importFlags = aiProcess_Triangulate | aiProcess_JoinIdenticalVertices - | aiProcess_FlipUVs; + // No aiProcess_FlipUVs: assimp's own output is already v-up, which is what + // ANARI wants. See docs/adr/0014-store-images-in-anari-orientation.md. + auto importFlags = aiProcess_Triangulate | aiProcess_JoinIdenticalVertices; if (flatten) importFlags |= aiProcess_PreTransformVertices; diff --git a/tsd/src/tsd/io/importers/import_GLTF.cpp b/tsd/src/tsd/io/importers/import_GLTF.cpp index f01f7c75a..2e4221c3c 100644 --- a/tsd/src/tsd/io/importers/import_GLTF.cpp +++ b/tsd/src/tsd/io/importers/import_GLTF.cpp @@ -207,8 +207,8 @@ static SamplerRef importGLTFTexture(Scene &scene, if (elementType == ANARI_UNKNOWN) return {}; - const ImageSource source{"gltf:"s + imageId, - isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB}; + const ImageSource source{ + "gltf:"s + imageId, isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB}; // tinygltf decodes through stb, which hands back the picture's first row // first whatever the container stored. auto decoded = cache.acquireDecoded(source, @@ -984,6 +984,9 @@ static std::vector importGLTFMeshes(Scene &scene, scene.createArray(ANARI_FLOAT32_VEC2, texCoordAccessor.count); auto *texCoordDataOut = vertexTexCoordArray->mapAs(); copyStridedData(model, texCoordIt->second, texCoordDataOut); + // glTF's `v` runs down the image; ANARI's runs up it. + for (size_t v = 0; v < texCoordAccessor.count; ++v) + texCoordDataOut[v].y = 1.f - texCoordDataOut[v].y; vertexTexCoordArray->unmap(); const std::string attributeName = @@ -1102,6 +1105,10 @@ static std::vector importGLTFMeshes(Scene &scene, : std::vector{}; auto texCoords = copyAccessorData(model, texCoordIt->second); + // Raw accessor data is glTF's v-down; match the v-up coordinates + // that landed on the geometry above. + for (auto &uv : texCoords) + uv.y = 1.f - uv.y; // Get or generate indices std::vector indices; @@ -1148,7 +1155,7 @@ static std::vector importGLTFMeshes(Scene &scene, tangents, indices.size(), posAccessor.count, - true, + false, outputFaceVaryingTangents); vertexTangentArray->unmap(); diff --git a/tsd/src/tsd/io/importers/import_PBRT.cpp b/tsd/src/tsd/io/importers/import_PBRT.cpp index 5d75a96d4..33603466f 100644 --- a/tsd/src/tsd/io/importers/import_PBRT.cpp +++ b/tsd/src/tsd/io/importers/import_PBRT.cpp @@ -46,9 +46,8 @@ float3 getFloat3(const pbrt::ParamList &p, const std::string &name, float3 def) // Defined later — shared with the lights/area-emitters path so that // `"spectrum reflectance" [λ v λ v …]`, `"blackbody"`, etc. resolve // identically for materials, lights, and any other RGB triple lookup. -static float3 resolveEmissionColor(const pbrt::ParamList ¶ms, - const std::string &name, - float3 fallback); +static float3 resolveEmissionColor( + const pbrt::ParamList ¶ms, const std::string &name, float3 fallback); float3 getRgb( const pbrt::ParamList &p, const std::string &name, float3 def = float3(1.f)) @@ -217,8 +216,6 @@ static GeometryRef buildTriangleMesh(Scene &scene, const pbrt::Shape &shape) auto uvArr = scene.createArray(ANARI_FLOAT32_VEC2, numUV); auto *outUV = uvArr->mapAs(); std::memcpy(outUV, uv.data(), numUV * sizeof(float2)); - for (size_t i = 0; i < numUV; i++) - outUV[i].y = 1.f - outUV[i].y; uvArr->unmap(); geom->setParameterObject("vertex.attribute0", *uvArr); } @@ -425,8 +422,6 @@ static GeometryRef buildPlyMesh( auto *outUV = uvArr->mapAs(); std::memcpy( outUV, texcoords->buffer.get(), texcoords->count * sizeof(float2)); - for (size_t i = 0; i < texcoords->count; i++) - outUV[i].y = 1.f - outUV[i].y; uvArr->unmap(); geom->setParameterObject("vertex.attribute0", *uvArr); } @@ -683,8 +678,8 @@ static bool convertNanoVdbMediumShape(Scene &scene, { const auto filename = medium.params.getString("filename"); if (filename.empty()) { - logWarning( - "[import_PBRT] nanovdb medium '%s' missing filename", mediumName.c_str()); + logWarning("[import_PBRT] nanovdb medium '%s' missing filename", + mediumName.c_str()); return false; } @@ -713,8 +708,10 @@ static bool convertNanoVdbMediumShape(Scene &scene, // PBRT albedo = sigma_s / (sigma_a + sigma_s). For a sampled spectrum // we fall back on resolveEmissionColor's mean-of-samples behaviour. - const float3 sigmaA = resolveEmissionColor(medium.params, "sigma_a", float3(0.f)); - const float3 sigmaS = resolveEmissionColor(medium.params, "sigma_s", float3(1.f)); + const float3 sigmaA = + resolveEmissionColor(medium.params, "sigma_a", float3(0.f)); + const float3 sigmaS = + resolveEmissionColor(medium.params, "sigma_s", float3(1.f)); const float3 extinction = sigmaA + sigmaS; float3 albedo(1.f); if (extinction.x > 0.f) @@ -903,28 +900,28 @@ static BakedTexture combineMix( return out; } -// Apply PBRT v4's UV-coordinate transform (`uscale`, `vscale`, `udelta`, -// `vdelta`) to a sampler via its `inTransform`/`inOffset`. PBRT samples the -// image at `(us*u + ud, vs*v + vd)` in its v-up convention. Our importer has -// already flipped each vertex's v to ANARI's v-down convention, and the -// image is also stored top-down, so the equivalent fetch on our side is -// `(us*u_a + ud, vs*v_a + (1 - vs - vd))` — the v-flip cancels into the -// constant offset. -static void applyPbrtUvTransform( - SamplerRef &sampler, const pbrt::ParamList ¶ms) +// PBRT v4's UV-coordinate transform (`uscale`, `vscale`, `udelta`, `vdelta`), +// as sampler settings. PBRT samples the image at `(us*u + ud, vs*v + vd)` in +// its v-up convention, which is now also ANARI's, so this is the transform +// verbatim. +static SamplerSettings pbrtSamplerSettings(const pbrt::ParamList ¶ms) { + SamplerSettings settings; + const float us = params.getFloat("uscale", 1.f); const float vs = params.getFloat("vscale", 1.f); const float ud = params.getFloat("udelta", 0.f); const float vd = params.getFloat("vdelta", 0.f); if (us == 1.f && vs == 1.f && ud == 0.f && vd == 0.f) - return; - mat4 m{float4(us, 0.f, 0.f, 0.f), + return settings; + + settings.uvTransform = mat4{float4(us, 0.f, 0.f, 0.f), float4(0.f, vs, 0.f, 0.f), float4(0.f, 0.f, 1.f, 0.f), float4(0.f, 0.f, 0.f, 1.f)}; - sampler->setParameter("inTransform", m); - sampler->setParameter("inOffset", float4(ud, 1.f - vs - vd, 0.f, 0.f)); + settings.uvOffset = float4(ud, vd, 0.f, 0.f); + settings.hasUvTransform = true; + return settings; } static BakedTexture bakeTexture(Scene &scene, @@ -1013,10 +1010,13 @@ static BakedTexture bakeTexture(Scene &scene, // PBRT splits image textures by colorType: "spectrum" is sRGB color // data, "float" is linear scalar data (roughness, masks, bumps). const bool isLinear = (texDef.colorType == "float"); - auto sampler = importTexture(scene, fullPath, texCache, isLinear); + auto sampler = importTexture(scene, + fullPath, + texCache, + isLinear, + pbrtSamplerSettings(texDef.params)); if (!sampler) return {}; - applyPbrtUvTransform(sampler, texDef.params); // Per the ANARI sampler spec, a fetched texel is completed to four // components with the missing first three defaulting to 0. A 1-channel // grayscale image bound to a color slot therefore reads `baseColor.xyz @@ -1185,7 +1185,7 @@ static bool resolveImagemapChain(const pbrt::Scene &pbrtScene, fv && !fv->empty()) k *= (*fv)[0]; else if (auto *sv = std::get_if>(&it->second); - sv && !sv->empty() && nextTex.empty()) + sv && !sv->empty() && nextTex.empty()) nextTex = (*sv)[0]; } if (nextTex.empty()) @@ -1216,8 +1216,7 @@ static SamplerRef importHeightAsNormalMap(Scene &scene, { // Key under a separate id so this doesn't collide with any value-domain // sampler that may already exist for the same file. - const ImageSource source{"pbrt:" + filepath + "::normal", - ColorSpace::LINEAR}; + const ImageSource source{"pbrt:" + filepath + "::normal", ColorSpace::LINEAR}; if (auto cached = texCache.find(source)) return makeImageSampler(scene, cached, fileOf(filepath) + "_bump"); @@ -1245,7 +1244,10 @@ static SamplerRef importHeightAsNormalMap(Scene &scene, const float hx = raw[y * w + xp] - raw[y * w + xm]; const float hy = raw[yp * w + x] - raw[ym * w + x]; const float nx = -hx * k; - const float ny = -hy * k; + // `hy` runs down the decoded image, but the array this lands in is + // stored bottom-up, so the row axis the gradient was taken over is + // reversed relative to the `v` that will fetch it. + const float ny = hy * k; const float nz = 1.f; const float invLen = 1.f / std::sqrt(nx * nx + ny * ny + nz * nz); // Pack [-1,1] -> [0,1] (glTF normal-map convention). @@ -1346,7 +1348,7 @@ static float resolveDielectricEta(const pbrt::ParamList ¶ms, float def) if (auto *fv = std::get_if>(&it->second)) { eta = extract(*fv, def); } else if (auto *sv = std::get_if>(&it->second); - sv && !sv->empty()) { + sv && !sv->empty()) { logWarning( "[import_PBRT] dielectric: named spectrum '%s' for eta " "not supported, using %.3f", @@ -1915,7 +1917,7 @@ static MaterialRef applyShapeAlpha(Scene &scene, floatAlpha = std::clamp((*fv)[0], 0.f, 1.f); haveFloatAlpha = true; } else if (auto *sv = std::get_if>(&alphaIt->second); - sv && !sv->empty()) { + sv && !sv->empty()) { const auto &alphaTexName = (*sv)[0]; auto texIt = pbrtScene.textures.find(alphaTexName); if (texIt == pbrtScene.textures.end()) { @@ -2181,7 +2183,8 @@ static void convertLight(Scene &scene, if (radiance) { // HDRI-driven: PBRT v4 layers blackbody/rgb L and `scale` on top of // the image. Carry that as the `color` multiplier. - color = applyScale(params, resolveEmissionColor(params, "L"), exposureScale); + color = + applyScale(params, resolveEmissionColor(params, "L"), exposureScale); } else { // Filename-less / load failure: bake the resolved emission directly // into a 1x1 radiance pixel so `radiance` carries the actual light. diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 0bf61b0b4..483047836 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -103,6 +103,46 @@ std::string tgaFixtureContents() return std::string(reinterpret_cast(tga), sizeof(tga)); } +// An 8x8 BC1 DDS: the top half of every block is red, the bottom half blue. +// Block-compressed texels are the one case the import layer cannot reorder, so +// this is the fixture that exercises the sampler-side compensation instead. +std::string ddsFixtureContents() +{ + // One BC1 block: color0 = red, color1 = blue, then four rows of 2-bit + // indices, top row first -- rows 0 and 1 pick color0, rows 2 and 3 color1. + const unsigned char block[] = { + 0x00, 0xf8, 0x1f, 0x00, 0x00, 0x00, 0x55, 0x55}; + + std::string dds; + auto u32 = [&dds](std::uint32_t v) { + dds.append(reinterpret_cast(&v), sizeof(v)); + }; + + dds += "DDS "; + u32(124); // header size + u32(0x1 | 0x2 | 0x4 | 0x1000 + | 0x80000); // CAPS|HEIGHT|WIDTH|PIXELFORMAT|LINEARSIZE + u32(8); // height + u32(8); // width + u32(4 * sizeof(block)); // linear size: four 4x4 blocks + u32(0); // depth + u32(1); // mip levels + for (int i = 0; i < 11; ++i) + u32(0); // reserved + u32(32); // pixel format size + u32(0x4); // DDPF_FOURCC + dds += "DXT1"; + for (int i = 0; i < 5; ++i) + u32(0); // bit counts and masks, unused for a fourCC format + u32(0x1000); // DDSCAPS_TEXTURE + for (int i = 0; i < 4; ++i) + u32(0); // caps2..4, reserved2 + + for (int i = 0; i < 4; ++i) + dds.append(reinterpret_cast(block), sizeof(block)); + return dds; +} + // Texel readers /////////////////////////////////////////////////////////////// // Importers do not agree on an element type -- the shared path expands to @@ -221,8 +261,8 @@ std::string mtlContents(const std::string &textureName) + textureName + "\n"; } -std::string gltfContents(const std::string &binName, - const std::string &textureName) +std::string gltfContents( + const std::string &binName, const std::string &textureName) { // glTF's `v` runs down the image per the spec, so the top of the quad // carries v = 0. @@ -371,6 +411,70 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") } } +SCENARIO( + "Block-compressed images get their flip from the sampler", "[ImageImport]") +{ + // BC blocks are 4x4, so the texels stay as the file authored them -- top-down + // -- and makeImageSampler compensates in the sampler's uv transform. The + // assertion is on where a coordinate lands, not on the matrix. + auto fetchedV = [](tsd::scene::SamplerRef sampler, float v) { + auto *transform = sampler->parameter("inTransform"); + auto *offset = sampler->parameter("inOffset"); + REQUIRE(transform != nullptr); + REQUIRE(offset != nullptr); + const auto uv = + tsd::core::math::mul(transform->value().get(), + float4(0.f, v, 0.f, 1.f)) + + offset->value().get(); + return uv.y; + }; + + GIVEN("An 8x8 BC1 DDS, red on top and blue on the bottom") + { + TempFile texture("tsd_test_orient.dds", ddsFixtureContents()); + + tsd::scene::Scene scene; + tsd::io::ImageCache cache(&scene); + + WHEN("It is imported with no uv transform of its own") + { + auto sampler = tsd::io::importTexture(scene, texture.path(), cache); + + THEN("The sampler reverses v, so v = 1 reaches the block stream's top") + { + REQUIRE(sampler); + REQUIRE(sampler->subtype() + == tsd::scene::tokens::sampler::compressedImage2D); + REQUIRE(fetchedV(sampler, 1.f) == Approx(0.f).margin(1e-5)); + REQUIRE(fetchedV(sampler, 0.f) == Approx(1.f).margin(1e-5)); + } + } + + WHEN("It is imported by a caller that authored its own uv transform") + { + // Half-scale in v, as USD's uvTransform or PBRT's vscale would give. + tsd::io::SamplerSettings settings; + settings.uvTransform = tsd::core::math::mat4(float4(1.f, 0.f, 0.f, 0.f), + float4(0.f, 0.5f, 0.f, 0.f), + float4(0.f, 0.f, 1.f, 0.f), + float4(0.f, 0.f, 0.f, 1.f)); + settings.hasUvTransform = true; + + auto sampler = tsd::io::importTexture( + scene, texture.path(), cache, /*isLinear=*/false, settings); + + THEN("The flip composes onto that transform rather than replacing it") + { + REQUIRE(sampler); + // The caller's transform sends v = 1 to 0.5; the flip then sends 0.5 + // to 0.5, and v = 0 to 1. + REQUIRE(fetchedV(sampler, 1.f) == Approx(0.5f).margin(1e-5)); + REQUIRE(fetchedV(sampler, 0.f) == Approx(1.f).margin(1e-5)); + } + } + } +} + // Importer-level contract ///////////////////////////////////////////////////// // The property that has to hold whatever the storage convention is: the corner @@ -383,8 +487,7 @@ SCENARIO("An imported quad's top corner addresses the image's top row", GIVEN("An OBJ quad textured with the fixture image") { TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); - TempFile mtl( - "tsd_test_orient.mtl", mtlContents("tsd_test_orient.tga")); + TempFile mtl("tsd_test_orient.mtl", mtlContents("tsd_test_orient.tga")); TempFile obj("tsd_test_orient.obj", objContents("tsd_test_orient.mtl")); tsd::scene::Scene scene; @@ -403,6 +506,34 @@ SCENARIO("An imported quad's top corner addresses the image's top row", } } +#if TSD_USE_ASSIMP + // Through the glTF fixture rather than the OBJ one: ASSIMP reports a + // GL-style shading model for OBJ, and that branch of the material importer + // binds no textures at all, so an OBJ would assert nothing here. + GIVEN("The same glTF quad, read through ASSIMP") + { + TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); + TempFile bin("tsd_test_orient.bin", gltfBufferContents()); + TempFile gltf("tsd_test_orient.gltf", + gltfContents("tsd_test_orient.bin", "tsd_test_orient.tga")); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("It is imported") + { + tsd::io::import_ASSIMP(scene, animMgr, gltf.path().c_str()); + + THEN("The top corner samples the top row") + { + const auto *image = fixtureImage(scene); + REQUIRE(image != nullptr); + REQUIRE(isTopRowColor(sampleAsAnari(image, uvAtTopOfQuad(scene)))); + } + } + } +#endif + GIVEN("A glTF quad textured with the fixture image") { TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); @@ -429,8 +560,7 @@ SCENARIO("An imported quad's top corner addresses the image's top row", GIVEN("A PBRT quad textured with the fixture image") { TempFile texture("tsd_test_orient.tga", tgaFixtureContents()); - TempFile pbrt( - "tsd_test_orient.pbrt", pbrtContents("tsd_test_orient.tga")); + TempFile pbrt("tsd_test_orient.pbrt", pbrtContents("tsd_test_orient.tga")); tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); From e321b4dffcfdffeddffc46c5116726cf645f2d44 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 16:39:22 -0500 Subject: [PATCH 22/78] refactor(io): route HDRI radiance through the image cache HDRImage was the last decoder creating Scene arrays on its own, and the one place in the tree whose comment named an orientation -- the opposite of what every other path then produced. It keeps its decode, because it handles multipart EXR and forces three channels and the shared texture path does neither, but it now declares the row order it emits and its callers store through ImageCache::acquireDecoded. That gets HDRIs the same keying and Scene-scoped lifetime as any other image, and caching they had none of before. The USD dome light bakes its radiometry scale into the texels, so it keys on the colour as well as the file. The new test covers the .hdr path end to end. PBRT's equal-area-to- equirectangular conversion still consumes HDRImage's raw buffer directly rather than a cached Image; it is correct as it stands, and rewriting its frame of reference has no test to hold it, so it is left alone. Co-Authored-By: Claude Fable 5 --- tsd/src/tsd/io/importers/detail/HDRImage.cpp | 4 +- tsd/src/tsd/io/importers/detail/HDRImage.h | 9 ++++ .../tsd/io/importers/detail/usd/UsdLights.cpp | 18 +++++-- tsd/src/tsd/io/importers/import_HDRI.cpp | 11 ++-- tsd/tests/test_ImageImport.cpp | 53 +++++++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/HDRImage.cpp b/tsd/src/tsd/io/importers/detail/HDRImage.cpp index de2eb52a0..cdf2fe7f2 100644 --- a/tsd/src/tsd/io/importers/detail/HDRImage.cpp +++ b/tsd/src/tsd/io/importers/detail/HDRImage.cpp @@ -168,9 +168,11 @@ bool HDRImage::import(std::string fileName) if (extension == ".hdr") { int w, h, n; + // Bottom row first, matching the EXR branch below and the orientation a + // Scene stores images in. stbi_set_flip_vertically_on_load(1); const float *imgData = stbi_loadf(fileName.c_str(), &w, &h, &n, STBI_rgb); - stbi_set_flip_vertically_on_load(0); // Restore default top-down orientation + stbi_set_flip_vertically_on_load(0); // this flag is global to stb width = w; height = h; numComponents = 3; // because of STBI_rgb diff --git a/tsd/src/tsd/io/importers/detail/HDRImage.h b/tsd/src/tsd/io/importers/detail/HDRImage.h index c07f08c19..04f2eeb09 100644 --- a/tsd/src/tsd/io/importers/detail/HDRImage.h +++ b/tsd/src/tsd/io/importers/detail/HDRImage.h @@ -3,12 +3,18 @@ #pragma once +#include "tsd/io/images.hpp" // std #include #include namespace tsd::io { +// The one decoder that does not go through ImageCache's own decode paths: it +// handles multipart EXR and forces three channels, neither of which the shared +// texture path does. It still declares the row order it produced, so callers +// hand it to ImageCache::acquireDecoded and get the same normalization, +// keying, and lifetime as any other image. struct HDRImage { bool import(std::string fileName); @@ -16,6 +22,9 @@ struct HDRImage unsigned width; unsigned height; unsigned numComponents; + // Both branches below emit the picture's bottom row first, which is already + // ANARI orientation. See docs/adr/0014-store-images-in-anari-orientation.md. + RowOrder rowOrder{RowOrder::BOTTOM_UP}; std::vector pixel; }; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index 6a3df784f..834ca243d 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -174,10 +174,20 @@ ArrayRef readDomeRadiance(ImportContext &ctx, for (auto &texel : rgb) texel *= radiometry.color; - auto retval = - ctx.scene.createArray(ANARI_FLOAT32_VEC3, image.width, image.height); - retval->setData(rgb.data()); - return retval; + // Keyed on the radiometry as well as the file: the scale above is baked + // into the texels, so two dome lights sharing a file but not a colour are + // genuinely different images. + const auto id = "usd:domelight:" + file + ":" + + std::to_string(radiometry.color.x) + "," + + std::to_string(radiometry.color.y) + "," + + std::to_string(radiometry.color.z); + auto acquired = ctx.textureCache.acquireDecoded({id, ColorSpace::LINEAR}, + ANARI_FLOAT32_VEC3, + image.width, + image.height, + image.rowOrder, + rgb.data()); + return acquired.texels; } } // namespace diff --git a/tsd/src/tsd/io/importers/import_HDRI.cpp b/tsd/src/tsd/io/importers/import_HDRI.cpp index 4c481527e..0b83142ee 100644 --- a/tsd/src/tsd/io/importers/import_HDRI.cpp +++ b/tsd/src/tsd/io/importers/import_HDRI.cpp @@ -30,14 +30,19 @@ void import_HDRI(Scene &scene, } } - auto arr = scene.createArray(ANARI_FLOAT32_VEC3, img.width, img.height); - arr->setData(rgb.data()); + ImageCache cache(&scene); + auto image = cache.acquireDecoded({hdriFilename, ColorSpace::LINEAR}, + ANARI_FLOAT32_VEC3, + img.width, + img.height, + img.rowOrder, + rgb.data()); auto [inst, hdri] = scene.insertNewChildObjectNode( location ? location : scene.defaultLayer()->root(), tokens::light::hdri); hdri->setName(fileOf(filepath).c_str()); - hdri->setParameterObject("radiance", *arr); + hdri->setParameterObject("radiance", *image.texels); } else { tsd::core::logError("[import_HDRI] Failed to load file '%s'", filepath); } diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 483047836..115e23847 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -143,6 +143,29 @@ std::string ddsFixtureContents() return dds; } +// A 1x2 Radiance HDR: red on top, blue on the bottom. The scanline data is +// flat RGBE rather than run-length encoded, which stb takes for any image +// under eight texels wide. +std::string hdrFixtureContents() +{ + // Mantissa plus a shared exponent of 129, so the bright channel reads a + // little under 2.0 and the others are zero. + const unsigned char scanlines[] = { + 0xff, + 0x00, + 0x00, + 0x81, // top row: red + 0x00, + 0x00, + 0xff, + 0x81 // bottom row: blue + }; + + std::string hdr = "#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 2 +X 1\n"; + hdr.append(reinterpret_cast(scanlines), sizeof(scanlines)); + return hdr; +} + // Texel readers /////////////////////////////////////////////////////////////// // Importers do not agree on an element type -- the shared path expands to @@ -411,6 +434,36 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") } } +SCENARIO("An imported HDRI's radiance is in ANARI orientation", "[ImageImport]") +{ + GIVEN("A 1x2 Radiance HDR, red on top and blue on the bottom") + { + TempFile hdri("tsd_test_orient.hdr", hdrFixtureContents()); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("It is imported as a dome light") + { + tsd::io::import_HDRI(scene, animMgr, hdri.path().c_str()); + + THEN("Row 0 of the radiance array is the bottom row of the picture") + { + REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = scene.getObject(0); + REQUIRE(light); + auto *radiance = + light->parameterValueAsObject("radiance"); + REQUIRE(radiance != nullptr); + REQUIRE(radiance->dim(0) == 1); + REQUIRE(radiance->dim(1) == 2); + REQUIRE(isBottomRowColor(texelAsFloat3(radiance, 0))); + REQUIRE(isTopRowColor(texelAsFloat3(radiance, 1))); + } + } + } +} + SCENARIO( "Block-compressed images get their flip from the sampler", "[ImageImport]") { From 9d15b55115edb7a1441521e314b7330de6a20b48 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 16:50:10 -0500 Subject: [PATCH 23/78] fix(io): address code review of the image import path The one real defect: ASSIMP set `hasUvTransform` unconditionally, so every ASSIMP texture got an explicit identity inTransform/inOffset even where assimp authored no transform -- contradicting the flag's own documented contract. Standards: - `Image::operator bool` and `DecodedImage::operator bool` move out of the struct bodies into Inlined definitions sections (parent SS5). - `ImageCache` states its copy/move intent with the TypeMacros (SS6), and drops the private `lookup` returning `Image *` so one kind of lookup has one idiom (SS11 Fallible Returns). - `SamplerSettings`'s `hasUvTransform` bool and the value it guarded become one `std::optional` (SS7). - clang-format had hoisted `` above the project headers in import_ASSIMP.cpp; restored. - `src/tsd/io/CONTEXT.md` gains the vocabulary this work introduced -- Image, Image Source, Image Cache, Row Order, Color Space. Correctness and clarity: - glTF image ids are scoped to their file, as the proposal specified. Harmless while each importer owns its cache; a landmine the moment one is shared. - `import_HDRI` checks the acquired Image before dereferencing it. - `gltfTexelType` drops a parameter it never read. - ADR 0014 said glTF and PBRT both flip v. PBRT stopped flipping in c4106e72; corrected. Co-Authored-By: Claude Fable 5 --- .../0014-store-images-in-anari-orientation.md | 4 +- tsd/src/tsd/io/CONTEXT.md | 32 ++++++++++++++++ tsd/src/tsd/io/images/ImageCache.cpp | 32 ++++++---------- tsd/src/tsd/io/images/ImageCache.hpp | 37 +++++++++++++------ tsd/src/tsd/io/images/detail/decoders.hpp | 12 ++++-- .../io/importers/detail/usd/UsdMaterials.cpp | 6 ++- tsd/src/tsd/io/importers/import_ASSIMP.cpp | 8 ++-- tsd/src/tsd/io/importers/import_GLTF.cpp | 32 +++++++++++++--- tsd/src/tsd/io/importers/import_HDRI.cpp | 5 +++ tsd/src/tsd/io/importers/import_PBRT.cpp | 11 +++--- tsd/tests/test_ImageImport.cpp | 10 ++--- 11 files changed, 131 insertions(+), 58 deletions(-) diff --git a/tsd/docs/adr/0014-store-images-in-anari-orientation.md b/tsd/docs/adr/0014-store-images-in-anari-orientation.md index 5c1b043c4..8359a7a3d 100644 --- a/tsd/docs/adr/0014-store-images-in-anari-orientation.md +++ b/tsd/docs/adr/0014-store-images-in-anari-orientation.md @@ -4,7 +4,9 @@ A decoded image resident in a TSD Scene is stored in ANARI orientation: the array's row 0 is the bottom row of the picture, so texture coordinate `(0, 0)` addresses the lower-left corner. Importers hand ANARI texture coordinates in ANARI's convention, converting from the source format's where they differ — -glTF and PBRT flip `v`, and ASSIMP simply stops asking for `aiProcess_FlipUVs`. +glTF flips `v` because glTF's runs down the image, while PBRT stops flipping it +and ASSIMP stops asking for `aiProcess_FlipUVs`, both of those formats being +v-up already. Decoders declare the row order their library produced and `ImageCache` normalizes; no importer flips texels itself. Previously each of seven decode paths carried its own unstated assumption, and the assumptions cancelled for diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index 3aaab4a63..b29d5a7cd 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -134,3 +134,35 @@ Render Contexts to prefer, what to emit materials as, how far to refine subdivision surfaces, and which prim to import from. Converts to and from a DataTree so it can be persisted with a project and driven from scripting. _Avoid_: import config, import params + +### Images + +**Image**: +A decoded picture resident in a scene, held as an Array of texels. Identified +by its content, not by the sampler built from it: two materials binding the +same file at the same Color Space share one Image. + +**Image Source**: +What identifies an Image's content — a resolved absolute path for a +file-backed image, or an importer-scoped stable id otherwise +(`gltf::`, `assimp://embedded/`, `pbrt:::normal`) — +together with the Color Space it was decoded for. +_Avoid_: texture key, cache key + +**Image Cache**: +The owner of every decoded Image for one scene. It holds the scene it caches +for, so a cached Array can never reach a different one, and it must not +outlive that scene. +_Avoid_: texture cache + +**Row Order**: +Which row of a picture a texel array stores first. Decoders declare the Row +Order their library produced; the Image Cache normalizes to ANARI +orientation — row 0 is the bottom row — and no importer flips texels itself. +See [ADR 0014](../../../docs/adr/0014-store-images-in-anari-orientation.md). +_Avoid_: flipped, vertical orientation, y-up + +**Color Space**: +How a file's values relate to the linear values a renderer wants, either sRGB +or linear. Formats carrying an encoding of their own — EXR, and the block +format of a DDS — ignore what a caller asks for. diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp index 52a840835..b595d5eee 100644 --- a/tsd/src/tsd/io/images/ImageCache.cpp +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -89,8 +89,8 @@ Image ImageCache::acquire(const ImageSource &source) auto resolved = source; resolved.colorSpace = detail::colorSpaceForFile(source.id, source.colorSpace); - if (auto *cached = lookup(resolved)) - return *cached; + if (auto cached = find(resolved)) + return cached; return store( resolved, detail::decodeImageFile(resolved.id, resolved.colorSpace)); @@ -105,8 +105,8 @@ Image ImageCache::acquire(const ImageSource &source, resolved.colorSpace = detail::colorSpaceForFormatHint(formatHint, source.colorSpace); - if (auto *cached = lookup(resolved)) - return *cached; + if (auto cached = find(resolved)) + return cached; return store(resolved, detail::decodeImageFromMemory( @@ -120,8 +120,8 @@ Image ImageCache::acquireDecoded(const ImageSource &source, RowOrder rowOrder, const void *texels) { - if (auto *cached = lookup(source)) - return *cached; + if (auto cached = find(source)) + return cached; detail::DecodedImage decoded; decoded.elementType = elementType; @@ -151,12 +151,6 @@ size_t ImageCache::size() const return m_images.size(); } -Image *ImageCache::lookup(const ImageSource &source) -{ - auto found = m_images.find(keyOf(source)); - return found == m_images.end() ? nullptr : &found->second; -} - Image ImageCache::store( const ImageSource &source, detail::DecodedImage &&decoded) { @@ -219,14 +213,12 @@ SamplerRef makeImageSampler(Scene &scene, // against the bottom-up coordinates every importer now hands ANARI. Undo // that here, composed onto the caller's own transform rather than replacing // it, which is why makeImageSampler owns inTransform/inOffset outright. - auto uvTransform = settings.uvTransform; - auto uvOffset = settings.uvOffset; - if (image.blockCompressed) - composeVFlip(uvTransform, uvOffset); - - if (settings.hasUvTransform || image.blockCompressed) { - sampler->setParameter("inTransform", uvTransform); - sampler->setParameter("inOffset", uvOffset); + if (settings.uvTransform || image.blockCompressed) { + auto uv = settings.uvTransform.value_or(UvTransform{}); + if (image.blockCompressed) + composeVFlip(uv.transform, uv.offset); + sampler->setParameter("inTransform", uv.transform); + sampler->setParameter("inOffset", uv.offset); } sampler->setName(fileOf(displayName).c_str()); diff --git a/tsd/src/tsd/io/images/ImageCache.hpp b/tsd/src/tsd/io/images/ImageCache.hpp index 83e0f4f15..18d8a9eb7 100644 --- a/tsd/src/tsd/io/images/ImageCache.hpp +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -3,9 +3,11 @@ #pragma once +#include "tsd/core/TypeMacros.hpp" #include "tsd/scene/Scene.hpp" // std #include +#include #include #include @@ -49,10 +51,7 @@ struct Image // texel grid, so they cannot be reordered; makeImageSampler compensates. bool blockCompressed{false}; - explicit operator bool() const - { - return texels.valid(); - } + explicit operator bool() const; }; // Owns decoded images for one Scene. Holds the Scene it caches for so a cached @@ -63,6 +62,11 @@ class ImageCache ImageCache() = default; explicit ImageCache(tsd::scene::Scene *scene); + // Copyable and moveable: an ImageCache is a value the caller owns, and + // ImportContext holds one by value. + TSD_DEFAULT_COPYABLE(ImageCache) + TSD_DEFAULT_MOVEABLE(ImageCache) + tsd::scene::Scene *scene() const; // Decode `source.id` as a file path. @@ -90,13 +94,19 @@ class ImageCache size_t size() const; private: - Image *lookup(const ImageSource &source); Image store(const ImageSource &source, detail::DecodedImage &&decoded); tsd::scene::Scene *m_scene{nullptr}; std::unordered_map m_images; }; +// An importer's own uv transform, in the form ANARI takes it. +struct UvTransform +{ + tsd::math::mat4 transform{tsd::math::IDENTITY_MAT4}; + tsd::math::float4 offset{0.f, 0.f, 0.f, 0.f}; +}; + // How a sampler reads the image it is bound to. Everything a binding can vary // lives here, including the importer's own uv transform: `makeImageSampler` // owns the sampler's `inTransform`/`inOffset` outright, because a @@ -108,12 +118,10 @@ struct SamplerSettings const char *wrapMode1{"repeat"}; const char *wrapMode2{"repeat"}; const char *filter{"linear"}; - // The importer's own uv transform, in the same form ANARI takes it. - tsd::math::mat4 uvTransform{tsd::math::IDENTITY_MAT4}; - tsd::math::float4 uvOffset{0.f, 0.f, 0.f, 0.f}; - // Set only when the importer authored a transform, so a sampler that wants - // none is left without the parameters entirely rather than with an identity. - bool hasUvTransform{false}; + // The importer's own uv transform, in the same form ANARI takes it. Unset + // where the importer authored none, so a sampler that wants no transform is + // left without the parameters entirely rather than with an identity. + std::optional uvTransform; }; tsd::scene::SamplerRef makeImageSampler(tsd::scene::Scene &scene, @@ -121,4 +129,11 @@ tsd::scene::SamplerRef makeImageSampler(tsd::scene::Scene &scene, const std::string &displayName, const SamplerSettings &settings = {}); +// Inlined definitions //////////////////////////////////////////////////////// + +inline Image::operator bool() const +{ + return texels.valid(); +} + } // namespace tsd::io diff --git a/tsd/src/tsd/io/images/detail/decoders.hpp b/tsd/src/tsd/io/images/detail/decoders.hpp index bc3913b14..c5a7c2493 100644 --- a/tsd/src/tsd/io/images/detail/decoders.hpp +++ b/tsd/src/tsd/io/images/detail/decoders.hpp @@ -29,12 +29,16 @@ struct DecodedImage bool blockCompressed{false}; tsd::core::Token compressedFormat; - explicit operator bool() const - { - return elementType != ANARI_UNKNOWN && !texels.empty(); - } + explicit operator bool() const; }; +// Inlined definitions //////////////////////////////////////////////////////// + +inline DecodedImage::operator bool() const +{ + return elementType != ANARI_UNKNOWN && !texels.empty(); +} + // Whether a file's own encoding overrides the color space a caller asked for. // EXR carries linear values and DDS carries its encoding in the block format, // so both collapse onto LINEAR -- which also keeps one file from being decoded diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 65b246556..9a3044813 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -777,8 +777,10 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, SamplerSettings settings; settings.wrapMode1 = wrapS.c_str(); settings.wrapMode2 = wrapT.c_str(); - settings.uvTransform = uvTransformOfTexture(walker, texturePath); - settings.hasUvTransform = settings.uvTransform != math::IDENTITY_MAT4; + if (const auto uvTransform = uvTransformOfTexture(walker, texturePath); + uvTransform != math::IDENTITY_MAT4) { + settings.uvTransform = UvTransform{uvTransform}; + } const bool isLinear = textureIsLinear(walker, texturePath, colorRole); auto sampler = diff --git a/tsd/src/tsd/io/importers/import_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index 8d3619146..6a0124a35 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -5,10 +5,11 @@ #define TSD_USE_ASSIMP 1 #endif -#include #include "tsd/core/Logging.hpp" #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/importer_common.hpp" +// std +#include #if TSD_USE_ASSIMP // assimp #include @@ -290,13 +291,12 @@ static std::vector importASSIMPMaterials( aiUVTransform uvTransform; if (aiGetMaterialUVTransform(assimpMat, pKey, type, index, &uvTransform) == AI_SUCCESS) { - settings.uvTransform = + settings.uvTransform = UvTransform{ mat4({uvTransform.mScaling.x, 0.f, 0.f, uvTransform.mTranslation.x}, {0.f, uvTransform.mScaling.y, 0.f, uvTransform.mTranslation.y}, {0.f, 0.f, 1.f, 0.f}, - {0.0f, 0.0f, 0.f, 1.f}); + {0.0f, 0.0f, 0.f, 1.f})}; } - settings.hasUvTransform = true; return settings; }; diff --git a/tsd/src/tsd/io/importers/import_GLTF.cpp b/tsd/src/tsd/io/importers/import_GLTF.cpp index 2e4221c3c..71ba41d32 100644 --- a/tsd/src/tsd/io/importers/import_GLTF.cpp +++ b/tsd/src/tsd/io/importers/import_GLTF.cpp @@ -106,7 +106,7 @@ static std::string attributeNameForTexCoord(int texCoord) // decode path nothing here expands to float or applies a gamma curve in // software -- the *_SRGB formats let the device apply the true sRGB EOTF. static anari::DataType gltfTexelType( - const tinygltf::Image &image, bool isLinear, int textureIndex) + const tinygltf::Image &image, bool isLinear) { const int channels = image.component - 1; switch (image.pixel_type) { @@ -182,6 +182,9 @@ static SamplerRef importGLTFTexture(Scene &scene, const tinygltf::Model &model, int textureIndex, ImageCache &cache, + // Scopes the cache id to this glTF: image names are local to a file, so + // two assets each naming an image "diffuse" are different images. + const std::string &sourcePrefix, bool isLinear = false, const char *samplerName = nullptr, int texCoord = 0) @@ -203,12 +206,12 @@ static SamplerRef importGLTFTexture(Scene &scene, return {}; } - const auto elementType = gltfTexelType(image, isLinear, textureIndex); + const auto elementType = gltfTexelType(image, isLinear); if (elementType == ANARI_UNKNOWN) return {}; const ImageSource source{ - "gltf:"s + imageId, isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB}; + sourcePrefix + imageId, isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB}; // tinygltf decodes through stb, which hands back the picture's first row // first whatever the container stored. auto decoded = cache.acquireDecoded(source, @@ -241,7 +244,7 @@ static void applyNormalTextureScale(SamplerRef sampler, float scale) } static std::vector importGLTFMaterials( - Scene &scene, const tinygltf::Model &model) + Scene &scene, const tinygltf::Model &model, const std::string &filename) { // This function supports the following glTF material extensions: // - KHR_materials_transmission: transmission factor and texture @@ -254,6 +257,7 @@ static std::vector importGLTFMaterials( std::vector materials; ImageCache cache(&scene); + const auto sourcePrefix = "gltf:"s + filename + ":"; for (const auto &gltfMaterial : model.materials) { MaterialRef material; @@ -272,6 +276,7 @@ static std::vector importGLTFMaterials( model, pbr.baseColorTexture.index, cache, + sourcePrefix, false, "baseColor", pbr.baseColorTexture.texCoord)) { @@ -292,6 +297,7 @@ static std::vector importGLTFMaterials( model, pbr.baseColorTexture.index, cache, + sourcePrefix, true, "opacity", pbr.baseColorTexture.texCoord)) { @@ -311,6 +317,7 @@ static std::vector importGLTFMaterials( model, pbr.metallicRoughnessTexture.index, cache, + sourcePrefix, true, "metallic", pbr.metallicRoughnessTexture.texCoord)) { @@ -331,6 +338,7 @@ static std::vector importGLTFMaterials( model, pbr.metallicRoughnessTexture.index, cache, + sourcePrefix, true, "roughness", pbr.metallicRoughnessTexture.texCoord)) { @@ -350,6 +358,7 @@ static std::vector importGLTFMaterials( model, gltfMaterial.normalTexture.index, cache, + sourcePrefix, true, "normal", gltfMaterial.normalTexture.texCoord)) { @@ -363,6 +372,7 @@ static std::vector importGLTFMaterials( model, gltfMaterial.occlusionTexture.index, cache, + sourcePrefix, true, "occlusion", gltfMaterial.occlusionTexture.texCoord)) { @@ -387,6 +397,7 @@ static std::vector importGLTFMaterials( model, gltfMaterial.emissiveTexture.index, cache, + sourcePrefix, false, "emissive", gltfMaterial.emissiveTexture.texCoord)) { @@ -433,6 +444,7 @@ static std::vector importGLTFMaterials( model, transmissionTextureIndex, cache, + sourcePrefix, true, "transmission", transmissionTexCoord)) { @@ -479,6 +491,7 @@ static std::vector importGLTFMaterials( model, thicknessTextureIndex, cache, + sourcePrefix, true, "thickness", thicknessTexCoord)) { @@ -528,6 +541,7 @@ static std::vector importGLTFMaterials( model, clearcoatTextureIndex, cache, + sourcePrefix, true, "clearcoat", clearcoatTexCoord)) { @@ -554,6 +568,7 @@ static std::vector importGLTFMaterials( model, clearcoatRoughnessTextureIndex, cache, + sourcePrefix, true, "clearcoatRoughness", clearcoatRoughnessTexCoord)) { @@ -578,6 +593,7 @@ static std::vector importGLTFMaterials( model, clearcoatNormalTextureIndex, cache, + sourcePrefix, true, "clearcoatNormal", clearcoatNormalTexCoord)) { @@ -608,6 +624,7 @@ static std::vector importGLTFMaterials( model, specularTextureIndex, cache, + sourcePrefix, true, "specular", specularTexCoord)) { @@ -634,6 +651,7 @@ static std::vector importGLTFMaterials( model, specularColorTextureIndex, cache, + sourcePrefix, false, "specularColor", specularColorTexCoord)) { @@ -671,6 +689,7 @@ static std::vector importGLTFMaterials( model, sheenColorTextureIndex, cache, + sourcePrefix, false, "sheenColor", sheenColorTexCoord)) { @@ -697,6 +716,7 @@ static std::vector importGLTFMaterials( model, sheenRoughnessTextureIndex, cache, + sourcePrefix, true, "sheenRoughness", sheenRoughnessTexCoord)) { @@ -734,6 +754,7 @@ static std::vector importGLTFMaterials( model, iridescenceTextureIndex, cache, + sourcePrefix, true, "iridescence", iridescenceTexCoord)) { @@ -769,6 +790,7 @@ static std::vector importGLTFMaterials( model, iridescenceThicknessTextureIndex, cache, + sourcePrefix, true, "iridescenceThickness", iridescenceThicknessTexCoord)) { @@ -1413,7 +1435,7 @@ void import_GLTF(Scene &scene, scene.beginLayerEditBatch(); // Import materials - auto materials = importGLTFMaterials(scene, model); + auto materials = importGLTFMaterials(scene, model, filename); // Import meshes auto surfaces = importGLTFMeshes(scene, model, materials); diff --git a/tsd/src/tsd/io/importers/import_HDRI.cpp b/tsd/src/tsd/io/importers/import_HDRI.cpp index 0b83142ee..3d1cabeb4 100644 --- a/tsd/src/tsd/io/importers/import_HDRI.cpp +++ b/tsd/src/tsd/io/importers/import_HDRI.cpp @@ -38,6 +38,11 @@ void import_HDRI(Scene &scene, img.rowOrder, rgb.data()); + if (!image) { + logError("[import_HDRI] failed to store radiance for '%s'", filepath); + return; + } + auto [inst, hdri] = scene.insertNewChildObjectNode( location ? location : scene.defaultLayer()->root(), tokens::light::hdri); diff --git a/tsd/src/tsd/io/importers/import_PBRT.cpp b/tsd/src/tsd/io/importers/import_PBRT.cpp index 33603466f..5255c16ef 100644 --- a/tsd/src/tsd/io/importers/import_PBRT.cpp +++ b/tsd/src/tsd/io/importers/import_PBRT.cpp @@ -915,12 +915,11 @@ static SamplerSettings pbrtSamplerSettings(const pbrt::ParamList ¶ms) if (us == 1.f && vs == 1.f && ud == 0.f && vd == 0.f) return settings; - settings.uvTransform = mat4{float4(us, 0.f, 0.f, 0.f), - float4(0.f, vs, 0.f, 0.f), - float4(0.f, 0.f, 1.f, 0.f), - float4(0.f, 0.f, 0.f, 1.f)}; - settings.uvOffset = float4(ud, vd, 0.f, 0.f); - settings.hasUvTransform = true; + settings.uvTransform = UvTransform{mat4{float4(us, 0.f, 0.f, 0.f), + float4(0.f, vs, 0.f, 0.f), + float4(0.f, 0.f, 1.f, 0.f), + float4(0.f, 0.f, 0.f, 1.f)}, + float4(ud, vd, 0.f, 0.f)}; return settings; } diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 115e23847..5c075fed0 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -507,11 +507,11 @@ SCENARIO( { // Half-scale in v, as USD's uvTransform or PBRT's vscale would give. tsd::io::SamplerSettings settings; - settings.uvTransform = tsd::core::math::mat4(float4(1.f, 0.f, 0.f, 0.f), - float4(0.f, 0.5f, 0.f, 0.f), - float4(0.f, 0.f, 1.f, 0.f), - float4(0.f, 0.f, 0.f, 1.f)); - settings.hasUvTransform = true; + settings.uvTransform = + tsd::io::UvTransform{tsd::core::math::mat4(float4(1.f, 0.f, 0.f, 0.f), + float4(0.f, 0.5f, 0.f, 0.f), + float4(0.f, 0.f, 1.f, 0.f), + float4(0.f, 0.f, 0.f, 1.f))}; auto sampler = tsd::io::importTexture( scene, texture.path(), cache, /*isLinear=*/false, settings); From 56a9dd839984f46ce2a5403441aa45332c12386d Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 28 Jul 2026 17:07:46 -0500 Subject: [PATCH 24/78] docs(io): record what landed and what is left of the image import The proposal doc read entirely in the future tense, which is misleading now that most of it is in the tree. Add a status banner and a Status table, mark the Survey as describing the tree at 017f7d91, and note at each point where the implementation diverged from the plan: HDRImage kept its own flip, and the DDS recommendation was ratified but shaped as SamplerSettings rather than a helper callers must remember to call. Add a Remaining work section covering the six items in rough payoff order -- one cache per import rather than per importer, retiring the shims, PBRT's infinite light, HDRImage's flip, native element types, and the EXR/TIFF/equal-area test gaps -- each with what it needs and why it did not land here. Also record that ASSIMP binds no textures for OBJ. Its GL-style material branch reads no texture slot at all, so an OBJ with map_Kd imports untextured through ASSIMP and correctly through import_OBJ. Unrelated to this work, found by writing its tests, and it will mislead someone. Co-Authored-By: Claude Fable 5 --- tsd/docs/tsd-io-image-import.md | 203 ++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 39 deletions(-) diff --git a/tsd/docs/tsd-io-image-import.md b/tsd/docs/tsd-io-image-import.md index 3e7d41861..e601d6661 100644 --- a/tsd/docs/tsd-io-image-import.md +++ b/tsd/docs/tsd-io-image-import.md @@ -1,21 +1,33 @@ # Central image import for tsd_io +> **Status: steps 0-3 landed** (`98e4a1e8`..`21d4d741` on `usd-import-rework`). +> The contract itself is recorded in +> [ADR 0014](adr/0014-store-images-in-anari-orientation.md); this document +> keeps the survey that motivated it and tracks what is left. The Survey below +> describes the tree *before* the change and is retained as the record of why. +> See [Status](#status) and [Remaining work](#remaining-work). + ## Summary -Every importer that reads texels reaches a different decoder, and each one -carries a private, undocumented assumption about which row of the decoded -image is row 0. The assumptions cancel out for glTF, ASSIMP, and PBRT and do -not cancel for OBJ and USD, which is why textures from those two formats come -out vertically mirrored. Nothing downstream corrects it: no render index or -sampler code in TSD flips anything. +Every importer that read texels reached a different decoder, and each one +carried a private, undocumented assumption about which row of the decoded +image is row 0. The assumptions cancelled out for glTF, ASSIMP, and PBRT and +did not cancel for OBJ and USD, which is why textures from those two formats +came out vertically mirrored. Nothing downstream corrected it: no render index +or sampler code in TSD flipped anything. -This proposes one image-import component with a single stated orientation +This proposed one image-import component with a single stated orientation contract — decoded texels are stored in ANARI orientation, row 0 is the bottom row — plus a scene-scoped cache so a texture referenced from many places is -decoded once. +decoded once. The contract and the component landed; the cache is still scoped +to one importer call rather than one import. ## Survey +*Everything in this section describes the tree at `017f7d91`, before the +change. It is kept because the reasoning, not just the conclusion, is what a +future decoder author needs.* + ### Decode paths in the tree today | # | Path | Entry point | Decoder | Row 0 | @@ -207,7 +219,13 @@ behavior change. One place: `ImageCache`'s store step, between decode and `Array::setData`. Each decoder reports `RowOrder`; stb, tinyexr, OIIO, and tinygltf report `TOP_DOWN`, and the cache reverses rows before the texels reach the scene. -`HDRImage` stops flipping and reports `TOP_DOWN` like everything else. + +> **As landed:** `HDRImage` did *not* stop flipping. It keeps its own decode, +> because it handles multipart EXR and forces three channels and the shared +> path does neither, and it declares `BOTTOM_UP` rather than flipping twice. +> That satisfies the contract — a decoder declares, the cache normalizes — +> but it does mean two places in the tree reverse rows. See +> [Remaining work](#remaining-work). **Block-compressed DDS is the one exception.** BC blocks are 4×4, so a vertical flip requires decode and re-encode, which defeats the point of @@ -220,6 +238,14 @@ must compose rather than overwrite — a `composeVFlip(mat4 &, float4 &)` helper keeps that honest. The alternative, decoding DDS to RGBA and flipping, is simpler but throws away the compression. +> **As landed:** this recommendation was ratified and implemented, with one +> change of shape. Rather than a `composeVFlip` helper that callers must +> remember to use, `makeImageSampler` owns `inTransform`/`inOffset` outright +> and takes the importer's own transform through `SamplerSettings`. A caller +> cannot overwrite the flip, because it no longer sets those parameters +> itself. `tests/test_ImageImport.cpp` asserts both the flip and that it +> composes onto a caller's transform rather than replacing it. + ### Importer changes that must land with the flip Flipping the arrays without these is a regression, so they belong in one @@ -235,6 +261,12 @@ commit: | `SceneToUSD` | flip rows when writing PNG (`:195`) and EXR (`:167`), which are top-down formats | | `calcTangentsForTriangleMesh` | default `flipTexCoordY` to `false`; both callers (`import_ASSIMP.cpp:217`, `import_GLTF.cpp:1213`) pass `false` | +One wrinkle this table missed: glTF's tangent path does not read the texture +coordinates it just stored, it re-reads the raw accessor data, which is still +glTF's v-down. So it flips that local copy too before handing it to +mikktspace. ASSIMP's caller reads the stored coordinates and simply takes the +new default. + Normal maps are unaffected. mikktspace is fed v-up coordinates in both the old and new schemes — today via `flipTexCoordY = true` undoing the v-down convention, afterward because the coordinates are already v-up — so the tangent @@ -255,6 +287,10 @@ at: - Nothing caches across unrelated user actions, so a texture edited on disk is picked up on the next import without invalidation machinery. +> **Not implemented.** `ImageCache` exists and is scoped to a `Scene`, but each +> importer still constructs its own, so reuse stops at the importer-call +> boundary. This is [remaining work item 1](#1-one-cache-per-import-not-per-importer). + `ImageSource::id` is the resolved absolute path for file-backed images and an importer-scoped stable string otherwise (`"gltf::image"`, `"assimp::embedded"`, `"pbrt:::normal"`). The cache key is @@ -264,33 +300,122 @@ importer-scoped stable string otherwise (`"gltf::image"`, The glTF path already keeps the file's integer type and uses ANARI's `*_SRGB` element formats; the shared path expands to `ANARI_FLOAT32*` and applies -`pow(x, 2.2)` in software (`importer_common.cpp:455`). Moving the shared path -onto native types would cut texture memory 4× for the common 8-bit case and -replace the 2.2 gamma approximation with the true sRGB EOTF the device -applies, deleting `applyGamma22InPlace` and the comment at `:448` explaining -why the OIIO path has to imitate stb's approximation. This is worth doing but -is a separable change; it should not ride along with the orientation fix. - -## Sequencing - -0. **Characterize.** One tiny asset per format (glTF, OBJ, USD, PBRT, ASSIMP, - DDS, EXR, TIFF, HDRI) with a distinguishable corner, and a test in - `tests/test_Importers.cpp` asserting the texel that the material's uv `(0,0)` - resolves to. This is what makes the flip safe, and it fails for OBJ and USD - before any code moves. -1. **Introduce `tsd/io/images`** with the contract, `ImageCache`, and the - decoders moved over unchanged. `importTexture` and friends become shims. - No behavior change. -2. **Flip**, together with every importer change in the table above. Behavior - change, gated by step 0. -3. **Fold in the stragglers** — `importGLTFTexture` onto `acquireDecoded`, - `importHeightAsNormalMap` and `HDRImage` onto the cache. Delete the dead - `flipNormalMapY` parameter. -4. **Native element types** (follow-on above). - -Steps 1–3 are mechanical once step 0 exists. Step 2 is the only one that can -regress a scene, and it is the whole point. - -An ADR recording the orientation contract belongs with step 2, since it is the -kind of decision `docs/adr/` exists for and every future decoder needs to -find it. +`pow(x, 2.2)` in software (`images/detail/decoders.cpp:251`). Moving the +shared path onto native types would cut texture memory 4× for the common +8-bit case and replace the 2.2 gamma approximation with the true sRGB EOTF the +device applies, deleting `applyGamma22InPlace` and the comment above it +explaining why the OIIO path has to imitate stb's approximation. This is worth +doing but is a separable change; it should not ride along with the orientation +fix. + +## Status + +| Step | | | +|---|---|---| +| 0 | Characterize | **Landed** `98e4a1e8` | +| 1 | Introduce `tsd/io/images` | **Landed** `d27547b8` | +| 2 | Flip | **Landed** `c4106e72` | +| 3 | Fold in the stragglers | **Landed in part** `860cf337` | +| 4 | Native element types | **Not started** | + +Step 0 became `tests/test_ImageImport.cpp` (tag `[ImageImport]`). It ran red +exactly where this document predicted — glTF and PBRT passed the end-to-end +assertion, OBJ and USD failed it — which is the local evidence for the survey +above, arrived at independently of the source reading that produced it. + +Two corrections to this document's step 0, established before it was written: +fixtures are synthesized into the temp directory rather than checked in, +following the TGA in `tests/test_UsdImport.cpp` and the TIFF in +`tests/test_Importers.cpp`; and the assertion is a scene query rather than a +render, so no device is needed. One 1x2 TGA serves OBJ, glTF, PBRT, USD, and +ASSIMP; DDS and Radiance HDR have fixtures of their own. + +## Remaining work + +Roughly in the order that pays off soonest. + +### 1. One cache per import, not per importer + +This document's [Cache lifetime](#cache-lifetime) section is the one part of +step 1 that was not implemented. Every importer still builds a function-local +`ImageCache` (`import_OBJ.cpp:59`, `import_GLTF.cpp:259`, +`import_ASSIMP.cpp:249`, `import_PBRT.cpp:2321`, `UsdImportContext.h:56`, +`import_HDRI.cpp:33`), so reuse still exists only *within* one importer call. +The stated payoff — "a USD stage that references an OBJ decodes it twice" — +is unfixed. + +What it needs: `import_file()` owning one cache and threading it to whichever +importer it dispatches to, plus the overload taking an existing `ImageCache &` +for an application importing many files as one operation. That is an +`ImageCache &` parameter across the importer signatures in `importers.hpp`, +which is why it did not ride along with a behavior change. + +Prerequisite already done: `ImageSource` ids are file-scoped +(`gltf::`), so sharing a cache across files is safe. + +### 2. Retire the shims + +`importTexture`, `importTextureFromMemory`, and `importRawTexture2D` +(`importer_common.hpp:32-54`) are pure forwarding to `ImageCache` and +`makeImageSampler`. They exist so the ~20 call sites did not churn in the same +commit as the behavior change, which has now happened. Two ways to do the same +thing is the state this work set out to remove, so they should go once +something else is touching those call sites anyway — item 1 is the natural +moment. + +### 3. PBRT's infinite light + +`loadInfiniteRadiance` (`import_PBRT.cpp:2094`, `:2107`) is the last place +outside `ImageCache` calling `scene.createArray` for image data. It consumes +`HDRImage`'s raw buffer and, for a square source, runs it through +`convertEqualAreaToEquirectangular`, whose frame of reference is documented in +terms of the buffer layout it is handed. + +It is correct as it stands. Moving it onto the cache means rewriting that +conversion's frame of reference — three sign changes — and there is no test +holding it, because a PBRT equal-area HDRI is not cheap to synthesize. Write +the fixture first. + +### 4. `HDRImage`'s own flip + +`HDRImage` reverses rows in both branches (`HDRImage.cpp:81`, `:173`) and +declares `BOTTOM_UP`. The contract holds, but "one place flips" does not. +Folding it into the shared path means teaching `decodeImageFile` about +multipart EXR and about forcing three channels; worth doing when something +else needs multipart EXR, not before. + +### 5. Native element types + +Unchanged from [the follow-on above](#follow-on-preserve-element-types), and +still the largest single win: the shared path expands every image to +`ANARI_FLOAT32_*` (`decoders.cpp:43`) and applies `pow(x, 2.2)` in software +(`decoders.cpp:251`). Moving to the file's own type and ANARI's `*_SRGB` +formats — which the glTF path already does — would cut texture memory 4× for +the common 8-bit case and replace the gamma approximation with the true sRGB +EOTF the device applies, deleting `applyGamma22InPlace` and the comment above +it explaining why the OIIO path has to imitate stb. + +Note this interacts with the orientation tests: they read texels through a +helper that already handles both `ANARI_FLOAT32_*` and `ANARI_UFIXED8_*`, so +they should survive the change unmodified. That is deliberate. + +### 6. Test coverage gaps + +- `decodeExr` and `decodeOiio` both declare `RowOrder::TOP_DOWN` with nothing + asserting it. An EXR fixture is cheap — tinyexr can write one. A TIFF + fixture exists in `tests/test_Importers.cpp` but is 1x1, so it says nothing + about row order; widening it to 1x2 would. +- `convertEqualAreaToEquirectangular` is untested, which is what blocks item 3. + +## Discovered along the way + +Not part of this work, recorded because the tests surfaced it and it will +mislead someone otherwise. + +**ASSIMP binds no textures for OBJ.** ASSIMP reports a GL-style shading model +for OBJ files, and that branch of `importASSIMPMaterials` +(`import_ASSIMP.cpp`, the `else // GL-like dflt. material` case) sets colour +and opacity and nothing else — no texture slot is read. An OBJ with a +`map_Kd` therefore imports untextured through ASSIMP, while the same file +imports correctly through `import_OBJ`. This is why the ASSIMP orientation +test goes through the glTF fixture instead. From 86a55014194eeeee26915840dc6ace47dbb4afa5 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 13 Aug 2026 21:53:43 -0500 Subject: [PATCH 25/78] fix(io): scale default curve/point radius to prim bounds on USD import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Width-less BasisCurves and Points prims (common in Blender hair exports) previously left the geometry radius unset, inheriting ANARI's default of 1 world unit — hair strands imported as giant overlapping tubes. Fall back to an explicit radius of 1e-3 of the prim's own bounding-box diagonal so strands stay hair-like at any scene scale; authored widths convert exactly as before. Co-Authored-By: Claude Fable 5 --- .../io/importers/detail/usd/UsdGeometry.cpp | 38 ++++++--- tsd/tests/test_UsdImport.cpp | 82 +++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 5701c3036..4cc3bba34 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -22,6 +22,7 @@ #include // std #include +#include #include #include #include @@ -121,21 +122,38 @@ std::vector bakedPositions( return retval; } -// USD authors widths; TSD geometry takes radii. +// USD authors widths; TSD geometry takes radii. Prims with no authored +// widths (common for Blender hair exports) would otherwise inherit ANARI's +// default radius of 1 world unit, which dwarfs most scenes — instead fall +// back to a small radius scaled to the prim's own bounds so strands stay +// hair-like at any scene scale. void bindRadiiFromWidths(ImportContext &ctx, GeometryRef &geometry, const Primvar &widths, - size_t vertexCount) + const std::vector &positions) { - if (!widths.valid() || !widths.value.IsHolding()) - return; - const auto &w = widths.value.UncheckedGet(); - if (w.empty()) + const bool haveWidths = widths.valid() + && widths.value.IsHolding() + && !widths.value.UncheckedGet().empty(); + + if (!haveWidths) { + float3 lo(std::numeric_limits::max()); + float3 hi(std::numeric_limits::lowest()); + for (const auto &p : positions) { + lo = tsd::math::min(lo, p); + hi = tsd::math::max(hi, p); + } + const float diagonal = + positions.empty() ? 0.f : tsd::math::length(hi - lo); + geometry->setParameter( + "radius", diagonal > 0.f ? 1e-3f * diagonal : 1e-3f); return; + } + const auto &w = widths.value.UncheckedGet(); std::vector radii; - radii.reserve(vertexCount); - for (size_t i = 0; i < vertexCount; ++i) + radii.reserve(positions.size()); + for (size_t i = 0; i < positions.size(); ++i) radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); auto array = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); @@ -711,7 +729,7 @@ std::vector convertPoints(ImportContext &ctx, bindRadiiFromWidths(ctx, geometry, readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), - positions.size()); + positions); return {ctx.scene.createSurface(primPath.GetText(), geometry, @@ -764,7 +782,7 @@ std::vector convertCurves(ImportContext &ctx, bindRadiiFromWidths(ctx, geometry, readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), - positions.size()); + positions); return {ctx.scene.createSurface(primPath.GetText(), geometry, diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 66e5c191e..d82cfa313 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -80,6 +80,7 @@ SCENARIO("Import report counts skipped prims by reason", "[UsdImport]") #include "tsd/io/importers.hpp" #include "tsd/scene/Scene.hpp" // std +#include #include #include #include @@ -2456,5 +2457,86 @@ def Mesh "Real" } } +SCENARIO("Width-less curves and points get a bounds-scaled radius", + "[UsdImport]") +{ + // Blender hair exports commonly omit widths; without an explicit radius the + // ANARI default of 1 world unit dwarfs most scenes. + GIVEN("A Stage with a widthless curve, a widthed curve, and widthless points") + { + StageFixture stage("tsd_test_usd_widthless_curves.usda", R"(#usda 1.0 + +def Xform "World" +{ + def BasisCurves "Hair" + { + uniform token type = "linear" + int[] curveVertexCounts = [4] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)] + } + + def BasisCurves "Rope" + { + uniform token type = "linear" + int[] curveVertexCounts = [2] + point3f[] points = [(0, 0, 0), (1, 0, 0)] + float[] widths = [0.2, 0.2] (interpolation = "vertex") + } + + def Points "Sprinkles" + { + point3f[] points = [(0, 0, 0), (2, 0, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The widthless curve's radius scales with its bounds") + { + auto geometry = findGeometry(scene, "/World/Hair"); + REQUIRE(geometry); + REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::curve); + REQUIRE(geometry->parameterValueAsObject( + "vertex.radius") + == nullptr); + + const auto radius = geometry->parameterValueAs("radius"); + REQUIRE(radius.has_value()); + REQUIRE(*radius == Approx(1e-3f * std::sqrt(3.f))); + } + + THEN("Authored widths still become per-vertex radii") + { + auto geometry = findGeometry(scene, "/World/Rope"); + REQUIRE(geometry); + + auto *radii = geometry->parameterValueAsObject( + "vertex.radius"); + REQUIRE(radii != nullptr); + REQUIRE(radii->size() == 2); + REQUIRE(radii->dataAs()[0] == Approx(0.1f)); + REQUIRE_FALSE(geometry->parameterValueAs("radius").has_value()); + } + + THEN("Widthless points scale the same way") + { + auto geometry = findGeometry(scene, "/World/Sprinkles"); + REQUIRE(geometry); + REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::sphere); + + const auto radius = geometry->parameterValueAs("radius"); + REQUIRE(radius.has_value()); + REQUIRE(*radius == Approx(2e-3f)); + } + } + } +} + #endif // TSD_USE_USD From b57afb89b82f5f28d89784f08b22e18bdbbe7d9e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 14 Aug 2026 14:17:55 -0500 Subject: [PATCH 26/78] rename matx --> mtlx --- tsd/apps/interactive/scivisStudio/ProjectContext.cpp | 4 ++-- .../scivisStudio/modals/AddStaticDatasetDialog.cpp | 2 +- tsd/apps/interactive/viewer/README.md | 2 +- tsd/apps/tools/README.md | 2 +- tsd/apps/tools/tsdOffline.cpp | 2 +- tsd/docs/usd-materialx-known-gaps.md | 6 +++--- tsd/src/tsd/app/Context.cpp | 4 ++-- tsd/src/tsd/io/importers.hpp | 2 +- tsd/src/tsd/io/importers/import_file.cpp | 2 +- tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp | 2 +- tsd/tests/test_UsdImport.cpp | 4 ++-- 11 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 2d4aa9b85..b7208f284 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -1817,8 +1817,8 @@ const char *toString(tsd::io::ImporterType importerType) return "TRK"; case tsd::io::ImporterType::USD: return "USD"; - case tsd::io::ImporterType::USD_MATX: - return "USD_MATX"; + case tsd::io::ImporterType::USD_MTLX: + return "USD_MTLX"; case tsd::io::ImporterType::VTP: return "VTP"; case tsd::io::ImporterType::VTU: diff --git a/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp index f33483b2f..0610777c6 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp @@ -49,7 +49,7 @@ constexpr std::array SOURCES = {{ {"SWC", tsd::io::ImporterType::SWC}, {"TRK", tsd::io::ImporterType::TRK}, {"USD", tsd::io::ImporterType::USD}, - {"USD_MATX", tsd::io::ImporterType::USD_MATX}, + {"USD_MTLX", tsd::io::ImporterType::USD_MTLX}, {"VTP", tsd::io::ImporterType::VTP}, {"VTU", tsd::io::ImporterType::VTU}, {"XYZDP", tsd::io::ImporterType::XYZDP}, diff --git a/tsd/apps/interactive/viewer/README.md b/tsd/apps/interactive/viewer/README.md index d3fea93b0..49842c7df 100644 --- a/tsd/apps/interactive/viewer/README.md +++ b/tsd/apps/interactive/viewer/README.md @@ -78,7 +78,7 @@ Use one of these flags before filenames to select how those files are loaded: - `-swc` - `-trk` - `-usd` -- `-usd_matx` (USD, emitting native MaterialX materials rather than a portable +- `-usd_mtlx` (USD, emitting native MaterialX materials rather than a portable mapping; falls back per material where no MaterialX network converts) - `-vtp` - `-vtu` diff --git a/tsd/apps/tools/README.md b/tsd/apps/tools/README.md index a5ca5538b..7ebe598f3 100644 --- a/tsd/apps/tools/README.md +++ b/tsd/apps/tools/README.md @@ -88,7 +88,7 @@ override flags (`--campos`, `--lookpos`, `--upvec`, `--fovy`). Use `-tsd ` to load a Scene Archive. Foreign-format importer flags include `-gltf`, `-obj`, `-ply`, `-volume`, `-hdri`, `-silo`, `-usd`, -`-usd_matx`, +`-usd_mtlx`, `-assimp`, `-axyz`, `-e57xyz`, `-pdb`, `-swc`, `-trk`, `-nbody`, and `-l`/`--layer`. diff --git a/tsd/apps/tools/tsdOffline.cpp b/tsd/apps/tools/tsdOffline.cpp index f0f9cfb3a..ab6d25864 100644 --- a/tsd/apps/tools/tsdOffline.cpp +++ b/tsd/apps/tools/tsdOffline.cpp @@ -146,7 +146,7 @@ static void printUsage(const char *programName) std::cout << " -silo Import Silo files\n"; std::cout << " -usd Import USD files\n"; std::cout - << " -usd_matx Import USD files as native MaterialX\n"; + << " -usd_mtlx Import USD files as native MaterialX\n"; std::cout << " -l, --layer Specify layer name for following imports\n"; std::cout diff --git a/tsd/docs/usd-materialx-known-gaps.md b/tsd/docs/usd-materialx-known-gaps.md index a1303eeb3..16e96561c 100644 --- a/tsd/docs/usd-materialx-known-gaps.md +++ b/tsd/docs/usd-materialx-known-gaps.md @@ -105,16 +105,16 @@ the above. Unrelated to USD import, and in `devices/`, not TSD. Reproducing any of this needs three things that are not in the repo: the reference asset, a TSD build with `TSD_USE_USD=ON` and `TSD_USE_OIIO=ON` (the asset's textures are TIFF), and a VisRTX build with both MDL and MaterialX -enabled, installed where `anari::loadLibrary` finds `visrtx_matx`. A standalone +enabled, installed where `anari::loadLibrary` finds `visrtx_mtlx`. A standalone TSD build is enough for TSD's own side; the device comes from the parent repo. `tsdOffline` defaults to the `visrtx` library, which has no MaterialX shader generation and so reports none of the failures above -- pass `--lib -visrtx_matx` explicitly. It also prompts for a camera: +visrtx_mtlx` explicitly. It also prompts for a camera: ```bash F=.../ShdrPlygrnd/ShdrPlygrnd_OpenPBR.usda -echo 8 | ./tsdOffline --lib visrtx_matx -usd_matx $F -o /tmp/out.png -s 1 -w 128 -h 96 > /tmp/run.log 2>&1 +echo 8 | ./tsdOffline --lib visrtx_mtlx -usd_mtlx $F -o /tmp/out.png -s 1 -w 128 -h 96 > /tmp/run.log 2>&1 ``` Redirect with `> log 2>&1`, not `2>&1 > log`, or the ANARI errors miss the file. diff --git a/tsd/src/tsd/app/Context.cpp b/tsd/src/tsd/app/Context.cpp index 0e80a22c9..7877e7869 100644 --- a/tsd/src/tsd/app/Context.cpp +++ b/tsd/src/tsd/app/Context.cpp @@ -130,8 +130,8 @@ void Context::parseCommandLine(std::vector &args) importerType = tsd::io::ImporterType::TRK; else if (arg == "-usd") importerType = tsd::io::ImporterType::USD; - else if (arg == "-usd_matx") - importerType = tsd::io::ImporterType::USD_MATX; + else if (arg == "-usd_mtlx") + importerType = tsd::io::ImporterType::USD_MTLX; else if (arg == "-vtp") importerType = tsd::io::ImporterType::VTP; else if (arg == "-vtu") diff --git a/tsd/src/tsd/io/importers.hpp b/tsd/src/tsd/io/importers.hpp index 1583439a9..943b30f67 100644 --- a/tsd/src/tsd/io/importers.hpp +++ b/tsd/src/tsd/io/importers.hpp @@ -162,7 +162,7 @@ enum class ImporterType SWC_SDF, TRK, USD, - USD_MATX, // native MaterialX materials instead of a portable mapping + USD_MTLX, // native MaterialX materials instead of a portable mapping VTP, VTU, XYZDP, diff --git a/tsd/src/tsd/io/importers/import_file.cpp b/tsd/src/tsd/io/importers/import_file.cpp index 2b0361a82..559a5ce38 100644 --- a/tsd/src/tsd/io/importers/import_file.cpp +++ b/tsd/src/tsd/io/importers/import_file.cpp @@ -108,7 +108,7 @@ void import_file(Scene &scene, tsd::io::import_TRK(scene, animMgr, file.c_str(), root); else if (f.first == ImporterType::USD) tsd::io::import_USD(scene, animMgr, file.c_str(), root); - else if (f.first == ImporterType::USD_MATX) { + else if (f.first == ImporterType::USD_MTLX) { UsdImportOptions options; options.materialMode = UsdMaterialMode::MATERIALX; tsd::io::import_USD(scene, animMgr, file.c_str(), root, options); diff --git a/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp b/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp index 9ddd1049a..5c25f389f 100644 --- a/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp +++ b/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp @@ -50,7 +50,7 @@ constexpr ImporterChoice IMPORTERS[] = { {"SWC_SDF", tsd::io::ImporterType::SWC_SDF}, {"TRK", tsd::io::ImporterType::TRK}, {"USD", tsd::io::ImporterType::USD}, - {"USD_MATX", tsd::io::ImporterType::USD_MATX}, + {"USD_MTLX", tsd::io::ImporterType::USD_MTLX}, {"VTP", tsd::io::ImporterType::VTP}, {"VTU", tsd::io::ImporterType::VTU}, {"XYZDP", tsd::io::ImporterType::XYZDP}, diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index d82cfa313..1cc5119c3 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -1404,13 +1404,13 @@ def Xform "World" // The importer's MaterialX mode is only reachable from an application // through an Importer Type, so the dispatch is worth pinning separately // from the option it sets. - WHEN("The file is imported through the USD_MATX Importer Type") + WHEN("The file is imported through the USD_MTLX Importer Type") { tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); tsd::io::import_file(scene, animMgr, - {tsd::io::ImporterType::USD_MATX, stage.path()}); + {tsd::io::ImporterType::USD_MTLX, stage.path()}); THEN("MaterialX materials arrive without asking for options") { From dcd0fbec7bba8c2645a3f9f133b52589c872c12f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 14 Aug 2026 14:52:08 -0500 Subject: [PATCH 27/78] fix(io): store decoded images top-down, as ANARI addresses them ANARI addresses texture coordinate (0, 0) at an image's upper-left corner. The previous commit on this branch read it as the lower-left and stored every array bottom-up, so this reverses the contract and every uv conversion that followed from it. Row 0 of a decoded array is the picture's top row again, which is what stb, tinyexr, OIIO, and tinygltf all hand back, so nothing is reordered on the texture path at all now. The direction each importer converts reverses with it: - ImageCache stores TOP_DOWN, and the order is asked for per ImageSource rather than fixed for the whole tree. - glTF stops flipping v on vertex.attributeN and on the raw accessor data it re-reads for tangents; it is already v-down. - OBJ, USD, and PBRT flip v; ASSIMP asks for aiProcess_FlipUVs again. - SceneToUSD writes arrays straight to PNG and EXR again. - calcTangentsForTriangleMesh defaults flipTexCoordY back to true. - PBRT's height-derived normal map takes its v gradient negated again. A format that carries a uv transform of its own has it conjugated by the vertex flip rather than flipped twice: vs*v + (1 - vs - vd). That restores PBRT's term and adds the equivalent to USD's UsdTransform2d and ASSIMP's aiUVTransform, neither of which had one. ASSIMP's also packed its translation into the w row of the matrix, where ANARI never reads it, so that translation was silently dropped; it moves to inOffset. USD flips v on the primvar a material reads as its texture coordinates rather than on every float2 primvar, so a primvar in a spare attribute slot is still bound as authored. A Surface reading a shared vertex primvar as uv gets its own reversed copy. An hdri light's radiance is mapped over the sphere by the light rather than addressed by an image sampler, so import_HDRI and the USD dome light ask the cache for BOTTOM_UP -- the order that path has always been given -- and are unchanged. Block-compressed DDS decodes top-down, which is what a sampler wants, so its v-flip no longer fires. The mechanism stays, keyed on the cache having failed to reorder the texels rather than on the format, and a test reaches it by asking for the other row order. The characterization suite is green: the end-to-end assertion that a quad's top corner samples the picture's top row holds for all five importers. Studio's array preview gets a free fix, since ImGui draws row 0 at the top. Co-Authored-By: Claude Fable 5 --- .../0014-store-images-in-anari-orientation.md | 32 +++--- tsd/docs/tsd-io-image-import.md | 101 +++++++++--------- tsd/src/tsd/io/CONTEXT.md | 12 ++- tsd/src/tsd/io/exporters/SceneToUSD.cpp | 84 ++++++--------- tsd/src/tsd/io/images/ImageCache.cpp | 41 +++---- tsd/src/tsd/io/images/ImageCache.hpp | 20 +++- tsd/src/tsd/io/importers/detail/HDRImage.h | 5 +- .../io/importers/detail/importer_common.hpp | 7 +- .../io/importers/detail/usd/UsdGeometry.cpp | 56 +++++++--- .../tsd/io/importers/detail/usd/UsdLights.cpp | 6 +- .../io/importers/detail/usd/UsdMaterials.cpp | 38 ++++--- tsd/src/tsd/io/importers/import_ASSIMP.cpp | 33 ++++-- tsd/src/tsd/io/importers/import_GLTF.cpp | 11 +- tsd/src/tsd/io/importers/import_HDRI.cpp | 6 +- tsd/src/tsd/io/importers/import_OBJ.cpp | 10 +- tsd/src/tsd/io/importers/import_PBRT.cpp | 18 ++-- tsd/tests/test_ImageImport.cpp | 55 ++++++---- tsd/tests/test_UsdImport.cpp | 5 +- 18 files changed, 303 insertions(+), 237 deletions(-) diff --git a/tsd/docs/adr/0014-store-images-in-anari-orientation.md b/tsd/docs/adr/0014-store-images-in-anari-orientation.md index 8359a7a3d..29e5157af 100644 --- a/tsd/docs/adr/0014-store-images-in-anari-orientation.md +++ b/tsd/docs/adr/0014-store-images-in-anari-orientation.md @@ -1,21 +1,25 @@ # Store images in ANARI orientation A decoded image resident in a TSD Scene is stored in ANARI orientation: the -array's row 0 is the bottom row of the picture, so texture coordinate `(0, 0)` -addresses the lower-left corner. Importers hand ANARI texture coordinates in -ANARI's convention, converting from the source format's where they differ — -glTF flips `v` because glTF's runs down the image, while PBRT stops flipping it -and ASSIMP stops asking for `aiProcess_FlipUVs`, both of those formats being -v-up already. +array's row 0 is the top row of the picture, because ANARI addresses texture +coordinate `(0, 0)` at the image's upper-left corner. Importers hand ANARI +texture coordinates in ANARI's convention, converting from the source format's +where they differ — glTF's `v` already runs down the image and is passed +through, while OBJ, USD, PBRT, and ASSIMP are all v-up and have their `v` +reversed. A format that also carries a uv transform of its own has it +conjugated by that reversal rather than flipped twice: PBRT's `vscale`/`vdelta` +and USD's `UsdTransform2d` both become `vs*v + (1 - vs - vd)`. + Decoders declare the row order their library produced and `ImageCache` normalizes; no importer flips texels itself. Previously each of seven decode paths carried its own unstated assumption, and the assumptions cancelled for glTF, ASSIMP, and PBRT but not for OBJ and USD, whose textures rendered -mirrored. Two consequences follow from the contract: anything writing a scene -array back out as a top-down image format must reverse its rows, which -`SceneToUSD` now does for PNG and EXR; and block-compressed DDS, whose 4×4 -blocks cannot be row-reversed without decoding and re-encoding, stays as -authored and instead gets a `v`-flip composed into its sampler's -`inTransform`/`inOffset` — which is why `makeImageSampler` owns those two -parameters outright and takes the importer's own uv transform through -`SamplerSettings`. +mirrored. Two consequences follow from the contract: an image bound somewhere +that is not an image sampler asks `ImageCache` for the order that consumer +wants — an `hdri` light's `radiance` runs bottom-up, so it says so on its +`ImageSource`; and block-compressed DDS, whose 4×4 blocks cannot be +row-reversed without decoding and re-encoding, would stay as authored and +instead get a `v`-flip composed into its sampler's `inTransform`/`inOffset` +— which is why `makeImageSampler` owns those two parameters outright and takes +the importer's own uv transform through `SamplerSettings`. No decoder in the +tree emits bottom-up rows today, so that path is dormant rather than dead. diff --git a/tsd/docs/tsd-io-image-import.md b/tsd/docs/tsd-io-image-import.md index e601d6661..a1ba3dbef 100644 --- a/tsd/docs/tsd-io-image-import.md +++ b/tsd/docs/tsd-io-image-import.md @@ -1,7 +1,12 @@ # Central image import for tsd_io -> **Status: steps 0-3 landed** (`98e4a1e8`..`21d4d741` on `usd-import-rework`). -> The contract itself is recorded in +> **Status: steps 0-3 landed** (`98e4a1e8`..`21d4d741` on `usd-import-rework`), +> **then corrected**: the first version of this work read ANARI as addressing +> texture coordinate `(0, 0)` at an image's *lower*-left corner. ANARI +> specifies the upper-left. The contract now stored is top-down, and the +> direction of every `v` conversion below reverses with it; the parts of this +> document that still name the old direction are marked. The contract itself +> is recorded in > [ADR 0014](adr/0014-store-images-in-anari-orientation.md); this document > keeps the survey that motivated it and tracks what is left. The Survey below > describes the tree *before* the change and is retained as the record of why. @@ -17,10 +22,10 @@ came out vertically mirrored. Nothing downstream corrected it: no render index or sampler code in TSD flipped anything. This proposed one image-import component with a single stated orientation -contract — decoded texels are stored in ANARI orientation, row 0 is the bottom -row — plus a scene-scoped cache so a texture referenced from many places is -decoded once. The contract and the component landed; the cache is still scoped -to one importer call rather than one import. +contract — decoded texels are stored in ANARI orientation, which is row 0 is +the *top* row — plus a scene-scoped cache so a texture referenced from many +places is decoded once. The contract and the component landed; the cache is +still scoped to one importer call rather than one import. ## Survey @@ -69,20 +74,21 @@ three importers and disagree in two: | OBJ | v-up (`vt` spec) | passed through (`import_OBJ.cpp:132`) | top | **mirrored** | | USD | v-up (UsdPreviewSurface / MaterialX `st`) | passed through | top | **mirrored** | -So the working importers are all on the GL contract — top-down image, v-down -coordinates — and the two that hand ANARI genuinely v-up coordinates are -broken. `import_PBRT.cpp:909` states this explicitly and calls the v-down -convention "ANARI's", which is backwards; the same comment then documents the -`(1 - vs - vd)` term in `applyPbrtUvTransform` as compensation for the double -flip. - -Two further consequences of storing every array upside down relative to -ANARI's definition: - -- Anything that reads a scene's texture array back *as an image* sees it - flipped. `SceneToUSD.cpp:167`/`:195` writes arrays straight to EXR and PNG, - both of which are top-down formats — correct today only because the arrays - are top-down too. +So the working importers are all on the top-down image, v-down coordinates +contract — which is ANARI's — and the two that hand ANARI v-up coordinates +against a top-down array are broken. `import_PBRT.cpp:909` states this +explicitly and calls the v-down convention "ANARI's", which is right; the same +comment then documents the `(1 - vs - vd)` term in `applyPbrtUvTransform` as +compensation for the flip it applies per vertex. + +The fix is therefore to make the two broken importers convert like the three +working ones, and to state the contract the three already follow so the next +decoder cannot pick a different one. Two things depend on it: + +- Anything that reads a scene's texture array back *as an image* must agree + with it. `SceneToUSD.cpp:167`/`:195` writes arrays straight to EXR and PNG, + both of which are top-down formats, and its `UsdTransform2d` reverses `v` + for USD's v-up `st` — both correct under a top-down contract. - `calcTangentsForTriangleMesh`'s `flipTexCoordY` parameter (`importer_common.cpp:641`, defaulting to `true`) exists solely to undo the v-down convention before handing coordinates to mikktspace. @@ -116,8 +122,8 @@ height map uses `path + "::normal"` (`import_PBRT.cpp:1219`). ### The contract > A decoded image resident in a TSD scene is stored in ANARI orientation: the -> array's row 0 is the bottom row of the picture, so texture coordinate -> `(0, 0)` addresses the image's lower-left corner. Importers hand ANARI +> array's row 0 is the top row of the picture, so texture coordinate +> `(0, 0)` addresses the image's upper-left corner. Importers hand ANARI > texture coordinates in ANARI's convention, converting from the source > format's convention where they differ. @@ -146,7 +152,7 @@ struct ImageSource ColorSpace colorSpace = ColorSpace::SRGB; }; -// A decoded image resident in a Scene, in ANARI orientation. +// A decoded image resident in a Scene, in the row order its source asked for. struct Image { tsd::scene::ArrayRef texels; @@ -217,15 +223,16 @@ behavior change. ### Where the flip happens One place: `ImageCache`'s store step, between decode and `Array::setData`. Each -decoder reports `RowOrder`; stb, tinyexr, OIIO, and tinygltf report -`TOP_DOWN`, and the cache reverses rows before the texels reach the scene. +decoder reports `RowOrder` and each `ImageSource` asks for one, and the cache +reverses rows between them before the texels reach the scene. -> **As landed:** `HDRImage` did *not* stop flipping. It keeps its own decode, -> because it handles multipart EXR and forces three channels and the shared -> path does neither, and it declares `BOTTOM_UP` rather than flipping twice. -> That satisfies the contract — a decoder declares, the cache normalizes — -> but it does mean two places in the tree reverse rows. See -> [Remaining work](#remaining-work). +> **As corrected:** stb, tinyexr, OIIO, and tinygltf all report `TOP_DOWN`, +> which is what a sampled image is stored as, so nothing is reversed on the +> texture path today. `HDRImage` reports `BOTTOM_UP` and feeds `hdri` lights, +> whose `radiance` is mapped over the sphere by the light rather than +> addressed by a sampler; those `ImageSource`s ask for `BOTTOM_UP` and are +> likewise not reversed. The mechanism is what holds the contract for the next +> decoder, not something any path exercises now. **Block-compressed DDS is the one exception.** BC blocks are 4×4, so a vertical flip requires decode and re-encode, which defeats the point of @@ -253,24 +260,20 @@ commit: | Importer | Change | |---|---| -| glTF | flip `v` when building `vertex.attributeN` (`import_GLTF.cpp:1053`), since glTF is v-down | -| ASSIMP | drop `aiProcess_FlipUVs` (`import_ASSIMP.cpp:695`); assimp's default output is already v-up | -| PBRT | drop `v = 1 - v` at `:221` and `:429`; simplify `applyPbrtUvTransform` (`:913`) to plain `(us*u + ud, vs*v + vd)` and delete the compensating comment at `:906` | -| OBJ | none — becomes correct | -| USD | none — becomes correct | -| `SceneToUSD` | flip rows when writing PNG (`:195`) and EXR (`:167`), which are top-down formats | -| `calcTangentsForTriangleMesh` | default `flipTexCoordY` to `false`; both callers (`import_ASSIMP.cpp:217`, `import_GLTF.cpp:1213`) pass `false` | - -One wrinkle this table missed: glTF's tangent path does not read the texture -coordinates it just stored, it re-reads the raw accessor data, which is still -glTF's v-down. So it flips that local copy too before handing it to -mikktspace. ASSIMP's caller reads the stored coordinates and simply takes the -new default. - -Normal maps are unaffected. mikktspace is fed v-up coordinates in both the old -and new schemes — today via `flipTexCoordY = true` undoing the v-down -convention, afterward because the coordinates are already v-up — so the tangent -basis is invariant, and the fetch coordinates and row order change together. +| glTF | none — already v-down, like ANARI | +| ASSIMP | keep `aiProcess_FlipUVs`, and conjugate `aiUVTransform`'s `v` by it, which it was not doing | +| PBRT | keep `v = 1 - v` at `:221` and `:429`, and the `(1 - vs - vd)` term in the uv transform | +| OBJ | flip `v` when building `vertex.attribute0` (`import_OBJ.cpp:132`) | +| USD | flip `v` on the primvar a material reads as texture coordinates (`UsdGeometry.cpp`), and conjugate `UsdTransform2d`'s `v` by that flip | +| `SceneToUSD` | none — writing a top-down array to PNG/EXR is already right | +| `calcTangentsForTriangleMesh` | keep `flipTexCoordY` defaulting to `true`; glTF's caller passes `true` | + +> **As corrected:** this table is the reverse of what the first version of +> this work did. It is what actually landed. + +Normal maps are unaffected. mikktspace is fed v-up coordinates throughout, via +`flipTexCoordY = true` undoing the v-down convention, so the tangent basis is +invariant. ### Cache lifetime diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index b29d5a7cd..9206cc3f7 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -146,7 +146,8 @@ same file at the same Color Space share one Image. What identifies an Image's content — a resolved absolute path for a file-backed image, or an importer-scoped stable id otherwise (`gltf::`, `assimp://embedded/`, `pbrt:::normal`) — -together with the Color Space it was decoded for. +together with the Color Space it was decoded for and the Row Order it is +stored in. _Avoid_: texture key, cache key **Image Cache**: @@ -157,9 +158,12 @@ _Avoid_: texture cache **Row Order**: Which row of a picture a texel array stores first. Decoders declare the Row -Order their library produced; the Image Cache normalizes to ANARI -orientation — row 0 is the bottom row — and no importer flips texels itself. -See [ADR 0014](../../../docs/adr/0014-store-images-in-anari-orientation.md). +Order their library produced and an Image Source asks for the one its consumer +wants; the Image Cache normalizes between them, and no importer flips texels +itself. A sampled image is stored top-down — row 0 is the top row, so texture +coordinate (0, 0) addresses the upper-left corner, which is where ANARI +addresses it. See +[ADR 0014](../../../docs/adr/0014-store-images-in-anari-orientation.md). _Avoid_: flipped, vertical orientation, y-up **Color Space**: diff --git a/tsd/src/tsd/io/exporters/SceneToUSD.cpp b/tsd/src/tsd/io/exporters/SceneToUSD.cpp index 0b36784d5..77f383bd0 100644 --- a/tsd/src/tsd/io/exporters/SceneToUSD.cpp +++ b/tsd/src/tsd/io/exporters/SceneToUSD.cpp @@ -49,7 +49,6 @@ #include #include #include -#include #include #include #include @@ -127,25 +126,6 @@ pxr::SdfPath allocateUniquePath( return newPath; } -// Scene arrays are in ANARI orientation -- row 0 is the picture's bottom row -// -- while PNG and EXR are both top-down formats, so a texture written -// straight out would come back mirrored. See -// docs/adr/0014-store-images-in-anari-orientation.md. -static std::vector topDownRowsOf(const Array *image) -{ - const auto width = image->dim(0); - const auto height = image->dim(1); - const auto rowBytes = width * image->elementSize(); - - std::vector rows(rowBytes * height); - const auto *src = static_cast(image->data()); - for (size_t r = 0; r < height; ++r) - std::memcpy(rows.data() + r * rowBytes, - src + (height - 1 - r) * rowBytes, - rowBytes); - return rows; -} - static std::filesystem::path tsdSamplerToFile( pxr::UsdStageRefPtr &stage, const Sampler *sampler) { @@ -180,25 +160,24 @@ static std::filesystem::path tsdSamplerToFile( outputBasePath += ".exr"; int channels = (format == ANARI_FLOAT32_VEC3) ? 3 : 4; - textureWriteQueue.push_back( - std::async([=, rows = topDownRowsOf(image)]() -> std::string { - const char *err = nullptr; - int ret = SaveEXR(reinterpret_cast(rows.data()), - width, - height, - channels, - 0, - outputBasePath.string().c_str(), - &err); - if (ret != TINYEXR_SUCCESS && err) { - auto result = - "Failed to save "s + outputBasePath.string() + " : " + err; - FreeEXRErrorMessage(err); - return result; - } else { - return "Saved "s + outputBasePath.string(); - } - })); + textureWriteQueue.push_back(std::async([=]() -> std::string { + const char *err = nullptr; + int ret = SaveEXR(reinterpret_cast(image->data()), + width, + height, + channels, + 0, + outputBasePath.string().c_str(), + &err); + if (ret != TINYEXR_SUCCESS && err) { + auto result = + "Failed to save "s + outputBasePath.string() + " : " + err; + FreeEXRErrorMessage(err); + return result; + } else { + return "Saved "s + outputBasePath.string(); + } + })); } else { // Write as PNG for LDR data outputBasePath += ".png"; @@ -212,20 +191,19 @@ static std::filesystem::path tsdSamplerToFile( channels = 1; if (channels > 0) { - textureWriteQueue.push_back( - std::async([=, rows = topDownRowsOf(image)]() -> std::string { - int ret = stbi_write_png(outputBasePath.string().c_str(), - width, - height, - channels, - rows.data(), - width * channels); - if (!ret) { - return "Failed to save "s + outputBasePath.string(); - } else { - return "Saved "s + outputBasePath.string(); - } - })); + textureWriteQueue.push_back(std::async([=]() -> std::string { + int ret = stbi_write_png(outputBasePath.string().c_str(), + width, + height, + channels, + image->data(), + width * channels); + if (!ret) { + return "Failed to save "s + outputBasePath.string(); + } else { + return "Saved "s + outputBasePath.string(); + } + })); } } } else if (sampler->subtype() == tokens::sampler::compressedImage2D) { diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp index b595d5eee..a6e132947 100644 --- a/tsd/src/tsd/io/images/ImageCache.cpp +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -24,31 +24,25 @@ using namespace tsd::scene; namespace { -// The row order a decoded image is stored in once it is resident in a Scene. -// -// ANARI orientation: row 0 is the bottom row of the picture, so texture -// coordinate (0, 0) addresses the lower-left corner. This is what every -// decoder is normalized to and the only place in the tree that decides -// orientation. See docs/adr/0014-store-images-in-anari-orientation.md. -constexpr RowOrder SCENE_ROW_ORDER = RowOrder::BOTTOM_UP; - std::string keyOf(const ImageSource &source) { return source.id - + (source.colorSpace == ColorSpace::LINEAR ? "_linear" : "_srgb"); + + (source.colorSpace == ColorSpace::LINEAR ? "_linear" : "_srgb") + + (source.rowOrder == RowOrder::BOTTOM_UP ? "_up" : "_down"); } -// Reverse the image's rows in place so it lands in SCENE_ROW_ORDER. -void normalizeRowOrder(detail::DecodedImage &image) +// Reverse the image's rows in place so it lands in `target`, reporting whether +// it got there. This is the only place in the tree that reorders texels. +bool normalizeRowOrder(detail::DecodedImage &image, RowOrder target) { - if (image.rowOrder == SCENE_ROW_ORDER || image.height < 2) - return; + if (image.rowOrder == target || image.height < 2) + return true; if (image.blockCompressed) { // BC blocks cover 4x4 texels, so reversing rows would mean decoding and // re-encoding, which is the whole cost compressedImage2D exists to avoid. // makeImageSampler compensates in the sampler's transform instead. - return; + return false; } const size_t rowBytes = image.texels.size() / image.height; @@ -58,7 +52,8 @@ void normalizeRowOrder(detail::DecodedImage &image) first + (r + 1) * rowBytes, first + (image.height - 1 - r) * rowBytes); } - image.rowOrder = SCENE_ROW_ORDER; + image.rowOrder = target; + return true; } // Compose `v -> 1 - v` onto a sampler's uv transform, applied after whatever @@ -162,9 +157,8 @@ Image ImageCache::store( return {}; } - normalizeRowOrder(decoded); - Image image; + image.vFlipInSampler = !normalizeRowOrder(decoded, source.rowOrder); image.blockCompressed = decoded.blockCompressed; if (decoded.blockCompressed) { image.texels = m_scene->createArray(ANARI_INT8, decoded.texels.size()); @@ -208,14 +202,13 @@ SamplerRef makeImageSampler(Scene &scene, sampler->setParameter("wrapMode2", settings.wrapMode2); sampler->setParameter("filter", settings.filter); - // Block-compressed texels are the one thing normalizeRowOrder cannot - // reverse, so they are still stored as the file authored them -- top-down, - // against the bottom-up coordinates every importer now hands ANARI. Undo - // that here, composed onto the caller's own transform rather than replacing - // it, which is why makeImageSampler owns inTransform/inOffset outright. - if (settings.uvTransform || image.blockCompressed) { + // An image normalizeRowOrder could not reverse is still in the order the + // file authored, against the coordinates the importer hands ANARI. Undo that + // here, composed onto the caller's own transform rather than replacing it, + // which is why makeImageSampler owns inTransform/inOffset outright. + if (settings.uvTransform || image.vFlipInSampler) { auto uv = settings.uvTransform.value_or(UvTransform{}); - if (image.blockCompressed) + if (image.vFlipInSampler) composeVFlip(uv.transform, uv.offset); sampler->setParameter("inTransform", uv.transform); sampler->setParameter("inOffset", uv.offset); diff --git a/tsd/src/tsd/io/images/ImageCache.hpp b/tsd/src/tsd/io/images/ImageCache.hpp index 18d8a9eb7..a69e43ef5 100644 --- a/tsd/src/tsd/io/images/ImageCache.hpp +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -25,7 +25,8 @@ enum class ColorSpace LINEAR }; -// The row order a decoder produced. Declared by decoders, never by importers. +// Whether row 0 is the picture's top row or its bottom one. Decoders declare +// the order they produced; an ImageSource asks for the order it needs. enum class RowOrder { TOP_DOWN, @@ -41,15 +42,24 @@ struct ImageSource // "assimp://embedded/", "pbrt:::normal"). std::string id; ColorSpace colorSpace{ColorSpace::SRGB}; + // The row order to store the image in, which is part of what identifies it: + // the two orders are different bytes. Sampled images take the default, + // because ANARI addresses texture coordinate (0, 0) at the image's top-left. + // See docs/adr/0014-store-images-in-anari-orientation.md. + RowOrder rowOrder{RowOrder::TOP_DOWN}; }; // A decoded image resident in a Scene. struct Image { tsd::scene::ArrayRef texels; - // Block-compressed texels are the authored block stream rather than a - // texel grid, so they cannot be reordered; makeImageSampler compensates. + // Block-compressed texels are the authored block stream rather than a texel + // grid, so they cannot be reordered. bool blockCompressed{false}; + // Set when the texels could not be brought into the order the source asked + // for -- only block-compressed ones -- so makeImageSampler compensates in + // the sampler's uv transform instead. + bool vFlipInSampler{false}; explicit operator bool() const; }; @@ -109,8 +119,8 @@ struct UvTransform // How a sampler reads the image it is bound to. Everything a binding can vary // lives here, including the importer's own uv transform: `makeImageSampler` -// owns the sampler's `inTransform`/`inOffset` outright, because a -// block-compressed image needs a v-flip composed into them and a caller that +// owns the sampler's `inTransform`/`inOffset` outright, because an image that +// could not be reordered needs a v-flip composed into them and a caller that // set them afterwards would silently drop it. struct SamplerSettings { diff --git a/tsd/src/tsd/io/importers/detail/HDRImage.h b/tsd/src/tsd/io/importers/detail/HDRImage.h index 04f2eeb09..8b7ad1b3b 100644 --- a/tsd/src/tsd/io/importers/detail/HDRImage.h +++ b/tsd/src/tsd/io/importers/detail/HDRImage.h @@ -22,8 +22,9 @@ struct HDRImage unsigned width; unsigned height; unsigned numComponents; - // Both branches below emit the picture's bottom row first, which is already - // ANARI orientation. See docs/adr/0014-store-images-in-anari-orientation.md. + // Both branches below emit the picture's bottom row first, which is the + // order the hdri lights this decoder feeds want. See + // docs/adr/0014-store-images-in-anari-orientation.md. RowOrder rowOrder{RowOrder::BOTTOM_UP}; std::vector pixel; }; diff --git a/tsd/src/tsd/io/importers/detail/importer_common.hpp b/tsd/src/tsd/io/importers/detail/importer_common.hpp index 487f998cc..cd7f2ca06 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.hpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.hpp @@ -72,10 +72,9 @@ bool calcTangentsForTriangleMesh(const tsd::math::uint3 *indices, tsd::math::float4 *tangents, size_t numIndices, size_t numVertices, - // Importers hand ANARI v-up coordinates, which is what mikktspace wants, - // so this only exists for a caller reading a source format's raw v-down - // coordinates directly. - bool flipTexCoordY = false, + // mikktspace wants v-up coordinates, while the coordinates importers hand + // ANARI run down the image, so the default reverses them back. + bool flipTexCoordY = true, bool faceVaryingTangents = false); #if TSD_USE_VTK diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 4cc3bba34..8374279a4 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -143,10 +143,8 @@ void bindRadiiFromWidths(ImportContext &ctx, lo = tsd::math::min(lo, p); hi = tsd::math::max(hi, p); } - const float diagonal = - positions.empty() ? 0.f : tsd::math::length(hi - lo); - geometry->setParameter( - "radius", diagonal > 0.f ? 1e-3f * diagonal : 1e-3f); + const float diagonal = positions.empty() ? 0.f : tsd::math::length(hi - lo); + geometry->setParameter("radius", diagonal > 0.f ? 1e-3f * diagonal : 1e-3f); return; } @@ -286,6 +284,9 @@ struct TriangulatedPrimvar const char *prefix{nullptr}; size_t valuesPerTriangle{0}; ArrayRef sharedArray; + // The same vertex data with `v` reversed, for the Surfaces that read this + // primvar as their texture coordinates. + ArrayRef sharedUvArray; // Vertex data is the only kind every Surface can point at unchanged. bool isShared() const; @@ -368,21 +369,42 @@ TriangulatedPrimvars triangulatePrimvars(const pxr::HdMeshUtil &meshUtil, return retval; } -// Bind one expanded primvar onto a geometry drawing `triangles`. +// Reverse an array of texture coordinates in place. USD authors `st` v-up, +// while the coordinates TSD hands ANARI run down the image. See +// docs/adr/0014-store-images-in-anari-orientation.md. +void reverseTexCoordV(ArrayRef &array) +{ + if (array->elementType() != ANARI_FLOAT32_VEC2) + return; + auto *uv = array->mapAs(); + for (size_t i = 0; i < array->size(); ++i) + uv[i].y = 1.f - uv[i].y; + array->unmap(); +} + +// Bind one expanded primvar onto a geometry drawing `triangles`. `isUv` marks +// the one primvar this Surface's material reads as texture coordinates, which +// is the only binding whose `v` is reversed -- the same primvar bound to a +// spare attribute slot elsewhere is data TSD knows nothing about, and is bound +// as authored. void bindTrianglePrimvar(ImportContext &ctx, GeometryRef &geometry, TriangulatedPrimvar &primvar, const std::vector &triangles, - const std::string &tsdName) + const std::string &tsdName, + bool isUv = false) { ArrayRef array; if (primvar.isShared()) { - if (!primvar.sharedArray) { - primvar.sharedArray = ctx.scene.createArray( + auto &shared = isUv ? primvar.sharedUvArray : primvar.sharedArray; + if (!shared) { + shared = ctx.scene.createArray( anariTypeOfPrimvar(primvar.value), primvar.value.GetArraySize()); - primvar.sharedArray->setData(pxr::HdGetValueData(primvar.value)); + shared->setData(pxr::HdGetValueData(primvar.value)); + if (isUv) + reverseTexCoordV(shared); } - array = primvar.sharedArray; + array = shared; } else { const auto selected = gatherTrianglesValue( primvar.value, triangles, primvar.valuesPerTriangle); @@ -391,6 +413,8 @@ void bindTrianglePrimvar(ImportContext &ctx, array = ctx.scene.createArray( anariTypeOfPrimvar(selected), selected.GetArraySize()); array->setData(pxr::HdGetValueData(selected)); + if (isUv) + reverseTexCoordV(array); } geometry->setParameterObject( @@ -431,10 +455,14 @@ GeometryRef buildTriangleGeometry(ImportContext &ctx, indexArray->setData(indices.data(), indices.size()); geometry->setParameterObject("primitive.index", *indexArray); - auto bind = [&](const std::string &primvarName, const std::string &tsdName) { + auto bind = [&](const std::string &primvarName, + const std::string &tsdName, + bool isUv = false) { auto found = attributes.find(primvarName); - if (found != attributes.end()) - bindTrianglePrimvar(ctx, geometry, found->second, triangles, tsdName); + if (found != attributes.end()) { + bindTrianglePrimvar( + ctx, geometry, found->second, triangles, tsdName, isUv); + } }; // Normals, UVs, display colour, then any remaining primvars in name order so @@ -442,7 +470,7 @@ GeometryRef buildTriangleGeometry(ImportContext &ctx, const auto normalsName = pxr::HdPrimvarsSchemaTokens->normals.GetString(); const auto colorName = pxr::HdTokens->displayColor.GetString(); bind(normalsName, "normal"); - bind(uvName, "attribute0"); + bind(uvName, "attribute0", /*isUv=*/true); bind(colorName, "color"); int nextAttribute = 1; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index 834ca243d..4039edde3 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -181,7 +181,11 @@ ArrayRef readDomeRadiance(ImportContext &ctx, + std::to_string(radiometry.color.x) + "," + std::to_string(radiometry.color.y) + "," + std::to_string(radiometry.color.z); - auto acquired = ctx.textureCache.acquireDecoded({id, ColorSpace::LINEAR}, + // Stored bottom-up: an hdri light's radiance is mapped over the sphere by + // the light rather than addressed by an image sampler, so the top-left + // origin samplers are stored for does not apply to it. + auto acquired = ctx.textureCache.acquireDecoded( + {id, ColorSpace::LINEAR, RowOrder::BOTTOM_UP}, ANARI_FLOAT32_VEC3, image.width, image.height, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 9a3044813..e72205655 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -122,27 +122,34 @@ std::string uvPrimvarOfTexture( return {}; } -math::mat4 uvTransformOfTexture( +// The UsdTransform2d feeding a texture's `st`, in the form ANARI takes it. +// USD applies it to the authored v-up coordinates, and the geometry converter +// has already reversed each vertex's `v` into ANARI's convention, so `v` is +// conjugated by that reversal: 1 - (s*(1 - v) + t) == s*v + (1 - s - t). +std::optional uvTransformOfTexture( const NetworkWalker &walker, const pxr::TfToken &texturePath) { auto transformNode = walker.connectedNode(texturePath, "st"); if (walker.nodeId(transformNode) != TRANSFORM_2D_ID) - return math::IDENTITY_MAT4; + return {}; - auto retval = math::IDENTITY_MAT4; + math::float2 s(1.f, 1.f); const auto scale = walker.parameter(transformNode, "scale"); if (scale.IsHolding()) { - const auto s = scale.UncheckedGet(); - retval[0][0] = s[0]; - retval[1][1] = s[1]; + const auto value = scale.UncheckedGet(); + s = math::float2(value[0], value[1]); } + math::float2 t(0.f, 0.f); const auto translation = walker.parameter(transformNode, "translation"); if (translation.IsHolding()) { - const auto t = translation.UncheckedGet(); - retval[3][0] = t[0]; - retval[3][1] = t[1]; + const auto value = translation.UncheckedGet(); + t = math::float2(value[0], value[1]); } - return retval; + + auto retval = math::IDENTITY_MAT4; + retval[0][0] = s.x; + retval[1][1] = s.y; + return UvTransform{retval, math::float4(t.x, 1.f - s.y - t.y, 0.f, 0.f)}; } std::string wrapModeOf(const NetworkWalker &walker, @@ -769,18 +776,15 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, file = ctx.basePath + file; // Everything the binding varies goes in before the sampler is built: - // makeImageSampler owns inTransform/inOffset, because a block-compressed - // image needs a v-flip composed into them that a later setParameter here - // would drop. + // makeImageSampler owns inTransform/inOffset, because an image that could + // not be reordered needs a v-flip composed into them that a later + // setParameter here would drop. const auto wrapS = wrapModeOf(walker, texturePath, "wrapS"); const auto wrapT = wrapModeOf(walker, texturePath, "wrapT"); SamplerSettings settings; settings.wrapMode1 = wrapS.c_str(); settings.wrapMode2 = wrapT.c_str(); - if (const auto uvTransform = uvTransformOfTexture(walker, texturePath); - uvTransform != math::IDENTITY_MAT4) { - settings.uvTransform = UvTransform{uvTransform}; - } + settings.uvTransform = uvTransformOfTexture(walker, texturePath); const bool isLinear = textureIsLinear(walker, texturePath, colorRole); auto sampler = diff --git a/tsd/src/tsd/io/importers/import_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index 6a0124a35..bbc395542 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -281,9 +281,9 @@ static std::vector importASSIMPMaterials( }; // The uv transform goes to loadTexture rather than onto the returned - // sampler, because makeImageSampler owns inTransform/inOffset: a - // block-compressed image needs a v-flip composed into them, and setting - // them here afterwards would drop it. + // sampler, because makeImageSampler owns inTransform/inOffset: an image + // that could not be reordered needs a v-flip composed into them, and + // setting them here afterwards would drop it. auto getTextureUVSettings = [&](const char *pKey, unsigned int type, unsigned int index = 0) -> SamplerSettings { @@ -291,11 +291,21 @@ static std::vector importASSIMPMaterials( aiUVTransform uvTransform; if (aiGetMaterialUVTransform(assimpMat, pKey, type, index, &uvTransform) == AI_SUCCESS) { - settings.uvTransform = UvTransform{ - mat4({uvTransform.mScaling.x, 0.f, 0.f, uvTransform.mTranslation.x}, - {0.f, uvTransform.mScaling.y, 0.f, uvTransform.mTranslation.y}, - {0.f, 0.f, 1.f, 0.f}, - {0.0f, 0.0f, 0.f, 1.f})}; + // aiProcess_FlipUVs reverses the coordinates but not the transform + // authored against them, so `v` is conjugated by that flip: + // 1 - (sv*(1 - v) + tv) == sv*v + (1 - sv - tv). Translation belongs + // in the offset rather than the matrix, which ANARI applies to + // (u, v, 0, 1) and reads back only the first two components of. + const float sv = uvTransform.mScaling.y; + settings.uvTransform = + UvTransform{mat4(float4(uvTransform.mScaling.x, 0.f, 0.f, 0.f), + float4(0.f, sv, 0.f, 0.f), + float4(0.f, 0.f, 1.f, 0.f), + float4(0.f, 0.f, 0.f, 1.f)), + float4(uvTransform.mTranslation.x, + 1.f - sv - uvTransform.mTranslation.y, + 0.f, + 0.f)}; } return settings; }; @@ -701,9 +711,10 @@ void import_ASSIMP(Scene &scene, Assimp::Importer importer; - // No aiProcess_FlipUVs: assimp's own output is already v-up, which is what - // ANARI wants. See docs/adr/0014-store-images-in-anari-orientation.md. - auto importFlags = aiProcess_Triangulate | aiProcess_JoinIdenticalVertices; + // aiProcess_FlipUVs: assimp's own output is v-up, and ANARI's `v` runs down + // the image. See docs/adr/0014-store-images-in-anari-orientation.md. + auto importFlags = aiProcess_Triangulate | aiProcess_JoinIdenticalVertices + | aiProcess_FlipUVs; if (flatten) importFlags |= aiProcess_PreTransformVertices; diff --git a/tsd/src/tsd/io/importers/import_GLTF.cpp b/tsd/src/tsd/io/importers/import_GLTF.cpp index 71ba41d32..03ae79c04 100644 --- a/tsd/src/tsd/io/importers/import_GLTF.cpp +++ b/tsd/src/tsd/io/importers/import_GLTF.cpp @@ -1006,9 +1006,8 @@ static std::vector importGLTFMeshes(Scene &scene, scene.createArray(ANARI_FLOAT32_VEC2, texCoordAccessor.count); auto *texCoordDataOut = vertexTexCoordArray->mapAs(); copyStridedData(model, texCoordIt->second, texCoordDataOut); - // glTF's `v` runs down the image; ANARI's runs up it. - for (size_t v = 0; v < texCoordAccessor.count; ++v) - texCoordDataOut[v].y = 1.f - texCoordDataOut[v].y; + // glTF's `v` runs down the image, which is ANARI's convention too, + // so it goes through as authored. vertexTexCoordArray->unmap(); const std::string attributeName = @@ -1127,10 +1126,6 @@ static std::vector importGLTFMeshes(Scene &scene, : std::vector{}; auto texCoords = copyAccessorData(model, texCoordIt->second); - // Raw accessor data is glTF's v-down; match the v-up coordinates - // that landed on the geometry above. - for (auto &uv : texCoords) - uv.y = 1.f - uv.y; // Get or generate indices std::vector indices; @@ -1177,7 +1172,7 @@ static std::vector importGLTFMeshes(Scene &scene, tangents, indices.size(), posAccessor.count, - false, + true, outputFaceVaryingTangents); vertexTangentArray->unmap(); diff --git a/tsd/src/tsd/io/importers/import_HDRI.cpp b/tsd/src/tsd/io/importers/import_HDRI.cpp index 3d1cabeb4..8d0155be2 100644 --- a/tsd/src/tsd/io/importers/import_HDRI.cpp +++ b/tsd/src/tsd/io/importers/import_HDRI.cpp @@ -31,7 +31,11 @@ void import_HDRI(Scene &scene, } ImageCache cache(&scene); - auto image = cache.acquireDecoded({hdriFilename, ColorSpace::LINEAR}, + // Stored bottom-up: an hdri light's radiance is mapped over the sphere by + // the light rather than addressed by an image sampler, so the top-left + // origin samplers are stored for does not apply to it. + auto image = cache.acquireDecoded( + {hdriFilename, ColorSpace::LINEAR, RowOrder::BOTTOM_UP}, ANARI_FLOAT32_VEC3, img.width, img.height, diff --git a/tsd/src/tsd/io/importers/import_OBJ.cpp b/tsd/src/tsd/io/importers/import_OBJ.cpp index b1837f8a0..b828c6833 100644 --- a/tsd/src/tsd/io/importers/import_OBJ.cpp +++ b/tsd/src/tsd/io/importers/import_OBJ.cpp @@ -129,9 +129,13 @@ void import_OBJ(Scene &scene, const auto *t0 = texcoords + (ti0 * 2); const auto *t1 = texcoords + (ti1 * 2); const auto *t2 = texcoords + (ti2 * 2); - outTexcoords[i + 0] = ti0 >= 0 ? float2(t0[0], t0[1]) : float2(0.f); - outTexcoords[i + 1] = ti1 >= 0 ? float2(t1[0], t1[1]) : float2(0.f); - outTexcoords[i + 2] = ti2 >= 0 ? float2(t2[0], t2[1]) : float2(0.f); + // OBJ's `vt` runs up the image; ANARI's `v` runs down it. + outTexcoords[i + 0] = + ti0 >= 0 ? float2(t0[0], 1.f - t0[1]) : float2(0.f); + outTexcoords[i + 1] = + ti1 >= 0 ? float2(t1[0], 1.f - t1[1]) : float2(0.f); + outTexcoords[i + 2] = + ti2 >= 0 ? float2(t2[0], 1.f - t2[1]) : float2(0.f); } if (normals) { diff --git a/tsd/src/tsd/io/importers/import_PBRT.cpp b/tsd/src/tsd/io/importers/import_PBRT.cpp index 5255c16ef..e1767b724 100644 --- a/tsd/src/tsd/io/importers/import_PBRT.cpp +++ b/tsd/src/tsd/io/importers/import_PBRT.cpp @@ -216,6 +216,9 @@ static GeometryRef buildTriangleMesh(Scene &scene, const pbrt::Shape &shape) auto uvArr = scene.createArray(ANARI_FLOAT32_VEC2, numUV); auto *outUV = uvArr->mapAs(); std::memcpy(outUV, uv.data(), numUV * sizeof(float2)); + // PBRT's `v` runs up the image; ANARI's runs down it. + for (size_t i = 0; i < numUV; i++) + outUV[i].y = 1.f - outUV[i].y; uvArr->unmap(); geom->setParameterObject("vertex.attribute0", *uvArr); } @@ -422,6 +425,9 @@ static GeometryRef buildPlyMesh( auto *outUV = uvArr->mapAs(); std::memcpy( outUV, texcoords->buffer.get(), texcoords->count * sizeof(float2)); + // PBRT's `v` runs up the image; ANARI's runs down it. + for (size_t i = 0; i < texcoords->count; i++) + outUV[i].y = 1.f - outUV[i].y; uvArr->unmap(); geom->setParameterObject("vertex.attribute0", *uvArr); } @@ -902,8 +908,9 @@ static BakedTexture combineMix( // PBRT v4's UV-coordinate transform (`uscale`, `vscale`, `udelta`, `vdelta`), // as sampler settings. PBRT samples the image at `(us*u + ud, vs*v + vd)` in -// its v-up convention, which is now also ANARI's, so this is the transform -// verbatim. +// its v-up convention, and each vertex's `v` has already been reversed into +// ANARI's, so the transform's `v` is conjugated by that reversal: +// 1 - (vs*(1 - v) + vd) == vs*v + (1 - vs - vd). static SamplerSettings pbrtSamplerSettings(const pbrt::ParamList ¶ms) { SamplerSettings settings; @@ -919,7 +926,7 @@ static SamplerSettings pbrtSamplerSettings(const pbrt::ParamList ¶ms) float4(0.f, vs, 0.f, 0.f), float4(0.f, 0.f, 1.f, 0.f), float4(0.f, 0.f, 0.f, 1.f)}, - float4(ud, vd, 0.f, 0.f)}; + float4(ud, 1.f - vs - vd, 0.f, 0.f)}; return settings; } @@ -1243,10 +1250,7 @@ static SamplerRef importHeightAsNormalMap(Scene &scene, const float hx = raw[y * w + xp] - raw[y * w + xm]; const float hy = raw[yp * w + x] - raw[ym * w + x]; const float nx = -hx * k; - // `hy` runs down the decoded image, but the array this lands in is - // stored bottom-up, so the row axis the gradient was taken over is - // reversed relative to the `v` that will fetch it. - const float ny = hy * k; + const float ny = -hy * k; const float nz = 1.f; const float invLen = 1.f / std::sqrt(nx * nx + ny * ny + nz * nz); // Pack [-1,1] -> [0,1] (glTF normal-map convention). diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 5c075fed0..7d08755b0 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -80,7 +80,7 @@ struct TempFile // // A TGA with descriptor bit 5 clear stores its rows bottom-up, so the first // texel in the file is the *bottom* row. That is deliberate: it means this -// fixture only reports "row 0 is the bottom row" if the import layer actually +// fixture only reports "row 0 is the top row" if the import layer actually // establishes the contract, rather than passing a decoder's byte order // through and happening to agree with it. std::string tgaFixtureContents() @@ -212,8 +212,8 @@ const tsd::scene::Array *fixtureImage(tsd::scene::Scene &scene) return nullptr; } -// Resolve a texture coordinate the way ANARI resolves it: `v` runs up the -// array, so `v = 0` addresses row 0. +// Resolve a texture coordinate the way ANARI resolves it: `v` runs down the +// picture, and row 0 is the picture's top row, so `v = 0` addresses row 0. float3 sampleAsAnari(const tsd::scene::Array *image, const float2 &uv) { const auto height = image->dim(1); @@ -419,7 +419,7 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") auto sampler = tsd::io::importTexture( scene, texture.path(), cache, /*isLinear=*/true); - THEN("Row 0 of the array is the bottom row of the picture") + THEN("Row 0 of the array is the top row of the picture") { REQUIRE(sampler); auto *image = @@ -427,14 +427,17 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") REQUIRE(image != nullptr); REQUIRE(image->dim(0) == 1); REQUIRE(image->dim(1) == 2); - REQUIRE(isBottomRowColor(texelAsFloat3(image, 0))); - REQUIRE(isTopRowColor(texelAsFloat3(image, 1))); + REQUIRE(isTopRowColor(texelAsFloat3(image, 0))); + REQUIRE(isBottomRowColor(texelAsFloat3(image, 1))); } } } } -SCENARIO("An imported HDRI's radiance is in ANARI orientation", "[ImageImport]") +// An hdri light's radiance is mapped over the sphere by the light rather than +// addressed by an image sampler, so the top-left origin the sampler path is +// stored for does not reach it. It asks the cache for bottom-up rows instead. +SCENARIO("An imported HDRI's radiance runs bottom-up", "[ImageImport]") { GIVEN("A 1x2 Radiance HDR, red on top and blue on the bottom") { @@ -464,12 +467,14 @@ SCENARIO("An imported HDRI's radiance is in ANARI orientation", "[ImageImport]") } } -SCENARIO( - "Block-compressed images get their flip from the sampler", "[ImageImport]") +SCENARIO("Block-compressed images are bound as the file authored them", + "[ImageImport]") { - // BC blocks are 4x4, so the texels stay as the file authored them -- top-down - // -- and makeImageSampler compensates in the sampler's uv transform. The - // assertion is on where a coordinate lands, not on the matrix. + // BC blocks are 4x4, so the texels can never be reordered. A DDS decodes + // top-down, which is the order a sampled image is stored in, so nothing has + // to be compensated for -- but a consumer asking for the other order gets + // the compensation in the sampler's uv transform instead. The assertion is + // on where a coordinate lands, not on the matrix. auto fetchedV = [](tsd::scene::SamplerRef sampler, float v) { auto *transform = sampler->parameter("inTransform"); auto *offset = sampler->parameter("inOffset"); @@ -493,13 +498,13 @@ SCENARIO( { auto sampler = tsd::io::importTexture(scene, texture.path(), cache); - THEN("The sampler reverses v, so v = 1 reaches the block stream's top") + THEN("The sampler leaves the coordinates alone") { REQUIRE(sampler); REQUIRE(sampler->subtype() == tsd::scene::tokens::sampler::compressedImage2D); - REQUIRE(fetchedV(sampler, 1.f) == Approx(0.f).margin(1e-5)); - REQUIRE(fetchedV(sampler, 0.f) == Approx(1.f).margin(1e-5)); + REQUIRE(fetchedV(sampler, 1.f) == Approx(1.f).margin(1e-5)); + REQUIRE(fetchedV(sampler, 0.f) == Approx(0.f).margin(1e-5)); } } @@ -516,12 +521,25 @@ SCENARIO( auto sampler = tsd::io::importTexture( scene, texture.path(), cache, /*isLinear=*/false, settings); - THEN("The flip composes onto that transform rather than replacing it") + THEN("That transform reaches the sampler unchanged") { REQUIRE(sampler); - // The caller's transform sends v = 1 to 0.5; the flip then sends 0.5 - // to 0.5, and v = 0 to 1. REQUIRE(fetchedV(sampler, 1.f) == Approx(0.5f).margin(1e-5)); + REQUIRE(fetchedV(sampler, 0.f) == Approx(0.f).margin(1e-5)); + } + } + + WHEN("It is acquired for a consumer that wants the opposite row order") + { + auto image = cache.acquire({texture.path(), + tsd::io::ColorSpace::SRGB, + tsd::io::RowOrder::BOTTOM_UP}); + auto sampler = tsd::io::makeImageSampler(scene, image, texture.path()); + + THEN("The sampler reverses v, since the texels could not be") + { + REQUIRE(sampler); + REQUIRE(fetchedV(sampler, 1.f) == Approx(0.f).margin(1e-5)); REQUIRE(fetchedV(sampler, 0.f) == Approx(1.f).margin(1e-5)); } } @@ -532,7 +550,6 @@ SCENARIO( // The property that has to hold whatever the storage convention is: the corner // of the mesh at the top of the picture must address the picture's top row. -// It holds today for glTF and PBRT and fails for OBJ and USD. SCENARIO("An imported quad's top corner addresses the image's top row", "[ImageImport]") diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 1cc5119c3..4944b98fc 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -1000,7 +1000,10 @@ def Xform "World" REQUIRE(uv[3].x == Approx(0.0f)); // second triangle's first corner REQUIRE(uv[4].x == Approx(1.0f)); REQUIRE(uv[5].x == Approx(0.0f)); - REQUIRE(uv[5].y == Approx(1.0f)); + // `v` arrives reversed out of USD's v-up `st` into ANARI's, which runs + // down the image: the authored (0, 0) and (0, 1) become 1 and 0. + REQUIRE(uv[3].y == Approx(1.0f)); + REQUIRE(uv[5].y == Approx(0.0f)); } THEN("The normals arrive too") From 6bfabc99871d03a0722a4ffdde57b1e52145c9eb Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 17 Aug 2026 19:29:19 -0500 Subject: [PATCH 28/78] fix(tests): read image texels through helium instead of dataAs The image import orientation tests read 8-bit texels with `dataAs()` and manual `* 3` / `* 4` stride arithmetic. `dataAs` asserts that `sizeof(T)` matches the array's element size, so on the glTF path -- the one importer that keeps the file's 8-bit type, here ANARI_UFIXED8_RGBA_SRGB -- the assert fired and aborted tsdTests in a debug build, taking the rest of the suite with it. Release builds hid it: NDEBUG drops the assert and the cast happens to land on the right byte. helium::readAsAttributeValueFlat() reads every element type the fixtures produce, applying the element stride and the sRGB decode itself, so the whole switch collapses to one call. The decode changes the glTF path's values but no outcome: the row predicates compare channels against each other and the sRGB transfer function is monotonic. Co-Authored-By: Claude Opus 5 --- tsd/tests/test_ImageImport.cpp | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 7d08755b0..2d2003730 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -3,6 +3,8 @@ // catch #include "catch.hpp" +// helium +#include // tsd #include "tsd/animation/AnimationManager.hpp" #include "tsd/core/TSDMath.hpp" @@ -170,31 +172,14 @@ std::string hdrFixtureContents() // Importers do not agree on an element type -- the shared path expands to // ANARI_FLOAT32_*, glTF keeps the file's 8-bit type and asks for an sRGB -// format -- so orientation assertions have to read either. +// format -- so orientation assertions have to read either. helium reads any +// of them, applying the element stride and the sRGB decode itself; a type it +// does not know reads back as (0, 0, 0), which no row predicate accepts. float3 texelAsFloat3(const tsd::scene::Array *image, size_t index) { - const auto type = image->elementType(); - switch (type) { - case ANARI_FLOAT32_VEC3: - return image->dataAs()[index]; - case ANARI_FLOAT32_VEC4: { - const auto t = image->dataAs()[index]; - return float3(t.x, t.y, t.z); - } - case ANARI_UFIXED8_VEC3: - case ANARI_UFIXED8_RGB_SRGB: { - const auto *t = image->dataAs() + index * 3; - return float3(t[0] / 255.f, t[1] / 255.f, t[2] / 255.f); - } - case ANARI_UFIXED8_VEC4: - case ANARI_UFIXED8_RGBA_SRGB: { - const auto *t = image->dataAs() + index * 4; - return float3(t[0] / 255.f, t[1] / 255.f, t[2] / 255.f); - } - default: - FAIL("unhandled texel element type " << type); - return float3(0.f); - } + const auto texel = helium::readAsAttributeValueFlat( + image->data(), image->elementType(), index); + return float3(texel.x, texel.y, texel.z); } // The fixture image a scene imported, found by its shape rather than by the From 08355720edb5487f1037fb01a07d0b2775151a7c Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 09:09:34 -0500 Subject: [PATCH 29/78] fix(tsd): compile error in optionally compiled region --- tsd/src/tsd/io/importers/import_ASSIMP.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/tsd/src/tsd/io/importers/import_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index bbc395542..4b2741560 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -385,8 +385,6 @@ static std::vector importASSIMPMaterials( mat4({2, 0, 0, 0}, {0, 2, 0, 0}, {0, 0, 0, 0}, {-1, -1, 0, 1})); m->setParameterObject("anisotropyDirection", *sampler); } - auto settings = getTextureUVSettings( - AI_MATKEY_UVTRANSFORM(aiTextureType_ANISOTROPY, 0)); if (auto sampler = loadTexture(anisotropyTexture, true, settings); sampler) { // Map red to red/green/blue as expected by our gltf pbr From e342ec14523f891fa710a897b2b468be61ce628b Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 17 Aug 2026 21:26:14 -0500 Subject: [PATCH 30/78] feat(io): support animated USD scenes via a shared Stage Session USD stages exported from simulation carry their motion on PointInstancer positions and scales, which is the one animation path the importer did not support: it re-pulled mesh points through a binding that opened its own UsdStage, snapped to the nearer authored sample, and silently no-oped when the element count moved. An import now acquires a Stage Session -- the open Stage, the Hydra resolution chain, and the Time Code both evaluate at -- from a process-wide weak registry keyed by absolute path, and hands it to every binding it creates. Import and scrub therefore resolve through the same chain, a 1.6 GB stage is opened once rather than twice, and normalized animation time maps onto the Stage's own Time Code range so USD interpolates there instead of snapping. That deletes the sampleTimes/timeBase caches from bindings; old archives still carry them and are simply not read. Point instancers gain a binding per (instancer, prototype) pair that re-fills the transform Array the import created, re-applying the same placement selection through the same readInstancerPlacements() the importer calls. A count that moves mid-sequence allocates a right-sized Array and re-points the node, since a TSD Array cannot resize. Re-running conversion per frame was rejected: it churns object identity and forces ANARI handle teardown for half a million instances that only moved. Because a scrub unmaps one transform Array per instancer and RenderIndexAllLayers rebuilds its world on every MAT4 unmap, a time change is now bracketed by Scene::beginUpdateBatch()/endUpdateBatch() and render indices coalesce the rebuilds they owe. Also: one Animation per import rather than one per prim, which collided on leaf names; the Stage's frame count and rate are reported and widened onto the shared playback clock by import_file rather than applied by the importer; and a time-sampled scalar or token whose value never changes is no longer reported as a dropped value, which is what every warp scene's visibility and displayColor attributes are. Verified on ~/data/usd/warp: all three scenes import with zero reported skips, scrub end-to-end, and strace shows exactly one open of example_apic_fluid.usd, before the first scrub. Co-Authored-By: Claude Opus 5 --- .../network/client/NetworkUpdateDelegate.cpp | 10 + .../network/client/NetworkUpdateDelegate.hpp | 2 + ...mported-scenes-retain-an-open-usd-stage.md | 6 + ...age-session-across-import-and-animation.md | 53 +++ ...rrays-rather-than-re-running-conversion.md | 55 +++ tsd/src/tsd/animation/AnimationManager.cpp | 6 + tsd/src/tsd/animation/FileBinding.hpp | 13 + tsd/src/tsd/io/CMakeLists.txt | 3 + tsd/src/tsd/io/CONTEXT.md | 14 + tsd/src/tsd/io/UsdImport.cpp | 6 + tsd/src/tsd/io/UsdImport.hpp | 13 + .../io/animation/UsdGeometryFileBinding.cpp | 187 ++++----- .../io/animation/UsdGeometryFileBinding.hpp | 50 +-- .../io/animation/UsdInstancerFileBinding.cpp | 172 ++++++++ .../io/animation/UsdInstancerFileBinding.hpp | 79 ++++ tsd/src/tsd/io/archives/AnimationArchive.cpp | 13 + .../tsd/io/archives/detail/ArchivePlan.cpp | 13 + .../io/importers/detail/usd/UsdAnimation.cpp | 64 ++- .../io/importers/detail/usd/UsdAnimation.h | 19 + .../io/importers/detail/usd/UsdDialect.cpp | 22 +- .../tsd/io/importers/detail/usd/UsdDialect.h | 11 +- .../importers/detail/usd/UsdImportContext.cpp | 57 +++ .../importers/detail/usd/UsdImportContext.h | 27 ++ .../io/importers/detail/usd/UsdInstancing.cpp | 111 ++--- .../io/importers/detail/usd/UsdInstancing.h | 34 ++ .../io/importers/detail/usd/UsdMaterials.cpp | 4 +- tsd/src/tsd/io/importers/import_USD.cpp | 92 ++--- tsd/src/tsd/io/importers/import_file.cpp | 51 ++- .../serialization/serialization_datatree.cpp | 3 + tsd/src/tsd/io/usd/UsdStageSession.cpp | 184 +++++++++ tsd/src/tsd/io/usd/UsdStageSession.h | 71 ++++ tsd/src/tsd/rendering/index/RenderIndex.cpp | 29 +- tsd/src/tsd/rendering/index/RenderIndex.hpp | 11 + .../rendering/index/RenderIndexAllLayers.cpp | 12 +- .../index/RenderIndexFlatRegistry.cpp | 2 +- tsd/src/tsd/scene/Scene.cpp | 10 + tsd/src/tsd/scene/Scene.hpp | 6 + tsd/src/tsd/scene/UpdateDelegate.cpp | 12 + tsd/src/tsd/scene/UpdateDelegate.hpp | 11 + tsd/tests/test_AnimationManager.cpp | 75 ++++ tsd/tests/test_UsdImport.cpp | 389 +++++++++++++++++- 41 files changed, 1725 insertions(+), 277 deletions(-) create mode 100644 tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md create mode 100644 tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md create mode 100644 tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp create mode 100644 tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp create mode 100644 tsd/src/tsd/io/usd/UsdStageSession.cpp create mode 100644 tsd/src/tsd/io/usd/UsdStageSession.h diff --git a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp index 95f19f09f..9a84afd2f 100644 --- a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp +++ b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp @@ -172,4 +172,14 @@ bool NetworkUpdateDelegate::isReady(const char *fcn) const return true; } +void NetworkUpdateDelegate::signalUpdateBatchBegin() +{ + // Every mutation is already sent as it happens; nothing is coalesced here. +} + +void NetworkUpdateDelegate::signalUpdateBatchEnd() +{ + // See signalUpdateBatchBegin(). +} + } // namespace tsd::network diff --git a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp index 4aac68c85..1fd6cda53 100644 --- a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp +++ b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp @@ -42,6 +42,8 @@ struct NetworkUpdateDelegate : public tsd::scene::BaseUpdateDelegate void signalActiveLayersChanged() override; void signalObjectFilteringChanged() override; void signalInvalidateCachedObjects() override; + void signalUpdateBatchBegin() override; + void signalUpdateBatchEnd() override; private: bool isReady(const char *fcn) const; diff --git a/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md b/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md index be9e242b8..8e9f1a750 100644 --- a/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md +++ b/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md @@ -12,3 +12,9 @@ index, serialization records file and prim paths and reconstructs by re-opening rather than by copying data, scrubbing performs real work per frame, and stage access must be accounted for when a scene is used across threads. Static content carries no such dependency. + +Amended by +[ADR 0021](0021-share-one-usd-stage-session-across-import-and-animation.md): the +retained stage is now a Stage Session shared with the import rather than one the +binding opens for itself, and the scene index it holds really is retained, which +the code this ADR described did not do. diff --git a/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md new file mode 100644 index 000000000..a3042ae07 --- /dev/null +++ b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md @@ -0,0 +1,53 @@ +# Share one USD Stage Session across an import and its animations + +An imported USD Stage is held open by a **Stage Session**: the `UsdStage`, the +Hydra resolution chain built on it, and the Time Code both are currently +evaluated at, owned together. `import_USD` acquires one for the file it is +importing and consumes its chain instead of building a second one beside it; +every animation binding that import creates holds the same Session. A Session +is acquired from a process-wide registry keyed by absolute file path, held +weakly, so the last holder to let go closes the Stage. A fully static import +therefore retains nothing — the last reference drops when `import_USD` returns +— while an animated import pins the file for as long as its bindings live. + +This extends ADR 0015's "Hydra owns resolution" from import time to every time. +It also amends ADR 0018, which described per-scene stage retention and claimed +a retained scene index the code did not in fact keep: `UsdGeometryFileBinding` +used to re-open its own raw `UsdStage` and read `UsdGeomPointBased` schemas +directly, so a scrub resolved through a different path than the import had +converted from, and a 1.6 GB stage was opened twice. Keying by path rather than +by Scene or by AnimationManager keeps USD knowledge out of `tsd_scene` and +`tsd_animation`, which sit below `tsd_io`, and makes deserialization trivially +correct: a binding stores only the file path and rejoins by path, with no +ordering dependency on anything else in the archive. + +Two things follow from the Session owning the chain. Building the filter chain +moved into the Session, so import and scrub provably resolve identically; and +dialect pruning moved *out* of the chain into traversal, so the Session carries +no trace of any one import's options and two imports of one file with different +options can still share it. + +The Session also owns the mapping from TSD's normalized animation time onto the +Stage's own Time Code, and USD evaluates continuously there rather than snapping +to the nearest authored sample. That is what usdview shows, and it fixes a real +aliasing bug: `example_granular_collision_sdf.usd` authors samples at time codes +1…400 on a stage range of 0…400, so under snapping every frame of a 400-frame +grid mapped to `400*i/399` and never landed on an authored code. Because the +mapping is a Stage-level fact living once in the Session, the `sampleTimes` and +`timeBase` caches every binding used to carry are deleted rather than +maintained; they were derived data, and re-deriving them from the Stage is +strictly more correct than trusting a possibly-stale copy. Archives written +before this omit them on write and ignore them on read, and silently gain the +continuous-time behavior. Versioning with a legacy snapping path was rejected: +it would keep behavior we decided was wrong alive forever. + +The costs are accepted knowingly. An animated import holds its file open — +1.6 GB for `example_apic_fluid.usd` — for the bindings' lifetime. Interpolating +between samples of a particle simulation, where index *i* is a different +particle from frame to frame, is physically meaningless; it is visually +harmless, it is what usdview does, and USD's own `interpolation` stage metadata +is the correct lever if it ever matters. And the shared Session makes threading +*sharper*, not safer: `setTime` is a global mutation every binding reads, so the +Session is a single serialization point by construction, and nothing here makes +a TSD scene safe to use across threads while USD is being read. ADR 0018 already +listed thread safety as unresolved and it stays that way. diff --git a/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md b/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md new file mode 100644 index 000000000..1b769aad2 --- /dev/null +++ b/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md @@ -0,0 +1,55 @@ +# Re-fill captured Arrays rather than re-running conversion per frame + +A USD animation binding re-fills the TSD Arrays the import created. It does not +re-run the converter that created them. The converter that *creates* a target +Array is the same code that *constructs the binding* and hands it that Array +plus the prim path, so there is no second inference pass and nothing has to find +the array again by name. + +Re-running conversion per frame was the obvious alternative and is the wrong +one. It allocates new Arrays every frame, rebinds parameters, and churns object +identity — which forces the render index to tear down and recreate ANARI handles +instead of updating buffers — and it re-creates the Materials and Surfaces that +did not change. The scenes that motivated this work make the scale plain: +`example_apic_fluid.usd` scatters 531,441 instances, about 34 MB of matrices per +frame. Nothing about that survives a per-frame rebuild. The part that varies +over time is the contents of a handful of Arrays; it is not the Surface and +Material graph around them. + +Concretely, a point instancer becomes one transform-array node per +`(instancer, prototype)` pair, and one binding per pair re-reads the instancer +from the Stage Session at the current Time Code, re-applies the same +per-prototype instance-index selection and visibility mask the importer applied +— through the same `readInstancerPlacements()` the importer calls, so the two +cannot drift — and writes through `Array::setData()`. Deforming geometry works +the same way on `vertex.position` and `vertex.normal`. + +A TSD `Array` has no resize, so an element count that moves mid-sequence +allocates a right-sized Array and rebinds: `setAsTransformArray` for an +instancer, a parameter rebind for geometry. That costs nothing on the common +path and pays handle churn only on the frames where the count actually moves. +Detecting constant counts at import time was rejected: proving it means reading +every sample — all 1.6 GB of `apic_fluid` — and sampling first/mid/last is a +heuristic that would have passed on all three motivating scenes while still +being wrong in general. + +The one case this deliberately does not handle is a mesh whose vertex count and +topology both move. Points, indices and primvars are then one consistent set +that has to be re-pulled together, and re-pulling them together through +triangulation and per-Surface attribute binding (ADR 0020) *is* re-running the +converter. The binding detects that case, warns once, and leaves the frame as +imported rather than writing a mesh whose positions and indices disagree. +Nothing in the available data exercises it; a fuller "split converters into +resolve → plain data / data → TSD objects" refactor is what would fix it +properly, and that is a refactor project with a feature attached rather than +part of this one. + +Because a scrub writes one Array per animated instancer and +`RenderIndexAllLayers` rebuilds its world on every `ANARI_FLOAT32_MAT4` array +unmap, batching is not optional: `AnimationManager` brackets a time change in +`Scene::beginUpdateBatch()`/`endUpdateBatch()`, and a render index coalesces the +rebuilds it owes until the batch ends. The existing `beginLayerEditBatch` could +not be reused — it batches *structural* layer changes, and an array unmap is not +one. Without the new bracket, a stage with several animated instancers would pay +a full world rebuild per instancer per frame. None of the three motivating +scenes would have caught that, since each has exactly one. diff --git a/tsd/src/tsd/animation/AnimationManager.cpp b/tsd/src/tsd/animation/AnimationManager.cpp index 77c80e918..3c20a87a2 100644 --- a/tsd/src/tsd/animation/AnimationManager.cpp +++ b/tsd/src/tsd/animation/AnimationManager.cpp @@ -76,15 +76,21 @@ void AnimationManager::setAnimationTimeInternal( if (resetPlaybackAccumulator) m_playbackAccumulator = 0.f; + // One time change is one update: a Stage with several animated instancers + // rewrites one transform Array per instancer, and without this each rewrite + // would cost a full world rebuild. + m_scene->beginUpdateBatch(); m_applyingAnimations = true; try { for (auto &anim : m_animations) anim.setAnimationTime(time); } catch (...) { m_applyingAnimations = false; + m_scene->endUpdateBatch(); throw; } m_applyingAnimations = false; + m_scene->endUpdateBatch(); if (m_timeChangedCallback) m_timeChangedCallback(time); diff --git a/tsd/src/tsd/animation/FileBinding.hpp b/tsd/src/tsd/animation/FileBinding.hpp index 4bc2f6f6c..94e793c98 100644 --- a/tsd/src/tsd/animation/FileBinding.hpp +++ b/tsd/src/tsd/animation/FileBinding.hpp @@ -6,8 +6,11 @@ #include "tsd/animation/Binding.hpp" // tsd_core #include "tsd/core/DataTree.hpp" +// tsd_scene +#include "tsd/scene/LayerNodeData.hpp" // std #include +#include namespace tsd::animation { @@ -42,6 +45,11 @@ struct FileBinding : public Binding // Write binding-specific data to node (called by animationToNode in tsd_io). virtual void toDataNode(core::DataNode &node) const = 0; + // Layer nodes this binding writes to, if any. Archive planning classifies + // these exactly as it classifies a transform binding's target, so a binding + // that drives layer state rather than an object needs no per-kind case. + virtual std::vector layerTargets() const; + protected: // Register the runtime callback on anim. Called both on first import and // after reconstruction from a legacy application-state DataNode. @@ -55,4 +63,9 @@ struct FileBinding : public Binding inline FileBinding::FileBinding(scene::Scene *scene) : Binding(scene) {} +inline std::vector FileBinding::layerTargets() const +{ + return {}; +} + } // namespace tsd::animation diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 43e9b1774..ab9df952d 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -35,6 +35,7 @@ PRIVATE importers/detail/pbrt/PbrtParser.cpp importers/detail/pbrt/PbrtScene.cpp animation/UsdGeometryFileBinding.cpp + animation/UsdInstancerFileBinding.cpp importers/import_AGX.cpp importers/import_ASSIMP.cpp importers/import_AXYZ.cpp @@ -215,10 +216,12 @@ if (TSD_USE_USD) importers/detail/usd/UsdAnimation.cpp importers/detail/usd/UsdDialect.cpp importers/detail/usd/UsdGeometry.cpp + importers/detail/usd/UsdImportContext.cpp importers/detail/usd/UsdInstancing.cpp importers/detail/usd/UsdLights.cpp importers/detail/usd/UsdMaterials.cpp importers/detail/usd/UsdSubdivision.cpp + usd/UsdStageSession.cpp ) # CaseFileFormat -- SdfFileFormat plugin for EnSight .case files diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index 9206cc3f7..18c9e3b8d 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -92,6 +92,20 @@ The composed scene an import reads a foreign representation from, identified by the file that was opened. _Avoid_: USD scene, USD file +**Stage Session**: +The retained open Stage and resolution chain shared by every animation binding +from one Import, identified by the Stage's file. Its lifetime is the bindings', +not the Import's: an Import with no animation lets go of its Session on return. +See +[ADR 0021](../../../docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md). +_Avoid_: stage cache, open stage + +**Time Code**: +The Stage's own clock, distinct from TSD's normalized animation time. An Import +records the mapping between them; bindings re-resolve at a Time Code rather +than at a stored sample index. +_Avoid_: frame, timestep, sample index + **USD Layer**: A single composition-arc source contributing opinions to a Stage. Always qualified: unqualified **Layer** means a TSD Layer and never this. diff --git a/tsd/src/tsd/io/UsdImport.cpp b/tsd/src/tsd/io/UsdImport.cpp index 64d030eb2..f49861b20 100644 --- a/tsd/src/tsd/io/UsdImport.cpp +++ b/tsd/src/tsd/io/UsdImport.cpp @@ -124,6 +124,12 @@ std::string UsdImportReport::summary() const std::string retval = std::to_string(convertedPrims) + " prims converted, " + std::to_string(skipped.size()) + " skipped"; + if (animatedPrims > 0) { + retval += ", " + std::to_string(animatedPrims) + " animated prims bound (" + + std::to_string(sampleCount) + " samples @ " + + std::to_string(int(timeCodesPerSecond)) + " fps)"; + } + // Counts by reason, in enum order, omitting reasons that did not occur. bool first = true; for (int i = 0; i < int(UsdSkipReason::COUNT); ++i) { diff --git a/tsd/src/tsd/io/UsdImport.hpp b/tsd/src/tsd/io/UsdImport.hpp index 7f0bf605b..6da52b192 100644 --- a/tsd/src/tsd/io/UsdImport.hpp +++ b/tsd/src/tsd/io/UsdImport.hpp @@ -139,6 +139,19 @@ struct UsdImportReport size_t convertedPrims{0}; std::vector skipped; + // How many prims got an animation binding, in place of the per-prim list an + // Import used to leave behind when every animated prim got its own + // Animation. + size_t animatedPrims{0}; + + // What the Stage's own clock says, for the application to fold into the one + // playback clock every animation shares. Reported, never applied: an Import + // does not reach into global playback state. `sampleCount` is the largest + // number of authored time samples any bound attribute carries -- zero when + // the Import found no animation. + size_t sampleCount{0}; + float timeCodesPerSecond{0.f}; + size_t countOf(UsdSkipReason reason) const; bool contains(UsdSkipReason reason) const; std::string summary() const; diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp index 6d230401a..af85b326d 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -3,53 +3,35 @@ #include "tsd/io/animation/UsdGeometryFileBinding.hpp" // tsd_core -#include "tsd/animation/Interpolation.hpp" #include "tsd/core/DataTree.hpp" #include "tsd/core/Logging.hpp" #include "tsd/scene/objects/Array.hpp" #if TSD_USE_USD +// tsd_io +#include "tsd/io/usd/UsdStageSession.h" // usd -#include #include #include #endif -// std -#include -#include namespace tsd::io { using namespace tsd::core; -#if TSD_USE_USD - -struct UsdGeometryFileBinding::StageHolder -{ - pxr::UsdStageRefPtr stage; -}; - -#else - -struct UsdGeometryFileBinding::StageHolder -{ -}; - -#endif - UsdGeometryFileBinding::UsdGeometryFileBinding(scene::Scene *scene, scene::Geometry *geometry, + std::shared_ptr session, std::string stageFile, - std::string primPath, - std::vector sampleTimes, - std::vector timeBase) + std::string primPath) : FileBinding(scene), m_geometry(geometry), + m_session(std::move(session)), m_stageFile(std::move(stageFile)), - m_primPath(std::move(primPath)), - m_sampleTimes(std::move(sampleTimes)), - m_timeBase(std::move(timeBase)) + m_primPath(std::move(primPath)) {} +UsdGeometryFileBinding::~UsdGeometryFileBinding() = default; + std::string UsdGeometryFileBinding::kind() const { return "usdGeometry"; @@ -57,18 +39,13 @@ std::string UsdGeometryFileBinding::kind() const void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const { + // The Stage's own clock is enough to re-derive everything a scrub needs, so + // no cache of authored sample times is written; an older Archive that still + // carries one is simply not read. auto *geometry = m_geometry.get(); node["targetIndex"] = geometry ? geometry->index() : tsd::core::INVALID_INDEX; node["stageFile"] = m_stageFile; node["primPath"] = m_primPath; - - auto ×Node = node["sampleTimes"]; - for (double t : m_sampleTimes) - timesNode.append() = float(t); - - auto &timeBaseNode = node["timeBase"]; - for (float t : m_timeBase) - timeBaseNode.append() = t; } void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) @@ -79,16 +56,6 @@ void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) } } -size_t UsdGeometryFileBinding::frameCount() const -{ - return m_sampleTimes.size(); -} - -int UsdGeometryFileBinding::currentFrame() const -{ - return m_currentFrame; -} - void UsdGeometryFileBinding::addCallbackToAnimation( tsd::animation::Animation &anim) { @@ -109,86 +76,120 @@ UsdGeometryFileBinding *UsdGeometryFileBinding::addToAnimation( return nullptr; } - std::vector sampleTimes; - if (auto *timesNode = node.child("sampleTimes")) { - timesNode->foreach_child([&](core::DataNode &n) { - sampleTimes.push_back(n.getValueOr(0.f)); - }); - } - - std::vector timeBase; - if (auto *timeBaseNode = node.child("timeBase")) { - timeBaseNode->foreach_child([&](core::DataNode &n) { - timeBase.push_back(n.getValueOr(0.f)); - }); - } - return &anim.emplaceFileBinding(&scene, geometry, + std::shared_ptr{}, node["stageFile"].getValueOr(""), - node["primPath"].getValueOr(""), - std::move(sampleTimes), - std::move(timeBase)); + node["primPath"].getValueOr("")); } #if TSD_USE_USD -void UsdGeometryFileBinding::update(float t) +namespace { + +// Whether the prim's topology moves with its points. When it does, points, +// indices and primvars are one consistent set that has to be re-pulled +// together, which is re-running conversion -- exactly what this binding exists +// to avoid. +bool topologyIsTimeSampled(const pxr::UsdPrim &prim) +{ + pxr::UsdGeomMesh mesh(prim); + if (!mesh) + return false; + const auto counts = mesh.GetFaceVertexCountsAttr(); + const auto indices = mesh.GetFaceVertexIndicesAttr(); + return (counts && counts.GetNumTimeSamples() > 1) + || (indices && indices.GetNumTimeSamples() > 1); +} + +// Write `values` into `array`, or -- if the count moved -- allocate a +// right-sized Array and rebind the parameter to it, since a TSD Array's size +// is fixed at construction. +void writeVertexArray(scene::Scene &scene, + scene::Geometry &geometry, + core::Token parameter, + const pxr::VtVec3fArray &values) { - if (m_sampleTimes.empty() || !scene()) + auto *array = geometry.parameterValueAsObject(parameter); + if (!array) return; - // Pick the authored sample `t` falls in, so playback follows the authored - // spacing rather than an even grid. - const auto sample = tsd::animation::findTimeSample(m_timeBase, t); - const int frame = int(sample.alpha >= 0.5f ? sample.hi : sample.lo); - if (frame == m_currentFrame && m_stage) + if (array->size() == values.size()) { + array->setData(values.cdata()); return; + } - if (!m_stage) { - m_stage = std::make_shared(); - m_stage->stage = pxr::UsdStage::Open(m_stageFile); - if (!m_stage->stage) { - logWarning("[UsdGeometryFileBinding] failed to open stage '%s'", - m_stageFile.c_str()); - return; - } + auto replacement = scene.createArray(ANARI_FLOAT32_VEC3, values.size()); + replacement->setData(values.cdata()); + replacement->setName(array->name().c_str()); + geometry.setParameterObject(parameter, *replacement); +} + +} // namespace + +bool UsdGeometryFileBinding::ensureSession() +{ + if (m_session) + return true; + if (m_sessionFailed) + return false; + + m_session = usd::acquireUsdSession(m_stageFile); + if (!m_session) { + m_sessionFailed = true; + logWarning("[UsdGeometryFileBinding] failed to open stage '%s'", + m_stageFile.c_str()); } + return bool(m_session); +} - auto prim = m_stage->stage->GetPrimAtPath(pxr::SdfPath(m_primPath)); - pxr::UsdGeomPointBased pointBased(prim); - if (!pointBased) +void UsdGeometryFileBinding::update(float t) +{ + if (!scene() || !ensureSession()) return; auto *geometry = m_geometry.get(); if (!geometry) return; - const pxr::UsdTimeCode time(m_sampleTimes[size_t(frame)]); + m_session->setTime(m_session->timeCodeAt(t)); + + auto prim = m_session->stage()->GetPrimAtPath(pxr::SdfPath(m_primPath)); + pxr::UsdGeomPointBased pointBased(prim); + if (!pointBased) + return; + + const auto time = m_session->currentTime(); pxr::VtVec3fArray points; if (pointBased.GetPointsAttr().Get(&points, time) && !points.empty()) { - if (auto *positions = - geometry->parameterValueAsObject("vertex.position")) { - if (positions->size() == points.size()) - positions->setData(points.cdata()); + auto *positions = + geometry->parameterValueAsObject("vertex.position"); + const bool countMoved = positions && positions->size() != points.size(); + if (countMoved && topologyIsTimeSampled(prim)) { + if (!m_countChangeReported) { + m_countChangeReported = true; + logWarning("[UsdGeometryFileBinding] '%s': vertex count and topology" + " both change over time; the frame is left as imported", + m_primPath.c_str()); + } + return; } + writeVertexArray(*scene(), *geometry, "vertex.position", points); } pxr::VtVec3fArray normals; - if (pointBased.GetNormalsAttr().Get(&normals, time) && !normals.empty()) { - if (auto *normalArray = - geometry->parameterValueAsObject("vertex.normal")) { - if (normalArray->size() == normals.size()) - normalArray->setData(normals.cdata()); - } - } - - m_currentFrame = frame; + if (pointBased.GetNormalsAttr().Get(&normals, time) && !normals.empty()) + writeVertexArray(*scene(), *geometry, "vertex.normal", normals); } #else +bool UsdGeometryFileBinding::ensureSession() +{ + return false; +} + void UsdGeometryFileBinding::update(float) { logError("[UsdGeometryFileBinding] USD not enabled in TSD build."); diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp index 7214329e5..3dad4c73f 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp @@ -9,33 +9,38 @@ #include "tsd/scene/Scene.hpp" #include "tsd/scene/objects/Geometry.hpp" // std +#include #include -#include namespace tsd::io { +namespace usd { +struct UsdStageSession; +} // namespace usd + /* - * Binding that re-pulls a deforming mesh's vertex arrays from a USD Stage at - * the current animation time, so that a long animation of a dense mesh does - * not have to fit in memory. The Stage stays open for the binding's lifetime + * Binding that re-pulls a deforming mesh's vertex arrays from a Stage Session + * at the current animation time, so that a long animation of a dense mesh does + * not have to fit in memory. The Session stays open for the binding's lifetime * (ADR 0018); serialization records the file and prim paths and reconstructs - * by re-opening. + * by rejoining the Session for that file. + * + * The animation time maps onto the Stage's own Time Code range and USD + * evaluates there, so a value between authored samples is interpolated rather + * than snapped -- the same thing usdview shows. * * Example: * auto &b = anim.emplaceFileBinding( - * &scene, geometry.data(), stageFile, "/World/Character", sampleTimes); + * &scene, geometry.data(), session, stageFile, "/World/Character"); */ struct UsdGeometryFileBinding : public tsd::animation::FileBinding { - // `sampleTimes` are the prim's authored time codes; `timeBase` is those - // same samples on the animation clock, so the frame chosen for a given time - // follows the authored spacing rather than an even grid. UsdGeometryFileBinding(scene::Scene *scene, scene::Geometry *geometry, + std::shared_ptr session, std::string stageFile, - std::string primPath, - std::vector sampleTimes, - std::vector timeBase); + std::string primPath); + ~UsdGeometryFileBinding() override; // FileBinding interface // @@ -43,13 +48,9 @@ struct UsdGeometryFileBinding : public tsd::animation::FileBinding void toDataNode(tsd::core::DataNode &node) const override; void onDefragment(const scene::IndexRemapper &cb) override; - // Pull the vertex arrays for the authored sample `t` falls in. No-ops if - // the frame has not changed. + // Pull the vertex arrays for the Time Code `t` maps to. void update(float t) override; - size_t frameCount() const; - int currentFrame() const; - // Reconstruct from a serialized node; returns null if the target geometry is // missing from the scene. static UsdGeometryFileBinding *addToAnimation(tsd::animation::Animation &anim, @@ -59,17 +60,16 @@ struct UsdGeometryFileBinding : public tsd::animation::FileBinding private: void addCallbackToAnimation(tsd::animation::Animation &anim) override; + // Joined on first use rather than at construction, so a binding read back + // from an Archive does not open the Stage until something scrubs. + bool ensureSession(); + scene::ObjectUsePtr m_geometry; + std::shared_ptr m_session; std::string m_stageFile; std::string m_primPath; - std::vector m_sampleTimes; - std::vector m_timeBase; - int m_currentFrame{0}; - - // Opened lazily on the first update and retained thereafter, so scrubbing - // does not pay stage-open cost per frame. - struct StageHolder; - std::shared_ptr m_stage; + bool m_sessionFailed{false}; + bool m_countChangeReported{false}; }; } // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp new file mode 100644 index 000000000..37fe9238b --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp @@ -0,0 +1,172 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/animation/UsdInstancerFileBinding.hpp" +// tsd_core +#include "tsd/core/DataTree.hpp" +#include "tsd/core/Logging.hpp" +#if TSD_USE_USD +// tsd_io +#include "tsd/io/importers/detail/usd/UsdInstancing.h" +#include "tsd/io/usd/UsdStageSession.h" +#endif + +namespace tsd::io { + +using namespace tsd::core; + +UsdInstancerFileBinding::UsdInstancerFileBinding(scene::Scene *scene, + std::shared_ptr session, + scene::LayerNodeRef arrayNode, + scene::ArrayRef transforms, + std::string stageFile, + std::string primPath, + size_t prototypeIndex) + : FileBinding(scene), + m_session(std::move(session)), + m_arrayNode(arrayNode), + m_transforms(transforms), + m_stageFile(std::move(stageFile)), + m_primPath(std::move(primPath)), + m_prototypeIndex(prototypeIndex) +{} + +UsdInstancerFileBinding::~UsdInstancerFileBinding() = default; + +std::string UsdInstancerFileBinding::kind() const +{ + return "usdInstancer"; +} + +void UsdInstancerFileBinding::toDataNode(core::DataNode &node) const +{ + if (m_arrayNode && scene()) { + auto *layer = (*m_arrayNode).value().layer(); + node["layerName"] = scene()->getLayerName(layer).str(); + node["nodeIndex"] = m_arrayNode->index(); + } + node["stageFile"] = m_stageFile; + node["primPath"] = m_primPath; + node["prototypeIndex"] = uint64_t(m_prototypeIndex); +} + +std::vector UsdInstancerFileBinding::layerTargets() const +{ + if (!m_arrayNode) + return {}; + return {m_arrayNode}; +} + +void UsdInstancerFileBinding::onDefragment(const scene::IndexRemapper &cb) +{ + if (m_transforms) { + const size_t newIndex = cb(m_transforms->type(), m_transforms->index()); + m_transforms.updateDefragmentedIndex(newIndex); + } +} + +void UsdInstancerFileBinding::addCallbackToAnimation( + tsd::animation::Animation &anim) +{ + anim.addCallbackBinding([this](float t) { this->update(t); }); +} + +UsdInstancerFileBinding *UsdInstancerFileBinding::addToAnimation( + tsd::animation::Animation &anim, scene::Scene &scene, core::DataNode &node) +{ + scene::LayerNodeRef arrayNode; + if (auto *layerNameNode = node.child("layerName")) { + const auto layerName = + core::Token(layerNameNode->getValueOr("").c_str()); + if (auto *layer = scene.layer(layerName)) + arrayNode = layer->at(node["nodeIndex"].getValueOr(0)); + } + + if (!arrayNode) { + logWarning( + "[UsdInstancerFileBinding] transform-array node not found; skipping"); + return nullptr; + } + + auto *transforms = (*arrayNode)->getTransformArray(); + if (!transforms) { + logWarning("[UsdInstancerFileBinding] node '%s' is not a transform-array" + " node; skipping", + (*arrayNode)->name().c_str()); + return nullptr; + } + + return &anim.emplaceFileBinding(&scene, + std::shared_ptr{}, + arrayNode, + transforms->self(), + node["stageFile"].getValueOr(""), + node["primPath"].getValueOr(""), + size_t(node["prototypeIndex"].getValueOr(0))); +} + +#if TSD_USE_USD + +bool UsdInstancerFileBinding::ensureSession() +{ + if (m_session) + return true; + if (m_sessionFailed) + return false; + + m_session = usd::acquireUsdSession(m_stageFile); + if (!m_session) { + m_sessionFailed = true; + logWarning("[UsdInstancerFileBinding] failed to open stage '%s'", + m_stageFile.c_str()); + } + return bool(m_session); +} + +void UsdInstancerFileBinding::update(float t) +{ + if (!scene() || !m_arrayNode || !ensureSession()) + return; + + m_session->setTime(m_session->timeCodeAt(t)); + + auto prim = m_session->sceneIndex()->GetPrim(pxr::SdfPath(m_primPath)); + const auto placements = + usd::readInstancerPlacements(prim).forPrototype(m_prototypeIndex); + if (placements.empty()) + return; + + auto *transforms = m_transforms.get(); + if (transforms && transforms->size() == placements.size()) { + transforms->setData(placements.data(), placements.size()); + return; + } + + // A TSD Array's size is fixed at construction, so a placement count that + // moves mid-sequence costs one allocation and one rebind on that frame. + auto replacement = + scene()->createArray(ANARI_FLOAT32_MAT4, placements.size()); + replacement->setData(placements.data(), placements.size()); + if (transforms) + replacement->setName(transforms->name().c_str()); + + (*m_arrayNode)->setAsTransformArray(replacement.data()); + m_transforms = replacement; + scene()->signalLayerStructureChanged((*m_arrayNode).value().layer()); +} + +#else + +void UsdInstancerFileBinding::update(float) +{ + logError("[UsdInstancerFileBinding] USD not enabled in TSD build."); +} + +bool UsdInstancerFileBinding::ensureSession() +{ + return false; +} + +#endif + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp new file mode 100644 index 000000000..777e7c71b --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp @@ -0,0 +1,79 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/animation/Animation.hpp" +#include "tsd/animation/FileBinding.hpp" +#include "tsd/scene/ObjectUsePtr.hpp" +#include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Array.hpp" +// std +#include +#include + +namespace tsd::io { + +namespace usd { +struct UsdStageSession; +} // namespace usd + +/* + * Binding that re-fills the transform Array of one point-instancer Prototype + * from a Stage Session at the current animation time. It re-reads the + * instancer at a Time Code and re-applies the same Prototype and visibility + * selection the Import applied, then writes through the Array the Import + * created -- it does not re-run conversion, which would churn object identity + * and force ANARI handle teardown for half a million instances that only + * moved. + * + * A mid-sequence change in placement count allocates a right-sized Array and + * re-points the node at it, because a TSD Array cannot resize. + * + * Example: + * anim.emplaceFileBinding( + * &scene, session, arrayNode, transforms, file, "/root/points", 0); + */ +struct UsdInstancerFileBinding : public tsd::animation::FileBinding +{ + UsdInstancerFileBinding(scene::Scene *scene, + std::shared_ptr session, + scene::LayerNodeRef arrayNode, + scene::ArrayRef transforms, + std::string stageFile, + std::string primPath, + size_t prototypeIndex); + ~UsdInstancerFileBinding() override; + + // FileBinding interface // + + std::string kind() const override; + void toDataNode(tsd::core::DataNode &node) const override; + std::vector layerTargets() const override; + void onDefragment(const scene::IndexRemapper &cb) override; + + void update(float t) override; + + // Reconstruct from a serialized node; returns null if the target layer node + // is missing from the scene or is not a transform-array node. + static UsdInstancerFileBinding *addToAnimation(tsd::animation::Animation &anim, + scene::Scene &scene, + tsd::core::DataNode &node); + + private: + void addCallbackToAnimation(tsd::animation::Animation &anim) override; + + // Joined on first use rather than at construction, so a binding read back + // from an Archive does not open the Stage until something scrubs. + bool ensureSession(); + + std::shared_ptr m_session; + scene::LayerNodeRef m_arrayNode; + scene::ObjectUsePtr m_transforms; + std::string m_stageFile; + std::string m_primPath; + size_t m_prototypeIndex{0}; + bool m_sessionFailed{false}; +}; + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/archives/AnimationArchive.cpp b/tsd/src/tsd/io/archives/AnimationArchive.cpp index 6059a7b58..00708d01f 100644 --- a/tsd/src/tsd/io/archives/AnimationArchive.cpp +++ b/tsd/src/tsd/io/archives/AnimationArchive.cpp @@ -161,6 +161,19 @@ bool validateFileBinding( } return true; } + if (kind == "usdInstancer") { + if (binding.child("layerName") == nullptr + || binding.child("nodeIndex") == nullptr) { + return fail(message, + "USD instancer binding requires a transform-array layer node"); + } + if (binding.child("stageFile") == nullptr + || binding.child("primPath") == nullptr) { + return fail( + message, "USD instancer binding requires a stage file and prim path"); + } + return true; + } if (kind == "ensight") { auto *parts = binding.child("parts"); if (!parts || parts->numChildren() == 0) diff --git a/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp b/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp index c5ebae760..de2301dd8 100644 --- a/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp +++ b/tsd/src/tsd/io/archives/detail/ArchivePlan.cpp @@ -99,6 +99,19 @@ bool classifyFileBinding(const animation::FileBinding &binding, core::DataTree scratch; binding.toDataNode(scratch.root()); + // A binding that drives layer state is classified by its layer nodes, the + // same way a transform binding's target is. + const auto layerTargets = binding.layerTargets(); + if (!layerTargets.empty()) { + for (const auto &target : layerTargets) { + if (containsNode(result.plan.nodes, target)) + inside = true; + else + outside = true; + } + return true; + } + if (binding.kind() == "spatialField") { return classifyObjectTarget(entries, ANARI_VOLUME, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp index 17f44feec..ab009f8f7 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -4,8 +4,10 @@ #include "tsd/io/importers/detail/usd/UsdAnimation.h" #include "tsd/animation/AnimationManager.hpp" #include "tsd/io/animation/UsdGeometryFileBinding.hpp" +#include "tsd/io/animation/UsdInstancerFileBinding.hpp" // usd #include +#include #include // std #include @@ -141,9 +143,53 @@ void addTransformAnimation( frames.push_back(next); } - auto &animation = ctx.animMgr.addAnimation(primPath.GetName()); - addTransformStepBinding( - animation, node, frames, normalizeSampleTimes(ctx.stage, times)); + addTransformStepBinding(ctx.animation(), + node, + frames, + normalizeSampleTimes(ctx.stage, times)); + ctx.reportSampleCount(authoredTimes.size()); +} + +size_t pointInstancerSampleCount( + ImportContext &ctx, const pxr::SdfPath &primPath) +{ + pxr::UsdGeomPointInstancer instancer(ctx.stage->GetPrimAtPath(primPath)); + if (!instancer) + return 0; + + // Every attribute that can move a placement, including the two velocity + // attributes Hydra folds into the instance transforms it computes. + const pxr::UsdAttribute attributes[] = {instancer.GetPositionsAttr(), + instancer.GetOrientationsAttr(), + instancer.GetScalesAttr(), + instancer.GetVelocitiesAttr(), + instancer.GetAngularVelocitiesAttr(), + instancer.GetProtoIndicesAttr(), + instancer.GetInvisibleIdsAttr()}; + + size_t retval = 0; + for (const auto &attribute : attributes) { + if (attribute) + retval = std::max(retval, size_t(attribute.GetNumTimeSamples())); + } + return retval; +} + +void addInstancerAnimation(ImportContext &ctx, + const pxr::SdfPath &primPath, + size_t prototypeIndex, + LayerNodeRef arrayNode, + ArrayRef transforms, + size_t sampleCount) +{ + ctx.animation().emplaceFileBinding(&ctx.scene, + ctx.session, + arrayNode, + transforms, + ctx.filePath, + primPath.GetString(), + prototypeIndex); + ctx.reportSampleCount(sampleCount); } void addDeformingGeometryAnimation( @@ -163,15 +209,13 @@ void addDeformingGeometryAnimation( return; // One eager frame is already in the Scene; the rest is pulled from the - // retained Stage on demand (ADR 0018). - auto timeBase = normalizeSampleTimes(ctx.stage, sampleTimes); - auto &animation = ctx.animMgr.addAnimation(primPath.GetName()); - animation.emplaceFileBinding(&ctx.scene, + // shared Stage Session on demand (ADR 0018). + ctx.animation().emplaceFileBinding(&ctx.scene, geometry.data(), + ctx.session, ctx.filePath, - primPath.GetString(), - std::move(sampleTimes), - std::move(timeBase)); + primPath.GetString()); + ctx.reportSampleCount(sampleTimes.size()); } } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h index 29051ac08..48c7044c1 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h @@ -4,6 +4,7 @@ #pragma once #include "tsd/io/importers/detail/usd/UsdImportContext.h" +#include "tsd/scene/objects/Array.hpp" // std #include @@ -21,6 +22,24 @@ std::vector normalizeSampleTimes( void addTransformAnimation( ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node); +// How many time samples the placements of a point instancer are authored +// with, taken across every attribute that moves them. Zero or one means the +// instancer does not animate. +size_t pointInstancerSampleCount( + ImportContext &ctx, const pxr::SdfPath &primPath); + +// Bind one Prototype's transform Array to the Stage Session, so that scrubbing +// re-fills the matrices the Import just wrote rather than re-running +// conversion. `arrayNode` is the transform-array node holding `transforms`, +// which the binding needs in order to re-point the node if the placement count +// changes mid-sequence. +void addInstancerAnimation(ImportContext &ctx, + const pxr::SdfPath &primPath, + size_t prototypeIndex, + LayerNodeRef arrayNode, + ArrayRef transforms, + size_t sampleCount); + // Bind a geometry's vertex arrays to the retained Stage so that a long // animation of a dense mesh is pulled on demand instead of held in memory // (ADR 0018). Does nothing unless the prim's points are time-sampled. diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index 0e954b5c3..64bf84fce 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -337,22 +336,13 @@ std::shared_ptr claimDialectPrims(ImportContext &ctx) return retval; } -pxr::HdSceneIndexBaseRefPtr pruneClaimedPrims( - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const std::shared_ptr &claimed) +bool ClaimedPrims::claims(const pxr::SdfPath &path) const { - if (!claimed || claimed->entries.empty()) - return sceneIndex; - - pxr::SdfPathVector prefixes; - for (const auto &entry : claimed->entries) - prefixes.push_back(entry.path); - - auto inputArgs = pxr::HdRetainedContainerDataSource::New( - pxr::HdsiPrefixPathPruningSceneIndexTokens->excludePathPrefixes, - pxr::HdRetainedTypedSampledDataSource::New(prefixes)); - - return pxr::HdsiPrefixPathPruningSceneIndex::New(sceneIndex, inputArgs); + for (const auto &entry : entries) { + if (path == entry.path || path.HasPrefix(entry.path)) + return true; + } + return false; } void importDialectPrims(ImportContext &ctx, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h index 944dec907..ebbd8e905 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h @@ -36,16 +36,17 @@ struct ClaimedPrims std::vector entries; core::DataTree renderSettings; + + // Whether `path` is a Claimed Prim or lives beneath one. Traversal asks this + // rather than the resolved scene being pruned, so that the Stage Session -- + // which is shared with every other Import of this file -- stays free of any + // one Import's dialect handling. + bool claims(const pxr::SdfPath &path) const; }; // Scan the raw Stage for dialect markers. std::shared_ptr claimDialectPrims(ImportContext &ctx); -// Remove every Claimed Prim subtree from the resolved scene. -pxr::HdSceneIndexBaseRefPtr pruneClaimedPrims( - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const std::shared_ptr &claimed); - // Route Claimed Prims to the dialect's own importers. void importDialectPrims(ImportContext &ctx, const std::shared_ptr &claimed, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp new file mode 100644 index 000000000..28df8b64e --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp @@ -0,0 +1,57 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +#include "tsd/animation/AnimationManager.hpp" +// usd +#include +// std +#include +#include + +namespace tsd::io::usd { + +tsd::animation::Animation &ImportContext::animation() +{ + if (!importAnimation) + importAnimation = &animMgr.addAnimation(filePath); + return *importAnimation; +} + +void ImportContext::reportSampleCount(size_t count) +{ + report.animatedPrims++; + report.sampleCount = std::max(report.sampleCount, count); + if (session) + report.timeCodesPerSecond = float(session->timeCodesPerSecond()); +} + +bool attributeValueVaries(const pxr::UsdAttribute &attribute) +{ + if (!attribute) + return false; + + std::vector times; + attribute.GetTimeSamples(×); + if (times.size() < 2) + return false; + + // Reading every sample of an array attribute means reading the whole file. + if (attribute.GetTypeName().IsArray()) + return true; + + pxr::VtValue first; + if (!attribute.Get(&first, pxr::UsdTimeCode(times.front()))) + return false; + + for (size_t i = 1; i < times.size(); ++i) { + pxr::VtValue value; + if (!attribute.Get(&value, pxr::UsdTimeCode(times[i]))) + return true; + if (value != first) + return true; + } + return false; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index 0f34266d1..8d473ec6d 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -7,6 +7,7 @@ #include "tsd/core/TSDMath.hpp" #include "tsd/io/UsdImport.hpp" #include "tsd/io/importers/detail/importer_common.hpp" +#include "tsd/io/usd/UsdStageSession.h" #include "tsd/scene/Scene.hpp" // usd #include @@ -14,10 +15,12 @@ #include #include // std +#include #include #include namespace tsd::animation { +struct Animation; struct AnimationManager; } // namespace tsd::animation @@ -43,6 +46,7 @@ struct ImportContext tsd::animation::AnimationManager &animMgr; const UsdImportOptions &options; UsdImportReport &report; + std::shared_ptr session; pxr::UsdStageRefPtr stage; std::string filePath; std::string basePath; @@ -52,6 +56,18 @@ struct ImportContext // not resolve at all. pxr::UsdTimeCode importTime{pxr::UsdTimeCode::EarliestTime()}; + // One Import is one Animation (ADR 0009), created on the first binding that + // needs it and named for the Stage's file. Per-prim Animations collided on + // leaf names and implied independent control that does not exist: every + // Animation is driven by the same AnimationManager clock. + tsd::animation::Animation &animation(); + + // Fold a bound attribute's authored sample count into the Import Report. + void reportSampleCount(size_t count); + + // Set by animation(); this stays an aggregate, so it cannot be private. + tsd::animation::Animation *importAnimation{nullptr}; + // Caches keyed by resolved prim path, so shared content converts once. ImageCache textureCache{&scene}; std::unordered_map materialCache; @@ -68,6 +84,17 @@ struct ImportContext tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m); pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source); +// Whether an attribute's time samples actually differ from one another. USD +// exporters routinely re-author every attribute at every frame regardless of +// change, so "is time-sampled" is not the same question as "is animated". +// +// The comparison is deliberately asymmetric: a time-sampled *array* attribute +// is assumed to vary without reading it, because proving otherwise means +// reading every sample -- gigabytes for a particle simulation. So a large array +// authored identically at every frame is still treated as animated, still gets +// a binding, and is still re-pulled per frame. +bool attributeValueVaries(const pxr::UsdAttribute &attribute); + // Inlined definitions //////////////////////////////////////////////////////// inline void ImportContext::reportSkip(const pxr::SdfPath &primPath, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index a079396fe..d68182ded 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 #include "tsd/io/importers/detail/usd/UsdInstancing.h" +#include "tsd/io/importers/detail/usd/UsdAnimation.h" #include "tsd/io/importers/detail/usd/UsdGeometry.h" // usd #include @@ -209,17 +210,34 @@ std::vector readInstanceTransforms( return retval; } -struct InstancerTopology +} // namespace + +bool InstancerPlacements::isVisible(int instanceId) const { - pxr::VtArray prototypes; - pxr::VtArray instanceLocations; - std::vector instanceIndices; - pxr::VtBoolArray mask; -}; + if (mask.empty()) + return true; + return size_t(instanceId) >= mask.size() || mask[size_t(instanceId)]; +} + +std::vector InstancerPlacements::forPrototype( + size_t prototypeIndex) const +{ + std::vector retval; + if (prototypeIndex >= instanceIndices.size()) + return retval; -InstancerTopology readTopology(const pxr::HdSceneIndexPrim &prim) + for (int index : instanceIndices[prototypeIndex]) { + if (!isVisible(index)) + continue; + if (size_t(index) < transforms.size()) + retval.push_back(transforms[size_t(index)]); + } + return retval; +} + +InstancerPlacements readInstancerPlacements(const pxr::HdSceneIndexPrim &prim) { - InstancerTopology retval; + InstancerPlacements retval; auto schema = pxr::HdInstancerTopologySchema::GetFromParent(prim.dataSource); if (!schema) return retval; @@ -237,17 +255,16 @@ InstancerTopology readTopology(const pxr::HdSceneIndexPrim &prim) retval.instanceIndices.push_back( element ? element->GetTypedValue(0) : pxr::VtIntArray()); } - return retval; -} -bool instanceIsVisible(const InstancerTopology &topology, int index) -{ - if (topology.mask.empty()) - return true; - return size_t(index) >= topology.mask.size() || topology.mask[size_t(index)]; -} + size_t instanceCount = 0; + for (const auto &element : retval.instanceIndices) { + for (int i : element) + instanceCount = std::max(instanceCount, size_t(i) + 1); + } + retval.transforms = readInstanceTransforms(prim, instanceCount); -} // namespace + return retval; +} void convertInstancer(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, @@ -256,41 +273,23 @@ void convertInstancer(ImportContext &ctx, LayerNodeRef node, InstancerRegistry ®istry) { - const auto topology = readTopology(prim); - if (topology.prototypes.empty()) + const auto placementsOfPrim = readInstancerPlacements(prim); + if (placementsOfPrim.prototypes.empty()) return; // Native instancing is resolved against each USD Instance's own node after // the hierarchy has been mirrored. - if (!topology.instanceLocations.empty()) + if (!placementsOfPrim.instanceLocations.empty()) return; - const auto transforms = readInstanceTransforms(prim, [&] { - size_t count = 0; - for (const auto &indices : topology.instanceIndices) { - for (int i : indices) - count = std::max(count, size_t(i) + 1); - } - return count; - }()); + const auto animatedSamples = pointInstancerSampleCount(ctx, primPath); - for (size_t protoIndex = 0; protoIndex < topology.prototypes.size(); + for (size_t protoIndex = 0; protoIndex < placementsOfPrim.prototypes.size(); ++protoIndex) { auto content = convertPrototype( - ctx, sceneIndex, topology.prototypes[protoIndex], registry); - - const auto &indices = protoIndex < topology.instanceIndices.size() - ? topology.instanceIndices[protoIndex] - : pxr::VtIntArray(); + ctx, sceneIndex, placementsOfPrim.prototypes[protoIndex], registry); - // Placements USD marks invisible are omitted rather than emitted hidden. - std::vector placements; - for (int index : indices) { - if (!instanceIsVisible(topology, index)) - continue; - if (size_t(index) < transforms.size()) - placements.push_back(transforms[size_t(index)]); - } + const auto placements = placementsOfPrim.forPrototype(protoIndex); if (placements.empty()) continue; @@ -301,7 +300,7 @@ void convertInstancer(ImportContext &ctx, (primPath.GetName() + "_" + std::to_string(i)).c_str()); expandPrototype(ctx, sceneIndex, - topology.prototypes[protoIndex], + placementsOfPrim.prototypes[protoIndex], *content, placementNode); } @@ -322,6 +321,14 @@ void convertInstancer(ImportContext &ctx, for (auto &surface : content->surfaces) ctx.scene.insertChildObjectNode( arrayNode, surface, surface->name().c_str()); + + // The Array this Prototype's placements just went into is the Array the + // binding re-fills; handing it over here is what keeps a scrub from having + // to find it again by name. + if (animatedSamples > 1) { + addInstancerAnimation( + ctx, primPath, protoIndex, arrayNode, transformArray, animatedSamples); + } } } @@ -340,33 +347,33 @@ void attachNativeInstances(ImportContext &ctx, if (prim.primType != pxr::HdPrimTypeTokens->instancer) continue; - const auto topology = readTopology(prim); - if (topology.prototypes.empty() || topology.instanceLocations.empty()) + const auto placements = readInstancerPlacements(prim); + if (placements.prototypes.empty() || placements.instanceLocations.empty()) continue; auto content = - convertPrototype(ctx, sceneIndex, topology.prototypes[0], registry); + convertPrototype(ctx, sceneIndex, placements.prototypes[0], registry); - const auto &indices = topology.instanceIndices.empty() + const auto &indices = placements.instanceIndices.empty() ? pxr::VtIntArray() - : topology.instanceIndices[0]; + : placements.instanceIndices[0]; - for (size_t i = 0; i < topology.instanceLocations.size(); ++i) { + for (size_t i = 0; i < placements.instanceLocations.size(); ++i) { const int index = size_t(i) < indices.size() ? indices[i] : int(i); - if (!instanceIsVisible(topology, index)) + if (!placements.isVisible(index)) continue; // Each USD Instance becomes one node referencing the same shared // objects, so editing the Prototype's material affects every placement // as it does in USD. - const auto locationKey = topology.instanceLocations[i].GetString(); + const auto locationKey = placements.instanceLocations[i].GetString(); auto found = registry.nodeForPrimPath.find(locationKey); auto placementNode = found != registry.nodeForPrimPath.end() ? found->second : importRoot; if (content->internalTransformsAnimated) { expandPrototype( - ctx, sceneIndex, topology.prototypes[0], *content, placementNode); + ctx, sceneIndex, placements.prototypes[0], *content, placementNode); } else { for (auto &surface : content->surfaces) { ctx.scene.insertChildObjectNode( diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h index 6400246c9..68d2011b2 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -6,6 +6,7 @@ #include "tsd/io/importers/detail/usd/UsdImportContext.h" // usd #include +#include // std #include #include @@ -32,6 +33,39 @@ struct PrototypeContent bool internalTransformsAnimated{false}; }; +/* + * What one resolved point-instancer prim says about its placements: which + * Prototypes it scatters, the transform of every instance id, which ids each + * Prototype claims, and which of them USD marks invisible. + * + * This is read once per instancer -- reading the per-instance transforms is + * the expensive part at half a million instances -- and then queried once per + * Prototype. + */ +struct InstancerPlacements +{ + pxr::VtArray prototypes; + // Non-empty only for native instancing, whose placements are attached to + // each USD Instance's own node rather than to a transform array. + pxr::VtArray instanceLocations; + std::vector instanceIndices; + pxr::VtBoolArray mask; + std::vector transforms; + + bool isVisible(int instanceId) const; + + // The visible placements of one Prototype, in instance-index order. + // Placements USD marks invisible are omitted rather than emitted hidden, so + // this is also what an animation binding must reproduce to keep a scrub + // selecting the same instances the Import did. + std::vector forPrototype(size_t prototypeIndex) const; +}; + +// Read one resolved instancer prim. Callers that only need the placements of a +// single Prototype still pay one read of the whole instancer, which is why +// this is separate from forPrototype(). +InstancerPlacements readInstancerPlacements(const pxr::HdSceneIndexPrim &prim); + /* * State shared between the mirrored-hierarchy traversal and the instancing * pass: where each USD prim landed in the Layer, and every Prototype converted diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index e72205655..ccb274d7d 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -679,9 +679,11 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, pxr::UsdShadeShader shader(descendant); if (!shader) continue; + // Re-authoring an unchanged input at every frame is not a loss, so the + // samples are compared rather than merely counted. bool animated = false; for (const auto &input : shader.GetInputs()) - animated = animated || input.GetAttr().GetNumTimeSamples() > 0; + animated = animated || attributeValueVaries(input.GetAttr()); if (animated) { ctx.reportSkip(materialPath, prim.primType.GetString(), diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index b2c85b11f..2fa216dd8 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -12,21 +12,15 @@ #include "tsd/io/importers/detail/usd/UsdImportContext.h" #include "tsd/io/importers/detail/usd/UsdInstancing.h" #include "tsd/io/importers/detail/usd/UsdLights.h" +#include "tsd/io/usd/UsdStageSession.h" // usd #include #include -#include #include #include -#include -#include -#include -#include #include #include #include -#include -#include #endif // std #include @@ -42,39 +36,6 @@ namespace { using namespace tsd::io::usd; -/////////////////////////////////////////////////////////////////////////////// -// Scene index chain ////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -// Everything OpenUSD can resolve for us, resolved before TSD sees it: sphere, -// cone and cylinder stay analytic for TSD's native quadrics while capsule, -// cube and plane become meshes; NURBS are approximated; pinned curves are -// expanded; tetrahedral meshes are converted. -pxr::HdSceneIndexBaseRefPtr buildFilterChain(pxr::HdSceneIndexBaseRefPtr input) -{ - using pxr::HdsiImplicitSurfaceSceneIndexTokens; - - auto implicitArgs = - pxr::HdRetainedContainerDataSource::New(pxr::HdPrimTypeTokens->capsule, - pxr::HdRetainedTypedSampledDataSource::New( - HdsiImplicitSurfaceSceneIndexTokens->toMesh), - pxr::HdPrimTypeTokens->cube, - pxr::HdRetainedTypedSampledDataSource::New( - HdsiImplicitSurfaceSceneIndexTokens->toMesh), - pxr::HdPrimTypeTokens->plane, - pxr::HdRetainedTypedSampledDataSource::New( - HdsiImplicitSurfaceSceneIndexTokens->toMesh)); - // Cone and cylinder are deliberately left alone: axisToTransform would move - // the shape's spine into a transform this importer does not read (local - // transforms come from the Stage, not the resolved scene), so the converter - // folds the axis into the emitted endpoints instead. - - auto retval = pxr::HdsiImplicitSurfaceSceneIndex::New(input, implicitArgs); - auto nurbs = pxr::HdsiNurbsApproximatingSceneIndex::New(retval); - auto curves = pxr::HdsiPinnedCurveExpandingSceneIndex::New(nurbs); - return pxr::HdsiTetMeshConversionSceneIndex::New(curves); -} - /////////////////////////////////////////////////////////////////////////////// // Traversal ////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// @@ -112,6 +73,7 @@ struct Traversal ImportContext &ctx; pxr::HdSceneIndexBaseRefPtr sceneIndex; InstancerRegistry &instancers; + const ClaimedPrims *claimed{nullptr}; void visit(const pxr::SdfPath &primPath, LayerNodeRef parent, @@ -154,6 +116,11 @@ void Traversal::visit(const pxr::SdfPath &primPath, bool hidden, const tsd::math::mat4 &parentXform) { + // Claimed Prims reach the Scene through the dialect's own importers; the + // generic path must not also convert a carrier prim into geometry. + if (claimed && claimed->claims(primPath)) + return; + auto prim = sceneIndex->GetPrim(primPath); // Prototype content reaches the Scene through its instancer, not here. @@ -195,10 +162,11 @@ void Traversal::visit(const pxr::SdfPath &primPath, // Visibility is imported as one static enabled/disabled state, so say when // the Stage animates it rather than leaving the difference to be noticed. + // Exporters routinely re-author every attribute at every frame, so the + // samples are compared: a value that never changes is not a loss. if (auto imageable = pxr::UsdGeomImageable(ctx.stage->GetPrimAtPath(primPath))) { - if (auto attribute = imageable.GetVisibilityAttr(); - attribute && attribute.GetNumTimeSamples() > 0) { + if (attributeValueVaries(imageable.GetVisibilityAttr())) { ctx.reportSkip(primPath, prim.primType.GetString(), UsdSkipReason::TIME_VARYING_VALUE_DROPPED, @@ -310,34 +278,38 @@ UsdImportReport import_USD(Scene &scene, { UsdImportReport report; - auto stage = pxr::UsdStage::Open(filepath, pxr::UsdStage::LoadAll); - if (!stage) { + // The Session owns the Stage and the chain that resolves it; every animation + // binding this import creates joins the same one, so a scrub resolves + // through exactly what was converted here. A fully static import lets go of + // it on return. + auto session = usd::acquireUsdSession(filepath); + if (!session) { logError("[import_USD] failed to open stage '%s'", filepath); return report; } report.stageOpened = true; - ImportContext ctx{ - scene, animMgr, options, report, stage, filepath, pathOf(filepath)}; + auto stage = session->stage(); + ImportContext ctx{scene, + animMgr, + options, + report, + session, + stage, + filepath, + pathOf(filepath)}; // Values authored only as time samples do not resolve at UsdTimeCode's // default, so the import reads at the Stage's own start of time instead. - if (stage->HasAuthoredTimeCodeRange()) - ctx.importTime = pxr::UsdTimeCode(stage->GetStartTimeCode()); + ctx.importTime = pxr::UsdTimeCode(session->startTimeCode()); + session->setTime(ctx.importTime); - // Dialect pre-pass: markers on the raw Stage claim whole subtrees, which are - // pruned from the resolved scene so the generic path never converts a - // carrier prim into meaningless geometry. + // Dialect pre-pass: markers on the raw Stage claim whole subtrees, which the + // traversal skips so the generic path never converts a carrier prim into + // meaningless geometry. auto claimed = claimDialectPrims(ctx); - pxr::UsdImagingCreateSceneIndicesInfo createInfo; - createInfo.stage = stage; - createInfo.addDrawModeSceneIndex = false; - auto sceneIndices = pxr::UsdImagingCreateSceneIndices(createInfo); - sceneIndices.stageSceneIndex->SetTime(ctx.importTime); - - auto sceneIndex = buildFilterChain( - pruneClaimedPrims(sceneIndices.finalSceneIndex, claimed)); + auto sceneIndex = session->sceneIndex(); auto root = scene.insertChildNode( location ? location : scene.defaultLayer()->root(), filepath); @@ -355,7 +327,7 @@ UsdImportReport import_USD(Scene &scene, : pxr::SdfPath(options.primPath); InstancerRegistry instancers; - Traversal traversal{ctx, sceneIndex, instancers}; + Traversal traversal{ctx, sceneIndex, instancers, claimed.get()}; scene.beginLayerEditBatch(); if (scopeRoot == pxr::SdfPath::AbsoluteRootPath()) { diff --git a/tsd/src/tsd/io/importers/import_file.cpp b/tsd/src/tsd/io/importers/import_file.cpp index 559a5ce38..56a503c15 100644 --- a/tsd/src/tsd/io/importers/import_file.cpp +++ b/tsd/src/tsd/io/importers/import_file.cpp @@ -3,8 +3,12 @@ #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/importer_common.hpp" +// tsd_animation +#include "tsd/animation/AnimationManager.hpp" // tsd_core #include "tsd/core/Logging.hpp" +// std +#include namespace tsd::io { @@ -26,6 +30,43 @@ void ensureDefaultTransferFunction(tsd::core::TransferFunction &tf) tf.range = {}; } +// A USD Import reports the Stage's frame range and rate rather than applying +// them, because every Animation shares one playback clock and an Import must +// not clobber what is already on it. Widening is the one safe move: content +// that needs more frames than the clock has would otherwise be under-sampled, +// while content that needs fewer still plays correctly on a longer clock. When +// two Stages disagree, the conflict is logged and the larger value wins. +void widenAnimationClock( + tsd::animation::AnimationManager &animMgr, const UsdImportReport &report) +{ + if (report.sampleCount < 2) + return; + + const int frames = animMgr.getAnimationTotalFrames(); + if (int(report.sampleCount) > frames) + animMgr.setAnimationTotalFrames(int(report.sampleCount)); + else if (int(report.sampleCount) < frames) { + tsd::core::logStatus( + "...stage has %zu samples but the animation clock is already %i frames" + " long; keeping %i", + report.sampleCount, + frames, + frames); + } + + const float fps = animMgr.getAnimationFPS(); + if (report.timeCodesPerSecond > fps) + animMgr.setAnimationFPS(report.timeCodesPerSecond); + else if (report.timeCodesPerSecond > 0.f && report.timeCodesPerSecond < fps) { + tsd::core::logStatus( + "...stage runs at %g fps but the animation clock is already at %g fps;" + " keeping %g", + double(report.timeCodesPerSecond), + double(fps), + double(fps)); + } +} + } // namespace void import_file(Scene &scene, @@ -106,12 +147,14 @@ void import_file(Scene &scene, tsd::io::import_SWC_SDF(scene, animMgr, file.c_str(), root); else if (f.first == ImporterType::TRK) tsd::io::import_TRK(scene, animMgr, file.c_str(), root); - else if (f.first == ImporterType::USD) - tsd::io::import_USD(scene, animMgr, file.c_str(), root); - else if (f.first == ImporterType::USD_MTLX) { + else if (f.first == ImporterType::USD) { + widenAnimationClock( + animMgr, tsd::io::import_USD(scene, animMgr, file.c_str(), root)); + } else if (f.first == ImporterType::USD_MTLX) { UsdImportOptions options; options.materialMode = UsdMaterialMode::MATERIALX; - tsd::io::import_USD(scene, animMgr, file.c_str(), root, options); + widenAnimationClock(animMgr, + tsd::io::import_USD(scene, animMgr, file.c_str(), root, options)); } else if (f.first == ImporterType::VTP) tsd::io::import_VTP(scene, animMgr, file.c_str(), root); else if (f.first == ImporterType::VTU) { diff --git a/tsd/src/tsd/io/serialization/serialization_datatree.cpp b/tsd/src/tsd/io/serialization/serialization_datatree.cpp index 2b93b6e89..bfd72e08b 100644 --- a/tsd/src/tsd/io/serialization/serialization_datatree.cpp +++ b/tsd/src/tsd/io/serialization/serialization_datatree.cpp @@ -9,6 +9,7 @@ #include "tsd/io/animation/EnSightFileBinding.hpp" #include "tsd/io/animation/SpatialFieldFileBinding.hpp" #include "tsd/io/animation/UsdGeometryFileBinding.hpp" +#include "tsd/io/animation/UsdInstancerFileBinding.hpp" #include "tsd/io/archives/AnimationManagerArchive.hpp" #include "tsd/io/archives/CameraArchive.hpp" #include "tsd/io/archives/RendererArchive.hpp" @@ -211,6 +212,8 @@ void nodeToAnimation( std::move(data->fieldMappings)); } else if (kind == "usdGeometry") { UsdGeometryFileBinding::addToAnimation(anim, scene, fbNode); + } else if (kind == "usdInstancer") { + UsdInstancerFileBinding::addToAnimation(anim, scene, fbNode); } else { logWarning("[nodeToAnimation] unknown file binding kind '%s'; skipping", kind.c_str()); diff --git a/tsd/src/tsd/io/usd/UsdStageSession.cpp b/tsd/src/tsd/io/usd/UsdStageSession.cpp new file mode 100644 index 000000000..979994b0d --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdStageSession.cpp @@ -0,0 +1,184 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/usd/UsdStageSession.h" +#include "tsd/core/Logging.hpp" +// usd +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include + +namespace tsd::io::usd { + +namespace { + +// Everything OpenUSD can resolve for us, resolved before TSD sees it: sphere, +// cone and cylinder stay analytic for TSD's native quadrics while capsule, +// cube and plane become meshes; NURBS are approximated; pinned curves are +// expanded; tetrahedral meshes are converted. +pxr::HdSceneIndexBaseRefPtr buildFilterChain(pxr::HdSceneIndexBaseRefPtr input) +{ + using pxr::HdsiImplicitSurfaceSceneIndexTokens; + + auto implicitArgs = + pxr::HdRetainedContainerDataSource::New(pxr::HdPrimTypeTokens->capsule, + pxr::HdRetainedTypedSampledDataSource::New( + HdsiImplicitSurfaceSceneIndexTokens->toMesh), + pxr::HdPrimTypeTokens->cube, + pxr::HdRetainedTypedSampledDataSource::New( + HdsiImplicitSurfaceSceneIndexTokens->toMesh), + pxr::HdPrimTypeTokens->plane, + pxr::HdRetainedTypedSampledDataSource::New( + HdsiImplicitSurfaceSceneIndexTokens->toMesh)); + // Cone and cylinder are deliberately left alone: axisToTransform would move + // the shape's spine into a transform this importer does not read (local + // transforms come from the Stage, not the resolved scene), so the converter + // folds the axis into the emitted endpoints instead. + + auto retval = pxr::HdsiImplicitSurfaceSceneIndex::New(input, implicitArgs); + auto nurbs = pxr::HdsiNurbsApproximatingSceneIndex::New(retval); + auto curves = pxr::HdsiPinnedCurveExpandingSceneIndex::New(nurbs); + return pxr::HdsiTetMeshConversionSceneIndex::New(curves); +} + +// The registry key. A Stage that cannot be resolved to an absolute path is +// keyed by what the caller said, which is still stable within one process. +std::string sessionKeyOf(const std::string &filePath) +{ + std::error_code ec; + auto absolute = std::filesystem::weakly_canonical(filePath, ec); + return ec ? filePath : absolute.string(); +} + +std::mutex ®istryMutex() +{ + static std::mutex mutex; + return mutex; +} + +std::map> ®istry() +{ + static std::map> sessions; + return sessions; +} + +} // namespace + +UsdStageSession::UsdStageSession( + std::string filePath, pxr::UsdStageRefPtr stage) + : m_filePath(std::move(filePath)), m_stage(std::move(stage)) +{ + pxr::UsdImagingCreateSceneIndicesInfo createInfo; + createInfo.stage = m_stage; + createInfo.addDrawModeSceneIndex = false; + auto sceneIndices = pxr::UsdImagingCreateSceneIndices(createInfo); + m_stageSceneIndex = sceneIndices.stageSceneIndex; + m_sceneIndex = buildFilterChain(sceneIndices.finalSceneIndex); + + // Values authored only as time samples do not resolve at UsdTimeCode's + // default, so a Stage with no authored range is still read at a real time. + m_startTimeCode = m_stage->GetStartTimeCode(); + m_endTimeCode = m_stage->GetEndTimeCode(); + if (!(m_endTimeCode > m_startTimeCode)) + m_endTimeCode = m_startTimeCode; + + setTime(pxr::UsdTimeCode(m_startTimeCode)); +} + +UsdStageSession::~UsdStageSession() = default; + +const std::string &UsdStageSession::filePath() const +{ + return m_filePath; +} + +const pxr::UsdStageRefPtr &UsdStageSession::stage() const +{ + return m_stage; +} + +const pxr::HdSceneIndexBaseRefPtr &UsdStageSession::sceneIndex() const +{ + return m_sceneIndex; +} + +double UsdStageSession::startTimeCode() const +{ + return m_startTimeCode; +} + +double UsdStageSession::endTimeCode() const +{ + return m_endTimeCode; +} + +double UsdStageSession::timeCodesPerSecond() const +{ + return m_stage->GetTimeCodesPerSecond(); +} + +pxr::UsdTimeCode UsdStageSession::timeCodeAt(float t) const +{ + const double span = m_endTimeCode - m_startTimeCode; + return pxr::UsdTimeCode(m_startTimeCode + double(t) * span); +} + +pxr::UsdTimeCode UsdStageSession::currentTime() const +{ + return m_currentTime; +} + +void UsdStageSession::setTime(pxr::UsdTimeCode time) +{ + if (m_currentTime == time) + return; + m_currentTime = time; + if (!m_stageSceneIndex) + return; + m_stageSceneIndex->SetTime(time); + m_stageSceneIndex->ApplyPendingUpdates(); +} + +std::shared_ptr acquireUsdSession(const std::string &filePath) +{ + const auto key = sessionKeyOf(filePath); + + std::lock_guard guard(registryMutex()); + + auto &sessions = registry(); + if (auto found = sessions.find(key); found != sessions.end()) { + if (auto existing = found->second.lock()) + return existing; + } + + auto stage = pxr::UsdStage::Open(filePath, pxr::UsdStage::LoadAll); + if (!stage) + return {}; + + // The deleter takes the registry entry out with the Session, so a later + // acquire of the same file opens a fresh Stage rather than finding a corpse. + std::shared_ptr session( + new UsdStageSession(filePath, stage), [key](UsdStageSession *s) { + { + std::lock_guard innerGuard(registryMutex()); + auto &inner = registry(); + if (auto found = inner.find(key); + found != inner.end() && found->second.expired()) + inner.erase(found); + } + delete s; + }); + + sessions[key] = session; + return session; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/usd/UsdStageSession.h b/tsd/src/tsd/io/usd/UsdStageSession.h new file mode 100644 index 000000000..4be55c426 --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdStageSession.h @@ -0,0 +1,71 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// usd +#include +#include +#include +// std +#include +#include + +namespace tsd::io::usd { + +/* + * A Stage held open together with the resolution chain that turns it into the + * scene TSD reads, and the Time Code both are currently evaluated at. One + * Session is shared by the Import that created it and by every animation + * binding that Import produced, so a scrub resolves through exactly the chain + * the Import converted from. + * + * setTime() is the one place SetTime/ApplyPendingUpdates happen, and it does + * them once per distinct Time Code no matter how many bindings ask for it. + * Nothing here depends on Import Options: two Imports of one file with + * different options still share one Session. + * + * Example: + * auto session = acquireUsdSession("/data/sim.usd"); + * session->setTime(session->timeCodeAt(0.5f)); + * auto prim = session->sceneIndex()->GetPrim(primPath); + */ +struct UsdStageSession +{ + // Use acquireUsdSession(); the constructor is public only so the registry + // can build one with make_shared. + UsdStageSession(std::string filePath, pxr::UsdStageRefPtr stage); + ~UsdStageSession(); + + const std::string &filePath() const; + const pxr::UsdStageRefPtr &stage() const; + const pxr::HdSceneIndexBaseRefPtr &sceneIndex() const; + + // The Stage's own clock. + double startTimeCode() const; + double endTimeCode() const; + double timeCodesPerSecond() const; + + // Normalized animation time onto the Stage's clock. USD evaluates + // continuously at the result, so no snapping to an authored sample happens. + pxr::UsdTimeCode timeCodeAt(float t) const; + + pxr::UsdTimeCode currentTime() const; + void setTime(pxr::UsdTimeCode time); + + private: + std::string m_filePath; + pxr::UsdStageRefPtr m_stage; + pxr::UsdImagingStageSceneIndexRefPtr m_stageSceneIndex; + pxr::HdSceneIndexBaseRefPtr m_sceneIndex; + pxr::UsdTimeCode m_currentTime{pxr::UsdTimeCode::EarliestTime()}; + double m_startTimeCode{0.0}; + double m_endTimeCode{0.0}; +}; + +// Open `filePath`, or join the Session already open on it. Sessions are keyed +// by absolute path in a process-wide registry that holds them weakly: the last +// holder to let go closes the Stage. Returns null if the Stage cannot open. +std::shared_ptr acquireUsdSession(const std::string &filePath); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/rendering/index/RenderIndex.cpp b/tsd/src/tsd/rendering/index/RenderIndex.cpp index c30491c94..6f00e9459 100644 --- a/tsd/src/tsd/rendering/index/RenderIndex.cpp +++ b/tsd/src/tsd/rendering/index/RenderIndex.cpp @@ -108,7 +108,7 @@ void RenderIndex::populate() createANARICacheObjects(db.camera, m_cache.camera); createANARICacheObjects(db.renderer, m_cache.renderer); - updateWorld(); + requestWorldUpdate(); } void RenderIndex::setFilterFunction(RenderIndexFilterFcn f) @@ -121,7 +121,7 @@ void RenderIndex::setExternalInstances( { m_externalInstances.resize(count); std::copy(instances, instances + count, m_externalInstances.data()); - updateWorld(); + requestWorldUpdate(); } void RenderIndex::signalObjectAdded(const Object *obj) @@ -205,7 +205,7 @@ void RenderIndex::signalObjectFilteringChanged() void RenderIndex::signalObjectRemoved(const Object *o) { m_cache.removeHandle(o); - updateWorld(); + requestWorldUpdate(); } void RenderIndex::signalRemoveAllObjects() @@ -221,7 +221,30 @@ void RenderIndex::signalInvalidateCachedObjects() { signalRemoveAllObjects(); populate(); + requestWorldUpdate(); +} + +void RenderIndex::signalUpdateBatchBegin() +{ + m_updateBatchDepth++; +} + +void RenderIndex::signalUpdateBatchEnd() +{ + if (m_updateBatchDepth > 0) + m_updateBatchDepth--; + if (m_updateBatchDepth > 0 || !m_worldUpdateDeferred) + return; + m_worldUpdateDeferred = false; updateWorld(); } +void RenderIndex::requestWorldUpdate() +{ + if (m_updateBatchDepth > 0) + m_worldUpdateDeferred = true; + else + updateWorld(); +} + } // namespace tsd::rendering diff --git a/tsd/src/tsd/rendering/index/RenderIndex.hpp b/tsd/src/tsd/rendering/index/RenderIndex.hpp index 5a7beffaa..bc33d0685 100644 --- a/tsd/src/tsd/rendering/index/RenderIndex.hpp +++ b/tsd/src/tsd/rendering/index/RenderIndex.hpp @@ -71,10 +71,18 @@ struct RenderIndex : public BaseUpdateDelegate void signalObjectRemoved(const Object *o) override; void signalRemoveAllObjects() override; void signalInvalidateCachedObjects() override; + void signalUpdateBatchBegin() override; + void signalUpdateBatchEnd() override; protected: virtual void updateWorld() = 0; + // Rebuild the ANARI world, or -- inside an update batch -- remember that one + // rebuild is owed and do it when the batch ends. Scrubbing an animated Stage + // unmaps one transform Array per instancer per frame, and each unmap would + // otherwise pay a full world rebuild. + void requestWorldUpdate(); + Scene *m_ctx{nullptr}; AnariHandleCache m_cache; @@ -82,6 +90,9 @@ struct RenderIndex : public BaseUpdateDelegate std::vector m_externalInstances; private: + int m_updateBatchDepth{0}; + bool m_worldUpdateDeferred{false}; + friend struct RenderToAnariObjectsVisitor; }; diff --git a/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp b/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp index 10d6ebaed..1f186cdc2 100644 --- a/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp +++ b/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp @@ -62,7 +62,7 @@ void RenderIndexAllLayers::signalArrayUnmapped(const Array *a) { RenderIndex::signalArrayUnmapped(a); if (a->elementType() == ANARI_FLOAT32_MAT4) - updateWorld(); + requestWorldUpdate(); } void RenderIndexAllLayers::signalObjectParameterUseCountZero(const Object *o) @@ -98,14 +98,14 @@ void RenderIndexAllLayers::signalObjectLayerUseCountZero(const Object *o) void RenderIndexAllLayers::signalLayerAdded(const Layer *l) { syncLayerInstances(l, false, objectMask_all()); - updateWorld(); + requestWorldUpdate(); } void RenderIndexAllLayers::signalLayerStructureUpdated(const Layer *l) { if (m_instanceCache.contains(l)) { syncLayerInstances(l, false, objectMask_all()); - updateWorld(); + requestWorldUpdate(); } } @@ -113,7 +113,7 @@ void RenderIndexAllLayers::signalLayerTransformUpdated(const Layer *l) { if (m_instanceCache.contains(l)) { syncLayerTransforms(l); - updateWorld(); + requestWorldUpdate(); } } @@ -122,7 +122,7 @@ void RenderIndexAllLayers::signalLayerRemoved(const Layer *l) if (m_instanceCache.contains(l)) { releaseInstances(device(), m_instanceCache[l]); m_instanceCache.erase(l); - updateWorld(); + requestWorldUpdate(); } } @@ -141,7 +141,7 @@ void RenderIndexAllLayers::signalObjectFilteringChanged() { if (m_filter || m_filterForceUpdate) { releaseAllInstances(); - updateWorld(); + requestWorldUpdate(); m_filterForceUpdate = false; } } diff --git a/tsd/src/tsd/rendering/index/RenderIndexFlatRegistry.cpp b/tsd/src/tsd/rendering/index/RenderIndexFlatRegistry.cpp index 6eafa125d..7825cc282 100644 --- a/tsd/src/tsd/rendering/index/RenderIndexFlatRegistry.cpp +++ b/tsd/src/tsd/rendering/index/RenderIndexFlatRegistry.cpp @@ -22,7 +22,7 @@ void RenderIndexFlatRegistry::signalObjectAdded(const Object *obj) if (!obj) return; RenderIndex::signalObjectAdded(obj); - updateWorld(); + requestWorldUpdate(); } void RenderIndexFlatRegistry::signalObjectParameterUseCountZero(const Object *) diff --git a/tsd/src/tsd/scene/Scene.cpp b/tsd/src/tsd/scene/Scene.cpp index 708315c04..efaa38440 100644 --- a/tsd/src/tsd/scene/Scene.cpp +++ b/tsd/src/tsd/scene/Scene.cpp @@ -946,6 +946,16 @@ void Scene::endLayerEditBatch() bl.clear(); } +void Scene::beginUpdateBatch() +{ + m_updateDelegate.signalUpdateBatchBegin(); +} + +void Scene::endUpdateBatch() +{ + m_updateDelegate.signalUpdateBatchEnd(); +} + void Scene::signalLayerStructureChanged(const Layer *l) { if (m_inLayerBatch) diff --git a/tsd/src/tsd/scene/Scene.hpp b/tsd/src/tsd/scene/Scene.hpp index be1407c9d..4a36a2ce5 100644 --- a/tsd/src/tsd/scene/Scene.hpp +++ b/tsd/src/tsd/scene/Scene.hpp @@ -211,6 +211,12 @@ struct Scene void beginLayerEditBatch(); // structural layer changes are batched void endLayerEditBatch(); // stop batching + flush all layer update signals + // Bracket a run of mutations that should cost delegates at most one rebuild. + // Unlike the layer-edit batch, every signal is still delivered as it + // happens; what is coalesced is the work a delegate does in response. Nests. + void beginUpdateBatch(); + void endUpdateBatch(); + void signalLayerStructureChanged(const Layer *l); void signalLayerTransformChanged(const Layer *l); void signalActiveLayersChanged(); diff --git a/tsd/src/tsd/scene/UpdateDelegate.cpp b/tsd/src/tsd/scene/UpdateDelegate.cpp index 3b67cda54..de7be088d 100644 --- a/tsd/src/tsd/scene/UpdateDelegate.cpp +++ b/tsd/src/tsd/scene/UpdateDelegate.cpp @@ -150,4 +150,16 @@ void MultiUpdateDelegate::signalInvalidateCachedObjects() d->signalInvalidateCachedObjects(); } +void MultiUpdateDelegate::signalUpdateBatchBegin() +{ + for (auto &d : m_delegates) + d->signalUpdateBatchBegin(); +} + +void MultiUpdateDelegate::signalUpdateBatchEnd() +{ + for (auto &d : m_delegates) + d->signalUpdateBatchEnd(); +} + } // namespace tsd::scene diff --git a/tsd/src/tsd/scene/UpdateDelegate.hpp b/tsd/src/tsd/scene/UpdateDelegate.hpp index cc85fc501..de72b4cd3 100644 --- a/tsd/src/tsd/scene/UpdateDelegate.hpp +++ b/tsd/src/tsd/scene/UpdateDelegate.hpp @@ -54,6 +54,13 @@ struct BaseUpdateDelegate virtual void signalObjectFilteringChanged() = 0; virtual void signalInvalidateCachedObjects() = 0; + // Bracket a run of mutations that should produce at most one downstream + // rebuild. Nesting is counted, so an outer batch is not ended by an inner + // one. Every signal above still arrives; only the work they trigger is + // coalesced. + virtual void signalUpdateBatchBegin() = 0; + virtual void signalUpdateBatchEnd() = 0; + TSD_NOT_COPYABLE(BaseUpdateDelegate) TSD_DEFAULT_MOVEABLE(BaseUpdateDelegate) }; @@ -90,6 +97,8 @@ struct EmptyUpdateDelegate : public BaseUpdateDelegate void signalActiveLayersChanged() override {} void signalObjectFilteringChanged() override {} void signalInvalidateCachedObjects() override {} + void signalUpdateBatchBegin() override {} + void signalUpdateBatchEnd() override {} }; /* @@ -136,6 +145,8 @@ struct MultiUpdateDelegate : public BaseUpdateDelegate void signalActiveLayersChanged() override; void signalObjectFilteringChanged() override; void signalInvalidateCachedObjects() override; + void signalUpdateBatchBegin() override; + void signalUpdateBatchEnd() override; private: std::vector> m_delegates; diff --git a/tsd/tests/test_AnimationManager.cpp b/tsd/tests/test_AnimationManager.cpp index 981cdbdbc..f4c95e227 100644 --- a/tsd/tests/test_AnimationManager.cpp +++ b/tsd/tests/test_AnimationManager.cpp @@ -8,10 +8,85 @@ #include "tsd/core/DataTree.hpp" #include "tsd/io/serialization/serialization_internal.hpp" #include "tsd/scene/Scene.hpp" +#include "tsd/scene/UpdateDelegate.hpp" +#include "tsd/scene/objects/Array.hpp" +// std +#include using tsd::animation::AnimationManager; using tsd::scene::Scene; +namespace { + +// Records the batch bracket and the array unmaps inside it, which is the +// signal a render index coalesces its world rebuilds on. +struct BatchRecordingDelegate : public tsd::scene::EmptyUpdateDelegate +{ + void signalUpdateBatchBegin() override + { + depth++; + begins++; + } + + void signalUpdateBatchEnd() override + { + depth--; + ends++; + } + + void signalArrayUnmapped(const tsd::scene::Array *) override + { + unmapsInsideBatch += depth > 0 ? 1 : 0; + unmapsOutsideBatch += depth > 0 ? 0 : 1; + } + + int depth{0}; + int begins{0}; + int ends{0}; + int unmapsInsideBatch{0}; + int unmapsOutsideBatch{0}; +}; + +} // namespace + +SCENARIO("A time change is one update batch", "[AnimationManager]") +{ + GIVEN("Several bindings that each rewrite an Array") + { + Scene scene; + AnimationManager mgr(&scene); + + auto *recorder = + scene.updateDelegate().emplace(); + + std::vector arrays; + for (int i = 0; i < 3; ++i) { + auto &anim = mgr.addAnimation("rewriter" + std::to_string(i)); + auto array = scene.createArray(ANARI_FLOAT32_MAT4, 1); + arrays.push_back(array); + anim.addCallbackBinding([array](float t) mutable { + const auto m = tsd::math::IDENTITY_MAT4; + array->setData(&m, 1); + }); + } + + WHEN("The animation time changes once") + { + const int unmapsBefore = recorder->unmapsOutsideBatch; + mgr.setAnimationTime(0.5f); + + THEN("Every rewrite lands inside exactly one balanced batch") + { + REQUIRE(recorder->begins == 1); + REQUIRE(recorder->ends == 1); + REQUIRE(recorder->depth == 0); + REQUIRE(recorder->unmapsInsideBatch == 3); + REQUIRE(recorder->unmapsOutsideBatch == unmapsBefore); + } + } + } +} + SCENARIO("tsd::animation::AnimationManager playback", "[AnimationManager]") { Scene scene; diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 4944b98fc..9a6ad62ff 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -76,6 +76,7 @@ SCENARIO("Import report counts skipped prims by reason", "[UsdImport]") #if TSD_USE_USD #include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/animation/UsdGeometryFileBinding.hpp" #include "tsd/io/archives/AnimationManagerArchive.hpp" #include "tsd/io/importers.hpp" #include "tsd/scene/Scene.hpp" @@ -2541,5 +2542,391 @@ def Xform "World" } } -#endif // TSD_USE_USD +// Animated USD /////////////////////////////////////////////////////////////// + +namespace { + +// The transform Array of the first transform-array node beneath `name`. +tsd::scene::Array *findTransformArray( + tsd::scene::Layer *layer, const char *name) +{ + auto parent = findNode(layer, name); + if (!parent) + return nullptr; + tsd::scene::Array *found = nullptr; + layer->traverse(parent, [&](auto &node, int) { + if (!found && node->type() == ANARI_ARRAY1D) + found = node->getTransformArray(); + return true; + }); + return found; +} + +} // namespace + +SCENARIO("A point instancer's placements follow the Stage clock", "[UsdImport]") +{ + GIVEN("A PointInstancer whose positions and scales are time-sampled") + { + StageFixture stage("tsd_test_usd_animated_instancer.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def PointInstancer "Swarm" +{ + point3f[] positions.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (2, 0, 0)], + 2: [(0, 0, 0), (10, 0, 0), (20, 0, 0)], + } + float3[] scales.timeSamples = { + 0: [(1, 1, 1), (1, 1, 1), (1, 1, 1)], + 2: [(2, 2, 2), (2, 2, 2), (2, 2, 2)], + } + int[] protoIndices = [0, 0, 0] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("One Animation holds the instancer's binding") + { + REQUIRE(animMgr.animations().size() == 1); + REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); + REQUIRE(animMgr.animations()[0].fileBindings()[0]->kind() + == "usdInstancer"); + } + + THEN("The Stage's frame range and rate are reported, not applied") + { + REQUIRE(report.animatedPrims == 1); + REQUIRE(report.sampleCount == 2); + REQUIRE(report.timeCodesPerSecond == Approx(24.f)); + REQUIRE(animMgr.getAnimationTotalFrames() == 100); // untouched + } + + THEN("The imported placements are the Stage's first frame") + { + auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->size() == 3); + const auto *m = transforms->dataAs(); + REQUIRE(m[2][3].x == Approx(2.f)); + REQUIRE(m[2][0].x == Approx(1.f)); + } + + THEN("Scrubbing re-fills the same Array in place") + { + auto *before = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(before != nullptr); + + animMgr.setAnimationTime(1.0f); + + auto *after = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(after == before); // no reallocation on a constant count + const auto *m = after->dataAs(); + REQUIRE(m[2][3].x == Approx(20.f)); + REQUIRE(m[2][0].x == Approx(2.f)); + } + + THEN("A time between authored samples is interpolated, not snapped") + { + animMgr.setAnimationTime(0.5f); + + auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(transforms != nullptr); + const auto *m = transforms->dataAs(); + REQUIRE(m[2][3].x == Approx(11.f)); + } + } + } +} + +SCENARIO("An instancer whose placement count changes reallocates", + "[UsdImport]") +{ + GIVEN("A PointInstancer that gains a placement mid-sequence") + { + StageFixture stage("tsd_test_usd_growing_instancer.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def PointInstancer "Growing" +{ + point3f[] positions.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0)], + 2: [(0, 0, 0), (1, 0, 0), (2, 0, 0)], + } + int[] protoIndices.timeSamples = { + 0: [0, 0], + 2: [0, 0, 0], + } + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported and scrubbed past the change") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + auto *before = findTransformArray(scene.defaultLayer(), "Growing"); + REQUIRE(before != nullptr); + REQUIRE(before->size() == 2); + + animMgr.setAnimationTime(1.0f); + + THEN("The node is re-pointed at a right-sized Array") + { + auto *after = findTransformArray(scene.defaultLayer(), "Growing"); + REQUIRE(after != nullptr); + REQUIRE(after->size() == 3); + REQUIRE(after->dataAs()[2][3].x == Approx(2.f)); + } + } + } +} + +SCENARIO("Constant-valued time samples are not reported as a loss", + "[UsdImport]") +{ + GIVEN("A mesh whose visibility is authored at every frame but never changes") + { + // What a simulation exporter writes: every attribute re-authored at every + // frame regardless of whether it moved. + StageFixture stage("tsd_test_usd_constant_visibility.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Steady" +{ + token visibility.timeSamples = { + 0: "inherited", + 1: "inherited", + 2: "inherited", + } + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Nothing is reported as dropped") + { + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) + == 0); + } + } + } +} + +SCENARIO("One import is one Animation", "[UsdImport]") +{ + GIVEN("A Stage animating two prims that share a leaf name") + { + StageFixture stage("tsd_test_usd_one_animation.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Xform "A" +{ + def Xform "Mover" + { + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 2: (2, 0, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} + +def Xform "B" +{ + def Xform "Mover" + { + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 2: (0, 5, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Both prims land in one Animation named for the file") + { + REQUIRE(animMgr.animations().size() == 1); + REQUIRE(animMgr.animations()[0].name() == stage.path()); + REQUIRE(animMgr.animations()[0].transformBindings().size() == 2); + } + + THEN("The Report counts them in place of the lost per-prim entries") + { + REQUIRE(report.animatedPrims == 2); + } + } + } +} + +SCENARIO("Instancer bindings survive save and reload", "[UsdImport]") +{ + GIVEN("An imported Stage with an animated PointInstancer") + { + StageFixture stage("tsd_test_usd_instancer_archive.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def PointInstancer "Swarm" +{ + point3f[] positions.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0)], + 2: [(0, 0, 0), (9, 0, 0)], + } + int[] protoIndices = [0, 0] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + WHEN("The animation manager round-trips through an Archive") + { + tsd::core::DataTree tree; + REQUIRE(tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); + + tsd::animation::AnimationManager restored(&scene); + REQUIRE( + tsd::io::deserialize_AnimationManagerArchive(restored, tree.root())); + THEN("The reconstructed binding scrubs the same Array") + { + REQUIRE(restored.animations().size() == 1); + REQUIRE(restored.animations()[0].fileBindings().size() == 1); + REQUIRE(restored.animations()[0].fileBindings()[0]->kind() + == "usdInstancer"); + + restored.setAnimationTime(1.0f); + + auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->dataAs()[1][3].x == Approx(9.f)); + } + } + } +} + +SCENARIO("An old-format geometry binding still reconstructs", "[UsdImport]") +{ + GIVEN("An Archive node carrying the dropped sampleTimes and timeBase fields") + { + StageFixture stage("tsd_test_usd_legacy_binding.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Blob" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 2: [(0, 0, 0), (3, 0, 0), (0, 3, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + animMgr.removeAllAnimations(); + + // Written the way an Archive from before continuous Time Code evaluation + // was: the derived sample cache is present and must simply be ignored. + tsd::core::DataTree tree; + auto &node = tree.root(); + node["targetIndex"] = size_t(0); + node["stageFile"] = stage.path(); + node["primPath"] = std::string("/Blob"); + node["sampleTimes"].append() = 0.f; + node["sampleTimes"].append() = 2.f; + node["timeBase"].append() = 0.f; + node["timeBase"].append() = 1.f; + + WHEN("It is read back") + { + auto &anim = animMgr.addAnimation("legacy"); + REQUIRE(tsd::io::UsdGeometryFileBinding::addToAnimation( + anim, scene, node) + != nullptr); + + THEN("It scrubs from the Stage's own clock") + { + animMgr.setAnimationTime(1.0f); + + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->dataAs()[1].x == Approx(3.f)); + } + } + } +} + +#endif // TSD_USE_USD From 0ccece9f27b8b9dafac437c161a141a678b23022 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 17 Aug 2026 21:40:47 -0500 Subject: [PATCH 31/78] fix(io): address code review of the animated USD import Standards axis: - UsdStageSession no longer manages its own memory. It is built with make_shared and takes its registry entry out in its own destructor, where the weak entry is already expired -- same semantics, no raw new/delete (STYLEGUIDE section 6), and the header comment about make_shared is now true. It also declares its lifetime with the TSD macros. - The session-joining both bindings had carried byte-identically, along with the session/stageFile/primPath trio, moves onto a shared UsdFileBinding base. - The update-batch hooks become optional virtuals with no-op bodies rather than pure ones, which is the shape STYLEGUIDE section 13 asks for and removes the empty overrides they forced on NetworkUpdateDelegate. - Clock widening moves off import_file and onto AnimationManager::widenClock, which is whose state it was reading and writing. Spec axis: - A Stage that authors time samples but no startTimeCode range no longer freezes: USD reports zero for both ends, so every animation time mapped onto one Time Code. Animated prims now tell the Session what range their samples cover as they are bound. The deleted normalizeSampleTimes() had a fallback for this and none of the fixtures covered it; two now do, and both fail without it. - readInstancerPlacements() no longer reads per-instance transforms on the native-instancing path, which discards them -- that read is the expensive part at half a million instances. - Claimed Prims are consulted by every walk over the resolved scene, not just the hierarchy traversal. Prefix pruning used to remove them for convertPrototype() and attachNativeInstances() too; moving the check into ImportContext::isClaimed restores that. - "N animated prims bound" counts prims again, not bindings: a multi-prototype instancer is one animated prim however many transform arrays it fills. - The Lua importUSD bindings and the USD tutorial fold the reported clock in, as import_file does; the spec asked for the callers to be wired. - test_ArchiveCompatibility.cpp gains the old-format usdGeometry fixture the spec named it for, plus a usdInstancer one. - ADR 0021 no longer claims every binding resolves through the Session's chain: the instancer binding does, the geometry binding reads the Stage schema directly as ADR 0018 described, and the Session's contribution there is that it does not open the file a second time. Co-Authored-By: Claude Opus 5 --- .../network/client/NetworkUpdateDelegate.cpp | 10 -- .../network/client/NetworkUpdateDelegate.hpp | 2 - tsd/apps/tutorial/tsdTutorialLoadUSD.cpp | 3 +- ...age-session-across-import-and-animation.md | 48 +++++++--- tsd/src/tsd/animation/AnimationManager.cpp | 14 +++ tsd/src/tsd/animation/AnimationManager.hpp | 8 ++ tsd/src/tsd/io/CMakeLists.txt | 1 + tsd/src/tsd/io/UsdImport.cpp | 24 +++++ tsd/src/tsd/io/UsdImport.hpp | 13 +++ tsd/src/tsd/io/animation/UsdFileBinding.cpp | 92 +++++++++++++++++++ tsd/src/tsd/io/animation/UsdFileBinding.hpp | 70 ++++++++++++++ .../io/animation/UsdGeometryFileBinding.cpp | 62 ++++++------- .../io/animation/UsdGeometryFileBinding.hpp | 18 +--- .../io/animation/UsdInstancerFileBinding.cpp | 51 +++++----- .../io/animation/UsdInstancerFileBinding.hpp | 18 +--- .../io/importers/detail/usd/UsdAnimation.cpp | 44 ++++----- .../io/importers/detail/usd/UsdAnimation.h | 3 +- .../importers/detail/usd/UsdImportContext.cpp | 10 +- .../importers/detail/usd/UsdImportContext.h | 14 ++- .../io/importers/detail/usd/UsdInstancing.cpp | 50 +++++++++- .../io/importers/detail/usd/UsdInstancing.h | 11 ++- tsd/src/tsd/io/importers/import_USD.cpp | 6 +- tsd/src/tsd/io/importers/import_file.cpp | 37 -------- tsd/src/tsd/io/usd/UsdStageSession.cpp | 66 ++++++++----- tsd/src/tsd/io/usd/UsdStageSession.h | 27 +++++- tsd/src/tsd/scene/UpdateDelegate.hpp | 9 +- tsd/src/tsd/scripting/bindings/IOBindings.cpp | 27 ++++-- tsd/tests/test_ArchiveCompatibility.cpp | 77 ++++++++++++++++ tsd/tests/test_UsdImport.cpp | 80 ++++++++++++++++ 29 files changed, 664 insertions(+), 231 deletions(-) create mode 100644 tsd/src/tsd/io/animation/UsdFileBinding.cpp create mode 100644 tsd/src/tsd/io/animation/UsdFileBinding.hpp diff --git a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp index 9a84afd2f..95f19f09f 100644 --- a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp +++ b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.cpp @@ -172,14 +172,4 @@ bool NetworkUpdateDelegate::isReady(const char *fcn) const return true; } -void NetworkUpdateDelegate::signalUpdateBatchBegin() -{ - // Every mutation is already sent as it happens; nothing is coalesced here. -} - -void NetworkUpdateDelegate::signalUpdateBatchEnd() -{ - // See signalUpdateBatchBegin(). -} - } // namespace tsd::network diff --git a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp index 1fd6cda53..4aac68c85 100644 --- a/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp +++ b/tsd/apps/interactive/network/client/NetworkUpdateDelegate.hpp @@ -42,8 +42,6 @@ struct NetworkUpdateDelegate : public tsd::scene::BaseUpdateDelegate void signalActiveLayersChanged() override; void signalObjectFilteringChanged() override; void signalInvalidateCachedObjects() override; - void signalUpdateBatchBegin() override; - void signalUpdateBatchEnd() override; private: bool isReady(const char *fcn) const; diff --git a/tsd/apps/tutorial/tsdTutorialLoadUSD.cpp b/tsd/apps/tutorial/tsdTutorialLoadUSD.cpp index 8969e0598..93d5ce034 100644 --- a/tsd/apps/tutorial/tsdTutorialLoadUSD.cpp +++ b/tsd/apps/tutorial/tsdTutorialLoadUSD.cpp @@ -76,7 +76,8 @@ int main(int argc, char *argv[]) // Populate spheres // tsd::animation::AnimationManager anim(&scene); - tsd::io::import_USD(scene, anim, g_filename.c_str()); + tsd::io::widenAnimationClock( + anim, tsd::io::import_USD(scene, anim, g_filename.c_str())); // Setup ANARI device // diff --git a/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md index a3042ae07..ce053939d 100644 --- a/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md +++ b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md @@ -10,22 +10,32 @@ weakly, so the last holder to let go closes the Stage. A fully static import therefore retains nothing — the last reference drops when `import_USD` returns — while an animated import pins the file for as long as its bindings live. -This extends ADR 0015's "Hydra owns resolution" from import time to every time. -It also amends ADR 0018, which described per-scene stage retention and claimed -a retained scene index the code did not in fact keep: `UsdGeometryFileBinding` -used to re-open its own raw `UsdStage` and read `UsdGeomPointBased` schemas -directly, so a scrub resolved through a different path than the import had -converted from, and a 1.6 GB stage was opened twice. Keying by path rather than -by Scene or by AnimationManager keeps USD knowledge out of `tsd_scene` and -`tsd_animation`, which sit below `tsd_io`, and makes deserialization trivially -correct: a binding stores only the file path and rejoins by path, with no -ordering dependency on anything else in the archive. +This extends ADR 0015's "Hydra owns resolution" from import time to every time, +for the bindings that resolve through Hydra. It also amends ADR 0018, which +described per-scene stage retention and claimed a retained scene index the code +did not in fact keep: `UsdGeometryFileBinding` used to re-open its own raw +`UsdStage`, so a 1.6 GB stage was opened twice. + +Be precise about what changed for which binding. The instancer binding reads the +resolved scene, so it re-runs the import's own `readInstancerPlacements()` +against the Session's chain and cannot drift from what the import converted. The +geometry binding still reads `UsdGeomPointBased` off the Stage directly, as ADR +0018 described — the Session did not change where it reads from, only that it no +longer opens the file to do it, and it leaves the Session's Time Code alone +because it never touches the resolved scene. + +Keying by path rather than by Scene or by AnimationManager keeps USD knowledge +out of `tsd_scene` and `tsd_animation`, which sit below `tsd_io`, and makes +deserialization trivially correct: a binding stores only the file path and +rejoins by path, with no ordering dependency on anything else in the archive. Two things follow from the Session owning the chain. Building the filter chain -moved into the Session, so import and scrub provably resolve identically; and -dialect pruning moved *out* of the chain into traversal, so the Session carries -no trace of any one import's options and two imports of one file with different -options can still share it. +moved into the Session, so an import and a scrub that both read the resolved +scene provably resolve identically; and dialect pruning moved *out* of the chain +into the import, so the Session carries no trace of any one import's options and +two imports of one file with different options can still share it. Pruning was a +filtering scene index; it is now a question the Import Context answers +(`ImportContext::isClaimed`), which every walk over the resolved scene asks. The Session also owns the mapping from TSD's normalized animation time onto the Stage's own Time Code, and USD evaluates continuously there rather than snapping @@ -35,12 +45,20 @@ aliasing bug: `example_granular_collision_sdf.usd` authors samples at time codes grid mapped to `400*i/399` and never landed on an authored code. Because the mapping is a Stage-level fact living once in the Session, the `sampleTimes` and `timeBase` caches every binding used to carry are deleted rather than -maintained; they were derived data, and re-deriving them from the Stage is +maintained: they were derived data, and re-deriving them from the Stage is strictly more correct than trusting a possibly-stale copy. Archives written before this omit them on write and ignore them on read, and silently gain the continuous-time behavior. Versioning with a legacy snapping path was rejected: it would keep behavior we decided was wrong alive forever. +One case has no Stage-level answer to re-derive. Nothing obliges a Stage to +author a `startTimeCode`/`endTimeCode` range, and USD reports zero for both ends +when it has not — which would map every animation time onto one Time Code and +freeze the scene. Animated prims therefore tell the Session what range their own +samples cover as they are bound, and the Session widens a fallback range with +them; a Stage that authored a range of its own remains the authority and is +never widened. + The costs are accepted knowingly. An animated import holds its file open — 1.6 GB for `example_apic_fluid.usd` — for the bindings' lifetime. Interpolating between samples of a particle simulation, where index *i* is a different diff --git a/tsd/src/tsd/animation/AnimationManager.cpp b/tsd/src/tsd/animation/AnimationManager.cpp index 3c20a87a2..b64ecdfe5 100644 --- a/tsd/src/tsd/animation/AnimationManager.cpp +++ b/tsd/src/tsd/animation/AnimationManager.cpp @@ -63,6 +63,20 @@ void AnimationManager::removeAllAnimations() m_animations.clear(); } +bool AnimationManager::widenClock(int frames, float fps) +{ + const bool framesWon = frames > m_totalFrames; + const bool fpsWon = fps > m_animationFPS; + + if (framesWon) + setAnimationTotalFrames(frames); + if (fpsWon) + setAnimationFPS(fps); + + return !(frames > 0 && frames < m_totalFrames) + && !(fps > 0.f && fps < m_animationFPS); +} + void AnimationManager::setAnimationTime(float time) { setAnimationTimeInternal(time, true); diff --git a/tsd/src/tsd/animation/AnimationManager.hpp b/tsd/src/tsd/animation/AnimationManager.hpp index eb6acf85b..bbc56fb95 100644 --- a/tsd/src/tsd/animation/AnimationManager.hpp +++ b/tsd/src/tsd/animation/AnimationManager.hpp @@ -61,6 +61,14 @@ struct AnimationManager void setAnimationFrame(int frame); void incrementAnimationFrame(); + // Widen the clock to hold content that needs `frames` at `fps`, keeping + // whatever is already longer or faster. Every Animation shares this one + // clock, so an import must not clobber it: content needing more frames than + // the clock has would be under-sampled, while content needing fewer still + // plays correctly on a longer clock. Returns false when the request lost, + // so the caller can say so. + bool widenClock(int frames, float fps); + // Playing state — call tick(elapsedSeconds) once per UI frame void tick(float elapsedSeconds); void play(); diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index ab9df952d..cd0af79d3 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -34,6 +34,7 @@ PRIVATE importers/detail/pbrt/PbrtLexer.cpp importers/detail/pbrt/PbrtParser.cpp importers/detail/pbrt/PbrtScene.cpp + animation/UsdFileBinding.cpp animation/UsdGeometryFileBinding.cpp animation/UsdInstancerFileBinding.cpp importers/import_AGX.cpp diff --git a/tsd/src/tsd/io/UsdImport.cpp b/tsd/src/tsd/io/UsdImport.cpp index f49861b20..71c9bc18a 100644 --- a/tsd/src/tsd/io/UsdImport.cpp +++ b/tsd/src/tsd/io/UsdImport.cpp @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 #include "tsd/io/UsdImport.hpp" +// tsd_animation +#include "tsd/animation/AnimationManager.hpp" +// tsd_core +#include "tsd/core/Logging.hpp" // std #include #include @@ -147,4 +151,24 @@ std::string UsdImportReport::summary() const return retval; } +void widenAnimationClock( + tsd::animation::AnimationManager &animMgr, const UsdImportReport &report) +{ + if (report.sampleCount < 2) + return; + + const int frames = int(report.sampleCount); + const float fps = report.timeCodesPerSecond; + if (animMgr.widenClock(frames, fps)) + return; + + core::logStatus( + "[import_USD] stage wants %i frames at %g fps but the animation clock is" + " already %i frames at %g fps; keeping the longer and faster of the two", + frames, + double(fps), + animMgr.getAnimationTotalFrames(), + double(animMgr.getAnimationFPS())); +} + } // namespace tsd::io diff --git a/tsd/src/tsd/io/UsdImport.hpp b/tsd/src/tsd/io/UsdImport.hpp index 6da52b192..9f4b30b7d 100644 --- a/tsd/src/tsd/io/UsdImport.hpp +++ b/tsd/src/tsd/io/UsdImport.hpp @@ -8,6 +8,10 @@ #include #include +namespace tsd::animation { +struct AnimationManager; +} // namespace tsd::animation + namespace tsd::io { /////////////////////////////////////////////////////////////////////////////// @@ -157,4 +161,13 @@ struct UsdImportReport std::string summary() const; }; +// Fold what a USD Import reported about its Stage's clock into the one +// playback clock every Animation shares, widening it and never shrinking it. +// This is deliberately not done by the Import itself: an Import reports the +// Stage's frame range and rate, and the application decides what to do with +// them -- SciVis Studio, for one, keeps its shot authoritative. A conflict +// between two Stages is logged and the larger value wins. +void widenAnimationClock( + tsd::animation::AnimationManager &animMgr, const UsdImportReport &report); + } // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdFileBinding.cpp b/tsd/src/tsd/io/animation/UsdFileBinding.cpp new file mode 100644 index 000000000..1420c73bc --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdFileBinding.cpp @@ -0,0 +1,92 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/animation/UsdFileBinding.hpp" +// tsd_core +#include "tsd/core/DataTree.hpp" +#include "tsd/core/Logging.hpp" +#if TSD_USE_USD +// tsd_io +#include "tsd/io/usd/UsdStageSession.h" +#endif + +namespace tsd::io { + +using namespace tsd::core; + +UsdFileBinding::UsdFileBinding(scene::Scene *scene, + std::shared_ptr session, + std::string stageFile, + std::string primPath) + : FileBinding(scene), + m_session(std::move(session)), + m_stageFile(std::move(stageFile)), + m_primPath(std::move(primPath)) +{} + +UsdFileBinding::~UsdFileBinding() = default; + +const std::string &UsdFileBinding::stageFile() const +{ + return m_stageFile; +} + +const std::string &UsdFileBinding::primPath() const +{ + return m_primPath; +} + +usd::UsdStageSession *UsdFileBinding::session() const +{ + return m_session.get(); +} + +void UsdFileBinding::writePathsToDataNode(core::DataNode &node) const +{ + node["stageFile"] = m_stageFile; + node["primPath"] = m_primPath; +} + +#if TSD_USE_USD + +bool UsdFileBinding::ensureSession() +{ + if (m_session) + return true; + if (m_sessionFailed) + return false; + + m_session = usd::acquireUsdSession(m_stageFile); + if (!m_session) { + m_sessionFailed = true; + logWarning( + "[%s] failed to open stage '%s'", logTag(), m_stageFile.c_str()); + } + return bool(m_session); +} + +void UsdFileBinding::noteAuthoredSampleTimes(const std::vector ×) +{ + if (m_session) + m_session->noteAuthoredSampleTimes(times); +} + +#else + +bool UsdFileBinding::ensureSession() +{ + if (m_sessionFailed) + return false; + m_sessionFailed = true; + logError("[%s] USD not enabled in TSD build.", logTag()); + return false; +} + +void UsdFileBinding::noteAuthoredSampleTimes(const std::vector &) +{ + // No Session to tell. +} + +#endif + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdFileBinding.hpp b/tsd/src/tsd/io/animation/UsdFileBinding.hpp new file mode 100644 index 000000000..816f0cb25 --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdFileBinding.hpp @@ -0,0 +1,70 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/animation/FileBinding.hpp" +// std +#include +#include +#include + +namespace tsd::io { + +namespace usd { +struct UsdStageSession; +} // namespace usd + +/* + * What every USD animation binding needs regardless of what it drives: the + * Stage Session it resolves through, and the file and prim paths that identify + * what it resolves. Bindings created by an Import are handed the Session the + * Import used; bindings reconstructed from an Archive carry only the paths and + * join the Session for that file on the first update. + * + * Example: + * struct MyBinding : UsdFileBinding { + * void update(float t) override { + * if (!ensureSession()) return; + * ... + * } + * }; + */ +struct UsdFileBinding : public tsd::animation::FileBinding +{ + UsdFileBinding(scene::Scene *scene, + std::shared_ptr session, + std::string stageFile, + std::string primPath); + ~UsdFileBinding() override; + + const std::string &stageFile() const; + const std::string &primPath() const; + + protected: + // Join the Session for this binding's file, or report why not. Retried at + // most once: a file that failed to open is not reopened on every scrub. + bool ensureSession(); + + usd::UsdStageSession *session() const; + + // Write the file and prim paths every USD binding serializes. + void writePathsToDataNode(tsd::core::DataNode &node) const; + + // Tell the Session about times authored on this binding's own prim, so a + // Stage that carries samples without authoring a time-code range still maps + // animation time onto a range that moves. + void noteAuthoredSampleTimes(const std::vector ×); + + // Named in the warning ensureSession() emits, so a failure says which kind + // of binding could not resolve. + virtual const char *logTag() const = 0; + + private: + std::shared_ptr m_session; + std::string m_stageFile; + std::string m_primPath; + bool m_sessionFailed{false}; +}; + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp index af85b326d..54c36ed08 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -6,6 +6,8 @@ #include "tsd/core/DataTree.hpp" #include "tsd/core/Logging.hpp" #include "tsd/scene/objects/Array.hpp" +// std +#include #if TSD_USE_USD // tsd_io #include "tsd/io/usd/UsdStageSession.h" @@ -23,11 +25,9 @@ UsdGeometryFileBinding::UsdGeometryFileBinding(scene::Scene *scene, std::shared_ptr session, std::string stageFile, std::string primPath) - : FileBinding(scene), - m_geometry(geometry), - m_session(std::move(session)), - m_stageFile(std::move(stageFile)), - m_primPath(std::move(primPath)) + : UsdFileBinding( + scene, std::move(session), std::move(stageFile), std::move(primPath)), + m_geometry(geometry) {} UsdGeometryFileBinding::~UsdGeometryFileBinding() = default; @@ -37,6 +37,11 @@ std::string UsdGeometryFileBinding::kind() const return "usdGeometry"; } +const char *UsdGeometryFileBinding::logTag() const +{ + return "UsdGeometryFileBinding"; +} + void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const { // The Stage's own clock is enough to re-derive everything a scrub needs, so @@ -44,8 +49,7 @@ void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const // carries one is simply not read. auto *geometry = m_geometry.get(); node["targetIndex"] = geometry ? geometry->index() : tsd::core::INVALID_INDEX; - node["stageFile"] = m_stageFile; - node["primPath"] = m_primPath; + writePathsToDataNode(node); } void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) @@ -127,22 +131,6 @@ void writeVertexArray(scene::Scene &scene, } // namespace -bool UsdGeometryFileBinding::ensureSession() -{ - if (m_session) - return true; - if (m_sessionFailed) - return false; - - m_session = usd::acquireUsdSession(m_stageFile); - if (!m_session) { - m_sessionFailed = true; - logWarning("[UsdGeometryFileBinding] failed to open stage '%s'", - m_stageFile.c_str()); - } - return bool(m_session); -} - void UsdGeometryFileBinding::update(float t) { if (!scene() || !ensureSession()) @@ -152,14 +140,25 @@ void UsdGeometryFileBinding::update(float t) if (!geometry) return; - m_session->setTime(m_session->timeCodeAt(t)); - - auto prim = m_session->stage()->GetPrimAtPath(pxr::SdfPath(m_primPath)); + auto prim = session()->stage()->GetPrimAtPath(pxr::SdfPath(primPath())); pxr::UsdGeomPointBased pointBased(prim); if (!pointBased) return; - const auto time = m_session->currentTime(); + // A Stage that carries samples but authored no time-code range has no range + // to map onto until its own prims say what they cover. + if (!m_sampleTimesNoted && !session()->hasAuthoredTimeRange()) { + m_sampleTimesNoted = true; + std::vector times; + pointBased.GetPointsAttr().GetTimeSamples(×); + noteAuthoredSampleTimes(times); + } + + // Points are read from the Stage's own schema rather than through the + // Session's resolved chain, as they always have been (ADR 0018); the Session + // is shared so that this does not mean a second open of the file. Its + // resolved scene is not read here, so its Time Code is left alone. + const auto time = session()->timeCodeAt(t); pxr::VtVec3fArray points; if (pointBased.GetPointsAttr().Get(&points, time) && !points.empty()) { @@ -171,7 +170,7 @@ void UsdGeometryFileBinding::update(float t) m_countChangeReported = true; logWarning("[UsdGeometryFileBinding] '%s': vertex count and topology" " both change over time; the frame is left as imported", - m_primPath.c_str()); + primPath().c_str()); } return; } @@ -185,14 +184,9 @@ void UsdGeometryFileBinding::update(float t) #else -bool UsdGeometryFileBinding::ensureSession() -{ - return false; -} - void UsdGeometryFileBinding::update(float) { - logError("[UsdGeometryFileBinding] USD not enabled in TSD build."); + ensureSession(); // reports that this build has no USD, once } #endif diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp index 3dad4c73f..5ab7af6a6 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp @@ -4,7 +4,7 @@ #pragma once #include "tsd/animation/Animation.hpp" -#include "tsd/animation/FileBinding.hpp" +#include "tsd/io/animation/UsdFileBinding.hpp" #include "tsd/scene/ObjectUsePtr.hpp" #include "tsd/scene/Scene.hpp" #include "tsd/scene/objects/Geometry.hpp" @@ -14,10 +14,6 @@ namespace tsd::io { -namespace usd { -struct UsdStageSession; -} // namespace usd - /* * Binding that re-pulls a deforming mesh's vertex arrays from a Stage Session * at the current animation time, so that a long animation of a dense mesh does @@ -33,7 +29,7 @@ struct UsdStageSession; * auto &b = anim.emplaceFileBinding( * &scene, geometry.data(), session, stageFile, "/World/Character"); */ -struct UsdGeometryFileBinding : public tsd::animation::FileBinding +struct UsdGeometryFileBinding : public UsdFileBinding { UsdGeometryFileBinding(scene::Scene *scene, scene::Geometry *geometry, @@ -59,16 +55,10 @@ struct UsdGeometryFileBinding : public tsd::animation::FileBinding private: void addCallbackToAnimation(tsd::animation::Animation &anim) override; - - // Joined on first use rather than at construction, so a binding read back - // from an Archive does not open the Stage until something scrubs. - bool ensureSession(); + const char *logTag() const override; scene::ObjectUsePtr m_geometry; - std::shared_ptr m_session; - std::string m_stageFile; - std::string m_primPath; - bool m_sessionFailed{false}; + bool m_sampleTimesNoted{false}; bool m_countChangeReported{false}; }; diff --git a/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp index 37fe9238b..ac0c0608b 100644 --- a/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp +++ b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp @@ -22,12 +22,10 @@ UsdInstancerFileBinding::UsdInstancerFileBinding(scene::Scene *scene, std::string stageFile, std::string primPath, size_t prototypeIndex) - : FileBinding(scene), - m_session(std::move(session)), + : UsdFileBinding( + scene, std::move(session), std::move(stageFile), std::move(primPath)), m_arrayNode(arrayNode), m_transforms(transforms), - m_stageFile(std::move(stageFile)), - m_primPath(std::move(primPath)), m_prototypeIndex(prototypeIndex) {} @@ -38,6 +36,11 @@ std::string UsdInstancerFileBinding::kind() const return "usdInstancer"; } +const char *UsdInstancerFileBinding::logTag() const +{ + return "UsdInstancerFileBinding"; +} + void UsdInstancerFileBinding::toDataNode(core::DataNode &node) const { if (m_arrayNode && scene()) { @@ -45,8 +48,7 @@ void UsdInstancerFileBinding::toDataNode(core::DataNode &node) const node["layerName"] = scene()->getLayerName(layer).str(); node["nodeIndex"] = m_arrayNode->index(); } - node["stageFile"] = m_stageFile; - node["primPath"] = m_primPath; + writePathsToDataNode(node); node["prototypeIndex"] = uint64_t(m_prototypeIndex); } @@ -107,30 +109,24 @@ UsdInstancerFileBinding *UsdInstancerFileBinding::addToAnimation( #if TSD_USE_USD -bool UsdInstancerFileBinding::ensureSession() -{ - if (m_session) - return true; - if (m_sessionFailed) - return false; - - m_session = usd::acquireUsdSession(m_stageFile); - if (!m_session) { - m_sessionFailed = true; - logWarning("[UsdInstancerFileBinding] failed to open stage '%s'", - m_stageFile.c_str()); - } - return bool(m_session); -} - void UsdInstancerFileBinding::update(float t) { if (!scene() || !m_arrayNode || !ensureSession()) return; - m_session->setTime(m_session->timeCodeAt(t)); + const pxr::SdfPath path(primPath()); - auto prim = m_session->sceneIndex()->GetPrim(pxr::SdfPath(m_primPath)); + // A Stage that carries samples but authored no time-code range has no range + // to map onto until its own prims say what they cover. + if (!m_sampleTimesNoted && !session()->hasAuthoredTimeRange()) { + m_sampleTimesNoted = true; + noteAuthoredSampleTimes(usd::pointInstancerSampleTimes( + session()->stage()->GetPrimAtPath(path))); + } + + session()->setTime(session()->timeCodeAt(t)); + + auto prim = session()->sceneIndex()->GetPrim(path); const auto placements = usd::readInstancerPlacements(prim).forPrototype(m_prototypeIndex); if (placements.empty()) @@ -159,12 +155,7 @@ void UsdInstancerFileBinding::update(float t) void UsdInstancerFileBinding::update(float) { - logError("[UsdInstancerFileBinding] USD not enabled in TSD build."); -} - -bool UsdInstancerFileBinding::ensureSession() -{ - return false; + ensureSession(); // reports that this build has no USD, once } #endif diff --git a/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp index 777e7c71b..19aa50be8 100644 --- a/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp +++ b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp @@ -4,7 +4,7 @@ #pragma once #include "tsd/animation/Animation.hpp" -#include "tsd/animation/FileBinding.hpp" +#include "tsd/io/animation/UsdFileBinding.hpp" #include "tsd/scene/ObjectUsePtr.hpp" #include "tsd/scene/Scene.hpp" #include "tsd/scene/objects/Array.hpp" @@ -14,10 +14,6 @@ namespace tsd::io { -namespace usd { -struct UsdStageSession; -} // namespace usd - /* * Binding that re-fills the transform Array of one point-instancer Prototype * from a Stage Session at the current animation time. It re-reads the @@ -34,7 +30,7 @@ struct UsdStageSession; * anim.emplaceFileBinding( * &scene, session, arrayNode, transforms, file, "/root/points", 0); */ -struct UsdInstancerFileBinding : public tsd::animation::FileBinding +struct UsdInstancerFileBinding : public UsdFileBinding { UsdInstancerFileBinding(scene::Scene *scene, std::shared_ptr session, @@ -62,18 +58,12 @@ struct UsdInstancerFileBinding : public tsd::animation::FileBinding private: void addCallbackToAnimation(tsd::animation::Animation &anim) override; + const char *logTag() const override; - // Joined on first use rather than at construction, so a binding read back - // from an Archive does not open the Stage until something scrubs. - bool ensureSession(); - - std::shared_ptr m_session; scene::LayerNodeRef m_arrayNode; scene::ObjectUsePtr m_transforms; - std::string m_stageFile; - std::string m_primPath; size_t m_prototypeIndex{0}; - bool m_sessionFailed{false}; + bool m_sampleTimesNoted{false}; }; } // namespace tsd::io diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp index ab009f8f7..ea041e566 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -5,9 +5,9 @@ #include "tsd/animation/AnimationManager.hpp" #include "tsd/io/animation/UsdGeometryFileBinding.hpp" #include "tsd/io/animation/UsdInstancerFileBinding.hpp" +#include "tsd/io/importers/detail/usd/UsdInstancing.h" // usd #include -#include #include // std #include @@ -123,6 +123,11 @@ void addTransformAnimation( if (authoredTimes.size() < 2) return; + // A Stage that authored no time-code range of its own still needs one, and + // its animated prims are the only thing that can say what it should be. + if (ctx.session) + ctx.session->noteAuthoredSampleTimes(authoredTimes); + TransformSampler sampler{xformable}; std::vector times; @@ -147,40 +152,25 @@ void addTransformAnimation( node, frames, normalizeSampleTimes(ctx.stage, times)); - ctx.reportSampleCount(authoredTimes.size()); + ctx.reportAnimatedPrim(authoredTimes.size()); } size_t pointInstancerSampleCount( ImportContext &ctx, const pxr::SdfPath &primPath) { - pxr::UsdGeomPointInstancer instancer(ctx.stage->GetPrimAtPath(primPath)); - if (!instancer) - return 0; - - // Every attribute that can move a placement, including the two velocity - // attributes Hydra folds into the instance transforms it computes. - const pxr::UsdAttribute attributes[] = {instancer.GetPositionsAttr(), - instancer.GetOrientationsAttr(), - instancer.GetScalesAttr(), - instancer.GetVelocitiesAttr(), - instancer.GetAngularVelocitiesAttr(), - instancer.GetProtoIndicesAttr(), - instancer.GetInvisibleIdsAttr()}; - - size_t retval = 0; - for (const auto &attribute : attributes) { - if (attribute) - retval = std::max(retval, size_t(attribute.GetNumTimeSamples())); - } - return retval; + auto times = pointInstancerSampleTimes(ctx.stage->GetPrimAtPath(primPath)); + // A Stage that authored no time-code range of its own still needs one, and + // its animated prims are the only thing that can say what it should be. + if (ctx.session) + ctx.session->noteAuthoredSampleTimes(times); + return times.size(); } void addInstancerAnimation(ImportContext &ctx, const pxr::SdfPath &primPath, size_t prototypeIndex, LayerNodeRef arrayNode, - ArrayRef transforms, - size_t sampleCount) + ArrayRef transforms) { ctx.animation().emplaceFileBinding(&ctx.scene, ctx.session, @@ -189,7 +179,6 @@ void addInstancerAnimation(ImportContext &ctx, ctx.filePath, primPath.GetString(), prototypeIndex); - ctx.reportSampleCount(sampleCount); } void addDeformingGeometryAnimation( @@ -208,6 +197,9 @@ void addDeformingGeometryAnimation( if (sampleTimes.size() < 2) return; + if (ctx.session) + ctx.session->noteAuthoredSampleTimes(sampleTimes); + // One eager frame is already in the Scene; the rest is pulled from the // shared Stage Session on demand (ADR 0018). ctx.animation().emplaceFileBinding(&ctx.scene, @@ -215,7 +207,7 @@ void addDeformingGeometryAnimation( ctx.session, ctx.filePath, primPath.GetString()); - ctx.reportSampleCount(sampleTimes.size()); + ctx.reportAnimatedPrim(sampleTimes.size()); } } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h index 48c7044c1..69a757486 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h @@ -37,8 +37,7 @@ void addInstancerAnimation(ImportContext &ctx, const pxr::SdfPath &primPath, size_t prototypeIndex, LayerNodeRef arrayNode, - ArrayRef transforms, - size_t sampleCount); + ArrayRef transforms); // Bind a geometry's vertex arrays to the retained Stage so that a long // animation of a dense mesh is pulled on demand instead of held in memory diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp index 28df8b64e..be6310940 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp @@ -3,6 +3,7 @@ #include "tsd/io/importers/detail/usd/UsdImportContext.h" #include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/importers/detail/usd/UsdDialect.h" // usd #include // std @@ -18,14 +19,19 @@ tsd::animation::Animation &ImportContext::animation() return *importAnimation; } -void ImportContext::reportSampleCount(size_t count) +void ImportContext::reportAnimatedPrim(size_t sampleCount) { report.animatedPrims++; - report.sampleCount = std::max(report.sampleCount, count); + report.sampleCount = std::max(report.sampleCount, sampleCount); if (session) report.timeCodesPerSecond = float(session->timeCodesPerSecond()); } +bool ImportContext::isClaimed(const pxr::SdfPath &path) const +{ + return claimedPrims && claimedPrims->claims(path); +} + bool attributeValueVaries(const pxr::UsdAttribute &attribute) { if (!attribute) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index 8d473ec6d..3324ffbee 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -28,6 +28,8 @@ namespace tsd::io::usd { using namespace tsd::scene; +struct ClaimedPrims; + /* * Everything one USD Stage import needs to carry between converters: the * target Scene, the settings driving the import, the report being accumulated, @@ -62,8 +64,16 @@ struct ImportContext // Animation is driven by the same AnimationManager clock. tsd::animation::Animation &animation(); - // Fold a bound attribute's authored sample count into the Import Report. - void reportSampleCount(size_t count); + // Record one prim as animated, with the number of time samples the binding + // was built from, so the Import Report can name the Stage's frame range. + void reportAnimatedPrim(size_t sampleCount); + + // Prims the TSD dialect claimed, which every path that walks the resolved + // scene must skip: they reach the Scene through the dialect's own importers. + // Set by the dialect pre-pass; null until then. Kept here rather than in the + // resolution chain so the Stage Session stays free of one Import's handling. + const ClaimedPrims *claimedPrims{nullptr}; + bool isClaimed(const pxr::SdfPath &path) const; // Set by animation(); this stays an aggregate, so it cannot be private. tsd::animation::Animation *importAnimation{nullptr}; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index d68182ded..2e37492aa 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include // std #include @@ -90,6 +91,8 @@ std::shared_ptr convertPrototype(ImportContext &ctx, const auto inverseRoot = tsd::math::inverse(rootXform); for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, prototypeRoot)) { + if (ctx.isClaimed(path)) + continue; auto prim = sceneIndex->GetPrim(path); if (!isGeometryPrimType(prim.primType)) continue; @@ -256,6 +259,12 @@ InstancerPlacements readInstancerPlacements(const pxr::HdSceneIndexPrim &prim) element ? element->GetTypedValue(0) : pxr::VtIntArray()); } + // Native instancing attaches its Prototypes at each USD Instance's own node, + // so it never asks forPrototype() for transforms; reading them would be the + // most expensive part of this call and all of it wasted. + if (!retval.instanceLocations.empty()) + return retval; + size_t instanceCount = 0; for (const auto &element : retval.instanceIndices) { for (int i : element) @@ -266,6 +275,36 @@ InstancerPlacements readInstancerPlacements(const pxr::HdSceneIndexPrim &prim) return retval; } +std::vector pointInstancerSampleTimes(const pxr::UsdPrim &prim) +{ + pxr::UsdGeomPointInstancer instancer(prim); + if (!instancer) + return {}; + + // Every attribute that can move a placement, including the two velocity + // attributes Hydra folds into the instance transforms it computes. + const pxr::UsdAttribute attributes[] = {instancer.GetPositionsAttr(), + instancer.GetOrientationsAttr(), + instancer.GetScalesAttr(), + instancer.GetVelocitiesAttr(), + instancer.GetAngularVelocitiesAttr(), + instancer.GetProtoIndicesAttr(), + instancer.GetInvisibleIdsAttr()}; + + std::vector retval; + for (const auto &attribute : attributes) { + if (!attribute) + continue; + std::vector times; + attribute.GetTimeSamples(×); + retval.insert(retval.end(), times.begin(), times.end()); + } + + std::sort(retval.begin(), retval.end()); + retval.erase(std::unique(retval.begin(), retval.end()), retval.end()); + return retval; +} + void convertInstancer(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, @@ -283,6 +322,7 @@ void convertInstancer(ImportContext &ctx, return; const auto animatedSamples = pointInstancerSampleCount(ctx, primPath); + bool boundAnyPrototype = false; for (size_t protoIndex = 0; protoIndex < placementsOfPrim.prototypes.size(); ++protoIndex) { @@ -326,10 +366,14 @@ void convertInstancer(ImportContext &ctx, // binding re-fills; handing it over here is what keeps a scrub from having // to find it again by name. if (animatedSamples > 1) { - addInstancerAnimation( - ctx, primPath, protoIndex, arrayNode, transformArray, animatedSamples); + addInstancerAnimation(ctx, primPath, protoIndex, arrayNode, transformArray); + boundAnyPrototype = true; } } + + // One animated prim, however many Prototypes it scatters. + if (boundAnyPrototype) + ctx.reportAnimatedPrim(animatedSamples); } void attachNativeInstances(ImportContext &ctx, @@ -343,6 +387,8 @@ void attachNativeInstances(ImportContext &ctx, return; for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { + if (ctx.isClaimed(path)) + continue; auto prim = sceneIndex->GetPrim(path); if (prim.primType != pxr::HdPrimTypeTokens->instancer) continue; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h index 68d2011b2..acefc5379 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -7,6 +7,7 @@ // usd #include #include +#include // std #include #include @@ -63,9 +64,17 @@ struct InstancerPlacements // Read one resolved instancer prim. Callers that only need the placements of a // single Prototype still pay one read of the whole instancer, which is why -// this is separate from forPrototype(). +// this is separate from forPrototype(). Native instancing places its +// Prototypes at their own nodes rather than through a transform array, so its +// per-instance transforms -- the expensive part of this read at half a million +// instances -- are not read at all. InstancerPlacements readInstancerPlacements(const pxr::HdSceneIndexPrim &prim); +// Every time code authored on any attribute that moves a point instancer's +// placements, in order and without duplicates. Empty when the instancer does +// not animate. Reads the raw Stage prim, not the resolved one. +std::vector pointInstancerSampleTimes(const pxr::UsdPrim &prim); + /* * State shared between the mirrored-hierarchy traversal and the instancing * pass: where each USD prim landed in the Layer, and every Prototype converted diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 2fa216dd8..424abe5bf 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -73,7 +73,6 @@ struct Traversal ImportContext &ctx; pxr::HdSceneIndexBaseRefPtr sceneIndex; InstancerRegistry &instancers; - const ClaimedPrims *claimed{nullptr}; void visit(const pxr::SdfPath &primPath, LayerNodeRef parent, @@ -118,7 +117,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, { // Claimed Prims reach the Scene through the dialect's own importers; the // generic path must not also convert a carrier prim into geometry. - if (claimed && claimed->claims(primPath)) + if (ctx.isClaimed(primPath)) return; auto prim = sceneIndex->GetPrim(primPath); @@ -308,6 +307,7 @@ UsdImportReport import_USD(Scene &scene, // traversal skips so the generic path never converts a carrier prim into // meaningless geometry. auto claimed = claimDialectPrims(ctx); + ctx.claimedPrims = claimed.get(); auto sceneIndex = session->sceneIndex(); @@ -327,7 +327,7 @@ UsdImportReport import_USD(Scene &scene, : pxr::SdfPath(options.primPath); InstancerRegistry instancers; - Traversal traversal{ctx, sceneIndex, instancers, claimed.get()}; + Traversal traversal{ctx, sceneIndex, instancers}; scene.beginLayerEditBatch(); if (scopeRoot == pxr::SdfPath::AbsoluteRootPath()) { diff --git a/tsd/src/tsd/io/importers/import_file.cpp b/tsd/src/tsd/io/importers/import_file.cpp index 56a503c15..23fde4451 100644 --- a/tsd/src/tsd/io/importers/import_file.cpp +++ b/tsd/src/tsd/io/importers/import_file.cpp @@ -30,43 +30,6 @@ void ensureDefaultTransferFunction(tsd::core::TransferFunction &tf) tf.range = {}; } -// A USD Import reports the Stage's frame range and rate rather than applying -// them, because every Animation shares one playback clock and an Import must -// not clobber what is already on it. Widening is the one safe move: content -// that needs more frames than the clock has would otherwise be under-sampled, -// while content that needs fewer still plays correctly on a longer clock. When -// two Stages disagree, the conflict is logged and the larger value wins. -void widenAnimationClock( - tsd::animation::AnimationManager &animMgr, const UsdImportReport &report) -{ - if (report.sampleCount < 2) - return; - - const int frames = animMgr.getAnimationTotalFrames(); - if (int(report.sampleCount) > frames) - animMgr.setAnimationTotalFrames(int(report.sampleCount)); - else if (int(report.sampleCount) < frames) { - tsd::core::logStatus( - "...stage has %zu samples but the animation clock is already %i frames" - " long; keeping %i", - report.sampleCount, - frames, - frames); - } - - const float fps = animMgr.getAnimationFPS(); - if (report.timeCodesPerSecond > fps) - animMgr.setAnimationFPS(report.timeCodesPerSecond); - else if (report.timeCodesPerSecond > 0.f && report.timeCodesPerSecond < fps) { - tsd::core::logStatus( - "...stage runs at %g fps but the animation clock is already at %g fps;" - " keeping %g", - double(report.timeCodesPerSecond), - double(fps), - double(fps)); - } -} - } // namespace void import_file(Scene &scene, diff --git a/tsd/src/tsd/io/usd/UsdStageSession.cpp b/tsd/src/tsd/io/usd/UsdStageSession.cpp index 979994b0d..72981766a 100644 --- a/tsd/src/tsd/io/usd/UsdStageSession.cpp +++ b/tsd/src/tsd/io/usd/UsdStageSession.cpp @@ -12,6 +12,7 @@ #include #include // std +#include #include #include #include @@ -73,8 +74,10 @@ std::map> ®istry() } // namespace UsdStageSession::UsdStageSession( - std::string filePath, pxr::UsdStageRefPtr stage) - : m_filePath(std::move(filePath)), m_stage(std::move(stage)) + std::string key, std::string filePath, pxr::UsdStageRefPtr stage) + : m_key(std::move(key)), + m_filePath(std::move(filePath)), + m_stage(std::move(stage)) { pxr::UsdImagingCreateSceneIndicesInfo createInfo; createInfo.stage = m_stage; @@ -85,15 +88,29 @@ UsdStageSession::UsdStageSession( // Values authored only as time samples do not resolve at UsdTimeCode's // default, so a Stage with no authored range is still read at a real time. - m_startTimeCode = m_stage->GetStartTimeCode(); - m_endTimeCode = m_stage->GetEndTimeCode(); - if (!(m_endTimeCode > m_startTimeCode)) - m_endTimeCode = m_startTimeCode; + m_authoredTimeRange = m_stage->HasAuthoredTimeCodeRange(); + if (m_authoredTimeRange) { + m_startTimeCode = m_stage->GetStartTimeCode(); + m_endTimeCode = m_stage->GetEndTimeCode(); + if (!(m_endTimeCode > m_startTimeCode)) + m_endTimeCode = m_startTimeCode; + } setTime(pxr::UsdTimeCode(m_startTimeCode)); } -UsdStageSession::~UsdStageSession() = default; +UsdStageSession::~UsdStageSession() +{ + // Take the registry entry out with the Session, so a later acquire of this + // file opens a fresh Stage rather than finding a corpse. The entry may + // already have been replaced by a newer Session for the same file, which + // `expired()` distinguishes. + std::lock_guard guard(registryMutex()); + auto &sessions = registry(); + if (auto found = sessions.find(m_key); + found != sessions.end() && found->second.expired()) + sessions.erase(found); +} const std::string &UsdStageSession::filePath() const { @@ -125,6 +142,26 @@ double UsdStageSession::timeCodesPerSecond() const return m_stage->GetTimeCodesPerSecond(); } +bool UsdStageSession::hasAuthoredTimeRange() const +{ + return m_authoredTimeRange; +} + +void UsdStageSession::noteAuthoredSampleTimes(const std::vector ×) +{ + if (m_authoredTimeRange || times.empty()) + return; + + if (!m_sawSampleTimes) { + m_sawSampleTimes = true; + m_startTimeCode = times.front(); + m_endTimeCode = times.back(); + return; + } + m_startTimeCode = std::min(m_startTimeCode, times.front()); + m_endTimeCode = std::max(m_endTimeCode, times.back()); +} + pxr::UsdTimeCode UsdStageSession::timeCodeAt(float t) const { const double span = m_endTimeCode - m_startTimeCode; @@ -163,20 +200,7 @@ std::shared_ptr acquireUsdSession(const std::string &filePath) if (!stage) return {}; - // The deleter takes the registry entry out with the Session, so a later - // acquire of the same file opens a fresh Stage rather than finding a corpse. - std::shared_ptr session( - new UsdStageSession(filePath, stage), [key](UsdStageSession *s) { - { - std::lock_guard innerGuard(registryMutex()); - auto &inner = registry(); - if (auto found = inner.find(key); - found != inner.end() && found->second.expired()) - inner.erase(found); - } - delete s; - }); - + auto session = std::make_shared(key, filePath, stage); sessions[key] = session; return session; } diff --git a/tsd/src/tsd/io/usd/UsdStageSession.h b/tsd/src/tsd/io/usd/UsdStageSession.h index 4be55c426..8b4fde89b 100644 --- a/tsd/src/tsd/io/usd/UsdStageSession.h +++ b/tsd/src/tsd/io/usd/UsdStageSession.h @@ -3,6 +3,8 @@ #pragma once +// tsd_core +#include "tsd/core/TypeMacros.hpp" // usd #include #include @@ -10,6 +12,7 @@ // std #include #include +#include namespace tsd::io::usd { @@ -32,9 +35,14 @@ namespace tsd::io::usd { */ struct UsdStageSession { - // Use acquireUsdSession(); the constructor is public only so the registry - // can build one with make_shared. - UsdStageSession(std::string filePath, pxr::UsdStageRefPtr stage); + TSD_NOT_COPYABLE(UsdStageSession) + TSD_NOT_MOVEABLE(UsdStageSession) + + // Use acquireUsdSession(); this is public only so the registry can build a + // Session with make_shared. `key` is what the registry filed it under, which + // the Session needs in order to take itself back out again. + UsdStageSession( + std::string key, std::string filePath, pxr::UsdStageRefPtr stage); ~UsdStageSession(); const std::string &filePath() const; @@ -46,6 +54,16 @@ struct UsdStageSession double endTimeCode() const; double timeCodesPerSecond() const; + // Whether the Stage authored a time-code range of its own. When it did not, + // the range is whatever noteAuthoredSampleTimes() has been told about. + bool hasAuthoredTimeRange() const; + + // Widen the fallback range with times authored on one attribute. Does + // nothing when the Stage authored a range, which is the authority. Without + // this a Stage that has time samples but no `startTimeCode`/`endTimeCode` + // would map every animation time onto one Time Code and never move. + void noteAuthoredSampleTimes(const std::vector ×); + // Normalized animation time onto the Stage's clock. USD evaluates // continuously at the result, so no snapping to an authored sample happens. pxr::UsdTimeCode timeCodeAt(float t) const; @@ -54,6 +72,7 @@ struct UsdStageSession void setTime(pxr::UsdTimeCode time); private: + std::string m_key; std::string m_filePath; pxr::UsdStageRefPtr m_stage; pxr::UsdImagingStageSceneIndexRefPtr m_stageSceneIndex; @@ -61,6 +80,8 @@ struct UsdStageSession pxr::UsdTimeCode m_currentTime{pxr::UsdTimeCode::EarliestTime()}; double m_startTimeCode{0.0}; double m_endTimeCode{0.0}; + bool m_authoredTimeRange{false}; + bool m_sawSampleTimes{false}; }; // Open `filePath`, or join the Session already open on it. Sessions are keyed diff --git a/tsd/src/tsd/scene/UpdateDelegate.hpp b/tsd/src/tsd/scene/UpdateDelegate.hpp index de72b4cd3..0b962a59b 100644 --- a/tsd/src/tsd/scene/UpdateDelegate.hpp +++ b/tsd/src/tsd/scene/UpdateDelegate.hpp @@ -57,9 +57,10 @@ struct BaseUpdateDelegate // Bracket a run of mutations that should produce at most one downstream // rebuild. Nesting is counted, so an outer batch is not ended by an inner // one. Every signal above still arrives; only the work they trigger is - // coalesced. - virtual void signalUpdateBatchBegin() = 0; - virtual void signalUpdateBatchEnd() = 0; + // coalesced. Optional hooks (STYLEGUIDE section 13): a delegate that has + // nothing to coalesce need not say so. + virtual void signalUpdateBatchBegin() {} + virtual void signalUpdateBatchEnd() {} TSD_NOT_COPYABLE(BaseUpdateDelegate) TSD_DEFAULT_MOVEABLE(BaseUpdateDelegate) @@ -97,8 +98,6 @@ struct EmptyUpdateDelegate : public BaseUpdateDelegate void signalActiveLayersChanged() override {} void signalObjectFilteringChanged() override {} void signalInvalidateCachedObjects() override {} - void signalUpdateBatchBegin() override {} - void signalUpdateBatchEnd() override {} }; /* diff --git a/tsd/src/tsd/scripting/bindings/IOBindings.cpp b/tsd/src/tsd/scripting/bindings/IOBindings.cpp index 08702d073..76182c8f5 100644 --- a/tsd/src/tsd/scripting/bindings/IOBindings.cpp +++ b/tsd/src/tsd/scripting/bindings/IOBindings.cpp @@ -139,28 +139,41 @@ void registerIOBindings(sol::state &lua) TSD_LUA_IMPORT_WRAP(tsd::io::import_HDRI(s, anim, f.c_str(), loc), f); }); + // Every USD entry point folds the Stage's reported clock into the shared + // playback clock, the same way import_file does, so a scripted import + // scrubs at the Stage's own rate rather than the manager's default. + auto importUSD = [](scene::Scene &s, + animation::AnimationManager &anim, + const std::string &f, + scene::LayerNodeRef loc, + const tsd::io::UsdImportOptions &options) { + auto report = tsd::io::import_USD(s, anim, f.c_str(), loc, options); + tsd::io::widenAnimationClock(anim, report); + return report; + }; + io["importUSD"] = sol::overload( - [](scene::Scene &s, + [importUSD](scene::Scene &s, animation::AnimationManager &anim, const std::string &f) { - TSD_LUA_IMPORT_WRAP(tsd::io::import_USD(s, anim, f.c_str()), f); + TSD_LUA_IMPORT_WRAP(importUSD(s, anim, f, {}, {}), f); }, - [](scene::Scene &s, + [importUSD](scene::Scene &s, animation::AnimationManager &anim, const std::string &f, scene::LayerNodeRef loc) { - TSD_LUA_IMPORT_WRAP(tsd::io::import_USD(s, anim, f.c_str(), loc), f); + TSD_LUA_IMPORT_WRAP(importUSD(s, anim, f, loc, {}), f); }, // Settings arrive as a plain table mirroring the option names, so // scripted imports can be configured without a binding per field. - [](scene::Scene &s, + [importUSD](scene::Scene &s, animation::AnimationManager &anim, const std::string &f, scene::LayerNodeRef loc, sol::table settings) { - auto options = usdImportOptionsFromLuaTable(settings); TSD_LUA_IMPORT_WRAP( - tsd::io::import_USD(s, anim, f.c_str(), loc, options), f); + importUSD(s, anim, f, loc, usdImportOptionsFromLuaTable(settings)), + f); }); io["importPDB"] = sol::overload( diff --git a/tsd/tests/test_ArchiveCompatibility.cpp b/tsd/tests/test_ArchiveCompatibility.cpp index da39ebe45..04b18210a 100644 --- a/tsd/tests/test_ArchiveCompatibility.cpp +++ b/tsd/tests/test_ArchiveCompatibility.cpp @@ -12,6 +12,7 @@ #include "tsd/io/archives/detail/ArchivePlan.hpp" #include "tsd/io/serialization/serialization_internal.hpp" #include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Geometry.hpp" // std #include #include @@ -1109,6 +1110,82 @@ SCENARIO("tsd::io archive plans reject unsupported file bindings", REQUIRE(result.status == tsd::io::ArchivePlanStatus::UnsupportedFileBinding); } +SCENARIO("tsd::io accepts USD file bindings written before continuous time", + "[ArchiveCompatibility]") +{ + // The `sampleTimes`/`timeBase` pair was a cache of what the Stage already + // says, and was dropped when bindings started resolving at a Time Code + // (ADR 0021). Archives that still carry it must keep validating: the fields + // are ignored, not rejected, and no format version was bumped for them. + GIVEN("An Animation Archive whose usdGeometry binding carries the old cache") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animations(&scene); + auto geometry = scene.createObject( + tsd::scene::tokens::geometry::triangle); + + tsd::core::DataTree tree; + auto &archive = tree.root(); + archive["name"] = std::string("legacy"); + auto &binding = archive["fileBindings"].append(); + binding["kind"] = std::string("usdGeometry"); + binding["targetIndex"] = geometry->index(); + binding["stageFile"] = std::string("/data/blob.usd"); + binding["primPath"] = std::string("/Blob"); + binding["sampleTimes"].append() = 0.f; + binding["sampleTimes"].append() = 2.f; + binding["timeBase"].append() = 0.f; + binding["timeBase"].append() = 1.f; + + THEN("It still validates against the scene") + { + std::string message; + REQUIRE(tsd::io::validate_AnimationArchive(animations, archive, &message)); + } + + THEN("It deserializes, dropping the fields rather than failing on them") + { + auto *restored = + tsd::io::deserialize_AnimationArchive(animations, archive); + REQUIRE(restored != nullptr); + REQUIRE(restored->fileBindings().size() == 1); + REQUIRE(restored->fileBindings()[0]->kind() == "usdGeometry"); + + tsd::core::DataTree rewritten; + restored->fileBindings()[0]->toDataNode(rewritten.root()); + REQUIRE(rewritten.root().child("stageFile") != nullptr); + REQUIRE(rewritten.root().child("sampleTimes") == nullptr); + REQUIRE(rewritten.root().child("timeBase") == nullptr); + } + } + + GIVEN("An Animation Archive holding a usdInstancer binding") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animations(&scene); + auto transforms = scene.createArray(ANARI_FLOAT32_MAT4, 2); + auto node = scene.insertChildTransformArrayNode( + scene.defaultLayer()->root(), transforms.data(), "swarm"); + + tsd::core::DataTree tree; + auto &archive = tree.root(); + archive["name"] = std::string("swarm"); + auto &binding = archive["fileBindings"].append(); + binding["kind"] = std::string("usdInstancer"); + binding["layerName"] = std::string("default"); + binding["nodeIndex"] = node->index(); + binding["stageFile"] = std::string("/data/swarm.usd"); + binding["primPath"] = std::string("/Swarm"); + binding["prototypeIndex"] = uint64_t(0); + + THEN("The kind is a recognized part of the format") + { + std::string message; + REQUIRE(tsd::io::validate_AnimationArchive(animations, archive, &message)); + } + } +} + SCENARIO("tsd::io scene exclusion rejects mixed animation ownership", "[ArchiveCompatibility]") { diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 9a6ad62ff..d7fc3388c 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -2929,4 +2929,84 @@ def Mesh "Blob" } } +SCENARIO("A Stage that authored no time-code range still animates", + "[UsdImport]") +{ + GIVEN("A PointInstancer with time samples but no startTimeCode") + { + // Nothing forces a Stage to declare its own range, and USD reports 0 for + // both ends when it does not. Without a fallback every animation time + // would map onto one Time Code and the placements would never move. + StageFixture stage("tsd_test_usd_unranged_instancer.usda", R"(#usda 1.0 + +def PointInstancer "Drifting" +{ + point3f[] positions.timeSamples = { + 5: [(0, 0, 0), (1, 0, 0)], + 9: [(0, 0, 0), (7, 0, 0)], + } + int[] protoIndices = [0, 0] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported and scrubbed to the end") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + animMgr.setAnimationTime(1.0f); + + THEN("The authored samples define the range that time maps onto") + { + auto *transforms = findTransformArray(scene.defaultLayer(), "Drifting"); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->dataAs()[1][3].x == Approx(7.f)); + } + } + } + + GIVEN("A deforming mesh with time samples but no startTimeCode") + { + StageFixture stage("tsd_test_usd_unranged_mesh.usda", R"(#usda 1.0 + +def Mesh "Blob" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points.timeSamples = { + 5: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 9: [(0, 0, 0), (4, 0, 0), (0, 4, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported and scrubbed to the end") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + animMgr.setAnimationTime(1.0f); + + THEN("The authored samples define the range that time maps onto") + { + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->dataAs()[1].x == Approx(4.f)); + } + } + } +} + #endif // TSD_USE_USD From 45ef85a6321e11f11c65ec5a69528ecf417ea53d Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 17 Aug 2026 21:56:16 -0500 Subject: [PATCH 32/78] refactor(io): split USD geometry conversion into resolve and build The deferred refactor, done. Conversion had one function that read a gprim and created TSD objects in the same pass, so an animation binding could only re-pull the arrays it could identify afterwards -- in practice points and normals. A mesh whose vertex count moved took its topology with it, and writing new positions against old indices describes a mesh that never existed, so the binding detected that case and skipped the frame. Conversion is now two halves. resolveGeometry() reads topology and primvars, refines, triangulates, expands and gathers, and returns a ResolvedGeometry -- a list of Parts, one per Surface the prim yields, holding inert buffers and touching no Scene. Building Surfaces, Geometries, Materials and Arrays from those Parts is separate. The import runs both; a scrub runs only the first and hands the result to refillGeometry(), which writes a Part over an existing Geometry, reusing every Array that still fits. Points, indices and primvars therefore arrive as one set and go in as one set. A mesh that gains a vertex and a triangle mid-sequence now re-pulls correctly, which the new fixture asserts -- along with the Surface, Geometry and Material counts staying put across the scrub, since keeping object identity is the whole reason not to re-run conversion. The binding carries what the import decided that does not vary with time -- the uv primvar per Part, whether the mesh refines, the baked transform -- and replays it, so it never resolves materials again. That is serialized with the binding; an Archive written before this names one geometry and no replay state, and reads back as a single Part with defaults. Supporting moves: intArrayOf() is now in its own small header, and meshWantsRefinement() takes a stage and a level rather than an ImportContext, so the resolve half genuinely depends on nothing above it. GeometryResolveOptions lives in an OpenUSD-free header so the binding still declares itself in builds without USD. Verified unchanged on ~/data/usd/warp: same import times (0.045/0.043/0.135s) and same peak RSS (265/282/524 MB) as before the split, all three scenes still reporting zero skips and scrubbing correctly. Co-Authored-By: Claude Opus 5 --- ...age-session-across-import-and-animation.md | 18 +- ...rrays-rather-than-re-running-conversion.md | 57 +- tsd/src/tsd/io/CMakeLists.txt | 1 + tsd/src/tsd/io/CONTEXT.md | 15 + .../io/animation/UsdGeometryFileBinding.cpp | 256 +++-- .../io/animation/UsdGeometryFileBinding.hpp | 48 +- .../io/importers/detail/usd/UsdAnimation.cpp | 24 +- .../io/importers/detail/usd/UsdAnimation.h | 13 +- .../io/importers/detail/usd/UsdGeometry.cpp | 920 ++--------------- .../tsd/io/importers/detail/usd/UsdGeometry.h | 25 +- .../importers/detail/usd/UsdImportContext.h | 7 +- .../io/importers/detail/usd/UsdInstancing.cpp | 3 +- .../importers/detail/usd/UsdSubdivision.cpp | 10 +- .../io/importers/detail/usd/UsdSubdivision.h | 11 +- tsd/src/tsd/io/importers/import_USD.cpp | 26 +- tsd/src/tsd/io/usd/UsdDataSource.h | 20 + .../tsd/io/usd/UsdGeometryResolveOptions.h | 38 + tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp | 923 ++++++++++++++++++ tsd/src/tsd/io/usd/UsdResolvedGeometry.h | 116 +++ tsd/tests/test_UsdImport.cpp | 98 ++ 20 files changed, 1618 insertions(+), 1011 deletions(-) create mode 100644 tsd/src/tsd/io/usd/UsdDataSource.h create mode 100644 tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h create mode 100644 tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp create mode 100644 tsd/src/tsd/io/usd/UsdResolvedGeometry.h diff --git a/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md index ce053939d..b082c2d57 100644 --- a/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md +++ b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md @@ -10,19 +10,19 @@ weakly, so the last holder to let go closes the Stage. A fully static import therefore retains nothing — the last reference drops when `import_USD` returns — while an animated import pins the file for as long as its bindings live. -This extends ADR 0015's "Hydra owns resolution" from import time to every time, -for the bindings that resolve through Hydra. It also amends ADR 0018, which +This extends ADR 0015's "Hydra owns resolution" from import time to every time. +It also amends ADR 0018, which described per-scene stage retention and claimed a retained scene index the code did not in fact keep: `UsdGeometryFileBinding` used to re-open its own raw `UsdStage`, so a 1.6 GB stage was opened twice. -Be precise about what changed for which binding. The instancer binding reads the -resolved scene, so it re-runs the import's own `readInstancerPlacements()` -against the Session's chain and cannot drift from what the import converted. The -geometry binding still reads `UsdGeomPointBased` off the Stage directly, as ADR -0018 described — the Session did not change where it reads from, only that it no -longer opens the file to do it, and it leaves the Session's Time Code alone -because it never touches the resolved scene. +Both bindings resolve through the Session's chain rather than off the Stage's +schemas, so neither can drift from what the import converted: the instancer +binding re-runs the import's own `readInstancerPlacements()`, and the geometry +binding re-runs the import's own `resolveGeometry()` (ADR 0022). The Stage +itself is still read directly for the things Hydra does not model — authored +time samples, the `anari:` and `tsd:io:` vocabularies — which is what it was +always retained for (ADR 0015). Keying by path rather than by Scene or by AnimationManager keeps USD knowledge out of `tsd_scene` and `tsd_animation`, which sit below `tsd_io`, and makes diff --git a/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md b/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md index 1b769aad2..d920ff65b 100644 --- a/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md +++ b/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md @@ -1,10 +1,17 @@ # Re-fill captured Arrays rather than re-running conversion per frame -A USD animation binding re-fills the TSD Arrays the import created. It does not -re-run the converter that created them. The converter that *creates* a target -Array is the same code that *constructs the binding* and hands it that Array -plus the prim path, so there is no second inference pass and nothing has to find -the array again by name. +Geometry conversion is split in two. **Resolving** a gprim — reading its +topology and primvars, refining it, triangulating it, expanding and gathering +its attributes — produces plain data and is the half that changes over time. +**Building** turns that data into Surfaces, Geometries, Materials and Arrays, +and is the half that does not. A USD animation binding re-runs the resolve half +and writes the result over the objects the import built; it never re-runs the +build half. + +`resolveGeometry()` touches no Scene and returns a `ResolvedGeometry`: a list of +Parts, one per Surface the prim yields, each holding its attributes as inert +buffers. `refillGeometry()` writes one Part over one existing Geometry. The +import calls both in sequence; a scrub calls only the second. Re-running conversion per frame was the obvious alternative and is the wrong one. It allocates new Arrays every frame, rebinds parameters, and churns object @@ -16,13 +23,13 @@ frame. Nothing about that survives a per-frame rebuild. The part that varies over time is the contents of a handful of Arrays; it is not the Surface and Material graph around them. -Concretely, a point instancer becomes one transform-array node per -`(instancer, prototype)` pair, and one binding per pair re-reads the instancer -from the Stage Session at the current Time Code, re-applies the same -per-prototype instance-index selection and visibility mask the importer applied -— through the same `readInstancerPlacements()` the importer calls, so the two -cannot drift — and writes through `Array::setData()`. Deforming geometry works -the same way on `vertex.position` and `vertex.normal`. +Point instancers get the same treatment without needing the split, because a +prototype's placements are already one flat buffer: an instancer becomes one +transform-array node per `(instancer, prototype)` pair, and one binding per pair +re-reads the instancer from the Stage Session at the current Time Code, +re-applies the same per-prototype instance-index selection and visibility mask +the importer applied — through the same `readInstancerPlacements()` the importer +calls, so the two cannot drift — and writes through `Array::setData()`. A TSD `Array` has no resize, so an element count that moves mid-sequence allocates a right-sized Array and rebinds: `setAsTransformArray` for an @@ -33,16 +40,22 @@ every sample — all 1.6 GB of `apic_fluid` — and sampling first/mid/last is a heuristic that would have passed on all three motivating scenes while still being wrong in general. -The one case this deliberately does not handle is a mesh whose vertex count and -topology both move. Points, indices and primvars are then one consistent set -that has to be re-pulled together, and re-pulling them together through -triangulation and per-Surface attribute binding (ADR 0020) *is* re-running the -converter. The binding detects that case, warns once, and leaves the frame as -imported rather than writing a mesh whose positions and indices disagree. -Nothing in the available data exercises it; a fuller "split converters into -resolve → plain data / data → TSD objects" refactor is what would fix it -properly, and that is a refactor project with a feature attached rather than -part of this one. +A mesh whose vertex count and topology both move is handled by construction, +which is the main thing the split buys. Points, indices and primvars are one +consistent set: they come out of a single resolve and go in through a single +refill, so the Geometry is never left describing half of one frame and half of +another. Re-pulling only points — which is what a binding without the split can +do — would have described a mesh that never existed. + +What a binding still cannot do is change how many Parts a prim has. Parts appear +and disappear when a mesh's material subsets change, and that means new Surfaces +and new Materials — conversion, not animation. The binding writes the Parts it +still recognizes, warns once, and leaves the rest as imported. It also replays +rather than recomputes everything the import decided that does not vary with +time: which primvar each Part's material reads as texture coordinates (which is +what assigns every other primvar its attribute slot), whether the mesh refines, +and what transform is baked in. Re-deriving those would mean resolving materials +again, which is exactly the object churn this ADR exists to avoid. Because a scrub writes one Array per animated instancer and `RenderIndexAllLayers` rebuilds its world on every `ANARI_FLOAT32_MAT4` array diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index cd0af79d3..97840e165 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -222,6 +222,7 @@ if (TSD_USE_USD) importers/detail/usd/UsdLights.cpp importers/detail/usd/UsdMaterials.cpp importers/detail/usd/UsdSubdivision.cpp + usd/UsdResolvedGeometry.cpp usd/UsdStageSession.cpp ) diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index 18c9e3b8d..c26761d8a 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -130,6 +130,21 @@ The flavor of shading network selected from a bound foreign material, such as UsdPreviewSurface, MaterialX, or MDL. _Avoid_: shader target, material backend +**Resolved Geometry**: +One foreign gprim read out into plain buffers — topology, points, and every +primvar already expanded and gathered — with no TSD scene objects involved. +Resolving is the half of geometry conversion that varies with Time Code; +building the Surfaces, Materials and Arrays around it is the half that does +not. An animation binding re-runs the first and never the second. See +[ADR 0022](../../../docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md). +_Avoid_: geometry cache, mesh data + +**Part**: +One Surface's worth of a Resolved Geometry, named for the prim or subset it +came from. A mesh with per-face material subsets resolves to several Parts +sharing its vertex data; every other gprim resolves to one. +_Avoid_: submesh, chunk + **Claimed Prim**: A prim an importer handles outside its generic conversion path, and which is therefore withheld from that path. diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp index 54c36ed08..6d32ae342 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -10,9 +10,9 @@ #include #if TSD_USE_USD // tsd_io +#include "tsd/io/usd/UsdResolvedGeometry.h" #include "tsd/io/usd/UsdStageSession.h" // usd -#include #include #endif @@ -20,14 +20,50 @@ namespace tsd::io { using namespace tsd::core; +namespace { + +// The transform is written out flat: sixteen floats in the order the matrix +// stores them, which is what reads it back. +void writeMat4(core::DataNode &node, const tsd::math::mat4 &m) +{ + auto &values = node; + for (int column = 0; column < 4; ++column) { + for (int row = 0; row < 4; ++row) + values.append() = m[column][row]; + } +} + +tsd::math::mat4 readMat4(core::DataNode *node) +{ + auto retval = tsd::math::IDENTITY_MAT4; + if (!node) + return retval; + + std::vector values; + node->foreach_child( + [&](core::DataNode &n) { values.push_back(n.getValueOr(0.f)); }); + if (values.size() != 16) + return retval; + + for (int column = 0; column < 4; ++column) { + for (int row = 0; row < 4; ++row) + retval[column][row] = values[size_t(column * 4 + row)]; + } + return retval; +} + +} // namespace + UsdGeometryFileBinding::UsdGeometryFileBinding(scene::Scene *scene, - scene::Geometry *geometry, std::shared_ptr session, std::string stageFile, - std::string primPath) + std::string primPath, + std::vector parts, + usd::GeometryResolveOptions resolveOptions) : UsdFileBinding( scene, std::move(session), std::move(stageFile), std::move(primPath)), - m_geometry(geometry) + m_parts(std::move(parts)), + m_resolveOptions(std::move(resolveOptions)) {} UsdGeometryFileBinding::~UsdGeometryFileBinding() = default; @@ -47,16 +83,46 @@ void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const // The Stage's own clock is enough to re-derive everything a scrub needs, so // no cache of authored sample times is written; an older Archive that still // carries one is simply not read. - auto *geometry = m_geometry.get(); - node["targetIndex"] = geometry ? geometry->index() : tsd::core::INVALID_INDEX; writePathsToDataNode(node); + + // `targetIndex` names the first Part's geometry, which is all an Archive + // written before the converter split carried and all such an Archive is read + // back as. + auto *first = m_parts.empty() ? nullptr : m_parts.front().geometry.get(); + node["targetIndex"] = first ? first->index() : tsd::core::INVALID_INDEX; + + auto &partsNode = node["parts"]; + for (const auto &part : m_parts) { + auto *geometry = part.geometry.get(); + if (!geometry) + continue; + auto &partNode = partsNode.append(); + partNode["name"] = part.name; + partNode["targetIndex"] = geometry->index(); + } + + // The half of the conversion that does not change over time, so a scrub + // reproduces it rather than resolving materials again. + auto &replay = node["resolve"]; + replay["refine"] = m_resolveOptions.refine; + replay["refinementLevel"] = m_resolveOptions.refinementLevel; + writeMat4(replay["bakeXform"], m_resolveOptions.bakeXform); + auto &uvNode = replay["uvNames"]; + for (const auto &[part, uvName] : m_resolveOptions.uvNamesByPart) { + auto &entry = uvNode.append(); + entry["part"] = part; + entry["uv"] = uvName; + } } void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) { - if (m_geometry) { - const size_t newIndex = cb(m_geometry->type(), m_geometry->index()); - m_geometry.updateDefragmentedIndex(newIndex); + for (auto &part : m_parts) { + if (!part.geometry) + continue; + const size_t newIndex = + cb(part.geometry->type(), part.geometry->index()); + part.geometry.updateDefragmentedIndex(newIndex); } } @@ -69,117 +135,113 @@ void UsdGeometryFileBinding::addCallbackToAnimation( UsdGeometryFileBinding *UsdGeometryFileBinding::addToAnimation( tsd::animation::Animation &anim, scene::Scene &scene, core::DataNode &node) { - const auto targetIndex = - node["targetIndex"].getValueOr(tsd::core::INVALID_INDEX); - auto *geometry = static_cast( - scene.getObject(ANARI_GEOMETRY, targetIndex)); - if (!geometry) { - logWarning( - "[UsdGeometryFileBinding] geometry index %zu not found; skipping", - targetIndex); + const auto primPath = node["primPath"].getValueOr(""); + + auto geometryAt = [&](size_t index) -> scene::Geometry * { + return static_cast( + scene.getObject(ANARI_GEOMETRY, index)); + }; + + std::vector parts; + if (auto *partsNode = node.child("parts")) { + partsNode->foreach_child([&](core::DataNode &partNode) { + const auto index = + partNode["targetIndex"].getValueOr(tsd::core::INVALID_INDEX); + if (auto *geometry = geometryAt(index)) { + parts.push_back( + {partNode["name"].getValueOr(primPath), geometry}); + } + }); + } else { + // An Archive written before the converter split names one geometry and + // nothing else. That is exactly a single Part covering the whole prim. + const auto index = + node["targetIndex"].getValueOr(tsd::core::INVALID_INDEX); + if (auto *geometry = geometryAt(index)) + parts.push_back({primPath, geometry}); + } + + if (parts.empty()) { + logWarning("[UsdGeometryFileBinding] no target geometry for '%s' survives" + " in the scene; skipping", + primPath.c_str()); return nullptr; } + usd::GeometryResolveOptions resolveOptions; + if (auto *replay = node.child("resolve")) { + resolveOptions.refine = (*replay)["refine"].getValueOr(false); + resolveOptions.refinementLevel = + (*replay)["refinementLevel"].getValueOr(2); + resolveOptions.bakeXform = readMat4(replay->child("bakeXform")); + if (auto *uvNode = replay->child("uvNames")) { + uvNode->foreach_child([&](core::DataNode &entry) { + resolveOptions.uvNamesByPart[entry["part"].getValueOr("")] = + entry["uv"].getValueOr("st"); + }); + } + } + return &anim.emplaceFileBinding(&scene, - geometry, std::shared_ptr{}, node["stageFile"].getValueOr(""), - node["primPath"].getValueOr("")); + primPath, + std::move(parts), + std::move(resolveOptions)); } #if TSD_USE_USD -namespace { - -// Whether the prim's topology moves with its points. When it does, points, -// indices and primvars are one consistent set that has to be re-pulled -// together, which is re-running conversion -- exactly what this binding exists -// to avoid. -bool topologyIsTimeSampled(const pxr::UsdPrim &prim) -{ - pxr::UsdGeomMesh mesh(prim); - if (!mesh) - return false; - const auto counts = mesh.GetFaceVertexCountsAttr(); - const auto indices = mesh.GetFaceVertexIndicesAttr(); - return (counts && counts.GetNumTimeSamples() > 1) - || (indices && indices.GetNumTimeSamples() > 1); -} - -// Write `values` into `array`, or -- if the count moved -- allocate a -// right-sized Array and rebind the parameter to it, since a TSD Array's size -// is fixed at construction. -void writeVertexArray(scene::Scene &scene, - scene::Geometry &geometry, - core::Token parameter, - const pxr::VtVec3fArray &values) -{ - auto *array = geometry.parameterValueAsObject(parameter); - if (!array) - return; - - if (array->size() == values.size()) { - array->setData(values.cdata()); - return; - } - - auto replacement = scene.createArray(ANARI_FLOAT32_VEC3, values.size()); - replacement->setData(values.cdata()); - replacement->setName(array->name().c_str()); - geometry.setParameterObject(parameter, *replacement); -} - -} // namespace - void UsdGeometryFileBinding::update(float t) { - if (!scene() || !ensureSession()) + if (!scene() || m_parts.empty() || !ensureSession()) return; - auto *geometry = m_geometry.get(); - if (!geometry) - return; - - auto prim = session()->stage()->GetPrimAtPath(pxr::SdfPath(primPath())); - pxr::UsdGeomPointBased pointBased(prim); - if (!pointBased) - return; + const pxr::SdfPath path(primPath()); // A Stage that carries samples but authored no time-code range has no range // to map onto until its own prims say what they cover. if (!m_sampleTimesNoted && !session()->hasAuthoredTimeRange()) { m_sampleTimesNoted = true; - std::vector times; - pointBased.GetPointsAttr().GetTimeSamples(×); - noteAuthoredSampleTimes(times); + pxr::UsdGeomPointBased pointBased(session()->stage()->GetPrimAtPath(path)); + if (pointBased) { + std::vector times; + pointBased.GetPointsAttr().GetTimeSamples(×); + noteAuthoredSampleTimes(times); + } } - // Points are read from the Stage's own schema rather than through the - // Session's resolved chain, as they always have been (ADR 0018); the Session - // is shared so that this does not mean a second open of the file. Its - // resolved scene is not read here, so its Time Code is left alone. - const auto time = session()->timeCodeAt(t); - - pxr::VtVec3fArray points; - if (pointBased.GetPointsAttr().Get(&points, time) && !points.empty()) { - auto *positions = - geometry->parameterValueAsObject("vertex.position"); - const bool countMoved = positions && positions->size() != points.size(); - if (countMoved && topologyIsTimeSampled(prim)) { - if (!m_countChangeReported) { - m_countChangeReported = true; - logWarning("[UsdGeometryFileBinding] '%s': vertex count and topology" - " both change over time; the frame is left as imported", - primPath().c_str()); - } - return; - } - writeVertexArray(*scene(), *geometry, "vertex.position", points); + session()->setTime(session()->timeCodeAt(t)); + + auto prim = session()->sceneIndex()->GetPrim(path); + const auto resolved = + usd::resolveGeometry(session()->sceneIndex(), path, prim, m_resolveOptions); + if (!resolved.valid()) + return; + + // Every Part is written in one pass, so points, indices and primvars always + // describe the same frame. A Part that has gone missing is left as imported + // rather than half-updated: Parts appear and disappear when the mesh's + // material subsets change, and that means new Surfaces and Materials, which + // is conversion rather than animation. + size_t applied = 0; + for (auto &part : m_parts) { + auto *geometry = part.geometry.get(); + const auto *resolvedPart = resolved.part(part.name); + if (!geometry || !resolvedPart) + continue; + if (usd::refillGeometry(*scene(), *geometry, *resolvedPart)) + applied++; } - pxr::VtVec3fArray normals; - if (pointBased.GetNormalsAttr().Get(&normals, time) && !normals.empty()) - writeVertexArray(*scene(), *geometry, "vertex.normal", normals); + if (applied < m_parts.size() && !m_partsChangedReported) { + m_partsChangedReported = true; + logWarning("[UsdGeometryFileBinding] '%s': %zu of %zu parts no longer" + " resolve; their geometry is left as imported", + primPath().c_str(), + m_parts.size() - applied, + m_parts.size()); + } } #else diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp index 5ab7af6a6..84700a13c 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp @@ -8,18 +8,30 @@ #include "tsd/scene/ObjectUsePtr.hpp" #include "tsd/scene/Scene.hpp" #include "tsd/scene/objects/Geometry.hpp" +// tsd_core +#include "tsd/core/TSDMath.hpp" +// tsd_io +#include "tsd/io/usd/UsdGeometryResolveOptions.h" // std +#include #include #include +#include namespace tsd::io { /* - * Binding that re-pulls a deforming mesh's vertex arrays from a Stage Session - * at the current animation time, so that a long animation of a dense mesh does - * not have to fit in memory. The Session stays open for the binding's lifetime - * (ADR 0018); serialization records the file and prim paths and reconstructs - * by rejoining the Session for that file. + * Binding that re-resolves a deforming gprim from a Stage Session at the + * current animation time and writes the result over the Geometries the Import + * built, so that a long animation of a dense mesh does not have to fit in + * memory (ADR 0018). + * + * It re-runs the resolve half of conversion and none of the build half: points, + * indices and primvars arrive as one consistent set, while the Surfaces and + * Materials around them keep their identity and their ANARI handles (ADR 0022). + * Everything the Import decided that does not change over time -- which primvar + * each Part's material reads, whether the mesh refines, what transform is baked + * in -- is carried here and replayed rather than recomputed. * * The animation time maps onto the Stage's own Time Code range and USD * evaluates there, so a value between authored samples is interpolated rather @@ -27,15 +39,23 @@ namespace tsd::io { * * Example: * auto &b = anim.emplaceFileBinding( - * &scene, geometry.data(), session, stageFile, "/World/Character"); + * &scene, session, stageFile, "/World/Character", converted); */ struct UsdGeometryFileBinding : public UsdFileBinding { + // One resolved Part and the Geometry it was built into. + struct Part + { + std::string name; + scene::ObjectUsePtr geometry; + }; + UsdGeometryFileBinding(scene::Scene *scene, - scene::Geometry *geometry, std::shared_ptr session, std::string stageFile, - std::string primPath); + std::string primPath, + std::vector parts, + usd::GeometryResolveOptions resolveOptions); ~UsdGeometryFileBinding() override; // FileBinding interface // @@ -44,11 +64,12 @@ struct UsdGeometryFileBinding : public UsdFileBinding void toDataNode(tsd::core::DataNode &node) const override; void onDefragment(const scene::IndexRemapper &cb) override; - // Pull the vertex arrays for the Time Code `t` maps to. + // Re-resolve the gprim at the Time Code `t` maps to and write it over the + // bound Geometries. void update(float t) override; - // Reconstruct from a serialized node; returns null if the target geometry is - // missing from the scene. + // Reconstruct from a serialized node; returns null if no target geometry + // survives in the scene. static UsdGeometryFileBinding *addToAnimation(tsd::animation::Animation &anim, scene::Scene &scene, tsd::core::DataNode &node); @@ -57,9 +78,10 @@ struct UsdGeometryFileBinding : public UsdFileBinding void addCallbackToAnimation(tsd::animation::Animation &anim) override; const char *logTag() const override; - scene::ObjectUsePtr m_geometry; + std::vector m_parts; + usd::GeometryResolveOptions m_resolveOptions; bool m_sampleTimesNoted{false}; - bool m_countChangeReported{false}; + bool m_partsChangedReported{false}; }; } // namespace tsd::io diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp index ea041e566..84e860f7c 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -181,11 +181,12 @@ void addInstancerAnimation(ImportContext &ctx, prototypeIndex); } -void addDeformingGeometryAnimation( - ImportContext &ctx, const pxr::SdfPath &primPath, GeometryRef geometry) +void addDeformingGeometryAnimation(ImportContext &ctx, + const pxr::SdfPath &primPath, + ConvertedGeometry &converted) { auto prim = ctx.stage->GetPrimAtPath(primPath); - if (!prim || !geometry) + if (!prim || converted.geometryByPart.empty()) return; pxr::UsdGeomPointBased pointBased(prim); @@ -200,13 +201,24 @@ void addDeformingGeometryAnimation( if (ctx.session) ctx.session->noteAuthoredSampleTimes(sampleTimes); + std::vector parts; + parts.reserve(converted.geometryByPart.size()); + for (auto &[name, geometry] : converted.geometryByPart) { + UsdGeometryFileBinding::Part part; + part.name = name; + part.geometry = geometry.data(); + parts.push_back(std::move(part)); + } + // One eager frame is already in the Scene; the rest is pulled from the - // shared Stage Session on demand (ADR 0018). + // shared Stage Session on demand (ADR 0018), re-resolved rather than + // re-converted (ADR 0022). ctx.animation().emplaceFileBinding(&ctx.scene, - geometry.data(), ctx.session, ctx.filePath, - primPath.GetString()); + primPath.GetString(), + std::move(parts), + converted.resolveOptions); ctx.reportAnimatedPrim(sampleTimes.size()); } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h index 69a757486..3cca9f072 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h @@ -3,6 +3,7 @@ #pragma once +#include "tsd/io/importers/detail/usd/UsdGeometry.h" #include "tsd/io/importers/detail/usd/UsdImportContext.h" #include "tsd/scene/objects/Array.hpp" // std @@ -39,10 +40,12 @@ void addInstancerAnimation(ImportContext &ctx, LayerNodeRef arrayNode, ArrayRef transforms); -// Bind a geometry's vertex arrays to the retained Stage so that a long -// animation of a dense mesh is pulled on demand instead of held in memory -// (ADR 0018). Does nothing unless the prim's points are time-sampled. -void addDeformingGeometryAnimation( - ImportContext &ctx, const pxr::SdfPath &primPath, GeometryRef geometry); +// Bind everything one converted gprim produced to the Stage Session, so that a +// long animation of a dense mesh is pulled on demand instead of held in memory +// (ADR 0018) and arrives as one consistent set (ADR 0022). Does nothing unless +// the prim's points are time-sampled. +void addDeformingGeometryAnimation(ImportContext &ctx, + const pxr::SdfPath &primPath, + ConvertedGeometry &converted); } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 8374279a4..21bd157b7 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -5,28 +5,11 @@ #include "tsd/io/importers/detail/usd/UsdMaterials.h" #include "tsd/io/importers/detail/usd/UsdSubdivision.h" // usd -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include // std -#include -#include #include -#include #include -#include #include namespace tsd::io::usd { @@ -35,130 +18,6 @@ using namespace tsd::core; namespace { -/////////////////////////////////////////////////////////////////////////////// -// Primvar plumbing /////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -// One primvar, already flattened out of any indexing, with the interpolation -// that decides which TSD attribute slot it lands in. -struct Primvar -{ - pxr::VtValue value; - pxr::TfToken interpolation; - pxr::TfToken role; - - bool valid() const; -}; - -bool Primvar::valid() const -{ - return !value.IsEmpty() && value.IsArrayValued() && value.GetArraySize() > 0; -} - -Primvar readPrimvar( - const pxr::HdPrimvarsSchema &primvars, const pxr::TfToken &name) -{ - Primvar retval; - auto primvar = primvars.GetPrimvar(name); - if (!primvar) - return retval; - if (auto value = primvar.GetFlattenedPrimvarValue()) - retval.value = value->GetValue(0); - if (auto interpolation = primvar.GetInterpolation()) - retval.interpolation = interpolation->GetTypedValue(0); - if (auto role = primvar.GetRole()) - retval.role = role->GetTypedValue(0); - return retval; -} - -// Which TSD parameter prefix an interpolation maps onto. Constant primvars -// have no per-element ANARI slot and are handled by the caller where they -// carry meaning (display colour), otherwise dropped. -const char *prefixForInterpolation(const pxr::TfToken &interpolation) -{ - if (interpolation == pxr::HdPrimvarSchemaTokens->uniform) - return "primitive."; - if (interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) - return "faceVarying."; - if (interpolation == pxr::HdPrimvarSchemaTokens->vertex - || interpolation == pxr::HdPrimvarSchemaTokens->varying) - return "vertex."; - return nullptr; -} - -anari::DataType anariTypeOfPrimvar(const pxr::VtValue &value) -{ - if (value.IsHolding()) - return ANARI_FLOAT32; - if (value.IsHolding()) - return ANARI_FLOAT32_VEC2; - if (value.IsHolding()) - return ANARI_FLOAT32_VEC3; - if (value.IsHolding()) - return ANARI_FLOAT32_VEC4; - return ANARI_UNKNOWN; -} - -/////////////////////////////////////////////////////////////////////////////// -// Shared conversion helpers ////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -// Prototype-internal transforms are baked into vertex data (ADR 0016); -// everything else passes through untouched. -std::vector bakedPositions( - const pxr::VtVec3fArray &source, const tsd::math::mat4 &bakeXform) -{ - const bool bake = bakeXform != tsd::math::IDENTITY_MAT4; - std::vector retval; - retval.reserve(source.size()); - for (const auto &p : source) { - float3 v(p[0], p[1], p[2]); - if (bake) { - const auto t = tsd::math::mul(bakeXform, float4(v.x, v.y, v.z, 1.f)); - v = float3(t.x, t.y, t.z); - } - retval.push_back(v); - } - return retval; -} - -// USD authors widths; TSD geometry takes radii. Prims with no authored -// widths (common for Blender hair exports) would otherwise inherit ANARI's -// default radius of 1 world unit, which dwarfs most scenes — instead fall -// back to a small radius scaled to the prim's own bounds so strands stay -// hair-like at any scene scale. -void bindRadiiFromWidths(ImportContext &ctx, - GeometryRef &geometry, - const Primvar &widths, - const std::vector &positions) -{ - const bool haveWidths = widths.valid() - && widths.value.IsHolding() - && !widths.value.UncheckedGet().empty(); - - if (!haveWidths) { - float3 lo(std::numeric_limits::max()); - float3 hi(std::numeric_limits::lowest()); - for (const auto &p : positions) { - lo = tsd::math::min(lo, p); - hi = tsd::math::max(hi, p); - } - const float diagonal = positions.empty() ? 0.f : tsd::math::length(hi - lo); - geometry->setParameter("radius", diagonal > 0.f ? 1e-3f * diagonal : 1e-3f); - return; - } - - const auto &w = widths.value.UncheckedGet(); - std::vector radii; - radii.reserve(positions.size()); - for (size_t i = 0; i < positions.size(); ++i) - radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); - - auto array = ctx.scene.createArray(ANARI_FLOAT32, radii.size()); - array->setData(radii.data(), radii.size()); - geometry->setParameterObject("vertex.radius", *array); -} - // The material a resolved prim binds, or an empty path when it binds none. pxr::SdfPath boundMaterialPathOf(const pxr::HdSceneIndexPrim &prim) { @@ -175,433 +34,56 @@ pxr::SdfPath boundMaterialPathOf(const pxr::HdSceneIndexPrim &prim) // reference viewer instead of taking TSD's default. MaterialRef displayColorMaterial(ImportContext &ctx, const pxr::SdfPath &primPath, - const Primvar &displayColor, - const Primvar &displayOpacity) + const pxr::HdSceneIndexPrim &prim) { auto retval = ctx.scene.createObject(tokens::material::matte); retval->setName((primPath.GetString() + "_displayColor").c_str()); - if (displayColor.valid() - && displayColor.value.IsHolding()) { - const auto &c = displayColor.value.UncheckedGet(); - retval->setParameter("color", float3(c[0][0], c[0][1], c[0][2])); - } - if (displayOpacity.valid() - && displayOpacity.value.IsHolding()) { - const auto &o = displayOpacity.value.UncheckedGet(); - retval->setParameter("opacity", o[0]); - } - return retval; -} - -/////////////////////////////////////////////////////////////////////////////// -// Mesh conversion //////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -// Apply a transform to the float-typed array a primvar holds, whatever its -// component count. Anything else has no ANARI attribute slot and so yields an -// empty value. -template -pxr::VtValue transformFloatArray(const pxr::VtValue &value, Fn &&fn) -{ - if (value.IsHolding()) - return pxr::VtValue(fn(value.UncheckedGet())); - if (value.IsHolding()) - return pxr::VtValue(fn(value.UncheckedGet())); - if (value.IsHolding()) - return pxr::VtValue(fn(value.UncheckedGet())); - if (value.IsHolding()) - return pxr::VtValue(fn(value.UncheckedGet())); - return {}; -} -// Expand a uniform (per-face) primvar to per-triangle values using the -// triangulation's record of which coarse face each triangle came from. -template -pxr::VtArray expandUniform( - const pxr::VtArray &source, const pxr::VtIntArray &primitiveParams) -{ - pxr::VtArray retval; - retval.reserve(primitiveParams.size()); - for (int param : primitiveParams) { - const int face = pxr::HdMeshUtil::DecodeFaceIndexFromCoarseFaceParam(param); - retval.push_back(source[size_t(face) < source.size() ? size_t(face) - : source.size() - 1]); - } + const auto display = readDisplayColor(prim); + if (display.hasColor) + retval->setParameter("color", display.color); + if (display.hasOpacity) + retval->setParameter("opacity", display.opacity); return retval; } -pxr::VtValue expandUniformValue( - const pxr::VtValue &value, const pxr::VtIntArray &primitiveParams) -{ - return transformFloatArray(value, [&](const auto &source) { - return expandUniform(source, primitiveParams); - }); -} - -// Select the values belonging to a chosen set of triangles out of an array -// laid out in triangle order -- one value per triangle for per-primitive data, -// three for per-corner data. -template -pxr::VtArray gatherTriangles(const pxr::VtArray &source, - const std::vector &triangles, - size_t valuesPerTriangle) +// The subsets a mesh divides itself into, in the order the resolver visits +// them, so Part names line up with the materials resolved for them. +std::vector subsetPathsOf( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath) { - pxr::VtArray retval; - retval.reserve(triangles.size() * valuesPerTriangle); - for (uint32_t triangle : triangles) { - const size_t base = size_t(triangle) * valuesPerTriangle; - for (size_t i = 0; i < valuesPerTriangle; ++i) - retval.push_back(source[base + i]); + std::vector retval; + for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) { + if (sceneIndex->GetPrim(childPath).primType + == pxr::HdPrimTypeTokens->geomSubset) + retval.push_back(childPath); } return retval; } -pxr::VtValue gatherTrianglesValue(const pxr::VtValue &value, - const std::vector &triangles, - size_t valuesPerTriangle) +// Resolve the materials this gprim needs, and with them the attribute-slot +// assignment the resolver must use. This is the half of conversion that does +// not change over time, which is why it happens once and its answer is +// replayed rather than recomputed. +struct MaterialPlan { - return transformFloatArray(value, [&](const auto &source) { - return gatherTriangles(source, triangles, valuesPerTriangle); - }); -} - -// Everything one converted mesh needs to hand to the Surface builder. -struct ConvertedMesh -{ - ArrayRef vertexPosition; - pxr::VtVec3iArray triangleIndices; - pxr::VtIntArray primitiveParams; + std::map byPart; + std::map uvNamesByPart; + MaterialRef fallback; }; -// A primvar expanded onto the triangulated topology and ready to bind. Vertex -// data stays as authored and is indexed by the triangle indices, so its Array -// is created once and shared by every Surface built from the mesh; uniform and -// face-varying data are laid out in triangle order and have to be gathered per -// Surface, because a subset draws only some of the triangles. -struct TriangulatedPrimvar -{ - pxr::VtValue value; - const char *prefix{nullptr}; - size_t valuesPerTriangle{0}; - ArrayRef sharedArray; - // The same vertex data with `v` reversed, for the Surfaces that read this - // primvar as their texture coordinates. - ArrayRef sharedUvArray; - - // Vertex data is the only kind every Surface can point at unchanged. - bool isShared() const; -}; - -bool TriangulatedPrimvar::isShared() const -{ - return valuesPerTriangle == 0; -} - -// Kept sorted by name: the order primvars are visited decides which of them -// takes each spare attribute slot, and that has to be stable across runs. This -// is why the mesh converter reaches for std::map rather than FlatMap. -using TriangulatedPrimvars = std::map; - -} // namespace - -bool isGeometryPrimType(const pxr::TfToken &primType) -{ - return primType == pxr::HdPrimTypeTokens->mesh - || primType == pxr::HdPrimTypeTokens->points - || primType == pxr::HdPrimTypeTokens->basisCurves - || primType == pxr::HdPrimTypeTokens->sphere - || primType == pxr::HdPrimTypeTokens->cone - || primType == pxr::HdPrimTypeTokens->cylinder; -} - -namespace { - -// Expand every primvar that has an attribute slot onto the triangulated -// topology, once, so that each Surface built from the mesh only has to select -// the values for its own triangles. Primvars whose expansion fails or comes up -// short of the triangulation are left out rather than bound partially. -TriangulatedPrimvars triangulatePrimvars(const pxr::HdMeshUtil &meshUtil, - const ConvertedMesh &mesh, - const std::map &primvars) -{ - TriangulatedPrimvars retval; - for (const auto &[name, primvar] : primvars) { - if (name == pxr::HdPrimvarsSchemaTokens->points.GetString() - || name == pxr::HdTokens->displayOpacity.GetString()) - continue; - - TriangulatedPrimvar attribute; - attribute.prefix = prefixForInterpolation(primvar.interpolation); - if (!attribute.prefix || anariTypeOfPrimvar(primvar.value) == ANARI_UNKNOWN) - continue; - - if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) { - attribute.value = expandUniformValue(primvar.value, mesh.primitiveParams); - attribute.valuesPerTriangle = 1; - } else if (primvar.interpolation - == pxr::HdPrimvarSchemaTokens->faceVarying) { - const auto result = meshUtil.ComputeTriangulatedFaceVaryingPrimvar( - pxr::HdGetValueData(primvar.value), - int(primvar.value.GetArraySize()), - pxr::HdGetValueTupleType(primvar.value).type, - &attribute.value); - // Unchanged means the mesh is already all triangles: the flattened - // input is already one value per triangle corner, in triangle order. - if (result == pxr::HdMeshComputationResult::Unchanged) - attribute.value = primvar.value; - else if (result != pxr::HdMeshComputationResult::Success) - continue; - attribute.valuesPerTriangle = 3; - } else { - attribute.value = primvar.value; - } - - // Whatever will be gathered has to cover the whole triangulation, since - // any subset may ask for any triangle. Vertex data is bound as authored - // and indexed by the triangle indices, so there is nothing to check here. - if (!attribute.value.IsArrayValued() || attribute.value.GetArraySize() == 0 - || attribute.value.GetArraySize() - < mesh.triangleIndices.size() * attribute.valuesPerTriangle) - continue; - - retval.emplace(name, std::move(attribute)); - } - return retval; -} - -// Reverse an array of texture coordinates in place. USD authors `st` v-up, -// while the coordinates TSD hands ANARI run down the image. See -// docs/adr/0014-store-images-in-anari-orientation.md. -void reverseTexCoordV(ArrayRef &array) -{ - if (array->elementType() != ANARI_FLOAT32_VEC2) - return; - auto *uv = array->mapAs(); - for (size_t i = 0; i < array->size(); ++i) - uv[i].y = 1.f - uv[i].y; - array->unmap(); -} - -// Bind one expanded primvar onto a geometry drawing `triangles`. `isUv` marks -// the one primvar this Surface's material reads as texture coordinates, which -// is the only binding whose `v` is reversed -- the same primvar bound to a -// spare attribute slot elsewhere is data TSD knows nothing about, and is bound -// as authored. -void bindTrianglePrimvar(ImportContext &ctx, - GeometryRef &geometry, - TriangulatedPrimvar &primvar, - const std::vector &triangles, - const std::string &tsdName, - bool isUv = false) -{ - ArrayRef array; - if (primvar.isShared()) { - auto &shared = isUv ? primvar.sharedUvArray : primvar.sharedArray; - if (!shared) { - shared = ctx.scene.createArray( - anariTypeOfPrimvar(primvar.value), primvar.value.GetArraySize()); - shared->setData(pxr::HdGetValueData(primvar.value)); - if (isUv) - reverseTexCoordV(shared); - } - array = shared; - } else { - const auto selected = gatherTrianglesValue( - primvar.value, triangles, primvar.valuesPerTriangle); - if (!selected.IsArrayValued() || selected.GetArraySize() == 0) - return; - array = ctx.scene.createArray( - anariTypeOfPrimvar(selected), selected.GetArraySize()); - array->setData(pxr::HdGetValueData(selected)); - if (isUv) - reverseTexCoordV(array); - } - - geometry->setParameterObject( - Token((primvar.prefix + tsdName).c_str()), *array); -} - -// Append the triangles one coarse face produced to a Surface's selection. -void appendTrianglesOfFace(std::vector &selection, - const std::vector> &trianglesOfFace, - size_t face) -{ - const auto &triangles = trianglesOfFace[face]; - selection.insert(selection.end(), triangles.begin(), triangles.end()); -} - -// One Surface's worth of the mesh: the triangles it draws, with every primvar -// re-indexed to match. `uvName` is whichever primvar this Surface's own -// material reads, which is why the attribute slots cannot be assigned once for -// the whole mesh. -GeometryRef buildTriangleGeometry(ImportContext &ctx, - const ConvertedMesh &mesh, - TriangulatedPrimvars &attributes, - const std::vector &triangles, - const std::string &uvName, - const char *name) -{ - auto geometry = ctx.scene.createObject(tokens::geometry::triangle); - geometry->setName(name); - geometry->setParameterObject("vertex.position", *mesh.vertexPosition); - - std::vector indices; - indices.reserve(triangles.size()); - for (uint32_t triangle : triangles) { - const auto &t = mesh.triangleIndices[triangle]; - indices.push_back(uint3(t[0], t[1], t[2])); - } - auto indexArray = ctx.scene.createArray(ANARI_UINT32_VEC3, indices.size()); - indexArray->setData(indices.data(), indices.size()); - geometry->setParameterObject("primitive.index", *indexArray); - - auto bind = [&](const std::string &primvarName, - const std::string &tsdName, - bool isUv = false) { - auto found = attributes.find(primvarName); - if (found != attributes.end()) { - bindTrianglePrimvar( - ctx, geometry, found->second, triangles, tsdName, isUv); - } - }; - - // Normals, UVs, display colour, then any remaining primvars in name order so - // the attribute assignment is deterministic. - const auto normalsName = pxr::HdPrimvarsSchemaTokens->normals.GetString(); - const auto colorName = pxr::HdTokens->displayColor.GetString(); - bind(normalsName, "normal"); - bind(uvName, "attribute0", /*isUv=*/true); - bind(colorName, "color"); - - int nextAttribute = 1; - for (auto &[primvarName, primvar] : attributes) { - if (nextAttribute > 3) - break; - if (primvarName == normalsName || primvarName == colorName - || primvarName == uvName) - continue; - bindTrianglePrimvar(ctx, - geometry, - primvar, - triangles, - "attribute" + std::to_string(nextAttribute++)); - } - - return geometry; -} - -std::vector convertMesh(ImportContext &ctx, +MaterialPlan planMaterials(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim, - const tsd::math::mat4 &bakeXform) + const pxr::HdSceneIndexPrim &prim) { - auto meshSchema = pxr::HdMeshSchema::GetFromParent(prim.dataSource); - auto topologySchema = meshSchema.GetTopology(); - auto primvarsSchema = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); - - // Resolve every primvar up front so that refinement can replace the - // vertex-interpolated ones in place. - std::map primvars; - for (const auto &name : primvarsSchema.GetPrimvarNames()) { - auto primvar = readPrimvar(primvarsSchema, name); - if (primvar.valid()) - primvars.emplace(name.GetString(), std::move(primvar)); - } - - auto lookup = [&](const std::string &name) -> Primvar { - auto found = primvars.find(name); - return found == primvars.end() ? Primvar() : found->second; - }; - - auto points = lookup(pxr::HdPrimvarsSchemaTokens->points.GetString()); - if (!points.valid() || !points.value.IsHolding()) - return {}; - - auto faceVertexCounts = intArrayOf(topologySchema.GetFaceVertexCounts()); - auto faceVertexIndices = intArrayOf(topologySchema.GetFaceVertexIndices()); - auto holeIndices = intArrayOf(topologySchema.GetHoleIndices()); - if (faceVertexCounts.empty() || faceVertexIndices.empty()) - return {}; + MaterialPlan retval; - auto orientationSource = topologySchema.GetOrientation(); - const auto orientation = orientationSource - ? orientationSource->GetTypedValue(0) - : pxr::HdTokens->rightHanded; - - if (meshWantsRefinement(ctx, primPath)) { - MeshPrimvars sourcePrimvars; - for (const auto &[name, primvar] : primvars) { - if (name == pxr::HdPrimvarsSchemaTokens->points.GetString()) - continue; - if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->vertex - || primvar.interpolation == pxr::HdPrimvarSchemaTokens->varying) - sourcePrimvars.vertex.emplace_back(name, primvar.value); - else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) - sourcePrimvars.faceVarying.emplace_back(name, primvar.value); - else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) - sourcePrimvars.uniform.emplace_back(name, primvar.value); - } - - auto refined = refineMesh(meshSchema, - faceVertexCounts, - faceVertexIndices, - holeIndices, - orientation, - points.value.UncheckedGet(), - sourcePrimvars, - ctx.options.refinementLevel); - - if (refined.valid) { - faceVertexCounts = refined.faceVertexCounts; - faceVertexIndices = refined.faceVertexIndices; - holeIndices = refined.holeIndices; - points.value = pxr::VtValue(refined.points); - auto writeBack = [&](const std::vector &group) { - for (const auto &[name, value] : group) - primvars[name].value = value; - }; - writeBack(refined.primvars.vertex); - writeBack(refined.primvars.faceVarying); - writeBack(refined.primvars.uniform); - } - } - - pxr::HdMeshTopology topology(pxr::PxOsdOpenSubdivTokens->none, - orientation, - faceVertexCounts, - faceVertexIndices, - holeIndices); - - // OpenUSD's own topology-aware triangulation handles non-convex polygons - // and holes; a hand-rolled fan does not. - pxr::HdMeshUtil meshUtil(&topology, primPath); - ConvertedMesh mesh; - meshUtil.ComputeTriangleIndices(&mesh.triangleIndices, &mesh.primitiveParams); - if (mesh.triangleIndices.empty()) - return {}; - - // Resolve the bound material first: it names the UV primvar to bind. const auto resolved = resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)); - - const auto positions = - bakedPositions(points.value.UncheckedGet(), bakeXform); - mesh.vertexPosition = - ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); - mesh.vertexPosition->setData(positions.data(), positions.size()); - - auto attributes = triangulatePrimvars(meshUtil, mesh, primvars); - - // Material // - - auto material = resolved.material; - if (!material) { - material = displayColorMaterial(ctx, - primPath, - lookup(pxr::HdTokens->displayColor.GetString()), - lookup(pxr::HdTokens->displayOpacity.GetString())); - } + retval.fallback = resolved.material + ? resolved.material + : displayColorMaterial(ctx, primPath, prim); // A material names the primvar its texture reader wants. A subset without a // material of its own falls back to this one, and this one to the @@ -609,320 +91,102 @@ std::vector convertMesh(ImportContext &ctx, const std::string meshUvName = resolved.uvPrimvarName.empty() ? "st" : resolved.uvPrimvarName; - std::vector allTriangles(mesh.triangleIndices.size()); - std::iota(allTriangles.begin(), allTriangles.end(), 0u); - - // Per-face material subsets each become their own Surface over their own - // triangles, sharing this mesh's vertex arrays. - std::vector retval; - std::vector subsetPaths; - for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) { - if (sceneIndex->GetPrim(childPath).primType - == pxr::HdPrimTypeTokens->geomSubset) - subsetPaths.push_back(childPath); - } + const auto meshName = primPath.GetString(); + retval.byPart[meshName] = retval.fallback; + retval.uvNamesByPart[meshName] = meshUvName; - if (subsetPaths.empty()) { - auto geometry = buildTriangleGeometry( - ctx, mesh, attributes, allTriangles, meshUvName, primPath.GetText()); - retval.push_back( - ctx.scene.createSurface(primPath.GetText(), geometry, material)); + if (prim.primType != pxr::HdPrimTypeTokens->mesh) return retval; - } - - // Map each coarse face to the triangles it produced, once, so every subset - // can select its own triangles cheaply. - std::vector> trianglesOfFace; - for (size_t i = 0; i < mesh.primitiveParams.size(); ++i) { - const int face = pxr::HdMeshUtil::DecodeFaceIndexFromCoarseFaceParam( - mesh.primitiveParams[i]); - if (face < 0) - continue; - if (trianglesOfFace.size() <= size_t(face)) - trianglesOfFace.resize(size_t(face) + 1); - trianglesOfFace[size_t(face)].push_back(uint32_t(i)); - } - - std::vector faceIsClaimed(trianglesOfFace.size(), false); - for (const auto &subsetPath : subsetPaths) { + for (const auto &subsetPath : subsetPathsOf(sceneIndex, primPath)) { auto subsetPrim = sceneIndex->GetPrim(subsetPath); - auto subsetSchema = - pxr::HdGeomSubsetSchema::GetFromParent(subsetPrim.dataSource); - const auto faceIndices = intArrayOf(subsetSchema.GetIndices()); - if (faceIndices.empty()) - continue; - - std::vector subsetTriangles; - for (int face : faceIndices) { - if (face < 0 || size_t(face) >= trianglesOfFace.size()) - continue; - faceIsClaimed[size_t(face)] = true; - appendTrianglesOfFace(subsetTriangles, trianglesOfFace, size_t(face)); - } - if (subsetTriangles.empty()) - continue; - - // A subset resolves its own material, which may read a different UV - // primvar than the mesh's does, so its attributes are bound to suit it. const auto subsetResolved = resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(subsetPrim)); - const std::string &subsetUvName = subsetResolved.uvPrimvarName.empty() + + const auto subsetName = subsetPath.GetString(); + retval.byPart[subsetName] = subsetResolved.material ? subsetResolved.material + : retval.fallback; + retval.uvNamesByPart[subsetName] = subsetResolved.uvPrimvarName.empty() ? meshUvName : subsetResolved.uvPrimvarName; - - auto subsetGeometry = buildTriangleGeometry(ctx, - mesh, - attributes, - subsetTriangles, - subsetUvName, - subsetPath.GetText()); - - retval.push_back(ctx.scene.createSurface(subsetPath.GetText(), - subsetGeometry, - subsetResolved.material ? subsetResolved.material : material)); - } - - // Faces no subset claimed keep the mesh's own binding rather than going - // missing with the geometry that no Surface would have drawn. - std::vector unclaimedTriangles; - for (size_t face = 0; face < trianglesOfFace.size(); ++face) { - if (!faceIsClaimed[face]) - appendTrianglesOfFace(unclaimedTriangles, trianglesOfFace, face); - } - - // No subset drew anything -- with nothing to divide the mesh up, draw all of - // it, including any triangle whose coarse face could not be identified. - if (retval.empty()) - unclaimedTriangles = allTriangles; - - if (!unclaimedTriangles.empty()) { - auto geometry = buildTriangleGeometry(ctx, - mesh, - attributes, - unclaimedTriangles, - meshUvName, - primPath.GetText()); - retval.push_back( - ctx.scene.createSurface(primPath.GetText(), geometry, material)); } return retval; } -/////////////////////////////////////////////////////////////////////////////// -// Points and curves ////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -// Points, curves, and quadrics resolve their material the same way a mesh -// does, including the display-colour fallback for unmaterialed prims. -MaterialRef materialForPrim(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim) +// Turn resolved Parts into TSD objects. Attributes carrying a shared key are +// built once and pointed at by every Part that names them, which is how a +// subdivided mesh's Surfaces end up sharing one position Array. +void buildParts(ImportContext &ctx, + const ResolvedGeometry &resolved, + const MaterialPlan &materials, + ConvertedGeometry &out) { - if (auto material = - resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)).material) - return material; - - auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); - return displayColorMaterial(ctx, - primPath, - readPrimvar(primvars, pxr::HdTokens->displayColor), - readPrimvar(primvars, pxr::HdTokens->displayOpacity)); -} - -std::vector convertPoints(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim, - const tsd::math::mat4 &bakeXform) -{ - auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); - const auto points = - readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); - if (!points.valid() || !points.value.IsHolding()) - return {}; - - auto geometry = ctx.scene.createObject(tokens::geometry::sphere); - geometry->setName(primPath.GetText()); + std::map sharedArrays; - const auto positions = - bakedPositions(points.value.UncheckedGet(), bakeXform); - auto positionArray = - ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); - positionArray->setData(positions.data(), positions.size()); - geometry->setParameterObject("vertex.position", *positionArray); + for (const auto &part : resolved.parts) { + auto geometry = ctx.scene.createObject(part.subtype); + geometry->setName(part.name.c_str()); - bindRadiiFromWidths(ctx, - geometry, - readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), - positions); - - return {ctx.scene.createSurface(primPath.GetText(), - geometry, - materialForPrim(ctx, sceneIndex, primPath, prim))}; -} - -std::vector convertCurves(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim, - const tsd::math::mat4 &bakeXform) -{ - auto curvesSchema = pxr::HdBasisCurvesSchema::GetFromParent(prim.dataSource); - auto topologySchema = curvesSchema.GetTopology(); - auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); - - const auto points = - readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); - if (!points.valid() || !points.value.IsHolding()) - return {}; + for (const auto &attribute : part.attributes) { + if (!attribute.valid()) + continue; - const auto vertexCounts = intArrayOf(topologySchema.GetCurveVertexCounts()); - if (vertexCounts.empty()) - return {}; + ArrayRef array; + if (!attribute.sharedKey.empty()) { + auto &shared = sharedArrays[attribute.sharedKey]; + if (!shared) { + shared = ctx.scene.createArray(attribute.type, attribute.count()); + shared->setData(attribute.data()); + } + array = shared; + } else { + array = ctx.scene.createArray(attribute.type, attribute.count()); + array->setData(attribute.data()); + } + geometry->setParameterObject(attribute.parameter, *array); + } - auto geometry = ctx.scene.createObject(tokens::geometry::curve); - geometry->setName(primPath.GetText()); + for (const auto &[name, value] : part.scalars) + geometry->setParameter(name, value); - const auto positions = - bakedPositions(points.value.UncheckedGet(), bakeXform); - auto positionArray = - ctx.scene.createArray(ANARI_FLOAT32_VEC3, positions.size()); - positionArray->setData(positions.data(), positions.size()); - geometry->setParameterObject("vertex.position", *positionArray); + auto found = materials.byPart.find(part.name); + auto material = + found != materials.byPart.end() ? found->second : materials.fallback; - // A curve segment index per consecutive vertex pair within each curve. - std::vector segments; - uint32_t base = 0; - for (int count : vertexCounts) { - for (int i = 0; i + 1 < count; ++i) - segments.push_back(base + uint32_t(i)); - base += uint32_t(count); + out.geometryByPart.emplace_back(part.name, geometry); + out.surfaces.push_back( + ctx.scene.createSurface(part.name.c_str(), geometry, material)); } - if (!segments.empty()) { - auto indexArray = ctx.scene.createArray(ANARI_UINT32, segments.size()); - indexArray->setData(segments.data(), segments.size()); - geometry->setParameterObject("primitive.index", *indexArray); - } - - bindRadiiFromWidths(ctx, - geometry, - readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), - positions); - - return {ctx.scene.createSurface(primPath.GetText(), - geometry, - materialForPrim(ctx, sceneIndex, primPath, prim))}; } -/////////////////////////////////////////////////////////////////////////////// -// Analytic quadrics ////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// +} // namespace -std::vector convertQuadric(ImportContext &ctx, +ConvertedGeometry convertGeometry(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, const pxr::HdSceneIndexPrim &prim, const tsd::math::mat4 &bakeXform) { - auto readDouble = [](const pxr::HdDoubleDataSourceHandle &h, double alt) { - return h ? h->GetTypedValue(0) : alt; - }; - - GeometryRef geometry; - const float3 origin = [&] { - if (bakeXform == tsd::math::IDENTITY_MAT4) - return float3(0.f); - const auto t = tsd::math::mul(bakeXform, float4(0.f, 0.f, 0.f, 1.f)); - return float3(t.x, t.y, t.z); - }(); - - if (prim.primType == pxr::HdPrimTypeTokens->sphere) { - auto schema = pxr::HdSphereSchema::GetFromParent(prim.dataSource); - const float radius = float(readDouble(schema.GetRadius(), 1.0)); - geometry = ctx.scene.createObject(tokens::geometry::sphere); - auto positions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, 1); - positions->setData(&origin, 1); - geometry->setParameterObject("vertex.position", *positions); - geometry->setParameter("radius", radius); - } else if (prim.primType == pxr::HdPrimTypeTokens->cone - || prim.primType == pxr::HdPrimTypeTokens->cylinder) { - const bool isCone = prim.primType == pxr::HdPrimTypeTokens->cone; - double height = 2.0; - double radius = 1.0; - pxr::TfToken axis = pxr::HdConeSchemaTokens->Z; - if (isCone) { - auto schema = pxr::HdConeSchema::GetFromParent(prim.dataSource); - height = readDouble(schema.GetHeight(), height); - radius = readDouble(schema.GetRadius(), radius); - if (auto a = schema.GetAxis()) - axis = a->GetTypedValue(0); - } else { - auto schema = pxr::HdCylinderSchema::GetFromParent(prim.dataSource); - height = readDouble(schema.GetHeight(), height); - radius = readDouble(schema.GetRadius(), radius); - if (auto a = schema.GetAxis()) - axis = a->GetTypedValue(0); - } - - // Fold the shape's spine axis into the emitted endpoints so that the - // shape stays analytic rather than becoming a mesh. - float3 spine(0.f, 0.f, 1.f); - if (axis == pxr::HdConeSchemaTokens->X) - spine = float3(1.f, 0.f, 0.f); - else if (axis == pxr::HdConeSchemaTokens->Y) - spine = float3(0.f, 1.f, 0.f); - - const float half = float(height) * 0.5f; - float3 endpoints[2] = {origin - spine * half, origin + spine * half}; - if (bakeXform != tsd::math::IDENTITY_MAT4) { - for (auto &e : endpoints) { - const auto t = tsd::math::mul(bakeXform, float4(e.x, e.y, e.z, 1.f)); - e = float3(t.x, t.y, t.z); - } - } - - geometry = ctx.scene.createObject( - isCone ? tokens::geometry::cone : tokens::geometry::cylinder); - auto positions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, 2); - positions->setData(endpoints, 2); - geometry->setParameterObject("vertex.position", *positions); - if (isCone) { - const float radii[2] = {float(radius), 0.f}; - auto radiusArray = ctx.scene.createArray(ANARI_FLOAT32, 2); - radiusArray->setData(radii, 2); - geometry->setParameterObject("vertex.radius", *radiusArray); - } else { - geometry->setParameter("radius", float(radius)); - } - } + ConvertedGeometry retval; - if (!geometry) - return {}; + // Materials first: which primvar a Part's material reads as texture + // coordinates decides which slot every other primvar falls into. + const auto materials = planMaterials(ctx, sceneIndex, primPath, prim); - geometry->setName(primPath.GetText()); - return {ctx.scene.createSurface(primPath.GetText(), - geometry, - materialForPrim(ctx, sceneIndex, primPath, prim))}; -} + retval.resolveOptions.bakeXform = bakeXform; + retval.resolveOptions.refinementLevel = ctx.options.refinementLevel; + retval.resolveOptions.refine = + meshWantsRefinement(ctx.stage, ctx.options.refinementLevel, primPath); + retval.resolveOptions.uvNamesByPart = materials.uvNamesByPart; -} // namespace + const auto resolved = + resolveGeometry(sceneIndex, primPath, prim, retval.resolveOptions); + if (!resolved.valid()) + return retval; -std::vector convertGeometry(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim, - const tsd::math::mat4 &bakeXform) -{ - if (prim.primType == pxr::HdPrimTypeTokens->mesh) - return convertMesh(ctx, sceneIndex, primPath, prim, bakeXform); - if (prim.primType == pxr::HdPrimTypeTokens->points) - return convertPoints(ctx, sceneIndex, primPath, prim, bakeXform); - if (prim.primType == pxr::HdPrimTypeTokens->basisCurves) - return convertCurves(ctx, sceneIndex, primPath, prim, bakeXform); - return convertQuadric(ctx, sceneIndex, primPath, prim, bakeXform); + buildParts(ctx, resolved, materials, retval); + return retval; } } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h index f229b78b4..f855eeab7 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h @@ -4,21 +4,40 @@ #pragma once #include "tsd/io/importers/detail/usd/UsdImportContext.h" +#include "tsd/io/usd/UsdResolvedGeometry.h" // usd #include // std +#include +#include #include namespace tsd::io::usd { -// True for the resolved prim types this converter turns into TSD geometry. -bool isGeometryPrimType(const pxr::TfToken &primType); +/* + * What an Import made of one gprim: the Surfaces it emitted, and everything a + * later resolve of the same prim needs in order to reproduce exactly this + * conversion. An animation binding keeps the second half so that a scrub + * re-fills these Geometries rather than building new ones (ADR 0022). + */ +struct ConvertedGeometry +{ + std::vector surfaces; + + // The Geometry each resolved Part became, by Part name. Names are the prim's + // own path and its subsets' paths, so they survive a re-resolve. + std::vector> geometryByPart; + + // Reproduces the attribute-slot assignment and baking this conversion used. + // Replaying it is what keeps a scrub from having to resolve materials again. + GeometryResolveOptions resolveOptions; +}; // Convert one resolved gprim into TSD Surfaces. A mesh carrying per-face // material subsets yields several Surfaces, one per subset, sharing the mesh's // vertex arrays. `bakeXform` is baked into the emitted vertex data and is the // identity for everything but Prototype-internal geometry (ADR 0016). -std::vector convertGeometry(ImportContext &ctx, +ConvertedGeometry convertGeometry(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, const pxr::HdSceneIndexPrim &prim, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index 3324ffbee..bc35a55e7 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -7,6 +7,7 @@ #include "tsd/core/TSDMath.hpp" #include "tsd/io/UsdImport.hpp" #include "tsd/io/importers/detail/importer_common.hpp" +#include "tsd/io/usd/UsdDataSource.h" #include "tsd/io/usd/UsdStageSession.h" #include "tsd/scene/Scene.hpp" // usd @@ -92,7 +93,6 @@ struct ImportContext // Small conversions shared by every converter ///////////////////////////////// tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m); -pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source); // Whether an attribute's time samples actually differ from one another. USD // exporters routinely re-author every attribute at every frame regardless of @@ -120,11 +120,6 @@ inline void ImportContext::reportSkip(const pxr::SdfPath &primPath, detail.c_str()); } -inline pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) -{ - return source ? source->GetTypedValue(0) : pxr::VtIntArray(); -} - inline tsd::math::mat4 toTsdMat4(const pxr::GfMatrix4d &m) { tsd::math::mat4 retval; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index 2e37492aa..300bb3dc5 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -99,7 +99,8 @@ std::shared_ptr convertPrototype(ImportContext &ctx, const auto bake = content->internalTransformsAnimated ? tsd::math::IDENTITY_MAT4 : tsd::math::mul(inverseRoot, flattenedXformOf(sceneIndex, path)); - for (auto &surface : convertGeometry(ctx, sceneIndex, path, prim, bake)) + for (auto &surface : + convertGeometry(ctx, sceneIndex, path, prim, bake).surfaces) content->surfaces.push_back(surface); if (content->internalTransformsAnimated) content->gprimPaths.push_back(path); diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp index 6eb11a501..34e85be06 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 #include "tsd/io/importers/detail/usd/UsdSubdivision.h" +// tsd_core +#include "tsd/core/TSDMath.hpp" // usd #include #include @@ -263,12 +265,14 @@ pxr::PxOsdSubdivTags readSubdivTags(const pxr::HdMeshSchema &meshSchema) } // namespace -bool meshWantsRefinement(const ImportContext &ctx, const pxr::SdfPath &primPath) +bool meshWantsRefinement(const pxr::UsdStageRefPtr &stage, + int refinementLevel, + const pxr::SdfPath &primPath) { - if (ctx.options.refinementLevel <= 0) + if (refinementLevel <= 0 || !stage) return false; - auto prim = ctx.stage->GetPrimAtPath(primPath); + auto prim = stage->GetPrimAtPath(primPath); if (!prim) return false; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h index a5ea19912..43d76cf8b 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h @@ -3,9 +3,10 @@ #pragma once -#include "tsd/io/importers/detail/usd/UsdImportContext.h" +#include "tsd/io/usd/UsdDataSource.h" // usd #include +#include #include #include #include @@ -48,9 +49,11 @@ struct RefinedMesh // True when this mesh should be refined: the Stage explicitly declares a // subdivision scheme other than "none" and the caller asked for refinement. // USD's schema default is catmullClark for every mesh, so authoring is what -// distinguishes a subdivision surface from an ordinary polygon mesh. -bool meshWantsRefinement( - const ImportContext &ctx, const pxr::SdfPath &primPath); +// distinguishes a subdivision surface from an ordinary polygon mesh. Reads the +// raw Stage prim, which is where the scheme is authored. +bool meshWantsRefinement(const pxr::UsdStageRefPtr &stage, + int refinementLevel, + const pxr::SdfPath &primPath); // Refine with OpenSubdiv, honouring subdivision tags -- creases, corners, and // holes -- carried on the resolved prim. diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 424abe5bf..067a780f8 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -197,7 +197,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, // Content // - bool converted = false; + bool convertedAnything = false; if (prim.primType.IsEmpty()) { if (!isHierarchyPrim(primPath)) { ctx.scene.removeNode(node); @@ -209,21 +209,19 @@ void Traversal::visit(const pxr::SdfPath &primPath, return; } } else if (isGeometryPrimType(prim.primType)) { - for (auto &surface : convertGeometry( - ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4)) { + auto converted = convertGeometry( + ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4); + for (auto &surface : converted.surfaces) ctx.scene.insertChildObjectNode(node, surface, surface->name().c_str()); - if (auto *geometry = surface->parameterValueAsObject( - tokens::surface::geometry)) - addDeformingGeometryAnimation(ctx, primPath, geometry->self()); - } - converted = true; + addDeformingGeometryAnimation(ctx, primPath, converted); + convertedAnything = true; } else if (prim.primType == pxr::HdPrimTypeTokens->instancer) { convertInstancer(ctx, sceneIndex, primPath, prim, node, instancers); - converted = true; + convertedAnything = true; } else if (isLightPrimType(prim.primType)) { if (auto light = convertLight(ctx, primPath, prim)) { ctx.scene.insertChildObjectNode(node, light, primPath.GetName().c_str()); - converted = true; + convertedAnything = true; } else { ctx.scene.removeNode(node); insertPlaceholder( @@ -232,7 +230,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, } } else if (prim.primType == pxr::HdPrimTypeTokens->camera) { convertCamera(ctx, primPath); - converted = true; + convertedAnything = true; } else if (prim.primType == pxr::HdPrimTypeTokens->material || prim.primType == pxr::HdPrimTypeTokens->geomSubset) { // Materials convert on demand from the prims that bind them; geom subsets @@ -240,8 +238,8 @@ void Traversal::visit(const pxr::SdfPath &primPath, ctx.scene.removeNode(node); return; } else if (isVolumePrimType(prim.primType)) { - converted = convertVolume(ctx, primPath, node); - if (!converted) { + convertedAnything = convertVolume(ctx, primPath, node); + if (!convertedAnything) { ctx.scene.removeNode(node); insertPlaceholder( ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); @@ -257,7 +255,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, return; } - if (converted) + if (convertedAnything) ctx.report.convertedPrims++; instancers.nodeForPrimPath[primPath.GetString()] = node; diff --git a/tsd/src/tsd/io/usd/UsdDataSource.h b/tsd/src/tsd/io/usd/UsdDataSource.h new file mode 100644 index 000000000..5aaae5c4d --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdDataSource.h @@ -0,0 +1,20 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// usd +#include +#include + +namespace tsd::io::usd { + +// Read an int-array data source, or an empty array when it is absent. Every +// Hydra schema hands topology out this way, and every reader of one wants the +// same "absent is empty" answer. +inline pxr::VtIntArray intArrayOf(const pxr::HdIntArrayDataSourceHandle &source) +{ + return source ? source->GetTypedValue(0) : pxr::VtIntArray(); +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h b/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h new file mode 100644 index 000000000..ee402ece9 --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h @@ -0,0 +1,38 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// tsd_core +#include "tsd/core/TSDMath.hpp" +// std +#include +#include + +namespace tsd::io::usd { + +/* + * What resolving a gprim needs that it cannot read off the resolved prim -- + * which is to say, everything an Import decided that does not change over + * time. An animation binding carries one of these and replays it, so a scrub + * reproduces the Import's conversion instead of guessing at it again. + * + * `uvNamesByPart` replays the attribute-slot assignment: which primvar a Part's + * material reads as texture coordinates decides which slot every other primvar + * falls into, and a scrub must not re-resolve materials to find that out. An + * absent entry means the conventional `st`. + * + * Deliberately free of OpenUSD types, so the animation bindings that carry one + * still declare themselves in builds without USD. + */ +struct GeometryResolveOptions +{ + // Baked into the emitted vertex data; identity for everything but + // Prototype-internal geometry (ADR 0016). + tsd::math::mat4 bakeXform{tsd::math::IDENTITY_MAT4}; + bool refine{false}; + int refinementLevel{2}; + std::map uvNamesByPart; +}; + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp b/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp new file mode 100644 index 000000000..350ef9f2b --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp @@ -0,0 +1,923 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/usd/UsdResolvedGeometry.h" +#include "tsd/io/importers/detail/usd/UsdSubdivision.h" +#include "tsd/scene/objects/Array.hpp" +// usd +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +/////////////////////////////////////////////////////////////////////////////// +// Primvar plumbing /////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +// One primvar, already flattened out of any indexing, with the interpolation +// that decides which TSD attribute slot it lands in. +struct Primvar +{ + pxr::VtValue value; + pxr::TfToken interpolation; + pxr::TfToken role; + + bool valid() const; +}; + +bool Primvar::valid() const +{ + return !value.IsEmpty() && value.IsArrayValued() && value.GetArraySize() > 0; +} + +Primvar readPrimvar( + const pxr::HdPrimvarsSchema &primvars, const pxr::TfToken &name) +{ + Primvar retval; + auto primvar = primvars.GetPrimvar(name); + if (!primvar) + return retval; + if (auto value = primvar.GetFlattenedPrimvarValue()) + retval.value = value->GetValue(0); + if (auto interpolation = primvar.GetInterpolation()) + retval.interpolation = interpolation->GetTypedValue(0); + if (auto role = primvar.GetRole()) + retval.role = role->GetTypedValue(0); + return retval; +} + +// Which TSD parameter prefix an interpolation maps onto. Constant primvars +// have no per-element ANARI slot and are handled by the caller where they +// carry meaning (display colour), otherwise dropped. +const char *prefixForInterpolation(const pxr::TfToken &interpolation) +{ + if (interpolation == pxr::HdPrimvarSchemaTokens->uniform) + return "primitive."; + if (interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) + return "faceVarying."; + if (interpolation == pxr::HdPrimvarSchemaTokens->vertex + || interpolation == pxr::HdPrimvarSchemaTokens->varying) + return "vertex."; + return nullptr; +} + +anari::DataType anariTypeOfPrimvar(const pxr::VtValue &value) +{ + if (value.IsHolding()) + return ANARI_FLOAT32; + if (value.IsHolding()) + return ANARI_FLOAT32_VEC2; + if (value.IsHolding()) + return ANARI_FLOAT32_VEC3; + if (value.IsHolding()) + return ANARI_FLOAT32_VEC4; + return ANARI_UNKNOWN; +} + +/////////////////////////////////////////////////////////////////////////////// +// Shared resolution helpers ////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +// Prototype-internal transforms are baked into vertex data (ADR 0016); +// everything else passes through untouched. +pxr::VtVec3fArray bakedPositions( + const pxr::VtVec3fArray &source, const tsd::math::mat4 &bakeXform) +{ + if (bakeXform == tsd::math::IDENTITY_MAT4) + return source; + + pxr::VtVec3fArray retval; + retval.reserve(source.size()); + for (const auto &p : source) { + const auto t = tsd::math::mul(bakeXform, float4(p[0], p[1], p[2], 1.f)); + retval.push_back(pxr::GfVec3f(t.x, t.y, t.z)); + } + return retval; +} + +void addAttribute(ResolvedPart &part, + Token parameter, + pxr::VtValue value, + std::string sharedKey = {}) +{ + const auto type = anariTypeOfPrimvar(value); + if (type == ANARI_UNKNOWN || !value.IsArrayValued() + || value.GetArraySize() == 0) + return; + part.attributes.push_back( + {parameter, type, std::move(value), std::move(sharedKey)}); +} + +// USD authors widths; TSD geometry takes radii. Prims with no authored +// widths (common for Blender hair exports) would otherwise inherit ANARI's +// default radius of 1 world unit, which dwarfs most scenes -- instead fall +// back to a small radius scaled to the prim's own bounds so strands stay +// hair-like at any scene scale. +void resolveRadii(ResolvedPart &part, + const Primvar &widths, + const pxr::VtVec3fArray &positions) +{ + const bool haveWidths = widths.valid() + && widths.value.IsHolding() + && !widths.value.UncheckedGet().empty(); + + if (!haveWidths) { + float3 lo(std::numeric_limits::max()); + float3 hi(std::numeric_limits::lowest()); + for (const auto &p : positions) { + const float3 v(p[0], p[1], p[2]); + lo = tsd::math::min(lo, v); + hi = tsd::math::max(hi, v); + } + const float diagonal = positions.empty() ? 0.f : tsd::math::length(hi - lo); + part.scalars.emplace_back( + Token("radius"), diagonal > 0.f ? 1e-3f * diagonal : 1e-3f); + return; + } + + const auto &w = widths.value.UncheckedGet(); + pxr::VtFloatArray radii; + radii.reserve(positions.size()); + for (size_t i = 0; i < positions.size(); ++i) + radii.push_back(0.5f * w[std::min(i, w.size() - 1)]); + addAttribute(part, Token("vertex.radius"), pxr::VtValue(radii)); +} + +/////////////////////////////////////////////////////////////////////////////// +// Mesh resolution //////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +// Apply a transform to the float-typed array a primvar holds, whatever its +// component count. Anything else has no ANARI attribute slot and so yields an +// empty value. +template +pxr::VtValue transformFloatArray(const pxr::VtValue &value, Fn &&fn) +{ + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + if (value.IsHolding()) + return pxr::VtValue(fn(value.UncheckedGet())); + return {}; +} + +// Expand a uniform (per-face) primvar to per-triangle values using the +// triangulation's record of which coarse face each triangle came from. +template +pxr::VtArray expandUniform( + const pxr::VtArray &source, const pxr::VtIntArray &primitiveParams) +{ + pxr::VtArray retval; + retval.reserve(primitiveParams.size()); + for (int param : primitiveParams) { + const int face = pxr::HdMeshUtil::DecodeFaceIndexFromCoarseFaceParam(param); + retval.push_back(source[size_t(face) < source.size() ? size_t(face) + : source.size() - 1]); + } + return retval; +} + +pxr::VtValue expandUniformValue( + const pxr::VtValue &value, const pxr::VtIntArray &primitiveParams) +{ + return transformFloatArray(value, [&](const auto &source) { + return expandUniform(source, primitiveParams); + }); +} + +// Select the values belonging to a chosen set of triangles out of an array +// laid out in triangle order -- one value per triangle for per-primitive data, +// three for per-corner data. +template +pxr::VtArray gatherTriangles(const pxr::VtArray &source, + const std::vector &triangles, + size_t valuesPerTriangle) +{ + pxr::VtArray retval; + retval.reserve(triangles.size() * valuesPerTriangle); + for (uint32_t triangle : triangles) { + const size_t base = size_t(triangle) * valuesPerTriangle; + for (size_t i = 0; i < valuesPerTriangle; ++i) + retval.push_back(source[base + i]); + } + return retval; +} + +pxr::VtValue gatherTrianglesValue(const pxr::VtValue &value, + const std::vector &triangles, + size_t valuesPerTriangle) +{ + return transformFloatArray(value, [&](const auto &source) { + return gatherTriangles(source, triangles, valuesPerTriangle); + }); +} + +// Reverse texture coordinates. USD authors `st` v-up, while the coordinates +// TSD hands ANARI run down the image. See +// docs/adr/0014-store-images-in-anari-orientation.md. +pxr::VtValue reversedTexCoordV(const pxr::VtValue &value) +{ + if (!value.IsHolding()) + return value; + auto uv = value.UncheckedGet(); + for (auto &c : uv) + c[1] = 1.f - c[1]; + return pxr::VtValue(uv); +} + +// The triangulated mesh, before any Part selects from it. +struct TriangulatedMesh +{ + pxr::VtVec3fArray positions; + pxr::VtVec3iArray triangleIndices; + pxr::VtIntArray primitiveParams; +}; + +// A primvar expanded onto the triangulated topology and ready to bind. Vertex +// data stays as authored and is indexed by the triangle indices, so it is +// shared by every Part built from the mesh; uniform and face-varying data are +// laid out in triangle order and have to be gathered per Part, because a +// subset draws only some of the triangles. +struct TriangulatedPrimvar +{ + pxr::VtValue value; + const char *prefix{nullptr}; + size_t valuesPerTriangle{0}; + + // Vertex data is the only kind every Part can point at unchanged. + bool isShared() const { return valuesPerTriangle == 0; } +}; + +// Kept sorted by name: the order primvars are visited decides which of them +// takes each spare attribute slot, and that has to be stable across runs. This +// is why the mesh resolver reaches for std::map rather than FlatMap. +using TriangulatedPrimvars = std::map; + +// Expand every primvar that has an attribute slot onto the triangulated +// topology, once, so that each Part only has to select the values for its own +// triangles. Primvars whose expansion fails or comes up short of the +// triangulation are left out rather than bound partially. +TriangulatedPrimvars triangulatePrimvars(const pxr::HdMeshUtil &meshUtil, + const TriangulatedMesh &mesh, + const std::map &primvars) +{ + TriangulatedPrimvars retval; + for (const auto &[name, primvar] : primvars) { + if (name == pxr::HdPrimvarsSchemaTokens->points.GetString() + || name == pxr::HdTokens->displayOpacity.GetString()) + continue; + + TriangulatedPrimvar attribute; + attribute.prefix = prefixForInterpolation(primvar.interpolation); + if (!attribute.prefix || anariTypeOfPrimvar(primvar.value) == ANARI_UNKNOWN) + continue; + + if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) { + attribute.value = expandUniformValue(primvar.value, mesh.primitiveParams); + attribute.valuesPerTriangle = 1; + } else if (primvar.interpolation + == pxr::HdPrimvarSchemaTokens->faceVarying) { + const auto result = meshUtil.ComputeTriangulatedFaceVaryingPrimvar( + pxr::HdGetValueData(primvar.value), + int(primvar.value.GetArraySize()), + pxr::HdGetValueTupleType(primvar.value).type, + &attribute.value); + // Unchanged means the mesh is already all triangles: the flattened + // input is already one value per triangle corner, in triangle order. + if (result == pxr::HdMeshComputationResult::Unchanged) + attribute.value = primvar.value; + else if (result != pxr::HdMeshComputationResult::Success) + continue; + attribute.valuesPerTriangle = 3; + } else { + attribute.value = primvar.value; + } + + // Whatever will be gathered has to cover the whole triangulation, since + // any subset may ask for any triangle. Vertex data is bound as authored + // and indexed by the triangle indices, so there is nothing to check here. + if (!attribute.value.IsArrayValued() || attribute.value.GetArraySize() == 0 + || attribute.value.GetArraySize() + < mesh.triangleIndices.size() * attribute.valuesPerTriangle) + continue; + + retval.emplace(name, std::move(attribute)); + } + return retval; +} + +// Resolve one primvar into one Part's attribute slot. `isUv` marks the one +// primvar this Part's material reads as texture coordinates, which is the only +// binding whose `v` is reversed -- the same primvar in a spare slot elsewhere +// is data TSD knows nothing about, and is resolved as authored. +void resolvePartPrimvar(ResolvedPart &part, + const std::string &primvarName, + const TriangulatedPrimvar &primvar, + const std::vector &triangles, + const std::string &tsdName, + bool isUv) +{ + const Token parameter((primvar.prefix + tsdName).c_str()); + + if (primvar.isShared()) { + auto value = isUv ? reversedTexCoordV(primvar.value) : primvar.value; + addAttribute(part, + parameter, + std::move(value), + primvarName + (isUv ? "#uv" : "#raw")); + return; + } + + auto selected = gatherTrianglesValue( + primvar.value, triangles, primvar.valuesPerTriangle); + if (isUv) + selected = reversedTexCoordV(selected); + addAttribute(part, parameter, std::move(selected)); +} + +// One Part's worth of the mesh: the triangles it draws, with every primvar +// re-indexed to match. `uvName` is whichever primvar this Part's own material +// reads, which is why the attribute slots cannot be assigned once for the +// whole mesh. +ResolvedPart resolveTrianglePart(const TriangulatedMesh &mesh, + const TriangulatedPrimvars &attributes, + const std::vector &triangles, + const std::string &uvName, + const std::string &name) +{ + ResolvedPart part; + part.subtype = tsd::scene::tokens::geometry::triangle; + part.name = name; + + addAttribute(part, + Token("vertex.position"), + pxr::VtValue(mesh.positions), + "points"); + + pxr::VtVec3iArray indices; + indices.reserve(triangles.size()); + for (uint32_t triangle : triangles) + indices.push_back(mesh.triangleIndices[triangle]); + part.attributes.push_back({Token("primitive.index"), + ANARI_UINT32_VEC3, + pxr::VtValue(indices), + {}}); + + auto bind = [&](const std::string &primvarName, + const std::string &tsdName, + bool isUv = false) { + auto found = attributes.find(primvarName); + if (found != attributes.end()) { + resolvePartPrimvar( + part, primvarName, found->second, triangles, tsdName, isUv); + } + }; + + // Normals, UVs, display colour, then any remaining primvars in name order so + // the attribute assignment is deterministic. + const auto normalsName = pxr::HdPrimvarsSchemaTokens->normals.GetString(); + const auto colorName = pxr::HdTokens->displayColor.GetString(); + bind(normalsName, "normal"); + bind(uvName, "attribute0", /*isUv=*/true); + bind(colorName, "color"); + + int nextAttribute = 1; + for (const auto &[primvarName, primvar] : attributes) { + if (nextAttribute > 3) + break; + if (primvarName == normalsName || primvarName == colorName + || primvarName == uvName) + continue; + resolvePartPrimvar(part, + primvarName, + primvar, + triangles, + "attribute" + std::to_string(nextAttribute++), + false); + } + + return part; +} + +// Append the triangles one coarse face produced to a Part's selection. +void appendTrianglesOfFace(std::vector &selection, + const std::vector> &trianglesOfFace, + size_t face) +{ + const auto &triangles = trianglesOfFace[face]; + selection.insert(selection.end(), triangles.begin(), triangles.end()); +} + +std::string uvNameFor(const GeometryResolveOptions &options, + const std::string &partName, + const std::string &fallback) +{ + auto found = options.uvNamesByPart.find(partName); + return found == options.uvNamesByPart.end() ? fallback : found->second; +} + +ResolvedGeometry resolveMesh(const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const GeometryResolveOptions &options) +{ + ResolvedGeometry retval; + + auto meshSchema = pxr::HdMeshSchema::GetFromParent(prim.dataSource); + auto topologySchema = meshSchema.GetTopology(); + auto primvarsSchema = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + + // Resolve every primvar up front so that refinement can replace the + // vertex-interpolated ones in place. + std::map primvars; + for (const auto &name : primvarsSchema.GetPrimvarNames()) { + auto primvar = readPrimvar(primvarsSchema, name); + if (primvar.valid()) + primvars.emplace(name.GetString(), std::move(primvar)); + } + + auto pointsIt = primvars.find(pxr::HdPrimvarsSchemaTokens->points.GetString()); + if (pointsIt == primvars.end() + || !pointsIt->second.value.IsHolding()) + return retval; + auto points = pointsIt->second; + + auto faceVertexCounts = intArrayOf(topologySchema.GetFaceVertexCounts()); + auto faceVertexIndices = intArrayOf(topologySchema.GetFaceVertexIndices()); + auto holeIndices = intArrayOf(topologySchema.GetHoleIndices()); + if (faceVertexCounts.empty() || faceVertexIndices.empty()) + return retval; + + auto orientationSource = topologySchema.GetOrientation(); + const auto orientation = orientationSource + ? orientationSource->GetTypedValue(0) + : pxr::HdTokens->rightHanded; + + if (options.refine) { + MeshPrimvars sourcePrimvars; + for (const auto &[name, primvar] : primvars) { + if (name == pxr::HdPrimvarsSchemaTokens->points.GetString()) + continue; + if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->vertex + || primvar.interpolation == pxr::HdPrimvarSchemaTokens->varying) + sourcePrimvars.vertex.emplace_back(name, primvar.value); + else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->faceVarying) + sourcePrimvars.faceVarying.emplace_back(name, primvar.value); + else if (primvar.interpolation == pxr::HdPrimvarSchemaTokens->uniform) + sourcePrimvars.uniform.emplace_back(name, primvar.value); + } + + auto refined = refineMesh(meshSchema, + faceVertexCounts, + faceVertexIndices, + holeIndices, + orientation, + points.value.UncheckedGet(), + sourcePrimvars, + options.refinementLevel); + + if (refined.valid) { + faceVertexCounts = refined.faceVertexCounts; + faceVertexIndices = refined.faceVertexIndices; + holeIndices = refined.holeIndices; + points.value = pxr::VtValue(refined.points); + auto writeBack = [&](const std::vector &group) { + for (const auto &[name, value] : group) + primvars[name].value = value; + }; + writeBack(refined.primvars.vertex); + writeBack(refined.primvars.faceVarying); + writeBack(refined.primvars.uniform); + } + } + + pxr::HdMeshTopology topology(pxr::PxOsdOpenSubdivTokens->none, + orientation, + faceVertexCounts, + faceVertexIndices, + holeIndices); + + // OpenUSD's own topology-aware triangulation handles non-convex polygons + // and holes; a hand-rolled fan does not. + pxr::HdMeshUtil meshUtil(&topology, primPath); + TriangulatedMesh mesh; + meshUtil.ComputeTriangleIndices(&mesh.triangleIndices, &mesh.primitiveParams); + if (mesh.triangleIndices.empty()) + return retval; + + mesh.positions = bakedPositions( + points.value.UncheckedGet(), options.bakeXform); + + const auto attributes = triangulatePrimvars(meshUtil, mesh, primvars); + + const std::string meshName = primPath.GetString(); + const std::string meshUvName = uvNameFor(options, meshName, "st"); + + std::vector allTriangles(mesh.triangleIndices.size()); + std::iota(allTriangles.begin(), allTriangles.end(), 0u); + + // Per-face material subsets each become their own Part over their own + // triangles, sharing this mesh's vertex data. + std::vector subsetPaths; + for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) { + if (sceneIndex->GetPrim(childPath).primType + == pxr::HdPrimTypeTokens->geomSubset) + subsetPaths.push_back(childPath); + } + + if (subsetPaths.empty()) { + retval.parts.push_back(resolveTrianglePart( + mesh, attributes, allTriangles, meshUvName, meshName)); + return retval; + } + + // Map each coarse face to the triangles it produced, once, so every subset + // can select its own triangles cheaply. + std::vector> trianglesOfFace; + for (size_t i = 0; i < mesh.primitiveParams.size(); ++i) { + const int face = pxr::HdMeshUtil::DecodeFaceIndexFromCoarseFaceParam( + mesh.primitiveParams[i]); + if (face < 0) + continue; + if (trianglesOfFace.size() <= size_t(face)) + trianglesOfFace.resize(size_t(face) + 1); + trianglesOfFace[size_t(face)].push_back(uint32_t(i)); + } + + std::vector faceIsClaimed(trianglesOfFace.size(), false); + + for (const auto &subsetPath : subsetPaths) { + auto subsetPrim = sceneIndex->GetPrim(subsetPath); + auto subsetSchema = + pxr::HdGeomSubsetSchema::GetFromParent(subsetPrim.dataSource); + const auto faceIndices = intArrayOf(subsetSchema.GetIndices()); + if (faceIndices.empty()) + continue; + + std::vector subsetTriangles; + for (int face : faceIndices) { + if (face < 0 || size_t(face) >= trianglesOfFace.size()) + continue; + faceIsClaimed[size_t(face)] = true; + appendTrianglesOfFace(subsetTriangles, trianglesOfFace, size_t(face)); + } + if (subsetTriangles.empty()) + continue; + + // A subset's material may read a different UV primvar than the mesh's + // does, so its attributes are resolved to suit it. + const std::string subsetName = subsetPath.GetString(); + retval.parts.push_back(resolveTrianglePart(mesh, + attributes, + subsetTriangles, + uvNameFor(options, subsetName, meshUvName), + subsetName)); + } + + // Faces no subset claimed keep the mesh's own binding rather than going + // missing with the geometry that no Part would have drawn. + std::vector unclaimedTriangles; + for (size_t face = 0; face < trianglesOfFace.size(); ++face) { + if (!faceIsClaimed[face]) + appendTrianglesOfFace(unclaimedTriangles, trianglesOfFace, face); + } + + // No subset drew anything -- with nothing to divide the mesh up, draw all of + // it, including any triangle whose coarse face could not be identified. + if (retval.parts.empty()) + unclaimedTriangles = allTriangles; + + if (!unclaimedTriangles.empty()) { + retval.parts.push_back(resolveTrianglePart( + mesh, attributes, unclaimedTriangles, meshUvName, meshName)); + } + + return retval; +} + +/////////////////////////////////////////////////////////////////////////////// +// Points and curves ////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +ResolvedGeometry resolvePoints(const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const GeometryResolveOptions &options) +{ + ResolvedGeometry retval; + + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + const auto points = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); + if (!points.valid() || !points.value.IsHolding()) + return retval; + + ResolvedPart part; + part.subtype = tsd::scene::tokens::geometry::sphere; + part.name = primPath.GetString(); + + const auto positions = bakedPositions( + points.value.UncheckedGet(), options.bakeXform); + addAttribute(part, Token("vertex.position"), pxr::VtValue(positions)); + resolveRadii(part, + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), + positions); + + retval.parts.push_back(std::move(part)); + return retval; +} + +ResolvedGeometry resolveCurves(const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const GeometryResolveOptions &options) +{ + ResolvedGeometry retval; + + auto curvesSchema = pxr::HdBasisCurvesSchema::GetFromParent(prim.dataSource); + auto topologySchema = curvesSchema.GetTopology(); + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + + const auto points = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); + if (!points.valid() || !points.value.IsHolding()) + return retval; + + const auto vertexCounts = intArrayOf(topologySchema.GetCurveVertexCounts()); + if (vertexCounts.empty()) + return retval; + + ResolvedPart part; + part.subtype = tsd::scene::tokens::geometry::curve; + part.name = primPath.GetString(); + + const auto positions = bakedPositions( + points.value.UncheckedGet(), options.bakeXform); + addAttribute(part, Token("vertex.position"), pxr::VtValue(positions)); + + // A curve segment index per consecutive vertex pair within each curve. + pxr::VtUIntArray segments; + uint32_t base = 0; + for (int count : vertexCounts) { + for (int i = 0; i + 1 < count; ++i) + segments.push_back(base + uint32_t(i)); + base += uint32_t(count); + } + if (!segments.empty()) { + part.attributes.push_back({Token("primitive.index"), + ANARI_UINT32, + pxr::VtValue(segments), + {}}); + } + + resolveRadii(part, + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->widths), + positions); + + retval.parts.push_back(std::move(part)); + return retval; +} + +/////////////////////////////////////////////////////////////////////////////// +// Analytic quadrics ////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +ResolvedGeometry resolveQuadric(const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const GeometryResolveOptions &options) +{ + ResolvedGeometry retval; + + auto readDouble = [](const pxr::HdDoubleDataSourceHandle &h, double alt) { + return h ? h->GetTypedValue(0) : alt; + }; + + const auto &bakeXform = options.bakeXform; + const float3 origin = [&] { + if (bakeXform == tsd::math::IDENTITY_MAT4) + return float3(0.f); + const auto t = tsd::math::mul(bakeXform, float4(0.f, 0.f, 0.f, 1.f)); + return float3(t.x, t.y, t.z); + }(); + + ResolvedPart part; + part.name = primPath.GetString(); + + if (prim.primType == pxr::HdPrimTypeTokens->sphere) { + auto schema = pxr::HdSphereSchema::GetFromParent(prim.dataSource); + part.subtype = tsd::scene::tokens::geometry::sphere; + pxr::VtVec3fArray positions{pxr::GfVec3f(origin.x, origin.y, origin.z)}; + addAttribute(part, Token("vertex.position"), pxr::VtValue(positions)); + part.scalars.emplace_back( + Token("radius"), float(readDouble(schema.GetRadius(), 1.0))); + } else if (prim.primType == pxr::HdPrimTypeTokens->cone + || prim.primType == pxr::HdPrimTypeTokens->cylinder) { + const bool isCone = prim.primType == pxr::HdPrimTypeTokens->cone; + double height = 2.0; + double radius = 1.0; + pxr::TfToken axis = pxr::HdConeSchemaTokens->Z; + if (isCone) { + auto schema = pxr::HdConeSchema::GetFromParent(prim.dataSource); + height = readDouble(schema.GetHeight(), height); + radius = readDouble(schema.GetRadius(), radius); + if (auto a = schema.GetAxis()) + axis = a->GetTypedValue(0); + } else { + auto schema = pxr::HdCylinderSchema::GetFromParent(prim.dataSource); + height = readDouble(schema.GetHeight(), height); + radius = readDouble(schema.GetRadius(), radius); + if (auto a = schema.GetAxis()) + axis = a->GetTypedValue(0); + } + + // Fold the shape's spine axis into the emitted endpoints so that the + // shape stays analytic rather than becoming a mesh. + float3 spine(0.f, 0.f, 1.f); + if (axis == pxr::HdConeSchemaTokens->X) + spine = float3(1.f, 0.f, 0.f); + else if (axis == pxr::HdConeSchemaTokens->Y) + spine = float3(0.f, 1.f, 0.f); + + const float half = float(height) * 0.5f; + float3 endpoints[2] = {origin - spine * half, origin + spine * half}; + if (bakeXform != tsd::math::IDENTITY_MAT4) { + for (auto &e : endpoints) { + const auto t = tsd::math::mul(bakeXform, float4(e.x, e.y, e.z, 1.f)); + e = float3(t.x, t.y, t.z); + } + } + + part.subtype = isCone ? tsd::scene::tokens::geometry::cone + : tsd::scene::tokens::geometry::cylinder; + pxr::VtVec3fArray positions; + for (const auto &e : endpoints) + positions.push_back(pxr::GfVec3f(e.x, e.y, e.z)); + addAttribute(part, Token("vertex.position"), pxr::VtValue(positions)); + + if (isCone) { + pxr::VtFloatArray radii{float(radius), 0.f}; + addAttribute(part, Token("vertex.radius"), pxr::VtValue(radii)); + } else { + part.scalars.emplace_back(Token("radius"), float(radius)); + } + } else { + return retval; + } + + retval.parts.push_back(std::move(part)); + return retval; +} + +} // namespace + +/////////////////////////////////////////////////////////////////////////////// +// Plain-data accessors /////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +size_t ResolvedAttribute::count() const +{ + return value.IsArrayValued() ? value.GetArraySize() : 0; +} + +const void *ResolvedAttribute::data() const +{ + return pxr::HdGetValueData(value); +} + +bool ResolvedAttribute::valid() const +{ + return type != ANARI_UNKNOWN && count() > 0 && data() != nullptr; +} + +const ResolvedAttribute *ResolvedPart::attribute(Token parameter) const +{ + for (const auto &a : attributes) { + if (a.parameter == parameter) + return &a; + } + return nullptr; +} + +bool ResolvedGeometry::valid() const +{ + return !parts.empty(); +} + +const ResolvedPart *ResolvedGeometry::part(const std::string &name) const +{ + for (const auto &p : parts) { + if (p.name == name) + return &p; + } + return nullptr; +} + +/////////////////////////////////////////////////////////////////////////////// +// Entry points /////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +bool isGeometryPrimType(const pxr::TfToken &primType) +{ + return primType == pxr::HdPrimTypeTokens->mesh + || primType == pxr::HdPrimTypeTokens->points + || primType == pxr::HdPrimTypeTokens->basisCurves + || primType == pxr::HdPrimTypeTokens->sphere + || primType == pxr::HdPrimTypeTokens->cone + || primType == pxr::HdPrimTypeTokens->cylinder; +} + +DisplayColor readDisplayColor(const pxr::HdSceneIndexPrim &prim) +{ + DisplayColor retval; + + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + const auto color = readPrimvar(primvars, pxr::HdTokens->displayColor); + const auto opacity = readPrimvar(primvars, pxr::HdTokens->displayOpacity); + + if (color.valid() && color.value.IsHolding()) { + const auto &c = color.value.UncheckedGet(); + retval.hasColor = true; + retval.color = float3(c[0][0], c[0][1], c[0][2]); + } + if (opacity.valid() && opacity.value.IsHolding()) { + retval.hasOpacity = true; + retval.opacity = opacity.value.UncheckedGet()[0]; + } + return retval; +} + +ResolvedGeometry resolveGeometry( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const GeometryResolveOptions &options) +{ + if (prim.primType == pxr::HdPrimTypeTokens->mesh) + return resolveMesh(sceneIndex, primPath, prim, options); + if (prim.primType == pxr::HdPrimTypeTokens->points) + return resolvePoints(primPath, prim, options); + if (prim.primType == pxr::HdPrimTypeTokens->basisCurves) + return resolveCurves(primPath, prim, options); + return resolveQuadric(primPath, prim, options); +} + +bool refillGeometry(scene::Scene &scene, + scene::Geometry &geometry, + const ResolvedPart &part) +{ + if (geometry.subtype() != part.subtype) + return false; + + for (const auto &attribute : part.attributes) { + if (!attribute.valid()) + continue; + + auto *array = + geometry.parameterValueAsObject(attribute.parameter); + if (array && array->size() == attribute.count() + && array->elementType() == attribute.type) { + array->setData(attribute.data()); + continue; + } + + // A TSD Array's size is fixed at construction, so an element count that + // moves costs one allocation and one rebind for that parameter. Every + // parameter of the Part is written in this one pass, so the Geometry is + // never left half in one frame and half in another. + auto replacement = + scene.createArray(attribute.type, attribute.count()); + replacement->setData(attribute.data()); + if (array) + replacement->setName(array->name().c_str()); + geometry.setParameterObject(attribute.parameter, *replacement); + } + + for (const auto &[name, value] : part.scalars) + geometry.setParameter(name, value); + + return true; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/usd/UsdResolvedGeometry.h b/tsd/src/tsd/io/usd/UsdResolvedGeometry.h new file mode 100644 index 000000000..55bf1bf7f --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdResolvedGeometry.h @@ -0,0 +1,116 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// tsd_core +#include "tsd/core/TSDMath.hpp" +#include "tsd/core/Token.hpp" +// tsd_io +#include "tsd/io/usd/UsdGeometryResolveOptions.h" +// tsd_scene +#include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Geometry.hpp" +// usd +#include +#include +#include +// std +#include +#include +#include + +namespace tsd::io::usd { + +/* + * The plain-data half of geometry conversion. + * + * Resolving a gprim -- reading its topology and primvars, refining it, + * triangulating it, expanding and gathering its attributes -- is the part that + * changes over time. Turning the result into Surfaces, Materials and Arrays is + * the part that does not. Separating them is what lets an animation binding + * re-pull a mesh's points, indices and primvars as one consistent set without + * re-creating the objects around them (ADR 0022). + * + * Nothing here touches a Scene: a ResolvedGeometry is inert data that can be + * produced at any Time Code and then either built into new objects or written + * over existing ones. + */ + +// One geometry parameter's worth of resolved data, already expanded, gathered +// and oriented -- whatever lands here binds as authored. +struct ResolvedAttribute +{ + tsd::core::Token parameter; + anari::DataType type{ANARI_UNKNOWN}; + pxr::VtValue value; + + // Non-empty when this exact buffer also appears on other Parts, which is how + // every Surface of a subdivided mesh ends up pointing at one position Array + // rather than a copy each. Keyed on what produced the data, not on the + // parameter it lands in: two Parts can bind one primvar to different slots. + std::string sharedKey; + + size_t count() const; + const void *data() const; + bool valid() const; +}; + +// One emitted Surface's geometry. A mesh carrying per-face material subsets +// resolves to several Parts sharing the mesh's vertex data. +struct ResolvedPart +{ + tsd::core::Token subtype; + std::string name; + std::vector attributes; + std::vector> scalars; + + const ResolvedAttribute *attribute(tsd::core::Token parameter) const; +}; + +struct ResolvedGeometry +{ + std::vector parts; + + bool valid() const; + const ResolvedPart *part(const std::string &name) const; +}; + +// Resolve one gprim at whatever Time Code the scene index is currently set to. +ResolvedGeometry resolveGeometry( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const GeometryResolveOptions &options); + +// True for the resolved prim types resolveGeometry() handles. +bool isGeometryPrimType(const pxr::TfToken &primType); + +// The constant display colour and opacity a prim carries, which is what an +// unmaterialed prim is shaded with so it looks as it does in a reference +// viewer rather than taking TSD's default. +struct DisplayColor +{ + bool hasColor{false}; + tsd::math::float3 color{1.f}; + bool hasOpacity{false}; + float opacity{1.f}; +}; + +DisplayColor readDisplayColor(const pxr::HdSceneIndexPrim &prim); + +/* + * Write a resolved Part over an existing Geometry, reusing every Array whose + * size and element type still fit and allocating only the ones that do not. + * This is the whole point of the split: points, indices and primvars arrive + * together, so the Geometry is never left describing half of one frame and + * half of another. + * + * Returns false when the Part cannot be applied to this Geometry at all -- + * a different subtype, which means the prim changed shape rather than moved. + */ +bool refillGeometry(scene::Scene &scene, + scene::Geometry &geometry, + const ResolvedPart &part); + +} // namespace tsd::io::usd diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index d7fc3388c..99fd25d88 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -3009,4 +3009,102 @@ def Mesh "Blob" } } +SCENARIO("A mesh whose topology changes re-pulls a consistent set", + "[UsdImport]") +{ + GIVEN("A mesh whose points, indices and primvars all change together") + { + // The case a binding that only re-pulls points cannot serve: writing new + // positions without new indices would describe a mesh that never existed. + StageFixture stage("tsd_test_usd_morphing_mesh.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Morphing" +{ + int[] faceVertexCounts.timeSamples = { + 0: [3], + 2: [3, 3], + } + int[] faceVertexIndices.timeSamples = { + 0: [0, 1, 2], + 2: [0, 1, 2, 0, 2, 3], + } + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 2: [(0, 0, 0), (1, 0, 0), (0, 1, 0), (-1, 1, 0)], + } + color3f[] primvars:displayColor ( + interpolation = "vertex" + ) + color3f[] primvars:displayColor.timeSamples = { + 0: [(1, 0, 0), (0, 1, 0), (0, 0, 1)], + 2: [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + auto geometry = scene.getObject(0); + REQUIRE(geometry); + + auto arraySize = [&](const char *parameter) -> size_t { + auto *array = + geometry->parameterValueAsObject(parameter); + return array ? array->size() : 0; + }; + + THEN("The first frame is one triangle over three vertices") + { + REQUIRE(arraySize("vertex.position") == 3); + REQUIRE(arraySize("primitive.index") == 1); + REQUIRE(arraySize("vertex.color") == 3); + } + + THEN("Scrubbing re-pulls points, indices and primvars together") + { + animMgr.setAnimationTime(1.0f); + + REQUIRE(arraySize("vertex.position") == 4); + REQUIRE(arraySize("primitive.index") == 2); + REQUIRE(arraySize("vertex.color") == 4); + + // Every index has to address the positions that arrived with it. + auto *indices = + geometry->parameterValueAsObject( + "primitive.index"); + const auto *triangles = indices->dataAs(); + for (size_t i = 0; i < indices->size(); ++i) { + REQUIRE(triangles[i].x < 4); + REQUIRE(triangles[i].y < 4); + REQUIRE(triangles[i].z < 4); + } + } + + THEN("The Surface and its Geometry keep their identity across the scrub") + { + const auto surfacesBefore = scene.numberOfObjects(ANARI_SURFACE); + const auto geometriesBefore = scene.numberOfObjects(ANARI_GEOMETRY); + const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); + + animMgr.setAnimationTime(1.0f); + + // Re-running conversion would have built new ones, forcing the render + // index to tear down and recreate ANARI handles (ADR 0022). + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == surfacesBefore); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == geometriesBefore); + REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); + } + } + } +} + #endif // TSD_USE_USD From 1cc6b15931a731e8b4704f885fd03689d73fb412 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Mon, 17 Aug 2026 22:09:15 -0500 Subject: [PATCH 33/78] fix(io): address code review of the converter split Two behavioural regressions the split introduced, both from moving material resolution ahead of geometry resolution: Orphan objects. The old convertMesh returned early -- no points, no topology, no curve vertex counts -- above the "resolve the bound material first" comment, and skipped a subset's material when the subset drew no triangles. planMaterials ran before any of that, so a mesh with no points, or a subset with no faces, permanently added a Material and its textures that no Surface referenced. convertGeometry now asks geometryWillResolve() before building anything and resolves geometry first, so planMaterials is driven by the Parts that actually exist. A material naming a texture-coordinate primvar other than "st" changes the slot assignment, so that case costs one extra resolve -- at import only, never per frame. Lost array sharing. buildParts shares one position Array across a mesh's Surfaces, but refillGeometry allocated per Part, so a resize gave each subset its own copy and the common path re-wrote the same buffer once per Part per frame. refillGeometry now takes a RefillCache spanning the whole set, as buildParts already did. Also from the review: - The attribute-slot assignment is now replayed alongside the uv name. It was inconsistent to pin attribute0 and let attribute1..3 fall out of whichever primvars a given frame carried; a primvar appearing mid-sequence silently re-slotted the rest. - refillGeometry clears the parameters a Part no longer provides, so a primvar that stops resolving leaves nothing of the previous frame behind -- including a prim swapping vertex.radius for a scalar radius. - TriangulatedPrimvar::isShared() moves out of the struct body (STYLEGUIDE section 5); the pre-move code had it out-of-line and the move regressed it. - The two new lookup maps become FlatMap, per the TSD styleguide table. The mesh primvar map stays std::map: it is sorted on purpose, which its comment already said. - DisplayColor uses std::optional rather than hand-rolled has/value pairs. - Every attribute now joins a Part through one helper, so index arrays get the same empty/untyped rejection primvar arrays always had. Reviewed and rejected: a claim that `for (auto &s : convertGeometry(...).surfaces)` dangles. Binding a reference to a subobject of a temporary extends the complete temporary, which a standalone ASan/UBSan case confirms -- the C++23 change to range-for covers a different shape. Also rejected: readDisplayColor indexing c[0] unchecked; Primvar::valid() already requires a non-empty array. Co-Authored-By: Claude Opus 5 --- .../io/animation/UsdGeometryFileBinding.cpp | 35 ++- .../io/importers/detail/usd/UsdGeometry.cpp | 130 ++++++----- .../io/importers/detail/usd/UsdSubdivision.h | 2 +- .../tsd/io/usd/UsdGeometryResolveOptions.h | 18 +- tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp | 189 ++++++++++++++-- tsd/src/tsd/io/usd/UsdResolvedGeometry.h | 38 +++- tsd/tests/test_UsdImport.cpp | 211 ++++++++++++++++++ 7 files changed, 522 insertions(+), 101 deletions(-) diff --git a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp index 6d32ae342..ace9164ec 100644 --- a/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -26,10 +26,9 @@ namespace { // stores them, which is what reads it back. void writeMat4(core::DataNode &node, const tsd::math::mat4 &m) { - auto &values = node; for (int column = 0; column < 4; ++column) { for (int row = 0; row < 4; ++row) - values.append() = m[column][row]; + node.append() = m[column][row]; } } @@ -113,6 +112,15 @@ void UsdGeometryFileBinding::toDataNode(core::DataNode &node) const entry["part"] = part; entry["uv"] = uvName; } + + auto &slotNode = replay["slots"]; + for (const auto &[part, primvars] : m_resolveOptions.slotPrimvarsByPart) { + auto &entry = slotNode.append(); + entry["part"] = part; + auto &names = entry["primvars"]; + for (const auto &primvar : primvars) + names.append() = primvar; + } } void UsdGeometryFileBinding::onDefragment(const scene::IndexRemapper &cb) @@ -176,8 +184,21 @@ UsdGeometryFileBinding *UsdGeometryFileBinding::addToAnimation( resolveOptions.bakeXform = readMat4(replay->child("bakeXform")); if (auto *uvNode = replay->child("uvNames")) { uvNode->foreach_child([&](core::DataNode &entry) { - resolveOptions.uvNamesByPart[entry["part"].getValueOr("")] = - entry["uv"].getValueOr("st"); + resolveOptions.uvNamesByPart.set( + entry["part"].getValueOr(""), + entry["uv"].getValueOr("st")); + }); + } + if (auto *slotNode = replay->child("slots")) { + slotNode->foreach_child([&](core::DataNode &entry) { + std::vector primvars; + if (auto *names = entry.child("primvars")) { + names->foreach_child([&](core::DataNode &n) { + primvars.push_back(n.getValueOr("")); + }); + } + resolveOptions.slotPrimvarsByPart.set( + entry["part"].getValueOr(""), std::move(primvars)); }); } } @@ -224,13 +245,17 @@ void UsdGeometryFileBinding::update(float t) // rather than half-updated: Parts appear and disappear when the mesh's // material subsets change, and that means new Surfaces and Materials, which // is conversion rather than animation. + // One cache across the whole set, so a mesh's Surfaces keep sharing one + // position Array and that Array is written once rather than once per Surface. + usd::RefillCache cache; + size_t applied = 0; for (auto &part : m_parts) { auto *geometry = part.geometry.get(); const auto *resolvedPart = resolved.part(part.name); if (!geometry || !resolvedPart) continue; - if (usd::refillGeometry(*scene(), *geometry, *resolvedPart)) + if (usd::refillGeometry(*scene(), *geometry, *resolvedPart, cache)) applied++; } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 21bd157b7..58149319c 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -8,7 +8,6 @@ #include #include // std -#include #include #include @@ -40,75 +39,73 @@ MaterialRef displayColorMaterial(ImportContext &ctx, retval->setName((primPath.GetString() + "_displayColor").c_str()); const auto display = readDisplayColor(prim); - if (display.hasColor) - retval->setParameter("color", display.color); - if (display.hasOpacity) - retval->setParameter("opacity", display.opacity); + if (display.color) + retval->setParameter("color", *display.color); + if (display.opacity) + retval->setParameter("opacity", *display.opacity); return retval; } -// The subsets a mesh divides itself into, in the order the resolver visits -// them, so Part names line up with the materials resolved for them. -std::vector subsetPathsOf( - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath) -{ - std::vector retval; - for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) { - if (sceneIndex->GetPrim(childPath).primType - == pxr::HdPrimTypeTokens->geomSubset) - retval.push_back(childPath); - } - return retval; -} - -// Resolve the materials this gprim needs, and with them the attribute-slot -// assignment the resolver must use. This is the half of conversion that does -// not change over time, which is why it happens once and its answer is -// replayed rather than recomputed. +// The materials this gprim's Parts need, and with them the texture-coordinate +// primvar each Part's attribute assignment must be built around. This is the +// half of conversion that does not change over time, which is why it happens +// once and its answer is replayed rather than recomputed. struct MaterialPlan { - std::map byPart; - std::map uvNamesByPart; + FlatMap byPart; + FlatMap uvNamesByPart; MaterialRef fallback; + + // True when some material asked for a texture-coordinate primvar other than + // the conventional one, which is the only case where knowing the materials + // changes what a resolve produces. + bool anyNonDefaultUv{false}; }; +// Deliberately driven by the Parts a resolve actually produced: resolving a +// material creates scene objects, and a subset that draws no triangles must +// not leave a Material and its textures behind that nothing references. MaterialPlan planMaterials(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim) + const pxr::HdSceneIndexPrim &prim, + const ResolvedGeometry &resolved) { MaterialPlan retval; - const auto resolved = + const auto meshResolved = resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)); - retval.fallback = resolved.material - ? resolved.material + retval.fallback = meshResolved.material + ? meshResolved.material : displayColorMaterial(ctx, primPath, prim); // A material names the primvar its texture reader wants. A subset without a // material of its own falls back to this one, and this one to the // conventional name. const std::string meshUvName = - resolved.uvPrimvarName.empty() ? "st" : resolved.uvPrimvarName; + meshResolved.uvPrimvarName.empty() ? "st" : meshResolved.uvPrimvarName; + retval.anyNonDefaultUv = meshUvName != "st"; const auto meshName = primPath.GetString(); - retval.byPart[meshName] = retval.fallback; - retval.uvNamesByPart[meshName] = meshUvName; - - if (prim.primType != pxr::HdPrimTypeTokens->mesh) - return retval; + for (const auto &part : resolved.parts) { + if (part.name == meshName) { + retval.byPart.set(part.name, retval.fallback); + retval.uvNamesByPart.set(part.name, meshUvName); + continue; + } - for (const auto &subsetPath : subsetPathsOf(sceneIndex, primPath)) { - auto subsetPrim = sceneIndex->GetPrim(subsetPath); + auto subsetPrim = sceneIndex->GetPrim(pxr::SdfPath(part.name)); const auto subsetResolved = resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(subsetPrim)); + retval.byPart.set(part.name, + subsetResolved.material ? subsetResolved.material : retval.fallback); - const auto subsetName = subsetPath.GetString(); - retval.byPart[subsetName] = subsetResolved.material ? subsetResolved.material - : retval.fallback; - retval.uvNamesByPart[subsetName] = subsetResolved.uvPrimvarName.empty() + const auto subsetUvName = subsetResolved.uvPrimvarName.empty() ? meshUvName : subsetResolved.uvPrimvarName; + retval.uvNamesByPart.set(part.name, subsetUvName); + retval.anyNonDefaultUv = + retval.anyNonDefaultUv || subsetUvName != "st"; } return retval; @@ -122,7 +119,7 @@ void buildParts(ImportContext &ctx, const MaterialPlan &materials, ConvertedGeometry &out) { - std::map sharedArrays; + FlatMap sharedArrays; for (const auto &part : resolved.parts) { auto geometry = ctx.scene.createObject(part.subtype); @@ -133,16 +130,13 @@ void buildParts(ImportContext &ctx, continue; ArrayRef array; - if (!attribute.sharedKey.empty()) { - auto &shared = sharedArrays[attribute.sharedKey]; - if (!shared) { - shared = ctx.scene.createArray(attribute.type, attribute.count()); - shared->setData(attribute.data()); - } - array = shared; - } else { + if (auto *shared = sharedArrays.at(attribute.sharedKey)) + array = *shared; + if (!array) { array = ctx.scene.createArray(attribute.type, attribute.count()); array->setData(attribute.data()); + if (!attribute.sharedKey.empty()) + sharedArrays.set(attribute.sharedKey, array); } geometry->setParameterObject(attribute.parameter, *array); } @@ -150,9 +144,8 @@ void buildParts(ImportContext &ctx, for (const auto &[name, value] : part.scalars) geometry->setParameter(name, value); - auto found = materials.byPart.find(part.name); - auto material = - found != materials.byPart.end() ? found->second : materials.fallback; + auto *found = materials.byPart.at(part.name); + auto material = found ? *found : materials.fallback; out.geometryByPart.emplace_back(part.name, geometry); out.surfaces.push_back( @@ -170,21 +163,42 @@ ConvertedGeometry convertGeometry(ImportContext &ctx, { ConvertedGeometry retval; - // Materials first: which primvar a Part's material reads as texture - // coordinates decides which slot every other primvar falls into. - const auto materials = planMaterials(ctx, sceneIndex, primPath, prim); + // Nothing is built for a gprim that resolves to nothing -- not even the + // material it binds, which would otherwise outlive the geometry that was + // supposed to reference it. + if (!geometryWillResolve(prim)) + return retval; retval.resolveOptions.bakeXform = bakeXform; retval.resolveOptions.refinementLevel = ctx.options.refinementLevel; retval.resolveOptions.refine = meshWantsRefinement(ctx.stage, ctx.options.refinementLevel, primPath); - retval.resolveOptions.uvNamesByPart = materials.uvNamesByPart; - const auto resolved = + // Resolve first, so materials are resolved only for the Parts that exist. + auto resolved = resolveGeometry(sceneIndex, primPath, prim, retval.resolveOptions); if (!resolved.valid()) return retval; + const auto materials = + planMaterials(ctx, sceneIndex, primPath, prim, resolved); + retval.resolveOptions.uvNamesByPart = materials.uvNamesByPart; + + // Which primvar a material reads as texture coordinates decides which slot + // every other primvar falls into, so a material naming something other than + // the conventional `st` means the first resolve assigned them wrongly. That + // costs one extra resolve, at import only: a binding is handed the answer. + if (materials.anyNonDefaultUv) { + resolved = resolveGeometry(sceneIndex, primPath, prim, retval.resolveOptions); + if (!resolved.valid()) + return retval; + } + + // Record the assignment the resolve settled on, so a scrub replays it rather + // than re-deriving it from whatever primvars that frame happens to carry. + for (const auto &part : resolved.parts) + retval.resolveOptions.slotPrimvarsByPart.set(part.name, part.slotPrimvars); + buildParts(ctx, resolved, materials, retval); return retval; } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h index 43d76cf8b..9774ef28d 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h @@ -6,10 +6,10 @@ #include "tsd/io/usd/UsdDataSource.h" // usd #include -#include #include #include #include +#include // std #include #include diff --git a/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h b/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h index ee402ece9..f9e363ba1 100644 --- a/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h +++ b/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h @@ -4,10 +4,11 @@ #pragma once // tsd_core +#include "tsd/core/FlatMap.hpp" #include "tsd/core/TSDMath.hpp" // std -#include #include +#include namespace tsd::io::usd { @@ -17,10 +18,14 @@ namespace tsd::io::usd { * time. An animation binding carries one of these and replays it, so a scrub * reproduces the Import's conversion instead of guessing at it again. * - * `uvNamesByPart` replays the attribute-slot assignment: which primvar a Part's - * material reads as texture coordinates decides which slot every other primvar - * falls into, and a scrub must not re-resolve materials to find that out. An - * absent entry means the conventional `st`. + * `uvNamesByPart` and `slotPrimvarsByPart` together replay the whole attribute + * assignment. Which primvar a Part's material reads as texture coordinates + * decides `attribute0`, and a scrub must not re-resolve materials to find that + * out; the remaining slots went to whichever primvars the prim happened to + * carry, so naming them is what keeps a primvar that appears or disappears + * mid-sequence from silently re-slotting the others. An absent uv entry means + * the conventional `st`; an absent slot entry means the resolve is free to + * assign, which is what the Import itself does on its first pass. * * Deliberately free of OpenUSD types, so the animation bindings that carry one * still declare themselves in builds without USD. @@ -32,7 +37,8 @@ struct GeometryResolveOptions tsd::math::mat4 bakeXform{tsd::math::IDENTITY_MAT4}; bool refine{false}; int refinementLevel{2}; - std::map uvNamesByPart; + tsd::core::FlatMap uvNamesByPart; + tsd::core::FlatMap> slotPrimvarsByPart; }; } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp b/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp index 350ef9f2b..ac6e3d638 100644 --- a/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp +++ b/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include namespace tsd::io::usd { @@ -116,12 +117,14 @@ pxr::VtVec3fArray bakedPositions( return retval; } -void addAttribute(ResolvedPart &part, +// The one way an attribute joins a Part: nothing that would bind as an empty +// or untyped Array gets in. +void addTypedAttribute(ResolvedPart &part, Token parameter, + anari::DataType type, pxr::VtValue value, std::string sharedKey = {}) { - const auto type = anariTypeOfPrimvar(value); if (type == ANARI_UNKNOWN || !value.IsArrayValued() || value.GetArraySize() == 0) return; @@ -129,6 +132,18 @@ void addAttribute(ResolvedPart &part, {parameter, type, std::move(value), std::move(sharedKey)}); } +// The same, for the float-typed primvar data whose ANARI type is inferable +// from what the VtValue holds. +void addAttribute(ResolvedPart &part, + Token parameter, + pxr::VtValue value, + std::string sharedKey = {}) +{ + const auto type = anariTypeOfPrimvar(value); + addTypedAttribute( + part, parameter, type, std::move(value), std::move(sharedKey)); +} + // USD authors widths; TSD geometry takes radii. Prims with no authored // widths (common for Blender hair exports) would otherwise inherit ANARI's // default radius of 1 world unit, which dwarfs most scenes -- instead fall @@ -269,9 +284,14 @@ struct TriangulatedPrimvar size_t valuesPerTriangle{0}; // Vertex data is the only kind every Part can point at unchanged. - bool isShared() const { return valuesPerTriangle == 0; } + bool isShared() const; }; +bool TriangulatedPrimvar::isShared() const +{ + return valuesPerTriangle == 0; +} + // Kept sorted by name: the order primvars are visited decides which of them // takes each spare attribute slot, and that has to be stable across runs. This // is why the mesh resolver reaches for std::map rather than FlatMap. @@ -367,6 +387,7 @@ ResolvedPart resolveTrianglePart(const TriangulatedMesh &mesh, const TriangulatedPrimvars &attributes, const std::vector &triangles, const std::string &uvName, + const std::vector *replaySlots, const std::string &name) { ResolvedPart part; @@ -382,10 +403,10 @@ ResolvedPart resolveTrianglePart(const TriangulatedMesh &mesh, indices.reserve(triangles.size()); for (uint32_t triangle : triangles) indices.push_back(mesh.triangleIndices[triangle]); - part.attributes.push_back({Token("primitive.index"), + addTypedAttribute(part, + Token("primitive.index"), ANARI_UINT32_VEC3, - pxr::VtValue(indices), - {}}); + pxr::VtValue(indices)); auto bind = [&](const std::string &primvarName, const std::string &tsdName, @@ -405,6 +426,26 @@ ResolvedPart resolveTrianglePart(const TriangulatedMesh &mesh, bind(uvName, "attribute0", /*isUv=*/true); bind(colorName, "color"); + // Replaying a recorded assignment keeps a primvar that appears or disappears + // mid-sequence from re-slotting the others; a Part being resolved for the + // first time has nothing to replay and assigns in name order, which is + // deterministic because the primvar map is sorted. + if (replaySlots) { + int slot = 1; + for (const auto &primvarName : *replaySlots) { + if (slot > 3) + break; + const auto tsdName = "attribute" + std::to_string(slot++); + part.slotPrimvars.push_back(primvarName); + auto found = attributes.find(primvarName); + if (found == attributes.end()) + continue; // the slot stays empty rather than shifting the rest along + resolvePartPrimvar( + part, primvarName, found->second, triangles, tsdName, false); + } + return part; + } + int nextAttribute = 1; for (const auto &[primvarName, primvar] : attributes) { if (nextAttribute > 3) @@ -412,6 +453,7 @@ ResolvedPart resolveTrianglePart(const TriangulatedMesh &mesh, if (primvarName == normalsName || primvarName == colorName || primvarName == uvName) continue; + part.slotPrimvars.push_back(primvarName); resolvePartPrimvar(part, primvarName, primvar, @@ -436,8 +478,14 @@ std::string uvNameFor(const GeometryResolveOptions &options, const std::string &partName, const std::string &fallback) { - auto found = options.uvNamesByPart.find(partName); - return found == options.uvNamesByPart.end() ? fallback : found->second; + const auto *found = options.uvNamesByPart.at(partName); + return found ? *found : fallback; +} + +const std::vector *replaySlotsFor( + const GeometryResolveOptions &options, const std::string &partName) +{ + return options.slotPrimvarsByPart.at(partName); } ResolvedGeometry resolveMesh(const pxr::HdSceneIndexBaseRefPtr &sceneIndex, @@ -550,8 +598,12 @@ ResolvedGeometry resolveMesh(const pxr::HdSceneIndexBaseRefPtr &sceneIndex, } if (subsetPaths.empty()) { - retval.parts.push_back(resolveTrianglePart( - mesh, attributes, allTriangles, meshUvName, meshName)); + retval.parts.push_back(resolveTrianglePart(mesh, + attributes, + allTriangles, + meshUvName, + replaySlotsFor(options, meshName), + meshName)); return retval; } @@ -595,6 +647,7 @@ ResolvedGeometry resolveMesh(const pxr::HdSceneIndexBaseRefPtr &sceneIndex, attributes, subsetTriangles, uvNameFor(options, subsetName, meshUvName), + replaySlotsFor(options, subsetName), subsetName)); } @@ -612,8 +665,12 @@ ResolvedGeometry resolveMesh(const pxr::HdSceneIndexBaseRefPtr &sceneIndex, unclaimedTriangles = allTriangles; if (!unclaimedTriangles.empty()) { - retval.parts.push_back(resolveTrianglePart( - mesh, attributes, unclaimedTriangles, meshUvName, meshName)); + retval.parts.push_back(resolveTrianglePart(mesh, + attributes, + unclaimedTriangles, + meshUvName, + replaySlotsFor(options, meshName), + meshName)); } return retval; @@ -686,10 +743,8 @@ ResolvedGeometry resolveCurves(const pxr::SdfPath &primPath, base += uint32_t(count); } if (!segments.empty()) { - part.attributes.push_back({Token("primitive.index"), - ANARI_UINT32, - pxr::VtValue(segments), - {}}); + addTypedAttribute( + part, Token("primitive.index"), ANARI_UINT32, pxr::VtValue(segments)); } resolveRadii(part, @@ -820,6 +875,17 @@ const ResolvedAttribute *ResolvedPart::attribute(Token parameter) const return nullptr; } +bool ResolvedPart::provides(Token parameter) const +{ + if (attribute(parameter)) + return true; + for (const auto &[name, value] : scalars) { + if (name == parameter) + return true; + } + return false; +} + bool ResolvedGeometry::valid() const { return !parts.empty(); @@ -838,6 +904,40 @@ const ResolvedPart *ResolvedGeometry::part(const std::string &name) const // Entry points /////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// +// Cheap enough to ask before anything is built: every check here is a data +// source read, not a conversion. +bool geometryWillResolve(const pxr::HdSceneIndexPrim &prim) +{ + if (!isGeometryPrimType(prim.primType)) + return false; + + if (prim.primType == pxr::HdPrimTypeTokens->sphere + || prim.primType == pxr::HdPrimTypeTokens->cone + || prim.primType == pxr::HdPrimTypeTokens->cylinder) + return true; + + auto primvars = pxr::HdPrimvarsSchema::GetFromParent(prim.dataSource); + const auto points = + readPrimvar(primvars, pxr::HdPrimvarsSchemaTokens->points); + if (!points.valid() || !points.value.IsHolding()) + return false; + + if (prim.primType == pxr::HdPrimTypeTokens->mesh) { + auto topology = + pxr::HdMeshSchema::GetFromParent(prim.dataSource).GetTopology(); + return !intArrayOf(topology.GetFaceVertexCounts()).empty() + && !intArrayOf(topology.GetFaceVertexIndices()).empty(); + } + + if (prim.primType == pxr::HdPrimTypeTokens->basisCurves) { + auto topology = + pxr::HdBasisCurvesSchema::GetFromParent(prim.dataSource).GetTopology(); + return !intArrayOf(topology.GetCurveVertexCounts()).empty(); + } + + return true; +} + bool isGeometryPrimType(const pxr::TfToken &primType) { return primType == pxr::HdPrimTypeTokens->mesh @@ -858,13 +958,10 @@ DisplayColor readDisplayColor(const pxr::HdSceneIndexPrim &prim) if (color.valid() && color.value.IsHolding()) { const auto &c = color.value.UncheckedGet(); - retval.hasColor = true; retval.color = float3(c[0][0], c[0][1], c[0][2]); } - if (opacity.valid() && opacity.value.IsHolding()) { - retval.hasOpacity = true; + if (opacity.valid() && opacity.value.IsHolding()) retval.opacity = opacity.value.UncheckedGet()[0]; - } return retval; } @@ -883,9 +980,40 @@ ResolvedGeometry resolveGeometry( return resolveQuadric(primPath, prim, options); } +namespace { + +// Whether a parameter is one this module owns, and so may clear when a resolve +// stops providing it. Anything else on the Geometry was put there by something +// that is not a per-frame resolve, and is left alone. +bool isResolvedParameterName(const char *name) +{ + const std::string_view view(name ? name : ""); + return view.rfind("vertex.", 0) == 0 || view.rfind("primitive.", 0) == 0 + || view.rfind("faceVarying.", 0) == 0 || view == "radius"; +} + +// Drop what this Part no longer provides, so a primvar that stops resolving +// leaves nothing of the previous frame behind -- including the case where a +// prim swaps a `vertex.radius` array for a scalar `radius` or back. +void clearStaleParameters( + scene::Geometry &geometry, const ResolvedPart &part) +{ + std::vector stale; + for (size_t i = 0; i < geometry.numParameters(); ++i) { + const auto *name = geometry.parameterNameAt(i); + if (isResolvedParameterName(name) && !part.provides(Token(name))) + stale.push_back(Token(name)); + } + for (auto name : stale) + geometry.removeParameter(name); +} + +} // namespace + bool refillGeometry(scene::Scene &scene, scene::Geometry &geometry, - const ResolvedPart &part) + const ResolvedPart &part, + RefillCache &cache) { if (geometry.subtype() != part.subtype) return false; @@ -894,11 +1022,23 @@ bool refillGeometry(scene::Scene &scene, if (!attribute.valid()) continue; + // A buffer shared with another Part of the same gprim is written once and + // then only re-bound, so a mesh's Surfaces keep pointing at one Array and + // that Array is not rewritten once per Surface per frame. + if (!attribute.sharedKey.empty()) { + if (auto *shared = cache.sharedArrays.at(attribute.sharedKey)) { + geometry.setParameterObject(attribute.parameter, **shared); + continue; + } + } + auto *array = geometry.parameterValueAsObject(attribute.parameter); if (array && array->size() == attribute.count() && array->elementType() == attribute.type) { array->setData(attribute.data()); + if (!attribute.sharedKey.empty()) + cache.sharedArrays.set(attribute.sharedKey, array->self()); continue; } @@ -906,17 +1046,20 @@ bool refillGeometry(scene::Scene &scene, // moves costs one allocation and one rebind for that parameter. Every // parameter of the Part is written in this one pass, so the Geometry is // never left half in one frame and half in another. - auto replacement = - scene.createArray(attribute.type, attribute.count()); + auto replacement = scene.createArray(attribute.type, attribute.count()); replacement->setData(attribute.data()); if (array) replacement->setName(array->name().c_str()); geometry.setParameterObject(attribute.parameter, *replacement); + if (!attribute.sharedKey.empty()) + cache.sharedArrays.set(attribute.sharedKey, replacement); } for (const auto &[name, value] : part.scalars) geometry.setParameter(name, value); + clearStaleParameters(geometry, part); + return true; } diff --git a/tsd/src/tsd/io/usd/UsdResolvedGeometry.h b/tsd/src/tsd/io/usd/UsdResolvedGeometry.h index 55bf1bf7f..26407e086 100644 --- a/tsd/src/tsd/io/usd/UsdResolvedGeometry.h +++ b/tsd/src/tsd/io/usd/UsdResolvedGeometry.h @@ -4,6 +4,7 @@ #pragma once // tsd_core +#include "tsd/core/FlatMap.hpp" #include "tsd/core/TSDMath.hpp" #include "tsd/core/Token.hpp" // tsd_io @@ -16,8 +17,9 @@ #include #include // std -#include +#include #include +#include #include namespace tsd::io::usd { @@ -32,9 +34,9 @@ namespace tsd::io::usd { * re-pull a mesh's points, indices and primvars as one consistent set without * re-creating the objects around them (ADR 0022). * - * Nothing here touches a Scene: a ResolvedGeometry is inert data that can be + * Resolving touches no Scene: a ResolvedGeometry is inert data that can be * produced at any Time Code and then either built into new objects or written - * over existing ones. + * over existing ones. Writing it back (refillGeometry, below) necessarily does. */ // One geometry parameter's worth of resolved data, already expanded, gathered @@ -65,7 +67,12 @@ struct ResolvedPart std::vector attributes; std::vector> scalars; + // Which primvar took each spare attribute slot, in slot order. Reported so + // an Import can record it and a later resolve can replay it. + std::vector slotPrimvars; + const ResolvedAttribute *attribute(tsd::core::Token parameter) const; + bool provides(tsd::core::Token parameter) const; }; struct ResolvedGeometry @@ -91,14 +98,28 @@ bool isGeometryPrimType(const pxr::TfToken &primType); // viewer rather than taking TSD's default. struct DisplayColor { - bool hasColor{false}; - tsd::math::float3 color{1.f}; - bool hasOpacity{false}; - float opacity{1.f}; + std::optional color; + std::optional opacity; }; DisplayColor readDisplayColor(const pxr::HdSceneIndexPrim &prim); +// True when a gprim carries enough to resolve into anything at all. Asked +// before the objects around a gprim are built, so that a prim yielding no +// geometry does not leave a Material behind that nothing references. +bool geometryWillResolve(const pxr::HdSceneIndexPrim &prim); + +/* + * Arrays shared between the Parts of one gprim, so that a mesh's Surfaces keep + * pointing at one position Array rather than a copy each -- and, on the common + * path, so that the one Array is written once per frame rather than once per + * Part. One of these covers one resolve; it must not outlive it. + */ +struct RefillCache +{ + tsd::core::FlatMap sharedArrays; +}; + /* * Write a resolved Part over an existing Geometry, reusing every Array whose * size and element type still fit and allocating only the ones that do not. @@ -111,6 +132,7 @@ DisplayColor readDisplayColor(const pxr::HdSceneIndexPrim &prim); */ bool refillGeometry(scene::Scene &scene, scene::Geometry &geometry, - const ResolvedPart &part); + const ResolvedPart &part, + RefillCache &cache); } // namespace tsd::io::usd diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 99fd25d88..b9fecca85 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -80,6 +80,7 @@ SCENARIO("Import report counts skipped prims by reason", "[UsdImport]") #include "tsd/io/archives/AnimationManagerArchive.hpp" #include "tsd/io/importers.hpp" #include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Material.hpp" // std #include #include @@ -3107,4 +3108,214 @@ def Mesh "Morphing" } } +SCENARIO("Conversion leaves nothing behind for geometry it does not emit", + "[UsdImport]") +{ + GIVEN("A mesh with no points that nonetheless binds a material") + { + StageFixture stage("tsd_test_usd_empty_mesh.usda", R"(#usda 1.0 + +def Material "Orphan" +{ + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (1, 0, 0) + token outputs:surface + } +} + +def Mesh "Empty" ( + prepend apiSchemas = ["MaterialBindingAPI"] +) +{ + rel material:binding = + int[] faceVertexCounts = [] + int[] faceVertexIndices = [] + point3f[] points = [] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + // A Scene creates one default Material of its own, so what matters is + // that the import adds none. + const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("No Surface, Geometry or Material is created for it") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 0); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 0); + REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); + } + } + } + + GIVEN("A mesh whose subset claims no faces but binds its own material") + { + StageFixture stage("tsd_test_usd_empty_subset.usda", R"(#usda 1.0 + +def Material "Used" +{ + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 1, 0) + token outputs:surface + } +} + +def Material "Unused" +{ + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 0, 1) + token outputs:surface + } +} + +def Mesh "Quad" +{ + int[] faceVertexCounts = [3, 3] + int[] faceVertexIndices = [0, 1, 2, 0, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + + def GeomSubset "Drawn" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0, 1] + rel material:binding = + } + + def GeomSubset "Empty" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [] + rel material:binding = + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Only the material the drawn subset uses is created") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + + bool sawUnused = false; + for (size_t i = 0; i < scene.numberOfObjects(ANARI_MATERIAL); ++i) { + auto material = scene.getObject(i); + if (material && material->name().find("Unused") != std::string::npos) + sawUnused = true; + } + REQUIRE_FALSE(sawUnused); + } + } + } +} + +SCENARIO("Parts keep sharing one position Array across a resize", "[UsdImport]") +{ + GIVEN("A mesh divided into subsets whose vertex count changes") + { + StageFixture stage("tsd_test_usd_shared_resize.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Split" +{ + int[] faceVertexCounts.timeSamples = { + 0: [3, 3], + 2: [3, 3, 3], + } + int[] faceVertexIndices.timeSamples = { + 0: [0, 1, 2, 0, 2, 3], + 2: [0, 1, 2, 0, 2, 3, 0, 3, 4], + } + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + 2: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (-1, 1, 0)], + } + + def GeomSubset "A" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0] + } + + def GeomSubset "B" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + auto positionArrayOf = [&](size_t i) { + auto geometry = scene.getObject(i); + return geometry + ? geometry->parameterValueAsObject( + "vertex.position") + : nullptr; + }; + + const auto parts = scene.numberOfObjects(ANARI_GEOMETRY); + REQUIRE(parts > 1); // the subsets, plus any unclaimed remainder + + THEN("Every Part shares one position Array on import") + { + auto *first = positionArrayOf(0); + REQUIRE(first != nullptr); + for (size_t i = 1; i < parts; ++i) + REQUIRE(positionArrayOf(i) == first); + } + + THEN("They still share one after a resize, not a copy each") + { + animMgr.setAnimationTime(1.0f); + + auto *first = positionArrayOf(0); + REQUIRE(first != nullptr); + REQUIRE(first->size() == 5); + for (size_t i = 1; i < parts; ++i) + REQUIRE(positionArrayOf(i) == first); + } + } + } +} + #endif // TSD_USE_USD From 463cd808625272cab2072aaef6cf6fe6bc2f2b43 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 11:18:36 -0500 Subject: [PATCH 34/78] fix(rendering): re-copy instance transforms when a transform Array is rewritten An animated USD PointInstancer did not move when scrubbed unless the same Stage also animated an Xform. The importer was doing its job: scrubbing example_sph.usd rewrote the instancer's mat4 Array correctly on every frame. The new matrices simply never reached ANARI. TransformsToAnariVisitor does not hand the Array to ANARI, it copies out of it, mapping each instance's own "transform" parameter array and writing parentXform * m into it. That copy is only made from syncLayerTransforms(). Array::setData() signals signalArrayUnmapped, and RenderIndexAllLayers answered a mat4 Array with requestWorldUpdate() alone -- which re-sets the world's instance list and commits, and never touches transform data. The instance kept its frame-0 matrices. example_granular_collision_sdf.usd looked fine because it is the only warp stage with animated Xforms alongside its instancer. TransformBinding::update signals its whole layer, whose sync happens to re-copy the instancer's Array too; the particles moved as a side effect of the rocks moving. signalArrayUnmapped now asks for the transform sync it always needed. Which layer holds the node is not knowable from the Array alone, so every cached layer is synced. That sync is deferred to the end of an update batch, as the world rebuild already was, because both callers fire once per animated prim per frame: a Stage with N animated nodes in one layer paid N full-layer traversals per scrub. signalLayerTransformUpdated joins the same path, so the eager sync it did per animated node collapses too. One scrub of the granular stage now traverses its layer once, where before the fix it traversed twice and did not move the particles at all. RenderIndex grows a flushDeferredUpdates() hook, called once when the outermost batch ends just before the deferred rebuild, plus inUpdateBatch() for subclasses that defer alongside it. test_RenderIndex covers the regression directly -- a unit sphere placed by a single-element transform array, rewritten in place, with and without a surrounding update batch. It fails both ways on the code this replaces. The test skips rather than fails where helide is unavailable. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/rendering/index/RenderIndex.cpp | 15 +- tsd/src/tsd/rendering/index/RenderIndex.hpp | 6 + .../rendering/index/RenderIndexAllLayers.cpp | 61 ++++++- .../rendering/index/RenderIndexAllLayers.hpp | 11 ++ tsd/tests/CMakeLists.txt | 2 + tsd/tests/test_RenderIndex.cpp | 153 ++++++++++++++++++ 6 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 tsd/tests/test_RenderIndex.cpp diff --git a/tsd/src/tsd/rendering/index/RenderIndex.cpp b/tsd/src/tsd/rendering/index/RenderIndex.cpp index 6f00e9459..4b7ec31a8 100644 --- a/tsd/src/tsd/rendering/index/RenderIndex.cpp +++ b/tsd/src/tsd/rendering/index/RenderIndex.cpp @@ -233,7 +233,10 @@ void RenderIndex::signalUpdateBatchEnd() { if (m_updateBatchDepth > 0) m_updateBatchDepth--; - if (m_updateBatchDepth > 0 || !m_worldUpdateDeferred) + if (m_updateBatchDepth > 0) + return; + flushDeferredUpdates(); + if (!m_worldUpdateDeferred) return; m_worldUpdateDeferred = false; updateWorld(); @@ -247,4 +250,14 @@ void RenderIndex::requestWorldUpdate() updateWorld(); } +bool RenderIndex::inUpdateBatch() const +{ + return m_updateBatchDepth > 0; +} + +void RenderIndex::flushDeferredUpdates() +{ + // Nothing beyond the world rebuild is deferred by default. +} + } // namespace tsd::rendering diff --git a/tsd/src/tsd/rendering/index/RenderIndex.hpp b/tsd/src/tsd/rendering/index/RenderIndex.hpp index bc33d0685..f1061cd9b 100644 --- a/tsd/src/tsd/rendering/index/RenderIndex.hpp +++ b/tsd/src/tsd/rendering/index/RenderIndex.hpp @@ -83,6 +83,12 @@ struct RenderIndex : public BaseUpdateDelegate // otherwise pay a full world rebuild. void requestWorldUpdate(); + bool inUpdateBatch() const; + + // Called once when the outermost update batch ends, just before the deferred + // world rebuild, so subclasses can flush work they deferred the same way. + virtual void flushDeferredUpdates(); + Scene *m_ctx{nullptr}; AnariHandleCache m_cache; diff --git a/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp b/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp index 1f186cdc2..bbfde2183 100644 --- a/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp +++ b/tsd/src/tsd/rendering/index/RenderIndexAllLayers.cpp @@ -61,8 +61,14 @@ void RenderIndexAllLayers::setIncludedLayers( void RenderIndexAllLayers::signalArrayUnmapped(const Array *a) { RenderIndex::signalArrayUnmapped(a); - if (a->elementType() == ANARI_FLOAT32_MAT4) - requestWorldUpdate(); + if (a->elementType() != ANARI_FLOAT32_MAT4) + return; + // A transform-array node's matrices are copied into each instance's own + // parameter array when its layer is synced, so rewriting the Array is only + // visible once that copy is made again; updating the world is not enough. + // Which layer holds the node is not knowable from the Array alone. + requestLayerTransformSync(nullptr); + requestWorldUpdate(); } void RenderIndexAllLayers::signalObjectParameterUseCountZero(const Object *o) @@ -112,7 +118,7 @@ void RenderIndexAllLayers::signalLayerStructureUpdated(const Layer *l) void RenderIndexAllLayers::signalLayerTransformUpdated(const Layer *l) { if (m_instanceCache.contains(l)) { - syncLayerTransforms(l); + requestLayerTransformSync(l); requestWorldUpdate(); } } @@ -234,6 +240,55 @@ void RenderIndexAllLayers::syncLayerInstances( syncLayerTransforms(layer); } +void RenderIndexAllLayers::requestLayerTransformSync(const Layer *layer) +{ + if (!inUpdateBatch()) { + if (layer) + syncLayerTransforms(layer); + else { + for (auto &entry : m_instanceCache) + syncLayerTransforms(entry.first); + } + return; + } + + if (m_allTransformSyncsDeferred) + return; + + if (!layer) { + m_allTransformSyncsDeferred = true; + m_deferredTransformSyncs.clear(); + return; + } + + // One animated Stage rewrites one node per animated prim, so the same layer + // arrives here as many times as it has animated prims. + if (std::find(m_deferredTransformSyncs.begin(), + m_deferredTransformSyncs.end(), + layer) + == m_deferredTransformSyncs.end()) { + m_deferredTransformSyncs.push_back(layer); + } +} + +void RenderIndexAllLayers::flushDeferredUpdates() +{ + // A layer removed during the batch took its instances out of the cache with + // it, which is what keeps a stale pointer from being traversed here. + if (m_allTransformSyncsDeferred) { + for (auto &entry : m_instanceCache) + syncLayerTransforms(entry.first); + } else { + for (auto *layer : m_deferredTransformSyncs) { + if (m_instanceCache.contains(layer)) + syncLayerTransforms(layer); + } + } + + m_allTransformSyncsDeferred = false; + m_deferredTransformSyncs.clear(); +} + void RenderIndexAllLayers::syncLayerTransforms(const Layer *layer) { auto d = device(); diff --git a/tsd/src/tsd/rendering/index/RenderIndexAllLayers.hpp b/tsd/src/tsd/rendering/index/RenderIndexAllLayers.hpp index d8aad0ce4..81a7d9ab5 100644 --- a/tsd/src/tsd/rendering/index/RenderIndexAllLayers.hpp +++ b/tsd/src/tsd/rendering/index/RenderIndexAllLayers.hpp @@ -50,6 +50,14 @@ struct RenderIndexAllLayers : public RenderIndex void syncLayerInstances( const Layer *layer, bool appendExisting, uint8_t mask); void syncLayerTransforms(const Layer *layer); + + // Re-copy a layer's node transforms into its ANARI instances, or -- inside + // an update batch -- remember that the copy is owed and make it once when + // the batch ends. A null layer means every cached layer, which is all a + // rewritten transform Array can say about where its matrices are used. + void requestLayerTransformSync(const Layer *layer); + void flushDeferredUpdates() override; + void releaseAllInstances(); RenderIndexFilterFcn m_filter; @@ -60,6 +68,9 @@ struct RenderIndexAllLayers : public RenderIndex using InstanceCache = FlatMap>; InstanceCache m_instanceCache; + + std::vector m_deferredTransformSyncs; + bool m_allTransformSyncsDeferred{false}; }; } // namespace tsd::rendering diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 01a4400a2..2bba1b0e7 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -32,6 +32,7 @@ project_add_executable( test_ObjectArchive.cpp test_ObjectUsePtr.cpp test_Parameter.cpp + test_RenderIndex.cpp test_Scene.cpp test_SceneArchive.cpp test_Token.cpp @@ -87,6 +88,7 @@ add_test(NAME tsd::Object COMMAND ${PROJECT_NAME} "[Object]" ) add_test(NAME tsd::ObjectArchive COMMAND ${PROJECT_NAME} "[ObjectArchive]") add_test(NAME tsd::ObjectUsePtr COMMAND ${PROJECT_NAME} "[ObjectUsePtr]" ) add_test(NAME tsd::Parameter COMMAND ${PROJECT_NAME} "[Parameter]" ) +add_test(NAME tsd::RenderIndex COMMAND ${PROJECT_NAME} "[RenderIndex]" ) add_test(NAME tsd::Scene COMMAND ${PROJECT_NAME} "[Scene]" ) add_test(NAME tsd::SceneArchive COMMAND ${PROJECT_NAME} "[SceneArchive]" ) add_test(NAME tsd::Token COMMAND ${PROJECT_NAME} "[Token]" ) diff --git a/tsd/tests/test_RenderIndex.cpp b/tsd/tests/test_RenderIndex.cpp new file mode 100644 index 000000000..3a927ed17 --- /dev/null +++ b/tsd/tests/test_RenderIndex.cpp @@ -0,0 +1,153 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/rendering/index/RenderIndexAllLayers.hpp" +#include "tsd/scene/Scene.hpp" +// anari +#include +// std +#include + +namespace { + +using namespace tsd::core; +using namespace tsd::scene; + +// A device the tests can drive without a display or a GPU. Absent builds skip +// rather than fail: what is under test is TSD's bookkeeping, not the device. +struct TestDevice +{ + TestDevice() + { + library = anari::loadLibrary("helide", [](const void *, + ANARIDevice, + ANARIObject, + anari::DataType, + ANARIStatusSeverity, + ANARIStatusCode, + const char *) {}); + if (library) + device = anari::newDevice(library, "default"); + } + + ~TestDevice() + { + if (device) + anari::release(device, device); + if (library) + anari::unloadLibrary(library); + } + + explicit operator bool() const + { + return device != nullptr; + } + + anari::Library library{nullptr}; + anari::Device device{nullptr}; +}; + +// The world's bounds are the cheapest thing a device will tell us about where +// the geometry it was given actually ended up. +tsd::math::float3 worldBoundsMax( + anari::Device d, tsd::rendering::RenderIndexAllLayers &index) +{ + tsd::math::float3 bounds[2] = {{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}}; + anariGetProperty(d, + index.world(), + "bounds", + ANARI_FLOAT32_BOX3, + &bounds[0], + sizeof(bounds), + ANARI_WAIT); + return bounds[1]; +} + +// One unit sphere at the origin, placed by a single-element transform array so +// that the only thing that can move it is the contents of that Array. +struct InstancedSphereScene +{ + InstancedSphereScene() + { + auto geometry = scene.createObject(tokens::geometry::sphere); + auto positions = scene.createArray(ANARI_FLOAT32_VEC3, 1); + const tsd::math::float3 origin(0.f, 0.f, 0.f); + positions->setData(&origin, 1); + geometry->setParameterObject("vertex.position", *positions); + geometry->setParameter("radius", 1.f); + + auto material = scene.createObject(tokens::material::matte); + auto surface = scene.createSurface("sphere", geometry, material); + + transforms = scene.createArray(ANARI_FLOAT32_MAT4, 1); + const auto identity = tsd::math::IDENTITY_MAT4; + transforms->setData(&identity, 1); + + auto node = scene.insertChildTransformArrayNode( + scene.defaultLayer()->root(), transforms.data(), "instances"); + scene.insertChildObjectNode(node, surface, "sphere"); + } + + Scene scene; + ArrayRef transforms; +}; + +tsd::math::mat4 translation(float x) +{ + auto retval = tsd::math::IDENTITY_MAT4; + retval[3] = tsd::math::float4(x, 0.f, 0.f, 1.f); + return retval; +} + +} // namespace + +SCENARIO("Rewriting a transform array moves its instances", "[RenderIndex]") +{ + TestDevice anariDevice; + if (!anariDevice) { + WARN("helide unavailable, skipping"); + return; + } + + GIVEN("A populated render index over a transform-array node") + { + InstancedSphereScene content; + auto *index = + content.scene.updateDelegate() + .emplace( + content.scene, Token("helide"), anariDevice.device); + index->populate(); + + REQUIRE(worldBoundsMax(anariDevice.device, *index).x == Approx(1.f)); + + WHEN("The transform array is rewritten in place") + { + const auto moved = translation(50.f); + content.transforms->setData(&moved, 1); + + // A layer's node transforms are copied into its ANARI instances rather + // than referenced, so an Array rewritten behind the render index's back + // has to make that copy happen again. Rebuilding the world does not. + THEN("The instance moves with it") + { + REQUIRE(worldBoundsMax(anariDevice.device, *index).x == Approx(51.f)); + } + } + + WHEN("The transform array is rewritten inside an update batch") + { + content.scene.beginUpdateBatch(); + const auto moved = translation(50.f); + content.transforms->setData(&moved, 1); + content.scene.endUpdateBatch(); + + THEN("The instance has moved by the time the batch ends") + { + REQUIRE(worldBoundsMax(anariDevice.device, *index).x == Approx(51.f)); + } + } + } +} From 959531516e19a5f1ea25c2034e318ebd650ad274 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 11:49:57 -0500 Subject: [PATCH 35/78] feat(io): map OmniPBR materials onto the portable material again The USD import rework deleted OmniPbrMaterial/MaterialCommon without a replacement, so an OmniPBR material fell through to the preview-surface reader under the default PHYSICALLY_BASED mode. That reader asks for `diffuseColor`/`metallic`/`roughness`; an OmniPBR node publishes `diffuse_color_constant`/`metallic_constant`/ `reflection_roughness_constant`, so every lookup missed and the asset imported flat grey while the Import Report claimed RICHER_MATERIAL_AVAILABLE for a material the importer could in fact map. This is Option A of the handoff: the mapping is restored as part of the portable mapping rather than as a passthrough mode, since it emits the same physicallyBased material the preview-surface reader does. It reads from the retained Stage via UsdShade -- as tryMdlPassthrough() does -- because OmniPBR's input names are UsdShade concepts the resolved Hydra network does not model portably. The old input-name table is ported whole; the old TextureCache plumbing is not, textures now going through ImageCache like every other import path. OmniPBR is named exactly rather than by prefix, and only on the MDL Render Context's surface output, so a module that merely starts with the name cannot hijack a material that also authored a preview surface. Also collapses the three copies of the asset-path anchoring shape in this file into one anchoredAssetPath(), and hoists the duplicated boundMaterial() test helper to file scope. Co-Authored-By: Claude Opus 5 --- .../io/importers/detail/usd/UsdMaterials.cpp | 259 +++++++++++++++++- tsd/tests/test_UsdImport.cpp | 241 +++++++++++++++- 2 files changed, 477 insertions(+), 23 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index ccb274d7d..d3537eb22 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -205,6 +206,23 @@ float3 asFloat3(const pxr::VtValue &v, const float3 &alt) return alt; } +// The absolute path an asset-valued input names, empty when it names nothing. +// The Stage's resolver produces a path for anything it could find; what it +// could not -- a UDIM tile set names no file -- is anchored to the Stage's own +// directory instead. +std::string anchoredAssetPath( + ImportContext &ctx, const pxr::SdfAssetPath &assetPath) +{ + auto file = assetPath.GetResolvedPath(); + if (file.empty()) + file = assetPath.GetAssetPath(); + if (file.empty()) + return {}; + if (!isAbsolute(file)) + file = ctx.basePath + file; + return file; +} + // Native MDL passthrough, read from the retained Stage because the MDL source // asset and its sub-identifier are UsdShade concepts rather than something the // resolved network models portably. Returns a null ref when the material has @@ -267,6 +285,224 @@ MaterialRef tryMdlPassthrough( return {}; } +// OmniPBR mapping //////////////////////////////////////////////////////////// + +// The value an input actually produces, which for an Omniverse asset is often +// published on the Material's own interface input rather than authored on the +// shader. GetValueProducingAttributes() walks that connection for us. +template +std::optional shaderInputValue( + const pxr::UsdShadeShader &shader, const char *name, pxr::UsdTimeCode time) +{ + auto input = shader.GetInput(pxr::TfToken(name)); + if (!input) + return {}; + const auto sources = input.GetValueProducingAttributes(); + const auto attribute = sources.empty() ? input.GetAttr() : sources.front(); + T value; + if (attribute && attribute.Get(&value, time)) + return value; + return {}; +} + +// The file an OmniPBR texture input names. OmniPBR usually names it on the +// input directly, but an asset authored through a Material Graph reaches it +// through a texture-reader node instead, whose own `file` input is where the +// path actually is. +std::optional omniPbrTextureAsset( + const pxr::UsdShadeShader &shader, const char *name, pxr::UsdTimeCode time) +{ + if (auto asset = shaderInputValue(shader, name, time)) + return asset; + + auto input = shader.GetInput(pxr::TfToken(name)); + if (!input || !input.HasConnectedSource()) + return {}; + + pxr::UsdShadeConnectableAPI source; + pxr::TfToken sourceName; + pxr::UsdShadeAttributeType sourceType; + if (!input.GetConnectedSource(&source, &sourceName, &sourceType)) + return {}; + + pxr::UsdShadeShader reader(source.GetPrim()); + if (!reader) + return {}; + return shaderInputValue(reader, "file", time); +} + +// The OmniPBR shader driving a material's MDL surface, or an invalid shader +// when the material is something else. OmniPBR is the shader Omniverse authors +// by default, and it is named exactly rather than by prefix: a module whose +// name merely starts with it is a different shader with input semantics of its +// own, and mapping it as OmniPBR would be a guess. +// +// Only the MDL Render Context's surface output is asked. The universal one is +// where a UsdPreviewSurface network would be, and that network is the reader +// below's to handle. +pxr::UsdShadeShader omniPbrShaderOf(const pxr::UsdShadeMaterial &usdMaterial) +{ + const pxr::TfToken mdl("mdl"); + + auto isOmniPbr = [&](const pxr::UsdShadeShader &shader) { + pxr::TfToken subIdentifier; + if (shader.GetSourceAssetSubIdentifier(&subIdentifier, mdl) + && subIdentifier == pxr::TfToken("OmniPBR")) + return true; + + // An asset that named no sub-identifier is identified by its module. + pxr::SdfAssetPath sourceAsset; + if (!shader.GetSourceAsset(&sourceAsset, mdl)) + return false; + // The authored path is what names the module; a resolved one would name + // wherever this Stage's MDL search paths happened to find it. + auto module = sourceAsset.GetAssetPath(); + if (module.empty()) + module = sourceAsset.GetResolvedPath(); + return std::filesystem::path(module).stem().string() == "OmniPBR"; + }; + + auto output = usdMaterial.GetSurfaceOutput(mdl); + if (output) { + for (const auto &connection : output.GetConnectedSources()) { + pxr::UsdShadeShader shader(connection.source.GetPrim()); + if (shader && isOmniPbr(shader)) + return shader; + } + } + + return pxr::UsdShadeShader(); +} + +// Map an OmniPBR shader onto a portable physically-based material, read from +// the retained Stage because OmniPBR's inputs are UsdShade concepts that the +// resolved network does not model portably -- the same reason +// tryMdlPassthrough() reads from there. Returns a null ref when the material +// is not OmniPBR, so the caller can fall through to the preview-surface +// reader. +// +// Only the inputs that carry over are read: the preview-surface reader would +// look for `diffuseColor`/`metallic`/`roughness` and find none of OmniPBR's +// own names, leaving the asset flat grey. +MaterialRef tryOmniPbrMapping(ImportContext &ctx, + const pxr::SdfPath &materialPath, + const pxr::TfToken &primType) +{ + auto usdPrim = ctx.stage->GetPrimAtPath(materialPath); + if (!usdPrim) + return {}; + + pxr::UsdShadeMaterial usdMaterial(usdPrim); + if (!usdMaterial) + return {}; + + auto shader = omniPbrShaderOf(usdMaterial); + if (!shader) + return {}; + + auto material = + ctx.scene.createObject(tokens::material::physicallyBased); + material->setName(materialPath.GetString().c_str()); + + // Every textured input takes precedence over its constant, which is what + // OmniPBR itself does with them. + auto bindTexture = + [&](const char *usdName, const char *tsdName, bool colorRole) -> bool { + const auto asset = omniPbrTextureAsset(shader, usdName, ctx.importTime); + if (!asset) + return false; + const auto file = anchoredAssetPath(ctx, *asset); + if (file.empty()) + return false; + // OmniPBR names no colour space of its own, so the role of the input is + // what says whether its texels must be de-gamma'd on load. + auto sampler = importTexture(ctx.scene, file, ctx.textureCache, !colorRole); + if (!sampler) { + ctx.reportSkip(materialPath, + primType.GetString(), + UsdSkipReason::TEXTURE_LOAD_FAILED, + file); + return false; + } + material->setParameterObject(Token(tsdName), *sampler); + return true; + }; + + // alphaMode is a string selection, so the index has to move with the value + // or the two disagree wherever the selection is what gets read. + auto setAlphaMode = [&](const char *mode) { + material->setParameter("alphaMode", mode); + auto *parameter = material->parameter("alphaMode"); + const auto &modes = parameter->stringValues(); + for (size_t i = 0; i < modes.size(); ++i) { + if (modes[i] == mode) { + parameter->setStringSelection(int(i)); + break; + } + } + }; + + auto scalar = [&](const char *name) { + return shaderInputValue(shader, name, ctx.importTime); + }; + auto color = [&](const char *name) -> std::optional { + const auto value = + shaderInputValue(shader, name, ctx.importTime); + if (!value) + return {}; + return float3((*value)[0], (*value)[1], (*value)[2]); + }; + + if (!bindTexture("diffuse_texture", "baseColor", true)) { + if (const auto diffuse = color("diffuse_color_constant")) + material->setParameter("baseColor", *diffuse); + } + + // The colour is what OmniPBR emits at unit intensity; the two multiply. + if (shaderInputValue(shader, "enable_emission", ctx.importTime) + .value_or(false)) { + const auto emissive = color("emissive_color").value_or(float3(0.f)); + const auto intensity = scalar("emissive_intensity").value_or(1.f); + material->setParameter("emissive", emissive * intensity); + } + + // OmniPBR's own defaults, which differ from the portable material's, so an + // asset that leaves these unauthored still looks like it did in Omniverse. + if (!bindTexture("metallic_texture", "metallic", false)) + material->setParameter( + "metallic", scalar("metallic_constant").value_or(0.f)); + if (!bindTexture("reflectionroughness_texture", "roughness", false)) { + material->setParameter( + "roughness", scalar("reflection_roughness_constant").value_or(0.5f)); + } + + bindTexture("normalmap_texture", "normal", false); + bindTexture("ao_texture", "occlusion", false); + + if (shaderInputValue(shader, "enable_opacity", ctx.importTime) + .value_or(false)) { + if (!bindTexture("opacity_texture", "opacity", false)) { + if (const auto opacity = scalar("opacity_constant")) + material->setParameter("opacity", *opacity); + } + // A threshold of zero means OmniPBR blends rather than cuts out. + const auto threshold = scalar("opacity_threshold").value_or(0.f); + if (threshold > 0.f) { + setAlphaMode("mask"); + material->setParameter("alphaCutoff", threshold); + } else + setAlphaMode("blend"); + } else + setAlphaMode("opaque"); + + if (const auto ior = scalar("ior_constant")) + material->setParameter("ior", *ior); + if (const auto specular = scalar("specular_level")) + material->setParameter("specular", *specular); + + return material; +} + #if TSD_USD_HAS_MATERIALX // The node a generated document holds under `name`, which OpenUSD's conversion @@ -345,14 +581,10 @@ std::vector resolveTexturePaths(ImportContext &ctx, if (!value.IsHolding()) continue; - const auto assetPath = value.UncheckedGet(); - auto file = assetPath.GetResolvedPath(); - if (file.empty()) - file = assetPath.GetAssetPath(); + const auto file = + anchoredAssetPath(ctx, value.UncheckedGet()); if (file.empty()) continue; - if (!isAbsolute(file)) - file = ctx.basePath + file; input->setValueString(file); @@ -694,6 +926,13 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, } } + // OmniPBR is part of the portable mapping rather than a passthrough mode: it + // maps onto the same physicallyBased material the preview-surface reader + // emits, and it has to be tried first because that reader would find none of + // OmniPBR's input names and emit its defaults instead. + if (auto material = tryOmniPbrMapping(ctx, materialPath, prim.primType)) + return cacheAndReturn(material); + if (!materialSchema) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -768,14 +1007,10 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, if (!fileValue.IsHolding()) return false; - const auto assetPath = fileValue.UncheckedGet(); - auto file = assetPath.GetResolvedPath(); - if (file.empty()) - file = assetPath.GetAssetPath(); + const auto file = + anchoredAssetPath(ctx, fileValue.UncheckedGet()); if (file.empty()) return false; - if (!isAbsolute(file)) - file = ctx.basePath + file; // Everything the binding varies goes in before the sampler is built: // makeImageSampler owns inTransform/inOffset, because an image that could diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index b9fecca85..4b8110d1b 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -199,6 +199,18 @@ tsd::scene::GeometryRef findGeometry(tsd::scene::Scene &scene, const char *name) return findObject(scene, ANARI_GEOMETRY, name); } +// The material a Surface actually uses, rather than whatever happens to sit at +// index 0 of the pool (which is the Scene's own default material). +tsd::scene::Material *boundMaterial(tsd::scene::Scene &scene) +{ + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + return material; +} + constexpr const char *QUAD_MESH_BODY = R"( int[] faceVertexCounts = [4] int[] faceVertexIndices = [0, 1, 2, 3] @@ -1228,17 +1240,6 @@ def Xform "World" SCENARIO("Native material passthrough is opt-in", "[UsdImport]") { - // The material a Surface actually uses, rather than whatever happens to sit - // at index 0 of the pool (which is the Scene's own default material). - auto boundMaterial = [](tsd::scene::Scene &scene) { - auto surface = scene.getObject(0); - REQUIRE(surface); - auto *material = surface->parameterValueAsObject( - tsd::scene::tokens::surface::material); - REQUIRE(material != nullptr); - return material; - }; - auto stringParameter = [](tsd::scene::Material *material, const char *name) { auto *p = material->parameter(name); return p ? p->value().getString() : std::string(); @@ -1678,6 +1679,224 @@ def Xform "World" #endif } +SCENARIO("An OmniPBR material maps onto the portable material", "[UsdImport]") +{ + GIVEN("A Stage whose material is an OmniPBR MDL shader") + { + StageFixture stage("tsd_test_usd_omnipbr.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "OmniPBR" + { + token outputs:mdl:surface.connect = + def Shader "Shader" + { + uniform token info:implementationSource = "sourceAsset" + uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@ + uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR" + color3f inputs:diffuse_color_constant = (0.9, 0.1, 0.2) + float inputs:metallic_constant = 0.75 + float inputs:reflection_roughness_constant = 0.25 + float inputs:ior_constant = 1.4 + bool inputs:enable_emission = 1 + color3f inputs:emissive_color = (0, 0.5, 0) + float inputs:emissive_intensity = 2 + token outputs:out + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + auto *material = boundMaterial(scene); + + THEN("Its authored inputs arrive, not the preview-surface defaults") + { + REQUIRE(material->subtype() + == tsd::scene::tokens::material::physicallyBased); + + auto color = material->parameterValueAs("baseColor"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(0.9f)); + REQUIRE(color->y == Approx(0.1f)); + REQUIRE(color->z == Approx(0.2f)); + + REQUIRE( + *material->parameterValueAs("metallic") == Approx(0.75f)); + REQUIRE( + *material->parameterValueAs("roughness") == Approx(0.25f)); + REQUIRE(*material->parameterValueAs("ior") == Approx(1.4f)); + + auto emissive = + material->parameterValueAs("emissive"); + REQUIRE(emissive.has_value()); + REQUIRE(emissive->y == Approx(1.0f)); + } + + THEN("Nothing claims a richer material was left on the table") + { + REQUIRE(!report.contains( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE)); + } + } + + WHEN("MDL passthrough is asked for instead") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MDL; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The native shader still wins over the portable mapping") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::mdl); + } + } + } + + GIVEN("An OmniPBR material reading textures and cutting out on opacity") + { + TextureFixture diffuse("tsd_test_usd_omnipbr_diffuse.tga"); + + StageFixture stage("tsd_test_usd_omnipbr_textured.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "OmniPBR" + { + token outputs:mdl:surface.connect = + def Shader "Shader" + { + uniform token info:implementationSource = "sourceAsset" + uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@ + uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR" + asset inputs:diffuse_texture = @tsd_test_usd_omnipbr_diffuse.tga@ + bool inputs:enable_opacity = 1 + float inputs:opacity_threshold = 0.3 + token outputs:out + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + auto *material = boundMaterial(scene); + + THEN("The texture named on the shader input is bound") + { + REQUIRE( + material->parameterValueAsObject("baseColor") + != nullptr); + REQUIRE(!report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + } + + THEN("An authored threshold becomes a cutout rather than a blend") + { + REQUIRE(material->parameterValueAs("alphaMode") + == std::string("mask")); + REQUIRE( + *material->parameterValueAs("alphaCutoff") == Approx(0.3f)); + + // The mode is a string selection, so the index has to agree with the + // value wherever the selection is what gets read. + auto *alphaMode = material->parameter("alphaMode"); + REQUIRE(alphaMode->stringValues()[alphaMode->stringSelection()] + == std::string("mask")); + } + } + } + + GIVEN("A material whose MDL module only looks like OmniPBR") + { + StageFixture stage("tsd_test_usd_omnipbr_lookalike.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Lookalike" + { + token outputs:mdl:surface.connect = + token outputs:surface.connect = + + def Shader "Mdl" + { + uniform token info:implementationSource = "sourceAsset" + uniform asset info:mdl:sourceAsset = @OmniPBRBase.mdl@ + uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBRBase" + color3f inputs:diffuse_color_constant = (0.9, 0.1, 0.2) + token outputs:out + } + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.1, 0.2, 0.9) + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("A module that merely starts with the name is not mapped as one") + { + // The authored preview surface is what this material actually says; + // OmniPBRBase is a different shader with input semantics of its own. + auto color = boundMaterial(scene)->parameterValueAs( + "baseColor"); + REQUIRE(color.has_value()); + REQUIRE(color->z == Approx(0.9f)); + } + } + } +} + SCENARIO( "A prim with no bound material takes its display colour", "[UsdImport]") { From 6ae6afa988e4806906b22d6b0deb73630e5a9cba Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 13:36:12 -0500 Subject: [PATCH 36/78] refactor(io): make resolveMaterial a dispatcher over one material cache resolveMaterial() was doing five jobs at once: cache lookup, material-mode dispatch, a time-sampled-shader-input scan, network and terminal selection, and the whole UsdPreviewSurface-to-physicallyBased mapping. The two passthrough modes read as `if`/`else if` prefixes bolted onto the front of the portable converter, so the default path was the last thing the reader found. Lift the scan to reportAnimatedShaderInputs() and the portable mapping to convertPreviewSurface(), and let tryMaterialXPassthrough() select its own network from the material schema. What is left is the dispatch the function always was. Collapse materialCache and uvPrimvarCache into one map of ResolvedMaterial. They were keyed identically and always written together, so the cache-hit path had to reconstruct a ResolvedMaterial from both and the write path had to push a placeholder into the second to keep them aligned. Cache the negative result too. A broken material bound by N prims used to re-run the full resolution N times and push N identical entries into report.skipped, which scaled the counts UsdImportReport::summary() prints with the binding count rather than the material count. Co-Authored-By: Claude Opus 5 --- .../io/importers/detail/usd/UsdDialect.cpp | 2 +- .../importers/detail/usd/UsdImportContext.h | 18 +- .../io/importers/detail/usd/UsdMaterials.cpp | 217 ++++++++++-------- .../io/importers/detail/usd/UsdMaterials.h | 11 - tsd/tests/test_UsdImport.cpp | 73 ++++++ 5 files changed, 208 insertions(+), 113 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index 64bf84fce..8d609ded4 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -247,7 +247,7 @@ MaterialRef boundMaterialOf(ImportContext &ctx, const pxr::UsdPrim &prim) const auto key = usdMaterial.GetPath().GetString(); if (auto found = ctx.materialCache.find(key); found != ctx.materialCache.end()) - return found->second; + return found->second.material; return {}; } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index bc35a55e7..d5a282918 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -31,6 +31,21 @@ using namespace tsd::scene; struct ClaimedPrims; +/* + * A material as TSD sees it, together with the primvar its own texture-reader + * node asked for. The UV name travels with the material because the geometry + * converter must bind that primvar rather than assume a conventional name. + * + * A default-constructed value is a material that did not resolve, which is + * cached like any other so a Stage binding one broken material a thousand + * times resolves and reports it once. + */ +struct ResolvedMaterial +{ + MaterialRef material; + std::string uvPrimvarName; +}; + /* * Everything one USD Stage import needs to carry between converters: the * target Scene, the settings driving the import, the report being accumulated, @@ -81,8 +96,7 @@ struct ImportContext // Caches keyed by resolved prim path, so shared content converts once. ImageCache textureCache{&scene}; - std::unordered_map materialCache; - std::unordered_map uvPrimvarCache; + std::unordered_map materialCache; void reportSkip(const pxr::SdfPath &primPath, const std::string &primType, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index d3537eb22..f00bf14e0 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -503,6 +503,26 @@ MaterialRef tryOmniPbrMapping(ImportContext &ctx, return material; } +// Try each Render Context in the caller's preference order, falling back per +// material so a Stage mixing network flavours resolves completely either way. +pxr::HdMaterialNetworkSchema selectNetwork( + const pxr::HdMaterialSchema &material, + const std::vector &preference) +{ + for (const auto &context : preference) { + auto network = material.GetMaterialNetwork(pxr::TfToken(context)); + if (network && network.GetNodes()) + return network; + } + // Nothing preferred matched: take whatever the material does have. + for (const auto &context : material.GetRenderContexts()) { + auto network = material.GetMaterialNetwork(context); + if (network && network.GetNodes()) + return network; + } + return material.GetMaterialNetwork(); +} + #if TSD_USD_HAS_MATERIALX // The node a generated document holds under `name`, which OpenUSD's conversion @@ -704,8 +724,16 @@ bool documentResolves(const MaterialX::DocumentPtr &document, std::string &why) MaterialRef tryMaterialXPassthrough(ImportContext &ctx, const pxr::SdfPath &materialPath, const pxr::HdSceneIndexPrim &prim, - const pxr::HdMaterialNetworkSchema &network) + const pxr::HdMaterialSchema &materialSchema) { + if (!materialSchema) + return {}; + + // Prefer an authored MaterialX network, but a preview-surface network also + // converts through its own MaterialX node definitions. + auto network = materialSchema.GetMaterialNetwork(MATERIALX_CONTEXT); + if (!network || !network.GetNodes()) + network = selectNetwork(materialSchema, ctx.options.renderContexts); if (!network || !network.GetNodes()) return {}; @@ -818,94 +846,12 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, #endif -// Try each Render Context in the caller's preference order, falling back per -// material so a Stage mixing network flavours resolves completely either way. -pxr::HdMaterialNetworkSchema selectNetwork( - const pxr::HdMaterialSchema &material, - const std::vector &preference) -{ - for (const auto &context : preference) { - auto network = material.GetMaterialNetwork(pxr::TfToken(context)); - if (network && network.GetNodes()) - return network; - } - // Nothing preferred matched: take whatever the material does have. - for (const auto &context : material.GetRenderContexts()) { - auto network = material.GetMaterialNetwork(context); - if (network && network.GetNodes()) - return network; - } - return material.GetMaterialNetwork(); -} - -} // namespace - -ResolvedMaterial resolveMaterial(ImportContext &ctx, - const pxr::HdSceneIndexBaseRefPtr &sceneIndex, - const pxr::SdfPath &materialPath) +// Material values are imported at one time, so say when the Stage animates +// them rather than leaving the difference to be noticed. +void reportAnimatedShaderInputs(ImportContext &ctx, + const pxr::SdfPath &materialPath, + const std::string &primType) { - if (materialPath.IsEmpty()) - return {}; - - const auto key = materialPath.GetString(); - if (auto found = ctx.materialCache.find(key); - found != ctx.materialCache.end()) { - ResolvedMaterial retval; - retval.material = found->second; - retval.uvPrimvarName = ctx.uvPrimvarCache[key]; - return retval; - } - - auto prim = sceneIndex->GetPrim(materialPath); - auto materialSchema = pxr::HdMaterialSchema::GetFromParent(prim.dataSource); - - auto cacheAndReturn = [&](MaterialRef material) { - ctx.materialCache[key] = material; - ctx.uvPrimvarCache[key] = std::string(); - ResolvedMaterial retval; - retval.material = material; - return retval; - }; - - // Native passthrough modes are opt-in; each falls back to the portable - // mapping, saying so, rather than dropping the material. - if (ctx.options.materialMode == UsdMaterialMode::MDL) { - if (auto material = tryMdlPassthrough(ctx, materialPath)) - return cacheAndReturn(material); - ctx.reportSkip(materialPath, - prim.primType.GetString(), - UsdSkipReason::RICHER_MATERIAL_AVAILABLE, - "no MDL network authored; reading a portable mapping instead"); - } else if (ctx.options.materialMode == UsdMaterialMode::MATERIALX) { -#if TSD_USD_HAS_MATERIALX - // Prefer an authored MaterialX network, but a preview-surface network also - // converts through its own MaterialX node definitions. - if (materialSchema) { - auto network = materialSchema.GetMaterialNetwork(MATERIALX_CONTEXT); - if (!network || !network.GetNodes()) - network = selectNetwork(materialSchema, ctx.options.renderContexts); - if (auto material = - tryMaterialXPassthrough(ctx, materialPath, prim, network)) - return cacheAndReturn(material); - } - ctx.reportSkip(materialPath, - prim.primType.GetString(), - UsdSkipReason::RICHER_MATERIAL_AVAILABLE, - "no network could be converted to a MaterialX document; reading a" - " portable mapping instead"); -#else - // MaterialX passthrough needs OpenUSD's HdMtlx document conversion, which - // this build of OpenUSD does not ship. - ctx.reportSkip(materialPath, - prim.primType.GetString(), - UsdSkipReason::RICHER_MATERIAL_AVAILABLE, - "MaterialX passthrough is unavailable in this OpenUSD build; " - "reading a portable mapping instead"); -#endif - } - - // Material values are imported at one time, so say when the Stage animates - // them rather than leaving the difference to be noticed. if (auto usdPrim = ctx.stage->GetPrimAtPath(materialPath)) { for (const auto &descendant : usdPrim.GetDescendants()) { pxr::UsdShadeShader shader(descendant); @@ -918,21 +864,24 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, animated = animated || attributeValueVaries(input.GetAttr()); if (animated) { ctx.reportSkip(materialPath, - prim.primType.GetString(), + primType, UsdSkipReason::TIME_VARYING_VALUE_DROPPED, "shader inputs are time-sampled; imported at one time"); break; } } } +} - // OmniPBR is part of the portable mapping rather than a passthrough mode: it - // maps onto the same physicallyBased material the preview-surface reader - // emits, and it has to be tried first because that reader would find none of - // OmniPBR's input names and emit its defaults instead. - if (auto material = tryOmniPbrMapping(ctx, materialPath, prim.primType)) - return cacheAndReturn(material); - +// The portable mapping: read the network's surface terminal as a +// UsdPreviewSurface and emit the physicallyBased material it describes. This +// is where every material not passed through natively ends up, so a Stage +// authored for another renderer still arrives with something bound. +ResolvedMaterial convertPreviewSurface(ImportContext &ctx, + const pxr::HdMaterialSchema &materialSchema, + const pxr::SdfPath &materialPath, + const pxr::HdSceneIndexPrim &prim) +{ if (!materialSchema) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -977,7 +926,7 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, auto material = ctx.scene.createObject(tokens::material::physicallyBased); - material->setName(key.c_str()); + material->setName(materialPath.GetString().c_str()); ResolvedMaterial retval; retval.material = material; @@ -1060,9 +1009,79 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, setFloatIfPresent("clearcoatRoughness", "clearcoatRoughness"); setFloatIfPresent("ior", "ior"); - ctx.materialCache[key] = material; - ctx.uvPrimvarCache[key] = retval.uvPrimvarName; return retval; } +} // namespace + +ResolvedMaterial resolveMaterial(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &materialPath) +{ + if (materialPath.IsEmpty()) + return {}; + + const auto key = materialPath.GetString(); + if (auto found = ctx.materialCache.find(key); + found != ctx.materialCache.end()) + return found->second; + + auto prim = sceneIndex->GetPrim(materialPath); + const auto primType = prim.primType.GetString(); + auto materialSchema = pxr::HdMaterialSchema::GetFromParent(prim.dataSource); + + // Every exit path caches, failures included: a material that cannot be + // resolved is resolved -- and reported -- once, not once per binding. + auto cache = [&](ResolvedMaterial resolved) { + ctx.materialCache[key] = resolved; + return resolved; + }; + + // Native passthrough modes are opt-in; each falls back to the portable + // mapping, saying so, rather than dropping the material. + switch (ctx.options.materialMode) { + case UsdMaterialMode::MDL: + if (auto material = tryMdlPassthrough(ctx, materialPath)) + return cache({material}); + ctx.reportSkip(materialPath, + primType, + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "no MDL network authored; reading a portable mapping instead"); + break; + case UsdMaterialMode::MATERIALX: +#if TSD_USD_HAS_MATERIALX + if (auto material = + tryMaterialXPassthrough(ctx, materialPath, prim, materialSchema)) + return cache({material}); + ctx.reportSkip(materialPath, + primType, + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "no network could be converted to a MaterialX document; reading a" + " portable mapping instead"); +#else + // MaterialX passthrough needs OpenUSD's HdMtlx document conversion, which + // this build of OpenUSD does not ship. + ctx.reportSkip(materialPath, + primType, + UsdSkipReason::RICHER_MATERIAL_AVAILABLE, + "MaterialX passthrough is unavailable in this OpenUSD build; " + "reading a portable mapping instead"); +#endif + break; + case UsdMaterialMode::PHYSICALLY_BASED: + break; + } + + reportAnimatedShaderInputs(ctx, materialPath, primType); + + // OmniPBR is part of the portable mapping rather than a passthrough mode: it + // maps onto the same physicallyBased material the preview-surface reader + // emits, and it has to be tried first because that reader would find none of + // OmniPBR's input names and emit its defaults instead. + if (auto material = tryOmniPbrMapping(ctx, materialPath, prim.primType)) + return cache({material}); + + return cache(convertPreviewSurface(ctx, materialSchema, materialPath, prim)); +} + } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h index 7fd40cf1a..0319861bc 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.h @@ -11,17 +11,6 @@ namespace tsd::io::usd { -/* - * A material as TSD sees it, together with the primvar its own texture-reader - * node asked for. The UV name travels with the material because the geometry - * converter must bind that primvar rather than assume a conventional name. - */ -struct ResolvedMaterial -{ - MaterialRef material; - std::string uvPrimvarName; -}; - // Resolve the material bound at `materialPath` in the resolved scene, honouring // the Render Context preference order with per-material fallback. Results are // cached on the context so a shared material converts once. diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 4b8110d1b..2ebf2d25d 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -1679,6 +1679,79 @@ def Xform "World" #endif } +// A material resolves once and is cached, including when it does not resolve +// at all. Without a negative entry every prim bound to the same broken +// material re-runs the resolve and files its own Import Report entry, which +// makes the counts the report prints scale with the binding count. +SCENARIO( + "An unresolvable material is reported once per material rather than" + " once per binding", + "[UsdImport]") +{ + GIVEN("A Stage where three meshes bind one material with no network") + { + StageFixture stage("tsd_test_usd_unresolvable_material.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Broken" + { + } + + def Mesh "QuadA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } + + def Mesh "QuadB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + rel material:binding = + } + + def Mesh "QuadC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(4, 0, 0), (5, 0, 0), (5, 1, 0), (4, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The Stage is imported") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = + tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}); + + THEN("The Import Report names the material exactly once") + { + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED) + == 1); + } + + THEN("Every mesh still arrives") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 3); + } + } + } +} + SCENARIO("An OmniPBR material maps onto the portable material", "[UsdImport]") { GIVEN("A Stage whose material is an OmniPBR MDL shader") From 9010894e20005564a394fa03be4a9f5a9161a54c Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 17:38:49 -0500 Subject: [PATCH 37/78] fix(io): keep the Import's Animation reachable when another one is added A USD import caches the Animation it creates so every binding lands in one Animation, but it cached a pointer, and the AnimationManager holds its Animations by value in a vector. An animated camera adds its own Animation mid-traversal, that push_back reallocates, and every transform binding afterwards is appended through the Animation that just moved: writes land in freed memory. ZeroDay_1.usdc, which has 8642 animated Xforms and an animated camera, corrupts the importer's own prim->node map and segfaults ~5000 prims in; Valgrind names the freed vector directly. Scenes with no camera prim -- every warp scene the feature was verified on -- never add a second Animation and so never showed it. The cache is now the Animation's index, which no reallocation moves. Routing the camera and the dialect through ImportContext::animation() would fix this Stage too, but leaves the same trap set for the next caller that holds an Animation reference across an addAnimation(). Two latent out-of-bounds in the same call chain, neither reachable today: densifyInterval() took its endpoint matrices by reference while appending to the vector one of them lives in, and addTransformStepBinding() sized its decomposed arrays by the frame count while telling the binding to read the time-base count. Verified: ZeroDay_1.usdc imports 7295 prims with 0 skips and 323 animated prims bound, and renders through its own camera; all three warp scenes still import and scrub; all 32 tests pass. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/importers/detail/importer_common.cpp | 6 ++++-- tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp | 7 +++++-- tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp | 8 +++++--- tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h | 8 ++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 5b2309d59..1cb27ef90 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -809,7 +809,9 @@ void addTransformStepBinding(tsd::animation::Animation &anim, const std::vector &frames, const std::vector &timeBase) { - size_t n = frames.size(); + // Every array handed to the binding is sized by the decomposition below, so + // the shorter of the two inputs is what can actually be read. + size_t n = std::min(frames.size(), timeBase.size()); std::vector rotation(n); std::vector translation(n); std::vector scale(n); @@ -837,7 +839,7 @@ void addTransformStepBinding(tsd::animation::Animation &anim, rotation.data(), translation.data(), scale.data(), - timeBase.size()); + n); } } // namespace tsd::io diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp index 84e860f7c..9a65826a8 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -59,11 +59,14 @@ tsd::math::mat4 TransformSampler::at(double time) const // interpolation: the endpoints coincide and the motion collapses. Recursively // insert the midpoint wherever the path from a key to the interval's midpoint // turns further than a quarter turn. +// m0 and m1 are taken by value: the caller's m0 is an element of `outFrames`, +// which this recursion appends to, so a reference into it dies on the first +// reallocation. void densifyInterval(const TransformSampler &sampler, double t0, - const tsd::math::mat4 &m0, + tsd::math::mat4 m0, double t1, - const tsd::math::mat4 &m1, + tsd::math::mat4 m1, int depth, std::vector &outTimes, std::vector &outFrames) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp index be6310940..11a436d3a 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp @@ -14,9 +14,11 @@ namespace tsd::io::usd { tsd::animation::Animation &ImportContext::animation() { - if (!importAnimation) - importAnimation = &animMgr.addAnimation(filePath); - return *importAnimation; + if (importAnimationIndex == NO_ANIMATION) { + animMgr.addAnimation(filePath); + importAnimationIndex = animMgr.animations().size() - 1; + } + return animMgr.animations()[importAnimationIndex]; } void ImportContext::reportAnimatedPrim(size_t sampleCount) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index d5a282918..a75d94b3e 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -91,8 +91,12 @@ struct ImportContext const ClaimedPrims *claimedPrims{nullptr}; bool isClaimed(const pxr::SdfPath &path) const; - // Set by animation(); this stays an aggregate, so it cannot be private. - tsd::animation::Animation *importAnimation{nullptr}; + // Set by animation(); this stays an aggregate, so it cannot be private. An + // index rather than a pointer or reference: the AnimationManager holds its + // Animations by value in a vector, so any other addAnimation() during this + // import -- a camera's, the dialect's -- moves the one this import made. + static constexpr size_t NO_ANIMATION = ~size_t(0); + size_t importAnimationIndex{NO_ANIMATION}; // Caches keyed by resolved prim path, so shared content converts once. ImageCache textureCache{&scene}; From a4eb07cad51babd22376a6effab91b2d94b48db4 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 19:48:39 -0500 Subject: [PATCH 38/78] refactor(io): store ImportContext's non-owning members as pointers This branch added "Store Non-Owning Members as Pointers, Never References" to STYLEGUIDE.md and then broke it in ImportContext, the type threaded through every USD converter. Taking Option A of the review item: follow the rule. Scene, AnimationManager, UsdImportOptions and UsdImportReport become nullptr-initialised raw pointers, so ImportContext is assignable and movable like every other TSD aggregate. import_USD() constructs the only ImportContext there is and never passes null, so nullability is handled at that boundary -- no null checks are added to converter bodies. textureCache's initialiser drops its address-of. Mechanical and behaviour-preserving. --- .../io/importers/detail/usd/UsdAnimation.cpp | 4 +-- .../io/importers/detail/usd/UsdDialect.cpp | 30 +++++++++---------- .../io/importers/detail/usd/UsdGeometry.cpp | 12 ++++---- .../importers/detail/usd/UsdImportContext.cpp | 12 ++++---- .../importers/detail/usd/UsdImportContext.h | 15 +++++----- .../io/importers/detail/usd/UsdInstancing.cpp | 16 +++++----- .../tsd/io/importers/detail/usd/UsdLights.cpp | 26 ++++++++-------- .../io/importers/detail/usd/UsdMaterials.cpp | 21 ++++++------- tsd/src/tsd/io/importers/import_USD.cpp | 30 +++++++++---------- 9 files changed, 84 insertions(+), 82 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp index 9a65826a8..9ba9a3814 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -175,7 +175,7 @@ void addInstancerAnimation(ImportContext &ctx, LayerNodeRef arrayNode, ArrayRef transforms) { - ctx.animation().emplaceFileBinding(&ctx.scene, + ctx.animation().emplaceFileBinding(ctx.scene, ctx.session, arrayNode, transforms, @@ -216,7 +216,7 @@ void addDeformingGeometryAnimation(ImportContext &ctx, // One eager frame is already in the Scene; the rest is pulled from the // shared Stage Session on demand (ADR 0018), re-resolved rather than // re-converted (ADR 0022). - ctx.animation().emplaceFileBinding(&ctx.scene, + ctx.animation().emplaceFileBinding(ctx.scene, ctx.session, ctx.filePath, primPath.GetString(), diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index 8d609ded4..58377321f 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -302,9 +302,9 @@ void importEnsightDataset(ImportContext &ctx, perPartMaterials[child.GetName().GetString()] = childMaterial; } - auto scopeNode = ctx.scene.insertChildNode(parent, primName.c_str()); - import_ENSIGHT(ctx.scene, - ctx.animMgr, + auto scopeNode = ctx.scene->insertChildNode(parent, primName.c_str()); + import_ENSIGHT(*ctx.scene, + *ctx.animMgr, caseFile.c_str(), scopeNode, fields, @@ -312,7 +312,7 @@ void importEnsightDataset(ImportContext &ctx, fallbackMaterial, perPartMaterials, 0); - ctx.report.convertedPrims++; + ctx.report->convertedPrims++; } } // namespace @@ -442,19 +442,19 @@ bool convertVolume( SpatialFieldRef field; if (isVtuAsset) { field = import_spatial_field( - ctx.scene, filePath.c_str(), std::move(propertyName)); + *ctx.scene, filePath.c_str(), std::move(propertyName)); } else { const auto extension = extensionOf(filePath); if (extension == ".raw") - field = import_RAW(ctx.scene, filePath.c_str()); + field = import_RAW(*ctx.scene, filePath.c_str()); else if (extension == ".flash") - field = import_FLASH(ctx.scene, filePath.c_str()); + field = import_FLASH(*ctx.scene, filePath.c_str()); else if (extension == ".nvdb" || extension == ".vdb") - field = import_NVDB(ctx.scene, filePath.c_str()); + field = import_NVDB(*ctx.scene, filePath.c_str()); else if (extension == ".mhd") - field = import_MHD(ctx.scene, filePath.c_str()); + field = import_MHD(*ctx.scene, filePath.c_str()); else if (extension == ".vtu") - field = import_VTU(ctx.scene, filePath.c_str(), propertyName); + field = import_VTU(*ctx.scene, filePath.c_str(), propertyName); } if (!field) @@ -463,7 +463,7 @@ bool convertVolume( const auto tf = getVolumeTransferFunction(prim); auto valueRange = field->computeValueRange(); - auto [volumeNode, volume] = ctx.scene.insertNewChildObjectNode( + auto [volumeNode, volume] = ctx.scene->insertNewChildObjectNode( node, tokens::volume::transferFunction1D); volume->setName(primName.c_str()); volume->setParameterObject("value", *field); @@ -472,7 +472,7 @@ bool convertVolume( if (tf.hasTransferFunction && !tf.colors.empty()) { auto coreTF = toTransferFunction(tf); if (!coreTF.colorPoints.empty() && !coreTF.opacityPoints.empty()) { - applyTransferFunction(ctx.scene, volume, coreTF); + applyTransferFunction(*ctx.scene, volume, coreTF); if (coreTF.range.lower < coreTF.range.upper) valueRange = math::float2(coreTF.range.lower, coreTF.range.upper); appliedTransferFunction = true; @@ -481,7 +481,7 @@ bool convertVolume( if (!appliedTransferFunction) { auto colors = makeDefaultColorMap(256); - auto colorArray = ctx.scene.createArray(ANARI_FLOAT32_VEC4, colors.size()); + auto colorArray = ctx.scene->createArray(ANARI_FLOAT32_VEC4, colors.size()); colorArray->setData(colors); volume->setParameterObject("color", *colorArray); volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); @@ -504,9 +504,9 @@ bool convertVolume( volume->setParameter("unitDistance", unitDistance); if (filePaths.size() > 1) { - auto &animation = ctx.animMgr.addAnimation(primName); + auto &animation = ctx.animMgr->addAnimation(primName); animation.emplaceFileBinding( - &ctx.scene, volume.data(), field, std::move(filePaths)); + ctx.scene, volume.data(), field, std::move(filePaths)); } return true; diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp index 58149319c..cc13faebe 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -35,7 +35,7 @@ MaterialRef displayColorMaterial(ImportContext &ctx, const pxr::SdfPath &primPath, const pxr::HdSceneIndexPrim &prim) { - auto retval = ctx.scene.createObject(tokens::material::matte); + auto retval = ctx.scene->createObject(tokens::material::matte); retval->setName((primPath.GetString() + "_displayColor").c_str()); const auto display = readDisplayColor(prim); @@ -122,7 +122,7 @@ void buildParts(ImportContext &ctx, FlatMap sharedArrays; for (const auto &part : resolved.parts) { - auto geometry = ctx.scene.createObject(part.subtype); + auto geometry = ctx.scene->createObject(part.subtype); geometry->setName(part.name.c_str()); for (const auto &attribute : part.attributes) { @@ -133,7 +133,7 @@ void buildParts(ImportContext &ctx, if (auto *shared = sharedArrays.at(attribute.sharedKey)) array = *shared; if (!array) { - array = ctx.scene.createArray(attribute.type, attribute.count()); + array = ctx.scene->createArray(attribute.type, attribute.count()); array->setData(attribute.data()); if (!attribute.sharedKey.empty()) sharedArrays.set(attribute.sharedKey, array); @@ -149,7 +149,7 @@ void buildParts(ImportContext &ctx, out.geometryByPart.emplace_back(part.name, geometry); out.surfaces.push_back( - ctx.scene.createSurface(part.name.c_str(), geometry, material)); + ctx.scene->createSurface(part.name.c_str(), geometry, material)); } } @@ -170,9 +170,9 @@ ConvertedGeometry convertGeometry(ImportContext &ctx, return retval; retval.resolveOptions.bakeXform = bakeXform; - retval.resolveOptions.refinementLevel = ctx.options.refinementLevel; + retval.resolveOptions.refinementLevel = ctx.options->refinementLevel; retval.resolveOptions.refine = - meshWantsRefinement(ctx.stage, ctx.options.refinementLevel, primPath); + meshWantsRefinement(ctx.stage, ctx.options->refinementLevel, primPath); // Resolve first, so materials are resolved only for the Parts that exist. auto resolved = diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp index 11a436d3a..c5dd43d9c 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp @@ -15,18 +15,18 @@ namespace tsd::io::usd { tsd::animation::Animation &ImportContext::animation() { if (importAnimationIndex == NO_ANIMATION) { - animMgr.addAnimation(filePath); - importAnimationIndex = animMgr.animations().size() - 1; + animMgr->addAnimation(filePath); + importAnimationIndex = animMgr->animations().size() - 1; } - return animMgr.animations()[importAnimationIndex]; + return animMgr->animations()[importAnimationIndex]; } void ImportContext::reportAnimatedPrim(size_t sampleCount) { - report.animatedPrims++; - report.sampleCount = std::max(report.sampleCount, sampleCount); + report->animatedPrims++; + report->sampleCount = std::max(report->sampleCount, sampleCount); if (session) - report.timeCodesPerSecond = float(session->timeCodesPerSecond()); + report->timeCodesPerSecond = float(session->timeCodesPerSecond()); } bool ImportContext::isClaimed(const pxr::SdfPath &path) const diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index a75d94b3e..eaf3ee9fc 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -54,16 +54,17 @@ struct ResolvedMaterial * can be read directly from prims by path. * * Example: - * ImportContext ctx{scene, animMgr, options, report, stage, filename}; + * ImportContext ctx{&scene, &animMgr, &options, &report, + * session, stage, filePath, basePath}; * ctx.reportSkip(primPath, "cylinderLight", * UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); */ struct ImportContext { - Scene &scene; - tsd::animation::AnimationManager &animMgr; - const UsdImportOptions &options; - UsdImportReport &report; + Scene *scene{nullptr}; + tsd::animation::AnimationManager *animMgr{nullptr}; + const UsdImportOptions *options{nullptr}; + UsdImportReport *report{nullptr}; std::shared_ptr session; pxr::UsdStageRefPtr stage; std::string filePath; @@ -99,7 +100,7 @@ struct ImportContext size_t importAnimationIndex{NO_ANIMATION}; // Caches keyed by resolved prim path, so shared content converts once. - ImageCache textureCache{&scene}; + ImageCache textureCache{scene}; std::unordered_map materialCache; void reportSkip(const pxr::SdfPath &primPath, @@ -130,7 +131,7 @@ inline void ImportContext::reportSkip(const pxr::SdfPath &primPath, UsdSkipReason reason, const std::string &detail) { - report.skipped.push_back({primPath.GetString(), primType, reason, detail}); + report->skipped.push_back({primPath.GetString(), primType, reason, detail}); core::logStatus("[import_USD] %s: %s%s%s", primPath.GetText(), toString(reason), diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index 300bb3dc5..9c6715c5d 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -105,7 +105,7 @@ std::shared_ptr convertPrototype(ImportContext &ctx, if (content->internalTransformsAnimated) content->gprimPaths.push_back(path); } - ctx.report.convertedPrims += content->surfaces.size(); + ctx.report->convertedPrims += content->surfaces.size(); registry.prototypes[key] = content; return content; @@ -129,9 +129,9 @@ void expandPrototype(ImportContext &ctx, const auto &path = content.gprimPaths[i]; const auto local = tsd::math::mul(inverseRoot, flattenedXformOf(sceneIndex, path)); - auto node = ctx.scene.insertChildTransformNode( + auto node = ctx.scene->insertChildTransformNode( parent, local, path.GetName().c_str()); - ctx.scene.insertChildObjectNode( + ctx.scene->insertChildObjectNode( node, content.surfaces[i], content.surfaces[i]->name().c_str()); } } @@ -336,7 +336,7 @@ void convertInstancer(ImportContext &ctx, if (content->internalTransformsAnimated) { for (size_t i = 0; i < placements.size(); ++i) { - auto placementNode = ctx.scene.insertChildTransformNode(node, + auto placementNode = ctx.scene->insertChildTransformNode(node, placements[i], (primPath.GetName() + "_" + std::to_string(i)).c_str()); expandPrototype(ctx, @@ -353,14 +353,14 @@ void convertInstancer(ImportContext &ctx, // render index does not push a transform-array node's matrices onto the // transform stack, which is why Prototype geometry is baked (ADR 0016). auto transformArray = - ctx.scene.createArray(ANARI_FLOAT32_MAT4, placements.size()); + ctx.scene->createArray(ANARI_FLOAT32_MAT4, placements.size()); transformArray->setData(placements.data(), placements.size()); transformArray->setName((primPath.GetString() + "_transforms").c_str()); - auto arrayNode = ctx.scene.insertChildTransformArrayNode( + auto arrayNode = ctx.scene->insertChildTransformArrayNode( node, transformArray.data(), primPath.GetName().c_str()); for (auto &surface : content->surfaces) - ctx.scene.insertChildObjectNode( + ctx.scene->insertChildObjectNode( arrayNode, surface, surface->name().c_str()); // The Array this Prototype's placements just went into is the Array the @@ -423,7 +423,7 @@ void attachNativeInstances(ImportContext &ctx, ctx, sceneIndex, placements.prototypes[0], *content, placementNode); } else { for (auto &surface : content->surfaces) { - ctx.scene.insertChildObjectNode( + ctx.scene->insertChildObjectNode( placementNode, surface, surface->name().c_str()); } } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index 4039edde3..d2bab43a4 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -220,7 +220,7 @@ LightRef convertLight(ImportContext &ctx, if (type == pxr::HdPrimTypeTokens->distantLight) { const auto radiometry = readRadiometry(usdPrim, 0.f); - auto light = ctx.scene.createObject(tokens::light::directional); + auto light = ctx.scene->createObject(tokens::light::directional); light->setName(primPath.GetName().c_str()); light->setParameter("color", radiometry.color); light->setParameter("irradiance", radiometry.intensity); @@ -235,7 +235,7 @@ LightRef convertLight(ImportContext &ctx, rectLight.GetHeightAttr().Get(&height); const auto radiometry = readRadiometry(usdPrim, width * height); - auto light = ctx.scene.createObject(tokens::light::quad); + auto light = ctx.scene->createObject(tokens::light::quad); light->setName(primPath.GetName().c_str()); light->setParameter("color", radiometry.color); light->setParameter("intensity", radiometry.intensity); @@ -261,7 +261,7 @@ LightRef convertLight(ImportContext &ctx, float openingAngle = 0.f; float falloffAngle = 0.f; if (readShaping(usdPrim, &openingAngle, &falloffAngle)) { - auto light = ctx.scene.createObject(tokens::light::spot); + auto light = ctx.scene->createObject(tokens::light::spot); light->setName(primPath.GetName().c_str()); light->setParameter("color", radiometry.color); light->setParameter("intensity", radiometry.intensity); @@ -270,7 +270,7 @@ LightRef convertLight(ImportContext &ctx, return light; } - auto light = ctx.scene.createObject( + auto light = ctx.scene->createObject( isDisk ? tokens::light::ring : tokens::light::point); light->setName(primPath.GetName().c_str()); light->setParameter("color", radiometry.color); @@ -283,7 +283,7 @@ LightRef convertLight(ImportContext &ctx, pxr::UsdLuxDomeLight domeLight(usdPrim); const auto radiometry = readRadiometry(usdPrim, 0.f); - auto light = ctx.scene.createObject(tokens::light::hdri); + auto light = ctx.scene->createObject(tokens::light::hdri); light->setName(primPath.GetName().c_str()); light->setParameter("scale", radiometry.intensity); @@ -312,7 +312,7 @@ LightRef convertLight(ImportContext &ctx, // Devices require radiance to be set; synthesize a constant environment // from the light's own colour so an untextured dome still lights. const float3 solid = radiometry.color * radiometry.intensity; - radiance = ctx.scene.createArray(ANARI_FLOAT32_VEC3, 1, 1); + radiance = ctx.scene->createArray(ANARI_FLOAT32_VEC3, 1, 1); radiance->setData(&solid, 1); } light->setParameterObject("radiance", *radiance); @@ -343,7 +343,7 @@ void convertCamera(ImportContext &ctx, const pxr::SdfPath &primPath) const bool isPerspective = defaultCamera.GetProjection() == pxr::GfCamera::Perspective; - auto camera = ctx.scene.createObject(isPerspective + auto camera = ctx.scene->createObject(isPerspective ? tokens::camera::perspective : tokens::camera::orthographic); camera->setName(name.c_str()); @@ -420,14 +420,14 @@ void convertCamera(ImportContext &ctx, const pxr::SdfPath &primPath) return; const size_t frameCount = sampleTimes.size(); - auto positions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, frameCount); - auto directions = ctx.scene.createArray(ANARI_FLOAT32_VEC3, frameCount); - auto ups = ctx.scene.createArray(ANARI_FLOAT32_VEC3, frameCount); + auto positions = ctx.scene->createArray(ANARI_FLOAT32_VEC3, frameCount); + auto directions = ctx.scene->createArray(ANARI_FLOAT32_VEC3, frameCount); + auto ups = ctx.scene->createArray(ANARI_FLOAT32_VEC3, frameCount); ArrayRef fovs, aspects; if (hasIntrinsicAnimation) { - fovs = ctx.scene.createArray(ANARI_FLOAT32, frameCount); - aspects = ctx.scene.createArray(ANARI_FLOAT32, frameCount); + fovs = ctx.scene->createArray(ANARI_FLOAT32, frameCount); + aspects = ctx.scene->createArray(ANARI_FLOAT32, frameCount); } auto *positionData = positions->mapAs(); @@ -478,7 +478,7 @@ void convertCamera(ImportContext &ctx, const pxr::SdfPath &primPath) // Stage's clock so this camera shares one clock with every other binding // from the same import. Nothing is resampled. const auto timeBase = normalizeSampleTimes(ctx.stage, sampleTimes); - auto &animation = ctx.animMgr.addAnimation(name); + auto &animation = ctx.animMgr->addAnimation(name); addValueTimeStepBindings(animation, camera.data(), parameterNames, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index f00bf14e0..3d94baf77 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -260,7 +260,7 @@ MaterialRef tryMdlPassthrough( pxr::TfToken subIdentifier; shader.GetSourceAssetSubIdentifier(&subIdentifier, pxr::TfToken("mdl")); - auto material = ctx.scene.createObject(tokens::material::mdl); + auto material = ctx.scene->createObject(tokens::material::mdl); material->setName(materialPath.GetString().c_str()); material->setParameter("sourceType", "module"); material->setParameter("source", module.c_str()); @@ -401,7 +401,7 @@ MaterialRef tryOmniPbrMapping(ImportContext &ctx, return {}; auto material = - ctx.scene.createObject(tokens::material::physicallyBased); + ctx.scene->createObject(tokens::material::physicallyBased); material->setName(materialPath.GetString().c_str()); // Every textured input takes precedence over its constant, which is what @@ -416,7 +416,8 @@ MaterialRef tryOmniPbrMapping(ImportContext &ctx, return false; // OmniPBR names no colour space of its own, so the role of the input is // what says whether its texels must be de-gamma'd on load. - auto sampler = importTexture(ctx.scene, file, ctx.textureCache, !colorRole); + auto sampler = + importTexture(*ctx.scene, file, ctx.textureCache, !colorRole); if (!sampler) { ctx.reportSkip(materialPath, primType.GetString(), @@ -733,7 +734,7 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, // converts through its own MaterialX node definitions. auto network = materialSchema.GetMaterialNetwork(MATERIALX_CONTEXT); if (!network || !network.GetNodes()) - network = selectNetwork(materialSchema, ctx.options.renderContexts); + network = selectNetwork(materialSchema, ctx.options->renderContexts); if (!network || !network.GetNodes()) return {}; @@ -816,7 +817,7 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, if (materialName.empty() || xml.empty()) return {}; - auto retval = ctx.scene.createObject(tokens::material::materialx); + auto retval = ctx.scene->createObject(tokens::material::materialx); retval->setName(materialPath.GetString().c_str()); retval->setParameter("sourceType", "documentInline"); retval->setParameter("source", xml.c_str()); @@ -830,7 +831,7 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, // is read once. for (const auto &texture : documentTextures) { auto sampler = importTexture( - ctx.scene, texture.file, ctx.textureCache, texture.isLinear); + *ctx.scene, texture.file, ctx.textureCache, texture.isLinear); if (!sampler) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -891,7 +892,7 @@ ResolvedMaterial convertPreviewSurface(ImportContext &ctx, } NetworkWalker walker{ - selectNetwork(materialSchema, ctx.options.renderContexts)}; + selectNetwork(materialSchema, ctx.options->renderContexts)}; if (!walker.network) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -925,7 +926,7 @@ ResolvedMaterial convertPreviewSurface(ImportContext &ctx, } auto material = - ctx.scene.createObject(tokens::material::physicallyBased); + ctx.scene->createObject(tokens::material::physicallyBased); material->setName(materialPath.GetString().c_str()); ResolvedMaterial retval; @@ -974,7 +975,7 @@ ResolvedMaterial convertPreviewSurface(ImportContext &ctx, const bool isLinear = textureIsLinear(walker, texturePath, colorRole); auto sampler = - importTexture(ctx.scene, file, ctx.textureCache, isLinear, settings); + importTexture(*ctx.scene, file, ctx.textureCache, isLinear, settings); if (!sampler) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -1039,7 +1040,7 @@ ResolvedMaterial resolveMaterial(ImportContext &ctx, // Native passthrough modes are opt-in; each falls back to the portable // mapping, saying so, rather than dropping the material. - switch (ctx.options.materialMode) { + switch (ctx.options->materialMode) { case UsdMaterialMode::MDL: if (auto material = tryMdlPassthrough(ctx, materialPath)) return cache({material}); diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 067a780f8..0a5ebdc2f 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -61,7 +61,7 @@ LayerNodeRef insertPlaceholder(ImportContext &ctx, { // insertChildNode() already leaves the node empty; setEmpty() would clear // the name along with the value. - auto node = ctx.scene.insertChildNode(parent, primPath.GetName().c_str()); + auto node = ctx.scene->insertChildNode(parent, primPath.GetName().c_str()); (*node)->setEnabled(false); (*node)->setInstanceParameter("usd:skipReason", Any(toString(reason))); (*node)->setInstanceParameter("usd:primPath", Any(primPath.GetText())); @@ -137,7 +137,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, if (auto value = purposeSchema.GetPurpose()) purpose = value->GetTypedValue(0); } - if (!purposeIsIncluded(purpose, ctx.options.purposes)) { + if (!purposeIsIncluded(purpose, ctx.options->purposes)) { ctx.reportSkip(primPath, prim.primType.GetString(), UsdSkipReason::PURPOSE_EXCLUDED, @@ -188,7 +188,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, : localXform; const auto accumulatedXform = tsd::math::mul(parentXform, nodeXform); - auto node = ctx.scene.insertChildTransformNode( + auto node = ctx.scene->insertChildTransformNode( parent, nodeXform, primPath.GetName().c_str()); if (resetsXformStack) (*node)->setInstanceParameter("usd:resetXformStack", Any(true)); @@ -200,7 +200,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, bool convertedAnything = false; if (prim.primType.IsEmpty()) { if (!isHierarchyPrim(primPath)) { - ctx.scene.removeNode(node); + ctx.scene->removeNode(node); ctx.reportSkip(primPath, ctx.stage->GetPrimAtPath(primPath).GetTypeName().GetString(), UsdSkipReason::UNSUPPORTED_PRIM_TYPE); @@ -212,7 +212,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, auto converted = convertGeometry( ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4); for (auto &surface : converted.surfaces) - ctx.scene.insertChildObjectNode(node, surface, surface->name().c_str()); + ctx.scene->insertChildObjectNode(node, surface, surface->name().c_str()); addDeformingGeometryAnimation(ctx, primPath, converted); convertedAnything = true; } else if (prim.primType == pxr::HdPrimTypeTokens->instancer) { @@ -220,10 +220,10 @@ void Traversal::visit(const pxr::SdfPath &primPath, convertedAnything = true; } else if (isLightPrimType(prim.primType)) { if (auto light = convertLight(ctx, primPath, prim)) { - ctx.scene.insertChildObjectNode(node, light, primPath.GetName().c_str()); + ctx.scene->insertChildObjectNode(node, light, primPath.GetName().c_str()); convertedAnything = true; } else { - ctx.scene.removeNode(node); + ctx.scene->removeNode(node); insertPlaceholder( ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); return; @@ -235,18 +235,18 @@ void Traversal::visit(const pxr::SdfPath &primPath, || prim.primType == pxr::HdPrimTypeTokens->geomSubset) { // Materials convert on demand from the prims that bind them; geom subsets // are consumed by their parent mesh. Neither is a loss. - ctx.scene.removeNode(node); + ctx.scene->removeNode(node); return; } else if (isVolumePrimType(prim.primType)) { convertedAnything = convertVolume(ctx, primPath, node); if (!convertedAnything) { - ctx.scene.removeNode(node); + ctx.scene->removeNode(node); insertPlaceholder( ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } } else { - ctx.scene.removeNode(node); + ctx.scene->removeNode(node); ctx.reportSkip(primPath, prim.primType.GetString(), UsdSkipReason::UNSUPPORTED_PRIM_TYPE); @@ -256,7 +256,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, } if (convertedAnything) - ctx.report.convertedPrims++; + ctx.report->convertedPrims++; instancers.nodeForPrimPath[primPath.GetString()] = node; addTransformAnimation(ctx, primPath, node); @@ -287,10 +287,10 @@ UsdImportReport import_USD(Scene &scene, report.stageOpened = true; auto stage = session->stage(); - ImportContext ctx{scene, - animMgr, - options, - report, + ImportContext ctx{&scene, + &animMgr, + &options, + &report, session, stage, filepath, From 0ef1079b329f098f874daa6d5aba991a47c07a46 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 20:43:37 -0500 Subject: [PATCH 39/78] refactor(io): store Traversal's non-owning members as pointers The sweep in a4eb07ca stopped at ImportContext and left Traversal, the one other struct in the USD import path holding non-owning references, breaking the same STYLEGUIDE.md rule. ImportContext and InstancerRegistry become nullptr-initialised raw pointers, so Traversal is assignable and movable like every other TSD aggregate. import_USD() constructs the only Traversal there is and never passes null, so nullability is handled at that boundary -- no null checks are added to visit() or its helpers. Function parameters stay references; the rule is about members. VertexRefiner and FaceVaryingRefiner in UsdSubdivision.cpp are local functors passed straight to OpenSubdiv, and are deliberately left alone. Mechanical and behaviour-preserving. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/importers/import_USD.cpp | 75 +++++++++++++------------ 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 0a5ebdc2f..cc8ceb963 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -70,9 +70,9 @@ LayerNodeRef insertPlaceholder(ImportContext &ctx, struct Traversal { - ImportContext &ctx; + ImportContext *ctx{nullptr}; pxr::HdSceneIndexBaseRefPtr sceneIndex; - InstancerRegistry &instancers; + InstancerRegistry *instancers{nullptr}; void visit(const pxr::SdfPath &primPath, LayerNodeRef parent, @@ -91,14 +91,14 @@ tsd::math::mat4 Traversal::localTransformOf( const pxr::SdfPath &primPath, bool *resetsXformStack) const { *resetsXformStack = false; - auto prim = ctx.stage->GetPrimAtPath(primPath); + auto prim = ctx->stage->GetPrimAtPath(primPath); if (!prim) return tsd::math::IDENTITY_MAT4; pxr::UsdGeomXformable xformable(prim); if (!xformable) return tsd::math::IDENTITY_MAT4; pxr::GfMatrix4d local(1.0); - xformable.GetLocalTransformation(&local, resetsXformStack, ctx.importTime); + xformable.GetLocalTransformation(&local, resetsXformStack, ctx->importTime); return toTsdMat4(local); } @@ -106,7 +106,7 @@ tsd::math::mat4 Traversal::localTransformOf( // something USD cannot image at all; only the latter is a loss worth naming. bool Traversal::isHierarchyPrim(const pxr::SdfPath &primPath) const { - auto prim = ctx.stage->GetPrimAtPath(primPath); + auto prim = ctx->stage->GetPrimAtPath(primPath); return !prim || bool(pxr::UsdGeomImageable(prim)); } @@ -117,7 +117,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, { // Claimed Prims reach the Scene through the dialect's own importers; the // generic path must not also convert a carrier prim into geometry. - if (ctx.isClaimed(primPath)) + if (ctx->isClaimed(primPath)) return; auto prim = sceneIndex->GetPrim(primPath); @@ -137,12 +137,12 @@ void Traversal::visit(const pxr::SdfPath &primPath, if (auto value = purposeSchema.GetPurpose()) purpose = value->GetTypedValue(0); } - if (!purposeIsIncluded(purpose, ctx.options->purposes)) { - ctx.reportSkip(primPath, + if (!purposeIsIncluded(purpose, ctx->options->purposes)) { + ctx->reportSkip(primPath, prim.primType.GetString(), UsdSkipReason::PURPOSE_EXCLUDED, purpose.GetString()); - insertPlaceholder(ctx, parent, primPath, UsdSkipReason::PURPOSE_EXCLUDED); + insertPlaceholder(*ctx, parent, primPath, UsdSkipReason::PURPOSE_EXCLUDED); return; } @@ -155,7 +155,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, visible = value->GetTypedValue(0); } if (!visible && !hidden) { - ctx.reportSkip( + ctx->reportSkip( primPath, prim.primType.GetString(), UsdSkipReason::RESOLVED_INVISIBLE); } @@ -164,9 +164,9 @@ void Traversal::visit(const pxr::SdfPath &primPath, // Exporters routinely re-author every attribute at every frame, so the // samples are compared: a value that never changes is not a loss. if (auto imageable = - pxr::UsdGeomImageable(ctx.stage->GetPrimAtPath(primPath))) { + pxr::UsdGeomImageable(ctx->stage->GetPrimAtPath(primPath))) { if (attributeValueVaries(imageable.GetVisibilityAttr())) { - ctx.reportSkip(primPath, + ctx->reportSkip(primPath, prim.primType.GetString(), UsdSkipReason::TIME_VARYING_VALUE_DROPPED, "visibility is time-sampled; imported at the Stage's start of time"); @@ -188,7 +188,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, : localXform; const auto accumulatedXform = tsd::math::mul(parentXform, nodeXform); - auto node = ctx.scene->insertChildTransformNode( + auto node = ctx->scene->insertChildTransformNode( parent, nodeXform, primPath.GetName().c_str()); if (resetsXformStack) (*node)->setInstanceParameter("usd:resetXformStack", Any(true)); @@ -200,66 +200,67 @@ void Traversal::visit(const pxr::SdfPath &primPath, bool convertedAnything = false; if (prim.primType.IsEmpty()) { if (!isHierarchyPrim(primPath)) { - ctx.scene->removeNode(node); - ctx.reportSkip(primPath, - ctx.stage->GetPrimAtPath(primPath).GetTypeName().GetString(), + ctx->scene->removeNode(node); + ctx->reportSkip(primPath, + ctx->stage->GetPrimAtPath(primPath).GetTypeName().GetString(), UsdSkipReason::UNSUPPORTED_PRIM_TYPE); insertPlaceholder( - ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } } else if (isGeometryPrimType(prim.primType)) { auto converted = convertGeometry( - ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4); + *ctx, sceneIndex, primPath, prim, tsd::math::IDENTITY_MAT4); for (auto &surface : converted.surfaces) - ctx.scene->insertChildObjectNode(node, surface, surface->name().c_str()); - addDeformingGeometryAnimation(ctx, primPath, converted); + ctx->scene->insertChildObjectNode(node, surface, surface->name().c_str()); + addDeformingGeometryAnimation(*ctx, primPath, converted); convertedAnything = true; } else if (prim.primType == pxr::HdPrimTypeTokens->instancer) { - convertInstancer(ctx, sceneIndex, primPath, prim, node, instancers); + convertInstancer(*ctx, sceneIndex, primPath, prim, node, *instancers); convertedAnything = true; } else if (isLightPrimType(prim.primType)) { - if (auto light = convertLight(ctx, primPath, prim)) { - ctx.scene->insertChildObjectNode(node, light, primPath.GetName().c_str()); + if (auto light = convertLight(*ctx, primPath, prim)) { + ctx->scene->insertChildObjectNode( + node, light, primPath.GetName().c_str()); convertedAnything = true; } else { - ctx.scene->removeNode(node); + ctx->scene->removeNode(node); insertPlaceholder( - ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); return; } } else if (prim.primType == pxr::HdPrimTypeTokens->camera) { - convertCamera(ctx, primPath); + convertCamera(*ctx, primPath); convertedAnything = true; } else if (prim.primType == pxr::HdPrimTypeTokens->material || prim.primType == pxr::HdPrimTypeTokens->geomSubset) { // Materials convert on demand from the prims that bind them; geom subsets // are consumed by their parent mesh. Neither is a loss. - ctx.scene->removeNode(node); + ctx->scene->removeNode(node); return; } else if (isVolumePrimType(prim.primType)) { - convertedAnything = convertVolume(ctx, primPath, node); + convertedAnything = convertVolume(*ctx, primPath, node); if (!convertedAnything) { - ctx.scene->removeNode(node); + ctx->scene->removeNode(node); insertPlaceholder( - ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } } else { - ctx.scene->removeNode(node); - ctx.reportSkip(primPath, + ctx->scene->removeNode(node); + ctx->reportSkip(primPath, prim.primType.GetString(), UsdSkipReason::UNSUPPORTED_PRIM_TYPE); insertPlaceholder( - ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } if (convertedAnything) - ctx.report->convertedPrims++; + ctx->report->convertedPrims++; - instancers.nodeForPrimPath[primPath.GetString()] = node; - addTransformAnimation(ctx, primPath, node); + instancers->nodeForPrimPath[primPath.GetString()] = node; + addTransformAnimation(*ctx, primPath, node); for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) visit(childPath, node, subtreeHidden, accumulatedXform); @@ -325,7 +326,7 @@ UsdImportReport import_USD(Scene &scene, : pxr::SdfPath(options.primPath); InstancerRegistry instancers; - Traversal traversal{ctx, sceneIndex, instancers}; + Traversal traversal{&ctx, sceneIndex, &instancers}; scene.beginLayerEditBatch(); if (scopeRoot == pxr::SdfPath::AbsoluteRootPath()) { From 6ab95b79642c958a1fff1985c76ae64e51266560 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 20:45:08 -0500 Subject: [PATCH 40/78] refactor(io): store the subdivision refiners' TopologyRefiner as a pointer Completes the "Store Non-Owning Members as Pointers, Never References" sweep started in a4eb07ca and continued in 0ef1079b. That commit's message says VertexRefiner and FaceVaryingRefiner are deliberately out of scope; that note is superseded -- these are the last two non-owning reference members in the USD import path, and they follow the rule now. Both functors hold the TopologyRefiner as a nullptr-initialised raw pointer and dereference it when forwarding to refineVertexBuffer() / refineFaceVaryingBuffer(), which keep taking a reference; the rule is about members, not parameters. The two construction sites pass refiner.get() from the shared_ptr that owns it, so nullability is handled at the same boundary that already checks the factory's result -- no null checks are added to operator(). Mechanical and behaviour-preserving. Co-Authored-By: Claude Opus 5 (1M context) --- .../tsd/io/importers/detail/usd/UsdSubdivision.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp index 34e85be06..959a482a3 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp @@ -115,24 +115,24 @@ std::vector> refineFaceVaryingBuffer( // member template that a local class may not declare. struct VertexRefiner { - OpenSubdiv::Far::TopologyRefiner &refiner; + OpenSubdiv::Far::TopologyRefiner *refiner{nullptr}; template std::vector> operator()(const float *source, size_t count) const { - return refineVertexBuffer(refiner, source, count); + return refineVertexBuffer(*refiner, source, count); } }; struct FaceVaryingRefiner { - OpenSubdiv::Far::TopologyRefiner &refiner; - int channel; + OpenSubdiv::Far::TopologyRefiner *refiner{nullptr}; + int channel{0}; template std::vector> operator()(const float *source, size_t count) const { - return refineFaceVaryingBuffer(refiner, source, count, channel); + return refineFaceVaryingBuffer(*refiner, source, count, channel); } }; @@ -363,7 +363,7 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, retval.holeIndices.push_back(face); } - const VertexRefiner vertexRefiner{*refiner}; + const VertexRefiner vertexRefiner{refiner.get()}; { pxr::VtValue refinedPoints; @@ -380,7 +380,7 @@ RefinedMesh refineMesh(const pxr::HdMeshSchema &meshSchema, for (size_t channel = 0; channel < primvars.faceVarying.size(); ++channel) { const auto &[name, value] = primvars.faceVarying[channel]; - const FaceVaryingRefiner faceVaryingRefiner{*refiner, int(channel)}; + const FaceVaryingRefiner faceVaryingRefiner{refiner.get(), int(channel)}; pxr::VtValue refined; if (!refineFloatArray(value, &refined, faceVaryingRefiner)) From bf03c590a078886e7103cc92337fd8fe3966209d Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 20:53:31 -0500 Subject: [PATCH 41/78] test(io): split the USD import suite by subject test_UsdImport.cpp had grown to 3613 lines holding 45 scenarios, well past the point where the next scenario has an obvious home. Move the shared fixtures into tests/UsdTestFixtures.h and split the scenarios into files named after what they cover, keeping the single [UsdImport] Catch tag so the one ctest registration still runs all of them. This is a pure move: the same 45 scenarios and 385 assertions run before and after. The fixtures also wrote to a fixed name under the system temp directory, so two concurrent runs of the test binary trampled each other. They now write into a directory unique to the process; anchoring the whole directory rather than mangling each file name keeps the relative asset references between a Stage fixture and its textures resolving. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/tests/CMakeLists.txt | 7 + tsd/tests/UsdTestFixtures.h | 212 ++ tsd/tests/test_UsdImport.cpp | 3174 +---------------- tsd/tests/test_UsdImport_animation.cpp | 613 ++++ tsd/tests/test_UsdImport_geometry.cpp | 501 +++ tsd/tests/test_UsdImport_instancing.cpp | 463 +++ tsd/tests/test_UsdImport_lights.cpp | 148 + tsd/tests/test_UsdImport_materials.cpp | 453 +++ .../test_UsdImport_materials_portable.cpp | 478 +++ tsd/tests/test_UsdImport_subsets.cpp | 420 +++ 10 files changed, 3334 insertions(+), 3135 deletions(-) create mode 100644 tsd/tests/UsdTestFixtures.h create mode 100644 tsd/tests/test_UsdImport_animation.cpp create mode 100644 tsd/tests/test_UsdImport_geometry.cpp create mode 100644 tsd/tests/test_UsdImport_instancing.cpp create mode 100644 tsd/tests/test_UsdImport_lights.cpp create mode 100644 tsd/tests/test_UsdImport_materials.cpp create mode 100644 tsd/tests/test_UsdImport_materials_portable.cpp create mode 100644 tsd/tests/test_UsdImport_subsets.cpp diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 2bba1b0e7..65c5d21ac 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -37,6 +37,13 @@ project_add_executable( test_SceneArchive.cpp test_Token.cpp test_UsdImport.cpp + test_UsdImport_animation.cpp + test_UsdImport_geometry.cpp + test_UsdImport_instancing.cpp + test_UsdImport_lights.cpp + test_UsdImport_materials.cpp + test_UsdImport_materials_portable.cpp + test_UsdImport_subsets.cpp ) if (TARGET tsd_scivis_studio_model) target_sources(${PROJECT_NAME} PRIVATE diff --git a/tsd/tests/UsdTestFixtures.h b/tsd/tests/UsdTestFixtures.h new file mode 100644 index 000000000..31b241555 --- /dev/null +++ b/tsd/tests/UsdTestFixtures.h @@ -0,0 +1,212 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Fixtures and lookups shared by the test_UsdImport*.cpp suites. Include this +// only from inside a `#if TSD_USE_USD` guard -- it needs OpenUSD-dependent +// declarations. + +#pragma once + +// catch +#include "catch.hpp" +// tsd +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/importers.hpp" +#include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Material.hpp" +// std +#include +#include +#include +#include + +// Fixture files live in a directory unique to this process, so two concurrent +// runs of the test binary cannot collide on a name while relative asset +// references between fixtures still resolve. +inline const std::filesystem::path &fixtureDirectory() +{ + struct ScopedDirectory + { + ScopedDirectory() + { + std::random_device entropy; + path = std::filesystem::temp_directory_path() + / ("tsd_test_usd_" + std::to_string(entropy())); + std::filesystem::create_directories(path); + } + + ~ScopedDirectory() + { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } + + std::filesystem::path path; + }; + + static const ScopedDirectory directory; + return directory.path; +} + +// Writes a text-format Stage to a temporary path for the lifetime of one +// scenario. USD's text format keeps every fixture readable next to the +// assertion it supports and keeps binary assets out of the repository. +struct StageFixture +{ + StageFixture(const char *name, const std::string &contents); + ~StageFixture(); + + std::string path() const; + + private: + std::filesystem::path m_path; +}; + +// A Stage written to disk and imported into a Scene: what nearly every +// scenario needs before it can assert anything. Holding the Scene, the +// AnimationManager and the Import Report together lets a scenario open with +// one line of setup and go straight to its assertions. +struct ImportedStage +{ + ImportedStage(const char *name, + const std::string &contents, + const tsd::io::UsdImportOptions &options = {}); + + std::string path() const; + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr{&scene}; + tsd::io::UsdImportReport report; + + private: + StageFixture m_stage; +}; + +// A real, decodable texture for the lifetime of one scenario. The import binds +// samplers by loading these, so a stand-in file with arbitrary bytes would not +// exercise the path -- this is a 1x1 uncompressed true-colour TGA, the +// smallest thing the image loader accepts that can be written by hand. +struct TextureFixture +{ + explicit TextureFixture(const char *name); + ~TextureFixture(); + + std::string path() const; + + private: + std::filesystem::path m_path; +}; + +// Depth-first search for the first node whose name matches. +inline tsd::scene::LayerNodeRef findNode( + tsd::scene::Layer *layer, const char *name) +{ + tsd::scene::LayerNodeRef found; + layer->traverse(layer->root(), [&](auto &node, int) { + if (!found && node->name() == name) + found = layer->at(node.index()); + return true; + }); + return found; +} + +// The converted object a prim produced, found by the prim path the importer +// names it after. +template +tsd::core::ObjectPoolRef findObject( + tsd::scene::Scene &scene, anari::DataType type, const char *name) +{ + for (size_t i = 0; i < scene.numberOfObjects(type); ++i) { + auto object = scene.getObject(i); + if (object && object->name() == name) + return object; + } + return {}; +} + +inline tsd::scene::GeometryRef findGeometry( + tsd::scene::Scene &scene, const char *name) +{ + return findObject(scene, ANARI_GEOMETRY, name); +} + +// The material a Surface actually uses, rather than whatever happens to sit at +// index 0 of the pool (which is the Scene's own default material). +inline tsd::scene::Material *boundMaterial(tsd::scene::Scene &scene) +{ + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + return material; +} + +inline constexpr const char *QUAD_MESH_BODY = R"( + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] +)"; + +// Inlined definitions //////////////////////////////////////////////////////// + +inline StageFixture::StageFixture(const char *name, const std::string &contents) + : m_path(fixtureDirectory() / name) +{ + std::ofstream file(m_path); + file << contents; +} + +inline StageFixture::~StageFixture() +{ + std::error_code ec; + std::filesystem::remove(m_path, ec); +} + +inline std::string StageFixture::path() const +{ + return m_path.string(); +} + +inline ImportedStage::ImportedStage(const char *name, + const std::string &contents, + const tsd::io::UsdImportOptions &options) + : m_stage(name, contents) +{ + report = tsd::io::import_USD(scene, animMgr, path().c_str(), {}, options); +} + +inline std::string ImportedStage::path() const +{ + return m_stage.path(); +} + +inline TextureFixture::TextureFixture(const char *name) + : m_path(fixtureDirectory() / name) +{ + const unsigned char tga[] = { + 0, // no image ID + 0, // no colour map + 2, // uncompressed true-colour + 0, 0, 0, 0, 0, // empty colour map spec + 0, 0, 0, 0, // origin + 1, 0, // width + 1, 0, // height + 24, // bits per pixel + 0, // descriptor + 0x20, 0x40, 0x60 // one BGR pixel + }; + std::ofstream file(m_path, std::ios::binary); + file.write(reinterpret_cast(tga), sizeof(tga)); +} + +inline TextureFixture::~TextureFixture() +{ + std::error_code ec; + std::filesystem::remove(m_path, ec); +} + +inline std::string TextureFixture::path() const +{ + return m_path.string(); +} diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 2ebf2d25d..82b099eac 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -75,203 +75,8 @@ SCENARIO("Import report counts skipped prims by reason", "[UsdImport]") #if TSD_USE_USD -#include "tsd/animation/AnimationManager.hpp" -#include "tsd/io/animation/UsdGeometryFileBinding.hpp" -#include "tsd/io/archives/AnimationManagerArchive.hpp" -#include "tsd/io/importers.hpp" -#include "tsd/scene/Scene.hpp" -#include "tsd/scene/objects/Material.hpp" -// std -#include -#include -#include -#include - -namespace { - -// Writes a text-format Stage to a temporary path for the lifetime of one -// scenario. USD's text format keeps every fixture readable next to the -// assertion it supports and keeps binary assets out of the repository. -struct StageFixture -{ - StageFixture(const char *name, const std::string &contents); - ~StageFixture(); - - std::string path() const; - - private: - std::filesystem::path m_path; -}; - -// Inlined definitions //////////////////////////////////////////////////////// - -inline StageFixture::StageFixture(const char *name, const std::string &contents) - : m_path(std::filesystem::temp_directory_path() / name) -{ - std::ofstream file(m_path); - file << contents; -} - -inline StageFixture::~StageFixture() -{ - std::error_code ec; - std::filesystem::remove(m_path, ec); -} - -inline std::string StageFixture::path() const -{ - return m_path.string(); -} - -// A real, decodable texture for the lifetime of one scenario. The import binds -// samplers by loading these, so a stand-in file with arbitrary bytes would not -// exercise the path -- this is a 1x1 uncompressed true-colour TGA, the -// smallest thing the image loader accepts that can be written by hand. -struct TextureFixture -{ - explicit TextureFixture(const char *name); - ~TextureFixture(); - - std::string path() const; - - private: - std::filesystem::path m_path; -}; - -inline TextureFixture::TextureFixture(const char *name) - : m_path(std::filesystem::temp_directory_path() / name) -{ - const unsigned char tga[] = { - 0, // no image ID - 0, // no colour map - 2, // uncompressed true-colour - 0, 0, 0, 0, 0, // empty colour map spec - 0, 0, 0, 0, // origin - 1, 0, // width - 1, 0, // height - 24, // bits per pixel - 0, // descriptor - 0x20, 0x40, 0x60 // one BGR pixel - }; - std::ofstream file(m_path, std::ios::binary); - file.write(reinterpret_cast(tga), sizeof(tga)); -} - -inline TextureFixture::~TextureFixture() -{ - std::error_code ec; - std::filesystem::remove(m_path, ec); -} - -inline std::string TextureFixture::path() const -{ - return m_path.string(); -} - -// Depth-first search for the first node whose name matches. -tsd::scene::LayerNodeRef findNode(tsd::scene::Layer *layer, const char *name) -{ - tsd::scene::LayerNodeRef found; - layer->traverse(layer->root(), [&](auto &node, int) { - if (!found && node->name() == name) - found = layer->at(node.index()); - return true; - }); - return found; -} - -// The converted object a prim produced, found by the prim path the importer -// names it after. -template -tsd::core::ObjectPoolRef findObject( - tsd::scene::Scene &scene, anari::DataType type, const char *name) -{ - for (size_t i = 0; i < scene.numberOfObjects(type); ++i) { - auto object = scene.getObject(i); - if (object && object->name() == name) - return object; - } - return {}; -} - -tsd::scene::GeometryRef findGeometry(tsd::scene::Scene &scene, const char *name) -{ - return findObject(scene, ANARI_GEOMETRY, name); -} - -// The material a Surface actually uses, rather than whatever happens to sit at -// index 0 of the pool (which is the Scene's own default material). -tsd::scene::Material *boundMaterial(tsd::scene::Scene &scene) -{ - auto surface = scene.getObject(0); - REQUIRE(surface); - auto *material = surface->parameterValueAsObject( - tsd::scene::tokens::surface::material); - REQUIRE(material != nullptr); - return material; -} - -constexpr const char *QUAD_MESH_BODY = R"( - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] -)"; - -} // namespace - -SCENARIO("A USD Stage's meshes arrive as surfaces", "[UsdImport]") -{ - GIVEN("A Stage with a single quad mesh") - { - StageFixture stage("tsd_test_usd_single_mesh.usda", - std::string(R"(#usda 1.0 - -def Xform "World" -{ - def Mesh "Quad" - { -)") + QUAD_MESH_BODY - + R"( - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The mesh becomes one triangle-geometry surface") - { - REQUIRE(report.stageOpened); - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); - - auto geometry = scene.getObject(0); - REQUIRE(geometry); - REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::triangle); - - auto *index = geometry->parameterValueAsObject( - "primitive.index"); - REQUIRE(index != nullptr); - REQUIRE(index->size() == 2); // a quad tessellates to two triangles - - auto *position = geometry->parameterValueAsObject( - "vertex.position"); - REQUIRE(position != nullptr); - REQUIRE(position->size() == 4); - } - - THEN("Nothing is silently lost") - { - REQUIRE(report.skipped.empty()); - REQUIRE(report.convertedPrims == 1); - } - } - } -} +// tsd_tests +#include "UsdTestFixtures.h" SCENARIO("A USD Stage's prim hierarchy is mirrored in the Layer", "[UsdImport]") { @@ -622,22 +427,22 @@ def Mesh "Quad" } } -SCENARIO("A point instancer shares one set of Prototype objects", "[UsdImport]") +SCENARIO("A prim that resets the transform stack ignores its ancestors", + "[UsdImport]") { - GIVEN("A Stage scattering one Prototype three times, one of them hidden") + GIVEN("A child that resets the transform stack under a moved parent") { - StageFixture stage("tsd_test_usd_point_instancer.usda", R"(#usda 1.0 + StageFixture stage("tsd_test_usd_xform_reset.usda", R"(#usda 1.0 -def PointInstancer "Scatter" +def Xform "Parent" { - point3f[] positions = [(0, 0, 0), (2, 0, 0), (4, 0, 0)] - int64[] ids = [0, 1, 2] - int[] protoIndices = [0, 0, 0] - int64[] invisibleIds = [1] - rel prototypes = [] + double3 xformOp:translate = (10, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] - def Mesh "Proto" + def Mesh "Detached" { + double3 xformOp:translate = (1, 2, 3) + uniform token[] xformOpOrder = ["!resetXformStack!", "xformOp:translate"] int[] faceVertexCounts = [3] int[] faceVertexIndices = [0, 1, 2] point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] @@ -648,348 +453,47 @@ def PointInstancer "Scatter" tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The Prototype is imported once, not once per placement") - { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); - } - - THEN("The placements become a single transform-array node") - { - auto *layer = scene.defaultLayer(); - auto scatter = findNode(layer, "Scatter"); - REQUIRE(scatter); - - // The array node is the instancer's own child, holding the visible - // placements only. - tsd::scene::Array *transforms = nullptr; - layer->traverse(scatter, [&](auto &node, int) { - if (!transforms && node->type() == ANARI_ARRAY1D) - transforms = node->getTransformArray(); - return true; - }); - REQUIRE(transforms != nullptr); - REQUIRE(transforms->size() == 2); // the invisible placement is omitted - } - - THEN("Nothing is silently lost") - { - REQUIRE(report.skipped.empty()); - } - } - } -} - -SCENARIO("USD Instances share objects across placements", "[UsdImport]") -{ - GIVEN("A Stage referencing one Prototype from two instanceable prims") - { - StageFixture stage("tsd_test_usd_native_instance.usda", R"(#usda 1.0 - -def Xform "Protos" -{ - def Xform "Asset" - { - def Mesh "Quad" - { - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } - } -} - -def Xform "InstanceA" ( - instanceable = true - prepend references = -) -{ - double3 xformOp:translate = (5, 0, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] -} - -def Xform "InstanceB" ( - instanceable = true - prepend references = -) -{ - double3 xformOp:translate = (9, 0, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The Prototype geometry exists once, plus the un-instanced source") - { - // /Protos/Asset/Quad imports as ordinary content; the two placements - // share a single converted Prototype rather than copying it. - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); - } - - THEN("Each placement is a node referencing the shared objects") + THEN("Composing parent and child lands where USD puts the child") { auto *layer = scene.defaultLayer(); - auto a = findNode(layer, "InstanceA"); - auto b = findNode(layer, "InstanceB"); - REQUIRE(a); - REQUIRE(b); - - auto sharedObjectUnder = [&](tsd::scene::LayerNodeRef parent) { - size_t index = tsd::core::INVALID_INDEX; - layer->traverse(parent, [&](auto &node, int) { - if (index == tsd::core::INVALID_INDEX && node->isObject()) - index = node->getObjectIndex(); - return true; - }); - return index; - }; - - const auto indexA = sharedObjectUnder(a); - const auto indexB = sharedObjectUnder(b); - REQUIRE(indexA != tsd::core::INVALID_INDEX); - REQUIRE(indexA == indexB); - } - } - } -} - -SCENARIO("Per-face material subsets become several Surfaces", "[UsdImport]") -{ - GIVEN("A two-face mesh with one face bound to its own material") - { - StageFixture stage("tsd_test_usd_subsets.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Scope "Looks" - { - def Material "Red" - { - token outputs:surface.connect = - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (1, 0, 0) - token outputs:surface - } - } - - def Material "Blue" - { - token outputs:surface.connect = - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0, 0, 1) - token outputs:surface - } - } - } - - def Mesh "Strip" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4, 4] - int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), - (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] - rel material:binding = - - def GeomSubset "Second" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [1] - rel material:binding = - } - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("There is one Surface per subset") - { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) >= 1); - } - - THEN("The faces no subset claims are still drawn, by the mesh's material") - { - // The first face belongs to no subset, so it stays with the mesh's own - // binding instead of vanishing with the un-surfaced parent geometry. - auto leftover = findGeometry(scene, "/World/Strip"); - REQUIRE(leftover); - auto *index = leftover->parameterValueAsObject( - "primitive.index"); - REQUIRE(index != nullptr); - REQUIRE(index->size() == 2); - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); - } - - THEN("The subset Surfaces share the mesh's vertex positions") - { - // Every geometry produced for this mesh points at the same - // vertex.position Array; only the index arrays differ. - std::vector positions; - for (size_t i = 0; i < scene.numberOfObjects(ANARI_GEOMETRY); ++i) { - auto geometry = scene.getObject(i); - if (!geometry) - continue; - if (auto *p = geometry->parameterValueAsObject( - "vertex.position")) - positions.push_back(p); - } - REQUIRE(positions.size() >= 2); - for (size_t i = 1; i < positions.size(); ++i) - REQUIRE(positions[i] == positions[0]); - } - } - } -} - -SCENARIO("Face-varying UVs follow each material subset", "[UsdImport]") -{ - GIVEN("A two-face mesh with per-corner UVs and a subset over each face") - { - StageFixture stage("tsd_test_usd_subset_facevarying.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Mesh "Quad" - { - int[] faceVertexCounts = [4, 4] - int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), - (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] - texCoord2f[] primvars:st = [(0, 0), (0.25, 0), (0.25, 0.25), (0, 0.25), - (0.5, 0), (0.75, 0), (0.75, 0.75), - (0.5, 0.75)] ( - interpolation = "faceVarying" - ) - normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1), - (0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)] ( - interpolation = "vertex" - ) - - def GeomSubset "Left" - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [0] - } - - def GeomSubset "Right" - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [1] - } - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Each subset carries the corners of the faces it selected") - { - // Face-varying data is indexed by triangle corner, so a subset cannot - // share the parent array the way vertex data can -- it must gather the - // corners of its own triangles. Each quad triangulates to two - // triangles, hence six corners, and the two faces' UVs are authored - // into disjoint halves of the unit square so a mis-gather shows up. - auto left = findGeometry(scene, "/World/Quad/Left"); - auto right = findGeometry(scene, "/World/Quad/Right"); - REQUIRE(left); - REQUIRE(right); - - auto *leftUVs = left->parameterValueAsObject( - "faceVarying.attribute0"); - auto *rightUVs = right->parameterValueAsObject( - "faceVarying.attribute0"); - REQUIRE(leftUVs != nullptr); - REQUIRE(rightUVs != nullptr); - REQUIRE(leftUVs->size() == 6); - REQUIRE(rightUVs->size() == 6); - - const auto *l = leftUVs->dataAs(); - const auto *r = rightUVs->dataAs(); - for (size_t i = 0; i < 6; ++i) { - REQUIRE(l[i].x < 0.5f); - REQUIRE(r[i].x >= 0.5f); - } - } + auto parent = findNode(layer, "Parent"); + auto detached = findNode(layer, "Detached"); + REQUIRE(parent); + REQUIRE(detached); - THEN("Vertex-interpolated attributes are still shared, not copied") - { - // Only per-corner and per-triangle data has to be gathered; vertex - // data is indexed by the indices each subset already carries, so one - // Array serves every Surface. - auto left = findGeometry(scene, "/World/Quad/Left"); - auto right = findGeometry(scene, "/World/Quad/Right"); - REQUIRE(left); - REQUIRE(right); - - auto *leftNormals = - left->parameterValueAsObject("vertex.normal"); - REQUIRE(leftNormals != nullptr); - REQUIRE(leftNormals - == right->parameterValueAsObject( - "vertex.normal")); + const auto composed = tsd::math::mul( + (*parent)->getTransform(), (*detached)->getTransform()); + REQUIRE(composed[3].x == Approx(1.f)); + REQUIRE(composed[3].y == Approx(2.f)); + REQUIRE(composed[3].z == Approx(3.f)); } } } } -SCENARIO( - "Face-varying primvars survive an already-triangulated mesh", "[UsdImport]") +SCENARIO("Time-varying visibility is reported rather than lost", "[UsdImport]") { - GIVEN("An all-triangle mesh with indexed face-varying UVs and normals") + GIVEN("A mesh whose visibility is animated") { - // Hydra's triangulator reports this topology as Unchanged rather than - // producing a copy of the input, a distinct result from Success that the - // conversion must not mistake for failure -- pre-triangulated exports - // carry every face-varying primvar down this path. - StageFixture stage("tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 + StageFixture stage("tsd_test_usd_animated_visibility.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) -def Xform "World" +def Mesh "Blinker" { - def Mesh "Triangles" - { - int[] faceVertexCounts = [3, 3] - int[] faceVertexIndices = [0, 1, 2, 0, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( - interpolation = "faceVarying" - ) - int[] primvars:st:indices = [0, 1, 2, 0, 2, 3] - normal3f[] primvars:normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), - (0, 0, 1), (0, 0, 1), (0, 0, 1)] ( - interpolation = "faceVarying" - ) + token visibility.timeSamples = { + 0: "inherited", + 1: "invisible", } + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] } )"); @@ -998,2615 +502,15 @@ def Xform "World" WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The UVs arrive flattened, one value per triangle corner") - { - auto geometry = findGeometry(scene, "/World/Triangles"); - REQUIRE(geometry); - - auto *uvs = geometry->parameterValueAsObject( - "faceVarying.attribute0"); - REQUIRE(uvs != nullptr); - REQUIRE(uvs->size() == 6); - - const auto *uv = uvs->dataAs(); - REQUIRE(uv[3].x == Approx(0.0f)); // second triangle's first corner - REQUIRE(uv[4].x == Approx(1.0f)); - REQUIRE(uv[5].x == Approx(0.0f)); - // `v` arrives reversed out of USD's v-up `st` into ANARI's, which runs - // down the image: the authored (0, 0) and (0, 1) become 1 and 0. - REQUIRE(uv[3].y == Approx(1.0f)); - REQUIRE(uv[5].y == Approx(0.0f)); - } + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The normals arrive too") + THEN("The caller is told the animation was not represented") { - auto geometry = findGeometry(scene, "/World/Triangles"); - REQUIRE(geometry); - - auto *normals = geometry->parameterValueAsObject( - "faceVarying.normal"); - REQUIRE(normals != nullptr); - REQUIRE(normals->size() == 6); - } - } - } -} - -SCENARIO("A subset binds the UV primvar its own material reads", "[UsdImport]") -{ - GIVEN("Two subsets whose materials read differently named UV primvars") - { - StageFixture stage("tsd_test_usd_subset_uv_primvar.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Scope "Looks" - { - def Material "ReadsMapOne" - { - token outputs:surface.connect = - - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor.connect = - token outputs:surface - } - - def Shader "Tex" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file = @missing_texture.png@ - float2 inputs:st.connect = - float3 outputs:rgb - } - - def Shader "Reader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "map1" - float2 outputs:result - } - } - - def Material "ReadsMapTwo" - { - token outputs:surface.connect = - - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor.connect = - token outputs:surface - } - - def Shader "Tex" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file = @missing_texture.png@ - float2 inputs:st.connect = - float3 outputs:rgb - } - - def Shader "Reader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "map2" - float2 outputs:result - } - } - } - - def Mesh "Quad" - { - int[] faceVertexCounts = [4, 4] - int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), - (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] - texCoord2f[] primvars:map1 = [(0.25, 0), (0.25, 0), (0.25, 0), - (0.25, 0), (0.25, 0), (0.25, 0), - (0.25, 0), (0.25, 0)] ( - interpolation = "faceVarying" - ) - texCoord2f[] primvars:map2 = [(0.75, 0), (0.75, 0), (0.75, 0), - (0.75, 0), (0.75, 0), (0.75, 0), - (0.75, 0), (0.75, 0)] ( - interpolation = "faceVarying" - ) - - def GeomSubset "Left" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [0] - rel material:binding = - } - - def GeomSubset "Right" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [1] - rel material:binding = - } - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Each subset's own primvar lands on its first attribute") - { - // The mesh itself binds no material, so the UV name cannot be decided - // once for the whole mesh: each subset's material names its own. - auto left = findGeometry(scene, "/World/Quad/Left"); - auto right = findGeometry(scene, "/World/Quad/Right"); - REQUIRE(left); - REQUIRE(right); - - auto *leftUVs = left->parameterValueAsObject( - "faceVarying.attribute0"); - auto *rightUVs = right->parameterValueAsObject( - "faceVarying.attribute0"); - REQUIRE(leftUVs != nullptr); - REQUIRE(rightUVs != nullptr); - REQUIRE(leftUVs->dataAs()[0].x == Approx(0.25f)); - REQUIRE(rightUVs->dataAs()[0].x == Approx(0.75f)); - } - } - } -} - -SCENARIO("An unconventionally named UV primvar is still found", "[UsdImport]") -{ - GIVEN("A material whose reader node asks for a primvar not called 'st'") - { - StageFixture stage("tsd_test_usd_uv_primvar.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Textured" - { - token outputs:surface.connect = - - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor.connect = - token outputs:surface - } - - def Shader "Tex" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file = @missing_texture.png@ - float2 inputs:st.connect = - float3 outputs:rgb - } - - def Shader "Reader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "map1" - float2 outputs:result - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - texCoord2f[] primvars:map1 = [(0, 0), (1, 0), (1, 1), (0, 1)] ( - interpolation = "vertex" - ) - rel material:binding = - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The named primvar lands on the geometry's first attribute") - { - auto geometry = scene.getObject(0); - REQUIRE(geometry); - auto *uvs = geometry->parameterValueAsObject( - "vertex.attribute0"); - REQUIRE(uvs != nullptr); - REQUIRE(uvs->size() == 4); - } - } - } -} - -SCENARIO("Native material passthrough is opt-in", "[UsdImport]") -{ - auto stringParameter = [](tsd::scene::Material *material, const char *name) { - auto *p = material->parameter(name); - return p ? p->value().getString() : std::string(); - }; - - GIVEN("A Stage whose material is an ordinary preview surface") - { - StageFixture stage("tsd_test_usd_preview_material.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Surface" - { - token outputs:surface.connect = - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.8, 0.2, 0.1) - float inputs:roughness = 0.4 - token outputs:surface - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("The default material mode is used") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("A portable physically-based material is emitted") - { - REQUIRE(boundMaterial(scene)->subtype() - == tsd::scene::tokens::material::physicallyBased); - REQUIRE(report.skipped.empty()); - } - } - - WHEN("A native passthrough is asked for that this material cannot give") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MDL; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - THEN("The fallback to a portable mapping is reported, not silent") - { - REQUIRE(boundMaterial(scene)->subtype() - == tsd::scene::tokens::material::physicallyBased); - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) - == 1); - } - } - -#if TSD_USD_HAS_MATERIALX - WHEN("MaterialX emission is asked for") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - THEN("A preview surface falls back rather than emitting a bad document") - { - // MaterialX has no node definition for UsdPreviewSurface, so there is - // nothing to pass through; the portable mapping is used and said so. - REQUIRE(boundMaterial(scene)->subtype() - == tsd::scene::tokens::material::physicallyBased); REQUIRE( - report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) == 1); } } -#endif - } - -#if TSD_USD_HAS_MATERIALX - GIVEN("A Stage with an authored MaterialX network") - { - StageFixture stage("tsd_test_usd_materialx.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Surface" - { - token outputs:mtlx:surface.connect = - - def Shader "Standard" - { - uniform token info:id = "ND_standard_surface_surfaceshader" - color3f inputs:base_color = (0.8, 0.2, 0.1) - float inputs:specular_roughness = 0.4 - token outputs:surface - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("MaterialX emission is asked for") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - THEN("The network passes through as an inline MaterialX document") - { - auto *material = boundMaterial(scene); - REQUIRE(material->subtype() == tsd::scene::tokens::material::materialx); - REQUIRE(stringParameter(material, "sourceType") == "documentInline"); - - const auto source = stringParameter(material, "source"); - REQUIRE(source.find("subtype() - != tsd::scene::tokens::material::materialx); - } - } - - // The importer's MaterialX mode is only reachable from an application - // through an Importer Type, so the dispatch is worth pinning separately - // from the option it sets. - WHEN("The file is imported through the USD_MTLX Importer Type") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_file(scene, - animMgr, - {tsd::io::ImporterType::USD_MTLX, stage.path()}); - - THEN("MaterialX materials arrive without asking for options") - { - REQUIRE(boundMaterial(scene)->subtype() - == tsd::scene::tokens::material::materialx); - } - } - - WHEN("The file is imported through the plain USD Importer Type") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_file(scene, - animMgr, - {tsd::io::ImporterType::USD, stage.path()}); - - THEN("The portable mapping is what arrives") - { - REQUIRE(boundMaterial(scene)->subtype() - != tsd::scene::tokens::material::materialx); - } - } - } - - // An inline document has no file of its own for a relative path to be - // relative to, so a texture that stays relative is a texture the device - // cannot open. - GIVEN("A MaterialX network reading textures by relative path") - { - TextureFixture present("tsd_test_usd_mtlx_present.tga"); - - StageFixture stage("tsd_test_usd_materialx_textures.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Surface" - { - token outputs:mtlx:surface.connect = - - def Shader "Present" - { - uniform token info:id = "ND_image_color3" - asset inputs:file = @tsd_test_usd_mtlx_present.tga@ - color3f outputs:out - } - - def Shader "Tiled" - { - uniform token info:id = "ND_image_color3" - asset inputs:file = @tiles/tsd_test_tile..png@ - color3f outputs:out - } - - def Shader "Standard" - { - uniform token info:id = "ND_standard_surface_surfaceshader" - color3f inputs:base_color.connect = - color3f inputs:coat_color.connect = - token outputs:surface - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("MaterialX emission is asked for") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - const auto source = - stringParameter(boundMaterial(scene), "source"); - - THEN("Texture paths leave as absolute paths") - { - REQUIRE(source.find(present.path()) != std::string::npos); - REQUIRE(source.find("\"tsd_test_usd_mtlx_present.tga\"") - == std::string::npos); - } - - THEN("A tile set is anchored without losing its token") - { - const auto tiled = - (std::filesystem::temp_directory_path() / "tiles").string(); - REQUIRE(source.find(tiled) != std::string::npos); - REQUIRE(source.find("") != std::string::npos); - } - - THEN("The texture that exists is not reported as missing") - { - for (const auto &skip : report.skipped) { - const bool missedThisOne = - skip.reason == tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED - && skip.detail == present.path(); - REQUIRE_FALSE(missedThisOne); - } - } - - // The device reads texels from samplers bound to the document's - // `filename` inputs by their document path, not by opening the files - // itself, so a material without them renders untextured however correct - // its paths are. - THEN("A sampler is bound to the input by its document path") - { - REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); - - // The name is the contract: the device publishes each textured input - // under its MaterialX element path. - auto *material = boundMaterial(scene); - std::string boundName; - for (size_t i = 0; i < material->numParameters(); i++) { - if (material->parameterAt(i).value().type() == ANARI_SAMPLER) - boundName = material->parameterNameAt(i); - } - REQUIRE_FALSE(boundName.empty()); - // The document path, node graph included -- the same string the device's - // shader generator reports as the port's path. - REQUIRE(boundName == "_/Present/file"); - } - - THEN("A tile set binds nothing, and says so") - { - REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); - // Only the one loadable texture became a sampler. - REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); - } - } - } - - GIVEN("A MaterialX network naming a texture that is not there") - { - StageFixture stage("tsd_test_usd_materialx_missing.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Surface" - { - token outputs:mtlx:surface.connect = - - def Shader "Missing" - { - uniform token info:id = "ND_image_color3" - asset inputs:file = @tsd_test_usd_absent.png@ - color3f outputs:out - } - - def Shader "Standard" - { - uniform token info:id = "ND_standard_surface_surfaceshader" - color3f inputs:base_color.connect = - token outputs:surface - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("MaterialX emission is asked for") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - THEN("The Import Report names it rather than leaving it to the device") - { - REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); - } - } - } - - // MaterialX matches a node to its definition on the exact set of inputs, so - // a connection between mismatched types leaves the surface node resolving to - // nothing. Emitting it anyway puts the failure inside the device, where it - // reads as `Could not find a nodedef for node 'Surface'` and the prim - // silently renders with the default material. - GIVEN("A MaterialX network connecting a color3 output to a float input") - { - StageFixture stage("tsd_test_usd_materialx_mistyped.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Surface" - { - token outputs:mtlx:surface.connect = - - def Shader "Tint" - { - uniform token info:id = "ND_constant_color3" - color3f inputs:value = (0.25, 0.5, 0.75) - color3f outputs:out - } - - def Shader "Standard" - { - uniform token info:id = "ND_standard_surface_surfaceshader" - float inputs:specular_roughness.connect = - token outputs:surface - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("MaterialX emission is asked for") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - THEN("The Import Report names it rather than leaving it to the device") - { - REQUIRE( - report.contains(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED)); - } - - THEN("The portable mapping is what arrives, not a document") - { - REQUIRE(boundMaterial(scene)->subtype() - != tsd::scene::tokens::material::materialx); - } - } - } -#endif -} - -// A material resolves once and is cached, including when it does not resolve -// at all. Without a negative entry every prim bound to the same broken -// material re-runs the resolve and files its own Import Report entry, which -// makes the counts the report prints scale with the binding count. -SCENARIO( - "An unresolvable material is reported once per material rather than" - " once per binding", - "[UsdImport]") -{ - GIVEN("A Stage where three meshes bind one material with no network") - { - StageFixture stage("tsd_test_usd_unresolvable_material.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Broken" - { - } - - def Mesh "QuadA" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } - - def Mesh "QuadB" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] - rel material:binding = - } - - def Mesh "QuadC" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(4, 0, 0), (5, 0, 0), (5, 1, 0), (4, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("The Stage is imported") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = - tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}); - - THEN("The Import Report names the material exactly once") - { - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED) - == 1); - } - - THEN("Every mesh still arrives") - { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 3); - } - } - } -} - -SCENARIO("An OmniPBR material maps onto the portable material", "[UsdImport]") -{ - GIVEN("A Stage whose material is an OmniPBR MDL shader") - { - StageFixture stage("tsd_test_usd_omnipbr.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "OmniPBR" - { - token outputs:mdl:surface.connect = - def Shader "Shader" - { - uniform token info:implementationSource = "sourceAsset" - uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@ - uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR" - color3f inputs:diffuse_color_constant = (0.9, 0.1, 0.2) - float inputs:metallic_constant = 0.75 - float inputs:reflection_roughness_constant = 0.25 - float inputs:ior_constant = 1.4 - bool inputs:enable_emission = 1 - color3f inputs:emissive_color = (0, 0.5, 0) - float inputs:emissive_intensity = 2 - token outputs:out - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("The default material mode is used") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - auto *material = boundMaterial(scene); - - THEN("Its authored inputs arrive, not the preview-surface defaults") - { - REQUIRE(material->subtype() - == tsd::scene::tokens::material::physicallyBased); - - auto color = material->parameterValueAs("baseColor"); - REQUIRE(color.has_value()); - REQUIRE(color->x == Approx(0.9f)); - REQUIRE(color->y == Approx(0.1f)); - REQUIRE(color->z == Approx(0.2f)); - - REQUIRE( - *material->parameterValueAs("metallic") == Approx(0.75f)); - REQUIRE( - *material->parameterValueAs("roughness") == Approx(0.25f)); - REQUIRE(*material->parameterValueAs("ior") == Approx(1.4f)); - - auto emissive = - material->parameterValueAs("emissive"); - REQUIRE(emissive.has_value()); - REQUIRE(emissive->y == Approx(1.0f)); - } - - THEN("Nothing claims a richer material was left on the table") - { - REQUIRE(!report.contains( - tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE)); - } - } - - WHEN("MDL passthrough is asked for instead") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::UsdImportOptions options; - options.materialMode = tsd::io::UsdMaterialMode::MDL; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); - - THEN("The native shader still wins over the portable mapping") - { - REQUIRE(boundMaterial(scene)->subtype() - == tsd::scene::tokens::material::mdl); - } - } - } - - GIVEN("An OmniPBR material reading textures and cutting out on opacity") - { - TextureFixture diffuse("tsd_test_usd_omnipbr_diffuse.tga"); - - StageFixture stage("tsd_test_usd_omnipbr_textured.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "OmniPBR" - { - token outputs:mdl:surface.connect = - def Shader "Shader" - { - uniform token info:implementationSource = "sourceAsset" - uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@ - uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR" - asset inputs:diffuse_texture = @tsd_test_usd_omnipbr_diffuse.tga@ - bool inputs:enable_opacity = 1 - float inputs:opacity_threshold = 0.3 - token outputs:out - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("The default material mode is used") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - auto *material = boundMaterial(scene); - - THEN("The texture named on the shader input is bound") - { - REQUIRE( - material->parameterValueAsObject("baseColor") - != nullptr); - REQUIRE(!report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); - } - - THEN("An authored threshold becomes a cutout rather than a blend") - { - REQUIRE(material->parameterValueAs("alphaMode") - == std::string("mask")); - REQUIRE( - *material->parameterValueAs("alphaCutoff") == Approx(0.3f)); - - // The mode is a string selection, so the index has to agree with the - // value wherever the selection is what gets read. - auto *alphaMode = material->parameter("alphaMode"); - REQUIRE(alphaMode->stringValues()[alphaMode->stringSelection()] - == std::string("mask")); - } - } - } - - GIVEN("A material whose MDL module only looks like OmniPBR") - { - StageFixture stage("tsd_test_usd_omnipbr_lookalike.usda", R"(#usda 1.0 - -def Xform "World" -{ - def Material "Lookalike" - { - token outputs:mdl:surface.connect = - token outputs:surface.connect = - - def Shader "Mdl" - { - uniform token info:implementationSource = "sourceAsset" - uniform asset info:mdl:sourceAsset = @OmniPBRBase.mdl@ - uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBRBase" - color3f inputs:diffuse_color_constant = (0.9, 0.1, 0.2) - token outputs:out - } - - def Shader "PBR" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.1, 0.2, 0.9) - token outputs:surface - } - } - - def Mesh "Quad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - rel material:binding = - } -} -)"); - - WHEN("The default material mode is used") - { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("A module that merely starts with the name is not mapped as one") - { - // The authored preview surface is what this material actually says; - // OmniPBRBase is a different shader with input semantics of its own. - auto color = boundMaterial(scene)->parameterValueAs( - "baseColor"); - REQUIRE(color.has_value()); - REQUIRE(color->z == Approx(0.9f)); - } - } - } -} - -SCENARIO( - "A prim with no bound material takes its display colour", "[UsdImport]") -{ - GIVEN("A mesh with display colour and opacity but no material") - { - StageFixture stage("tsd_test_usd_display_color.usda", R"(#usda 1.0 - -def Mesh "Quad" -{ - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - color3f[] primvars:displayColor = [(0.25, 0.5, 0.75)] ( - interpolation = "constant" - ) - float[] primvars:displayOpacity = [0.5] ( - interpolation = "constant" - ) -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The Surface's material carries the display values") - { - auto surface = scene.getObject(0); - REQUIRE(surface); - auto *material = surface->parameterValueAsObject( - tsd::scene::tokens::surface::material); - REQUIRE(material != nullptr); - - const auto color = - material->parameterValueAs("color"); - REQUIRE(color.has_value()); - REQUIRE(color->x == Approx(0.25f)); - REQUIRE(color->y == Approx(0.5f)); - REQUIRE(color->z == Approx(0.75f)); - - const auto opacity = material->parameterValueAs("opacity"); - REQUIRE(opacity.has_value()); - REQUIRE(*opacity == Approx(0.5f)); - } - } - } -} - -SCENARIO( - "Light exposure and normalization reach the emitted light", "[UsdImport]") -{ - GIVEN("A sphere light with exposure and normalization set") - { - // intensity 4, exposure 2 -> 4 * 2^2 = 16; normalize divides by the - // sphere's area, 4*pi*r^2 with r = 2 -> 16 / (16*pi). - StageFixture stage("tsd_test_usd_light_radiometry.usda", R"(#usda 1.0 - -def SphereLight "Lamp" -{ - float inputs:intensity = 4 - float inputs:exposure = 2 - bool inputs:normalize = true - float inputs:radius = 2 - color3f inputs:color = (1, 1, 1) -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The light's intensity accounts for both") - { - REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 1); - auto light = scene.getObject(0); - REQUIRE(light); - REQUIRE(light->subtype() == tsd::scene::tokens::light::point); - - const auto intensity = light->parameterValueAs("intensity"); - REQUIRE(intensity.has_value()); - const float expected = 16.f / (4.f * float(M_PI) * 4.f); - REQUIRE(*intensity == Approx(expected)); - REQUIRE(report.skipped.empty()); - } - } - } -} - -SCENARIO("A shaped sphere light becomes a spot light", "[UsdImport]") -{ - GIVEN("A sphere light carrying shaping attributes") - { - StageFixture stage("tsd_test_usd_spot.usda", R"(#usda 1.0 - -def SphereLight "Spot" ( - prepend apiSchemas = ["ShapingAPI"] -) -{ - float inputs:intensity = 1 - float inputs:radius = 0.5 - float inputs:shaping:cone:angle = 30 - float inputs:shaping:cone:softness = 0.5 -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Spot lighting survives the import") - { - auto light = scene.getObject(0); - REQUIRE(light); - REQUIRE(light->subtype() == tsd::scene::tokens::light::spot); - - const auto opening = light->parameterValueAs("openingAngle"); - REQUIRE(opening.has_value()); - REQUIRE(*opening == Approx(2.f * 30.f * float(M_PI) / 180.f)); - - const auto falloff = light->parameterValueAs("falloffAngle"); - REQUIRE(falloff.has_value()); - REQUIRE(*falloff == Approx(0.5f * 0.5f * *opening)); - } - } - } -} - -SCENARIO("Analytic quadrics stay analytic", "[UsdImport]") -{ - GIVEN("A Stage with a sphere and a cylinder") - { - StageFixture stage("tsd_test_usd_quadrics.usda", R"(#usda 1.0 - -def Sphere "Ball" -{ - double radius = 2 -} - -def Cylinder "Tube" -{ - double radius = 0.5 - double height = 4 - uniform token axis = "Y" -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("They map onto TSD's native quadric geometry, not meshes") - { - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 2); - - auto ball = scene.getObject(0); - REQUIRE(ball->subtype() == tsd::scene::tokens::geometry::sphere); - REQUIRE(ball->parameterValueAs("radius").value() == Approx(2.f)); - - auto tube = scene.getObject(1); - REQUIRE(tube->subtype() == tsd::scene::tokens::geometry::cylinder); - - // The spine axis is folded into the endpoints rather than a transform. - auto *positions = - tube->parameterValueAsObject("vertex.position"); - REQUIRE(positions != nullptr); - REQUIRE(positions->size() == 2); - const auto *p = positions->dataAs(); - REQUIRE(p[0].y == Approx(-2.f)); - REQUIRE(p[1].y == Approx(2.f)); - REQUIRE(report.skipped.empty()); - } - } - } -} - -SCENARIO( - "A non-convex polygon tessellates without spurious geometry", "[UsdImport]") -{ - GIVEN("A mesh with one concave five-sided face") - { - StageFixture stage("tsd_test_usd_nonconvex.usda", R"(#usda 1.0 - -def Mesh "Arrow" -{ - int[] faceVertexCounts = [5] - int[] faceVertexIndices = [0, 1, 2, 3, 4] - point3f[] points = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (1, 1, 0), (0, 2, 0)] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("It becomes exactly n-2 triangles") - { - auto geometry = scene.getObject(0); - REQUIRE(geometry); - auto *index = geometry->parameterValueAsObject( - "primitive.index"); - REQUIRE(index != nullptr); - REQUIRE(index->size() == 3); - } - } - } -} - -SCENARIO("Subdivision surfaces are refined by default", "[UsdImport]") -{ - GIVEN("A cube that explicitly declares a subdivision scheme") - { - StageFixture stage("tsd_test_usd_subdiv.usda", R"(#usda 1.0 - -def Mesh "SubdivCube" -{ - uniform token subdivisionScheme = "catmullClark" - int[] faceVertexCounts = [4, 4, 4, 4, 4, 4] - int[] faceVertexIndices = [0, 1, 2, 3, 4, 7, 6, 5, 0, 4, 5, 1, - 1, 5, 6, 2, 2, 6, 7, 3, 3, 7, 4, 0] - point3f[] points = [(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), - (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)] -} - -def Mesh "PolygonCube" -{ - uniform token subdivisionScheme = "none" - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported at the default refinement level") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The subdivision mesh gains vertices and the polygon mesh does not") - { - auto subdiv = scene.getObject(0); - auto polygon = scene.getObject(1); - REQUIRE(subdiv); - REQUIRE(polygon); - - auto vertexCount = [](auto geometry) { - auto *p = - geometry->template parameterValueAsObject( - "vertex.position"); - return p ? p->size() : size_t(0); - }; - - // Two levels of Catmull-Clark on a cube: 8 -> 26 -> 98 vertices. - REQUIRE(vertexCount(subdiv) == 98); - REQUIRE(vertexCount(polygon) == 4); - } - } - - WHEN("Refinement is turned off") - { - tsd::io::UsdImportOptions options; - options.refinementLevel = 0; - tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}, options); - - THEN("The subdivision mesh arrives at its authored resolution") - { - auto subdiv = scene.getObject(0); - auto *p = subdiv->parameterValueAsObject( - "vertex.position"); - REQUIRE(p != nullptr); - REQUIRE(p->size() == 8); - } - } - } -} - -SCENARIO("Animation is captured at the times actually authored", "[UsdImport]") -{ - GIVEN("A Stage with transforms keyed on a non-uniform time base") - { - StageFixture stage("tsd_test_usd_time_base.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 10 -) - -def Xform "Mover" -{ - double3 xformOp:translate.timeSamples = { - 0: (0, 0, 0), - 1: (1, 0, 0), - 10: (10, 0, 0), - } - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Mesh "Quad" - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The binding's time base mirrors the authored sample spacing") - { - REQUIRE(animMgr.animations().size() == 1); - const auto &bindings = animMgr.animations()[0].transformBindings(); - REQUIRE(bindings.size() == 1); - const auto &timeBase = bindings[0].timeBase(); - REQUIRE(timeBase.size() == 3); - REQUIRE(timeBase[0] == Approx(0.0f)); - REQUIRE(timeBase[1] == Approx(0.1f)); - REQUIRE(timeBase[2] == Approx(1.0f)); - } - } - } -} - -SCENARIO("A full turn authored with two keys does not collapse", "[UsdImport]") -{ - GIVEN("A prim rotating 360 degrees between two keyframes") - { - StageFixture stage("tsd_test_usd_full_turn.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 1 -) - -def Xform "Spinner" -{ - float3 xformOp:rotateXYZ.timeSamples = { - 0: (0, 0, 0), - 1: (0, 360, 0), - } - uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] - - def Mesh "Quad" - { - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Extra samples are inserted so the rotation still animates") - { - REQUIRE(animMgr.animations().size() == 1); - const auto &bindings = animMgr.animations()[0].transformBindings(); - REQUIRE(bindings.size() == 1); - REQUIRE(bindings[0].sampleCount() > 2); - } - } - } - - GIVEN("A prim translating between two keyframes") - { - StageFixture stage("tsd_test_usd_small_move.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 1 -) - -def Xform "Slider" -{ - double3 xformOp:translate.timeSamples = { - 0: (0, 0, 0), - 1: (5, 0, 0), - } - uniform token[] xformOpOrder = ["xformOp:translate"] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("No extra samples are invented") - { - const auto &bindings = animMgr.animations()[0].transformBindings(); - REQUIRE(bindings[0].sampleCount() == 2); - } - } - } -} - -SCENARIO("Cameras from a Stage arrive in the camera pool", "[UsdImport]") -{ - GIVEN("A Stage with an animated camera rig") - { - StageFixture stage("tsd_test_usd_camera.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Xform "Rig" -{ - double3 xformOp:translate.timeSamples = { - 0: (0, 0, 0), - 2: (0, 0, 10), - } - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Camera "Shot" - { - float focalLength = 50 - float horizontalAperture = 36 - float verticalAperture = 24 - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - const auto camerasBefore = scene.numberOfObjects(ANARI_CAMERA); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The authored viewpoint is available and animated") - { - REQUIRE(scene.numberOfObjects(ANARI_CAMERA) == camerasBefore + 1); - - bool hasCameraAnimation = false; - for (const auto &animation : animMgr.animations()) { - if (!animation.objectParameterBindings().empty()) - hasCameraAnimation = true; - } - REQUIRE(hasCameraAnimation); - } - } - } -} - -SCENARIO( - "Refinement carries face-varying primvars with the surface", "[UsdImport]") -{ - GIVEN("A subdivision mesh whose UVs are authored per face corner") - { - StageFixture stage("tsd_test_usd_subdiv_uvs.usda", R"(#usda 1.0 - -def Mesh "SubdivQuad" -{ - uniform token subdivisionScheme = "catmullClark" - int[] faceVertexCounts = [4] - int[] faceVertexIndices = [0, 1, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( - interpolation = "faceVarying" - ) -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The UVs survive refinement rather than being dropped") - { - auto geometry = scene.getObject(0); - REQUIRE(geometry); - auto *uvs = geometry->parameterValueAsObject( - "faceVarying.attribute0"); - REQUIRE(uvs != nullptr); - REQUIRE(uvs->size() > 4); - } - - THEN("Nothing is reported as lost") - { - REQUIRE(report.skipped.empty()); - } - } - } -} - -SCENARIO("Analytic prims without a material take their display colour", - "[UsdImport]") -{ - GIVEN("A point cloud carrying only display colour") - { - StageFixture stage("tsd_test_usd_points_display_color.usda", R"(#usda 1.0 - -def Points "Cloud" -{ - point3f[] points = [(0, 0, 0), (1, 0, 0)] - float[] widths = [0.2, 0.4] - color3f[] primvars:displayColor = [(1, 0, 0)] ( - interpolation = "constant" - ) -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Its material carries the display colour, not TSD's default") - { - auto surface = scene.getObject(0); - REQUIRE(surface); - auto *material = surface->parameterValueAsObject( - tsd::scene::tokens::surface::material); - REQUIRE(material != nullptr); - REQUIRE(material != scene.defaultMaterial().data()); - - const auto color = - material->parameterValueAs("color"); - REQUIRE(color.has_value()); - REQUIRE(color->x == Approx(1.f)); - REQUIRE(color->y == Approx(0.f)); - } - - THEN("Authored widths become per-point radii") - { - auto geometry = scene.getObject(0); - auto *radii = geometry->parameterValueAsObject( - "vertex.radius"); - REQUIRE(radii != nullptr); - REQUIRE(radii->size() == 2); - REQUIRE(radii->dataAs()[1] == Approx(0.2f)); - } - } - } -} - -SCENARIO("A prim that resets the transform stack ignores its ancestors", - "[UsdImport]") -{ - GIVEN("A child that resets the transform stack under a moved parent") - { - StageFixture stage("tsd_test_usd_xform_reset.usda", R"(#usda 1.0 - -def Xform "Parent" -{ - double3 xformOp:translate = (10, 0, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Mesh "Detached" - { - double3 xformOp:translate = (1, 2, 3) - uniform token[] xformOpOrder = ["!resetXformStack!", "xformOp:translate"] - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Composing parent and child lands where USD puts the child") - { - auto *layer = scene.defaultLayer(); - auto parent = findNode(layer, "Parent"); - auto detached = findNode(layer, "Detached"); - REQUIRE(parent); - REQUIRE(detached); - - const auto composed = tsd::math::mul( - (*parent)->getTransform(), (*detached)->getTransform()); - REQUIRE(composed[3].x == Approx(1.f)); - REQUIRE(composed[3].y == Approx(2.f)); - REQUIRE(composed[3].z == Approx(3.f)); - } - } - } -} - -SCENARIO("Time-varying visibility is reported rather than lost", "[UsdImport]") -{ - GIVEN("A mesh whose visibility is animated") - { - StageFixture stage("tsd_test_usd_animated_visibility.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Mesh "Blinker" -{ - token visibility.timeSamples = { - 0: "inherited", - 1: "invisible", - } - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The caller is told the animation was not represented") - { - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) - == 1); - } - } - } -} - -SCENARIO( - "Lazily-bound deforming geometry survives save and reload", "[UsdImport]") -{ - GIVEN("A Stage whose mesh points are time-sampled") - { - StageFixture stage("tsd_test_usd_deforming.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Mesh "Blob" -{ - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], - 1: [(0, 0, 0), (2, 0, 0), (0, 2, 0)], - 2: [(0, 0, 0), (3, 0, 0), (0, 3, 0)], - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Only one frame is eager; the rest is bound to the Stage") - { - REQUIRE(animMgr.animations().size() == 1); - REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); - REQUIRE( - animMgr.animations()[0].fileBindings()[0]->kind() == "usdGeometry"); - - auto geometry = scene.getObject(0); - auto *positions = geometry->parameterValueAsObject( - "vertex.position"); - REQUIRE(positions != nullptr); - REQUIRE(positions->size() == 3); - } - - THEN("Scrubbing pulls a later frame from the Stage") - { - animMgr.setAnimationTime(1.0f); - - auto geometry = scene.getObject(0); - auto *positions = geometry->parameterValueAsObject( - "vertex.position"); - REQUIRE(positions != nullptr); - REQUIRE(positions->dataAs()[1].x == Approx(3.f)); - } - - THEN("The binding reconstructs from an Archive") - { - tsd::core::DataTree tree; - REQUIRE( - tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); - - tsd::animation::AnimationManager restored(&scene); - REQUIRE(tsd::io::deserialize_AnimationManagerArchive( - restored, tree.root())); - REQUIRE(restored.animations().size() == 1); - REQUIRE(restored.animations()[0].fileBindings().size() == 1); - REQUIRE(restored.animations()[0].fileBindings()[0]->kind() - == "usdGeometry"); - - restored.setAnimationTime(1.0f); - auto geometry = scene.getObject(0); - auto *positions = geometry->parameterValueAsObject( - "vertex.position"); - REQUIRE(positions->dataAs()[1].x == Approx(3.f)); - } - } - } -} - -SCENARIO("Claimed dialect prims are handled once and only once", "[UsdImport]") -{ - GIVEN("A Stage whose carrier prims are claimed by the TSD dialect") - { - // The EnSight carrier marker is customData on the carrier's children; the - // claim-and-prune pre-pass must keep the generic path from converting - // them into meaningless geometry. - StageFixture stage("tsd_test_usd_dialect.usda", R"(#usda 1.0 - -def Scope "Dataset" -{ - def Mesh "part_one" ( - customData = { - dictionary ensight = { - string partName = "part_one" - } - } - ) - { - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } -} - -def Mesh "Real" -{ - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The carrier prim does not also arrive as generic geometry") - { - // Only the ordinary mesh converts: the claimed subtree is pruned from - // the resolved scene entirely. - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE_FALSE(findNode(scene.defaultLayer(), "part_one")); - } - } - } -} - -SCENARIO("Width-less curves and points get a bounds-scaled radius", - "[UsdImport]") -{ - // Blender hair exports commonly omit widths; without an explicit radius the - // ANARI default of 1 world unit dwarfs most scenes. - GIVEN("A Stage with a widthless curve, a widthed curve, and widthless points") - { - StageFixture stage("tsd_test_usd_widthless_curves.usda", R"(#usda 1.0 - -def Xform "World" -{ - def BasisCurves "Hair" - { - uniform token type = "linear" - int[] curveVertexCounts = [4] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)] - } - - def BasisCurves "Rope" - { - uniform token type = "linear" - int[] curveVertexCounts = [2] - point3f[] points = [(0, 0, 0), (1, 0, 0)] - float[] widths = [0.2, 0.2] (interpolation = "vertex") - } - - def Points "Sprinkles" - { - point3f[] points = [(0, 0, 0), (2, 0, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("The widthless curve's radius scales with its bounds") - { - auto geometry = findGeometry(scene, "/World/Hair"); - REQUIRE(geometry); - REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::curve); - REQUIRE(geometry->parameterValueAsObject( - "vertex.radius") - == nullptr); - - const auto radius = geometry->parameterValueAs("radius"); - REQUIRE(radius.has_value()); - REQUIRE(*radius == Approx(1e-3f * std::sqrt(3.f))); - } - - THEN("Authored widths still become per-vertex radii") - { - auto geometry = findGeometry(scene, "/World/Rope"); - REQUIRE(geometry); - - auto *radii = geometry->parameterValueAsObject( - "vertex.radius"); - REQUIRE(radii != nullptr); - REQUIRE(radii->size() == 2); - REQUIRE(radii->dataAs()[0] == Approx(0.1f)); - REQUIRE_FALSE(geometry->parameterValueAs("radius").has_value()); - } - - THEN("Widthless points scale the same way") - { - auto geometry = findGeometry(scene, "/World/Sprinkles"); - REQUIRE(geometry); - REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::sphere); - - const auto radius = geometry->parameterValueAs("radius"); - REQUIRE(radius.has_value()); - REQUIRE(*radius == Approx(2e-3f)); - } - } - } -} - -// Animated USD /////////////////////////////////////////////////////////////// - -namespace { - -// The transform Array of the first transform-array node beneath `name`. -tsd::scene::Array *findTransformArray( - tsd::scene::Layer *layer, const char *name) -{ - auto parent = findNode(layer, name); - if (!parent) - return nullptr; - tsd::scene::Array *found = nullptr; - layer->traverse(parent, [&](auto &node, int) { - if (!found && node->type() == ANARI_ARRAY1D) - found = node->getTransformArray(); - return true; - }); - return found; -} - -} // namespace - -SCENARIO("A point instancer's placements follow the Stage clock", "[UsdImport]") -{ - GIVEN("A PointInstancer whose positions and scales are time-sampled") - { - StageFixture stage("tsd_test_usd_animated_instancer.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def PointInstancer "Swarm" -{ - point3f[] positions.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0), (2, 0, 0)], - 2: [(0, 0, 0), (10, 0, 0), (20, 0, 0)], - } - float3[] scales.timeSamples = { - 0: [(1, 1, 1), (1, 1, 1), (1, 1, 1)], - 2: [(2, 2, 2), (2, 2, 2), (2, 2, 2)], - } - int[] protoIndices = [0, 0, 0] - rel prototypes = [] - - def Mesh "Proto" - { - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("One Animation holds the instancer's binding") - { - REQUIRE(animMgr.animations().size() == 1); - REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); - REQUIRE(animMgr.animations()[0].fileBindings()[0]->kind() - == "usdInstancer"); - } - - THEN("The Stage's frame range and rate are reported, not applied") - { - REQUIRE(report.animatedPrims == 1); - REQUIRE(report.sampleCount == 2); - REQUIRE(report.timeCodesPerSecond == Approx(24.f)); - REQUIRE(animMgr.getAnimationTotalFrames() == 100); // untouched - } - - THEN("The imported placements are the Stage's first frame") - { - auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); - REQUIRE(transforms != nullptr); - REQUIRE(transforms->size() == 3); - const auto *m = transforms->dataAs(); - REQUIRE(m[2][3].x == Approx(2.f)); - REQUIRE(m[2][0].x == Approx(1.f)); - } - - THEN("Scrubbing re-fills the same Array in place") - { - auto *before = findTransformArray(scene.defaultLayer(), "Swarm"); - REQUIRE(before != nullptr); - - animMgr.setAnimationTime(1.0f); - - auto *after = findTransformArray(scene.defaultLayer(), "Swarm"); - REQUIRE(after == before); // no reallocation on a constant count - const auto *m = after->dataAs(); - REQUIRE(m[2][3].x == Approx(20.f)); - REQUIRE(m[2][0].x == Approx(2.f)); - } - - THEN("A time between authored samples is interpolated, not snapped") - { - animMgr.setAnimationTime(0.5f); - - auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); - REQUIRE(transforms != nullptr); - const auto *m = transforms->dataAs(); - REQUIRE(m[2][3].x == Approx(11.f)); - } - } - } -} - -SCENARIO("An instancer whose placement count changes reallocates", - "[UsdImport]") -{ - GIVEN("A PointInstancer that gains a placement mid-sequence") - { - StageFixture stage("tsd_test_usd_growing_instancer.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def PointInstancer "Growing" -{ - point3f[] positions.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0)], - 2: [(0, 0, 0), (1, 0, 0), (2, 0, 0)], - } - int[] protoIndices.timeSamples = { - 0: [0, 0], - 2: [0, 0, 0], - } - rel prototypes = [] - - def Mesh "Proto" - { - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported and scrubbed past the change") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - auto *before = findTransformArray(scene.defaultLayer(), "Growing"); - REQUIRE(before != nullptr); - REQUIRE(before->size() == 2); - - animMgr.setAnimationTime(1.0f); - - THEN("The node is re-pointed at a right-sized Array") - { - auto *after = findTransformArray(scene.defaultLayer(), "Growing"); - REQUIRE(after != nullptr); - REQUIRE(after->size() == 3); - REQUIRE(after->dataAs()[2][3].x == Approx(2.f)); - } - } - } -} - -SCENARIO("Constant-valued time samples are not reported as a loss", - "[UsdImport]") -{ - GIVEN("A mesh whose visibility is authored at every frame but never changes") - { - // What a simulation exporter writes: every attribute re-authored at every - // frame regardless of whether it moved. - StageFixture stage("tsd_test_usd_constant_visibility.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Mesh "Steady" -{ - token visibility.timeSamples = { - 0: "inherited", - 1: "inherited", - 2: "inherited", - } - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Nothing is reported as dropped") - { - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) - == 0); - } - } - } -} - -SCENARIO("One import is one Animation", "[UsdImport]") -{ - GIVEN("A Stage animating two prims that share a leaf name") - { - StageFixture stage("tsd_test_usd_one_animation.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Xform "A" -{ - def Xform "Mover" - { - double3 xformOp:translate.timeSamples = { - 0: (0, 0, 0), - 2: (2, 0, 0), - } - uniform token[] xformOpOrder = ["xformOp:translate"] - } -} - -def Xform "B" -{ - def Xform "Mover" - { - double3 xformOp:translate.timeSamples = { - 0: (0, 0, 0), - 2: (0, 5, 0), - } - uniform token[] xformOpOrder = ["xformOp:translate"] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Both prims land in one Animation named for the file") - { - REQUIRE(animMgr.animations().size() == 1); - REQUIRE(animMgr.animations()[0].name() == stage.path()); - REQUIRE(animMgr.animations()[0].transformBindings().size() == 2); - } - - THEN("The Report counts them in place of the lost per-prim entries") - { - REQUIRE(report.animatedPrims == 2); - } - } - } -} - -SCENARIO("Instancer bindings survive save and reload", "[UsdImport]") -{ - GIVEN("An imported Stage with an animated PointInstancer") - { - StageFixture stage("tsd_test_usd_instancer_archive.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def PointInstancer "Swarm" -{ - point3f[] positions.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0)], - 2: [(0, 0, 0), (9, 0, 0)], - } - int[] protoIndices = [0, 0] - rel prototypes = [] - - def Mesh "Proto" - { - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - WHEN("The animation manager round-trips through an Archive") - { - tsd::core::DataTree tree; - REQUIRE(tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); - - tsd::animation::AnimationManager restored(&scene); - REQUIRE( - tsd::io::deserialize_AnimationManagerArchive(restored, tree.root())); - - THEN("The reconstructed binding scrubs the same Array") - { - REQUIRE(restored.animations().size() == 1); - REQUIRE(restored.animations()[0].fileBindings().size() == 1); - REQUIRE(restored.animations()[0].fileBindings()[0]->kind() - == "usdInstancer"); - - restored.setAnimationTime(1.0f); - - auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); - REQUIRE(transforms != nullptr); - REQUIRE(transforms->dataAs()[1][3].x == Approx(9.f)); - } - } - } -} - -SCENARIO("An old-format geometry binding still reconstructs", "[UsdImport]") -{ - GIVEN("An Archive node carrying the dropped sampleTimes and timeBase fields") - { - StageFixture stage("tsd_test_usd_legacy_binding.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Mesh "Blob" -{ - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], - 2: [(0, 0, 0), (3, 0, 0), (0, 3, 0)], - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - animMgr.removeAllAnimations(); - - // Written the way an Archive from before continuous Time Code evaluation - // was: the derived sample cache is present and must simply be ignored. - tsd::core::DataTree tree; - auto &node = tree.root(); - node["targetIndex"] = size_t(0); - node["stageFile"] = stage.path(); - node["primPath"] = std::string("/Blob"); - node["sampleTimes"].append() = 0.f; - node["sampleTimes"].append() = 2.f; - node["timeBase"].append() = 0.f; - node["timeBase"].append() = 1.f; - - WHEN("It is read back") - { - auto &anim = animMgr.addAnimation("legacy"); - REQUIRE(tsd::io::UsdGeometryFileBinding::addToAnimation( - anim, scene, node) - != nullptr); - - THEN("It scrubs from the Stage's own clock") - { - animMgr.setAnimationTime(1.0f); - - auto geometry = scene.getObject(0); - auto *positions = geometry->parameterValueAsObject( - "vertex.position"); - REQUIRE(positions != nullptr); - REQUIRE(positions->dataAs()[1].x == Approx(3.f)); - } - } - } -} - -SCENARIO("A Stage that authored no time-code range still animates", - "[UsdImport]") -{ - GIVEN("A PointInstancer with time samples but no startTimeCode") - { - // Nothing forces a Stage to declare its own range, and USD reports 0 for - // both ends when it does not. Without a fallback every animation time - // would map onto one Time Code and the placements would never move. - StageFixture stage("tsd_test_usd_unranged_instancer.usda", R"(#usda 1.0 - -def PointInstancer "Drifting" -{ - point3f[] positions.timeSamples = { - 5: [(0, 0, 0), (1, 0, 0)], - 9: [(0, 0, 0), (7, 0, 0)], - } - int[] protoIndices = [0, 0] - rel prototypes = [] - - def Mesh "Proto" - { - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported and scrubbed to the end") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - animMgr.setAnimationTime(1.0f); - - THEN("The authored samples define the range that time maps onto") - { - auto *transforms = findTransformArray(scene.defaultLayer(), "Drifting"); - REQUIRE(transforms != nullptr); - REQUIRE(transforms->dataAs()[1][3].x == Approx(7.f)); - } - } - } - - GIVEN("A deforming mesh with time samples but no startTimeCode") - { - StageFixture stage("tsd_test_usd_unranged_mesh.usda", R"(#usda 1.0 - -def Mesh "Blob" -{ - int[] faceVertexCounts = [3] - int[] faceVertexIndices = [0, 1, 2] - point3f[] points.timeSamples = { - 5: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], - 9: [(0, 0, 0), (4, 0, 0), (0, 4, 0)], - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported and scrubbed to the end") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - animMgr.setAnimationTime(1.0f); - - THEN("The authored samples define the range that time maps onto") - { - auto geometry = scene.getObject(0); - auto *positions = geometry->parameterValueAsObject( - "vertex.position"); - REQUIRE(positions != nullptr); - REQUIRE(positions->dataAs()[1].x == Approx(4.f)); - } - } - } -} - -SCENARIO("A mesh whose topology changes re-pulls a consistent set", - "[UsdImport]") -{ - GIVEN("A mesh whose points, indices and primvars all change together") - { - // The case a binding that only re-pulls points cannot serve: writing new - // positions without new indices would describe a mesh that never existed. - StageFixture stage("tsd_test_usd_morphing_mesh.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Mesh "Morphing" -{ - int[] faceVertexCounts.timeSamples = { - 0: [3], - 2: [3, 3], - } - int[] faceVertexIndices.timeSamples = { - 0: [0, 1, 2], - 2: [0, 1, 2, 0, 2, 3], - } - point3f[] points.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], - 2: [(0, 0, 0), (1, 0, 0), (0, 1, 0), (-1, 1, 0)], - } - color3f[] primvars:displayColor ( - interpolation = "vertex" - ) - color3f[] primvars:displayColor.timeSamples = { - 0: [(1, 0, 0), (0, 1, 0), (0, 0, 1)], - 2: [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0)], - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - auto geometry = scene.getObject(0); - REQUIRE(geometry); - - auto arraySize = [&](const char *parameter) -> size_t { - auto *array = - geometry->parameterValueAsObject(parameter); - return array ? array->size() : 0; - }; - - THEN("The first frame is one triangle over three vertices") - { - REQUIRE(arraySize("vertex.position") == 3); - REQUIRE(arraySize("primitive.index") == 1); - REQUIRE(arraySize("vertex.color") == 3); - } - - THEN("Scrubbing re-pulls points, indices and primvars together") - { - animMgr.setAnimationTime(1.0f); - - REQUIRE(arraySize("vertex.position") == 4); - REQUIRE(arraySize("primitive.index") == 2); - REQUIRE(arraySize("vertex.color") == 4); - - // Every index has to address the positions that arrived with it. - auto *indices = - geometry->parameterValueAsObject( - "primitive.index"); - const auto *triangles = indices->dataAs(); - for (size_t i = 0; i < indices->size(); ++i) { - REQUIRE(triangles[i].x < 4); - REQUIRE(triangles[i].y < 4); - REQUIRE(triangles[i].z < 4); - } - } - - THEN("The Surface and its Geometry keep their identity across the scrub") - { - const auto surfacesBefore = scene.numberOfObjects(ANARI_SURFACE); - const auto geometriesBefore = scene.numberOfObjects(ANARI_GEOMETRY); - const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); - - animMgr.setAnimationTime(1.0f); - - // Re-running conversion would have built new ones, forcing the render - // index to tear down and recreate ANARI handles (ADR 0022). - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == surfacesBefore); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == geometriesBefore); - REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); - } - } - } -} - -SCENARIO("Conversion leaves nothing behind for geometry it does not emit", - "[UsdImport]") -{ - GIVEN("A mesh with no points that nonetheless binds a material") - { - StageFixture stage("tsd_test_usd_empty_mesh.usda", R"(#usda 1.0 - -def Material "Orphan" -{ - token outputs:surface.connect = - - def Shader "Shader" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (1, 0, 0) - token outputs:surface - } -} - -def Mesh "Empty" ( - prepend apiSchemas = ["MaterialBindingAPI"] -) -{ - rel material:binding = - int[] faceVertexCounts = [] - int[] faceVertexIndices = [] - point3f[] points = [] -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - // A Scene creates one default Material of its own, so what matters is - // that the import adds none. - const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("No Surface, Geometry or Material is created for it") - { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 0); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 0); - REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); - } - } - } - - GIVEN("A mesh whose subset claims no faces but binds its own material") - { - StageFixture stage("tsd_test_usd_empty_subset.usda", R"(#usda 1.0 - -def Material "Used" -{ - token outputs:surface.connect = - - def Shader "Shader" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0, 1, 0) - token outputs:surface - } -} - -def Material "Unused" -{ - token outputs:surface.connect = - - def Shader "Shader" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0, 0, 1) - token outputs:surface - } -} - -def Mesh "Quad" -{ - int[] faceVertexCounts = [3, 3] - int[] faceVertexIndices = [0, 1, 2, 0, 2, 3] - point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] - - def GeomSubset "Drawn" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [0, 1] - rel material:binding = - } - - def GeomSubset "Empty" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [] - rel material:binding = - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - THEN("Only the material the drawn subset uses is created") - { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - - bool sawUnused = false; - for (size_t i = 0; i < scene.numberOfObjects(ANARI_MATERIAL); ++i) { - auto material = scene.getObject(i); - if (material && material->name().find("Unused") != std::string::npos) - sawUnused = true; - } - REQUIRE_FALSE(sawUnused); - } - } - } -} - -SCENARIO("Parts keep sharing one position Array across a resize", "[UsdImport]") -{ - GIVEN("A mesh divided into subsets whose vertex count changes") - { - StageFixture stage("tsd_test_usd_shared_resize.usda", R"(#usda 1.0 -( - startTimeCode = 0 - endTimeCode = 2 -) - -def Mesh "Split" -{ - int[] faceVertexCounts.timeSamples = { - 0: [3, 3], - 2: [3, 3, 3], - } - int[] faceVertexIndices.timeSamples = { - 0: [0, 1, 2, 0, 2, 3], - 2: [0, 1, 2, 0, 2, 3, 0, 3, 4], - } - point3f[] points.timeSamples = { - 0: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], - 2: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (-1, 1, 0)], - } - - def GeomSubset "A" - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [0] - } - - def GeomSubset "B" - { - uniform token elementType = "face" - uniform token familyName = "materialBind" - int[] indices = [1] - } -} -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - - WHEN("The Stage is imported") - { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - auto positionArrayOf = [&](size_t i) { - auto geometry = scene.getObject(i); - return geometry - ? geometry->parameterValueAsObject( - "vertex.position") - : nullptr; - }; - - const auto parts = scene.numberOfObjects(ANARI_GEOMETRY); - REQUIRE(parts > 1); // the subsets, plus any unclaimed remainder - - THEN("Every Part shares one position Array on import") - { - auto *first = positionArrayOf(0); - REQUIRE(first != nullptr); - for (size_t i = 1; i < parts; ++i) - REQUIRE(positionArrayOf(i) == first); - } - - THEN("They still share one after a resize, not a copy each") - { - animMgr.setAnimationTime(1.0f); - - auto *first = positionArrayOf(0); - REQUIRE(first != nullptr); - REQUIRE(first->size() == 5); - for (size_t i = 1; i < parts; ++i) - REQUIRE(positionArrayOf(i) == first); - } - } } } diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp new file mode 100644 index 000000000..e4b67286d --- /dev/null +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -0,0 +1,613 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Animated Stages: sample times, deforming geometry, dialect prims. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" +// tsd +#include "tsd/core/DataTree.hpp" +#include "tsd/io/animation/UsdGeometryFileBinding.hpp" +#include "tsd/io/archives/AnimationManagerArchive.hpp" + +SCENARIO("Animation is captured at the times actually authored", "[UsdImport]") +{ + GIVEN("A Stage with transforms keyed on a non-uniform time base") + { + StageFixture stage("tsd_test_usd_time_base.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 10 +) + +def Xform "Mover" +{ + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 1: (1, 0, 0), + 10: (10, 0, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Mesh "Quad" + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The binding's time base mirrors the authored sample spacing") + { + REQUIRE(animMgr.animations().size() == 1); + const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(bindings.size() == 1); + const auto &timeBase = bindings[0].timeBase(); + REQUIRE(timeBase.size() == 3); + REQUIRE(timeBase[0] == Approx(0.0f)); + REQUIRE(timeBase[1] == Approx(0.1f)); + REQUIRE(timeBase[2] == Approx(1.0f)); + } + } + } +} + +SCENARIO("A full turn authored with two keys does not collapse", "[UsdImport]") +{ + GIVEN("A prim rotating 360 degrees between two keyframes") + { + StageFixture stage("tsd_test_usd_full_turn.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 1 +) + +def Xform "Spinner" +{ + float3 xformOp:rotateXYZ.timeSamples = { + 0: (0, 0, 0), + 1: (0, 360, 0), + } + uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] + + def Mesh "Quad" + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Extra samples are inserted so the rotation still animates") + { + REQUIRE(animMgr.animations().size() == 1); + const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(bindings.size() == 1); + REQUIRE(bindings[0].sampleCount() > 2); + } + } + } + + GIVEN("A prim translating between two keyframes") + { + StageFixture stage("tsd_test_usd_small_move.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 1 +) + +def Xform "Slider" +{ + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 1: (5, 0, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("No extra samples are invented") + { + const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(bindings[0].sampleCount() == 2); + } + } + } +} + +SCENARIO( + "Lazily-bound deforming geometry survives save and reload", "[UsdImport]") +{ + GIVEN("A Stage whose mesh points are time-sampled") + { + StageFixture stage("tsd_test_usd_deforming.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Blob" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 1: [(0, 0, 0), (2, 0, 0), (0, 2, 0)], + 2: [(0, 0, 0), (3, 0, 0), (0, 3, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Only one frame is eager; the rest is bound to the Stage") + { + REQUIRE(animMgr.animations().size() == 1); + REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); + REQUIRE( + animMgr.animations()[0].fileBindings()[0]->kind() == "usdGeometry"); + + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->size() == 3); + } + + THEN("Scrubbing pulls a later frame from the Stage") + { + animMgr.setAnimationTime(1.0f); + + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->dataAs()[1].x == Approx(3.f)); + } + + THEN("The binding reconstructs from an Archive") + { + tsd::core::DataTree tree; + REQUIRE( + tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); + + tsd::animation::AnimationManager restored(&scene); + REQUIRE(tsd::io::deserialize_AnimationManagerArchive( + restored, tree.root())); + REQUIRE(restored.animations().size() == 1); + REQUIRE(restored.animations()[0].fileBindings().size() == 1); + REQUIRE(restored.animations()[0].fileBindings()[0]->kind() + == "usdGeometry"); + + restored.setAnimationTime(1.0f); + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions->dataAs()[1].x == Approx(3.f)); + } + } + } +} + +SCENARIO("Claimed dialect prims are handled once and only once", "[UsdImport]") +{ + GIVEN("A Stage whose carrier prims are claimed by the TSD dialect") + { + // The EnSight carrier marker is customData on the carrier's children; the + // claim-and-prune pre-pass must keep the generic path from converting + // them into meaningless geometry. + StageFixture stage("tsd_test_usd_dialect.usda", R"(#usda 1.0 + +def Scope "Dataset" +{ + def Mesh "part_one" ( + customData = { + dictionary ensight = { + string partName = "part_one" + } + } + ) + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} + +def Mesh "Real" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The carrier prim does not also arrive as generic geometry") + { + // Only the ordinary mesh converts: the claimed subtree is pruned from + // the resolved scene entirely. + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE_FALSE(findNode(scene.defaultLayer(), "part_one")); + } + } + } +} + +SCENARIO("Constant-valued time samples are not reported as a loss", + "[UsdImport]") +{ + GIVEN("A mesh whose visibility is authored at every frame but never changes") + { + // What a simulation exporter writes: every attribute re-authored at every + // frame regardless of whether it moved. + StageFixture stage("tsd_test_usd_constant_visibility.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Steady" +{ + token visibility.timeSamples = { + 0: "inherited", + 1: "inherited", + 2: "inherited", + } + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Nothing is reported as dropped") + { + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) + == 0); + } + } + } +} + +SCENARIO("One import is one Animation", "[UsdImport]") +{ + GIVEN("A Stage animating two prims that share a leaf name") + { + StageFixture stage("tsd_test_usd_one_animation.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Xform "A" +{ + def Xform "Mover" + { + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 2: (2, 0, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} + +def Xform "B" +{ + def Xform "Mover" + { + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 2: (0, 5, 0), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Both prims land in one Animation named for the file") + { + REQUIRE(animMgr.animations().size() == 1); + REQUIRE(animMgr.animations()[0].name() == stage.path()); + REQUIRE(animMgr.animations()[0].transformBindings().size() == 2); + } + + THEN("The Report counts them in place of the lost per-prim entries") + { + REQUIRE(report.animatedPrims == 2); + } + } + } +} + +SCENARIO("An old-format geometry binding still reconstructs", "[UsdImport]") +{ + GIVEN("An Archive node carrying the dropped sampleTimes and timeBase fields") + { + StageFixture stage("tsd_test_usd_legacy_binding.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Blob" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 2: [(0, 0, 0), (3, 0, 0), (0, 3, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + animMgr.removeAllAnimations(); + + // Written the way an Archive from before continuous Time Code evaluation + // was: the derived sample cache is present and must simply be ignored. + tsd::core::DataTree tree; + auto &node = tree.root(); + node["targetIndex"] = size_t(0); + node["stageFile"] = stage.path(); + node["primPath"] = std::string("/Blob"); + node["sampleTimes"].append() = 0.f; + node["sampleTimes"].append() = 2.f; + node["timeBase"].append() = 0.f; + node["timeBase"].append() = 1.f; + + WHEN("It is read back") + { + auto &anim = animMgr.addAnimation("legacy"); + REQUIRE(tsd::io::UsdGeometryFileBinding::addToAnimation( + anim, scene, node) + != nullptr); + + THEN("It scrubs from the Stage's own clock") + { + animMgr.setAnimationTime(1.0f); + + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->dataAs()[1].x == Approx(3.f)); + } + } + } +} + +SCENARIO("A mesh whose topology changes re-pulls a consistent set", + "[UsdImport]") +{ + GIVEN("A mesh whose points, indices and primvars all change together") + { + // The case a binding that only re-pulls points cannot serve: writing new + // positions without new indices would describe a mesh that never existed. + StageFixture stage("tsd_test_usd_morphing_mesh.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Morphing" +{ + int[] faceVertexCounts.timeSamples = { + 0: [3], + 2: [3, 3], + } + int[] faceVertexIndices.timeSamples = { + 0: [0, 1, 2], + 2: [0, 1, 2, 0, 2, 3], + } + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 2: [(0, 0, 0), (1, 0, 0), (0, 1, 0), (-1, 1, 0)], + } + color3f[] primvars:displayColor ( + interpolation = "vertex" + ) + color3f[] primvars:displayColor.timeSamples = { + 0: [(1, 0, 0), (0, 1, 0), (0, 0, 1)], + 2: [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + auto geometry = scene.getObject(0); + REQUIRE(geometry); + + auto arraySize = [&](const char *parameter) -> size_t { + auto *array = + geometry->parameterValueAsObject(parameter); + return array ? array->size() : 0; + }; + + THEN("The first frame is one triangle over three vertices") + { + REQUIRE(arraySize("vertex.position") == 3); + REQUIRE(arraySize("primitive.index") == 1); + REQUIRE(arraySize("vertex.color") == 3); + } + + THEN("Scrubbing re-pulls points, indices and primvars together") + { + animMgr.setAnimationTime(1.0f); + + REQUIRE(arraySize("vertex.position") == 4); + REQUIRE(arraySize("primitive.index") == 2); + REQUIRE(arraySize("vertex.color") == 4); + + // Every index has to address the positions that arrived with it. + auto *indices = + geometry->parameterValueAsObject( + "primitive.index"); + const auto *triangles = indices->dataAs(); + for (size_t i = 0; i < indices->size(); ++i) { + REQUIRE(triangles[i].x < 4); + REQUIRE(triangles[i].y < 4); + REQUIRE(triangles[i].z < 4); + } + } + + THEN("The Surface and its Geometry keep their identity across the scrub") + { + const auto surfacesBefore = scene.numberOfObjects(ANARI_SURFACE); + const auto geometriesBefore = scene.numberOfObjects(ANARI_GEOMETRY); + const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); + + animMgr.setAnimationTime(1.0f); + + // Re-running conversion would have built new ones, forcing the render + // index to tear down and recreate ANARI handles (ADR 0022). + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == surfacesBefore); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == geometriesBefore); + REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); + } + } + } +} + +SCENARIO("Parts keep sharing one position Array across a resize", "[UsdImport]") +{ + GIVEN("A mesh divided into subsets whose vertex count changes") + { + StageFixture stage("tsd_test_usd_shared_resize.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Mesh "Split" +{ + int[] faceVertexCounts.timeSamples = { + 0: [3, 3], + 2: [3, 3, 3], + } + int[] faceVertexIndices.timeSamples = { + 0: [0, 1, 2, 0, 2, 3], + 2: [0, 1, 2, 0, 2, 3, 0, 3, 4], + } + point3f[] points.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + 2: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (-1, 1, 0)], + } + + def GeomSubset "A" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0] + } + + def GeomSubset "B" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + auto positionArrayOf = [&](size_t i) { + auto geometry = scene.getObject(i); + return geometry + ? geometry->parameterValueAsObject( + "vertex.position") + : nullptr; + }; + + const auto parts = scene.numberOfObjects(ANARI_GEOMETRY); + REQUIRE(parts > 1); // the subsets, plus any unclaimed remainder + + THEN("Every Part shares one position Array on import") + { + auto *first = positionArrayOf(0); + REQUIRE(first != nullptr); + for (size_t i = 1; i < parts; ++i) + REQUIRE(positionArrayOf(i) == first); + } + + THEN("They still share one after a resize, not a copy each") + { + animMgr.setAnimationTime(1.0f); + + auto *first = positionArrayOf(0); + REQUIRE(first != nullptr); + REQUIRE(first->size() == 5); + for (size_t i = 1; i < parts; ++i) + REQUIRE(positionArrayOf(i) == first); + } + } + } +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_geometry.cpp b/tsd/tests/test_UsdImport_geometry.cpp new file mode 100644 index 000000000..e279ca1f8 --- /dev/null +++ b/tsd/tests/test_UsdImport_geometry.cpp @@ -0,0 +1,501 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Geometry conversion: meshes, quadrics, curves and subdivision. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" +// std +#include + +SCENARIO("A USD Stage's meshes arrive as surfaces", "[UsdImport]") +{ + GIVEN("A Stage with a single quad mesh") + { + StageFixture stage("tsd_test_usd_single_mesh.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Quad" + { +)") + QUAD_MESH_BODY + + R"( + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The mesh becomes one triangle-geometry surface") + { + REQUIRE(report.stageOpened); + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); + + auto geometry = scene.getObject(0); + REQUIRE(geometry); + REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::triangle); + + auto *index = geometry->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 2); // a quad tessellates to two triangles + + auto *position = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(position != nullptr); + REQUIRE(position->size() == 4); + } + + THEN("Nothing is silently lost") + { + REQUIRE(report.skipped.empty()); + REQUIRE(report.convertedPrims == 1); + } + } + } +} + +SCENARIO("Analytic quadrics stay analytic", "[UsdImport]") +{ + GIVEN("A Stage with a sphere and a cylinder") + { + StageFixture stage("tsd_test_usd_quadrics.usda", R"(#usda 1.0 + +def Sphere "Ball" +{ + double radius = 2 +} + +def Cylinder "Tube" +{ + double radius = 0.5 + double height = 4 + uniform token axis = "Y" +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("They map onto TSD's native quadric geometry, not meshes") + { + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 2); + + auto ball = scene.getObject(0); + REQUIRE(ball->subtype() == tsd::scene::tokens::geometry::sphere); + REQUIRE(ball->parameterValueAs("radius").value() == Approx(2.f)); + + auto tube = scene.getObject(1); + REQUIRE(tube->subtype() == tsd::scene::tokens::geometry::cylinder); + + // The spine axis is folded into the endpoints rather than a transform. + auto *positions = + tube->parameterValueAsObject("vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->size() == 2); + const auto *p = positions->dataAs(); + REQUIRE(p[0].y == Approx(-2.f)); + REQUIRE(p[1].y == Approx(2.f)); + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO( + "A non-convex polygon tessellates without spurious geometry", "[UsdImport]") +{ + GIVEN("A mesh with one concave five-sided face") + { + StageFixture stage("tsd_test_usd_nonconvex.usda", R"(#usda 1.0 + +def Mesh "Arrow" +{ + int[] faceVertexCounts = [5] + int[] faceVertexIndices = [0, 1, 2, 3, 4] + point3f[] points = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (1, 1, 0), (0, 2, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("It becomes exactly n-2 triangles") + { + auto geometry = scene.getObject(0); + REQUIRE(geometry); + auto *index = geometry->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 3); + } + } + } +} + +SCENARIO("Subdivision surfaces are refined by default", "[UsdImport]") +{ + GIVEN("A cube that explicitly declares a subdivision scheme") + { + StageFixture stage("tsd_test_usd_subdiv.usda", R"(#usda 1.0 + +def Mesh "SubdivCube" +{ + uniform token subdivisionScheme = "catmullClark" + int[] faceVertexCounts = [4, 4, 4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 7, 6, 5, 0, 4, 5, 1, + 1, 5, 6, 2, 2, 6, 7, 3, 3, 7, 4, 0] + point3f[] points = [(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), + (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)] +} + +def Mesh "PolygonCube" +{ + uniform token subdivisionScheme = "none" + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported at the default refinement level") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The subdivision mesh gains vertices and the polygon mesh does not") + { + auto subdiv = scene.getObject(0); + auto polygon = scene.getObject(1); + REQUIRE(subdiv); + REQUIRE(polygon); + + auto vertexCount = [](auto geometry) { + auto *p = + geometry->template parameterValueAsObject( + "vertex.position"); + return p ? p->size() : size_t(0); + }; + + // Two levels of Catmull-Clark on a cube: 8 -> 26 -> 98 vertices. + REQUIRE(vertexCount(subdiv) == 98); + REQUIRE(vertexCount(polygon) == 4); + } + } + + WHEN("Refinement is turned off") + { + tsd::io::UsdImportOptions options; + options.refinementLevel = 0; + tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The subdivision mesh arrives at its authored resolution") + { + auto subdiv = scene.getObject(0); + auto *p = subdiv->parameterValueAsObject( + "vertex.position"); + REQUIRE(p != nullptr); + REQUIRE(p->size() == 8); + } + } + } +} + +SCENARIO( + "Refinement carries face-varying primvars with the surface", "[UsdImport]") +{ + GIVEN("A subdivision mesh whose UVs are authored per face corner") + { + StageFixture stage("tsd_test_usd_subdiv_uvs.usda", R"(#usda 1.0 + +def Mesh "SubdivQuad" +{ + uniform token subdivisionScheme = "catmullClark" + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "faceVarying" + ) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The UVs survive refinement rather than being dropped") + { + auto geometry = scene.getObject(0); + REQUIRE(geometry); + auto *uvs = geometry->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(uvs != nullptr); + REQUIRE(uvs->size() > 4); + } + + THEN("Nothing is reported as lost") + { + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO("Width-less curves and points get a bounds-scaled radius", + "[UsdImport]") +{ + // Blender hair exports commonly omit widths; without an explicit radius the + // ANARI default of 1 world unit dwarfs most scenes. + GIVEN("A Stage with a widthless curve, a widthed curve, and widthless points") + { + StageFixture stage("tsd_test_usd_widthless_curves.usda", R"(#usda 1.0 + +def Xform "World" +{ + def BasisCurves "Hair" + { + uniform token type = "linear" + int[] curveVertexCounts = [4] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)] + } + + def BasisCurves "Rope" + { + uniform token type = "linear" + int[] curveVertexCounts = [2] + point3f[] points = [(0, 0, 0), (1, 0, 0)] + float[] widths = [0.2, 0.2] (interpolation = "vertex") + } + + def Points "Sprinkles" + { + point3f[] points = [(0, 0, 0), (2, 0, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The widthless curve's radius scales with its bounds") + { + auto geometry = findGeometry(scene, "/World/Hair"); + REQUIRE(geometry); + REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::curve); + REQUIRE(geometry->parameterValueAsObject( + "vertex.radius") + == nullptr); + + const auto radius = geometry->parameterValueAs("radius"); + REQUIRE(radius.has_value()); + REQUIRE(*radius == Approx(1e-3f * std::sqrt(3.f))); + } + + THEN("Authored widths still become per-vertex radii") + { + auto geometry = findGeometry(scene, "/World/Rope"); + REQUIRE(geometry); + + auto *radii = geometry->parameterValueAsObject( + "vertex.radius"); + REQUIRE(radii != nullptr); + REQUIRE(radii->size() == 2); + REQUIRE(radii->dataAs()[0] == Approx(0.1f)); + REQUIRE_FALSE(geometry->parameterValueAs("radius").has_value()); + } + + THEN("Widthless points scale the same way") + { + auto geometry = findGeometry(scene, "/World/Sprinkles"); + REQUIRE(geometry); + REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::sphere); + + const auto radius = geometry->parameterValueAs("radius"); + REQUIRE(radius.has_value()); + REQUIRE(*radius == Approx(2e-3f)); + } + } + } +} + +// Animated USD /////////////////////////////////////////////////////////////// + +namespace { + +// The transform Array of the first transform-array node beneath `name`. +tsd::scene::Array *findTransformArray( + tsd::scene::Layer *layer, const char *name) +{ + auto parent = findNode(layer, name); + if (!parent) + return nullptr; + tsd::scene::Array *found = nullptr; + layer->traverse(parent, [&](auto &node, int) { + if (!found && node->type() == ANARI_ARRAY1D) + found = node->getTransformArray(); + return true; + }); + return found; +} + +} // namespace + +SCENARIO("Conversion leaves nothing behind for geometry it does not emit", + "[UsdImport]") +{ + GIVEN("A mesh with no points that nonetheless binds a material") + { + StageFixture stage("tsd_test_usd_empty_mesh.usda", R"(#usda 1.0 + +def Material "Orphan" +{ + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (1, 0, 0) + token outputs:surface + } +} + +def Mesh "Empty" ( + prepend apiSchemas = ["MaterialBindingAPI"] +) +{ + rel material:binding = + int[] faceVertexCounts = [] + int[] faceVertexIndices = [] + point3f[] points = [] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + // A Scene creates one default Material of its own, so what matters is + // that the import adds none. + const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("No Surface, Geometry or Material is created for it") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 0); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 0); + REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); + } + } + } + + GIVEN("A mesh whose subset claims no faces but binds its own material") + { + StageFixture stage("tsd_test_usd_empty_subset.usda", R"(#usda 1.0 + +def Material "Used" +{ + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 1, 0) + token outputs:surface + } +} + +def Material "Unused" +{ + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 0, 1) + token outputs:surface + } +} + +def Mesh "Quad" +{ + int[] faceVertexCounts = [3, 3] + int[] faceVertexIndices = [0, 1, 2, 0, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + + def GeomSubset "Drawn" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0, 1] + rel material:binding = + } + + def GeomSubset "Empty" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [] + rel material:binding = + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Only the material the drawn subset uses is created") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + + bool sawUnused = false; + for (size_t i = 0; i < scene.numberOfObjects(ANARI_MATERIAL); ++i) { + auto material = scene.getObject(i); + if (material && material->name().find("Unused") != std::string::npos) + sawUnused = true; + } + REQUIRE_FALSE(sawUnused); + } + } + } +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_instancing.cpp b/tsd/tests/test_UsdImport_instancing.cpp new file mode 100644 index 000000000..3884a1190 --- /dev/null +++ b/tsd/tests/test_UsdImport_instancing.cpp @@ -0,0 +1,463 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Point instancers and native USD instances, static and animated. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" +// tsd +#include "tsd/core/DataTree.hpp" +#include "tsd/io/archives/AnimationManagerArchive.hpp" + +SCENARIO("A point instancer shares one set of Prototype objects", "[UsdImport]") +{ + GIVEN("A Stage scattering one Prototype three times, one of them hidden") + { + StageFixture stage("tsd_test_usd_point_instancer.usda", R"(#usda 1.0 + +def PointInstancer "Scatter" +{ + point3f[] positions = [(0, 0, 0), (2, 0, 0), (4, 0, 0)] + int64[] ids = [0, 1, 2] + int[] protoIndices = [0, 0, 0] + int64[] invisibleIds = [1] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The Prototype is imported once, not once per placement") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); + } + + THEN("The placements become a single transform-array node") + { + auto *layer = scene.defaultLayer(); + auto scatter = findNode(layer, "Scatter"); + REQUIRE(scatter); + + // The array node is the instancer's own child, holding the visible + // placements only. + tsd::scene::Array *transforms = nullptr; + layer->traverse(scatter, [&](auto &node, int) { + if (!transforms && node->type() == ANARI_ARRAY1D) + transforms = node->getTransformArray(); + return true; + }); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->size() == 2); // the invisible placement is omitted + } + + THEN("Nothing is silently lost") + { + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO("USD Instances share objects across placements", "[UsdImport]") +{ + GIVEN("A Stage referencing one Prototype from two instanceable prims") + { + StageFixture stage("tsd_test_usd_native_instance.usda", R"(#usda 1.0 + +def Xform "Protos" +{ + def Xform "Asset" + { + def Mesh "Quad" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } + } +} + +def Xform "InstanceA" ( + instanceable = true + prepend references = +) +{ + double3 xformOp:translate = (5, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] +} + +def Xform "InstanceB" ( + instanceable = true + prepend references = +) +{ + double3 xformOp:translate = (9, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The Prototype geometry exists once, plus the un-instanced source") + { + // /Protos/Asset/Quad imports as ordinary content; the two placements + // share a single converted Prototype rather than copying it. + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + } + + THEN("Each placement is a node referencing the shared objects") + { + auto *layer = scene.defaultLayer(); + auto a = findNode(layer, "InstanceA"); + auto b = findNode(layer, "InstanceB"); + REQUIRE(a); + REQUIRE(b); + + auto sharedObjectUnder = [&](tsd::scene::LayerNodeRef parent) { + size_t index = tsd::core::INVALID_INDEX; + layer->traverse(parent, [&](auto &node, int) { + if (index == tsd::core::INVALID_INDEX && node->isObject()) + index = node->getObjectIndex(); + return true; + }); + return index; + }; + + const auto indexA = sharedObjectUnder(a); + const auto indexB = sharedObjectUnder(b); + REQUIRE(indexA != tsd::core::INVALID_INDEX); + REQUIRE(indexA == indexB); + } + } + } +} + +namespace { + +// The transform Array of the first transform-array node beneath `name`. +tsd::scene::Array *findTransformArray( + tsd::scene::Layer *layer, const char *name) +{ + auto parent = findNode(layer, name); + if (!parent) + return nullptr; + tsd::scene::Array *found = nullptr; + layer->traverse(parent, [&](auto &node, int) { + if (!found && node->type() == ANARI_ARRAY1D) + found = node->getTransformArray(); + return true; + }); + return found; +} + +} // namespace + +SCENARIO("A point instancer's placements follow the Stage clock", "[UsdImport]") +{ + GIVEN("A PointInstancer whose positions and scales are time-sampled") + { + StageFixture stage("tsd_test_usd_animated_instancer.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def PointInstancer "Swarm" +{ + point3f[] positions.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0), (2, 0, 0)], + 2: [(0, 0, 0), (10, 0, 0), (20, 0, 0)], + } + float3[] scales.timeSamples = { + 0: [(1, 1, 1), (1, 1, 1), (1, 1, 1)], + 2: [(2, 2, 2), (2, 2, 2), (2, 2, 2)], + } + int[] protoIndices = [0, 0, 0] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("One Animation holds the instancer's binding") + { + REQUIRE(animMgr.animations().size() == 1); + REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); + REQUIRE(animMgr.animations()[0].fileBindings()[0]->kind() + == "usdInstancer"); + } + + THEN("The Stage's frame range and rate are reported, not applied") + { + REQUIRE(report.animatedPrims == 1); + REQUIRE(report.sampleCount == 2); + REQUIRE(report.timeCodesPerSecond == Approx(24.f)); + REQUIRE(animMgr.getAnimationTotalFrames() == 100); // untouched + } + + THEN("The imported placements are the Stage's first frame") + { + auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->size() == 3); + const auto *m = transforms->dataAs(); + REQUIRE(m[2][3].x == Approx(2.f)); + REQUIRE(m[2][0].x == Approx(1.f)); + } + + THEN("Scrubbing re-fills the same Array in place") + { + auto *before = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(before != nullptr); + + animMgr.setAnimationTime(1.0f); + + auto *after = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(after == before); // no reallocation on a constant count + const auto *m = after->dataAs(); + REQUIRE(m[2][3].x == Approx(20.f)); + REQUIRE(m[2][0].x == Approx(2.f)); + } + + THEN("A time between authored samples is interpolated, not snapped") + { + animMgr.setAnimationTime(0.5f); + + auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(transforms != nullptr); + const auto *m = transforms->dataAs(); + REQUIRE(m[2][3].x == Approx(11.f)); + } + } + } +} + +SCENARIO("An instancer whose placement count changes reallocates", + "[UsdImport]") +{ + GIVEN("A PointInstancer that gains a placement mid-sequence") + { + StageFixture stage("tsd_test_usd_growing_instancer.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def PointInstancer "Growing" +{ + point3f[] positions.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0)], + 2: [(0, 0, 0), (1, 0, 0), (2, 0, 0)], + } + int[] protoIndices.timeSamples = { + 0: [0, 0], + 2: [0, 0, 0], + } + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported and scrubbed past the change") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + auto *before = findTransformArray(scene.defaultLayer(), "Growing"); + REQUIRE(before != nullptr); + REQUIRE(before->size() == 2); + + animMgr.setAnimationTime(1.0f); + + THEN("The node is re-pointed at a right-sized Array") + { + auto *after = findTransformArray(scene.defaultLayer(), "Growing"); + REQUIRE(after != nullptr); + REQUIRE(after->size() == 3); + REQUIRE(after->dataAs()[2][3].x == Approx(2.f)); + } + } + } +} + +SCENARIO("Instancer bindings survive save and reload", "[UsdImport]") +{ + GIVEN("An imported Stage with an animated PointInstancer") + { + StageFixture stage("tsd_test_usd_instancer_archive.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def PointInstancer "Swarm" +{ + point3f[] positions.timeSamples = { + 0: [(0, 0, 0), (1, 0, 0)], + 2: [(0, 0, 0), (9, 0, 0)], + } + int[] protoIndices = [0, 0] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + WHEN("The animation manager round-trips through an Archive") + { + tsd::core::DataTree tree; + REQUIRE(tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); + + tsd::animation::AnimationManager restored(&scene); + REQUIRE( + tsd::io::deserialize_AnimationManagerArchive(restored, tree.root())); + + THEN("The reconstructed binding scrubs the same Array") + { + REQUIRE(restored.animations().size() == 1); + REQUIRE(restored.animations()[0].fileBindings().size() == 1); + REQUIRE(restored.animations()[0].fileBindings()[0]->kind() + == "usdInstancer"); + + restored.setAnimationTime(1.0f); + + auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->dataAs()[1][3].x == Approx(9.f)); + } + } + } +} + +SCENARIO("A Stage that authored no time-code range still animates", + "[UsdImport]") +{ + GIVEN("A PointInstancer with time samples but no startTimeCode") + { + // Nothing forces a Stage to declare its own range, and USD reports 0 for + // both ends when it does not. Without a fallback every animation time + // would map onto one Time Code and the placements would never move. + StageFixture stage("tsd_test_usd_unranged_instancer.usda", R"(#usda 1.0 + +def PointInstancer "Drifting" +{ + point3f[] positions.timeSamples = { + 5: [(0, 0, 0), (1, 0, 0)], + 9: [(0, 0, 0), (7, 0, 0)], + } + int[] protoIndices = [0, 0] + rel prototypes = [] + + def Mesh "Proto" + { + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported and scrubbed to the end") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + animMgr.setAnimationTime(1.0f); + + THEN("The authored samples define the range that time maps onto") + { + auto *transforms = findTransformArray(scene.defaultLayer(), "Drifting"); + REQUIRE(transforms != nullptr); + REQUIRE(transforms->dataAs()[1][3].x == Approx(7.f)); + } + } + } + + GIVEN("A deforming mesh with time samples but no startTimeCode") + { + StageFixture stage("tsd_test_usd_unranged_mesh.usda", R"(#usda 1.0 + +def Mesh "Blob" +{ + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + point3f[] points.timeSamples = { + 5: [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + 9: [(0, 0, 0), (4, 0, 0), (0, 4, 0)], + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported and scrubbed to the end") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + animMgr.setAnimationTime(1.0f); + + THEN("The authored samples define the range that time maps onto") + { + auto geometry = scene.getObject(0); + auto *positions = geometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(positions != nullptr); + REQUIRE(positions->dataAs()[1].x == Approx(4.f)); + } + } + } +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_lights.cpp b/tsd/tests/test_UsdImport_lights.cpp new file mode 100644 index 000000000..71601e4fc --- /dev/null +++ b/tsd/tests/test_UsdImport_lights.cpp @@ -0,0 +1,148 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Lights and cameras. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" + +SCENARIO( + "Light exposure and normalization reach the emitted light", "[UsdImport]") +{ + GIVEN("A sphere light with exposure and normalization set") + { + // intensity 4, exposure 2 -> 4 * 2^2 = 16; normalize divides by the + // sphere's area, 4*pi*r^2 with r = 2 -> 16 / (16*pi). + StageFixture stage("tsd_test_usd_light_radiometry.usda", R"(#usda 1.0 + +def SphereLight "Lamp" +{ + float inputs:intensity = 4 + float inputs:exposure = 2 + bool inputs:normalize = true + float inputs:radius = 2 + color3f inputs:color = (1, 1, 1) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The light's intensity accounts for both") + { + REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::point); + + const auto intensity = light->parameterValueAs("intensity"); + REQUIRE(intensity.has_value()); + const float expected = 16.f / (4.f * float(M_PI) * 4.f); + REQUIRE(*intensity == Approx(expected)); + REQUIRE(report.skipped.empty()); + } + } + } +} + +SCENARIO("A shaped sphere light becomes a spot light", "[UsdImport]") +{ + GIVEN("A sphere light carrying shaping attributes") + { + StageFixture stage("tsd_test_usd_spot.usda", R"(#usda 1.0 + +def SphereLight "Spot" ( + prepend apiSchemas = ["ShapingAPI"] +) +{ + float inputs:intensity = 1 + float inputs:radius = 0.5 + float inputs:shaping:cone:angle = 30 + float inputs:shaping:cone:softness = 0.5 +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Spot lighting survives the import") + { + auto light = scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::spot); + + const auto opening = light->parameterValueAs("openingAngle"); + REQUIRE(opening.has_value()); + REQUIRE(*opening == Approx(2.f * 30.f * float(M_PI) / 180.f)); + + const auto falloff = light->parameterValueAs("falloffAngle"); + REQUIRE(falloff.has_value()); + REQUIRE(*falloff == Approx(0.5f * 0.5f * *opening)); + } + } + } +} + +SCENARIO("Cameras from a Stage arrive in the camera pool", "[UsdImport]") +{ + GIVEN("A Stage with an animated camera rig") + { + StageFixture stage("tsd_test_usd_camera.usda", R"(#usda 1.0 +( + startTimeCode = 0 + endTimeCode = 2 +) + +def Xform "Rig" +{ + double3 xformOp:translate.timeSamples = { + 0: (0, 0, 0), + 2: (0, 0, 10), + } + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Camera "Shot" + { + float focalLength = 50 + float horizontalAperture = 36 + float verticalAperture = 24 + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + const auto camerasBefore = scene.numberOfObjects(ANARI_CAMERA); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The authored viewpoint is available and animated") + { + REQUIRE(scene.numberOfObjects(ANARI_CAMERA) == camerasBefore + 1); + + bool hasCameraAnimation = false; + for (const auto &animation : animMgr.animations()) { + if (!animation.objectParameterBindings().empty()) + hasCameraAnimation = true; + } + REQUIRE(hasCameraAnimation); + } + } + } +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_materials.cpp b/tsd/tests/test_UsdImport_materials.cpp new file mode 100644 index 000000000..eec976e60 --- /dev/null +++ b/tsd/tests/test_UsdImport_materials.cpp @@ -0,0 +1,453 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Native material passthrough: which Render Context wins, and when. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" + +SCENARIO("Native material passthrough is opt-in", "[UsdImport]") +{ + auto stringParameter = [](tsd::scene::Material *material, const char *name) { + auto *p = material->parameter(name); + return p ? p->value().getString() : std::string(); + }; + + GIVEN("A Stage whose material is an ordinary preview surface") + { + StageFixture stage("tsd_test_usd_preview_material.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:surface.connect = + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.8, 0.2, 0.1) + float inputs:roughness = 0.4 + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("A portable physically-based material is emitted") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE(report.skipped.empty()); + } + } + + WHEN("A native passthrough is asked for that this material cannot give") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MDL; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The fallback to a portable mapping is reported, not silent") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + == 1); + } + } + +#if TSD_USD_HAS_MATERIALX + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("A preview surface falls back rather than emitting a bad document") + { + // MaterialX has no node definition for UsdPreviewSurface, so there is + // nothing to pass through; the portable mapping is used and said so. + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + == 1); + } + } +#endif + } + +#if TSD_USD_HAS_MATERIALX + GIVEN("A Stage with an authored MaterialX network") + { + StageFixture stage("tsd_test_usd_materialx.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + color3f inputs:base_color = (0.8, 0.2, 0.1) + float inputs:specular_roughness = 0.4 + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The network passes through as an inline MaterialX document") + { + auto *material = boundMaterial(scene); + REQUIRE(material->subtype() == tsd::scene::tokens::material::materialx); + REQUIRE(stringParameter(material, "sourceType") == "documentInline"); + + const auto source = stringParameter(material, "source"); + REQUIRE(source.find("subtype() + != tsd::scene::tokens::material::materialx); + } + } + + // The importer's MaterialX mode is only reachable from an application + // through an Importer Type, so the dispatch is worth pinning separately + // from the option it sets. + WHEN("The file is imported through the USD_MTLX Importer Type") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_file(scene, + animMgr, + {tsd::io::ImporterType::USD_MTLX, stage.path()}); + + THEN("MaterialX materials arrive without asking for options") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::materialx); + } + } + + WHEN("The file is imported through the plain USD Importer Type") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_file(scene, + animMgr, + {tsd::io::ImporterType::USD, stage.path()}); + + THEN("The portable mapping is what arrives") + { + REQUIRE(boundMaterial(scene)->subtype() + != tsd::scene::tokens::material::materialx); + } + } + } + + // An inline document has no file of its own for a relative path to be + // relative to, so a texture that stays relative is a texture the device + // cannot open. + GIVEN("A MaterialX network reading textures by relative path") + { + TextureFixture present("tsd_test_usd_mtlx_present.tga"); + + StageFixture stage("tsd_test_usd_materialx_textures.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Present" + { + uniform token info:id = "ND_image_color3" + asset inputs:file = @tsd_test_usd_mtlx_present.tga@ + color3f outputs:out + } + + def Shader "Tiled" + { + uniform token info:id = "ND_image_color3" + asset inputs:file = @tiles/tsd_test_tile..png@ + color3f outputs:out + } + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + color3f inputs:base_color.connect = + color3f inputs:coat_color.connect = + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + const auto source = + stringParameter(boundMaterial(scene), "source"); + + THEN("Texture paths leave as absolute paths") + { + REQUIRE(source.find(present.path()) != std::string::npos); + REQUIRE(source.find("\"tsd_test_usd_mtlx_present.tga\"") + == std::string::npos); + } + + THEN("A tile set is anchored without losing its token") + { + const auto tiled = (fixtureDirectory() / "tiles").string(); + REQUIRE(source.find(tiled) != std::string::npos); + REQUIRE(source.find("") != std::string::npos); + } + + THEN("The texture that exists is not reported as missing") + { + for (const auto &skip : report.skipped) { + const bool missedThisOne = + skip.reason == tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED + && skip.detail == present.path(); + REQUIRE_FALSE(missedThisOne); + } + } + + // The device reads texels from samplers bound to the document's + // `filename` inputs by their document path, not by opening the files + // itself, so a material without them renders untextured however correct + // its paths are. + THEN("A sampler is bound to the input by its document path") + { + REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); + + // The name is the contract: the device publishes each textured input + // under its MaterialX element path. + auto *material = boundMaterial(scene); + std::string boundName; + for (size_t i = 0; i < material->numParameters(); i++) { + if (material->parameterAt(i).value().type() == ANARI_SAMPLER) + boundName = material->parameterNameAt(i); + } + REQUIRE_FALSE(boundName.empty()); + // The document path, node graph included -- the same string the device's + // shader generator reports as the port's path. + REQUIRE(boundName == "_/Present/file"); + } + + THEN("A tile set binds nothing, and says so") + { + REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + // Only the one loadable texture became a sampler. + REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); + } + } + } + + GIVEN("A MaterialX network naming a texture that is not there") + { + StageFixture stage("tsd_test_usd_materialx_missing.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Missing" + { + uniform token info:id = "ND_image_color3" + asset inputs:file = @tsd_test_usd_absent.png@ + color3f outputs:out + } + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + color3f inputs:base_color.connect = + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The Import Report names it rather than leaving it to the device") + { + REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + } + } + } + + // MaterialX matches a node to its definition on the exact set of inputs, so + // a connection between mismatched types leaves the surface node resolving to + // nothing. Emitting it anyway puts the failure inside the device, where it + // reads as `Could not find a nodedef for node 'Surface'` and the prim + // silently renders with the default material. + GIVEN("A MaterialX network connecting a color3 output to a float input") + { + StageFixture stage("tsd_test_usd_materialx_mistyped.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Surface" + { + token outputs:mtlx:surface.connect = + + def Shader "Tint" + { + uniform token info:id = "ND_constant_color3" + color3f inputs:value = (0.25, 0.5, 0.75) + color3f outputs:out + } + + def Shader "Standard" + { + uniform token info:id = "ND_standard_surface_surfaceshader" + float inputs:specular_roughness.connect = + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("MaterialX emission is asked for") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The Import Report names it rather than leaving it to the device") + { + REQUIRE( + report.contains(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED)); + } + + THEN("The portable mapping is what arrives, not a document") + { + REQUIRE(boundMaterial(scene)->subtype() + != tsd::scene::tokens::material::materialx); + } + } + } +#endif +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_materials_portable.cpp b/tsd/tests/test_UsdImport_materials_portable.cpp new file mode 100644 index 000000000..efa5867a4 --- /dev/null +++ b/tsd/tests/test_UsdImport_materials_portable.cpp @@ -0,0 +1,478 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Materials that land on TSD's portable material. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" + +SCENARIO("An unconventionally named UV primvar is still found", "[UsdImport]") +{ + GIVEN("A material whose reader node asks for a primvar not called 'st'") + { + StageFixture stage("tsd_test_usd_uv_primvar.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Textured" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @missing_texture.png@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "map1" + float2 outputs:result + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:map1 = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "vertex" + ) + rel material:binding = + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The named primvar lands on the geometry's first attribute") + { + auto geometry = scene.getObject(0); + REQUIRE(geometry); + auto *uvs = geometry->parameterValueAsObject( + "vertex.attribute0"); + REQUIRE(uvs != nullptr); + REQUIRE(uvs->size() == 4); + } + } + } +} + +// A material resolves once and is cached, including when it does not resolve +// at all. Without a negative entry every prim bound to the same broken +// material re-runs the resolve and files its own Import Report entry, which +// makes the counts the report prints scale with the binding count. +SCENARIO( + "An unresolvable material is reported once per material rather than" + " once per binding", + "[UsdImport]") +{ + GIVEN("A Stage where three meshes bind one material with no network") + { + StageFixture stage("tsd_test_usd_unresolvable_material.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Broken" + { + } + + def Mesh "QuadA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } + + def Mesh "QuadB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + rel material:binding = + } + + def Mesh "QuadC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(4, 0, 0), (5, 0, 0), (5, 1, 0), (4, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The Stage is imported") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = + tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}); + + THEN("The Import Report names the material exactly once") + { + REQUIRE( + report.countOf(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED) + == 1); + } + + THEN("Every mesh still arrives") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 3); + } + } + } +} + +SCENARIO("An OmniPBR material maps onto the portable material", "[UsdImport]") +{ + GIVEN("A Stage whose material is an OmniPBR MDL shader") + { + StageFixture stage("tsd_test_usd_omnipbr.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "OmniPBR" + { + token outputs:mdl:surface.connect = + def Shader "Shader" + { + uniform token info:implementationSource = "sourceAsset" + uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@ + uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR" + color3f inputs:diffuse_color_constant = (0.9, 0.1, 0.2) + float inputs:metallic_constant = 0.75 + float inputs:reflection_roughness_constant = 0.25 + float inputs:ior_constant = 1.4 + bool inputs:enable_emission = 1 + color3f inputs:emissive_color = (0, 0.5, 0) + float inputs:emissive_intensity = 2 + token outputs:out + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + auto *material = boundMaterial(scene); + + THEN("Its authored inputs arrive, not the preview-surface defaults") + { + REQUIRE(material->subtype() + == tsd::scene::tokens::material::physicallyBased); + + auto color = material->parameterValueAs("baseColor"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(0.9f)); + REQUIRE(color->y == Approx(0.1f)); + REQUIRE(color->z == Approx(0.2f)); + + REQUIRE( + *material->parameterValueAs("metallic") == Approx(0.75f)); + REQUIRE( + *material->parameterValueAs("roughness") == Approx(0.25f)); + REQUIRE(*material->parameterValueAs("ior") == Approx(1.4f)); + + auto emissive = + material->parameterValueAs("emissive"); + REQUIRE(emissive.has_value()); + REQUIRE(emissive->y == Approx(1.0f)); + } + + THEN("Nothing claims a richer material was left on the table") + { + REQUIRE(!report.contains( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE)); + } + } + + WHEN("MDL passthrough is asked for instead") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MDL; + auto report = tsd::io::import_USD( + scene, animMgr, stage.path().c_str(), {}, options); + + THEN("The native shader still wins over the portable mapping") + { + REQUIRE(boundMaterial(scene)->subtype() + == tsd::scene::tokens::material::mdl); + } + } + } + + GIVEN("An OmniPBR material reading textures and cutting out on opacity") + { + TextureFixture diffuse("tsd_test_usd_omnipbr_diffuse.tga"); + + StageFixture stage("tsd_test_usd_omnipbr_textured.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "OmniPBR" + { + token outputs:mdl:surface.connect = + def Shader "Shader" + { + uniform token info:implementationSource = "sourceAsset" + uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@ + uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR" + asset inputs:diffuse_texture = @tsd_test_usd_omnipbr_diffuse.tga@ + bool inputs:enable_opacity = 1 + float inputs:opacity_threshold = 0.3 + token outputs:out + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + auto *material = boundMaterial(scene); + + THEN("The texture named on the shader input is bound") + { + REQUIRE( + material->parameterValueAsObject("baseColor") + != nullptr); + REQUIRE(!report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + } + + THEN("An authored threshold becomes a cutout rather than a blend") + { + REQUIRE(material->parameterValueAs("alphaMode") + == std::string("mask")); + REQUIRE( + *material->parameterValueAs("alphaCutoff") == Approx(0.3f)); + + // The mode is a string selection, so the index has to agree with the + // value wherever the selection is what gets read. + auto *alphaMode = material->parameter("alphaMode"); + REQUIRE(alphaMode->stringValues()[alphaMode->stringSelection()] + == std::string("mask")); + } + } + } + + GIVEN("A material whose MDL module only looks like OmniPBR") + { + StageFixture stage("tsd_test_usd_omnipbr_lookalike.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Material "Lookalike" + { + token outputs:mdl:surface.connect = + token outputs:surface.connect = + + def Shader "Mdl" + { + uniform token info:implementationSource = "sourceAsset" + uniform asset info:mdl:sourceAsset = @OmniPBRBase.mdl@ + uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBRBase" + color3f inputs:diffuse_color_constant = (0.9, 0.1, 0.2) + token outputs:out + } + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.1, 0.2, 0.9) + token outputs:surface + } + } + + def Mesh "Quad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + rel material:binding = + } +} +)"); + + WHEN("The default material mode is used") + { + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("A module that merely starts with the name is not mapped as one") + { + // The authored preview surface is what this material actually says; + // OmniPBRBase is a different shader with input semantics of its own. + auto color = boundMaterial(scene)->parameterValueAs( + "baseColor"); + REQUIRE(color.has_value()); + REQUIRE(color->z == Approx(0.9f)); + } + } + } +} + +SCENARIO( + "A prim with no bound material takes its display colour", "[UsdImport]") +{ + GIVEN("A mesh with display colour and opacity but no material") + { + StageFixture stage("tsd_test_usd_display_color.usda", R"(#usda 1.0 + +def Mesh "Quad" +{ + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + color3f[] primvars:displayColor = [(0.25, 0.5, 0.75)] ( + interpolation = "constant" + ) + float[] primvars:displayOpacity = [0.5] ( + interpolation = "constant" + ) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The Surface's material carries the display values") + { + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + + const auto color = + material->parameterValueAs("color"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(0.25f)); + REQUIRE(color->y == Approx(0.5f)); + REQUIRE(color->z == Approx(0.75f)); + + const auto opacity = material->parameterValueAs("opacity"); + REQUIRE(opacity.has_value()); + REQUIRE(*opacity == Approx(0.5f)); + } + } + } +} + +SCENARIO("Analytic prims without a material take their display colour", + "[UsdImport]") +{ + GIVEN("A point cloud carrying only display colour") + { + StageFixture stage("tsd_test_usd_points_display_color.usda", R"(#usda 1.0 + +def Points "Cloud" +{ + point3f[] points = [(0, 0, 0), (1, 0, 0)] + float[] widths = [0.2, 0.4] + color3f[] primvars:displayColor = [(1, 0, 0)] ( + interpolation = "constant" + ) +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Its material carries the display colour, not TSD's default") + { + auto surface = scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + REQUIRE(material != scene.defaultMaterial().data()); + + const auto color = + material->parameterValueAs("color"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(1.f)); + REQUIRE(color->y == Approx(0.f)); + } + + THEN("Authored widths become per-point radii") + { + auto geometry = scene.getObject(0); + auto *radii = geometry->parameterValueAsObject( + "vertex.radius"); + REQUIRE(radii != nullptr); + REQUIRE(radii->size() == 2); + REQUIRE(radii->dataAs()[1] == Approx(0.2f)); + } + } + } +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_subsets.cpp b/tsd/tests/test_UsdImport_subsets.cpp new file mode 100644 index 000000000..0501e677a --- /dev/null +++ b/tsd/tests/test_UsdImport_subsets.cpp @@ -0,0 +1,420 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Material subsets and the per-subset attributes that follow them. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" +// std +#include + +SCENARIO("Per-face material subsets become several Surfaces", "[UsdImport]") +{ + GIVEN("A two-face mesh with one face bound to its own material") + { + StageFixture stage("tsd_test_usd_subsets.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Scope "Looks" + { + def Material "Red" + { + token outputs:surface.connect = + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (1, 0, 0) + token outputs:surface + } + } + + def Material "Blue" + { + token outputs:surface.connect = + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 0, 1) + token outputs:surface + } + } + } + + def Mesh "Strip" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + int[] faceVertexCounts = [4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + rel material:binding = + + def GeomSubset "Second" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + rel material:binding = + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("There is one Surface per subset") + { + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) >= 1); + } + + THEN("The faces no subset claims are still drawn, by the mesh's material") + { + // The first face belongs to no subset, so it stays with the mesh's own + // binding instead of vanishing with the un-surfaced parent geometry. + auto leftover = findGeometry(scene, "/World/Strip"); + REQUIRE(leftover); + auto *index = leftover->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 2); + REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + } + + THEN("The subset Surfaces share the mesh's vertex positions") + { + // Every geometry produced for this mesh points at the same + // vertex.position Array; only the index arrays differ. + std::vector positions; + for (size_t i = 0; i < scene.numberOfObjects(ANARI_GEOMETRY); ++i) { + auto geometry = scene.getObject(i); + if (!geometry) + continue; + if (auto *p = geometry->parameterValueAsObject( + "vertex.position")) + positions.push_back(p); + } + REQUIRE(positions.size() >= 2); + for (size_t i = 1; i < positions.size(); ++i) + REQUIRE(positions[i] == positions[0]); + } + } + } +} + +SCENARIO("Face-varying UVs follow each material subset", "[UsdImport]") +{ + GIVEN("A two-face mesh with per-corner UVs and a subset over each face") + { + StageFixture stage("tsd_test_usd_subset_facevarying.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Quad" + { + int[] faceVertexCounts = [4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (0.25, 0), (0.25, 0.25), (0, 0.25), + (0.5, 0), (0.75, 0), (0.75, 0.75), + (0.5, 0.75)] ( + interpolation = "faceVarying" + ) + normal3f[] normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1), + (0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1)] ( + interpolation = "vertex" + ) + + def GeomSubset "Left" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0] + } + + def GeomSubset "Right" + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Each subset carries the corners of the faces it selected") + { + // Face-varying data is indexed by triangle corner, so a subset cannot + // share the parent array the way vertex data can -- it must gather the + // corners of its own triangles. Each quad triangulates to two + // triangles, hence six corners, and the two faces' UVs are authored + // into disjoint halves of the unit square so a mis-gather shows up. + auto left = findGeometry(scene, "/World/Quad/Left"); + auto right = findGeometry(scene, "/World/Quad/Right"); + REQUIRE(left); + REQUIRE(right); + + auto *leftUVs = left->parameterValueAsObject( + "faceVarying.attribute0"); + auto *rightUVs = right->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(leftUVs != nullptr); + REQUIRE(rightUVs != nullptr); + REQUIRE(leftUVs->size() == 6); + REQUIRE(rightUVs->size() == 6); + + const auto *l = leftUVs->dataAs(); + const auto *r = rightUVs->dataAs(); + for (size_t i = 0; i < 6; ++i) { + REQUIRE(l[i].x < 0.5f); + REQUIRE(r[i].x >= 0.5f); + } + } + + THEN("Vertex-interpolated attributes are still shared, not copied") + { + // Only per-corner and per-triangle data has to be gathered; vertex + // data is indexed by the indices each subset already carries, so one + // Array serves every Surface. + auto left = findGeometry(scene, "/World/Quad/Left"); + auto right = findGeometry(scene, "/World/Quad/Right"); + REQUIRE(left); + REQUIRE(right); + + auto *leftNormals = + left->parameterValueAsObject("vertex.normal"); + REQUIRE(leftNormals != nullptr); + REQUIRE(leftNormals + == right->parameterValueAsObject( + "vertex.normal")); + } + } + } +} + +SCENARIO( + "Face-varying primvars survive an already-triangulated mesh", "[UsdImport]") +{ + GIVEN("An all-triangle mesh with indexed face-varying UVs and normals") + { + // Hydra's triangulator reports this topology as Unchanged rather than + // producing a copy of the input, a distinct result from Success that the + // conversion must not mistake for failure -- pre-triangulated exports + // carry every face-varying primvar down this path. + StageFixture stage("tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Triangles" + { + int[] faceVertexCounts = [3, 3] + int[] faceVertexIndices = [0, 1, 2, 0, 2, 3] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] ( + interpolation = "faceVarying" + ) + int[] primvars:st:indices = [0, 1, 2, 0, 2, 3] + normal3f[] primvars:normals = [(0, 0, 1), (0, 0, 1), (0, 0, 1), + (0, 0, 1), (0, 0, 1), (0, 0, 1)] ( + interpolation = "faceVarying" + ) + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("The UVs arrive flattened, one value per triangle corner") + { + auto geometry = findGeometry(scene, "/World/Triangles"); + REQUIRE(geometry); + + auto *uvs = geometry->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(uvs != nullptr); + REQUIRE(uvs->size() == 6); + + const auto *uv = uvs->dataAs(); + REQUIRE(uv[3].x == Approx(0.0f)); // second triangle's first corner + REQUIRE(uv[4].x == Approx(1.0f)); + REQUIRE(uv[5].x == Approx(0.0f)); + // `v` arrives reversed out of USD's v-up `st` into ANARI's, which runs + // down the image: the authored (0, 0) and (0, 1) become 1 and 0. + REQUIRE(uv[3].y == Approx(1.0f)); + REQUIRE(uv[5].y == Approx(0.0f)); + } + + THEN("The normals arrive too") + { + auto geometry = findGeometry(scene, "/World/Triangles"); + REQUIRE(geometry); + + auto *normals = geometry->parameterValueAsObject( + "faceVarying.normal"); + REQUIRE(normals != nullptr); + REQUIRE(normals->size() == 6); + } + } + } +} + +SCENARIO("A subset binds the UV primvar its own material reads", "[UsdImport]") +{ + GIVEN("Two subsets whose materials read differently named UV primvars") + { + StageFixture stage("tsd_test_usd_subset_uv_primvar.usda", R"(#usda 1.0 + +def Xform "World" +{ + def Scope "Looks" + { + def Material "ReadsMapOne" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @missing_texture.png@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "map1" + float2 outputs:result + } + } + + def Material "ReadsMapTwo" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor.connect = + token outputs:surface + } + + def Shader "Tex" + { + uniform token info:id = "UsdUVTexture" + asset inputs:file = @missing_texture.png@ + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "Reader" + { + uniform token info:id = "UsdPrimvarReader_float2" + token inputs:varname = "map2" + float2 outputs:result + } + } + } + + def Mesh "Quad" + { + int[] faceVertexCounts = [4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7] + point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (2, 0, 0), (3, 0, 0), (3, 1, 0), (2, 1, 0)] + texCoord2f[] primvars:map1 = [(0.25, 0), (0.25, 0), (0.25, 0), + (0.25, 0), (0.25, 0), (0.25, 0), + (0.25, 0), (0.25, 0)] ( + interpolation = "faceVarying" + ) + texCoord2f[] primvars:map2 = [(0.75, 0), (0.75, 0), (0.75, 0), + (0.75, 0), (0.75, 0), (0.75, 0), + (0.75, 0), (0.75, 0)] ( + interpolation = "faceVarying" + ) + + def GeomSubset "Left" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [0] + rel material:binding = + } + + def GeomSubset "Right" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token elementType = "face" + uniform token familyName = "materialBind" + int[] indices = [1] + rel material:binding = + } + } +} +)"); + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("The Stage is imported") + { + tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + + THEN("Each subset's own primvar lands on its first attribute") + { + // The mesh itself binds no material, so the UV name cannot be decided + // once for the whole mesh: each subset's material names its own. + auto left = findGeometry(scene, "/World/Quad/Left"); + auto right = findGeometry(scene, "/World/Quad/Right"); + REQUIRE(left); + REQUIRE(right); + + auto *leftUVs = left->parameterValueAsObject( + "faceVarying.attribute0"); + auto *rightUVs = right->parameterValueAsObject( + "faceVarying.attribute0"); + REQUIRE(leftUVs != nullptr); + REQUIRE(rightUVs != nullptr); + REQUIRE(leftUVs->dataAs()[0].x == Approx(0.25f)); + REQUIRE(rightUVs->dataAs()[0].x == Approx(0.75f)); + } + } + } +} + +#endif // TSD_USE_USD From f13521d48c6553b3eff6658a1786f5558e78a0ff Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:00:55 -0500 Subject: [PATCH 42/78] test(io): collapse the USD import per-scenario preamble Every scenario opened with the same four lines: a StageFixture, a Scene, an AnimationManager and the import_USD call. Fold them into an ImportedStage fixture that owns all four and imports in its constructor, with an overload taking UsdImportOptions for the scenarios that need one, so a scenario opens with its Stage and goes straight to its assertions. The scenarios that import the same Stage several ways keep the Stage text in a local string and build one ImportedStage per WHEN, which is what those sections already meant -- Catch re-runs the enclosing GIVEN for each leaf. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/tests/test_UsdImport.cpp | 120 +++++--------- tsd/tests/test_UsdImport_animation.cpp | 156 +++++++----------- tsd/tests/test_UsdImport_geometry.cpp | 113 +++++-------- tsd/tests/test_UsdImport_instancing.cpp | 109 +++++------- tsd/tests/test_UsdImport_lights.cpp | 37 ++--- tsd/tests/test_UsdImport_materials.cpp | 114 ++++++------- .../test_UsdImport_materials_portable.cpp | 84 +++------- tsd/tests/test_UsdImport_subsets.cpp | 55 ++---- 8 files changed, 287 insertions(+), 501 deletions(-) diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp index 82b099eac..81e0397cb 100644 --- a/tsd/tests/test_UsdImport.cpp +++ b/tsd/tests/test_UsdImport.cpp @@ -82,7 +82,7 @@ SCENARIO("A USD Stage's prim hierarchy is mirrored in the Layer", "[UsdImport]") { GIVEN("A Stage nesting a mesh two Xforms deep") { - StageFixture stage("tsd_test_usd_hierarchy.usda", + ImportedStage stage("tsd_test_usd_hierarchy.usda", std::string(R"(#usda 1.0 def Xform "World" @@ -104,13 +104,9 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); THEN("Each prim's name is findable at its own level") { @@ -136,7 +132,7 @@ def Xform "World" THEN("Nothing is silently lost") { - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } } @@ -147,8 +143,7 @@ SCENARIO( { GIVEN("A Stage with one mesh per Purpose") { - StageFixture stage("tsd_test_usd_purpose.usda", - std::string(R"(#usda 1.0 + const std::string purposeStage = std::string(R"(#usda 1.0 def Xform "World" { @@ -180,28 +175,26 @@ def Xform "World" + R"( } } -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); +)"; WHEN("The Stage is imported with default options") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + ImportedStage stage("tsd_test_usd_purpose.usda", purposeStage); THEN("Only default and render Purpose content arrives") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 2); } THEN("Each excluded prim is reported") { - REQUIRE(report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) == 2); + REQUIRE(stage.report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) + == 2); } THEN("Excluded prims leave a disabled Placeholder Node") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto helper = findNode(layer, "Helper"); REQUIRE(helper); REQUIRE((*helper)->isEmpty()); @@ -213,13 +206,13 @@ def Xform "World" { tsd::io::UsdImportOptions options; options.purposes.proxy = true; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage("tsd_test_usd_purpose.usda", purposeStage, options); THEN("Proxy content arrives too") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 3); - REQUIRE(report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 3); + REQUIRE(stage.report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) + == 1); } } } @@ -229,7 +222,7 @@ SCENARIO("Prims resolving to invisible import as disabled nodes", "[UsdImport]") { GIVEN("A Stage with an invisible mesh") { - StageFixture stage("tsd_test_usd_invisible.usda", + ImportedStage stage("tsd_test_usd_invisible.usda", std::string(R"(#usda 1.0 def Xform "World" @@ -243,26 +236,22 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The content still arrives so it can be toggled on") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); } THEN("Its node is disabled and the reason is reported") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto hidden = findNode(layer, "Hidden"); REQUIRE(hidden); REQUIRE_FALSE((*hidden)->isEnabled()); - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::RESOLVED_INVISIBLE) == 1); + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::RESOLVED_INVISIBLE) + == 1); } } } @@ -273,7 +262,7 @@ SCENARIO( { GIVEN("A Stage containing a prim type with no TSD equivalent") { - StageFixture stage("tsd_test_usd_unsupported.usda", + ImportedStage stage("tsd_test_usd_unsupported.usda", std::string(R"(#usda 1.0 def Xform "World" @@ -292,30 +281,25 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The supported content still arrives") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 0); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 0); } THEN("The unsupported prim is named in the report") { - REQUIRE(report.skipped.size() == 1); - REQUIRE(report.skipped[0].primPath == "/World/Tube"); - REQUIRE(report.skipped[0].reason + REQUIRE(stage.report.skipped.size() == 1); + REQUIRE(stage.report.skipped[0].primPath == "/World/Tube"); + REQUIRE(stage.report.skipped[0].reason == tsd::io::UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); } THEN("It leaves a disabled Placeholder Node where it belongs") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto tube = findNode(layer, "Tube"); REQUIRE(tube); REQUIRE((*tube)->isEmpty()); @@ -329,8 +313,7 @@ SCENARIO("An import can be restricted to one prim subtree", "[UsdImport]") { GIVEN("A Stage with two sibling assets") { - StageFixture stage("tsd_test_usd_subtree.usda", - std::string(R"(#usda 1.0 + const std::string siblingAssets = std::string(R"(#usda 1.0 def Xform "AssetA" { @@ -349,24 +332,20 @@ def Xform "AssetB" + R"( } } -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); +)"; WHEN("The import is pointed at one subtree") { tsd::io::UsdImportOptions options; options.primPath = "/AssetB"; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage("tsd_test_usd_subtree.usda", siblingAssets, options); THEN("Only that subtree arrives") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE(report.skipped.empty()); - REQUIRE_FALSE(findNode(scene.defaultLayer(), "AssetA")); - REQUIRE(findNode(scene.defaultLayer(), "AssetB")); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.report.skipped.empty()); + REQUIRE_FALSE(findNode(stage.scene.defaultLayer(), "AssetA")); + REQUIRE(findNode(stage.scene.defaultLayer(), "AssetB")); } } } @@ -377,7 +356,7 @@ SCENARIO( { GIVEN("A Z-up Stage authored in centimetres") { - StageFixture stage("tsd_test_usd_framing.usda", + ImportedStage stage("tsd_test_usd_framing.usda", std::string(R"(#usda 1.0 ( upAxis = "Z" @@ -391,16 +370,11 @@ def Mesh "Quad" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Up-axis and unit scale are recorded on the import root node") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto root = findNode(layer, stage.path().c_str()); REQUIRE(root); const auto ¶ms = (*root)->getInstanceParameters(); @@ -414,7 +388,7 @@ def Mesh "Quad" THEN("Geometry coordinates are left exactly as authored") { - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *position = geometry->parameterValueAsObject( "vertex.position"); REQUIRE(position != nullptr); @@ -432,7 +406,7 @@ SCENARIO("A prim that resets the transform stack ignores its ancestors", { GIVEN("A child that resets the transform stack under a moved parent") { - StageFixture stage("tsd_test_usd_xform_reset.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_xform_reset.usda", R"(#usda 1.0 def Xform "Parent" { @@ -450,16 +424,11 @@ def Xform "Parent" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Composing parent and child lands where USD puts the child") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto parent = findNode(layer, "Parent"); auto detached = findNode(layer, "Detached"); REQUIRE(parent); @@ -479,7 +448,7 @@ SCENARIO("Time-varying visibility is reported rather than lost", "[UsdImport]") { GIVEN("A mesh whose visibility is animated") { - StageFixture stage("tsd_test_usd_animated_visibility.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_animated_visibility.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -497,17 +466,12 @@ def Mesh "Blinker" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The caller is told the animation was not represented") { - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) == 1); } } diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp index e4b67286d..fbb8ff36e 100644 --- a/tsd/tests/test_UsdImport_animation.cpp +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -18,7 +18,7 @@ SCENARIO("Animation is captured at the times actually authored", "[UsdImport]") { GIVEN("A Stage with transforms keyed on a non-uniform time base") { - StageFixture stage("tsd_test_usd_time_base.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_time_base.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 10 @@ -42,17 +42,13 @@ def Xform "Mover" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The binding's time base mirrors the authored sample spacing") { - REQUIRE(animMgr.animations().size() == 1); - const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(stage.animMgr.animations().size() == 1); + const auto &bindings = + stage.animMgr.animations()[0].transformBindings(); REQUIRE(bindings.size() == 1); const auto &timeBase = bindings[0].timeBase(); REQUIRE(timeBase.size() == 3); @@ -68,7 +64,7 @@ SCENARIO("A full turn authored with two keys does not collapse", "[UsdImport]") { GIVEN("A prim rotating 360 degrees between two keyframes") { - StageFixture stage("tsd_test_usd_full_turn.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_full_turn.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 1 @@ -91,17 +87,13 @@ def Xform "Spinner" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Extra samples are inserted so the rotation still animates") { - REQUIRE(animMgr.animations().size() == 1); - const auto &bindings = animMgr.animations()[0].transformBindings(); + REQUIRE(stage.animMgr.animations().size() == 1); + const auto &bindings = + stage.animMgr.animations()[0].transformBindings(); REQUIRE(bindings.size() == 1); REQUIRE(bindings[0].sampleCount() > 2); } @@ -110,7 +102,7 @@ def Xform "Spinner" GIVEN("A prim translating between two keyframes") { - StageFixture stage("tsd_test_usd_small_move.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_small_move.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 1 @@ -126,16 +118,12 @@ def Xform "Slider" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("No extra samples are invented") { - const auto &bindings = animMgr.animations()[0].transformBindings(); + const auto &bindings = + stage.animMgr.animations()[0].transformBindings(); REQUIRE(bindings[0].sampleCount() == 2); } } @@ -147,7 +135,7 @@ SCENARIO( { GIVEN("A Stage whose mesh points are time-sampled") { - StageFixture stage("tsd_test_usd_deforming.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_deforming.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -165,21 +153,16 @@ def Mesh "Blob" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Only one frame is eager; the rest is bound to the Stage") { - REQUIRE(animMgr.animations().size() == 1); - REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); - REQUIRE( - animMgr.animations()[0].fileBindings()[0]->kind() == "usdGeometry"); + REQUIRE(stage.animMgr.animations().size() == 1); + REQUIRE(stage.animMgr.animations()[0].fileBindings().size() == 1); + REQUIRE(stage.animMgr.animations()[0].fileBindings()[0]->kind() + == "usdGeometry"); - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *positions = geometry->parameterValueAsObject( "vertex.position"); REQUIRE(positions != nullptr); @@ -188,9 +171,9 @@ def Mesh "Blob" THEN("Scrubbing pulls a later frame from the Stage") { - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *positions = geometry->parameterValueAsObject( "vertex.position"); REQUIRE(positions != nullptr); @@ -200,10 +183,10 @@ def Mesh "Blob" THEN("The binding reconstructs from an Archive") { tsd::core::DataTree tree; - REQUIRE( - tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); + REQUIRE(tsd::io::serialize_AnimationManagerArchive( + stage.animMgr, tree.root())); - tsd::animation::AnimationManager restored(&scene); + tsd::animation::AnimationManager restored(&stage.scene); REQUIRE(tsd::io::deserialize_AnimationManagerArchive( restored, tree.root())); REQUIRE(restored.animations().size() == 1); @@ -212,7 +195,7 @@ def Mesh "Blob" == "usdGeometry"); restored.setAnimationTime(1.0f); - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *positions = geometry->parameterValueAsObject( "vertex.position"); REQUIRE(positions->dataAs()[1].x == Approx(3.f)); @@ -228,7 +211,7 @@ SCENARIO("Claimed dialect prims are handled once and only once", "[UsdImport]") // The EnSight carrier marker is customData on the carrier's children; the // claim-and-prune pre-pass must keep the generic path from converting // them into meaningless geometry. - StageFixture stage("tsd_test_usd_dialect.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_dialect.usda", R"(#usda 1.0 def Scope "Dataset" { @@ -254,19 +237,14 @@ def Mesh "Real" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The carrier prim does not also arrive as generic geometry") { // Only the ordinary mesh converts: the claimed subtree is pruned from // the resolved scene entirely. - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE_FALSE(findNode(scene.defaultLayer(), "part_one")); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE_FALSE(findNode(stage.scene.defaultLayer(), "part_one")); } } } @@ -279,7 +257,7 @@ SCENARIO("Constant-valued time samples are not reported as a loss", { // What a simulation exporter writes: every attribute re-authored at every // frame regardless of whether it moved. - StageFixture stage("tsd_test_usd_constant_visibility.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_constant_visibility.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -298,17 +276,12 @@ def Mesh "Steady" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Nothing is reported as dropped") { - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) == 0); } } @@ -319,7 +292,7 @@ SCENARIO("One import is one Animation", "[UsdImport]") { GIVEN("A Stage animating two prims that share a leaf name") { - StageFixture stage("tsd_test_usd_one_animation.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_one_animation.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -350,23 +323,18 @@ def Xform "B" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Both prims land in one Animation named for the file") { - REQUIRE(animMgr.animations().size() == 1); - REQUIRE(animMgr.animations()[0].name() == stage.path()); - REQUIRE(animMgr.animations()[0].transformBindings().size() == 2); + REQUIRE(stage.animMgr.animations().size() == 1); + REQUIRE(stage.animMgr.animations()[0].name() == stage.path()); + REQUIRE(stage.animMgr.animations()[0].transformBindings().size() == 2); } THEN("The Report counts them in place of the lost per-prim entries") { - REQUIRE(report.animatedPrims == 2); + REQUIRE(stage.report.animatedPrims == 2); } } } @@ -376,7 +344,7 @@ SCENARIO("An old-format geometry binding still reconstructs", "[UsdImport]") { GIVEN("An Archive node carrying the dropped sampleTimes and timeBase fields") { - StageFixture stage("tsd_test_usd_legacy_binding.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_legacy_binding.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -393,10 +361,7 @@ def Mesh "Blob" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - animMgr.removeAllAnimations(); + stage.animMgr.removeAllAnimations(); // Written the way an Archive from before continuous Time Code evaluation // was: the derived sample cache is present and must simply be ignored. @@ -412,16 +377,16 @@ def Mesh "Blob" WHEN("It is read back") { - auto &anim = animMgr.addAnimation("legacy"); + auto &anim = stage.animMgr.addAnimation("legacy"); REQUIRE(tsd::io::UsdGeometryFileBinding::addToAnimation( - anim, scene, node) + anim, stage.scene, node) != nullptr); THEN("It scrubs from the Stage's own clock") { - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *positions = geometry->parameterValueAsObject( "vertex.position"); REQUIRE(positions != nullptr); @@ -438,7 +403,7 @@ SCENARIO("A mesh whose topology changes re-pulls a consistent set", { // The case a binding that only re-pulls points cannot serve: writing new // positions without new indices would describe a mesh that never existed. - StageFixture stage("tsd_test_usd_morphing_mesh.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_morphing_mesh.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -468,14 +433,9 @@ def Mesh "Morphing" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); REQUIRE(geometry); auto arraySize = [&](const char *parameter) -> size_t { @@ -493,7 +453,7 @@ def Mesh "Morphing" THEN("Scrubbing re-pulls points, indices and primvars together") { - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); REQUIRE(arraySize("vertex.position") == 4); REQUIRE(arraySize("primitive.index") == 2); @@ -513,17 +473,20 @@ def Mesh "Morphing" THEN("The Surface and its Geometry keep their identity across the scrub") { - const auto surfacesBefore = scene.numberOfObjects(ANARI_SURFACE); - const auto geometriesBefore = scene.numberOfObjects(ANARI_GEOMETRY); - const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); + const auto surfacesBefore = stage.scene.numberOfObjects(ANARI_SURFACE); + const auto geometriesBefore = + stage.scene.numberOfObjects(ANARI_GEOMETRY); + const auto materialsBefore = + stage.scene.numberOfObjects(ANARI_MATERIAL); - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); // Re-running conversion would have built new ones, forcing the render // index to tear down and recreate ANARI handles (ADR 0022). - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == surfacesBefore); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == geometriesBefore); - REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == surfacesBefore); + REQUIRE( + stage.scene.numberOfObjects(ANARI_GEOMETRY) == geometriesBefore); + REQUIRE(stage.scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); } } } @@ -533,7 +496,7 @@ SCENARIO("Parts keep sharing one position Array across a resize", "[UsdImport]") { GIVEN("A mesh divided into subsets whose vertex count changes") { - StageFixture stage("tsd_test_usd_shared_resize.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_shared_resize.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -570,22 +533,17 @@ def Mesh "Split" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - auto positionArrayOf = [&](size_t i) { - auto geometry = scene.getObject(i); + auto geometry = stage.scene.getObject(i); return geometry ? geometry->parameterValueAsObject( "vertex.position") : nullptr; }; - const auto parts = scene.numberOfObjects(ANARI_GEOMETRY); + const auto parts = stage.scene.numberOfObjects(ANARI_GEOMETRY); REQUIRE(parts > 1); // the subsets, plus any unclaimed remainder THEN("Every Part shares one position Array on import") @@ -598,7 +556,7 @@ def Mesh "Split" THEN("They still share one after a resize, not a copy each") { - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); auto *first = positionArrayOf(0); REQUIRE(first != nullptr); diff --git a/tsd/tests/test_UsdImport_geometry.cpp b/tsd/tests/test_UsdImport_geometry.cpp index e279ca1f8..f49ef414d 100644 --- a/tsd/tests/test_UsdImport_geometry.cpp +++ b/tsd/tests/test_UsdImport_geometry.cpp @@ -16,7 +16,7 @@ SCENARIO("A USD Stage's meshes arrive as surfaces", "[UsdImport]") { GIVEN("A Stage with a single quad mesh") { - StageFixture stage("tsd_test_usd_single_mesh.usda", + ImportedStage stage("tsd_test_usd_single_mesh.usda", std::string(R"(#usda 1.0 def Xform "World" @@ -29,20 +29,15 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The mesh becomes one triangle-geometry surface") { - REQUIRE(report.stageOpened); - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); + REQUIRE(stage.report.stageOpened); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_GEOMETRY) == 1); - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); REQUIRE(geometry); REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::triangle); @@ -59,8 +54,8 @@ def Xform "World" THEN("Nothing is silently lost") { - REQUIRE(report.skipped.empty()); - REQUIRE(report.convertedPrims == 1); + REQUIRE(stage.report.skipped.empty()); + REQUIRE(stage.report.convertedPrims == 1); } } } @@ -70,7 +65,7 @@ SCENARIO("Analytic quadrics stay analytic", "[UsdImport]") { GIVEN("A Stage with a sphere and a cylinder") { - StageFixture stage("tsd_test_usd_quadrics.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_quadrics.usda", R"(#usda 1.0 def Sphere "Ball" { @@ -85,22 +80,17 @@ def Cylinder "Tube" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("They map onto TSD's native quadric geometry, not meshes") { - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 2); + REQUIRE(stage.scene.numberOfObjects(ANARI_GEOMETRY) == 2); - auto ball = scene.getObject(0); + auto ball = stage.scene.getObject(0); REQUIRE(ball->subtype() == tsd::scene::tokens::geometry::sphere); REQUIRE(ball->parameterValueAs("radius").value() == Approx(2.f)); - auto tube = scene.getObject(1); + auto tube = stage.scene.getObject(1); REQUIRE(tube->subtype() == tsd::scene::tokens::geometry::cylinder); // The spine axis is folded into the endpoints rather than a transform. @@ -111,7 +101,7 @@ def Cylinder "Tube" const auto *p = positions->dataAs(); REQUIRE(p[0].y == Approx(-2.f)); REQUIRE(p[1].y == Approx(2.f)); - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } } @@ -122,7 +112,7 @@ SCENARIO( { GIVEN("A mesh with one concave five-sided face") { - StageFixture stage("tsd_test_usd_nonconvex.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_nonconvex.usda", R"(#usda 1.0 def Mesh "Arrow" { @@ -132,16 +122,11 @@ def Mesh "Arrow" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("It becomes exactly n-2 triangles") { - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); REQUIRE(geometry); auto *index = geometry->parameterValueAsObject( "primitive.index"); @@ -156,7 +141,7 @@ SCENARIO("Subdivision surfaces are refined by default", "[UsdImport]") { GIVEN("A cube that explicitly declares a subdivision scheme") { - StageFixture stage("tsd_test_usd_subdiv.usda", R"(#usda 1.0 + const std::string subdivCube = R"(#usda 1.0 def Mesh "SubdivCube" { @@ -175,19 +160,16 @@ def Mesh "PolygonCube" int[] faceVertexIndices = [0, 1, 2, 3] point3f[] points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] } -)"); - - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); +)"; WHEN("The Stage is imported at the default refinement level") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + ImportedStage stage("tsd_test_usd_subdiv.usda", subdivCube); THEN("The subdivision mesh gains vertices and the polygon mesh does not") { - auto subdiv = scene.getObject(0); - auto polygon = scene.getObject(1); + auto subdiv = stage.scene.getObject(0); + auto polygon = stage.scene.getObject(1); REQUIRE(subdiv); REQUIRE(polygon); @@ -208,11 +190,11 @@ def Mesh "PolygonCube" { tsd::io::UsdImportOptions options; options.refinementLevel = 0; - tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage("tsd_test_usd_subdiv.usda", subdivCube, options); THEN("The subdivision mesh arrives at its authored resolution") { - auto subdiv = scene.getObject(0); + auto subdiv = stage.scene.getObject(0); auto *p = subdiv->parameterValueAsObject( "vertex.position"); REQUIRE(p != nullptr); @@ -227,7 +209,7 @@ SCENARIO( { GIVEN("A subdivision mesh whose UVs are authored per face corner") { - StageFixture stage("tsd_test_usd_subdiv_uvs.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_subdiv_uvs.usda", R"(#usda 1.0 def Mesh "SubdivQuad" { @@ -241,16 +223,11 @@ def Mesh "SubdivQuad" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The UVs survive refinement rather than being dropped") { - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); REQUIRE(geometry); auto *uvs = geometry->parameterValueAsObject( "faceVarying.attribute0"); @@ -260,7 +237,7 @@ def Mesh "SubdivQuad" THEN("Nothing is reported as lost") { - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } } @@ -273,7 +250,7 @@ SCENARIO("Width-less curves and points get a bounds-scaled radius", // ANARI default of 1 world unit dwarfs most scenes. GIVEN("A Stage with a widthless curve, a widthed curve, and widthless points") { - StageFixture stage("tsd_test_usd_widthless_curves.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_widthless_curves.usda", R"(#usda 1.0 def Xform "World" { @@ -299,16 +276,11 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The widthless curve's radius scales with its bounds") { - auto geometry = findGeometry(scene, "/World/Hair"); + auto geometry = findGeometry(stage.scene, "/World/Hair"); REQUIRE(geometry); REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::curve); REQUIRE(geometry->parameterValueAsObject( @@ -322,7 +294,7 @@ def Xform "World" THEN("Authored widths still become per-vertex radii") { - auto geometry = findGeometry(scene, "/World/Rope"); + auto geometry = findGeometry(stage.scene, "/World/Rope"); REQUIRE(geometry); auto *radii = geometry->parameterValueAsObject( @@ -335,7 +307,7 @@ def Xform "World" THEN("Widthless points scale the same way") { - auto geometry = findGeometry(scene, "/World/Sprinkles"); + auto geometry = findGeometry(stage.scene, "/World/Sprinkles"); REQUIRE(geometry); REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::sphere); @@ -374,7 +346,7 @@ SCENARIO("Conversion leaves nothing behind for geometry it does not emit", { GIVEN("A mesh with no points that nonetheless binds a material") { - StageFixture stage("tsd_test_usd_empty_mesh.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_empty_mesh.usda", R"(#usda 1.0 def Material "Orphan" { @@ -399,29 +371,24 @@ def Mesh "Empty" ( } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - // A Scene creates one default Material of its own, so what matters is // that the import adds none. - const auto materialsBefore = scene.numberOfObjects(ANARI_MATERIAL); + const auto materialsBefore = stage.scene.numberOfObjects(ANARI_MATERIAL); WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("No Surface, Geometry or Material is created for it") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 0); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 0); - REQUIRE(scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 0); + REQUIRE(stage.scene.numberOfObjects(ANARI_GEOMETRY) == 0); + REQUIRE(stage.scene.numberOfObjects(ANARI_MATERIAL) == materialsBefore); } } } GIVEN("A mesh whose subset claims no faces but binds its own material") { - StageFixture stage("tsd_test_usd_empty_subset.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_empty_subset.usda", R"(#usda 1.0 def Material "Used" { @@ -475,20 +442,16 @@ def Mesh "Quad" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Only the material the drawn subset uses is created") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); bool sawUnused = false; - for (size_t i = 0; i < scene.numberOfObjects(ANARI_MATERIAL); ++i) { - auto material = scene.getObject(i); + const auto numMaterials = stage.scene.numberOfObjects(ANARI_MATERIAL); + for (size_t i = 0; i < numMaterials; ++i) { + auto material = stage.scene.getObject(i); if (material && material->name().find("Unused") != std::string::npos) sawUnused = true; } diff --git a/tsd/tests/test_UsdImport_instancing.cpp b/tsd/tests/test_UsdImport_instancing.cpp index 3884a1190..292f3bdca 100644 --- a/tsd/tests/test_UsdImport_instancing.cpp +++ b/tsd/tests/test_UsdImport_instancing.cpp @@ -17,7 +17,7 @@ SCENARIO("A point instancer shares one set of Prototype objects", "[UsdImport]") { GIVEN("A Stage scattering one Prototype three times, one of them hidden") { - StageFixture stage("tsd_test_usd_point_instancer.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_point_instancer.usda", R"(#usda 1.0 def PointInstancer "Scatter" { @@ -36,22 +36,17 @@ def PointInstancer "Scatter" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The Prototype is imported once, not once per placement") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 1); - REQUIRE(scene.numberOfObjects(ANARI_GEOMETRY) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_GEOMETRY) == 1); } THEN("The placements become a single transform-array node") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto scatter = findNode(layer, "Scatter"); REQUIRE(scatter); @@ -69,7 +64,7 @@ def PointInstancer "Scatter" THEN("Nothing is silently lost") { - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } } @@ -79,7 +74,7 @@ SCENARIO("USD Instances share objects across placements", "[UsdImport]") { GIVEN("A Stage referencing one Prototype from two instanceable prims") { - StageFixture stage("tsd_test_usd_native_instance.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_native_instance.usda", R"(#usda 1.0 def Xform "Protos" { @@ -113,23 +108,18 @@ def Xform "InstanceB" ( } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The Prototype geometry exists once, plus the un-instanced source") { // /Protos/Asset/Quad imports as ordinary content; the two placements // share a single converted Prototype rather than copying it. - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 2); } THEN("Each placement is a node referencing the shared objects") { - auto *layer = scene.defaultLayer(); + auto *layer = stage.scene.defaultLayer(); auto a = findNode(layer, "InstanceA"); auto b = findNode(layer, "InstanceB"); REQUIRE(a); @@ -178,7 +168,7 @@ SCENARIO("A point instancer's placements follow the Stage clock", "[UsdImport]") { GIVEN("A PointInstancer whose positions and scales are time-sampled") { - StageFixture stage("tsd_test_usd_animated_instancer.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_animated_instancer.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -206,32 +196,28 @@ def PointInstancer "Swarm" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("One Animation holds the instancer's binding") { - REQUIRE(animMgr.animations().size() == 1); - REQUIRE(animMgr.animations()[0].fileBindings().size() == 1); - REQUIRE(animMgr.animations()[0].fileBindings()[0]->kind() + REQUIRE(stage.animMgr.animations().size() == 1); + REQUIRE(stage.animMgr.animations()[0].fileBindings().size() == 1); + REQUIRE(stage.animMgr.animations()[0].fileBindings()[0]->kind() == "usdInstancer"); } THEN("The Stage's frame range and rate are reported, not applied") { - REQUIRE(report.animatedPrims == 1); - REQUIRE(report.sampleCount == 2); - REQUIRE(report.timeCodesPerSecond == Approx(24.f)); - REQUIRE(animMgr.getAnimationTotalFrames() == 100); // untouched + REQUIRE(stage.report.animatedPrims == 1); + REQUIRE(stage.report.sampleCount == 2); + REQUIRE(stage.report.timeCodesPerSecond == Approx(24.f)); + REQUIRE(stage.animMgr.getAnimationTotalFrames() == 100); // untouched } THEN("The imported placements are the Stage's first frame") { - auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + auto *transforms = + findTransformArray(stage.scene.defaultLayer(), "Swarm"); REQUIRE(transforms != nullptr); REQUIRE(transforms->size() == 3); const auto *m = transforms->dataAs(); @@ -241,12 +227,12 @@ def PointInstancer "Swarm" THEN("Scrubbing re-fills the same Array in place") { - auto *before = findTransformArray(scene.defaultLayer(), "Swarm"); + auto *before = findTransformArray(stage.scene.defaultLayer(), "Swarm"); REQUIRE(before != nullptr); - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); - auto *after = findTransformArray(scene.defaultLayer(), "Swarm"); + auto *after = findTransformArray(stage.scene.defaultLayer(), "Swarm"); REQUIRE(after == before); // no reallocation on a constant count const auto *m = after->dataAs(); REQUIRE(m[2][3].x == Approx(20.f)); @@ -255,9 +241,10 @@ def PointInstancer "Swarm" THEN("A time between authored samples is interpolated, not snapped") { - animMgr.setAnimationTime(0.5f); + stage.animMgr.setAnimationTime(0.5f); - auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + auto *transforms = + findTransformArray(stage.scene.defaultLayer(), "Swarm"); REQUIRE(transforms != nullptr); const auto *m = transforms->dataAs(); REQUIRE(m[2][3].x == Approx(11.f)); @@ -271,7 +258,7 @@ SCENARIO("An instancer whose placement count changes reallocates", { GIVEN("A PointInstancer that gains a placement mid-sequence") { - StageFixture stage("tsd_test_usd_growing_instancer.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_growing_instancer.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -298,22 +285,17 @@ def PointInstancer "Growing" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported and scrubbed past the change") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - - auto *before = findTransformArray(scene.defaultLayer(), "Growing"); + auto *before = findTransformArray(stage.scene.defaultLayer(), "Growing"); REQUIRE(before != nullptr); REQUIRE(before->size() == 2); - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); THEN("The node is re-pointed at a right-sized Array") { - auto *after = findTransformArray(scene.defaultLayer(), "Growing"); + auto *after = findTransformArray(stage.scene.defaultLayer(), "Growing"); REQUIRE(after != nullptr); REQUIRE(after->size() == 3); REQUIRE(after->dataAs()[2][3].x == Approx(2.f)); @@ -326,7 +308,7 @@ SCENARIO("Instancer bindings survive save and reload", "[UsdImport]") { GIVEN("An imported Stage with an animated PointInstancer") { - StageFixture stage("tsd_test_usd_instancer_archive.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_instancer_archive.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -350,16 +332,13 @@ def PointInstancer "Swarm" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - WHEN("The animation manager round-trips through an Archive") { tsd::core::DataTree tree; - REQUIRE(tsd::io::serialize_AnimationManagerArchive(animMgr, tree.root())); + REQUIRE(tsd::io::serialize_AnimationManagerArchive( + stage.animMgr, tree.root())); - tsd::animation::AnimationManager restored(&scene); + tsd::animation::AnimationManager restored(&stage.scene); REQUIRE( tsd::io::deserialize_AnimationManagerArchive(restored, tree.root())); @@ -372,7 +351,8 @@ def PointInstancer "Swarm" restored.setAnimationTime(1.0f); - auto *transforms = findTransformArray(scene.defaultLayer(), "Swarm"); + auto *transforms = + findTransformArray(stage.scene.defaultLayer(), "Swarm"); REQUIRE(transforms != nullptr); REQUIRE(transforms->dataAs()[1][3].x == Approx(9.f)); } @@ -388,7 +368,7 @@ SCENARIO("A Stage that authored no time-code range still animates", // Nothing forces a Stage to declare its own range, and USD reports 0 for // both ends when it does not. Without a fallback every animation time // would map onto one Time Code and the placements would never move. - StageFixture stage("tsd_test_usd_unranged_instancer.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_unranged_instancer.usda", R"(#usda 1.0 def PointInstancer "Drifting" { @@ -408,17 +388,14 @@ def PointInstancer "Drifting" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported and scrubbed to the end") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); THEN("The authored samples define the range that time maps onto") { - auto *transforms = findTransformArray(scene.defaultLayer(), "Drifting"); + auto *transforms = + findTransformArray(stage.scene.defaultLayer(), "Drifting"); REQUIRE(transforms != nullptr); REQUIRE(transforms->dataAs()[1][3].x == Approx(7.f)); } @@ -427,7 +404,7 @@ def PointInstancer "Drifting" GIVEN("A deforming mesh with time samples but no startTimeCode") { - StageFixture stage("tsd_test_usd_unranged_mesh.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_unranged_mesh.usda", R"(#usda 1.0 def Mesh "Blob" { @@ -440,17 +417,13 @@ def Mesh "Blob" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported and scrubbed to the end") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - animMgr.setAnimationTime(1.0f); + stage.animMgr.setAnimationTime(1.0f); THEN("The authored samples define the range that time maps onto") { - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *positions = geometry->parameterValueAsObject( "vertex.position"); REQUIRE(positions != nullptr); diff --git a/tsd/tests/test_UsdImport_lights.cpp b/tsd/tests/test_UsdImport_lights.cpp index 71601e4fc..f644aed64 100644 --- a/tsd/tests/test_UsdImport_lights.cpp +++ b/tsd/tests/test_UsdImport_lights.cpp @@ -17,7 +17,7 @@ SCENARIO( { // intensity 4, exposure 2 -> 4 * 2^2 = 16; normalize divides by the // sphere's area, 4*pi*r^2 with r = 2 -> 16 / (16*pi). - StageFixture stage("tsd_test_usd_light_radiometry.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_light_radiometry.usda", R"(#usda 1.0 def SphereLight "Lamp" { @@ -29,17 +29,12 @@ def SphereLight "Lamp" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The light's intensity accounts for both") { - REQUIRE(scene.numberOfObjects(ANARI_LIGHT) == 1); - auto light = scene.getObject(0); + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = stage.scene.getObject(0); REQUIRE(light); REQUIRE(light->subtype() == tsd::scene::tokens::light::point); @@ -47,7 +42,7 @@ def SphereLight "Lamp" REQUIRE(intensity.has_value()); const float expected = 16.f / (4.f * float(M_PI) * 4.f); REQUIRE(*intensity == Approx(expected)); - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } } @@ -57,7 +52,7 @@ SCENARIO("A shaped sphere light becomes a spot light", "[UsdImport]") { GIVEN("A sphere light carrying shaping attributes") { - StageFixture stage("tsd_test_usd_spot.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_spot.usda", R"(#usda 1.0 def SphereLight "Spot" ( prepend apiSchemas = ["ShapingAPI"] @@ -70,16 +65,11 @@ def SphereLight "Spot" ( } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Spot lighting survives the import") { - auto light = scene.getObject(0); + auto light = stage.scene.getObject(0); REQUIRE(light); REQUIRE(light->subtype() == tsd::scene::tokens::light::spot); @@ -99,7 +89,7 @@ SCENARIO("Cameras from a Stage arrive in the camera pool", "[UsdImport]") { GIVEN("A Stage with an animated camera rig") { - StageFixture stage("tsd_test_usd_camera.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_camera.usda", R"(#usda 1.0 ( startTimeCode = 0 endTimeCode = 2 @@ -122,20 +112,19 @@ def Xform "Rig" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - const auto camerasBefore = scene.numberOfObjects(ANARI_CAMERA); + // A Scene starts out with a camera of its own, so the count to compare + // against is an empty Scene's rather than this one's after the import. + const auto camerasBefore = + tsd::scene::Scene().numberOfObjects(ANARI_CAMERA); WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The authored viewpoint is available and animated") { - REQUIRE(scene.numberOfObjects(ANARI_CAMERA) == camerasBefore + 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_CAMERA) == camerasBefore + 1); bool hasCameraAnimation = false; - for (const auto &animation : animMgr.animations()) { + for (const auto &animation : stage.animMgr.animations()) { if (!animation.objectParameterBindings().empty()) hasCameraAnimation = true; } diff --git a/tsd/tests/test_UsdImport_materials.cpp b/tsd/tests/test_UsdImport_materials.cpp index eec976e60..f4e1d8c02 100644 --- a/tsd/tests/test_UsdImport_materials.cpp +++ b/tsd/tests/test_UsdImport_materials.cpp @@ -19,7 +19,7 @@ SCENARIO("Native material passthrough is opt-in", "[UsdImport]") GIVEN("A Stage whose material is an ordinary preview surface") { - StageFixture stage("tsd_test_usd_preview_material.usda", R"(#usda 1.0 + const std::string previewSurface = R"(#usda 1.0 def Xform "World" { @@ -45,37 +45,33 @@ def Xform "World" rel material:binding = } } -)"); +)"; WHEN("The default material mode is used") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + ImportedStage stage("tsd_test_usd_preview_material.usda", previewSurface); THEN("A portable physically-based material is emitted") { - REQUIRE(boundMaterial(scene)->subtype() + REQUIRE(boundMaterial(stage.scene)->subtype() == tsd::scene::tokens::material::physicallyBased); - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } WHEN("A native passthrough is asked for that this material cannot give") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MDL; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage( + "tsd_test_usd_preview_material.usda", previewSurface, options); THEN("The fallback to a portable mapping is reported, not silent") { - REQUIRE(boundMaterial(scene)->subtype() + REQUIRE(boundMaterial(stage.scene)->subtype() == tsd::scene::tokens::material::physicallyBased); - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) == 1); } } @@ -83,21 +79,19 @@ def Xform "World" #if TSD_USD_HAS_MATERIALX WHEN("MaterialX emission is asked for") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage( + "tsd_test_usd_preview_material.usda", previewSurface, options); THEN("A preview surface falls back rather than emitting a bad document") { // MaterialX has no node definition for UsdPreviewSurface, so there is // nothing to pass through; the portable mapping is used and said so. - REQUIRE(boundMaterial(scene)->subtype() + REQUIRE(boundMaterial(stage.scene)->subtype() == tsd::scene::tokens::material::physicallyBased); - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) == 1); } } @@ -107,7 +101,7 @@ def Xform "World" #if TSD_USD_HAS_MATERIALX GIVEN("A Stage with an authored MaterialX network") { - StageFixture stage("tsd_test_usd_materialx.usda", R"(#usda 1.0 + const std::string materialxNetwork = R"(#usda 1.0 def Xform "World" { @@ -134,20 +128,18 @@ def Xform "World" rel material:binding = } } -)"); +)"; WHEN("MaterialX emission is asked for") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage( + "tsd_test_usd_materialx.usda", materialxNetwork, options); THEN("The network passes through as an inline MaterialX document") { - auto *material = boundMaterial(scene); + auto *material = boundMaterial(stage.scene); REQUIRE(material->subtype() == tsd::scene::tokens::material::materialx); REQUIRE(stringParameter(material, "sourceType") == "documentInline"); @@ -162,19 +154,17 @@ def Xform "World" THEN("Nothing is reported as lost") { - REQUIRE(report.skipped.empty()); + REQUIRE(stage.report.skipped.empty()); } } WHEN("The default material mode is used") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); + ImportedStage stage("tsd_test_usd_materialx.usda", materialxNetwork); THEN("The portable mapping is still what arrives") { - REQUIRE(boundMaterial(scene)->subtype() + REQUIRE(boundMaterial(stage.scene)->subtype() != tsd::scene::tokens::material::materialx); } } @@ -184,6 +174,7 @@ def Xform "World" // from the option it sets. WHEN("The file is imported through the USD_MTLX Importer Type") { + StageFixture stage("tsd_test_usd_materialx.usda", materialxNetwork); tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); tsd::io::import_file(scene, @@ -199,6 +190,7 @@ def Xform "World" WHEN("The file is imported through the plain USD Importer Type") { + StageFixture stage("tsd_test_usd_materialx.usda", materialxNetwork); tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); tsd::io::import_file(scene, @@ -220,7 +212,7 @@ def Xform "World" { TextureFixture present("tsd_test_usd_mtlx_present.tga"); - StageFixture stage("tsd_test_usd_materialx_textures.usda", R"(#usda 1.0 + const std::string texturedNetwork = R"(#usda 1.0 def Xform "World" { @@ -261,19 +253,17 @@ def Xform "World" rel material:binding = } } -)"); +)"; WHEN("MaterialX emission is asked for") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage( + "tsd_test_usd_materialx_textures.usda", texturedNetwork, options); const auto source = - stringParameter(boundMaterial(scene), "source"); + stringParameter(boundMaterial(stage.scene), "source"); THEN("Texture paths leave as absolute paths") { @@ -291,7 +281,7 @@ def Xform "World" THEN("The texture that exists is not reported as missing") { - for (const auto &skip : report.skipped) { + for (const auto &skip : stage.report.skipped) { const bool missedThisOne = skip.reason == tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED && skip.detail == present.path(); @@ -305,34 +295,35 @@ def Xform "World" // its paths are. THEN("A sampler is bound to the input by its document path") { - REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SAMPLER) == 1); // The name is the contract: the device publishes each textured input // under its MaterialX element path. - auto *material = boundMaterial(scene); + auto *material = boundMaterial(stage.scene); std::string boundName; for (size_t i = 0; i < material->numParameters(); i++) { if (material->parameterAt(i).value().type() == ANARI_SAMPLER) boundName = material->parameterNameAt(i); } REQUIRE_FALSE(boundName.empty()); - // The document path, node graph included -- the same string the device's - // shader generator reports as the port's path. + // The document path, node graph included -- the same string the + // device's shader generator reports as the port's path. REQUIRE(boundName == "_/Present/file"); } THEN("A tile set binds nothing, and says so") { - REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + REQUIRE( + stage.report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); // Only the one loadable texture became a sampler. - REQUIRE(scene.numberOfObjects(ANARI_SAMPLER) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SAMPLER) == 1); } } } GIVEN("A MaterialX network naming a texture that is not there") { - StageFixture stage("tsd_test_usd_materialx_missing.usda", R"(#usda 1.0 + const std::string missingTexture = R"(#usda 1.0 def Xform "World" { @@ -365,20 +356,19 @@ def Xform "World" rel material:binding = } } -)"); +)"; WHEN("MaterialX emission is asked for") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage( + "tsd_test_usd_materialx_missing.usda", missingTexture, options); THEN("The Import Report names it rather than leaving it to the device") { - REQUIRE(report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + REQUIRE( + stage.report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); } } } @@ -390,7 +380,7 @@ def Xform "World" // silently renders with the default material. GIVEN("A MaterialX network connecting a color3 output to a float input") { - StageFixture stage("tsd_test_usd_materialx_mistyped.usda", R"(#usda 1.0 + const std::string mistypedNetwork = R"(#usda 1.0 def Xform "World" { @@ -423,26 +413,24 @@ def Xform "World" rel material:binding = } } -)"); +)"; WHEN("MaterialX emission is asked for") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage( + "tsd_test_usd_materialx_mistyped.usda", mistypedNetwork, options); THEN("The Import Report names it rather than leaving it to the device") { - REQUIRE( - report.contains(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED)); + REQUIRE(stage.report.contains( + tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED)); } THEN("The portable mapping is what arrives, not a document") { - REQUIRE(boundMaterial(scene)->subtype() + REQUIRE(boundMaterial(stage.scene)->subtype() != tsd::scene::tokens::material::materialx); } } diff --git a/tsd/tests/test_UsdImport_materials_portable.cpp b/tsd/tests/test_UsdImport_materials_portable.cpp index efa5867a4..8eb988973 100644 --- a/tsd/tests/test_UsdImport_materials_portable.cpp +++ b/tsd/tests/test_UsdImport_materials_portable.cpp @@ -14,7 +14,7 @@ SCENARIO("An unconventionally named UV primvar is still found", "[UsdImport]") { GIVEN("A material whose reader node asks for a primvar not called 'st'") { - StageFixture stage("tsd_test_usd_uv_primvar.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_uv_primvar.usda", R"(#usda 1.0 def Xform "World" { @@ -60,16 +60,11 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The named primvar lands on the geometry's first attribute") { - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); REQUIRE(geometry); auto *uvs = geometry->parameterValueAsObject( "vertex.attribute0"); @@ -91,7 +86,7 @@ SCENARIO( { GIVEN("A Stage where three meshes bind one material with no network") { - StageFixture stage("tsd_test_usd_unresolvable_material.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_unresolvable_material.usda", R"(#usda 1.0 def Xform "World" { @@ -133,21 +128,16 @@ def Xform "World" WHEN("The Stage is imported") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = - tsd::io::import_USD(scene, animMgr, stage.path().c_str(), {}); - THEN("The Import Report names the material exactly once") { - REQUIRE( - report.countOf(tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED) + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED) == 1); } THEN("Every mesh still arrives") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 3); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 3); } } } @@ -157,7 +147,7 @@ SCENARIO("An OmniPBR material maps onto the portable material", "[UsdImport]") { GIVEN("A Stage whose material is an OmniPBR MDL shader") { - StageFixture stage("tsd_test_usd_omnipbr.usda", R"(#usda 1.0 + const std::string omniPbrStage = R"(#usda 1.0 def Xform "World" { @@ -190,14 +180,12 @@ def Xform "World" rel material:binding = } } -)"); +)"; WHEN("The default material mode is used") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - auto *material = boundMaterial(scene); + ImportedStage stage("tsd_test_usd_omnipbr.usda", omniPbrStage); + auto *material = boundMaterial(stage.scene); THEN("Its authored inputs arrive, not the preview-surface defaults") { @@ -224,23 +212,20 @@ def Xform "World" THEN("Nothing claims a richer material was left on the table") { - REQUIRE(!report.contains( + REQUIRE(!stage.report.contains( tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE)); } } WHEN("MDL passthrough is asked for instead") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); tsd::io::UsdImportOptions options; options.materialMode = tsd::io::UsdMaterialMode::MDL; - auto report = tsd::io::import_USD( - scene, animMgr, stage.path().c_str(), {}, options); + ImportedStage stage("tsd_test_usd_omnipbr.usda", omniPbrStage, options); THEN("The native shader still wins over the portable mapping") { - REQUIRE(boundMaterial(scene)->subtype() + REQUIRE(boundMaterial(stage.scene)->subtype() == tsd::scene::tokens::material::mdl); } } @@ -250,7 +235,7 @@ def Xform "World" { TextureFixture diffuse("tsd_test_usd_omnipbr_diffuse.tga"); - StageFixture stage("tsd_test_usd_omnipbr_textured.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_omnipbr_textured.usda", R"(#usda 1.0 def Xform "World" { @@ -283,17 +268,15 @@ def Xform "World" WHEN("The default material mode is used") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - auto report = tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - auto *material = boundMaterial(scene); + auto *material = boundMaterial(stage.scene); THEN("The texture named on the shader input is bound") { REQUIRE( material->parameterValueAsObject("baseColor") != nullptr); - REQUIRE(!report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + REQUIRE(!stage.report.contains( + tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); } THEN("An authored threshold becomes a cutout rather than a blend") @@ -314,7 +297,7 @@ def Xform "World" GIVEN("A material whose MDL module only looks like OmniPBR") { - StageFixture stage("tsd_test_usd_omnipbr_lookalike.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_omnipbr_lookalike.usda", R"(#usda 1.0 def Xform "World" { @@ -354,16 +337,13 @@ def Xform "World" WHEN("The default material mode is used") { - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("A module that merely starts with the name is not mapped as one") { // The authored preview surface is what this material actually says; // OmniPBRBase is a different shader with input semantics of its own. - auto color = boundMaterial(scene)->parameterValueAs( - "baseColor"); + auto color = + boundMaterial(stage.scene)->parameterValueAs( + "baseColor"); REQUIRE(color.has_value()); REQUIRE(color->z == Approx(0.9f)); } @@ -376,7 +356,7 @@ SCENARIO( { GIVEN("A mesh with display colour and opacity but no material") { - StageFixture stage("tsd_test_usd_display_color.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_display_color.usda", R"(#usda 1.0 def Mesh "Quad" { @@ -392,16 +372,11 @@ def Mesh "Quad" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The Surface's material carries the display values") { - auto surface = scene.getObject(0); + auto surface = stage.scene.getObject(0); REQUIRE(surface); auto *material = surface->parameterValueAsObject( tsd::scene::tokens::surface::material); @@ -427,7 +402,7 @@ SCENARIO("Analytic prims without a material take their display colour", { GIVEN("A point cloud carrying only display colour") { - StageFixture stage("tsd_test_usd_points_display_color.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_points_display_color.usda", R"(#usda 1.0 def Points "Cloud" { @@ -439,21 +414,16 @@ def Points "Cloud" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Its material carries the display colour, not TSD's default") { - auto surface = scene.getObject(0); + auto surface = stage.scene.getObject(0); REQUIRE(surface); auto *material = surface->parameterValueAsObject( tsd::scene::tokens::surface::material); REQUIRE(material != nullptr); - REQUIRE(material != scene.defaultMaterial().data()); + REQUIRE(material != stage.scene.defaultMaterial().data()); const auto color = material->parameterValueAs("color"); @@ -464,7 +434,7 @@ def Points "Cloud" THEN("Authored widths become per-point radii") { - auto geometry = scene.getObject(0); + auto geometry = stage.scene.getObject(0); auto *radii = geometry->parameterValueAsObject( "vertex.radius"); REQUIRE(radii != nullptr); diff --git a/tsd/tests/test_UsdImport_subsets.cpp b/tsd/tests/test_UsdImport_subsets.cpp index 0501e677a..8d00f9024 100644 --- a/tsd/tests/test_UsdImport_subsets.cpp +++ b/tsd/tests/test_UsdImport_subsets.cpp @@ -16,7 +16,7 @@ SCENARIO("Per-face material subsets become several Surfaces", "[UsdImport]") { GIVEN("A two-face mesh with one face bound to its own material") { - StageFixture stage("tsd_test_usd_subsets.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_subsets.usda", R"(#usda 1.0 def Xform "World" { @@ -68,29 +68,24 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("There is one Surface per subset") { - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) >= 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) >= 1); } THEN("The faces no subset claims are still drawn, by the mesh's material") { // The first face belongs to no subset, so it stays with the mesh's own // binding instead of vanishing with the un-surfaced parent geometry. - auto leftover = findGeometry(scene, "/World/Strip"); + auto leftover = findGeometry(stage.scene, "/World/Strip"); REQUIRE(leftover); auto *index = leftover->parameterValueAsObject( "primitive.index"); REQUIRE(index != nullptr); REQUIRE(index->size() == 2); - REQUIRE(scene.numberOfObjects(ANARI_SURFACE) == 2); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 2); } THEN("The subset Surfaces share the mesh's vertex positions") @@ -98,8 +93,9 @@ def Xform "World" // Every geometry produced for this mesh points at the same // vertex.position Array; only the index arrays differ. std::vector positions; - for (size_t i = 0; i < scene.numberOfObjects(ANARI_GEOMETRY); ++i) { - auto geometry = scene.getObject(i); + const auto numGeometries = stage.scene.numberOfObjects(ANARI_GEOMETRY); + for (size_t i = 0; i < numGeometries; ++i) { + auto geometry = stage.scene.getObject(i); if (!geometry) continue; if (auto *p = geometry->parameterValueAsObject( @@ -118,7 +114,7 @@ SCENARIO("Face-varying UVs follow each material subset", "[UsdImport]") { GIVEN("A two-face mesh with per-corner UVs and a subset over each face") { - StageFixture stage("tsd_test_usd_subset_facevarying.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_subset_facevarying.usda", R"(#usda 1.0 def Xform "World" { @@ -155,13 +151,8 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Each subset carries the corners of the faces it selected") { // Face-varying data is indexed by triangle corner, so a subset cannot @@ -169,8 +160,8 @@ def Xform "World" // corners of its own triangles. Each quad triangulates to two // triangles, hence six corners, and the two faces' UVs are authored // into disjoint halves of the unit square so a mis-gather shows up. - auto left = findGeometry(scene, "/World/Quad/Left"); - auto right = findGeometry(scene, "/World/Quad/Right"); + auto left = findGeometry(stage.scene, "/World/Quad/Left"); + auto right = findGeometry(stage.scene, "/World/Quad/Right"); REQUIRE(left); REQUIRE(right); @@ -196,8 +187,8 @@ def Xform "World" // Only per-corner and per-triangle data has to be gathered; vertex // data is indexed by the indices each subset already carries, so one // Array serves every Surface. - auto left = findGeometry(scene, "/World/Quad/Left"); - auto right = findGeometry(scene, "/World/Quad/Right"); + auto left = findGeometry(stage.scene, "/World/Quad/Left"); + auto right = findGeometry(stage.scene, "/World/Quad/Right"); REQUIRE(left); REQUIRE(right); @@ -221,7 +212,7 @@ SCENARIO( // producing a copy of the input, a distinct result from Success that the // conversion must not mistake for failure -- pre-triangulated exports // carry every face-varying primvar down this path. - StageFixture stage("tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 def Xform "World" { @@ -242,16 +233,11 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("The UVs arrive flattened, one value per triangle corner") { - auto geometry = findGeometry(scene, "/World/Triangles"); + auto geometry = findGeometry(stage.scene, "/World/Triangles"); REQUIRE(geometry); auto *uvs = geometry->parameterValueAsObject( @@ -271,7 +257,7 @@ def Xform "World" THEN("The normals arrive too") { - auto geometry = findGeometry(scene, "/World/Triangles"); + auto geometry = findGeometry(stage.scene, "/World/Triangles"); REQUIRE(geometry); auto *normals = geometry->parameterValueAsObject( @@ -287,7 +273,7 @@ SCENARIO("A subset binds the UV primvar its own material reads", "[UsdImport]") { GIVEN("Two subsets whose materials read differently named UV primvars") { - StageFixture stage("tsd_test_usd_subset_uv_primvar.usda", R"(#usda 1.0 + ImportedStage stage("tsd_test_usd_subset_uv_primvar.usda", R"(#usda 1.0 def Xform "World" { @@ -388,19 +374,14 @@ def Xform "World" } )"); - tsd::scene::Scene scene; - tsd::animation::AnimationManager animMgr(&scene); - WHEN("The Stage is imported") { - tsd::io::import_USD(scene, animMgr, stage.path().c_str()); - THEN("Each subset's own primvar lands on its first attribute") { // The mesh itself binds no material, so the UV name cannot be decided // once for the whole mesh: each subset's material names its own. - auto left = findGeometry(scene, "/World/Quad/Left"); - auto right = findGeometry(scene, "/World/Quad/Right"); + auto left = findGeometry(stage.scene, "/World/Quad/Left"); + auto right = findGeometry(stage.scene, "/World/Quad/Right"); REQUIRE(left); REQUIRE(right); From cebdf5928fdc5cf8d0075599cf2d09cba688521a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:05:45 -0500 Subject: [PATCH 43/78] test(io): address code review of the USD import suite split Three defects the split introduced: - test_UsdImport_lights.cpp reads M_PI but lost the the pre-split file carried, so it compiled only through a transitive include. - findTransformArray was copied into test_UsdImport_geometry.cpp, which has no caller for it. Delete that copy; the one in the instancing suite is the one in use. - ScopedDirectory defined its constructor and destructor inside the struct body, against STYLEGUIDE.md section 5. Hoist it to file scope as ScopedFixtureDirectory with its definitions below the declarations. The fixture directory now also retries on a name another process already took, so uniqueness does not rest on how good std::random_device happens to be on a given toolchain. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/tests/UsdTestFixtures.h | 48 ++++++++++++++++----------- tsd/tests/test_UsdImport_geometry.cpp | 22 ------------ tsd/tests/test_UsdImport_lights.cpp | 2 ++ 3 files changed, 30 insertions(+), 42 deletions(-) diff --git a/tsd/tests/UsdTestFixtures.h b/tsd/tests/UsdTestFixtures.h index 31b241555..20747953e 100644 --- a/tsd/tests/UsdTestFixtures.h +++ b/tsd/tests/UsdTestFixtures.h @@ -20,31 +20,21 @@ #include #include +// A directory of this process' own, removed when the test binary exits. +struct ScopedFixtureDirectory +{ + ScopedFixtureDirectory(); + ~ScopedFixtureDirectory(); + + std::filesystem::path path; +}; + // Fixture files live in a directory unique to this process, so two concurrent // runs of the test binary cannot collide on a name while relative asset // references between fixtures still resolve. inline const std::filesystem::path &fixtureDirectory() { - struct ScopedDirectory - { - ScopedDirectory() - { - std::random_device entropy; - path = std::filesystem::temp_directory_path() - / ("tsd_test_usd_" + std::to_string(entropy())); - std::filesystem::create_directories(path); - } - - ~ScopedDirectory() - { - std::error_code ec; - std::filesystem::remove_all(path, ec); - } - - std::filesystem::path path; - }; - - static const ScopedDirectory directory; + static const ScopedFixtureDirectory directory; return directory.path; } @@ -150,6 +140,24 @@ inline constexpr const char *QUAD_MESH_BODY = R"( // Inlined definitions //////////////////////////////////////////////////////// +inline ScopedFixtureDirectory::ScopedFixtureDirectory() +{ + // create_directory reports whether it was this process that made the + // directory, so retrying on a taken name is what makes the choice safe + // rather than merely unlikely. + std::random_device entropy; + const auto root = std::filesystem::temp_directory_path(); + do { + path = root / ("tsd_test_usd_" + std::to_string(entropy())); + } while (!std::filesystem::create_directory(path)); +} + +inline ScopedFixtureDirectory::~ScopedFixtureDirectory() +{ + std::error_code ec; + std::filesystem::remove_all(path, ec); +} + inline StageFixture::StageFixture(const char *name, const std::string &contents) : m_path(fixtureDirectory() / name) { diff --git a/tsd/tests/test_UsdImport_geometry.cpp b/tsd/tests/test_UsdImport_geometry.cpp index f49ef414d..7f381ab1b 100644 --- a/tsd/tests/test_UsdImport_geometry.cpp +++ b/tsd/tests/test_UsdImport_geometry.cpp @@ -319,28 +319,6 @@ def Xform "World" } } -// Animated USD /////////////////////////////////////////////////////////////// - -namespace { - -// The transform Array of the first transform-array node beneath `name`. -tsd::scene::Array *findTransformArray( - tsd::scene::Layer *layer, const char *name) -{ - auto parent = findNode(layer, name); - if (!parent) - return nullptr; - tsd::scene::Array *found = nullptr; - layer->traverse(parent, [&](auto &node, int) { - if (!found && node->type() == ANARI_ARRAY1D) - found = node->getTransformArray(); - return true; - }); - return found; -} - -} // namespace - SCENARIO("Conversion leaves nothing behind for geometry it does not emit", "[UsdImport]") { diff --git a/tsd/tests/test_UsdImport_lights.cpp b/tsd/tests/test_UsdImport_lights.cpp index f644aed64..a68caf124 100644 --- a/tsd/tests/test_UsdImport_lights.cpp +++ b/tsd/tests/test_UsdImport_lights.cpp @@ -9,6 +9,8 @@ #include "catch.hpp" // tsd_tests #include "UsdTestFixtures.h" +// std +#include SCENARIO( "Light exposure and normalization reach the emitted light", "[UsdImport]") From 6a8257901657830794fe6af4f68db341382e5db5 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:31:16 -0500 Subject: [PATCH 44/78] refactor(io): move UsdVol conversion onto the shared spatial field dispatcher UsdDialect.cpp is named for the TSD dialect carrier prims a Stage's TSD vocabulary claims, so the generic path never converts them. UsdVol is standard USD, not a TSD dialect, and a reader looking for volume conversion looks for a peer of UsdGeometry.cpp and UsdLights.cpp. Move isVolumePrimType, convertVolume and the transfer-function helpers they are the only consumers of into UsdVolume.{cpp,h}. UsdDialect is left holding only claimDialectPrims, pruning, importDialectPrims, the EnSight carrier helpers and readRenderSettings. The move also drops convertVolume's hand-rolled file-extension chain, which import_spatial_field already is -- it even called it on the VTU branch three lines earlier. That part is not behaviour-neutral: a Stage naming a '.hdf5', '.silo'/'.sil' or ';property' field now reaches an importer where it never did, and one naming '.vdb' stops reaching one until the next commit teaches the dispatcher that name. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/CMakeLists.txt | 1 + .../io/importers/detail/usd/UsdDialect.cpp | 302 ------------------ .../tsd/io/importers/detail/usd/UsdDialect.h | 7 - .../tsd/io/importers/detail/usd/UsdVolume.cpp | 300 +++++++++++++++++ .../tsd/io/importers/detail/usd/UsdVolume.h | 21 ++ tsd/src/tsd/io/importers/import_USD.cpp | 1 + 6 files changed, 323 insertions(+), 309 deletions(-) create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp create mode 100644 tsd/src/tsd/io/importers/detail/usd/UsdVolume.h diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 97840e165..5c690f8b6 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -222,6 +222,7 @@ if (TSD_USE_USD) importers/detail/usd/UsdLights.cpp importers/detail/usd/UsdMaterials.cpp importers/detail/usd/UsdSubdivision.cpp + importers/detail/usd/UsdVolume.cpp usd/UsdResolvedGeometry.cpp usd/UsdStageSession.cpp ) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index 58377321f..772d987b3 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -3,25 +3,15 @@ #include "tsd/io/importers/detail/usd/UsdDialect.h" #include "tsd/animation/AnimationManager.hpp" -#include "tsd/core/ColorMapUtil.hpp" -#include "tsd/io/animation/SpatialFieldFileBinding.hpp" #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/usd/UsdMaterials.h" -#include "tsd/scene/objects/Array.hpp" // usd -#include #include #include -#include -#include -#include #include #include -#include #include -#include // std -#include #include #include @@ -31,151 +21,6 @@ using namespace tsd::core; namespace { -/////////////////////////////////////////////////////////////////////////////// -// Transfer functions authored on a Stage //////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -struct VolumeTransferFunction -{ - std::vector colors; - std::vector xPointsColor; - std::vector xPoints; - std::vector opacityValues; - math::float2 domain{0.0f, 1.0f}; - float unitDistance{0.0f}; - bool hasTransferFunction{false}; -}; - -bool extractColormapFromPrim( - const pxr::UsdPrim &prim, VolumeTransferFunction &tf) -{ - auto rgbaAttr = prim.GetAttribute(pxr::TfToken("rgbaPoints")); - if (!rgbaAttr) - return false; - - pxr::VtArray rgbaPoints; - if (!rgbaAttr.Get(&rgbaPoints) || rgbaPoints.empty()) - return false; - - tf.colors.resize(rgbaPoints.size()); - for (size_t i = 0; i < rgbaPoints.size(); ++i) { - const auto &c = rgbaPoints[i]; - tf.colors[i] = math::float4(c[0], c[1], c[2], c[3]); - } - - auto readFloatArray = [&](const char *name, std::vector &out) { - if (auto attr = prim.GetAttribute(pxr::TfToken(name))) { - pxr::VtArray values; - if (attr.Get(&values)) - out.assign(values.begin(), values.end()); - } - }; - - readFloatArray("xPointsColor", tf.xPointsColor); - readFloatArray("xPoints", tf.xPoints); - readFloatArray("opacityValues", tf.opacityValues); - - if (auto attr = prim.GetAttribute(pxr::TfToken("domain"))) { - pxr::GfVec2f domain; - if (attr.Get(&domain)) - tf.domain = math::float2(domain[0], domain[1]); - } - - if (auto attr = prim.GetAttribute(pxr::TfToken("unitDistance"))) { - float unitDistance = 0.f; - if (attr.Get(&unitDistance) && unitDistance > 0.0f) - tf.unitDistance = unitDistance; - } - - tf.hasTransferFunction = true; - return true; -} - -core::TransferFunction toTransferFunction(const VolumeTransferFunction &vtf) -{ - core::TransferFunction tf; - tf.range = {vtf.domain.x, vtf.domain.y}; - - const auto &xColor = - vtf.xPointsColor.empty() ? vtf.xPoints : vtf.xPointsColor; - for (size_t i = 0; i < vtf.colors.size() && i < xColor.size(); ++i) { - tf.colorPoints.emplace_back( - xColor[i], vtf.colors[i].x, vtf.colors[i].y, vtf.colors[i].z); - } - - if (!vtf.opacityValues.empty()) { - for (size_t i = 0; i < vtf.opacityValues.size() && i < vtf.xPoints.size(); - ++i) - tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.opacityValues[i]); - } else { - for (size_t i = 0; i < vtf.colors.size() && i < vtf.xPoints.size(); ++i) - tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.colors[i].w); - } - - return tf; -} - -VolumeTransferFunction getVolumeTransferFunction(const pxr::UsdPrim &prim) -{ - VolumeTransferFunction tf; - - // Material binding chain: Material -> VolumeShader -> Colormap. - if (pxr::UsdShadeMaterialBindingAPI::CanApply(prim)) { - pxr::UsdShadeMaterialBindingAPI binding(prim); - pxr::UsdShadeMaterial usdMaterial; - - if (auto materialRel = - prim.GetRelationship(pxr::TfToken("material:binding"))) { - pxr::SdfPathVector targets; - materialRel.GetTargets(&targets); - if (!targets.empty()) { - if (auto materialPrim = prim.GetStage()->GetPrimAtPath(targets[0])) - usdMaterial = pxr::UsdShadeMaterial(materialPrim); - } - } - - if (!usdMaterial && binding) - usdMaterial = binding.ComputeBoundMaterial(); - - if (usdMaterial) { - auto volumeOutput = usdMaterial.GetOutput(pxr::TfToken("nvindex:volume")); - if (volumeOutput && volumeOutput.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI source; - pxr::TfToken sourceName; - pxr::UsdShadeAttributeType sourceType; - volumeOutput.GetConnectedSource(&source, &sourceName, &sourceType); - pxr::UsdShadeShader volumeShader(source.GetPrim()); - - if (volumeShader) { - auto colormapInput = volumeShader.GetInput(pxr::TfToken("colormap")); - if (colormapInput && colormapInput.HasConnectedSource()) { - pxr::UsdShadeConnectableAPI colormapSource; - pxr::TfToken colormapSourceName; - pxr::UsdShadeAttributeType colormapSourceType; - if (colormapInput.GetConnectedSource(&colormapSource, - &colormapSourceName, - &colormapSourceType)) { - if (auto colormapPrim = colormapSource.GetPrim(); - colormapPrim && extractColormapFromPrim(colormapPrim, tf)) - return tf; - } - } - } - } - } - } - - // Child Shader prim carrying colormap attributes directly. - for (const auto &child : prim.GetChildren()) { - if (!child.IsA()) - continue; - if (extractColormapFromPrim(child, tf)) - return tf; - } - - return tf; -} - /////////////////////////////////////////////////////////////////////////////// // Render settings and EnSight carriers /////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// @@ -365,151 +210,4 @@ void importDialectPrims(ImportContext &ctx, } } -/////////////////////////////////////////////////////////////////////////////// -// Volumes //////////////////////////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////// - -bool isVolumePrimType(const pxr::TfToken &primType) -{ - return primType == pxr::HdPrimTypeTokens->volume; -} - -bool convertVolume( - ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node) -{ - auto prim = ctx.stage->GetPrimAtPath(primPath); - if (!prim) - return false; - - const auto primName = primPath.GetString(); - - std::vector filePaths; - bool isVtuAsset = false; - std::optional propertyName; - - auto fieldRel = prim.GetRelationship(pxr::TfToken("field:volume")); - if (!fieldRel) - fieldRel = prim.GetRelationship(pxr::TfToken("field:density")); - - if (fieldRel) { - pxr::SdfPathVector targets; - fieldRel.GetTargets(&targets); - if (!targets.empty()) { - if (auto fieldPrim = ctx.stage->GetPrimAtPath(targets[0])) { - isVtuAsset = fieldPrim.GetTypeName() == "VTUAsset"; - if (isVtuAsset) { - if (auto attr = fieldPrim.GetAttribute(pxr::TfToken("property"))) { - std::string value; - if (attr.Get(&value)) - propertyName = std::move(value); - } - } - - if (auto filePathAttr = - fieldPrim.GetAttribute(pxr::TfToken("filePath"))) { - std::vector sampleTimes; - filePathAttr.GetTimeSamples(&sampleTimes); - - auto appendPath = [&](const pxr::SdfAssetPath &assetPath) { - auto path = assetPath.GetResolvedPath(); - if (path.empty()) - path = assetPath.GetAssetPath(); - if (!path.empty()) - filePaths.push_back(std::move(path)); - }; - - if (!sampleTimes.empty()) { - for (double t : sampleTimes) { - pxr::SdfAssetPath assetPath; - if (filePathAttr.Get(&assetPath, t)) - appendPath(assetPath); - } - } else { - pxr::SdfAssetPath assetPath; - if (filePathAttr.Get(&assetPath)) - appendPath(assetPath); - } - } - } - } - } - - if (filePaths.empty()) - return false; - - const auto &filePath = filePaths.front(); - - SpatialFieldRef field; - if (isVtuAsset) { - field = import_spatial_field( - *ctx.scene, filePath.c_str(), std::move(propertyName)); - } else { - const auto extension = extensionOf(filePath); - if (extension == ".raw") - field = import_RAW(*ctx.scene, filePath.c_str()); - else if (extension == ".flash") - field = import_FLASH(*ctx.scene, filePath.c_str()); - else if (extension == ".nvdb" || extension == ".vdb") - field = import_NVDB(*ctx.scene, filePath.c_str()); - else if (extension == ".mhd") - field = import_MHD(*ctx.scene, filePath.c_str()); - else if (extension == ".vtu") - field = import_VTU(*ctx.scene, filePath.c_str(), propertyName); - } - - if (!field) - return false; - - const auto tf = getVolumeTransferFunction(prim); - auto valueRange = field->computeValueRange(); - - auto [volumeNode, volume] = ctx.scene->insertNewChildObjectNode( - node, tokens::volume::transferFunction1D); - volume->setName(primName.c_str()); - volume->setParameterObject("value", *field); - - bool appliedTransferFunction = false; - if (tf.hasTransferFunction && !tf.colors.empty()) { - auto coreTF = toTransferFunction(tf); - if (!coreTF.colorPoints.empty() && !coreTF.opacityPoints.empty()) { - applyTransferFunction(*ctx.scene, volume, coreTF); - if (coreTF.range.lower < coreTF.range.upper) - valueRange = math::float2(coreTF.range.lower, coreTF.range.upper); - appliedTransferFunction = true; - } - } - - if (!appliedTransferFunction) { - auto colors = makeDefaultColorMap(256); - auto colorArray = ctx.scene->createArray(ANARI_FLOAT32_VEC4, colors.size()); - colorArray->setData(colors); - volume->setParameterObject("color", *colorArray); - volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); - } - - if (auto attr = prim.GetAttribute(pxr::TfToken("anari:valueRange"))) { - pxr::GfVec2f customRange; - if (attr.Get(&customRange)) { - valueRange = math::float2(customRange[0], customRange[1]); - volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); - } - } - - float unitDistance = tf.unitDistance; - if (unitDistance <= 0.0f) { - if (auto attr = prim.GetAttribute(pxr::TfToken("anari:unitDistance"))) - attr.Get(&unitDistance); - } - if (unitDistance > 0.0f) - volume->setParameter("unitDistance", unitDistance); - - if (filePaths.size() > 1) { - auto &animation = ctx.animMgr->addAnimation(primName); - animation.emplaceFileBinding( - ctx.scene, volume.data(), field, std::move(filePaths)); - } - - return true; -} - } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h index ebbd8e905..471917e2f 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h @@ -52,11 +52,4 @@ void importDialectPrims(ImportContext &ctx, const std::shared_ptr &claimed, LayerNodeRef importRoot); -bool isVolumePrimType(const pxr::TfToken &primType); - -// Import a UsdVol Volume prim, honouring the `anari:` value-range and -// unit-distance annotations and any transfer function authored on the Stage. -bool convertVolume( - ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node); - } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp new file mode 100644 index 000000000..db98f3fdb --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp @@ -0,0 +1,300 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdVolume.h" +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/core/ColorMapUtil.hpp" +#include "tsd/io/animation/SpatialFieldFileBinding.hpp" +#include "tsd/io/importers.hpp" +// usd +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +// Transfer functions authored on a Stage //////////////////////////////////// + +struct VolumeTransferFunction +{ + std::vector colors; + std::vector xPointsColor; + std::vector xPoints; + std::vector opacityValues; + math::float2 domain{0.0f, 1.0f}; + float unitDistance{0.0f}; + bool hasTransferFunction{false}; +}; + +bool extractColormapFromPrim( + const pxr::UsdPrim &prim, VolumeTransferFunction &tf) +{ + auto rgbaAttr = prim.GetAttribute(pxr::TfToken("rgbaPoints")); + if (!rgbaAttr) + return false; + + pxr::VtArray rgbaPoints; + if (!rgbaAttr.Get(&rgbaPoints) || rgbaPoints.empty()) + return false; + + tf.colors.resize(rgbaPoints.size()); + for (size_t i = 0; i < rgbaPoints.size(); ++i) { + const auto &c = rgbaPoints[i]; + tf.colors[i] = math::float4(c[0], c[1], c[2], c[3]); + } + + auto readFloatArray = [&](const char *name, std::vector &out) { + if (auto attr = prim.GetAttribute(pxr::TfToken(name))) { + pxr::VtArray values; + if (attr.Get(&values)) + out.assign(values.begin(), values.end()); + } + }; + + readFloatArray("xPointsColor", tf.xPointsColor); + readFloatArray("xPoints", tf.xPoints); + readFloatArray("opacityValues", tf.opacityValues); + + if (auto attr = prim.GetAttribute(pxr::TfToken("domain"))) { + pxr::GfVec2f domain; + if (attr.Get(&domain)) + tf.domain = math::float2(domain[0], domain[1]); + } + + if (auto attr = prim.GetAttribute(pxr::TfToken("unitDistance"))) { + float unitDistance = 0.f; + if (attr.Get(&unitDistance) && unitDistance > 0.0f) + tf.unitDistance = unitDistance; + } + + tf.hasTransferFunction = true; + return true; +} + +core::TransferFunction toTransferFunction(const VolumeTransferFunction &vtf) +{ + core::TransferFunction tf; + tf.range = {vtf.domain.x, vtf.domain.y}; + + const auto &xColor = + vtf.xPointsColor.empty() ? vtf.xPoints : vtf.xPointsColor; + for (size_t i = 0; i < vtf.colors.size() && i < xColor.size(); ++i) { + tf.colorPoints.emplace_back( + xColor[i], vtf.colors[i].x, vtf.colors[i].y, vtf.colors[i].z); + } + + if (!vtf.opacityValues.empty()) { + for (size_t i = 0; i < vtf.opacityValues.size() && i < vtf.xPoints.size(); + ++i) + tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.opacityValues[i]); + } else { + for (size_t i = 0; i < vtf.colors.size() && i < vtf.xPoints.size(); ++i) + tf.opacityPoints.emplace_back(vtf.xPoints[i], vtf.colors[i].w); + } + + return tf; +} + +VolumeTransferFunction getVolumeTransferFunction(const pxr::UsdPrim &prim) +{ + VolumeTransferFunction tf; + + // Material binding chain: Material -> VolumeShader -> Colormap. + if (pxr::UsdShadeMaterialBindingAPI::CanApply(prim)) { + pxr::UsdShadeMaterialBindingAPI binding(prim); + pxr::UsdShadeMaterial usdMaterial; + + if (auto materialRel = + prim.GetRelationship(pxr::TfToken("material:binding"))) { + pxr::SdfPathVector targets; + materialRel.GetTargets(&targets); + if (!targets.empty()) { + if (auto materialPrim = prim.GetStage()->GetPrimAtPath(targets[0])) + usdMaterial = pxr::UsdShadeMaterial(materialPrim); + } + } + + if (!usdMaterial && binding) + usdMaterial = binding.ComputeBoundMaterial(); + + if (usdMaterial) { + auto volumeOutput = usdMaterial.GetOutput(pxr::TfToken("nvindex:volume")); + if (volumeOutput && volumeOutput.HasConnectedSource()) { + pxr::UsdShadeConnectableAPI source; + pxr::TfToken sourceName; + pxr::UsdShadeAttributeType sourceType; + volumeOutput.GetConnectedSource(&source, &sourceName, &sourceType); + pxr::UsdShadeShader volumeShader(source.GetPrim()); + + if (volumeShader) { + auto colormapInput = volumeShader.GetInput(pxr::TfToken("colormap")); + if (colormapInput && colormapInput.HasConnectedSource()) { + pxr::UsdShadeConnectableAPI colormapSource; + pxr::TfToken colormapSourceName; + pxr::UsdShadeAttributeType colormapSourceType; + if (colormapInput.GetConnectedSource(&colormapSource, + &colormapSourceName, + &colormapSourceType)) { + if (auto colormapPrim = colormapSource.GetPrim(); + colormapPrim && extractColormapFromPrim(colormapPrim, tf)) + return tf; + } + } + } + } + } + } + + // Child Shader prim carrying colormap attributes directly. + for (const auto &child : prim.GetChildren()) { + if (!child.IsA()) + continue; + if (extractColormapFromPrim(child, tf)) + return tf; + } + + return tf; +} + +} // namespace + +bool isVolumePrimType(const pxr::TfToken &primType) +{ + return primType == pxr::HdPrimTypeTokens->volume; +} + +bool convertVolume( + ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node) +{ + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim) + return false; + + const auto primName = primPath.GetString(); + + std::vector filePaths; + std::optional propertyName; + + auto fieldRel = prim.GetRelationship(pxr::TfToken("field:volume")); + if (!fieldRel) + fieldRel = prim.GetRelationship(pxr::TfToken("field:density")); + + if (fieldRel) { + pxr::SdfPathVector targets; + fieldRel.GetTargets(&targets); + if (!targets.empty()) { + if (auto fieldPrim = ctx.stage->GetPrimAtPath(targets[0])) { + // Only an unstructured field names the property to read out of the + // file; every other spatial field format has a single field per file. + if (fieldPrim.GetTypeName() == "VTUAsset") { + if (auto attr = fieldPrim.GetAttribute(pxr::TfToken("property"))) { + std::string value; + if (attr.Get(&value)) + propertyName = std::move(value); + } + } + + if (auto filePathAttr = + fieldPrim.GetAttribute(pxr::TfToken("filePath"))) { + std::vector sampleTimes; + filePathAttr.GetTimeSamples(&sampleTimes); + + auto appendPath = [&](const pxr::SdfAssetPath &assetPath) { + auto path = assetPath.GetResolvedPath(); + if (path.empty()) + path = assetPath.GetAssetPath(); + if (!path.empty()) + filePaths.push_back(std::move(path)); + }; + + if (!sampleTimes.empty()) { + for (double t : sampleTimes) { + pxr::SdfAssetPath assetPath; + if (filePathAttr.Get(&assetPath, t)) + appendPath(assetPath); + } + } else { + pxr::SdfAssetPath assetPath; + if (filePathAttr.Get(&assetPath)) + appendPath(assetPath); + } + } + } + } + } + + if (filePaths.empty()) + return false; + + const auto &filePath = filePaths.front(); + + auto field = import_spatial_field( + *ctx.scene, filePath.c_str(), std::move(propertyName)); + if (!field) + return false; + + const auto tf = getVolumeTransferFunction(prim); + auto valueRange = field->computeValueRange(); + + auto [volumeNode, volume] = ctx.scene->insertNewChildObjectNode( + node, tokens::volume::transferFunction1D); + volume->setName(primName.c_str()); + volume->setParameterObject("value", *field); + + bool appliedTransferFunction = false; + if (tf.hasTransferFunction && !tf.colors.empty()) { + auto coreTF = toTransferFunction(tf); + if (!coreTF.colorPoints.empty() && !coreTF.opacityPoints.empty()) { + applyTransferFunction(*ctx.scene, volume, coreTF); + if (coreTF.range.lower < coreTF.range.upper) + valueRange = math::float2(coreTF.range.lower, coreTF.range.upper); + appliedTransferFunction = true; + } + } + + if (!appliedTransferFunction) { + auto colors = makeDefaultColorMap(256); + auto colorArray = ctx.scene->createArray(ANARI_FLOAT32_VEC4, colors.size()); + colorArray->setData(colors); + volume->setParameterObject("color", *colorArray); + volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); + } + + if (auto attr = prim.GetAttribute(pxr::TfToken("anari:valueRange"))) { + pxr::GfVec2f customRange; + if (attr.Get(&customRange)) { + valueRange = math::float2(customRange[0], customRange[1]); + volume->setParameter("valueRange", ANARI_FLOAT32_BOX1, &valueRange); + } + } + + float unitDistance = tf.unitDistance; + if (unitDistance <= 0.0f) { + if (auto attr = prim.GetAttribute(pxr::TfToken("anari:unitDistance"))) + attr.Get(&unitDistance); + } + if (unitDistance > 0.0f) + volume->setParameter("unitDistance", unitDistance); + + if (filePaths.size() > 1) { + auto &animation = ctx.animMgr->addAnimation(primName); + animation.emplaceFileBinding( + ctx.scene, volume.data(), field, std::move(filePaths)); + } + + return true; +} + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h new file mode 100644 index 000000000..faea70722 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h @@ -0,0 +1,21 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +#include + +namespace tsd::io::usd { + +// Whether the resolved scene's prim type is a UsdVol Volume. +bool isVolumePrimType(const pxr::TfToken &primType); + +// Import a UsdVol Volume prim, honouring the `anari:` value-range and +// unit-distance annotations and any transfer function authored on the Stage. +bool convertVolume( + ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index cc8ceb963..e615764d9 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -12,6 +12,7 @@ #include "tsd/io/importers/detail/usd/UsdImportContext.h" #include "tsd/io/importers/detail/usd/UsdInstancing.h" #include "tsd/io/importers/detail/usd/UsdLights.h" +#include "tsd/io/importers/detail/usd/UsdVolume.h" #include "tsd/io/usd/UsdStageSession.h" // usd #include From cfeb64f1aa73bf847c3c8abff7dc12e9bfd769e6 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:31:25 -0500 Subject: [PATCH 45/78] refactor(io): teach the spatial field dispatcher '.vdb' '.vdb' is the one extension the bespoke chain the previous commit deleted could read that import_spatial_field could not. Teach the dispatcher that name rather than keep a second chain to hold a single extension: tsdVolumeToNanoVDB documents its output as '.vdb' and writes NanoVDB, so both names are the same reader. This closes the window the previous commit opened, where a Stage naming a '.vdb' field silently lost its volume. Covers the moved conversion with a UsdVol suite, since the USD import tests had none. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/importers.hpp | 2 +- tsd/src/tsd/io/importers/import_volume.cpp | 4 +- tsd/tests/CMakeLists.txt | 1 + tsd/tests/test_Importers.cpp | 45 ++++ tsd/tests/test_UsdImport_volumes.cpp | 251 +++++++++++++++++++++ 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 tsd/tests/test_UsdImport_volumes.cpp diff --git a/tsd/src/tsd/io/importers.hpp b/tsd/src/tsd/io/importers.hpp index 943b30f67..300ca50da 100644 --- a/tsd/src/tsd/io/importers.hpp +++ b/tsd/src/tsd/io/importers.hpp @@ -93,7 +93,7 @@ void import_XYZDP(Scene &scene, tsd::animation::AnimationManager &animMgr, const // Spatial field importers // // Dispatch to the appropriate spatial field importer based on file extension. -// Supports: .raw, .flash/.hdf5, .nvdb, .mhd, .vtu, .silo/.sil +// Supports: .raw, .flash/.hdf5, .nvdb/.vdb, .mhd, .vtu, .silo/.sil // Note: .vti is not supported here; use import_volume() for VTI files. SpatialFieldRef import_spatial_field(Scene &scene, const char *filename, std::optional propertyName = std::nullopt); diff --git a/tsd/src/tsd/io/importers/import_volume.cpp b/tsd/src/tsd/io/importers/import_volume.cpp index 30291e689..01fce442d 100644 --- a/tsd/src/tsd/io/importers/import_volume.cpp +++ b/tsd/src/tsd/io/importers/import_volume.cpp @@ -80,7 +80,9 @@ SpatialFieldRef import_spatial_field( return import_RAW(scene, file.c_str()); else if (ext == ".flash" || ext == ".hdf5") return import_FLASH(scene, file.c_str()); - else if (ext == ".nvdb") + // '.vdb' is NanoVDB here, not OpenVDB: tsdVolumeToNanoVDB writes NanoVDB + // grids under that extension, so both names reach the same reader. + else if (ext == ".nvdb" || ext == ".vdb") return import_NVDB(scene, file.c_str()); else if (ext == ".mhd") return import_MHD(scene, file.c_str()); diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 65c5d21ac..95a55610e 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -44,6 +44,7 @@ project_add_executable( test_UsdImport_materials.cpp test_UsdImport_materials_portable.cpp test_UsdImport_subsets.cpp + test_UsdImport_volumes.cpp ) if (TARGET tsd_scivis_studio_model) target_sources(${PROJECT_NAME} PRIVATE diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index c76c6eeb1..9be9bff67 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -6,6 +6,7 @@ // tsd #include "tsd/animation/AnimationManager.hpp" #include "tsd/core/TSDMath.hpp" +#include "tsd/io/exporters.hpp" #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/importer_common.hpp" #include "tsd/scene/Scene.hpp" @@ -68,6 +69,50 @@ SCENARIO( std::filesystem::remove(path); } +SCENARIO("The spatial field dispatcher reads NanoVDB under both of its names", + "[Importers]") +{ + // tsdVolumeToNanoVDB documents its output as '.vdb', so a NanoVDB grid + // reaches TSD under that name as often as under '.nvdb', and both have to + // find the same reader. + const auto rawPath = + std::filesystem::temp_directory_path() / "tsd_test_2x2x2_uint8.raw"; + { + std::ofstream file(rawPath, std::ios::binary); + const unsigned char voxels[8] = {0, 32, 64, 96, 128, 160, 192, 255}; + file.write(reinterpret_cast(voxels), sizeof(voxels)); + } + + const auto vdbPath = + std::filesystem::temp_directory_path() / "tsd_test_roundtrip.vdb"; + + tsd::scene::Scene scene; + + GIVEN("A NanoVDB grid written out under a '.vdb' name") + { + auto source = + tsd::io::import_spatial_field(scene, rawPath.string().c_str()); + REQUIRE(source); + tsd::io::export_StructuredVolumeToNanoVDB(source.data(), vdbPath.string()); + REQUIRE(std::filesystem::exists(vdbPath)); + + WHEN("The '.vdb' file is dispatched") + { + auto field = + tsd::io::import_spatial_field(scene, vdbPath.string().c_str()); + + THEN("The NanoVDB reader loads it") + { + REQUIRE(field); + REQUIRE(field->subtype() == tsd::scene::tokens::spatial_field::nanovdb); + } + } + } + + std::filesystem::remove(rawPath); + std::filesystem::remove(vdbPath); +} + namespace { // A 1x1 uncompressed grey+alpha 8-bit TIFF. Two channels is the case where diff --git a/tsd/tests/test_UsdImport_volumes.cpp b/tsd/tests/test_UsdImport_volumes.cpp new file mode 100644 index 000000000..7fa8502b8 --- /dev/null +++ b/tsd/tests/test_UsdImport_volumes.cpp @@ -0,0 +1,251 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// UsdVol Volume prims and the spatial fields they reference. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// tsd_tests +#include "UsdTestFixtures.h" +// tsd +#include "tsd/core/Logging.hpp" +#include "tsd/scene/objects/Array.hpp" +#include "tsd/scene/objects/Volume.hpp" +// std +#include +#include +#include + +namespace { + +// `valueRange` is held as an ANARI_FLOAT32_BOX1, which does not round-trip +// through Object::parameterValueAs<>() -- that asks the Any for its C++ type +// alone and a box1 is a float2 by another name. +inline tsd::math::float2 valueRangeOf(tsd::scene::Volume *volume) +{ + auto *parameter = volume->parameter("valueRange"); + REQUIRE(parameter != nullptr); + return parameter->value().getAs(ANARI_FLOAT32_BOX1); +} + +// A field file next to the Stage, so a `filePath` asset reference on a field +// prim resolves the way it does in a real asset. The name carries the volume's +// dimensions and voxel type, which is how import_RAW learns its layout. +struct RawFieldFixture +{ + explicit RawFieldFixture(const char *name) : m_path(fixtureDirectory() / name) + { + const unsigned char voxels[8] = {0, 32, 64, 96, 128, 160, 192, 255}; + std::ofstream file(m_path, std::ios::binary); + file.write(reinterpret_cast(voxels), sizeof(voxels)); + } + + ~RawFieldFixture() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + private: + std::filesystem::path m_path; +}; + +// Collects log messages for the lifetime of one scenario. Which importer a +// file extension reaches is otherwise invisible when the file named is a +// stand-in that no importer can actually read. +struct LogCapture +{ + LogCapture() + { + tsd::core::setLoggingCallback( + [this](tsd::core::LogLevel, std::string message) { + messages.push_back(std::move(message)); + }); + } + + ~LogCapture() + { + // No callback is the state the test binary starts in. + tsd::core::setNoLogging(); + } + + bool sawMessageContaining(const char *text) const + { + for (const auto &message : messages) { + if (message.find(text) != std::string::npos) + return true; + } + return false; + } + + std::vector messages; +}; + +} // namespace + +SCENARIO("A UsdVol Volume imports the field it references", "[UsdImport]") +{ + GIVEN("A Volume prim whose field names a RAW file next to the Stage") + { + RawFieldFixture field("tsd_test_usd_field_2x2x2_uint8.raw"); + ImportedStage stage("tsd_test_usd_volume.usda", R"(#usda 1.0 + +def Volume "Vol" +{ + rel field:density = + + def "Density" + { + asset filePath = @tsd_test_usd_field_2x2x2_uint8.raw@ + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The volume carries the field and a default color map") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_VOLUME) == 1); + auto volume = + findObject(stage.scene, ANARI_VOLUME, "/Vol"); + REQUIRE(volume); + REQUIRE( + volume->parameterValueAsObject("value") + != nullptr); + auto *color = + volume->parameterValueAsObject("color"); + REQUIRE(color != nullptr); + REQUIRE(color->size() == 256); + } + } + } +} + +SCENARIO("The anari: volume annotations override the field's own range", + "[UsdImport]") +{ + GIVEN("A Volume prim annotated with a value range and unit distance") + { + RawFieldFixture field("tsd_test_usd_annotated_2x2x2_uint8.raw"); + ImportedStage stage("tsd_test_usd_volume_annotated.usda", R"(#usda 1.0 + +def Volume "Vol" +{ + float2 anari:valueRange = (3, 7) + float anari:unitDistance = 2.5 + rel field:density = + + def "Density" + { + asset filePath = @tsd_test_usd_annotated_2x2x2_uint8.raw@ + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("Both annotations reach the volume") + { + auto volume = + findObject(stage.scene, ANARI_VOLUME, "/Vol"); + REQUIRE(volume); + + const auto range = valueRangeOf(volume.data()); + REQUIRE(range.x == Approx(3.f)); + REQUIRE(range.y == Approx(7.f)); + + const auto unitDistance = + volume->parameterValueAs("unitDistance"); + REQUIRE(unitDistance.has_value()); + REQUIRE(*unitDistance == Approx(2.5f)); + } + } + } +} + +SCENARIO("A colormap authored on a Volume becomes its transfer function", + "[UsdImport]") +{ + GIVEN("A Volume prim with a child Shader carrying colormap points") + { + RawFieldFixture field("tsd_test_usd_colormapped_2x2x2_uint8.raw"); + ImportedStage stage("tsd_test_usd_volume_colormap.usda", R"(#usda 1.0 + +def Volume "Vol" +{ + rel field:density = + + def "Density" + { + asset filePath = @tsd_test_usd_colormapped_2x2x2_uint8.raw@ + } + + def Shader "Colormap" + { + float4[] rgbaPoints = [(1, 0, 0, 0), (0, 0, 1, 1)] + float[] xPoints = [0, 1] + float2 domain = (10, 20) + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The colormap's colors and domain reach the volume") + { + auto volume = + findObject(stage.scene, ANARI_VOLUME, "/Vol"); + REQUIRE(volume); + + auto *color = + volume->parameterValueAsObject("color"); + REQUIRE(color != nullptr); + REQUIRE(color->size() == 256); + const auto *texels = color->dataAs(); + REQUIRE(texels[0].x == Approx(1.f)); + REQUIRE(texels[0].w == Approx(0.f)); + REQUIRE(texels[255].z == Approx(1.f)); + REQUIRE(texels[255].w == Approx(1.f)); + + const auto range = valueRangeOf(volume.data()); + REQUIRE(range.x == Approx(10.f)); + REQUIRE(range.y == Approx(20.f)); + } + } + } +} + +SCENARIO("Volume fields go through the shared spatial-field dispatcher", + "[UsdImport]") +{ + GIVEN("A Volume prim whose field names a Silo file") + { + LogCapture log; + ImportedStage stage("tsd_test_usd_volume_silo.usda", R"(#usda 1.0 + +def Volume "Vol" +{ + rel field:density = + + def "Density" + { + asset filePath = @tsd_test_usd_field.silo@ + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The Silo importer is the one asked for the field") + { + // The extension chain the USD importer used to hand-roll knew nothing + // of Silo, so this file never reached an importer at all. + REQUIRE(log.sawMessageContaining("[import_SILO]")); + } + } + } +} + +#endif // TSD_USE_USD From 473bdba2fcc184cf0efeb0d37e628a3f2ceb805a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:38:37 -0500 Subject: [PATCH 46/78] fix(io): address code review of the USD volume split UsdVolume.cpp reached pxr::VtArray only transitively through the usdShade headers -- the include that used to supply it, vt/dictionary.h, correctly stayed behind in UsdDialect.cpp. Include vt/array.h directly. Also date the new file 2026, matching its own header and every other file under detail/usd, and drop a redundant `inline` on a helper that already has internal linkage. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp | 3 ++- tsd/tests/test_UsdImport_volumes.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp index db98f3fdb..b613c42e2 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp @@ -1,4 +1,4 @@ -// Copyright 2024-2026 NVIDIA Corporation +// Copyright 2026 NVIDIA Corporation // SPDX-License-Identifier: Apache-2.0 #include "tsd/io/importers/detail/usd/UsdVolume.h" @@ -9,6 +9,7 @@ // usd #include #include +#include #include #include #include diff --git a/tsd/tests/test_UsdImport_volumes.cpp b/tsd/tests/test_UsdImport_volumes.cpp index 7fa8502b8..f25c5b3f4 100644 --- a/tsd/tests/test_UsdImport_volumes.cpp +++ b/tsd/tests/test_UsdImport_volumes.cpp @@ -23,7 +23,7 @@ namespace { // `valueRange` is held as an ANARI_FLOAT32_BOX1, which does not round-trip // through Object::parameterValueAs<>() -- that asks the Any for its C++ type // alone and a box1 is a float2 by another name. -inline tsd::math::float2 valueRangeOf(tsd::scene::Volume *volume) +tsd::math::float2 valueRangeOf(tsd::scene::Volume *volume) { auto *parameter = volume->parameter("valueRange"); REQUIRE(parameter != nullptr); From 95d53ed824efce1ae1372b787e06affc09506e6b Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:50:54 -0500 Subject: [PATCH 47/78] fix(io): convert the materials EnSight carrier prims bind boundMaterialOf() claimed to go through the material converter but only read ctx.materialCache, which a carrier prim's binding can never be in: the carrier subtree is a Claimed Prim, so the traversal skips it and nothing ever asks resolveMaterial() to convert what it binds. Both call sites therefore handed import_ENSIGHT a null fallback material and an empty per-part map, and every part fell back to the Scene's default material. Call resolveMaterial() for real, threading the scene index from import_USD() through importDialectPrims() and importEnsightDataset(). The binding is still computed on the Stage, since the carrier prim is never visited in the resolved scene. The Material prim it names resolves whether or not it is itself claimed: a claim only suppresses traversal, and resolveMaterial() reads the resolved scene by path without ever consulting it. Cover it with an EnSight-carrier scenario built on a two-part binary EnSight Gold fixture, asserting both the per-part binding and the carrier-level fallback land on their parts. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/importers/detail/usd/UsdDialect.cpp | 24 +-- .../tsd/io/importers/detail/usd/UsdDialect.h | 1 + tsd/src/tsd/io/importers/import_USD.cpp | 2 +- tsd/tests/test_UsdImport_animation.cpp | 163 ++++++++++++++++++ 4 files changed, 179 insertions(+), 11 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index 772d987b3..d5223372e 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -80,7 +80,14 @@ std::string ensightCaseFileOf(const pxr::UsdPrim &scopePrim) // Resolve a material bound on a raw Stage prim, going through the same // converter the resolved path uses so EnSight parts share materials with the // rest of the import. -MaterialRef boundMaterialOf(ImportContext &ctx, const pxr::UsdPrim &prim) +// +// The binding is computed on the Stage rather than read from the resolved +// scene because a carrier prim is a Claimed Prim: the traversal never visits +// it, so nothing has asked for its material. The Material prim it names is not +// itself claimed and resolves out of the resolved scene like any other. +MaterialRef boundMaterialOf(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::UsdPrim &prim) { pxr::UsdShadeMaterialBindingAPI binding(prim); if (!binding) @@ -88,15 +95,11 @@ MaterialRef boundMaterialOf(ImportContext &ctx, const pxr::UsdPrim &prim) auto usdMaterial = binding.ComputeBoundMaterial(); if (!usdMaterial) return {}; - - const auto key = usdMaterial.GetPath().GetString(); - if (auto found = ctx.materialCache.find(key); - found != ctx.materialCache.end()) - return found->second.material; - return {}; + return resolveMaterial(ctx, sceneIndex, usdMaterial.GetPath()).material; } void importEnsightDataset(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::UsdPrim &scopePrim, LayerNodeRef parent, const core::DataNode &settings) @@ -139,10 +142,10 @@ void importEnsightDataset(ImportContext &ctx, } } - auto fallbackMaterial = boundMaterialOf(ctx, scopePrim); + auto fallbackMaterial = boundMaterialOf(ctx, sceneIndex, scopePrim); core::FlatMap perPartMaterials; for (const auto &child : scopePrim.GetChildren()) { - auto childMaterial = boundMaterialOf(ctx, child); + auto childMaterial = boundMaterialOf(ctx, sceneIndex, child); if (childMaterial && childMaterial != fallbackMaterial) perPartMaterials[child.GetName().GetString()] = childMaterial; } @@ -191,6 +194,7 @@ bool ClaimedPrims::claims(const pxr::SdfPath &path) const } void importDialectPrims(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const std::shared_ptr &claimed, LayerNodeRef importRoot) { @@ -204,7 +208,7 @@ void importDialectPrims(ImportContext &ctx, switch (entry.kind) { case ClaimedPrims::Kind::ENSIGHT_DATASET: importEnsightDataset( - ctx, prim, importRoot, claimed->renderSettings.root()); + ctx, sceneIndex, prim, importRoot, claimed->renderSettings.root()); break; } } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h index 471917e2f..d0b45d77b 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h @@ -49,6 +49,7 @@ std::shared_ptr claimDialectPrims(ImportContext &ctx); // Route Claimed Prims to the dialect's own importers. void importDialectPrims(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const std::shared_ptr &claimed, LayerNodeRef importRoot); diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index e615764d9..26b3b46e5 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -345,7 +345,7 @@ UsdImportReport import_USD(Scene &scene, attachNativeInstances(ctx, sceneIndex, instancers, root); // Dialect content is routed to the handlers that already know these formats. - importDialectPrims(ctx, claimed, root); + importDialectPrims(ctx, sceneIndex, claimed, root); scene.endLayerEditBatch(); logStatus("[import_USD] %s: %s", filepath, report.summary().c_str()); diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp index fbb8ff36e..61c32eac8 100644 --- a/tsd/tests/test_UsdImport_animation.cpp +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -13,6 +13,10 @@ #include "tsd/core/DataTree.hpp" #include "tsd/io/animation/UsdGeometryFileBinding.hpp" #include "tsd/io/archives/AnimationManagerArchive.hpp" +// std +#include +#include +#include SCENARIO("Animation is captured at the times actually authored", "[UsdImport]") { @@ -250,6 +254,165 @@ def Mesh "Real" } } +// A minimal EnSight Gold dataset -- two single-triangle parts -- written into +// the shared fixture directory in the binary geometry format import_ENSIGHT +// accepts. The files live for the lifetime of the test binary along with every +// other fixture in that directory. +inline std::string writeEnSightDataset(const char *baseName) +{ + const auto geoName = std::string(baseName) + ".geo"; + const auto casePath = fixtureDirectory() / (std::string(baseName) + ".case"); + const auto geoPath = fixtureDirectory() / geoName; + + { + std::ofstream caseFile(casePath); + caseFile << "FORMAT\n" + << "type: ensight gold\n" + << "GEOMETRY\n" + << "model: " << geoName << "\n"; + } + + std::ofstream geo(geoPath, std::ios::binary); + auto record = [&](const char *text) { + char buffer[80] = {}; + std::strncpy(buffer, text, sizeof(buffer) - 1); + geo.write(buffer, sizeof(buffer)); + }; + auto integer = [&](int32_t value) { + geo.write(reinterpret_cast(&value), sizeof(value)); + }; + auto triangle = [&](int32_t id, const char *description) { + record("part"); + integer(id); + record(description); + record("coordinates"); + integer(3); + const float coordinates[9] = { + 0.f, 1.f, 0.f, // x + 0.f, 0.f, 1.f, // y + 0.f, 0.f, 0.f}; // z + geo.write(reinterpret_cast(coordinates), sizeof(coordinates)); + record("tria3"); + integer(1); + integer(1); + integer(2); + integer(3); + }; + + record("C Binary"); + record("TSD test dataset"); + record("two single-triangle parts"); + record("node id off"); + record("element id off"); + triangle(1, "part_one"); + triangle(2, "part_two"); + + return casePath.string(); +} + +SCENARIO("EnSight parts take the materials their carrier prims bind", + "[UsdImport]") +{ + GIVEN("A carrier scope and one of its parts each binding a material") + { + // Claimed Prims never reach the resolved traversal, so the materials they + // bind only convert if the dialect importer asks for them itself. + const auto caseFile = writeEnSightDataset("tsd_test_ensight_materials"); + ImportedStage stage("tsd_test_usd_ensight_materials.usda", + R"(#usda 1.0 +( + customLayerData = { + dictionary ensight = { + string caseFile = ")" + + caseFile + R"(" + } + } +) + +def Scope "Looks" +{ + def Material "Shared" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (1, 0, 0) + token outputs:surface + } + } + + def Material "PartOne" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0, 1, 0) + token outputs:surface + } + } +} + +def Scope "Dataset" ( + prepend apiSchemas = ["MaterialBindingAPI"] +) +{ + rel material:binding = + + def Mesh "part_one" ( + prepend apiSchemas = ["MaterialBindingAPI"] + customData = { + dictionary ensight = { + string partName = "part_one" + } + } + ) + { + rel material:binding = +)" + std::string(QUAD_MESH_BODY) + R"( } + + def Mesh "part_two" ( + customData = { + dictionary ensight = { + string partName = "part_two" + } + } + ) + { +)" + std::string(QUAD_MESH_BODY) + R"( } +} +)"); + + // Materials are named for the prim path they came from, so the name says + // which Material prim the part ended up bound to. + auto materialNameOfPart = [&](const char *partName) { + auto surface = findObject( + stage.scene, ANARI_SURFACE, partName); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + return material->name(); + }; + + WHEN("The Stage is imported") + { + THEN("The part with its own binding gets that material") + { + REQUIRE(materialNameOfPart("part_one") == "/Looks/PartOne"); + } + + THEN("The part without one falls back to the carrier's material") + { + REQUIRE(materialNameOfPart("part_two") == "/Looks/Shared"); + } + } + } +} + SCENARIO("Constant-valued time samples are not reported as a loss", "[UsdImport]") { From 586cb41099ab1f0a98c47d2987fd390d162f739e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:52:56 -0500 Subject: [PATCH 48/78] docs(io): drop the restated "what" above boundMaterialOf The comment opened by naming what the function does before getting to why it reads the binding off the raw Stage. Keep only the why, and fold the sharing rationale into it. --- tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index d5223372e..39ce90fba 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -77,14 +77,12 @@ std::string ensightCaseFileOf(const pxr::UsdPrim &scopePrim) return {}; } -// Resolve a material bound on a raw Stage prim, going through the same -// converter the resolved path uses so EnSight parts share materials with the -// rest of the import. -// -// The binding is computed on the Stage rather than read from the resolved +// The binding is computed on the raw Stage rather than read from the resolved // scene because a carrier prim is a Claimed Prim: the traversal never visits // it, so nothing has asked for its material. The Material prim it names is not -// itself claimed and resolves out of the resolved scene like any other. +// itself claimed, so it resolves through the same converter every other +// binding goes through -- which is what lets EnSight parts share materials +// with the rest of the import. MaterialRef boundMaterialOf(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::UsdPrim &prim) From 3a559989ac9994a4933e085d2d7001aad1a83dca Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 21:54:16 -0500 Subject: [PATCH 49/78] fix(io): correct why a Claimed carrier's material is still reachable The comment asserted the Material prim a carrier binds "is not itself claimed", which ClaimedPrims::claims() does not guarantee: the test is prefix-based, so a Material authored inside the carrier subtree is claimed along with it. That is not why the lookup works. A claim only suppresses traversal; resolveMaterial() reads the resolved scene by path and never asks isClaimed(), so a claimed Material converts just the same. Say that instead. Also name the EnSight fixture's lambda for the part record it writes rather than for the one element inside it, and drop the `inline` on writeEnSightDataset, which says nothing about a function local to a .cpp. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp | 9 +++++---- tsd/tests/test_UsdImport_animation.cpp | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp index 39ce90fba..36fdaf5da 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -79,10 +79,11 @@ std::string ensightCaseFileOf(const pxr::UsdPrim &scopePrim) // The binding is computed on the raw Stage rather than read from the resolved // scene because a carrier prim is a Claimed Prim: the traversal never visits -// it, so nothing has asked for its material. The Material prim it names is not -// itself claimed, so it resolves through the same converter every other -// binding goes through -- which is what lets EnSight parts share materials -// with the rest of the import. +// it, so nothing has asked for its material. Being claimed does not put the +// Material prim out of reach, though -- a claim only suppresses traversal, +// while resolveMaterial() reads the resolved scene by path -- so the material +// converts through the same converter every other binding goes through, which +// is what lets EnSight parts share materials with the rest of the import. MaterialRef boundMaterialOf(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::UsdPrim &prim) diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp index 61c32eac8..90fdc7d70 100644 --- a/tsd/tests/test_UsdImport_animation.cpp +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -258,7 +258,7 @@ def Mesh "Real" // the shared fixture directory in the binary geometry format import_ENSIGHT // accepts. The files live for the lifetime of the test binary along with every // other fixture in that directory. -inline std::string writeEnSightDataset(const char *baseName) +std::string writeEnSightDataset(const char *baseName) { const auto geoName = std::string(baseName) + ".geo"; const auto casePath = fixtureDirectory() / (std::string(baseName) + ".case"); @@ -281,7 +281,7 @@ inline std::string writeEnSightDataset(const char *baseName) auto integer = [&](int32_t value) { geo.write(reinterpret_cast(&value), sizeof(value)); }; - auto triangle = [&](int32_t id, const char *description) { + auto singleTrianglePart = [&](int32_t id, const char *description) { record("part"); integer(id); record(description); @@ -304,8 +304,8 @@ inline std::string writeEnSightDataset(const char *baseName) record("two single-triangle parts"); record("node id off"); record("element id off"); - triangle(1, "part_one"); - triangle(2, "part_two"); + singleTrianglePart(1, "part_one"); + singleTrianglePart(2, "part_two"); return casePath.string(); } From 9e4c0df38ab1054850eed71b17ee6ec6f1d7e362 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:09:01 -0500 Subject: [PATCH 50/78] refactor(io): report every skipped USD prim from one place The content dispatch in Traversal::visit() ended five branches in some subset of the same four steps -- remove the node, report the skip, insert a Placeholder Node, return -- and the subsets differed. The light branch placed a Placeholder Node but left reporting to convertLight(); the volume branch left it to convertVolume(), which reported nothing at all, so a Volume whose field file failed to load produced a Placeholder Node and no Import Report entry. Fold the four steps into one skipPrim() helper (it absorbs the former insertPlaceholder(), which had no other caller) and give the converters one rule: they signal failure and hand back the detail only they know, while the caller does all the reporting. That closes the volume hole, which now reports under a new FIELD_LOAD_FAILED reason -- a Volume whose field asset is missing is not an unsupported prim type, and the existing TEXTURE_LOAD_FAILED is the same shape of failure. The material/geomSubset branch keeps its own early return: those prims are consumed elsewhere, so neither a report entry nor a Placeholder Node belongs there. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/UsdImport.cpp | 2 + tsd/src/tsd/io/UsdImport.hpp | 1 + .../tsd/io/importers/detail/usd/UsdLights.cpp | 11 +-- .../tsd/io/importers/detail/usd/UsdLights.h | 9 ++- .../tsd/io/importers/detail/usd/UsdVolume.cpp | 18 +++-- .../tsd/io/importers/detail/usd/UsdVolume.h | 11 ++- tsd/src/tsd/io/importers/import_USD.cpp | 77 ++++++++++++------- 7 files changed, 86 insertions(+), 43 deletions(-) diff --git a/tsd/src/tsd/io/UsdImport.cpp b/tsd/src/tsd/io/UsdImport.cpp index 71c9bc18a..47db88865 100644 --- a/tsd/src/tsd/io/UsdImport.cpp +++ b/tsd/src/tsd/io/UsdImport.cpp @@ -96,6 +96,8 @@ const char *toString(UsdSkipReason reason) return "material resolution failed"; case UsdSkipReason::TEXTURE_LOAD_FAILED: return "texture load failed"; + case UsdSkipReason::FIELD_LOAD_FAILED: + return "field load failed"; case UsdSkipReason::UNSUPPORTED_LIGHT_TYPE: return "unsupported light type"; case UsdSkipReason::RICHER_MATERIAL_AVAILABLE: diff --git a/tsd/src/tsd/io/UsdImport.hpp b/tsd/src/tsd/io/UsdImport.hpp index 9f4b30b7d..84e87c6c0 100644 --- a/tsd/src/tsd/io/UsdImport.hpp +++ b/tsd/src/tsd/io/UsdImport.hpp @@ -100,6 +100,7 @@ enum class UsdSkipReason UNSUPPORTED_PRIM_TYPE, MATERIAL_RESOLUTION_FAILED, TEXTURE_LOAD_FAILED, + FIELD_LOAD_FAILED, UNSUPPORTED_LIGHT_TYPE, RICHER_MATERIAL_AVAILABLE, TIME_VARYING_VALUE_DROPPED, diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index d2bab43a4..54c9fc462 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -203,16 +203,15 @@ bool isLightPrimType(const pxr::TfToken &primType) LightRef convertLight(ImportContext &ctx, const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim) + const pxr::HdSceneIndexPrim &prim, + std::string *skipDetail) { // Light radiometry is modelled by UsdLux itself, so it is read from the // retained Stage rather than re-derived from the resolved prim. auto usdPrim = ctx.stage->GetPrimAtPath(primPath); if (!usdPrim) { - ctx.reportSkip(primPath, - prim.primType.GetString(), - UsdSkipReason::UNSUPPORTED_LIGHT_TYPE, - "light has no Stage prim (instanced lights are not supported)"); + *skipDetail = + "light has no Stage prim (instanced lights are not supported)"; return {}; } @@ -319,8 +318,6 @@ LightRef convertLight(ImportContext &ctx, return light; } - ctx.reportSkip( - primPath, type.GetString(), UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); return {}; } diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.h b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h index bd746a80b..254bdfc75 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h @@ -6,16 +6,21 @@ #include "tsd/io/importers/detail/usd/UsdImportContext.h" // usd #include +// std +#include namespace tsd::io::usd { bool isLightPrimType(const pxr::TfToken &primType); // Convert one resolved light prim. Returns a null ref for light types TSD -// cannot represent, which the caller reports rather than dropping silently. +// cannot represent, which the caller reports rather than dropping silently; +// `skipDetail` carries back what only this function knows about the decline, +// and stays empty when the light type alone says it all. LightRef convertLight(ImportContext &ctx, const pxr::SdfPath &primPath, - const pxr::HdSceneIndexPrim &prim); + const pxr::HdSceneIndexPrim &prim, + std::string *skipDetail); // Cameras import into the Scene's camera pool with their world transform, // animated through bindings where a rig authors motion anywhere above them. diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp index b613c42e2..83cc30149 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp @@ -176,12 +176,16 @@ bool isVolumePrimType(const pxr::TfToken &primType) return primType == pxr::HdPrimTypeTokens->volume; } -bool convertVolume( - ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node) +bool convertVolume(ImportContext &ctx, + const pxr::SdfPath &primPath, + LayerNodeRef node, + std::string *skipDetail) { auto prim = ctx.stage->GetPrimAtPath(primPath); - if (!prim) + if (!prim) { + *skipDetail = "volume has no Stage prim"; return false; + } const auto primName = primPath.GetString(); @@ -236,15 +240,19 @@ bool convertVolume( } } - if (filePaths.empty()) + if (filePaths.empty()) { + *skipDetail = "volume names no field file to read"; return false; + } const auto &filePath = filePaths.front(); auto field = import_spatial_field( *ctx.scene, filePath.c_str(), std::move(propertyName)); - if (!field) + if (!field) { + *skipDetail = "field file '" + filePath + "' could not be loaded"; return false; + } const auto tf = getVolumeTransferFunction(prim); auto valueRange = field->computeValueRange(); diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h index faea70722..6489309f8 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.h @@ -7,6 +7,8 @@ // usd #include #include +// std +#include namespace tsd::io::usd { @@ -15,7 +17,12 @@ bool isVolumePrimType(const pxr::TfToken &primType); // Import a UsdVol Volume prim, honouring the `anari:` value-range and // unit-distance annotations and any transfer function authored on the Stage. -bool convertVolume( - ImportContext &ctx, const pxr::SdfPath &primPath, LayerNodeRef node); +// Returns false for a Volume whose field TSD could not load, which the caller +// reports rather than dropping silently; `skipDetail` carries back which of +// the ways to have no field this Volume took. +bool convertVolume(ImportContext &ctx, + const pxr::SdfPath &primPath, + LayerNodeRef node, + std::string *skipDetail); } // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 26b3b46e5..d3cce48ba 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -53,20 +53,31 @@ bool purposeIsIncluded( return selection.defaultPurpose; } -// Leave a named, empty, disabled node where content would have been, so that a -// gap is visible in the hierarchy rather than only in a log. -LayerNodeRef insertPlaceholder(ImportContext &ctx, +// Undo the node a prim created, if any, and leave a named, empty, disabled +// Placeholder Node in its place, so that a gap is visible in the hierarchy +// rather than only in a log. Every prim whose content is a loss goes through +// here, which is what keeps one report entry and one Placeholder Node +// together: converters signal failure, this reports it. +void skipPrim(ImportContext &ctx, + LayerNodeRef node, // null when the prim never got one LayerNodeRef parent, const pxr::SdfPath &primPath, - UsdSkipReason reason) + const std::string &primType, + UsdSkipReason reason, + const std::string &detail = "") { + if (node) + ctx.scene->removeNode(node); + + ctx.reportSkip(primPath, primType, reason, detail); + // insertChildNode() already leaves the node empty; setEmpty() would clear // the name along with the value. - auto node = ctx.scene->insertChildNode(parent, primPath.GetName().c_str()); - (*node)->setEnabled(false); - (*node)->setInstanceParameter("usd:skipReason", Any(toString(reason))); - (*node)->setInstanceParameter("usd:primPath", Any(primPath.GetText())); - return node; + auto placeholder = + ctx.scene->insertChildNode(parent, primPath.GetName().c_str()); + (*placeholder)->setEnabled(false); + (*placeholder)->setInstanceParameter("usd:skipReason", Any(toString(reason))); + (*placeholder)->setInstanceParameter("usd:primPath", Any(primPath.GetText())); } struct Traversal @@ -139,11 +150,13 @@ void Traversal::visit(const pxr::SdfPath &primPath, purpose = value->GetTypedValue(0); } if (!purposeIsIncluded(purpose, ctx->options->purposes)) { - ctx->reportSkip(primPath, + skipPrim(*ctx, + {}, + parent, + primPath, prim.primType.GetString(), UsdSkipReason::PURPOSE_EXCLUDED, purpose.GetString()); - insertPlaceholder(*ctx, parent, primPath, UsdSkipReason::PURPOSE_EXCLUDED); return; } @@ -201,12 +214,12 @@ void Traversal::visit(const pxr::SdfPath &primPath, bool convertedAnything = false; if (prim.primType.IsEmpty()) { if (!isHierarchyPrim(primPath)) { - ctx->scene->removeNode(node); - ctx->reportSkip(primPath, + skipPrim(*ctx, + node, + parent, + primPath, ctx->stage->GetPrimAtPath(primPath).GetTypeName().GetString(), UsdSkipReason::UNSUPPORTED_PRIM_TYPE); - insertPlaceholder( - *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } } else if (isGeometryPrimType(prim.primType)) { @@ -220,14 +233,19 @@ void Traversal::visit(const pxr::SdfPath &primPath, convertInstancer(*ctx, sceneIndex, primPath, prim, node, *instancers); convertedAnything = true; } else if (isLightPrimType(prim.primType)) { - if (auto light = convertLight(*ctx, primPath, prim)) { + std::string skipDetail; + if (auto light = convertLight(*ctx, primPath, prim, &skipDetail)) { ctx->scene->insertChildObjectNode( node, light, primPath.GetName().c_str()); convertedAnything = true; } else { - ctx->scene->removeNode(node); - insertPlaceholder( - *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + skipPrim(*ctx, + node, + parent, + primPath, + prim.primType.GetString(), + UsdSkipReason::UNSUPPORTED_LIGHT_TYPE, + skipDetail); return; } } else if (prim.primType == pxr::HdPrimTypeTokens->camera) { @@ -240,20 +258,25 @@ void Traversal::visit(const pxr::SdfPath &primPath, ctx->scene->removeNode(node); return; } else if (isVolumePrimType(prim.primType)) { - convertedAnything = convertVolume(*ctx, primPath, node); + std::string skipDetail; + convertedAnything = convertVolume(*ctx, primPath, node, &skipDetail); if (!convertedAnything) { - ctx->scene->removeNode(node); - insertPlaceholder( - *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); + skipPrim(*ctx, + node, + parent, + primPath, + prim.primType.GetString(), + UsdSkipReason::FIELD_LOAD_FAILED, + skipDetail); return; } } else { - ctx->scene->removeNode(node); - ctx->reportSkip(primPath, + skipPrim(*ctx, + node, + parent, + primPath, prim.primType.GetString(), UsdSkipReason::UNSUPPORTED_PRIM_TYPE); - insertPlaceholder( - *ctx, parent, primPath, UsdSkipReason::UNSUPPORTED_PRIM_TYPE); return; } From ee0598bd9d1f545fe036b326f2cdc8532b50f1bc Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:09:07 -0500 Subject: [PATCH 51/78] test(io): cover a UsdVol Volume whose field file is missing The scenario asserts what the import previously dropped in silence: no Volume object arrives, the prim is named in the Import Report under FIELD_LOAD_FAILED with the file it could not read, and a disabled Placeholder Node stands where the Volume belongs. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/tests/test_UsdImport_volumes.cpp | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tsd/tests/test_UsdImport_volumes.cpp b/tsd/tests/test_UsdImport_volumes.cpp index f25c5b3f4..034bc5c00 100644 --- a/tsd/tests/test_UsdImport_volumes.cpp +++ b/tsd/tests/test_UsdImport_volumes.cpp @@ -217,6 +217,53 @@ def Volume "Vol" } } +SCENARIO("A Volume whose field cannot be loaded is reported", "[UsdImport]") +{ + GIVEN("A Volume prim naming a field file that is not there") + { + ImportedStage stage("tsd_test_usd_volume_missing_field.usda", R"(#usda 1.0 + +def Volume "Vol" +{ + rel field:density = + + def "Density" + { + asset filePath = @tsd_test_usd_absent_2x2x2_uint8.raw@ + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("No volume arrives") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_VOLUME) == 0); + } + + THEN("The prim is named in the Import Report") + { + REQUIRE(stage.report.skipped.size() == 1); + REQUIRE(stage.report.skipped[0].primPath == "/Vol"); + REQUIRE(stage.report.skipped[0].reason + == tsd::io::UsdSkipReason::FIELD_LOAD_FAILED); + REQUIRE(stage.report.skipped[0].detail.find( + "tsd_test_usd_absent_2x2x2_uint8.raw") + != std::string::npos); + } + + THEN("It leaves a disabled Placeholder Node where it belongs") + { + auto *layer = stage.scene.defaultLayer(); + auto vol = findNode(layer, "Vol"); + REQUIRE(vol); + REQUIRE((*vol)->isEmpty()); + REQUIRE_FALSE((*vol)->isEnabled()); + } + } + } +} + SCENARIO("Volume fields go through the shared spatial-field dispatcher", "[UsdImport]") { From d0f99acc82c5d16740ad64939acb415f3625a6b2 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:27:45 -0500 Subject: [PATCH 52/78] refactor(io): let an ImageCache alone say which Scene a Sampler joins The three texture shims took a `Scene &` beside the `ImageCache &` the image came from, so a caller could name a Scene the image had never reached -- the very thing an ImageCache holding its own Scene exists to prevent. They now take the cache alone and read `ImageCache::scene()`, which nothing had called until now. `makeImageSampler` gains an overload taking the cache for the same reason, so the two callers that acquire an image themselves -- glTF's `importGLTFTexture` and PBRT's `importHeightAsNormalMap` -- cannot name a second Scene either. With no Scene needed to build a Sampler, it fell out of four importer-local helpers that were only passing it along: ASSIMP's `importEmbeddedTexture` and PBRT's `bakeTexture`, `bakeTextureSlot`, and `resolveTexture`. Every ImageCache in the tree is constructed from the Scene that used to be passed beside it, so no call site changes behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/images/ImageCache.cpp | 10 ++ tsd/src/tsd/io/images/ImageCache.hpp | 9 ++ .../io/importers/detail/importer_common.cpp | 22 ++-- .../io/importers/detail/importer_common.hpp | 13 +-- .../io/importers/detail/usd/UsdMaterials.cpp | 10 +- tsd/src/tsd/io/importers/import_ASSIMP.cpp | 19 +--- tsd/src/tsd/io/importers/import_GLTF.cpp | 59 ++++------ tsd/src/tsd/io/importers/import_OBJ.cpp | 2 +- tsd/src/tsd/io/importers/import_PBRT.cpp | 105 ++++++------------ tsd/tests/test_ImageImport.cpp | 10 +- tsd/tests/test_Importers.cpp | 12 +- 11 files changed, 115 insertions(+), 156 deletions(-) diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp index a6e132947..d86878947 100644 --- a/tsd/src/tsd/io/images/ImageCache.cpp +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -218,4 +218,14 @@ SamplerRef makeImageSampler(Scene &scene, return sampler; } +SamplerRef makeImageSampler(ImageCache &cache, + const Image &image, + const std::string &displayName, + const SamplerSettings &settings) +{ + if (auto *scene = cache.scene()) + return makeImageSampler(*scene, image, displayName, settings); + return {}; +} + } // namespace tsd::io diff --git a/tsd/src/tsd/io/images/ImageCache.hpp b/tsd/src/tsd/io/images/ImageCache.hpp index a69e43ef5..845d51146 100644 --- a/tsd/src/tsd/io/images/ImageCache.hpp +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -139,6 +139,15 @@ tsd::scene::SamplerRef makeImageSampler(tsd::scene::Scene &scene, const std::string &displayName, const SamplerSettings &settings = {}); +// The same, for an image the given cache produced: the Sampler lands in the +// Scene the cache holds, so a caller that has both cannot name a Scene the +// image never reached. A cache with no Scene has no valid image to sample +// either, so this yields nothing rather than reaching for one. +tsd::scene::SamplerRef makeImageSampler(ImageCache &cache, + const Image &image, + const std::string &displayName, + const SamplerSettings &settings = {}); + // Inlined definitions //////////////////////////////////////////////////////// inline Image::operator bool() const diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 1cb27ef90..5efd9afa4 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -104,8 +104,11 @@ tsd::scene::ArrayRef readArray( // Texture import shims /////////////////////////////////////////////////////// // These forward to tsd::io::images, which owns decoding, orientation, keying, -// and lifetime for every image in the tree. They exist so the ~20 importer -// call sites keep one signature; new code should use ImageCache directly. +// and lifetime for every image in the tree. They exist so the call sites that +// want the whole of it -- acquire, then build a Sampler for what came back -- +// keep one signature. Like the makeImageSampler overload they end in, they +// take the ImageCache alone, so no caller can put the Sampler in a Scene the +// image never reached. namespace { @@ -116,9 +119,8 @@ ColorSpace colorSpaceOf(bool isLinear) } // namespace -SamplerRef importTexture(Scene &scene, +SamplerRef importTexture(ImageCache &cache, std::string filepath, - ImageCache &cache, bool isLinear, const SamplerSettings &settings) { @@ -128,31 +130,29 @@ SamplerRef importTexture(Scene &scene, }); auto image = cache.acquire({filepath, colorSpaceOf(isLinear)}); - return makeImageSampler(scene, image, filepath, settings); + return makeImageSampler(cache, image, filepath, settings); } -SamplerRef importTextureFromMemory(Scene &scene, +SamplerRef importTextureFromMemory(ImageCache &cache, const std::string &cacheKey, const std::string &displayName, const void *data, size_t numBytes, - ImageCache &cache, bool isLinear, const std::string &formatHint, const SamplerSettings &settings) { auto image = cache.acquire( {cacheKey, colorSpaceOf(isLinear)}, data, numBytes, formatHint); - return makeImageSampler(scene, image, displayName, settings); + return makeImageSampler(cache, image, displayName, settings); } -SamplerRef importRawTexture2D(Scene &scene, +SamplerRef importRawTexture2D(ImageCache &cache, const std::string &cacheKey, const std::string &displayName, const void *data, size_t width, size_t height, - ImageCache &cache, bool isLinear, const SamplerSettings &settings) { @@ -162,7 +162,7 @@ SamplerRef importRawTexture2D(Scene &scene, height, RowOrder::TOP_DOWN, data); - return makeImageSampler(scene, image, displayName, settings); + return makeImageSampler(cache, image, displayName, settings); } SamplerRef makeDefaultColorMapSampler(Scene &scene, const float2 &range) diff --git a/tsd/src/tsd/io/importers/detail/importer_common.hpp b/tsd/src/tsd/io/importers/detail/importer_common.hpp index cd7f2ca06..a14f5c18b 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.hpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.hpp @@ -28,28 +28,27 @@ std::vector splitString(const std::string &s, char delim); tsd::scene::ArrayRef readArray( tsd::scene::Scene &scene, anari::DataType elementType, std::FILE *fp); -// Thin shims over tsd::io::images; see the note above their definitions. -tsd::scene::SamplerRef importTexture(tsd::scene::Scene &scene, +// Thin shims over tsd::io::images; the Sampler lands in the Scene the given +// ImageCache holds, so no caller can name a different one. See the note above +// their definitions. +tsd::scene::SamplerRef importTexture(ImageCache &cache, std::string filepath, - ImageCache &cache, bool isLinear = false, const SamplerSettings &settings = {}); -tsd::scene::SamplerRef importTextureFromMemory(tsd::scene::Scene &scene, +tsd::scene::SamplerRef importTextureFromMemory(ImageCache &cache, const std::string &cacheKey, const std::string &displayName, const void *data, size_t numBytes, - ImageCache &cache, bool isLinear = false, const std::string &formatHint = "", const SamplerSettings &settings = {}); -tsd::scene::SamplerRef importRawTexture2D(tsd::scene::Scene &scene, +tsd::scene::SamplerRef importRawTexture2D(ImageCache &cache, const std::string &cacheKey, const std::string &displayName, const void *data, size_t width, size_t height, - ImageCache &cache, bool isLinear = false, const SamplerSettings &settings = {}); diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index 3d94baf77..a878c1e2f 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -416,8 +416,7 @@ MaterialRef tryOmniPbrMapping(ImportContext &ctx, return false; // OmniPBR names no colour space of its own, so the role of the input is // what says whether its texels must be de-gamma'd on load. - auto sampler = - importTexture(*ctx.scene, file, ctx.textureCache, !colorRole); + auto sampler = importTexture(ctx.textureCache, file, !colorRole); if (!sampler) { ctx.reportSkip(materialPath, primType.GetString(), @@ -830,8 +829,8 @@ MaterialRef tryMaterialXPassthrough(ImportContext &ctx, // surface, and through the same cache, so a texture shared between materials // is read once. for (const auto &texture : documentTextures) { - auto sampler = importTexture( - *ctx.scene, texture.file, ctx.textureCache, texture.isLinear); + auto sampler = + importTexture(ctx.textureCache, texture.file, texture.isLinear); if (!sampler) { ctx.reportSkip(materialPath, prim.primType.GetString(), @@ -974,8 +973,7 @@ ResolvedMaterial convertPreviewSurface(ImportContext &ctx, settings.uvTransform = uvTransformOfTexture(walker, texturePath); const bool isLinear = textureIsLinear(walker, texturePath, colorRole); - auto sampler = - importTexture(*ctx.scene, file, ctx.textureCache, isLinear, settings); + auto sampler = importTexture(ctx.textureCache, file, isLinear, settings); if (!sampler) { ctx.reportSkip(materialPath, prim.primType.GetString(), diff --git a/tsd/src/tsd/io/importers/import_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index 4b2741560..ae7c0062b 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -24,10 +24,9 @@ using namespace tsd::core; #if TSD_USE_ASSIMP -static SamplerRef importEmbeddedTexture(Scene &scene, +static SamplerRef importEmbeddedTexture(ImageCache &cache, const aiTexture *embeddedTexture, int embeddedTextureIndex, - ImageCache &cache, bool isLinear, const SamplerSettings &settings) { @@ -55,12 +54,11 @@ static SamplerRef importEmbeddedTexture(Scene &scene, } if (embeddedTexture->mHeight == 0) { - auto tex = importTextureFromMemory(scene, + auto tex = importTextureFromMemory(cache, textureId, displayName, embeddedTexture->pcData, embeddedTexture->mWidth, - cache, isLinear, embeddedTexture->achFormatHint, settings); @@ -85,13 +83,12 @@ static SamplerRef importEmbeddedTexture(Scene &scene, rgba[i * 4 + 3] = src.a; } - return importRawTexture2D(scene, + return importRawTexture2D(cache, textureId, displayName, rgba.data(), embeddedTexture->mWidth, embeddedTexture->mHeight, - cache, isLinear, settings); } @@ -265,15 +262,11 @@ static std::vector importASSIMPMaterials( auto [embeddedTexture, embeddedTextureIndex] = a_scene->GetEmbeddedTextureAndIndex(texName.C_Str()); if (embeddedTexture) { - tex = importEmbeddedTexture(scene, - embeddedTexture, - embeddedTextureIndex, - cache, - isLinear, - settings); + tex = importEmbeddedTexture( + cache, embeddedTexture, embeddedTextureIndex, isLinear, settings); } else { tex = importTexture( - scene, basePath + texName.C_Str(), cache, isLinear, settings); + cache, basePath + texName.C_Str(), isLinear, settings); } } diff --git a/tsd/src/tsd/io/importers/import_GLTF.cpp b/tsd/src/tsd/io/importers/import_GLTF.cpp index 03ae79c04..d568f014f 100644 --- a/tsd/src/tsd/io/importers/import_GLTF.cpp +++ b/tsd/src/tsd/io/importers/import_GLTF.cpp @@ -178,10 +178,9 @@ static SamplerSettings gltfSamplerSettings( return settings; } -static SamplerRef importGLTFTexture(Scene &scene, +static SamplerRef importGLTFTexture(ImageCache &cache, const tinygltf::Model &model, int textureIndex, - ImageCache &cache, // Scopes the cache id to this glTF: image names are local to a file, so // two assets each naming an image "diffuse" are different images. const std::string &sourcePrefix, @@ -232,7 +231,7 @@ static SamplerRef importGLTFTexture(Scene &scene, ? std::string(samplerName) + ":" + imageId : imageId; - return makeImageSampler(scene, decoded, displayName, settings); + return makeImageSampler(cache, decoded, displayName, settings); } static void applyNormalTextureScale(SamplerRef sampler, float scale) @@ -272,10 +271,9 @@ static std::vector importGLTFMaterials( pbr.baseColorFactor[2], pbr.baseColorFactor[3]); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, pbr.baseColorTexture.index, - cache, sourcePrefix, false, "baseColor", @@ -293,10 +291,9 @@ static std::vector importGLTFMaterials( float3(baseColorFactor[0], baseColorFactor[1], baseColorFactor[2])); } - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, pbr.baseColorTexture.index, - cache, sourcePrefix, true, "opacity", @@ -313,10 +310,9 @@ static std::vector importGLTFMaterials( // Metallic factor float metallicFactor = pbr.metallicFactor; - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, pbr.metallicRoughnessTexture.index, - cache, sourcePrefix, true, "metallic", @@ -334,10 +330,9 @@ static std::vector importGLTFMaterials( // Roughness factor float roughnessFactor = pbr.roughnessFactor; - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, pbr.metallicRoughnessTexture.index, - cache, sourcePrefix, true, "roughness", @@ -354,10 +349,9 @@ static std::vector importGLTFMaterials( } // Normal map - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, gltfMaterial.normalTexture.index, - cache, sourcePrefix, true, "normal", @@ -368,10 +362,9 @@ static std::vector importGLTFMaterials( } // Occlusion map - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, gltfMaterial.occlusionTexture.index, - cache, sourcePrefix, true, "occlusion", @@ -393,10 +386,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(emissiveStrengthExt, 1.0f, "emissiveStrength"); } - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, gltfMaterial.emissiveTexture.index, - cache, sourcePrefix, false, "emissive", @@ -440,10 +432,9 @@ static std::vector importGLTFMaterials( transmissionExt, -1, "transmissionTexture", "index"); auto transmissionTexCoord = GetValueOrDefault( transmissionExt, 0, "transmissionTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, transmissionTextureIndex, - cache, sourcePrefix, true, "transmission", @@ -487,10 +478,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(volumeExt, -1, "thicknessTexture", "index"); auto thicknessTexCoord = GetValueOrDefault(volumeExt, 0, "thicknessTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, thicknessTextureIndex, - cache, sourcePrefix, true, "thickness", @@ -537,10 +527,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(clearcoatExt, -1, "clearcoatTexture", "index"); auto clearcoatTexCoord = GetValueOrDefault(clearcoatExt, 0, "clearcoatTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, clearcoatTextureIndex, - cache, sourcePrefix, true, "clearcoat", @@ -564,10 +553,9 @@ static std::vector importGLTFMaterials( clearcoatExt, -1, "clearcoatRoughnessTexture", "index"); auto clearcoatRoughnessTexCoord = GetValueOrDefault( clearcoatExt, 0, "clearcoatRoughnessTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, clearcoatRoughnessTextureIndex, - cache, sourcePrefix, true, "clearcoatRoughness", @@ -589,10 +577,9 @@ static std::vector importGLTFMaterials( clearcoatExt, 1.0f, "clearcoatNormalTexture", "scale"); auto clearcoatNormalTexCoord = GetValueOrDefault( clearcoatExt, 0, "clearcoatNormalTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, clearcoatNormalTextureIndex, - cache, sourcePrefix, true, "clearcoatNormal", @@ -620,10 +607,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(specularExt, -1, "specularTexture", "index"); auto specularTexCoord = GetValueOrDefault(specularExt, 0, "specularTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, specularTextureIndex, - cache, sourcePrefix, true, "specular", @@ -647,10 +633,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(specularExt, -1, "specularColorTexture", "index"); auto specularColorTexCoord = GetValueOrDefault(specularExt, 0, "specularColorTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, specularColorTextureIndex, - cache, sourcePrefix, false, "specularColor", @@ -685,10 +670,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(sheenExt, -1, "sheenColorTexture", "index"); auto sheenColorTexCoord = GetValueOrDefault(sheenExt, 0, "sheenColorTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, sheenColorTextureIndex, - cache, sourcePrefix, false, "sheenColor", @@ -712,10 +696,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(sheenExt, -1, "sheenRoughnessTexture", "index"); auto sheenRoughnessTexCoord = GetValueOrDefault(sheenExt, 0, "sheenRoughnessTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, sheenRoughnessTextureIndex, - cache, sourcePrefix, true, "sheenRoughness", @@ -750,10 +733,9 @@ static std::vector importGLTFMaterials( GetValueOrDefault(iridescenceExt, -1, "iridescenceTexture", "index"); auto iridescenceTexCoord = GetValueOrDefault( iridescenceExt, 0, "iridescenceTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, iridescenceTextureIndex, - cache, sourcePrefix, true, "iridescence", @@ -786,10 +768,9 @@ static std::vector importGLTFMaterials( iridescenceExt, -1, "iridescenceThicknessTexture", "index"); auto iridescenceThicknessTexCoord = GetValueOrDefault( iridescenceExt, 0, "iridescenceThicknessTexture", "texCoord"); - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, iridescenceThicknessTextureIndex, - cache, sourcePrefix, true, "iridescenceThickness", diff --git a/tsd/src/tsd/io/importers/import_OBJ.cpp b/tsd/src/tsd/io/importers/import_OBJ.cpp index b828c6833..187d27feb 100644 --- a/tsd/src/tsd/io/importers/import_OBJ.cpp +++ b/tsd/src/tsd/io/importers/import_OBJ.cpp @@ -69,7 +69,7 @@ void import_OBJ(Scene &scene, m->setName(mat.name.c_str()); if (!mat.diffuse_texname.empty()) { - auto tex = importTexture(scene, basePath + mat.diffuse_texname, cache); + auto tex = importTexture(cache, basePath + mat.diffuse_texname); if (tex) m->setParameterObject("color", *tex); } diff --git a/tsd/src/tsd/io/importers/import_PBRT.cpp b/tsd/src/tsd/io/importers/import_PBRT.cpp index e1767b724..b6e2fe1e7 100644 --- a/tsd/src/tsd/io/importers/import_PBRT.cpp +++ b/tsd/src/tsd/io/importers/import_PBRT.cpp @@ -930,20 +930,18 @@ static SamplerSettings pbrtSamplerSettings(const pbrt::ParamList ¶ms) return settings; } -static BakedTexture bakeTexture(Scene &scene, +static BakedTexture bakeTexture(ImageCache &texCache, const pbrt::Scene &pbrtScene, const std::string &textureName, - const std::string &basePath, - ImageCache &texCache); + const std::string &basePath); // Resolve a PBRT texture-or-constant slot ("rgb tex1" / "float tex1" / // "texture tex1"). Used by both `scale` and `mix`. -static BakedTexture bakeTextureSlot(Scene &scene, +static BakedTexture bakeTextureSlot(ImageCache &texCache, const pbrt::ParamList ¶ms, const std::string ¶mName, const pbrt::Scene &pbrtScene, - const std::string &basePath, - ImageCache &texCache) + const std::string &basePath) { auto it = params.values.find(paramName); if (it == params.values.end()) { @@ -963,7 +961,7 @@ static BakedTexture bakeTextureSlot(Scene &scene, } if (auto *strings = std::get_if>(&it->second); strings && !strings->empty()) { - return bakeTexture(scene, pbrtScene, (*strings)[0], basePath, texCache); + return bakeTexture(texCache, pbrtScene, (*strings)[0], basePath); } return {}; } @@ -988,11 +986,10 @@ static float3 bakeMixAmount(const pbrt::ParamList ¶ms) return float3(0.5f); } -static BakedTexture bakeTexture(Scene &scene, +static BakedTexture bakeTexture(ImageCache &texCache, const pbrt::Scene &pbrtScene, const std::string &textureName, - const std::string &basePath, - ImageCache &texCache) + const std::string &basePath) { auto texIt = pbrtScene.textures.find(textureName); if (texIt == pbrtScene.textures.end()) { @@ -1016,11 +1013,8 @@ static BakedTexture bakeTexture(Scene &scene, // PBRT splits image textures by colorType: "spectrum" is sRGB color // data, "float" is linear scalar data (roughness, masks, bumps). const bool isLinear = (texDef.colorType == "float"); - auto sampler = importTexture(scene, - fullPath, - texCache, - isLinear, - pbrtSamplerSettings(texDef.params)); + auto sampler = importTexture( + texCache, fullPath, isLinear, pbrtSamplerSettings(texDef.params)); if (!sampler) return {}; // Per the ANARI sampler spec, a fetched texel is completed to four @@ -1063,18 +1057,18 @@ static BakedTexture bakeTexture(Scene &scene, // texture-ref) — not `tex1` / `tex2`. With the old keys, every `scale` // chain in `crown.pbrt` silently resolved to Constant(1) and the // referenced imagemaps never made it into the scene. - auto a = bakeTextureSlot( - scene, texDef.params, "tex", pbrtScene, basePath, texCache); - auto b = bakeTextureSlot( - scene, texDef.params, "scale", pbrtScene, basePath, texCache); + auto a = + bakeTextureSlot(texCache, texDef.params, "tex", pbrtScene, basePath); + auto b = + bakeTextureSlot(texCache, texDef.params, "scale", pbrtScene, basePath); return combineMul(a, b); } if (texDef.implType == "mix") { - auto a = bakeTextureSlot( - scene, texDef.params, "tex1", pbrtScene, basePath, texCache); - auto b = bakeTextureSlot( - scene, texDef.params, "tex2", pbrtScene, basePath, texCache); + auto a = + bakeTextureSlot(texCache, texDef.params, "tex1", pbrtScene, basePath); + auto b = + bakeTextureSlot(texCache, texDef.params, "tex2", pbrtScene, basePath); return combineMix(a, b, bakeMixAmount(texDef.params)); } @@ -1115,8 +1109,7 @@ static void applyAffineToSampler( sampler->setParameter("outOffset", o); } -static void resolveTexture(Scene &scene, - MaterialRef mat, +static void resolveTexture(MaterialRef mat, const std::string ¶mName, const std::string &texParamName, const pbrt::MaterialDef &matDef, @@ -1129,7 +1122,7 @@ static void resolveTexture(Scene &scene, if (texName.empty()) return; - auto baked = bakeTexture(scene, pbrtScene, texName, basePath, texCache); + auto baked = bakeTexture(texCache, pbrtScene, texName, basePath); switch (baked.kind) { case BakedTexture::Kind::None: return; @@ -1215,17 +1208,15 @@ static bool resolveImagemapChain(const pbrt::Scene &pbrtScene, // height. The fixed `kBumpStrength` boost exists because PBRT scales // (e.g. 0.25 in crown.pbrt) are calibrated for geometric displacement; // a tangent-only fake of the same scale would be visually invisible. -static SamplerRef importHeightAsNormalMap(Scene &scene, - const std::string &filepath, - float heightScale, - ImageCache &texCache) +static SamplerRef importHeightAsNormalMap( + ImageCache &texCache, const std::string &filepath, float heightScale) { // Key under a separate id so this doesn't collide with any value-domain // sampler that may already exist for the same file. const ImageSource source{"pbrt:" + filepath + "::normal", ColorSpace::LINEAR}; if (auto cached = texCache.find(source)) - return makeImageSampler(scene, cached, fileOf(filepath) + "_bump"); + return makeImageSampler(texCache, cached, fileOf(filepath) + "_bump"); int w = 0, h = 0, channels = 0; stbi_ldr_to_hdr_scale(1.f); @@ -1271,7 +1262,7 @@ static SamplerRef importHeightAsNormalMap(Scene &scene, RowOrder::TOP_DOWN, texels.data()); - return makeImageSampler(scene, image, fileOf(filepath) + "_bump"); + return makeImageSampler(texCache, image, fileOf(filepath) + "_bump"); } // Approximate normal-incidence reflectance for common PBRT named metal spectra. @@ -1463,14 +1454,8 @@ static MaterialRef convertMaterial(Scene &scene, mat = scene.createObject(tokens::material::matte); auto color = getRgb(params, "reflectance"); mat->setParameter("color", ANARI_FLOAT32_VEC3, &color); - resolveTexture(scene, - mat, - "color", - "reflectance", - matDef, - pbrtScene, - basePath, - texCache); + resolveTexture( + mat, "color", "reflectance", matDef, pbrtScene, basePath, texCache); } else if (type == "coateddiffuse") { mat = scene.createObject(tokens::material::physicallyBased); auto baseColor = getRgb(params, "reflectance"); @@ -1488,16 +1473,9 @@ static MaterialRef convertMaterial(Scene &scene, else coatRoughness = 0.f; mat->setParameter("clearcoatRoughness", coatRoughness); - resolveTexture(scene, - mat, - "baseColor", - "reflectance", - matDef, - pbrtScene, - basePath, - texCache); - resolveTexture(scene, - mat, + resolveTexture( + mat, "baseColor", "reflectance", matDef, pbrtScene, basePath, texCache); + resolveTexture(mat, "clearcoatRoughness", "roughness", matDef, @@ -1512,8 +1490,7 @@ static MaterialRef convertMaterial(Scene &scene, mat->setParameter("baseColor", ANARI_FLOAT32_VEC3, &baseColor); mat->setParameter("metallic", 1.f); mat->setParameter("roughness", conductorRoughness(params)); - resolveTexture(scene, - mat, + resolveTexture(mat, "roughness", "roughness", matDef, @@ -1530,8 +1507,7 @@ static MaterialRef convertMaterial(Scene &scene, mat->setParameter("metallic", 0.f); mat->setParameter("specular", 1.f); mat->setParameter("transmission", 1.f); - resolveTexture(scene, - mat, + resolveTexture(mat, "roughness", "roughness", matDef, @@ -1547,14 +1523,8 @@ static MaterialRef convertMaterial(Scene &scene, mat->setParameter("specular", 1.f); mat->setParameter("roughness", 1.f); mat->setParameter("transmission", 1.f); - resolveTexture(scene, - mat, - "baseColor", - "reflectance", - matDef, - pbrtScene, - basePath, - texCache); + resolveTexture( + mat, "baseColor", "reflectance", matDef, pbrtScene, basePath, texCache); } else if (type == "coatedconductor") { mat = scene.createObject(tokens::material::physicallyBased); auto baseColor = getRgb(params, @@ -1704,8 +1674,7 @@ static MaterialRef convertMaterial(Scene &scene, // Bake `result = c0 + (c1 - c0) * mask` into the mask sampler. // BakedTexture's affine is `tint*S + offset` per channel, so the // composed affine is `(c1-c0)*tint*S + ((c1-c0)*offset + c0)`. - auto baked = - bakeTexture(scene, pbrtScene, maskTexName, basePath, texCache); + auto baked = bakeTexture(texCache, pbrtScene, maskTexName, basePath); if (baked.kind == BakedTexture::Kind::Sampler) { const float3 bc0 = readBaseColor(mat0); const float3 bc1 = readBaseColor(mat1); @@ -1751,7 +1720,7 @@ static MaterialRef convertMaterial(Scene &scene, if (!normalMapPath.empty()) { try { auto fullPath = pbrt::resolveScenePath(basePath, normalMapPath); - if (auto sampler = importTexture(scene, fullPath, texCache, true)) + if (auto sampler = importTexture(texCache, fullPath, true)) mat->setParameterObject("normal", *sampler); } catch (const std::exception &e) { logWarning("[import_PBRT] normalmap: %s", e.what()); @@ -1768,8 +1737,8 @@ static MaterialRef convertMaterial(Scene &scene, float heightScale = 1.f; if (resolveImagemapChain( pbrtScene, (*sv)[0], basePath, heightPath, heightScale)) { - if (auto sampler = importHeightAsNormalMap( - scene, heightPath, heightScale, texCache)) + if (auto sampler = + importHeightAsNormalMap(texCache, heightPath, heightScale)) mat->setParameterObject("normal", *sampler); } else { logWarning( @@ -1940,7 +1909,7 @@ static MaterialRef applyShapeAlpha(Scene &scene, e.what()); return mat; } - sampler = importTexture(scene, fullPath, texCache, true); + sampler = importTexture(texCache, fullPath, true); if (!sampler) return mat; // The standard importer wires all 4 channels straight through. The diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 2d2003730..6750291ad 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -401,8 +401,8 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") WHEN("It is imported through the shared texture path") { - auto sampler = tsd::io::importTexture( - scene, texture.path(), cache, /*isLinear=*/true); + auto sampler = + tsd::io::importTexture(cache, texture.path(), /*isLinear=*/true); THEN("Row 0 of the array is the top row of the picture") { @@ -481,7 +481,7 @@ SCENARIO("Block-compressed images are bound as the file authored them", WHEN("It is imported with no uv transform of its own") { - auto sampler = tsd::io::importTexture(scene, texture.path(), cache); + auto sampler = tsd::io::importTexture(cache, texture.path()); THEN("The sampler leaves the coordinates alone") { @@ -504,7 +504,7 @@ SCENARIO("Block-compressed images are bound as the file authored them", float4(0.f, 0.f, 0.f, 1.f))}; auto sampler = tsd::io::importTexture( - scene, texture.path(), cache, /*isLinear=*/false, settings); + cache, texture.path(), /*isLinear=*/false, settings); THEN("That transform reaches the sampler unchanged") { @@ -519,7 +519,7 @@ SCENARIO("Block-compressed images are bound as the file authored them", auto image = cache.acquire({texture.path(), tsd::io::ColorSpace::SRGB, tsd::io::RowOrder::BOTTOM_UP}); - auto sampler = tsd::io::makeImageSampler(scene, image, texture.path()); + auto sampler = tsd::io::makeImageSampler(cache, image, texture.path()); THEN("The sampler reverses v, since the texels could not be") { diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index 9be9bff67..5c2b61148 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -220,8 +220,8 @@ SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") WHEN("It is imported as a linear texture") { - auto sampler = tsd::io::importTexture( - scene, tiff.path(), cache, /*isLinear=*/true); + auto sampler = + tsd::io::importTexture(cache, tiff.path(), /*isLinear=*/true); #if TSD_USE_OIIO THEN("The sampler carries the file's texels untransformed") @@ -248,8 +248,8 @@ SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") #if TSD_USE_OIIO WHEN("It is imported as an sRGB texture") { - auto sampler = tsd::io::importTexture( - scene, tiff.path(), cache, /*isLinear=*/false); + auto sampler = + tsd::io::importTexture(cache, tiff.path(), /*isLinear=*/false); THEN("The texels are decoded to linear, matching the stb-backed paths") { @@ -276,8 +276,8 @@ SCENARIO("TIFF textures decode into float texel arrays", "[Importers]") WHEN("It is imported as an sRGB texture") { - auto sampler = tsd::io::importTexture( - scene, tiff.path(), cache, /*isLinear=*/false); + auto sampler = + tsd::io::importTexture(cache, tiff.path(), /*isLinear=*/false); THEN("Only the grey channel is gamma-decoded, leaving alpha linear") { From 0dcf19dd64fb0d90a532ecc8561e37b9f18dd4f7 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:27:45 -0500 Subject: [PATCH 53/78] docs(io): record that the texture import shims stay "Remaining work" item 2 of the image-import note called for deleting `importTexture` and its two siblings as pure forwarding. Now that they take the ImageCache alone, they are what keeps the cache's Scene the only Scene a caller can put a Sampler in, and they still carry the path normalisation and colour-space choice their call sites share. Record the decision and its reason in place of a plan the code has gone against. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/docs/tsd-io-image-import.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tsd/docs/tsd-io-image-import.md b/tsd/docs/tsd-io-image-import.md index a1ba3dbef..e8e17ed7d 100644 --- a/tsd/docs/tsd-io-image-import.md +++ b/tsd/docs/tsd-io-image-import.md @@ -356,15 +356,22 @@ which is why it did not ride along with a behavior change. Prerequisite already done: `ImageSource` ids are file-scoped (`gltf::`), so sharing a cache across files is safe. -### 2. Retire the shims +### 2. Retire the shims — **decided against** `importTexture`, `importTextureFromMemory`, and `importRawTexture2D` -(`importer_common.hpp:32-54`) are pure forwarding to `ImageCache` and -`makeImageSampler`. They exist so the ~20 call sites did not churn in the same -commit as the behavior change, which has now happened. Two ways to do the same -thing is the state this work set out to remove, so they should go once -something else is touching those call sites anyway — item 1 is the natural -moment. +(`importer_common.hpp`) were to be deleted as pure forwarding to `ImageCache` +and `makeImageSampler`, on the grounds that two ways to do the same thing is +the state this work set out to remove. + +They stay, and now take `ImageCache &` alone instead of an `ImageCache &` +beside a `Scene &`. The pair was the real problem: a caller could name a Scene +the image had never reached, which is the failure the cache's ownership of a +Scene exists to prevent. `makeImageSampler` gained an overload taking the +cache for the same reason, and the callers that acquire their own image +(`importGLTFTexture`, PBRT's `importHeightAsNormalMap`) use it. What is left +of the shims is the path normalization and colour-space choice their nine call +sites share, which is worth one function. They are the intended API, not a +migration aid. ### 3. PBRT's infinite light From 0caf7463ecff8956e6430b2f0b27681ca2206187 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:39:59 -0500 Subject: [PATCH 54/78] refactor(io): stop building a one-call ImageCache for HDRI radiance import_HDRI decodes exactly one image per call, so the function-local ImageCache it routed radiance through could never take a hit: it keyed a single entry, copied the texels a second time into a DecodedImage, and was destroyed at scope exit. Its acquireDecoded row-order normalization was a no-op too, because HDRImage already emits the BOTTOM_UP order the ImageSource asked for. The array now comes straight from the Scene, as import_PBRT's infinite light has always built its own. Behaviour is unchanged: store() ended in the same createArray + setData, and the error path this drops was unreachable -- store() only fails on a null Scene or an empty decode, and HDRImage::import already rejects a zero-sized image. The comment left in its place records why this path skips the cache, and that ADR 0014 still describes an hdri light's radiance as reaching the light through ImageCache -- true of the UsdLights dome-light path, which keys on file plus radiometry and genuinely dedups, but no longer of this one. Whether that ADR and the "owner of every decoded Image" wording in io/CONTEXT.md should be amended is left for a follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/importers/detail/HDRImage.h | 4 +-- tsd/src/tsd/io/importers/import_HDRI.cpp | 32 ++++++++++------------ tsd/tests/test_ImageImport.cpp | 2 +- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/HDRImage.h b/tsd/src/tsd/io/importers/detail/HDRImage.h index 8b7ad1b3b..a2b7954dc 100644 --- a/tsd/src/tsd/io/importers/detail/HDRImage.h +++ b/tsd/src/tsd/io/importers/detail/HDRImage.h @@ -13,8 +13,8 @@ namespace tsd::io { // The one decoder that does not go through ImageCache's own decode paths: it // handles multipart EXR and forces three channels, neither of which the shared // texture path does. It still declares the row order it produced, so callers -// hand it to ImageCache::acquireDecoded and get the same normalization, -// keying, and lifetime as any other image. +// that store its texels through ImageCache::acquireDecoded get the same +// normalization, keying, and lifetime as any other image. struct HDRImage { bool import(std::string fileName); diff --git a/tsd/src/tsd/io/importers/import_HDRI.cpp b/tsd/src/tsd/io/importers/import_HDRI.cpp index 8d0155be2..86278ad43 100644 --- a/tsd/src/tsd/io/importers/import_HDRI.cpp +++ b/tsd/src/tsd/io/importers/import_HDRI.cpp @@ -30,28 +30,26 @@ void import_HDRI(Scene &scene, } } - ImageCache cache(&scene); - // Stored bottom-up: an hdri light's radiance is mapped over the sphere by - // the light rather than addressed by an image sampler, so the top-left - // origin samplers are stored for does not apply to it. - auto image = cache.acquireDecoded( - {hdriFilename, ColorSpace::LINEAR, RowOrder::BOTTOM_UP}, - ANARI_FLOAT32_VEC3, - img.width, - img.height, - img.rowOrder, - rgb.data()); - - if (!image) { - logError("[import_HDRI] failed to store radiance for '%s'", filepath); - return; - } + // Not stored through ImageCache: this importer decodes exactly one image + // per call, so a cache scoped to the call can never be hit and only buys a + // second copy of the texels. import_PBRT's infinite light binds its + // radiance directly for the same reason; UsdLights caches because many + // dome lights in one Stage can share a file and a radiometry scale. + // The rows stay bottom-up as HDRImage decoded them, which is the order an + // hdri light wants: its radiance is mapped over the sphere by the light + // rather than addressed by a sampler, so the top-left origin ADR 0014 + // stores sampled images in does not apply. That ADR still describes this + // radiance as reaching the light through ImageCache, which is now true + // only of the UsdLights path. + auto radiance = + scene.createArray(ANARI_FLOAT32_VEC3, img.width, img.height); + radiance->setData(rgb.data()); auto [inst, hdri] = scene.insertNewChildObjectNode( location ? location : scene.defaultLayer()->root(), tokens::light::hdri); hdri->setName(fileOf(filepath).c_str()); - hdri->setParameterObject("radiance", *image.texels); + hdri->setParameterObject("radiance", *radiance); } else { tsd::core::logError("[import_HDRI] Failed to load file '%s'", filepath); } diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 6750291ad..983955ef5 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -421,7 +421,7 @@ SCENARIO("Decoded images are stored in ANARI orientation", "[ImageImport]") // An hdri light's radiance is mapped over the sphere by the light rather than // addressed by an image sampler, so the top-left origin the sampler path is -// stored for does not reach it. It asks the cache for bottom-up rows instead. +// stored for does not reach it. It keeps the decoder's bottom-up rows. SCENARIO("An imported HDRI's radiance runs bottom-up", "[ImageImport]") { GIVEN("A 1x2 Radiance HDR, red on top and blue on the bottom") From ece64a13b2555b112a4575d741b54c98cd79d0e2 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:54:31 -0500 Subject: [PATCH 55/78] test(io): pin the compressed sampler's format and size parameters The DDS scenario asserted the sampler's subtype and its uv transform, but nothing checked the two parameters that tell the device how to read a block stream. The Array behind a compressed image is a flat run of bytes, so `format` and `size` are the only description of the picture the sampler carries, and a wrong or missing one renders garbage without failing a test. Both values are what the path already produced; this only records them. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/tests/test_ImageImport.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tsd/tests/test_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp index 983955ef5..902e16dec 100644 --- a/tsd/tests/test_ImageImport.cpp +++ b/tsd/tests/test_ImageImport.cpp @@ -488,6 +488,19 @@ SCENARIO("Block-compressed images are bound as the file authored them", REQUIRE(sampler); REQUIRE(sampler->subtype() == tsd::scene::tokens::sampler::compressedImage2D); + // The block format and the picture's dimensions describe an Array + // whose own shape is a flat byte run, so the sampler has to carry + // them; nothing else tells the device how to read the blocks. + auto *format = sampler->parameter("format"); + REQUIRE(format != nullptr); + REQUIRE(format->value().getString() == "BC1_RGB"); + auto *size = sampler->parameter("size"); + REQUIRE(size != nullptr); + REQUIRE(size->value().type() == ANARI_UINT64_VEC2); + const auto *extent = + static_cast(size->value().data()); + REQUIRE(extent[0] == 8); + REQUIRE(extent[1] == 8); REQUIRE(fetchedV(sampler, 1.f) == Approx(1.f).margin(1e-5)); REQUIRE(fetchedV(sampler, 0.f) == Approx(0.f).margin(1e-5)); } From baa79a0072407dec8b516d7dbf676673f6df9c05 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 22:54:43 -0500 Subject: [PATCH 56/78] refactor(io): let an Image carry its own format and dimensions ImageCache::store() wrote the block format and the picture's dimensions as stringly-keyed metadata on the texel Array, and makeImageSampler read them back untyped twenty lines later -- a side channel between two functions in one file, when Image was already the thing being passed between them. Both values now live on Image, where the ArrayRef and vFlipInSampler already did. blockCompressed collapses into the format being non-empty: the DDS decoder is the only producer of compressed texels and it yields no image at all for a format it does not recognize, so the two could never disagree. store() derives it the same way makeImageSampler does rather than copying the decoder's bool, which keeps the one Image it builds self-consistent. Nothing else read either metadata key -- serialization walks metadata generically, and the device's BC table in mdl/SamplerRegistry.cpp maps from MDL image formats rather than from these -- so the writes go away with the reads, and with them the file-scope ANARI_TYPEFOR_SPECIALIZATION that existed only to move the dimensions through an Any. The sampler's "size" parameter is now set untyped, which is the one place that needed the mapping. Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/images/ImageCache.cpp | 30 +++++++++++----------------- tsd/src/tsd/io/images/ImageCache.hpp | 25 ++++++++++++++++++----- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp index d86878947..792edbe9a 100644 --- a/tsd/src/tsd/io/images/ImageCache.cpp +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -9,14 +9,10 @@ #include "tsd/io/importers/detail/importer_common.hpp" // std #include +#include #include #include -using U64Vec2 = tsd::math::vec; -namespace anari { -ANARI_TYPEFOR_SPECIALIZATION(U64Vec2, ANARI_UINT64_VEC2); -} - namespace tsd::io { using namespace tsd::core; @@ -159,14 +155,12 @@ Image ImageCache::store( Image image; image.vFlipInSampler = !normalizeRowOrder(decoded, source.rowOrder); - image.blockCompressed = decoded.blockCompressed; - if (decoded.blockCompressed) { + image.width = decoded.width; + image.height = decoded.height; + image.compressedFormat = decoded.compressedFormat; + if (image.blockCompressed()) { image.texels = m_scene->createArray(ANARI_INT8, decoded.texels.size()); image.texels->setData(decoded.texels.data()); - image.texels->setMetadataValue( - "compressedFormat", decoded.compressedFormat.value()); - image.texels->setMetadataValue( - "imageSize", U64Vec2(decoded.width, decoded.height)); } else { image.texels = m_scene->createArray( decoded.elementType, decoded.width, decoded.height); @@ -185,17 +179,17 @@ SamplerRef makeImageSampler(Scene &scene, if (!image) return {}; - auto sampler = scene.createObject(image.blockCompressed + auto sampler = scene.createObject(image.blockCompressed() ? tokens::sampler::compressedImage2D : tokens::sampler::image2D); sampler->setParameterObject("image", *image.texels); - if (image.blockCompressed) { - const auto compressedFormat = - image.texels->getMetadataValue("compressedFormat").getString(); - sampler->setParameter("format", compressedFormat.c_str()); - sampler->setParameter( - "size", image.texels->getMetadataValue("imageSize").get()); + if (image.blockCompressed()) { + sampler->setParameter("format", image.compressedFormat.c_str()); + // Passed untyped because no vector type in the tree maps onto + // ANARI_UINT64_VEC2, and this is the only parameter that wants one. + const std::uint64_t size[] = {image.width, image.height}; + sampler->setParameter("size", ANARI_UINT64_VEC2, size); } sampler->setParameter("inAttribute", settings.inAttribute); sampler->setParameter("wrapMode1", settings.wrapMode1); diff --git a/tsd/src/tsd/io/images/ImageCache.hpp b/tsd/src/tsd/io/images/ImageCache.hpp index 845d51146..975c31227 100644 --- a/tsd/src/tsd/io/images/ImageCache.hpp +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -3,6 +3,7 @@ #pragma once +#include "tsd/core/Token.hpp" #include "tsd/core/TypeMacros.hpp" #include "tsd/scene/Scene.hpp" // std @@ -53,14 +54,23 @@ struct ImageSource struct Image { tsd::scene::ArrayRef texels; - // Block-compressed texels are the authored block stream rather than a texel - // grid, so they cannot be reordered. - bool blockCompressed{false}; + // The picture's dimensions. Kept here rather than read back off the Array, + // whose shape does not carry them for block-compressed texels: those are + // the authored block stream rather than a texel grid. + size_t width{0}; + size_t height{0}; + // The ANARI block format ("BC1_RGB", "BC7_SRGB", ...) the texels are the + // block stream of; empty for a texel grid. A decoder that recognizes no + // format yields no image at all, so this doubles as "is block-compressed". + tsd::core::Token compressedFormat; // Set when the texels could not be brought into the order the source asked - // for -- only block-compressed ones -- so makeImageSampler compensates in - // the sampler's uv transform instead. + // for -- only block-compressed ones, which cannot be reordered without + // decoding and re-encoding -- so makeImageSampler compensates in the + // sampler's uv transform instead. bool vFlipInSampler{false}; + bool blockCompressed() const; + explicit operator bool() const; }; @@ -150,6 +160,11 @@ tsd::scene::SamplerRef makeImageSampler(ImageCache &cache, // Inlined definitions //////////////////////////////////////////////////////// +inline bool Image::blockCompressed() const +{ + return !compressedFormat.empty(); +} + inline Image::operator bool() const { return texels.valid(); From a0a72e9570e59c219b705ac9b3b06584dba4e7ad Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 23:02:12 -0500 Subject: [PATCH 57/78] refactor(io): let NetworkWalker read a string-or-token shader input Three readers of the UsdPreviewSurface network each open with the same TfToken-or-string unwrapping of a shader parameter. That access belongs to NetworkWalker, which already owns every other way the network is read. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/importers/detail/usd/UsdMaterials.cpp | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp index a878c1e2f..0d86166ec 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -53,6 +53,8 @@ struct NetworkWalker pxr::TfToken nodeId(const pxr::TfToken &path) const; pxr::VtValue parameter( const pxr::TfToken &nodePath, const char *paramName) const; + std::string stringParameter( + const pxr::TfToken &nodePath, const char *paramName) const; pxr::TfToken connectedNode( const pxr::TfToken &nodePath, const char *inputName) const; }; @@ -84,6 +86,20 @@ pxr::VtValue NetworkWalker::parameter( return value ? value->GetValue(0) : pxr::VtValue(); } +// Shader inputs that name something -- a wrap mode, a colour space, a primvar +// -- are authored as a TfToken by some exporters and a plain string by others, +// so both spellings have to be accepted wherever one is read. +std::string NetworkWalker::stringParameter( + const pxr::TfToken &nodePath, const char *paramName) const +{ + const auto value = parameter(nodePath, paramName); + if (value.IsHolding()) + return value.UncheckedGet().GetString(); + if (value.IsHolding()) + return value.UncheckedGet(); + return {}; +} + pxr::TfToken NetworkWalker::connectedNode( const pxr::TfToken &nodePath, const char *inputName) const { @@ -108,14 +124,8 @@ std::string uvPrimvarOfTexture( // A UsdTransform2d may sit between the texture and the reader. for (int hop = 0; hop < 4 && !current.IsEmpty(); ++hop) { const auto id = walker.nodeId(current); - if (id == PRIMVAR_READER_ID) { - const auto varname = walker.parameter(current, "varname"); - if (varname.IsHolding()) - return varname.UncheckedGet(); - if (varname.IsHolding()) - return varname.UncheckedGet().GetString(); - return {}; - } + if (id == PRIMVAR_READER_ID) + return walker.stringParameter(current, "varname"); if (id != TRANSFORM_2D_ID) return {}; current = walker.connectedNode(current, "in"); @@ -157,12 +167,7 @@ std::string wrapModeOf(const NetworkWalker &walker, const pxr::TfToken &texturePath, const char *input) { - const auto value = walker.parameter(texturePath, input); - std::string mode = "repeat"; - if (value.IsHolding()) - mode = value.UncheckedGet().GetString(); - else if (value.IsHolding()) - mode = value.UncheckedGet(); + const auto mode = walker.stringParameter(texturePath, input); if (mode == "clamp") return "clampToEdge"; if (mode == "mirror") @@ -178,13 +183,7 @@ bool textureIsLinear(const NetworkWalker &walker, const pxr::TfToken &texturePath, bool colorRole) { - const auto value = walker.parameter(texturePath, "sourceColorSpace"); - std::string space; - if (value.IsHolding()) - space = value.UncheckedGet().GetString(); - else if (value.IsHolding()) - space = value.UncheckedGet(); - + const auto space = walker.stringParameter(texturePath, "sourceColorSpace"); if (space == "raw") return true; if (space == "sRGB") From 904e3dde250ef595560f4e9a7548b34cd31014bc Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 23:03:56 -0500 Subject: [PATCH 58/78] refactor(io): build every non-dome light through one helper Four branches of convertLight repeated the same three decisions -- the subtype's object, the prim's name, and where the radiometry's colour and brightness land. Only the brightness parameter actually differs, so it is the one thing the caller still passes. The dome light stays hand-built: it has no `color` parameter, and contorting the helper around that would cost more than the branch does. Co-Authored-By: Claude Opus 5 (1M context) --- .../tsd/io/importers/detail/usd/UsdLights.cpp | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index 54c9fc462..8e5cf597b 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -111,6 +111,23 @@ LightRadiometry readRadiometry(const pxr::UsdPrim &prim, float area) return retval; } +// Every light is named after its prim and carries the radiometry read above; +// all that varies is which parameter the brightness lands on. The dome light +// is deliberately not built this way -- it has no `color`, because its colour +// is baked into the radiance it maps over the sphere. +LightRef makeLight(ImportContext &ctx, + const Token &subtype, + const pxr::SdfPath &primPath, + const LightRadiometry &radiometry, + const char *brightnessParameter) +{ + auto retval = ctx.scene->createObject(subtype); + retval->setName(primPath.GetName().c_str()); + retval->setParameter("color", radiometry.color); + retval->setParameter(brightnessParameter, radiometry.intensity); + return retval; +} + // A sphere or disk light carrying shaping attributes is a spot light; USD // expresses the cone as a half-angle plus a softness fraction. bool readShaping( @@ -219,11 +236,8 @@ LightRef convertLight(ImportContext &ctx, if (type == pxr::HdPrimTypeTokens->distantLight) { const auto radiometry = readRadiometry(usdPrim, 0.f); - auto light = ctx.scene->createObject(tokens::light::directional); - light->setName(primPath.GetName().c_str()); - light->setParameter("color", radiometry.color); - light->setParameter("irradiance", radiometry.intensity); - return light; + return makeLight( + ctx, tokens::light::directional, primPath, radiometry, "irradiance"); } if (type == pxr::HdPrimTypeTokens->rectLight) { @@ -234,10 +248,8 @@ LightRef convertLight(ImportContext &ctx, rectLight.GetHeightAttr().Get(&height); const auto radiometry = readRadiometry(usdPrim, width * height); - auto light = ctx.scene->createObject(tokens::light::quad); - light->setName(primPath.GetName().c_str()); - light->setParameter("color", radiometry.color); - light->setParameter("intensity", radiometry.intensity); + auto light = + makeLight(ctx, tokens::light::quad, primPath, radiometry, "intensity"); light->setParameter("position", float3(-0.5f * width, -0.5f * height, 0.f)); light->setParameter("edge1", float3(width, 0.f, 0.f)); light->setParameter("edge2", float3(0.f, height, 0.f)); @@ -260,20 +272,18 @@ LightRef convertLight(ImportContext &ctx, float openingAngle = 0.f; float falloffAngle = 0.f; if (readShaping(usdPrim, &openingAngle, &falloffAngle)) { - auto light = ctx.scene->createObject(tokens::light::spot); - light->setName(primPath.GetName().c_str()); - light->setParameter("color", radiometry.color); - light->setParameter("intensity", radiometry.intensity); + auto light = makeLight( + ctx, tokens::light::spot, primPath, radiometry, "intensity"); light->setParameter("openingAngle", openingAngle); light->setParameter("falloffAngle", falloffAngle); return light; } - auto light = ctx.scene->createObject( - isDisk ? tokens::light::ring : tokens::light::point); - light->setName(primPath.GetName().c_str()); - light->setParameter("color", radiometry.color); - light->setParameter("intensity", radiometry.intensity); + auto light = makeLight(ctx, + isDisk ? tokens::light::ring : tokens::light::point, + primPath, + radiometry, + "intensity"); light->setParameter("radius", radius); return light; } From 5825c982cc29aac64adbf411404b4555676529c0 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 23:06:08 -0500 Subject: [PATCH 59/78] docs(io): reword makeLight's comment around why, not what Co-Authored-By: Claude Opus 5 (1M context) --- tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp index 8e5cf597b..75aee38f5 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -111,10 +111,10 @@ LightRadiometry readRadiometry(const pxr::UsdPrim &prim, float area) return retval; } -// Every light is named after its prim and carries the radiometry read above; -// all that varies is which parameter the brightness lands on. The dome light -// is deliberately not built this way -- it has no `color`, because its colour -// is baked into the radiance it maps over the sphere. +// Light types differ only in subtype and in which parameter their brightness +// lands on, so those are what a caller passes. The dome light is the exception +// and stays hand-built: it has no `color`, its colour being baked into the +// radiance it maps over the sphere. LightRef makeLight(ImportContext &ctx, const Token &subtype, const pxr::SdfPath &primPath, From dcacba4c01f6082184f98016b9fa4403d9971235 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 23:13:08 -0500 Subject: [PATCH 60/78] refactor(io): record only the nodes native instancing places on Traversal::visit() wrote every prim on the Stage into InstancerRegistry::nodeForPrimPath, a Stage-sized map serving a lookup that attachNativeInstances() makes only for its own placement paths -- and that Stages without native instancing never make at all. InstancerRegistry now discovers those placement paths up front, from the instancers under the propagated-prototypes root, and recordNode() keeps a node only when a placement will be attached to it. The map and the lookup are private to the registry, so the general traversal no longer reaches into the instancing feature's data structure directly. --- .../io/importers/detail/usd/UsdInstancing.cpp | 48 +++++++++++++++++-- .../io/importers/detail/usd/UsdInstancing.h | 28 +++++++++-- tsd/src/tsd/io/importers/import_USD.cpp | 4 +- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index 9c6715c5d..0daa00318 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -17,6 +17,7 @@ #include // std #include +#include #include namespace tsd::io::usd { @@ -306,6 +307,49 @@ std::vector pointInstancerSampleTimes(const pxr::UsdPrim &prim) return retval; } +InstancerRegistry::InstancerRegistry( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex) +{ + // Every prim a native-instance placement will be attached to, read from the + // instancers before the traversal runs. Walking the propagated-prototypes + // subtree costs a fraction of the Stage, and on a Stage without native + // instancing there is nothing here to walk and nothing to record. + const pxr::SdfPath root(NATIVE_INSTANCING_ROOT); + if (sceneIndex->GetPrim(root).dataSource == nullptr + && sceneIndex->GetChildPrimPaths(root).empty()) + return; + + for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { + auto prim = sceneIndex->GetPrim(path); + if (prim.primType != pxr::HdPrimTypeTokens->instancer) + continue; + auto schema = + pxr::HdInstancerTopologySchema::GetFromParent(prim.dataSource); + if (!schema) + continue; + auto locations = schema.GetInstanceLocations(); + if (!locations) + continue; + for (const auto &location : locations->GetTypedValue(0)) + m_placementPaths.insert(location.GetString()); + } +} + +LayerNodeRef InstancerRegistry::nodeFor( + const pxr::SdfPath &primPath, LayerNodeRef fallback) const +{ + auto found = m_nodeForPrimPath.find(primPath.GetString()); + return found != m_nodeForPrimPath.end() ? found->second : fallback; +} + +void InstancerRegistry::recordNode( + const pxr::SdfPath &primPath, LayerNodeRef node) +{ + auto key = primPath.GetString(); + if (m_placementPaths.count(key)) + m_nodeForPrimPath[std::move(key)] = node; +} + void convertInstancer(ImportContext &ctx, const pxr::HdSceneIndexBaseRefPtr &sceneIndex, const pxr::SdfPath &primPath, @@ -413,10 +457,8 @@ void attachNativeInstances(ImportContext &ctx, // Each USD Instance becomes one node referencing the same shared // objects, so editing the Prototype's material affects every placement // as it does in USD. - const auto locationKey = placements.instanceLocations[i].GetString(); - auto found = registry.nodeForPrimPath.find(locationKey); auto placementNode = - found != registry.nodeForPrimPath.end() ? found->second : importRoot; + registry.nodeFor(placements.instanceLocations[i], importRoot); if (content->internalTransformsAnimated) { expandPrototype( diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h index acefc5379..3b0f1b3ca 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace tsd::io::usd { @@ -77,14 +78,33 @@ std::vector pointInstancerSampleTimes(const pxr::UsdPrim &prim); /* * State shared between the mirrored-hierarchy traversal and the instancing - * pass: where each USD prim landed in the Layer, and every Prototype converted - * so far. Native-instance placements are attached after the traversal, because - * their instancer lives outside the mirrored hierarchy. + * pass: where the prims that native instancing will place on landed in the + * Layer, and every Prototype converted so far. Native-instance placements are + * attached after the traversal, because their instancer lives outside the + * mirrored hierarchy. + * + * Which prims those are is discovered from the instancers up front, so that + * the traversal records a handful of nodes rather than one per prim on the + * Stage -- a Stage-sized map to serve a lookup native instancing makes only + * for its own placement paths, and not at all on the Stages that have none. */ struct InstancerRegistry { - std::unordered_map nodeForPrimPath; + explicit InstancerRegistry(const pxr::HdSceneIndexBaseRefPtr &sceneIndex); + + // Where a placement path landed in the Layer, or `fallback` if the traversal + // never reached it. + LayerNodeRef nodeFor( + const pxr::SdfPath &primPath, LayerNodeRef fallback) const; + + // Remember where a prim landed, if native instancing will place on it. + void recordNode(const pxr::SdfPath &primPath, LayerNodeRef node); + std::unordered_map> prototypes; + + private: + std::unordered_set m_placementPaths; + std::unordered_map m_nodeForPrimPath; }; // Turn one resolved instancer prim into instancing Layer content beneath diff --git a/tsd/src/tsd/io/importers/import_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index d3cce48ba..aadd2d48d 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -283,7 +283,7 @@ void Traversal::visit(const pxr::SdfPath &primPath, if (convertedAnything) ctx->report->convertedPrims++; - instancers->nodeForPrimPath[primPath.GetString()] = node; + instancers->recordNode(primPath, node); addTransformAnimation(*ctx, primPath, node); for (const auto &childPath : sceneIndex->GetChildPrimPaths(primPath)) @@ -349,7 +349,7 @@ UsdImportReport import_USD(Scene &scene, ? pxr::SdfPath::AbsoluteRootPath() : pxr::SdfPath(options.primPath); - InstancerRegistry instancers; + InstancerRegistry instancers(sceneIndex); Traversal traversal{&ctx, sceneIndex, &instancers}; scene.beginLayerEditBatch(); From b422918322705d6bfb484c78676402ff9f677e09 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 23:14:03 -0500 Subject: [PATCH 61/78] build(io): regroup the source list by directory again This branch's additions landed between existing groups: UsdImport.cpp and the images/ sources between animation/ and exporters/, and the three animation/Usd*FileBinding.cpp inside the importers/detail/ block. Put each back with its own directory, top-level source first, as the rest of tsd's source lists are written. No source is added or removed. --- tsd/src/tsd/io/CMakeLists.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 5c690f8b6..d6215f8b1 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -7,6 +7,7 @@ project_add_library(STATIC) project_sources( PRIVATE + UsdImport.cpp archives/SceneArchive.cpp archives/ObjectArchive.cpp archives/LayerSubtreeArchive.cpp @@ -22,21 +23,20 @@ PRIVATE archives/detail/SubtreeArchiveDeserialization.cpp animation/EnSightFileBinding.cpp animation/SpatialFieldFileBinding.cpp - UsdImport.cpp - images/ImageCache.cpp - images/detail/decoders.cpp + animation/UsdFileBinding.cpp + animation/UsdGeometryFileBinding.cpp + animation/UsdInstancerFileBinding.cpp exporters/NanoVdbSidecar.cpp exporters/SceneToUSD.cpp exporters/StructuredVolumeToNanoVDB.cpp + images/ImageCache.cpp + images/detail/decoders.cpp importers/detail/ensight_io.cpp importers/detail/HDRImage.cpp importers/detail/importer_common.cpp importers/detail/pbrt/PbrtLexer.cpp importers/detail/pbrt/PbrtParser.cpp importers/detail/pbrt/PbrtScene.cpp - animation/UsdFileBinding.cpp - animation/UsdGeometryFileBinding.cpp - animation/UsdInstancerFileBinding.cpp importers/import_AGX.cpp importers/import_ASSIMP.cpp importers/import_AXYZ.cpp From 8a72d3d9751ae71a0ad90f0cde867f427ea30eaa Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 18 Aug 2026 23:17:42 -0500 Subject: [PATCH 62/78] refactor(io): find the native instancers through one shared walk The registry's placement-path discovery and attachNativeInstances() each walked the propagated-prototypes subtree and filtered instancer prims out of it themselves. The two have to agree on that set for a placement to land on its own node, so give them one nativeInstancerPaths() to share and let attachNativeInstances() keep only the filtering that is its own. --- .../io/importers/detail/usd/UsdInstancing.cpp | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index 0daa00318..f6ee78628 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -215,6 +215,28 @@ std::vector readInstanceTransforms( return retval; } +// Every resolved instancer prim UsdImaging synthesised for native instancing. +// Both passes over that subtree -- discovering placement paths before the +// traversal, attaching Prototypes after it -- have to agree on this set, so +// they share the walk that finds it rather than each filtering their own. +std::vector nativeInstancerPaths( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex) +{ + std::vector retval; + + const pxr::SdfPath root(NATIVE_INSTANCING_ROOT); + if (sceneIndex->GetPrim(root).dataSource == nullptr + && sceneIndex->GetChildPrimPaths(root).empty()) + return retval; + + for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { + if (sceneIndex->GetPrim(path).primType == pxr::HdPrimTypeTokens->instancer) + retval.push_back(path); + } + + return retval; +} + } // namespace bool InstancerPlacements::isVisible(int instanceId) const @@ -310,24 +332,16 @@ std::vector pointInstancerSampleTimes(const pxr::UsdPrim &prim) InstancerRegistry::InstancerRegistry( const pxr::HdSceneIndexBaseRefPtr &sceneIndex) { - // Every prim a native-instance placement will be attached to, read from the - // instancers before the traversal runs. Walking the propagated-prototypes - // subtree costs a fraction of the Stage, and on a Stage without native - // instancing there is nothing here to walk and nothing to record. - const pxr::SdfPath root(NATIVE_INSTANCING_ROOT); - if (sceneIndex->GetPrim(root).dataSource == nullptr - && sceneIndex->GetChildPrimPaths(root).empty()) - return; - - for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { - auto prim = sceneIndex->GetPrim(path); - if (prim.primType != pxr::HdPrimTypeTokens->instancer) - continue; - auto schema = - pxr::HdInstancerTopologySchema::GetFromParent(prim.dataSource); + // Reading the placements the instancers name, rather than every path the + // traversal will see, is what keeps recordNode() to a handful of nodes on a + // large Stage -- and to none at all on the Stages with no native instancing, + // where there is nothing here to walk either. + for (const pxr::SdfPath &path : nativeInstancerPaths(sceneIndex)) { + const auto schema = pxr::HdInstancerTopologySchema::GetFromParent( + sceneIndex->GetPrim(path).dataSource); if (!schema) continue; - auto locations = schema.GetInstanceLocations(); + const auto locations = schema.GetInstanceLocations(); if (!locations) continue; for (const auto &location : locations->GetTypedValue(0)) @@ -426,19 +440,11 @@ void attachNativeInstances(ImportContext &ctx, InstancerRegistry ®istry, LayerNodeRef importRoot) { - const pxr::SdfPath root(NATIVE_INSTANCING_ROOT); - if (sceneIndex->GetPrim(root).dataSource == nullptr - && sceneIndex->GetChildPrimPaths(root).empty()) - return; - - for (const pxr::SdfPath &path : pxr::HdSceneIndexPrimView(sceneIndex, root)) { + for (const pxr::SdfPath &path : nativeInstancerPaths(sceneIndex)) { if (ctx.isClaimed(path)) continue; - auto prim = sceneIndex->GetPrim(path); - if (prim.primType != pxr::HdPrimTypeTokens->instancer) - continue; - const auto placements = readInstancerPlacements(prim); + const auto placements = readInstancerPlacements(sceneIndex->GetPrim(path)); if (placements.prototypes.empty() || placements.instanceLocations.empty()) continue; From 6e0c1a0fc060b531feadf2ce0f8d46e3960d692b Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 09:05:45 -0500 Subject: [PATCH 63/78] test(io): compare the empty-mesh material count against an empty Scene Splitting the USD import suite moved this baseline below the ImportedStage construction that runs the import, so the count was sampled after the fact and the REQUIRE compared it to itself. Take it from a fresh Scene, the way the sibling camera case already does. Co-Authored-By: Claude Opus 5 --- tsd/tests/test_UsdImport_geometry.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tsd/tests/test_UsdImport_geometry.cpp b/tsd/tests/test_UsdImport_geometry.cpp index 7f381ab1b..c8a124f89 100644 --- a/tsd/tests/test_UsdImport_geometry.cpp +++ b/tsd/tests/test_UsdImport_geometry.cpp @@ -243,8 +243,8 @@ def Mesh "SubdivQuad" } } -SCENARIO("Width-less curves and points get a bounds-scaled radius", - "[UsdImport]") +SCENARIO( + "Width-less curves and points get a bounds-scaled radius", "[UsdImport]") { // Blender hair exports commonly omit widths; without an explicit radius the // ANARI default of 1 world unit dwarfs most scenes. @@ -283,8 +283,8 @@ def Xform "World" auto geometry = findGeometry(stage.scene, "/World/Hair"); REQUIRE(geometry); REQUIRE(geometry->subtype() == tsd::scene::tokens::geometry::curve); - REQUIRE(geometry->parameterValueAsObject( - "vertex.radius") + REQUIRE( + geometry->parameterValueAsObject("vertex.radius") == nullptr); const auto radius = geometry->parameterValueAs("radius"); @@ -349,9 +349,11 @@ def Mesh "Empty" ( } )"); - // A Scene creates one default Material of its own, so what matters is - // that the import adds none. - const auto materialsBefore = stage.scene.numberOfObjects(ANARI_MATERIAL); + // A Scene creates one default Material of its own, so the count to + // compare against is an empty Scene's rather than this one's after the + // import. + const auto materialsBefore = + tsd::scene::Scene().numberOfObjects(ANARI_MATERIAL); WHEN("The Stage is imported") { From eb55470d22ec2ed452f12ee3e7cea37b399aeff0 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 09:05:52 -0500 Subject: [PATCH 64/78] style(io): clang-format the split USD import test files The split left the eight suite files and UsdTestFixtures.h unformatted, on the reasoning that formatting would bury the move. The move has landed, so run clang-format over them. The two hand-laid-out byte tables -- the TGA fixture and the EnSight coordinates -- are fenced off, since one value per line reads worse than the grid they describe. Co-Authored-By: Claude Opus 5 --- tsd/tests/UsdTestFixtures.h | 2 ++ tsd/tests/test_UsdImport_animation.cpp | 36 ++++++++++--------- tsd/tests/test_UsdImport_instancing.cpp | 8 ++--- tsd/tests/test_UsdImport_materials.cpp | 13 +++---- .../test_UsdImport_materials_portable.cpp | 5 ++- tsd/tests/test_UsdImport_subsets.cpp | 3 +- 6 files changed, 34 insertions(+), 33 deletions(-) diff --git a/tsd/tests/UsdTestFixtures.h b/tsd/tests/UsdTestFixtures.h index 20747953e..0f3bfb184 100644 --- a/tsd/tests/UsdTestFixtures.h +++ b/tsd/tests/UsdTestFixtures.h @@ -192,6 +192,7 @@ inline std::string ImportedStage::path() const inline TextureFixture::TextureFixture(const char *name) : m_path(fixtureDirectory() / name) { + // clang-format off const unsigned char tga[] = { 0, // no image ID 0, // no colour map @@ -204,6 +205,7 @@ inline TextureFixture::TextureFixture(const char *name) 0, // descriptor 0x20, 0x40, 0x60 // one BGR pixel }; + // clang-format on std::ofstream file(m_path, std::ios::binary); file.write(reinterpret_cast(tga), sizeof(tga)); } diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp index 90fdc7d70..0d9176085 100644 --- a/tsd/tests/test_UsdImport_animation.cpp +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -287,10 +287,12 @@ std::string writeEnSightDataset(const char *baseName) record(description); record("coordinates"); integer(3); + // clang-format off const float coordinates[9] = { 0.f, 1.f, 0.f, // x 0.f, 0.f, 1.f, // y 0.f, 0.f, 0.f}; // z + // clang-format on geo.write(reinterpret_cast(coordinates), sizeof(coordinates)); record("tria3"); integer(1); @@ -310,8 +312,8 @@ std::string writeEnSightDataset(const char *baseName) return casePath.string(); } -SCENARIO("EnSight parts take the materials their carrier prims bind", - "[UsdImport]") +SCENARIO( + "EnSight parts take the materials their carrier prims bind", "[UsdImport]") { GIVEN("A carrier scope and one of its parts each binding a material") { @@ -372,7 +374,8 @@ def Scope "Dataset" ( ) { rel material:binding = -)" + std::string(QUAD_MESH_BODY) + R"( } +)" + std::string(QUAD_MESH_BODY) + + R"( } def Mesh "part_two" ( customData = { @@ -382,15 +385,16 @@ def Scope "Dataset" ( } ) { -)" + std::string(QUAD_MESH_BODY) + R"( } +)" + std::string(QUAD_MESH_BODY) + + R"( } } )"); // Materials are named for the prim path they came from, so the name says // which Material prim the part ended up bound to. auto materialNameOfPart = [&](const char *partName) { - auto surface = findObject( - stage.scene, ANARI_SURFACE, partName); + auto surface = + findObject(stage.scene, ANARI_SURFACE, partName); REQUIRE(surface); auto *material = surface->parameterValueAsObject( tsd::scene::tokens::surface::material); @@ -413,8 +417,8 @@ def Scope "Dataset" ( } } -SCENARIO("Constant-valued time samples are not reported as a loss", - "[UsdImport]") +SCENARIO( + "Constant-valued time samples are not reported as a loss", "[UsdImport]") { GIVEN("A mesh whose visibility is authored at every frame but never changes") { @@ -559,8 +563,8 @@ def Mesh "Blob" } } -SCENARIO("A mesh whose topology changes re-pulls a consistent set", - "[UsdImport]") +SCENARIO( + "A mesh whose topology changes re-pulls a consistent set", "[UsdImport]") { GIVEN("A mesh whose points, indices and primvars all change together") { @@ -623,9 +627,8 @@ def Mesh "Morphing" REQUIRE(arraySize("vertex.color") == 4); // Every index has to address the positions that arrived with it. - auto *indices = - geometry->parameterValueAsObject( - "primitive.index"); + auto *indices = geometry->parameterValueAsObject( + "primitive.index"); const auto *triangles = indices->dataAs(); for (size_t i = 0; i < indices->size(); ++i) { REQUIRE(triangles[i].x < 4); @@ -700,10 +703,9 @@ def Mesh "Split" { auto positionArrayOf = [&](size_t i) { auto geometry = stage.scene.getObject(i); - return geometry - ? geometry->parameterValueAsObject( - "vertex.position") - : nullptr; + return geometry ? geometry->parameterValueAsObject( + "vertex.position") + : nullptr; }; const auto parts = stage.scene.numberOfObjects(ANARI_GEOMETRY); diff --git a/tsd/tests/test_UsdImport_instancing.cpp b/tsd/tests/test_UsdImport_instancing.cpp index 292f3bdca..691ec4cad 100644 --- a/tsd/tests/test_UsdImport_instancing.cpp +++ b/tsd/tests/test_UsdImport_instancing.cpp @@ -253,8 +253,8 @@ def PointInstancer "Swarm" } } -SCENARIO("An instancer whose placement count changes reallocates", - "[UsdImport]") +SCENARIO( + "An instancer whose placement count changes reallocates", "[UsdImport]") { GIVEN("A PointInstancer that gains a placement mid-sequence") { @@ -360,8 +360,8 @@ def PointInstancer "Swarm" } } -SCENARIO("A Stage that authored no time-code range still animates", - "[UsdImport]") +SCENARIO( + "A Stage that authored no time-code range still animates", "[UsdImport]") { GIVEN("A PointInstancer with time samples but no startTimeCode") { diff --git a/tsd/tests/test_UsdImport_materials.cpp b/tsd/tests/test_UsdImport_materials.cpp index f4e1d8c02..71e8959ba 100644 --- a/tsd/tests/test_UsdImport_materials.cpp +++ b/tsd/tests/test_UsdImport_materials.cpp @@ -177,9 +177,8 @@ def Xform "World" StageFixture stage("tsd_test_usd_materialx.usda", materialxNetwork); tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_file(scene, - animMgr, - {tsd::io::ImporterType::USD_MTLX, stage.path()}); + tsd::io::import_file( + scene, animMgr, {tsd::io::ImporterType::USD_MTLX, stage.path()}); THEN("MaterialX materials arrive without asking for options") { @@ -193,9 +192,8 @@ def Xform "World" StageFixture stage("tsd_test_usd_materialx.usda", materialxNetwork); tsd::scene::Scene scene; tsd::animation::AnimationManager animMgr(&scene); - tsd::io::import_file(scene, - animMgr, - {tsd::io::ImporterType::USD, stage.path()}); + tsd::io::import_file( + scene, animMgr, {tsd::io::ImporterType::USD, stage.path()}); THEN("The portable mapping is what arrives") { @@ -262,8 +260,7 @@ def Xform "World" ImportedStage stage( "tsd_test_usd_materialx_textures.usda", texturedNetwork, options); - const auto source = - stringParameter(boundMaterial(stage.scene), "source"); + const auto source = stringParameter(boundMaterial(stage.scene), "source"); THEN("Texture paths leave as absolute paths") { diff --git a/tsd/tests/test_UsdImport_materials_portable.cpp b/tsd/tests/test_UsdImport_materials_portable.cpp index 8eb988973..d17741ff0 100644 --- a/tsd/tests/test_UsdImport_materials_portable.cpp +++ b/tsd/tests/test_UsdImport_materials_portable.cpp @@ -341,9 +341,8 @@ def Xform "World" { // The authored preview surface is what this material actually says; // OmniPBRBase is a different shader with input semantics of its own. - auto color = - boundMaterial(stage.scene)->parameterValueAs( - "baseColor"); + auto color = boundMaterial(stage.scene) + ->parameterValueAs("baseColor"); REQUIRE(color.has_value()); REQUIRE(color->z == Approx(0.9f)); } diff --git a/tsd/tests/test_UsdImport_subsets.cpp b/tsd/tests/test_UsdImport_subsets.cpp index 8d00f9024..789ccde67 100644 --- a/tsd/tests/test_UsdImport_subsets.cpp +++ b/tsd/tests/test_UsdImport_subsets.cpp @@ -212,7 +212,8 @@ SCENARIO( // producing a copy of the input, a distinct result from Success that the // conversion must not mistake for failure -- pre-triangulated exports // carry every face-varying primvar down this path. - ImportedStage stage("tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 + ImportedStage stage( + "tsd_test_usd_triangulated_facevarying.usda", R"(#usda 1.0 def Xform "World" { From 1fccb4a8cf65e75f667957480d0d6990685718e3 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 09:21:14 -0500 Subject: [PATCH 65/78] fix(io): check the magic before handing a file to the NanoVDB reader nanovdb::io::readGrid does not bound its own reads. On a file too short to hold a header it retries against a stream already at EOF and never returns -- an empty file, four bytes of text, and a truncated OpenVDB grid all hang it indefinitely, with no timeout to escape through. A file long enough to read a header out of throws cleanly, which is why this went unnoticed: a real OpenVDB grid is fine, an interrupted download of one is not. Read the 8-byte magic first and reject anything that is not one of the three NanoVDB numbers. Teaching import_spatial_field '.vdb' put this behind the viewer's drag-drop, so the hang is now reachable by dropping a stray file rather than only by naming one on the command line. An OpenVDB grid says so specifically. '.vdb' means NanoVDB on this path, which the extension gives no hint of, so the error names the converter that turns one into the other. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/importers/import_NVDB.cpp | 37 +++++++++++++++++ tsd/tests/test_Importers.cpp | 53 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/tsd/src/tsd/io/importers/import_NVDB.cpp b/tsd/src/tsd/io/importers/import_NVDB.cpp index 1784bbd57..25ba9b262 100644 --- a/tsd/src/tsd/io/importers/import_NVDB.cpp +++ b/tsd/src/tsd/io/importers/import_NVDB.cpp @@ -13,7 +13,9 @@ #include #include +#include #include +#include #include #include @@ -21,12 +23,47 @@ namespace tsd::io { using namespace tsd::core; +namespace { + +// nanovdb::io::readGrid does not bound its own reads: on a file too short to +// hold a header it retries against a stream that is already at EOF and never +// returns. A truncated download or an empty placeholder therefore hangs +// whatever called it, with no timeout to escape through, so check the magic +// ourselves before handing the path over. +bool hasNanoVdbMagic(const char *filepath, uint64_t &magic) +{ + magic = 0; + std::ifstream file(filepath, std::ios::binary); + if (!file.read(reinterpret_cast(&magic), sizeof(magic))) + return false; + return magic == NANOVDB_MAGIC_NUMB || magic == NANOVDB_MAGIC_FILE + || magic == NANOVDB_MAGIC_GRID; +} + +} // namespace + SpatialFieldRef import_NVDB(Scene &scene, const char *filepath) { std::string file = fileOf(filepath); if (file.empty()) return {}; + uint64_t magic = 0; + if (!hasNanoVdbMagic(filepath, magic)) { + // '.vdb' names a NanoVDB grid here, not an OpenVDB one: tsdVolumeToNanoVDB + // writes NanoVDB under that extension. Say so, because a file from any + // other OpenVDB tool lands on this path and the extension gives no hint. + if (nanovdb::toMagic(magic) == nanovdb::MagicType::OpenVDB) { + logError( + "[import_NVDB] '%s' is an OpenVDB file; TSD reads NanoVDB grids, " + "which tsdVolumeToNanoVDB can convert one into", + filepath); + } else { + logError("[import_NVDB] '%s' is not a NanoVDB file", filepath); + } + return {}; + } + const std::filesystem::path nvdbPath(filepath); const auto sidecarPath = makeSidecarPath(nvdbPath); diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index 5c2b61148..f9d653aaf 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -113,6 +113,59 @@ SCENARIO("The spatial field dispatcher reads NanoVDB under both of its names", std::filesystem::remove(vdbPath); } +SCENARIO("The NanoVDB reader rejects a file it cannot be holding a grid", + "[Importers]") +{ + // nanovdb::io::readGrid never returns on a file too short to hold a header, + // so a stray '.vdb' -- an interrupted download, an empty placeholder, an + // actual OpenVDB grid -- would hang whatever asked for it. If this scenario + // ever times out rather than failing, that guard is gone. + const auto path = + std::filesystem::temp_directory_path() / "tsd_test_not_a_grid.vdb"; + + auto writeBytes = [&](const void *bytes, size_t numBytes) { + std::ofstream file(path, std::ios::binary); + file.write(static_cast(bytes), numBytes); + }; + + tsd::scene::Scene scene; + + GIVEN("An empty file under a '.vdb' name") + { + writeBytes(nullptr, 0); + + THEN("No field arrives") + { + REQUIRE(!tsd::io::import_spatial_field(scene, path.string().c_str())); + } + } + + GIVEN("A file too short to hold a header") + { + const unsigned char bytes[4] = {'N', 'a', 'n', 'o'}; + writeBytes(bytes, sizeof(bytes)); + + THEN("No field arrives") + { + REQUIRE(!tsd::io::import_spatial_field(scene, path.string().c_str())); + } + } + + GIVEN("An OpenVDB grid under a '.vdb' name") + { + // The magic OpenVDB writes, then nothing that follows it. + const unsigned char bytes[8] = {0x20, 0x42, 0x44, 0x56, 0, 0, 0, 0}; + writeBytes(bytes, sizeof(bytes)); + + THEN("No field arrives") + { + REQUIRE(!tsd::io::import_spatial_field(scene, path.string().c_str())); + } + } + + std::filesystem::remove(path); +} + namespace { // A 1x1 uncompressed grey+alpha 8-bit TIFF. Two channels is the case where From 7bcc7f01f7a0db6aba6fd510dfdb3eab37d5f23c Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 09:26:12 -0500 Subject: [PATCH 66/78] test(io): cover the HDF5 half of the shared spatial-field dispatcher The Silo scenario stood alone for a change that also put '.hdf5' within reach of a UsdVol field for the first time. Add its counterpart, and assert on both that the dispatcher's own "no loader for file type" fallback stayed quiet, so neither can pass on a file that reached no importer at all. Both now write the file they name. It only has to exist: a filePath that resolves to nothing stays the bare name the Stage authored, and import_FLASH reads no such name, so the scenario would have measured that rather than which importer the extension chose. Co-Authored-By: Claude Opus 5 --- tsd/tests/test_UsdImport_volumes.cpp | 56 +++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tsd/tests/test_UsdImport_volumes.cpp b/tsd/tests/test_UsdImport_volumes.cpp index 034bc5c00..64e8c8c4a 100644 --- a/tsd/tests/test_UsdImport_volumes.cpp +++ b/tsd/tests/test_UsdImport_volumes.cpp @@ -52,6 +52,28 @@ struct RawFieldFixture std::filesystem::path m_path; }; +// A field file next to the Stage that holds nothing any importer can read. +// Which importer an extension reaches is the question; the file only has to +// exist, so that `filePath` resolves to a real path the way it does in an +// asset rather than staying the bare name the Stage authored. +struct UnreadableFieldFixture +{ + explicit UnreadableFieldFixture(const char *name) + : m_path(fixtureDirectory() / name) + { + std::ofstream file(m_path, std::ios::binary); + } + + ~UnreadableFieldFixture() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + private: + std::filesystem::path m_path; +}; + // Collects log messages for the lifetime of one scenario. Which importer a // file extension reaches is otherwise invisible when the file named is a // stand-in that no importer can actually read. @@ -269,6 +291,7 @@ SCENARIO("Volume fields go through the shared spatial-field dispatcher", { GIVEN("A Volume prim whose field names a Silo file") { + UnreadableFieldFixture field("tsd_test_usd_field.silo"); LogCapture log; ImportedStage stage("tsd_test_usd_volume_silo.usda", R"(#usda 1.0 @@ -288,8 +311,39 @@ def Volume "Vol" THEN("The Silo importer is the one asked for the field") { // The extension chain the USD importer used to hand-roll knew nothing - // of Silo, so this file never reached an importer at all. + // of Silo, so this file fell off the end of it. Only import_SILO logs + // under this prefix, in either build configuration, so seeing it is + // proof the dispatcher routed the extension; not seeing the fallback + // is proof nothing routed it by accident. REQUIRE(log.sawMessageContaining("[import_SILO]")); + REQUIRE(!log.sawMessageContaining("no loader for file type")); + } + } + } + + GIVEN("A Volume prim whose field names a FLASH-in-HDF5 file") + { + UnreadableFieldFixture field("tsd_test_usd_field.hdf5"); + LogCapture log; + ImportedStage stage("tsd_test_usd_volume_hdf5.usda", R"(#usda 1.0 + +def Volume "Vol" +{ + rel field:density = + + def "Density" + { + asset filePath = @tsd_test_usd_field.hdf5@ + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The FLASH importer is the one asked for the field") + { + REQUIRE(log.sawMessageContaining("[import_FLASH]")); + REQUIRE(!log.sawMessageContaining("no loader for file type")); } } } From 53a3dafdf6c41906f74c0a6d71a468c955d88b11 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 09:40:56 -0500 Subject: [PATCH 67/78] fix(io): let fileOf() name a file that was given no directory fileOf() scanned for the last path separator and answered "" when there was none, so a bare 'volume.raw' -- the form anyone types for a file in the working directory -- reported no file at all. Six importers guard on that result before doing any work, which made them silent no-ops; the thirty-odd call sites that only wanted a display name got an empty one, including every Sampler an importer names by a scoped id rather than a path. Hand both halves to std::filesystem::path, which knows a path may carry no directory and, on Windows, that '/' separates as well as '\'. The one-character scan missed that too. pathOf() keeps its trailing separator, since callers concatenate a sibling file onto it, and keeps answering "" when there is no directory -- that answer was always right. Co-Authored-By: Claude Opus 5 --- .../io/importers/detail/importer_common.cpp | 30 +++++--- tsd/tests/test_Importers.cpp | 75 +++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 5efd9afa4..032a54f30 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -36,20 +36,30 @@ constexpr char path_sep = '\\'; constexpr char path_sep = '/'; #endif +// Both of these defer the separator scanning to std::filesystem::path, which +// knows that Windows accepts '/' as well as '\\'. Scanning for one character +// missed that, and missed the bare filename -- 'volume.raw' has no separator +// at all, and reporting no file for it turned every importer that guards on +// the result into a silent no-op for a path typed relative to the cwd. + +// The directory `filepath` names, with the trailing separator kept so callers +// can concatenate a sibling file onto it. Empty when the path names no +// directory. std::string pathOf(const std::string &filepath) { - size_t pos = filepath.find_last_of(path_sep); - if (pos == std::string::npos) + auto parent = std::filesystem::path(filepath).parent_path().string(); + if (parent.empty()) return ""; - return filepath.substr(0, pos + 1); + if (parent.back() != '/' && parent.back() != '\\') + parent += path_sep; + return parent; } +// The file `filepath` names, without its directory. Empty only when the path +// names no file -- it is empty itself, or ends in a separator. std::string fileOf(const std::string &filepath) { - size_t pos = filepath.find_last_of(path_sep); - if (pos == std::string::npos) - return ""; - return filepath.substr(pos + 1, filepath.size()); + return std::filesystem::path(filepath).filename().string(); } std::string extensionOf(const std::string &filepath) @@ -398,7 +408,7 @@ static core::TransferFunction importParaViewTransferFunction( filepath.c_str()); return {}; } else if (const auto arrayStart = jsonContent.find("[", rgbPointsPos); - arrayStart == std::string::npos) { + arrayStart == std::string::npos) { logError( "[importParaViewTransferFunction] Invalid RGBPoints format in file: %s", filepath.c_str()); @@ -483,7 +493,7 @@ static core::TransferFunction importParaViewTransferFunction( std::istringstream opacitySS(opacityContent); for (std::string opacityToken; - std::getline(opacitySS, opacityToken, ',');) { + std::getline(opacitySS, opacityToken, ',');) { // Trim whitespace if (const auto first = opacityToken.find_first_not_of(" \t\n\r"); first != std::string::npos) { @@ -611,7 +621,7 @@ std::vector loadUserColorMaps( std::vector files; for (fs::directory_iterator it(directory, ec), end; !ec && it != end; - it.increment(ec)) { + it.increment(ec)) { const auto &entry = *it; if (!entry.is_regular_file(ec)) continue; diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index f9d653aaf..91ee8fc0d 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -16,6 +16,81 @@ #include #include +SCENARIO("Splitting a path names its file and its directory", "[Importers]") +{ + GIVEN("A path with a directory component") + { + THEN("The two halves rejoin into the original") + { + REQUIRE(tsd::io::fileOf("/a/b/volume.raw") == "volume.raw"); + REQUIRE(tsd::io::pathOf("/a/b/volume.raw") == "/a/b/"); + REQUIRE(tsd::io::fileOf("b/volume.raw") == "volume.raw"); + REQUIRE(tsd::io::pathOf("b/volume.raw") == "b/"); + } + } + + GIVEN("A bare filename, as typed relative to the working directory") + { + THEN("It is the file, and there is no directory") + { + // Importers guard on fileOf() being non-empty before doing any work, so + // answering "no file" here made every one of them a silent no-op. + REQUIRE(tsd::io::fileOf("volume.raw") == "volume.raw"); + REQUIRE(tsd::io::pathOf("volume.raw").empty()); + } + } + + GIVEN("A path that names a directory rather than a file") + { + THEN("There is no file") + { + REQUIRE(tsd::io::fileOf("/a/b/").empty()); + REQUIRE(tsd::io::fileOf("").empty()); + } + } + + GIVEN("A file directly under the root") + { + THEN("The directory is the root, and is not doubled") + { + REQUIRE(tsd::io::fileOf("/volume.raw") == "volume.raw"); + REQUIRE(tsd::io::pathOf("/volume.raw") == "/"); + } + } +} + +SCENARIO("A volume imports under a name relative to the working directory", + "[Importers]") +{ + // The dimensions and voxel type come out of the filename, so an importer + // needs the file half of the path whether or not a directory was given. + const auto directory = std::filesystem::temp_directory_path(); + const char *name = "tsd_test_relative_2x2x2_uint8.raw"; + { + std::ofstream file(directory / name, std::ios::binary); + const unsigned char voxels[8] = {0, 32, 64, 96, 128, 160, 192, 255}; + file.write(reinterpret_cast(voxels), sizeof(voxels)); + } + + tsd::scene::Scene scene; + + WHEN("The file is named without any directory") + { + const auto previous = std::filesystem::current_path(); + std::filesystem::current_path(directory); + auto field = tsd::io::import_spatial_field(scene, name); + std::filesystem::current_path(previous); + + THEN("It reads, and carries the name it was asked for") + { + REQUIRE(field); + REQUIRE(field->name() == name); + } + } + + std::filesystem::remove(directory / name); +} + SCENARIO( "Volume transfer functions reject missing control points", "[Importers]") { From a41fbc33dcceac2bc38a15c347a9ed62369190b9 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 09:41:07 -0500 Subject: [PATCH 68/78] refactor(io): stop guarding importers on a filename being non-empty Six importers opened with `if (fileOf(path).empty()) return;`, which only ever fired for the bare filename fileOf() used to misread. With that fixed it can only mean a path that is empty or names a directory, and the work below it already copes: RAW reads no dimensions out of no name, FLASH and NVDB fail to open, and the display name is simply empty. import_XYZDP and import_E57XYZ needed a real check first. Both fread() straight off the fopen() result without testing it, so the filename guard was the only thing between them and a null FILE* -- and only by accident, since it never looked at whether the file existed. Check what they actually depend on. import_AGX keeps its guard: it is the one that says something. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/importers/import_AXYZ.cpp | 2 -- tsd/src/tsd/io/importers/import_E57XYZ.cpp | 9 ++++++--- tsd/src/tsd/io/importers/import_FLASH.cpp | 2 -- tsd/src/tsd/io/importers/import_NVDB.cpp | 2 -- tsd/src/tsd/io/importers/import_RAW.cpp | 2 -- tsd/src/tsd/io/importers/import_XYZDP.cpp | 9 ++++++--- 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/tsd/src/tsd/io/importers/import_AXYZ.cpp b/tsd/src/tsd/io/importers/import_AXYZ.cpp index 601b21d0c..2e3bc6482 100644 --- a/tsd/src/tsd/io/importers/import_AXYZ.cpp +++ b/tsd/src/tsd/io/importers/import_AXYZ.cpp @@ -23,8 +23,6 @@ void import_AXYZ(Scene &scene, LayerNodeRef location) { std::string file = fileOf(filepath); - if (file.empty()) - return; // load particle data from file // diff --git a/tsd/src/tsd/io/importers/import_E57XYZ.cpp b/tsd/src/tsd/io/importers/import_E57XYZ.cpp index 336cd9d5c..e9e2a9a7b 100644 --- a/tsd/src/tsd/io/importers/import_E57XYZ.cpp +++ b/tsd/src/tsd/io/importers/import_E57XYZ.cpp @@ -23,13 +23,16 @@ void import_E57XYZ(Scene &scene, { (void)animMgr; std::string file = fileOf(filepath); - if (file.empty()) - return; // load particle data from file // - uint64_t numParticles = 0; auto *fp = std::fopen(filepath, "rb"); + if (!fp) { + logError("[import_e57xyz] could not open file %s", filepath); + return; + } + + uint64_t numParticles = 0; auto r = std::fread(&numParticles, sizeof(numParticles), 1, fp); logInfo( diff --git a/tsd/src/tsd/io/importers/import_FLASH.cpp b/tsd/src/tsd/io/importers/import_FLASH.cpp index 65ec27164..12777b059 100644 --- a/tsd/src/tsd/io/importers/import_FLASH.cpp +++ b/tsd/src/tsd/io/importers/import_FLASH.cpp @@ -418,8 +418,6 @@ struct FlashReader SpatialFieldRef import_FLASH(Scene &scene, const char *filepath) { std::string file = fileOf(filepath); - if (file.empty()) - return {}; FlashReader reader; if (!reader.open(filepath)) { diff --git a/tsd/src/tsd/io/importers/import_NVDB.cpp b/tsd/src/tsd/io/importers/import_NVDB.cpp index 25ba9b262..eee449d7e 100644 --- a/tsd/src/tsd/io/importers/import_NVDB.cpp +++ b/tsd/src/tsd/io/importers/import_NVDB.cpp @@ -45,8 +45,6 @@ bool hasNanoVdbMagic(const char *filepath, uint64_t &magic) SpatialFieldRef import_NVDB(Scene &scene, const char *filepath) { std::string file = fileOf(filepath); - if (file.empty()) - return {}; uint64_t magic = 0; if (!hasNanoVdbMagic(filepath, magic)) { diff --git a/tsd/src/tsd/io/importers/import_RAW.cpp b/tsd/src/tsd/io/importers/import_RAW.cpp index f5e723055..20aa18bde 100644 --- a/tsd/src/tsd/io/importers/import_RAW.cpp +++ b/tsd/src/tsd/io/importers/import_RAW.cpp @@ -12,8 +12,6 @@ namespace tsd::io { SpatialFieldRef import_RAW(Scene &scene, const char *filepath) { std::string file = fileOf(filepath); - if (file.empty()) - return {}; int dimX = 0, dimY = 0, dimZ = 0; anari::DataType type = ANARI_UNKNOWN; diff --git a/tsd/src/tsd/io/importers/import_XYZDP.cpp b/tsd/src/tsd/io/importers/import_XYZDP.cpp index ecee734d8..1b699eb59 100644 --- a/tsd/src/tsd/io/importers/import_XYZDP.cpp +++ b/tsd/src/tsd/io/importers/import_XYZDP.cpp @@ -21,13 +21,16 @@ void import_XYZDP(Scene &scene, { (void)animMgr; std::string file = fileOf(filepath); - if (file.empty()) - return; // load particle data from file // - uint64_t numParticles = 0; auto *fp = std::fopen(filepath, "rb"); + if (!fp) { + logError("[import_XYZ] could not open file %s", filepath); + return; + } + + uint64_t numParticles = 0; auto r = std::fread(&numParticles, sizeof(numParticles), 1, fp); logInfo( From b681fd1cd36dbeda679c80bc8ef0a842e996ab76 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 10:06:13 -0500 Subject: [PATCH 69/78] test(io): pin what an EnSight carrier's material outranks import_ENSIGHT picks a part's material as per-part binding > carrier binding > scalar colormap > default, and converting carrier bindings turned on two rungs that had never run. Which one wins over the colormap was left to whoever reads the if-chain. Cover it both ways over one dataset: with no carrier binding the part takes the colormap built from the field, and with one it takes the bound material instead. The dataset writer grows a scalar variable to have something for a colormap to be built from, and its two record writers move out to file scope so the variable file can use them too. Co-Authored-By: Claude Opus 5 --- tsd/tests/test_UsdImport_animation.cpp | 214 ++++++++++++++++++++----- 1 file changed, 177 insertions(+), 37 deletions(-) diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp index 0d9176085..c53267875 100644 --- a/tsd/tests/test_UsdImport_animation.cpp +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -254,13 +254,32 @@ def Mesh "Real" } } +// EnSight Gold writes its strings as fixed 80-byte records and its numbers +// raw, so both files below are laid out with these two. +void writeRecord(std::ofstream &out, const char *text) +{ + char buffer[80] = {}; + std::strncpy(buffer, text, sizeof(buffer) - 1); + out.write(buffer, sizeof(buffer)); +} + +void writeInteger(std::ofstream &out, int32_t value) +{ + out.write(reinterpret_cast(&value), sizeof(value)); +} + // A minimal EnSight Gold dataset -- two single-triangle parts -- written into // the shared fixture directory in the binary geometry format import_ENSIGHT // accepts. The files live for the lifetime of the test binary along with every // other fixture in that directory. -std::string writeEnSightDataset(const char *baseName) +// +// With `withScalarField`, the dataset also carries one node-centered scalar, +// which is what makes import_ENSIGHT synthesize a colormap material for a part +// that has no material bound to it. +std::string writeEnSightDataset(const char *baseName, bool withScalarField) { const auto geoName = std::string(baseName) + ".geo"; + const auto scalarName = std::string(baseName) + ".scl"; const auto casePath = fixtureDirectory() / (std::string(baseName) + ".case"); const auto geoPath = fixtureDirectory() / geoName; @@ -270,44 +289,58 @@ std::string writeEnSightDataset(const char *baseName) << "type: ensight gold\n" << "GEOMETRY\n" << "model: " << geoName << "\n"; + if (withScalarField) { + caseFile << "VARIABLE\n" + << "scalar per node: density " << scalarName << "\n"; + } } - std::ofstream geo(geoPath, std::ios::binary); - auto record = [&](const char *text) { - char buffer[80] = {}; - std::strncpy(buffer, text, sizeof(buffer) - 1); - geo.write(buffer, sizeof(buffer)); - }; - auto integer = [&](int32_t value) { - geo.write(reinterpret_cast(&value), sizeof(value)); - }; - auto singleTrianglePart = [&](int32_t id, const char *description) { - record("part"); - integer(id); - record(description); - record("coordinates"); - integer(3); - // clang-format off - const float coordinates[9] = { - 0.f, 1.f, 0.f, // x - 0.f, 0.f, 1.f, // y - 0.f, 0.f, 0.f}; // z - // clang-format on - geo.write(reinterpret_cast(coordinates), sizeof(coordinates)); - record("tria3"); - integer(1); - integer(1); - integer(2); - integer(3); - }; + const int32_t partIds[2] = {1, 2}; + const char *partDescriptions[2] = {"part_one", "part_two"}; + constexpr int numNodes = 3; - record("C Binary"); - record("TSD test dataset"); - record("two single-triangle parts"); - record("node id off"); - record("element id off"); - singleTrianglePart(1, "part_one"); - singleTrianglePart(2, "part_two"); + { + std::ofstream geo(geoPath, std::ios::binary); + writeRecord(geo, "C Binary"); + writeRecord(geo, "TSD test dataset"); + writeRecord(geo, "two single-triangle parts"); + writeRecord(geo, "node id off"); + writeRecord(geo, "element id off"); + + for (int i = 0; i < 2; ++i) { + writeRecord(geo, "part"); + writeInteger(geo, partIds[i]); + writeRecord(geo, partDescriptions[i]); + writeRecord(geo, "coordinates"); + writeInteger(geo, numNodes); + // clang-format off + const float coordinates[9] = { + 0.f, 1.f, 0.f, // x + 0.f, 0.f, 1.f, // y + 0.f, 0.f, 0.f}; // z + // clang-format on + geo.write( + reinterpret_cast(coordinates), sizeof(coordinates)); + writeRecord(geo, "tria3"); + writeInteger(geo, 1); + writeInteger(geo, 1); + writeInteger(geo, 2); + writeInteger(geo, 3); + } + } + + if (withScalarField) { + std::ofstream scalar(fixtureDirectory() / scalarName, std::ios::binary); + writeRecord(scalar, "per node scalar values"); + for (int i = 0; i < 2; ++i) { + writeRecord(scalar, "part"); + writeInteger(scalar, partIds[i]); + writeRecord(scalar, "coordinates"); + // The values only have to span a range for a colormap to be built over. + const float values[numNodes] = {0.f, 0.5f, 1.f}; + scalar.write(reinterpret_cast(values), sizeof(values)); + } + } return casePath.string(); } @@ -319,7 +352,8 @@ SCENARIO( { // Claimed Prims never reach the resolved traversal, so the materials they // bind only convert if the dialect importer asks for them itself. - const auto caseFile = writeEnSightDataset("tsd_test_ensight_materials"); + const auto caseFile = + writeEnSightDataset("tsd_test_ensight_materials", false); ImportedStage stage("tsd_test_usd_ensight_materials.usda", R"(#usda 1.0 ( @@ -417,6 +451,112 @@ def Scope "Dataset" ( } } +SCENARIO("A material bound on an EnSight carrier outranks the scalar colormap", + "[UsdImport]") +{ + // import_ENSIGHT picks a part's material as + // per-part binding > carrier binding > scalar colormap > default, + // but until carrier bindings converted at all, neither binding rung could + // ever win. Both halves below read the same dataset, so the only thing that + // differs is whether the carrier binds anything. + // + // The carriers map a field explicitly. The dialect always hands + // import_ENSIGHT a field list, so an unmapped variable is loaded by nobody + // and there would be no colormap for a binding to outrank. + const auto caseFile = writeEnSightDataset("tsd_test_ensight_colormap", true); + + auto materialOfPart = [](ImportedStage &stage, const char *partName) { + auto surface = + findObject(stage.scene, ANARI_SURFACE, partName); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + return material; + }; + + const auto stageBody = [&](const char *carrierBinding) { + return R"(#usda 1.0 +( + customLayerData = { + dictionary ensight = { + string caseFile = ")" + + caseFile + R"(" + } + } +) + +def Scope "Looks" +{ + def Material "Shared" + { + token outputs:surface.connect = + + def Shader "PBR" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (1, 0, 0) + token outputs:surface + } + } +} + +def Scope "Dataset" ( + prepend apiSchemas = ["MaterialBindingAPI"] +) +{ + custom string ensight:fieldMapping:attribute0 = "density" +)" + std::string(carrierBinding) + + R"( + def Mesh "part_one" ( + customData = { + dictionary ensight = { + string partName = "part_one" + } + } + ) + { +)" + std::string(QUAD_MESH_BODY) + + R"( } +} +)"; + }; + + GIVEN("A carrier that binds no material over a dataset with a scalar field") + { + ImportedStage stage( + "tsd_test_usd_ensight_colormap_unbound.usda", stageBody("")); + + WHEN("The Stage is imported") + { + THEN("The part takes the colormap built from that field") + { + // The colormap material is synthesized rather than converted from a + // prim, so it carries no name -- unlike the Scene's own default, which + // is a matte one called "default". + auto *material = materialOfPart(stage, "part_one"); + REQUIRE(material->name().empty()); + REQUIRE(material->subtype() + == tsd::scene::tokens::material::physicallyBased); + } + } + } + + GIVEN("The same dataset under a carrier that does bind one") + { + ImportedStage stage("tsd_test_usd_ensight_colormap_bound.usda", + stageBody(" rel material:binding = ")); + + WHEN("The Stage is imported") + { + THEN("The bound material wins and no colormap is built") + { + REQUIRE(materialOfPart(stage, "part_one")->name() == "/Looks/Shared"); + } + } + } +} + SCENARIO( "Constant-valued time samples are not reported as a loss", "[UsdImport]") { From 3e49027798f86ac5ac3d7cb9cfd271c9d2168619 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 10:15:12 -0500 Subject: [PATCH 70/78] fix(io): let pathOf() keep the separator the path already used pathOf() rebuilt its answer from parent_path() and appended the platform's own separator, so on Windows '/a/b/volume.raw' came back as '/a/b\' -- Windows accepts a forward slash, and the two halves no longer rejoined into the path that was passed in. Callers concatenate them to reach a sibling file, so that is the property that matters. Take the prefix fileOf() left behind instead. The separator is then whichever one the path was written with, on either platform, and the split is reversible by construction. Assert that directly rather than only through expected strings, since the expected strings were what quietly passed on Linux. Co-Authored-By: Claude Opus 5 --- .../io/importers/detail/importer_common.cpp | 40 ++++++++----------- tsd/tests/test_Importers.cpp | 25 ++++++++++++ 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 032a54f30..0b0b0a4b4 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -30,36 +30,30 @@ namespace tsd::io { using namespace tsd::core; using namespace tsd::scene; -#ifdef _WIN32 -constexpr char path_sep = '\\'; -#else -constexpr char path_sep = '/'; -#endif +// These two used to scan for one separator character themselves, which missed +// that Windows accepts '/' as well as '\\', and missed the bare filename -- +// 'volume.raw' has no separator at all, and reporting no file for it turned +// every importer that guards on the result into a silent no-op for a path +// typed relative to the cwd. -// Both of these defer the separator scanning to std::filesystem::path, which -// knows that Windows accepts '/' as well as '\\'. Scanning for one character -// missed that, and missed the bare filename -- 'volume.raw' has no separator -// at all, and reporting no file for it turned every importer that guards on -// the result into a silent no-op for a path typed relative to the cwd. +// The file `filepath` names, without its directory. Empty only when the path +// names no file -- it is empty itself, or ends in a separator. +std::string fileOf(const std::string &filepath) +{ + return std::filesystem::path(filepath).filename().string(); +} // The directory `filepath` names, with the trailing separator kept so callers // can concatenate a sibling file onto it. Empty when the path names no // directory. +// +// Taken as the prefix fileOf() left behind rather than rebuilt from +// parent_path(), so the separator is the one the path already used and the two +// halves always rejoin into the original. Appending the platform's own +// separator instead would hand back '/a/b\\x.raw' for '/a/b/x.raw' on Windows. std::string pathOf(const std::string &filepath) { - auto parent = std::filesystem::path(filepath).parent_path().string(); - if (parent.empty()) - return ""; - if (parent.back() != '/' && parent.back() != '\\') - parent += path_sep; - return parent; -} - -// The file `filepath` names, without its directory. Empty only when the path -// names no file -- it is empty itself, or ends in a separator. -std::string fileOf(const std::string &filepath) -{ - return std::filesystem::path(filepath).filename().string(); + return filepath.substr(0, filepath.size() - fileOf(filepath).size()); } std::string extensionOf(const std::string &filepath) diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index 91ee8fc0d..71bf529ad 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -18,14 +18,35 @@ SCENARIO("Splitting a path names its file and its directory", "[Importers]") { + // Callers concatenate the two back together to reach a file's sibling, so + // whatever else the split does, it has to be reversible. On Windows that + // rules out answering with the platform's own separator: these paths carry + // '/', which Windows accepts, and handing back '/a/b\\volume.raw' would not + // be the path that was passed in. + auto rejoins = [](const char *path) { + return tsd::io::pathOf(path) + tsd::io::fileOf(path) == path; + }; + GIVEN("A path with a directory component") { THEN("The two halves rejoin into the original") { REQUIRE(tsd::io::fileOf("/a/b/volume.raw") == "volume.raw"); REQUIRE(tsd::io::pathOf("/a/b/volume.raw") == "/a/b/"); + REQUIRE(rejoins("/a/b/volume.raw")); REQUIRE(tsd::io::fileOf("b/volume.raw") == "volume.raw"); REQUIRE(tsd::io::pathOf("b/volume.raw") == "b/"); + REQUIRE(rejoins("b/volume.raw")); + } + } + + GIVEN("A path written with the separator the host prefers") + { + THEN("The two halves still rejoin") + { + REQUIRE(rejoins((std::filesystem::temp_directory_path() / "volume.raw") + .string() + .c_str())); } } @@ -37,6 +58,7 @@ SCENARIO("Splitting a path names its file and its directory", "[Importers]") // answering "no file" here made every one of them a silent no-op. REQUIRE(tsd::io::fileOf("volume.raw") == "volume.raw"); REQUIRE(tsd::io::pathOf("volume.raw").empty()); + REQUIRE(rejoins("volume.raw")); } } @@ -45,7 +67,9 @@ SCENARIO("Splitting a path names its file and its directory", "[Importers]") THEN("There is no file") { REQUIRE(tsd::io::fileOf("/a/b/").empty()); + REQUIRE(tsd::io::pathOf("/a/b/") == "/a/b/"); REQUIRE(tsd::io::fileOf("").empty()); + REQUIRE(tsd::io::pathOf("").empty()); } } @@ -55,6 +79,7 @@ SCENARIO("Splitting a path names its file and its directory", "[Importers]") { REQUIRE(tsd::io::fileOf("/volume.raw") == "volume.raw"); REQUIRE(tsd::io::pathOf("/volume.raw") == "/"); + REQUIRE(rejoins("/volume.raw")); } } } From 728d48f26751fe180a1037f035b408bfd22c09d6 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 10:48:17 -0500 Subject: [PATCH 71/78] refactor(io): make the image cache the only way to name a Sampler's Scene makeImageSampler had two forms: the original taking a Scene, and an overload taking the ImageCache that produced the image, added so the texture shims could not pair an image with a Scene it never reached. Adding the second one left the first exported from ImageCache.hpp with exactly one caller in the tree -- the overload delegating to it -- so tsd_io still published an entry point that takes an arbitrary Scene, and there were two ways to build a Sampler where the point was to have one. Fold the body into the ImageCache form and delete the Scene one. Every call site already passed a cache; none of them change. The null-Scene answer moves into the merged function and stays what it was: a cache with no Scene holds no valid image either, so there is nothing to build. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/images/ImageCache.cpp | 17 ++++------------- tsd/src/tsd/io/images/ImageCache.hpp | 13 ++++--------- .../tsd/io/importers/detail/importer_common.cpp | 6 +++--- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/tsd/src/tsd/io/images/ImageCache.cpp b/tsd/src/tsd/io/images/ImageCache.cpp index 792edbe9a..06c532f03 100644 --- a/tsd/src/tsd/io/images/ImageCache.cpp +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -171,15 +171,16 @@ Image ImageCache::store( return image; } -SamplerRef makeImageSampler(Scene &scene, +SamplerRef makeImageSampler(ImageCache &cache, const Image &image, const std::string &displayName, const SamplerSettings &settings) { - if (!image) + auto *scene = cache.scene(); + if (!image || !scene) return {}; - auto sampler = scene.createObject(image.blockCompressed() + auto sampler = scene->createObject(image.blockCompressed() ? tokens::sampler::compressedImage2D : tokens::sampler::image2D); @@ -212,14 +213,4 @@ SamplerRef makeImageSampler(Scene &scene, return sampler; } -SamplerRef makeImageSampler(ImageCache &cache, - const Image &image, - const std::string &displayName, - const SamplerSettings &settings) -{ - if (auto *scene = cache.scene()) - return makeImageSampler(*scene, image, displayName, settings); - return {}; -} - } // namespace tsd::io diff --git a/tsd/src/tsd/io/images/ImageCache.hpp b/tsd/src/tsd/io/images/ImageCache.hpp index 975c31227..9835d3152 100644 --- a/tsd/src/tsd/io/images/ImageCache.hpp +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -144,15 +144,10 @@ struct SamplerSettings std::optional uvTransform; }; -tsd::scene::SamplerRef makeImageSampler(tsd::scene::Scene &scene, - const Image &image, - const std::string &displayName, - const SamplerSettings &settings = {}); - -// The same, for an image the given cache produced: the Sampler lands in the -// Scene the cache holds, so a caller that has both cannot name a Scene the -// image never reached. A cache with no Scene has no valid image to sample -// either, so this yields nothing rather than reaching for one. +// Build a Sampler for an image the given cache produced. The cache names the +// Scene the Sampler lands in, so a caller cannot pair an image with a Scene it +// never reached. A cache with no Scene has no valid image to sample either, so +// this yields nothing rather than reaching for one. tsd::scene::SamplerRef makeImageSampler(ImageCache &cache, const Image &image, const std::string &displayName, diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 0b0b0a4b4..1cbc11eba 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -110,9 +110,9 @@ tsd::scene::ArrayRef readArray( // These forward to tsd::io::images, which owns decoding, orientation, keying, // and lifetime for every image in the tree. They exist so the call sites that // want the whole of it -- acquire, then build a Sampler for what came back -- -// keep one signature. Like the makeImageSampler overload they end in, they -// take the ImageCache alone, so no caller can put the Sampler in a Scene the -// image never reached. +// keep one signature. Like the makeImageSampler they end in, they take the +// ImageCache alone, so no caller can put the Sampler in a Scene the image +// never reached. namespace { From 15cc0b3f188af9e7f2ae8f34e856b2b66713667c Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 10:49:35 -0500 Subject: [PATCH 72/78] refactor(io): stop handing PBRT's light converter a cache it never uses convertLight took an `ImageCache &` and opened by discarding it. Its one image-shaped job, an infinite light's radiance, goes through loadInfiniteRadiance(), which converts equal-area to equirectangular and so binds an array that is not the decoded image and could not be keyed as one. The parameter was never going to be read. This is the same vestigial `Scene &`/`ImageCache &` weight the texture shims shed; it survived because the parameter was silenced rather than removed. convertMaterial and applyShapeAlpha keep both -- they create Materials, so the Scene is doing work -- and both callers still take their pair from the single `ImageCache texCache(&scene)` above them. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/importers/import_PBRT.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tsd/src/tsd/io/importers/import_PBRT.cpp b/tsd/src/tsd/io/importers/import_PBRT.cpp index b6e2fe1e7..6105dce0a 100644 --- a/tsd/src/tsd/io/importers/import_PBRT.cpp +++ b/tsd/src/tsd/io/importers/import_PBRT.cpp @@ -2086,10 +2086,8 @@ static void convertLight(Scene &scene, const pbrt::LightDef &lightDef, LayerNodeRef parent, const std::string &basePath, - ImageCache &texCache, float exposureScale = 1.f) { - (void)texCache; const auto &type = lightDef.type; const auto ¶ms = lightDef.params; const auto xfm = pbrtTransformToMat4(lightDef.lightToWorld); @@ -2383,12 +2381,12 @@ void import_PBRT(Scene &scene, scene.insertChildObjectNode(subXfm, surface); } for (auto &light : it->second.lights) - convertLight(scene, light, xfmNode, basePath, texCache, exposureScale); + convertLight(scene, light, xfmNode, basePath, exposureScale); } // Lights for (auto &light : pbrtScene.lights) - convertLight(scene, light, root, basePath, texCache, exposureScale); + convertLight(scene, light, root, basePath, exposureScale); // Camera convertCamera(scene, file, pbrtScene); From 007459b7da2caa94aa0602175d44c9f4261bfa88 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 10:51:11 -0500 Subject: [PATCH 73/78] refactor(io): stop ImportContext from being copyable `textureCache` is an ImageCache built from the `scene` member, so the aggregate carried the Scene pointer twice. Nothing copies an ImportContext today -- one is made in import_USD() and passed by reference everywhere below -- but a copy followed by a reassignment of `scene` is all it would take for the cache to keep naming the old Scene, and every Sampler the import made would land there while its Materials went elsewhere. Delete the copy operations so that cannot be written. That costs the struct its aggregate status, so the initialization at import_USD.cpp:315 now runs through a constructor taking the same eight members in the same order -- the call site is unchanged, braces and all. `importAnimationIndex` loses the comment explaining why it could not be private; it still isn't, because nothing else about how this struct is used has changed. Co-Authored-By: Claude Opus 5 --- .../importers/detail/usd/UsdImportContext.h | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h index eaf3ee9fc..3b34c73a1 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -19,6 +19,7 @@ #include #include #include +#include namespace tsd::animation { struct Animation; @@ -53,6 +54,12 @@ struct ResolvedMaterial * -- the `anari:` and `tsd:io:` attribute vocabularies, carrier metadata -- * can be read directly from prims by path. * + * One import makes one of these and passes it by reference from there on: it + * is not copyable, because `textureCache` holds the same Scene the `scene` + * member names and a copy is the only way the two could ever come to name + * different Scenes -- which would put an image's Sampler somewhere the rest + * of the import never reached. + * * Example: * ImportContext ctx{&scene, &animMgr, &options, &report, * session, stage, filePath, basePath}; @@ -61,6 +68,18 @@ struct ResolvedMaterial */ struct ImportContext { + ImportContext(Scene *scene, + tsd::animation::AnimationManager *animMgr, + const UsdImportOptions *options, + UsdImportReport *report, + std::shared_ptr session, + pxr::UsdStageRefPtr stage, + std::string filePath, + std::string basePath); + + ImportContext(const ImportContext &) = delete; + ImportContext &operator=(const ImportContext &) = delete; + Scene *scene{nullptr}; tsd::animation::AnimationManager *animMgr{nullptr}; const UsdImportOptions *options{nullptr}; @@ -92,10 +111,10 @@ struct ImportContext const ClaimedPrims *claimedPrims{nullptr}; bool isClaimed(const pxr::SdfPath &path) const; - // Set by animation(); this stays an aggregate, so it cannot be private. An - // index rather than a pointer or reference: the AnimationManager holds its - // Animations by value in a vector, so any other addAnimation() during this - // import -- a camera's, the dialect's -- moves the one this import made. + // Set by animation(). An index rather than a pointer or reference: the + // AnimationManager holds its Animations by value in a vector, so any other + // addAnimation() during this import -- a camera's, the dialect's -- moves + // the one this import made. static constexpr size_t NO_ANIMATION = ~size_t(0); size_t importAnimationIndex{NO_ANIMATION}; @@ -126,6 +145,24 @@ bool attributeValueVaries(const pxr::UsdAttribute &attribute); // Inlined definitions //////////////////////////////////////////////////////// +inline ImportContext::ImportContext(Scene *scene, + tsd::animation::AnimationManager *animMgr, + const UsdImportOptions *options, + UsdImportReport *report, + std::shared_ptr session, + pxr::UsdStageRefPtr stage, + std::string filePath, + std::string basePath) + : scene(scene), + animMgr(animMgr), + options(options), + report(report), + session(std::move(session)), + stage(std::move(stage)), + filePath(std::move(filePath)), + basePath(std::move(basePath)) +{} + inline void ImportContext::reportSkip(const pxr::SdfPath &primPath, const std::string &primType, UsdSkipReason reason, From 4b18512b5435fca1b9f2a360fa8927dd61f26b09 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 10:52:17 -0500 Subject: [PATCH 74/78] docs(io): correct the shim call-site count and the cache inventory The texture shims' note counted "nine call sites"; there are ten in `tsd/src` (OBJ 1, ASSIMP 3, PBRT 3, UsdMaterials 3) plus six in the tests. The nine came from a task document that listed only two of UsdMaterials' three. Since nothing in the sentence needs a number, drop it -- a count is a fact that goes stale on the next call site, and the point of writing the note down was that a wrong number is debt with a label on it. The same section said makeImageSampler "gained an overload taking the cache"; there is no overload now, only the cache form. The function-local ImageCache inventory in Remaining work had drifted: three of its five line numbers were off, and import_HDRI no longer builds a cache at all. Co-Authored-By: Claude Opus 5 --- tsd/docs/tsd-io-image-import.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tsd/docs/tsd-io-image-import.md b/tsd/docs/tsd-io-image-import.md index e8e17ed7d..f0e06cfce 100644 --- a/tsd/docs/tsd-io-image-import.md +++ b/tsd/docs/tsd-io-image-import.md @@ -341,9 +341,9 @@ Roughly in the order that pays off soonest. This document's [Cache lifetime](#cache-lifetime) section is the one part of step 1 that was not implemented. Every importer still builds a function-local -`ImageCache` (`import_OBJ.cpp:59`, `import_GLTF.cpp:259`, -`import_ASSIMP.cpp:249`, `import_PBRT.cpp:2321`, `UsdImportContext.h:56`, -`import_HDRI.cpp:33`), so reuse still exists only *within* one importer call. +`ImageCache` (`import_OBJ.cpp:59`, `import_GLTF.cpp:258`, +`import_ASSIMP.cpp:246`, `import_PBRT.cpp:2292`, `UsdImportContext.h:122`), +so reuse still exists only *within* one importer call. The stated payoff — "a USD stage that references an OBJ decodes it twice" — is unfixed. @@ -366,11 +366,11 @@ the state this work set out to remove. They stay, and now take `ImageCache &` alone instead of an `ImageCache &` beside a `Scene &`. The pair was the real problem: a caller could name a Scene the image had never reached, which is the failure the cache's ownership of a -Scene exists to prevent. `makeImageSampler` gained an overload taking the -cache for the same reason, and the callers that acquire their own image -(`importGLTFTexture`, PBRT's `importHeightAsNormalMap`) use it. What is left -of the shims is the path normalization and colour-space choice their nine call -sites share, which is worth one function. They are the intended API, not a +Scene exists to prevent. `makeImageSampler` takes the cache for the same +reason, and the callers that acquire their own image (`importGLTFTexture`, +PBRT's `importHeightAsNormalMap`) reach it that way too. What is left of the +shims is the path normalization and colour-space choice their call sites +share, which is worth one function. They are the intended API, not a migration aid. ### 3. PBRT's infinite light From 175ee2baad993df850aa6cc6a6cfe1f4b4ded088 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 11:02:52 -0500 Subject: [PATCH 75/78] docs(io): say which images the orientation contract owns ADR 0014 named an hdri light's radiance as an image that asks ImageCache for the order its consumer wants, and io/CONTEXT.md called the cache "the owner of every decoded Image for one scene". Since 0caf7463 that is not what the tree does: import_HDRI builds its radiance array directly, because it decodes exactly one image per call and a cache scoped to the call can never be hit. Narrow both to the images the cache owns, and say plainly that a light-bound radiance array may be built without it. The contract itself does not change -- a sampled image is still stored top-down -- and the one path that still routes radiance through the cache, UsdLights, still asks for bottom-up on its ImageSource, which is now named as the example. The ADR's closing sentence said no decoder emits bottom-up rows, which read as false: HDRImage does. It is not one of io/images/detail's decoders, which is what the paragraph was about, so the ambiguity is resolved rather than the sentence deleted -- and the conclusion it supports still holds. No row reversal runs in the tree today, because HDRImage's one cached consumer asks for the order it already produced. The in-code note in import_HDRI said PBRT's infinite light binds its radiance directly "for the same reason". It does not: it resamples equal-area to equirectangular, so what it binds is not the decoded image and could not be keyed as one. That is a different reason and worth being the one written down. Co-Authored-By: Claude Opus 5 --- .../0014-store-images-in-anari-orientation.md | 24 ++++++++++++++----- tsd/src/tsd/io/CONTEXT.md | 6 +++-- tsd/src/tsd/io/importers/import_HDRI.cpp | 13 +++++----- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/tsd/docs/adr/0014-store-images-in-anari-orientation.md b/tsd/docs/adr/0014-store-images-in-anari-orientation.md index 29e5157af..f21e3fd95 100644 --- a/tsd/docs/adr/0014-store-images-in-anari-orientation.md +++ b/tsd/docs/adr/0014-store-images-in-anari-orientation.md @@ -14,12 +14,24 @@ Decoders declare the row order their library produced and `ImageCache` normalizes; no importer flips texels itself. Previously each of seven decode paths carried its own unstated assumption, and the assumptions cancelled for glTF, ASSIMP, and PBRT but not for OBJ and USD, whose textures rendered -mirrored. Two consequences follow from the contract: an image bound somewhere -that is not an image sampler asks `ImageCache` for the order that consumer -wants — an `hdri` light's `radiance` runs bottom-up, so it says so on its -`ImageSource`; and block-compressed DDS, whose 4×4 blocks cannot be +mirrored. Two consequences follow from the contract: a cached image bound +somewhere that is not an image sampler asks `ImageCache` for the order that +consumer wants — a USD dome light's `radiance` runs bottom-up, so it says so +on its `ImageSource`; and block-compressed DDS, whose 4×4 blocks cannot be row-reversed without decoding and re-encoding, would stay as authored and instead get a `v`-flip composed into its sampler's `inTransform`/`inOffset` — which is why `makeImageSampler` owns those two parameters outright and takes -the importer's own uv transform through `SamplerSettings`. No decoder in the -tree emits bottom-up rows today, so that path is dormant rather than dead. +the importer's own uv transform through `SamplerSettings`. Neither reversal +runs today: every decoder in `io/images/detail/decoders.hpp` emits top-down +rows, which is what a sampler asks for, so both paths are dormant rather than +dead. +The one loader that produces bottom-up rows, `importers/detail/HDRImage.h`, is +not one of those decoders, and its only cached consumer asks for bottom-up +delivery — so there is nothing to normalize there either. + +This contract governs images the Image Cache owns. A radiance array bound to an +`hdri` light may be built without the cache and is not covered: `import_HDRI` +decodes exactly one image per call, so a cache scoped to the call can never be +hit, and PBRT's infinite light resamples equal-area to equirectangular, so what +it binds is not the decoded image and could not be keyed as one. Both keep the +bottom-up rows `HDRImage` produced, which is the order an `hdri` light wants. diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index c26761d8a..357c7af90 100644 --- a/tsd/src/tsd/io/CONTEXT.md +++ b/tsd/src/tsd/io/CONTEXT.md @@ -180,9 +180,11 @@ stored in. _Avoid_: texture key, cache key **Image Cache**: -The owner of every decoded Image for one scene. It holds the scene it caches +The owner of every sampled Image for one scene. It holds the scene it caches for, so a cached Array can never reach a different one, and it must not -outlive that scene. +outlive that scene. An image bound to a light's radiance rather than to a +Sampler may be built without it — see ADR 0014 — so "every" is a claim about +what a Sampler can read, not about every decode in the tree. _Avoid_: texture cache **Row Order**: diff --git a/tsd/src/tsd/io/importers/import_HDRI.cpp b/tsd/src/tsd/io/importers/import_HDRI.cpp index 86278ad43..a67382dfe 100644 --- a/tsd/src/tsd/io/importers/import_HDRI.cpp +++ b/tsd/src/tsd/io/importers/import_HDRI.cpp @@ -32,15 +32,16 @@ void import_HDRI(Scene &scene, // Not stored through ImageCache: this importer decodes exactly one image // per call, so a cache scoped to the call can never be hit and only buys a - // second copy of the texels. import_PBRT's infinite light binds its - // radiance directly for the same reason; UsdLights caches because many - // dome lights in one Stage can share a file and a radiometry scale. + // second copy of the texels. import_PBRT's infinite light also binds its + // radiance directly, but for its own reason -- it resamples equal-area to + // equirectangular, so what it binds is not the decoded image and could not + // be keyed as one. UsdLights caches because many dome lights in one Stage + // can share a file and a radiometry scale. // The rows stay bottom-up as HDRImage decoded them, which is the order an // hdri light wants: its radiance is mapped over the sphere by the light // rather than addressed by a sampler, so the top-left origin ADR 0014 - // stores sampled images in does not apply. That ADR still describes this - // radiance as reaching the light through ImageCache, which is now true - // only of the UsdLights path. + // stores sampled images in does not apply. That ADR covers the images the + // cache owns and says so. auto radiance = scene.createArray(ANARI_FLOAT32_VEC3, img.width, img.height); radiance->setData(rgb.data()); From 04338ab4caa35ebe3e11b2bc82ce65719c6dd8d0 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 11:07:12 -0500 Subject: [PATCH 76/78] test(io): pin where each USD light type puts its brightness The light converter decides per branch which ANARI parameter carries a light's brightness -- `irradiance` for a distant light, `intensity` for rect and sphere, `scale` for a dome -- and since 904e3dde three of those names are arguments passed to a shared helper rather than literals at the site that means them. A wrong name still produces a light of the right subtype, so nothing that existed caught it: coverage was point and spot only. Three scenarios, one per untested branch, each asserting the name as well as the value: - distant: `irradiance` carries it and `intensity` is unset, and `normalize` leaves the intensity alone, because a distant light subtends no area to divide by. - rect: the quad's corner and two edge vectors describe the authored width and height, and `normalize` divides by their product. - dome: brightness rides on `scale`, `color` stays unset, and the colour arrives baked into a synthesized 1x1 radiance -- which is why this branch cannot go through the shared helper, and the assertion that says so. Co-Authored-By: Claude Opus 5 --- tsd/tests/test_UsdImport_lights.cpp | 152 ++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tsd/tests/test_UsdImport_lights.cpp b/tsd/tests/test_UsdImport_lights.cpp index a68caf124..bb0486193 100644 --- a/tsd/tests/test_UsdImport_lights.cpp +++ b/tsd/tests/test_UsdImport_lights.cpp @@ -7,6 +7,8 @@ // catch #include "catch.hpp" +// helium +#include // tsd_tests #include "UsdTestFixtures.h" // std @@ -87,6 +89,156 @@ def SphereLight "Spot" ( } } +// Each light type puts its brightness on a different ANARI parameter, and +// which one is decided per branch. A converter that reached for the wrong name +// would still produce a light of the right subtype, so subtype alone does not +// hold this. +SCENARIO("A distant light's brightness lands on irradiance", "[UsdImport]") +{ + GIVEN("A distant light with an authored intensity and colour") + { + ImportedStage stage("tsd_test_usd_distant.usda", R"(#usda 1.0 + +def DistantLight "Sun" +{ + float inputs:intensity = 3 + bool inputs:normalize = true + color3f inputs:color = (0.25, 0.5, 1) +} +)"); + + WHEN("The Stage is imported") + { + THEN("It becomes a directional light carrying irradiance, not intensity") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = stage.scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::directional); + + const auto irradiance = light->parameterValueAs("irradiance"); + REQUIRE(irradiance.has_value()); + // A distant light subtends no area, so `normalize` has nothing to + // divide by and must leave the authored intensity alone. + REQUIRE(*irradiance == Approx(3.f)); + REQUIRE(!light->parameterValueAs("intensity").has_value()); + + auto color = light->parameterValueAs("color"); + REQUIRE(color.has_value()); + REQUIRE(color->x == Approx(0.25f)); + REQUIRE(color->y == Approx(0.5f)); + REQUIRE(color->z == Approx(1.f)); + REQUIRE(stage.report.skipped.empty()); + } + } + } +} + +SCENARIO("A rect light becomes a quad spanned by its own width and height", + "[UsdImport]") +{ + GIVEN("A rect light 4 wide and 2 high, normalized") + { + ImportedStage stage("tsd_test_usd_rect.usda", R"(#usda 1.0 + +def RectLight "Panel" +{ + float inputs:intensity = 8 + bool inputs:normalize = true + float inputs:width = 4 + float inputs:height = 2 +} +)"); + + WHEN("The Stage is imported") + { + THEN("Its corner and edges describe the authored rectangle") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = stage.scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::quad); + + // normalize divides by the rectangle's area, 4 * 2. + const auto intensity = light->parameterValueAs("intensity"); + REQUIRE(intensity.has_value()); + REQUIRE(*intensity == Approx(1.f)); + + // ANARI's quad is a corner plus two edge vectors; USD's is centred on + // the prim's origin, so the corner is half of each extent back. + auto position = light->parameterValueAs("position"); + REQUIRE(position.has_value()); + REQUIRE(position->x == Approx(-2.f)); + REQUIRE(position->y == Approx(-1.f)); + REQUIRE(position->z == Approx(0.f)); + + auto edge1 = light->parameterValueAs("edge1"); + REQUIRE(edge1.has_value()); + REQUIRE(edge1->x == Approx(4.f)); + REQUIRE(edge1->y == Approx(0.f)); + + auto edge2 = light->parameterValueAs("edge2"); + REQUIRE(edge2.has_value()); + REQUIRE(edge2->x == Approx(0.f)); + REQUIRE(edge2->y == Approx(2.f)); + REQUIRE(stage.report.skipped.empty()); + } + } + } +} + +// The dome light is the one branch that cannot go through the shared helper: +// its colour is baked into the radiance it maps over the sphere, so a `color` +// parameter would be applied twice. +SCENARIO("An untextured dome light still lights the scene", "[UsdImport]") +{ + GIVEN("A dome light with a colour and an intensity but no texture") + { + ImportedStage stage("tsd_test_usd_dome.usda", R"(#usda 1.0 + +def DomeLight "Sky" +{ + float inputs:intensity = 2 + color3f inputs:color = (0.5, 0.25, 0) +} +)"); + + WHEN("The Stage is imported") + { + THEN("Its colour arrives baked into a constant radiance, not as color") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = stage.scene.getObject(0); + REQUIRE(light); + REQUIRE(light->subtype() == tsd::scene::tokens::light::hdri); + + // Brightness rides on `scale` here rather than on `intensity`, and + // `color` must stay unset: the texels below already carry it. + const auto scale = light->parameterValueAs("scale"); + REQUIRE(scale.has_value()); + REQUIRE(*scale == Approx(2.f)); + REQUIRE( + !light->parameterValueAs("color").has_value()); + + // Devices require radiance to be set, so an untextured dome gets a + // synthesized single texel rather than nothing. + auto *radiance = + light->parameterValueAsObject("radiance"); + REQUIRE(radiance != nullptr); + REQUIRE(radiance->dim(0) == 1); + REQUIRE(radiance->dim(1) == 1); + + const auto texel = helium::readAsAttributeValueFlat( + radiance->data(), radiance->elementType(), 0); + REQUIRE(texel.x == Approx(1.f)); + REQUIRE(texel.y == Approx(0.5f)); + REQUIRE(texel.z == Approx(0.f)); + REQUIRE(stage.report.skipped.empty()); + } + } + } +} + SCENARIO("Cameras from a Stage arrive in the camera pool", "[UsdImport]") { GIVEN("A Stage with an animated camera rig") From 062f1c77481c66bfd3fa68ea65aac631f0db8416 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 11:10:24 -0500 Subject: [PATCH 77/78] style(io): clang-format one call in UsdInstancing A single over-long argument list, unformatted since 0ccece9f. No code change. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp index f6ee78628..57327834a 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -425,7 +425,8 @@ void convertInstancer(ImportContext &ctx, // binding re-fills; handing it over here is what keeps a scrub from having // to find it again by name. if (animatedSamples > 1) { - addInstancerAnimation(ctx, primPath, protoIndex, arrayNode, transformArray); + addInstancerAnimation( + ctx, primPath, protoIndex, arrayNode, transformArray); boundAnyPrototype = true; } } From 6e164e028dc0eacf7cadb90d168b95353d97f1ef Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 19 Aug 2026 11:10:24 -0500 Subject: [PATCH 78/78] refactor(io): declare InstancerRegistry's copy and move intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root STYLEGUIDE §6 asks every type to say what copying it means. InstancerRegistry said nothing, so it was copyable by default -- harmless today, since it holds only two containers and no pointer into the scene index its constructor reads, but "harmless" was left for the reader to work out. It is built once per import and passed by reference, so the answer is that copying it is not intended: not copyable, movable by default. Co-Authored-By: Claude Opus 5 --- tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h index 3b0f1b3ca..e87d686c0 100644 --- a/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -3,6 +3,7 @@ #pragma once +#include "tsd/core/TypeMacros.hpp" #include "tsd/io/importers/detail/usd/UsdImportContext.h" // usd #include @@ -91,6 +92,8 @@ std::vector pointInstancerSampleTimes(const pxr::UsdPrim &prim); struct InstancerRegistry { explicit InstancerRegistry(const pxr::HdSceneIndexBaseRefPtr &sceneIndex); + TSD_NOT_COPYABLE(InstancerRegistry) + TSD_DEFAULT_MOVEABLE(InstancerRegistry) // Where a placement path landed in the Layer, or `fallback` if the traversal // never reached it.