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/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/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 diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 356beaef8..b7208f284 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_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 1612928fa..0610777c6 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_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 1d9e83b7d..49842c7df 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_mtlx` (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..7ebe598f3 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_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 4854ff768..ab6d25864 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_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/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/0014-store-images-in-anari-orientation.md b/tsd/docs/adr/0014-store-images-in-anari-orientation.md new file mode 100644 index 000000000..f21e3fd95 --- /dev/null +++ b/tsd/docs/adr/0014-store-images-in-anari-orientation.md @@ -0,0 +1,37 @@ +# 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 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: 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`. 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/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..8e9f1a750 --- /dev/null +++ b/tsd/docs/adr/0018-let-imported-scenes-retain-an-open-usd-stage.md @@ -0,0 +1,20 @@ +# 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. + +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/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..ba016a2ee --- /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 `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, +"supporting UDIM in TSD" has nowhere to send the texels. 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/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..b082c2d57 --- /dev/null +++ b/tsd/docs/adr/0021-share-one-usd-stage-session-across-import-and-animation.md @@ -0,0 +1,71 @@ +# 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`, so a 1.6 GB stage was opened twice. + +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 +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 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 +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. + +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 +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..d920ff65b --- /dev/null +++ b/tsd/docs/adr/0022-refill-captured-arrays-rather-than-re-running-conversion.md @@ -0,0 +1,68 @@ +# Re-fill captured Arrays rather than re-running conversion per frame + +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 +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. + +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 +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. + +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 +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/docs/tsd-io-image-import.md b/tsd/docs/tsd-io-image-import.md new file mode 100644 index 000000000..f0e06cfce --- /dev/null +++ b/tsd/docs/tsd-io-image-import.md @@ -0,0 +1,431 @@ +# Central image import for tsd_io + +> **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. +> See [Status](#status) and [Remaining work](#remaining-work). + +## Summary + +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 proposed one image-import component with a single stated orientation +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 + +*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 | +|---|---|---|---|---| +| 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 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. + +### 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 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. + +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 the row order its source asked for. +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` and each `ImageSource` asks for one, and the cache +reverses rows between them before the texels reach the scene. + +> **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 +`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. + +> **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 +commit: + +| Importer | Change | +|---|---| +| 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 + +`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. + +> **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 +`(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 (`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: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. + +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 — **decided against** + +`importTexture`, `importTextureFromMemory`, and `importRawTexture2D` +(`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` 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 + +`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. diff --git a/tsd/docs/usd-materialx-known-gaps.md b/tsd/docs/usd-materialx-known-gaps.md new file mode 100644 index 000000000..16e96561c --- /dev/null +++ b/tsd/docs/usd-materialx-known-gaps.md @@ -0,0 +1,131 @@ +# 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.** *Diagnosed and closed.* The device logged +`MaterialX: failed to transcode '': Could not find a nodedef +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: `. 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 +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. + +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_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_mtlx` explicitly. It also prompts for a camera: + +```bash +F=.../ShdrPlygrnd/ShdrPlygrnd_OpenPBR.usda +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. +`-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. 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. 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/animation/AnimationManager.cpp b/tsd/src/tsd/animation/AnimationManager.cpp index 77c80e918..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); @@ -76,15 +90,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/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/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/app/Context.cpp b/tsd/src/tsd/app/Context.cpp index be80642d4..7877e7869 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_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/core/Any.hpp b/tsd/src/tsd/core/Any.hpp index 5470daaa8..7e38360ae 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; @@ -276,6 +288,22 @@ inline bool Any::is() const return is(ANARI_BOOL); } +template <> +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 99940afd3..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,17 +23,20 @@ PRIVATE archives/detail/SubtreeArchiveDeserialization.cpp animation/EnSightFileBinding.cpp animation/SpatialFieldFileBinding.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 - importers/detail/usd/MaterialCommon.cpp - importers/detail/usd/OmniPbrMaterial.cpp importers/import_AGX.cpp importers/import_ASSIMP.cpp importers/import_AXYZ.cpp @@ -112,12 +116,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) @@ -128,6 +134,23 @@ 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) + 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) @@ -150,9 +173,59 @@ 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) + + # 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 + 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 + importers/detail/usd/UsdVolume.cpp + usd/UsdResolvedGeometry.cpp + usd/UsdStageSession.cpp + ) # CaseFileFormat -- SdfFileFormat plugin for EnSight .case files add_library(CaseFileFormat SHARED @@ -184,7 +257,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) diff --git a/tsd/src/tsd/io/CONTEXT.md b/tsd/src/tsd/io/CONTEXT.md index 609aadb1e..357c7af90 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,120 @@ _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 + +**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. +_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 + +**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. + +**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. + +**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 + +### 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 and the Row Order it is +stored in. +_Avoid_: texture key, cache key + +**Image Cache**: +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. 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**: +Which row of a picture a texel array stores first. Decoders declare the Row +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**: +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/UsdImport.cpp b/tsd/src/tsd/io/UsdImport.cpp new file mode 100644 index 000000000..47db88865 --- /dev/null +++ b/tsd/src/tsd/io/UsdImport.cpp @@ -0,0 +1,176 @@ +// Copyright 2026 NVIDIA Corporation +// 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 + +namespace tsd::io { + +const char *toString(UsdMaterialMode mode) +{ + switch (mode) { + case UsdMaterialMode::MATERIALX: + return "materialx"; + case UsdMaterialMode::MDL: + return "mdl"; + case UsdMaterialMode::PHYSICALLY_BASED: + default: + return "physicallyBased"; + } +} + +UsdMaterialMode usdMaterialModeFromString(const std::string &name) +{ + if (name == "materialx") + return UsdMaterialMode::MATERIALX; + if (name == "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(toString(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 = usdMaterialModeFromString(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::FIELD_LOAD_FAILED: + return "field 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"; + case UsdSkipReason::COUNT: + break; + } + 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"; + + 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) { + const auto reason = UsdSkipReason(i); + 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; +} + +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 new file mode 100644 index 000000000..84e87c6c0 --- /dev/null +++ b/tsd/src/tsd/io/UsdImport.hpp @@ -0,0 +1,174 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/DataTree.hpp" +// std +#include +#include + +namespace tsd::animation { +struct AnimationManager; +} // namespace tsd::animation + +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 +}; + +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 + * 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, + FIELD_LOAD_FAILED, + UNSUPPORTED_LIGHT_TYPE, + RICHER_MATERIAL_AVAILABLE, + 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); + +/* + * 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; + + // 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; +}; + +// 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 new file mode 100644 index 000000000..ace9164ec --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.cpp @@ -0,0 +1,281 @@ +// 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" +// std +#include +#if TSD_USE_USD +// tsd_io +#include "tsd/io/usd/UsdResolvedGeometry.h" +#include "tsd/io/usd/UsdStageSession.h" +// usd +#include +#endif + +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) +{ + for (int column = 0; column < 4; ++column) { + for (int row = 0; row < 4; ++row) + node.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, + std::shared_ptr session, + std::string stageFile, + std::string primPath, + std::vector parts, + usd::GeometryResolveOptions resolveOptions) + : UsdFileBinding( + scene, std::move(session), std::move(stageFile), std::move(primPath)), + m_parts(std::move(parts)), + m_resolveOptions(std::move(resolveOptions)) +{} + +UsdGeometryFileBinding::~UsdGeometryFileBinding() = default; + +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 + // no cache of authored sample times is written; an older Archive that still + // carries one is simply not read. + 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; + } + + 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) +{ + for (auto &part : m_parts) { + if (!part.geometry) + continue; + const size_t newIndex = + cb(part.geometry->type(), part.geometry->index()); + part.geometry.updateDefragmentedIndex(newIndex); + } +} + +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 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.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)); + }); + } + } + + return &anim.emplaceFileBinding(&scene, + std::shared_ptr{}, + node["stageFile"].getValueOr(""), + primPath, + std::move(parts), + std::move(resolveOptions)); +} + +#if TSD_USE_USD + +void UsdGeometryFileBinding::update(float t) +{ + if (!scene() || m_parts.empty() || !ensureSession()) + 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; + pxr::UsdGeomPointBased pointBased(session()->stage()->GetPrimAtPath(path)); + if (pointBased) { + std::vector times; + pointBased.GetPointsAttr().GetTimeSamples(×); + noteAuthoredSampleTimes(times); + } + } + + 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. + // 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, cache)) + applied++; + } + + 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 + +void UsdGeometryFileBinding::update(float) +{ + ensureSession(); // reports that this build has no USD, once +} + +#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..84700a13c --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdGeometryFileBinding.hpp @@ -0,0 +1,87 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/animation/Animation.hpp" +#include "tsd/io/animation/UsdFileBinding.hpp" +#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-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 + * than snapped -- the same thing usdview shows. + * + * Example: + * auto &b = anim.emplaceFileBinding( + * &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, + std::shared_ptr session, + std::string stageFile, + std::string primPath, + std::vector parts, + usd::GeometryResolveOptions resolveOptions); + ~UsdGeometryFileBinding() override; + + // FileBinding interface // + + std::string kind() const override; + void toDataNode(tsd::core::DataNode &node) const override; + void onDefragment(const scene::IndexRemapper &cb) override; + + // 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 no target geometry + // survives in the scene. + static UsdGeometryFileBinding *addToAnimation(tsd::animation::Animation &anim, + scene::Scene &scene, + tsd::core::DataNode &node); + + private: + void addCallbackToAnimation(tsd::animation::Animation &anim) override; + const char *logTag() const override; + + std::vector m_parts; + usd::GeometryResolveOptions m_resolveOptions; + bool m_sampleTimesNoted{false}; + bool m_partsChangedReported{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..ac0c0608b --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.cpp @@ -0,0 +1,163 @@ +// 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) + : UsdFileBinding( + scene, std::move(session), std::move(stageFile), std::move(primPath)), + m_arrayNode(arrayNode), + m_transforms(transforms), + m_prototypeIndex(prototypeIndex) +{} + +UsdInstancerFileBinding::~UsdInstancerFileBinding() = default; + +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()) { + auto *layer = (*m_arrayNode).value().layer(); + node["layerName"] = scene()->getLayerName(layer).str(); + node["nodeIndex"] = m_arrayNode->index(); + } + writePathsToDataNode(node); + 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 + +void UsdInstancerFileBinding::update(float t) +{ + if (!scene() || !m_arrayNode || !ensureSession()) + 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; + 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()) + 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) +{ + ensureSession(); // reports that this build has no USD, once +} + +#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..19aa50be8 --- /dev/null +++ b/tsd/src/tsd/io/animation/UsdInstancerFileBinding.hpp @@ -0,0 +1,69 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/animation/Animation.hpp" +#include "tsd/io/animation/UsdFileBinding.hpp" +#include "tsd/scene/ObjectUsePtr.hpp" +#include "tsd/scene/Scene.hpp" +#include "tsd/scene/objects/Array.hpp" +// std +#include +#include + +namespace tsd::io { + +/* + * 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 UsdFileBinding +{ + 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; + const char *logTag() const override; + + scene::LayerNodeRef m_arrayNode; + scene::ObjectUsePtr m_transforms; + size_t m_prototypeIndex{0}; + bool m_sampleTimesNoted{false}; +}; + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/archives/AnimationArchive.cpp b/tsd/src/tsd/io/archives/AnimationArchive.cpp index eae84f084..00708d01f 100644 --- a/tsd/src/tsd/io/archives/AnimationArchive.cpp +++ b/tsd/src/tsd/io/archives/AnimationArchive.cpp @@ -147,6 +147,33 @@ 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 == "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 83756a652..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, @@ -109,6 +122,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/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..06c532f03 --- /dev/null +++ b/tsd/src/tsd/io/images/ImageCache.cpp @@ -0,0 +1,216 @@ +// 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 +#include + +namespace tsd::io { + +using namespace tsd::core; +using namespace tsd::scene; + +namespace { + +std::string keyOf(const ImageSource &source) +{ + return source.id + + (source.colorSpace == ColorSpace::LINEAR ? "_linear" : "_srgb") + + (source.rowOrder == RowOrder::BOTTOM_UP ? "_up" : "_down"); +} + +// 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 == 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 false; + } + + 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 = target; + return true; +} + +// 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) {} + +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 = find(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 = find(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 = find(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::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 {}; + } + + Image image; + image.vFlipInSampler = !normalizeRowOrder(decoded, source.rowOrder); + 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()); + } 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(ImageCache &cache, + const Image &image, + const std::string &displayName, + const SamplerSettings &settings) +{ + auto *scene = cache.scene(); + if (!image || !scene) + return {}; + + auto sampler = scene->createObject(image.blockCompressed() + ? tokens::sampler::compressedImage2D + : tokens::sampler::image2D); + + sampler->setParameterObject("image", *image.texels); + 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); + sampler->setParameter("wrapMode2", settings.wrapMode2); + sampler->setParameter("filter", settings.filter); + + // 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.vFlipInSampler) + composeVFlip(uv.transform, uv.offset); + sampler->setParameter("inTransform", uv.transform); + sampler->setParameter("inOffset", uv.offset); + } + 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..9835d3152 --- /dev/null +++ b/tsd/src/tsd/io/images/ImageCache.hpp @@ -0,0 +1,168 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/Token.hpp" +#include "tsd/core/TypeMacros.hpp" +#include "tsd/scene/Scene.hpp" +// std +#include +#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 +}; + +// 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, + 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}; + // 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; + // 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, 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; +}; + +// 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); + + // 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. + 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 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 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 +{ + 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. 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; +}; + +// 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, + const SamplerSettings &settings = {}); + +// Inlined definitions //////////////////////////////////////////////////////// + +inline bool Image::blockCompressed() const +{ + return !compressedFormat.empty(); +} + +inline Image::operator bool() const +{ + return texels.valid(); +} + +} // 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..734879b85 --- /dev/null +++ b/tsd/src/tsd/io/images/detail/decoders.cpp @@ -0,0 +1,394 @@ +// 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..c5a7c2493 --- /dev/null +++ b/tsd/src/tsd/io/images/detail/decoders.hpp @@ -0,0 +1,58 @@ +// 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; +}; + +// 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 +// 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.hpp b/tsd/src/tsd/io/importers.hpp index ecf6ace6b..300ca50da 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 = {}); @@ -92,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); @@ -161,6 +162,7 @@ enum class ImporterType SWC_SDF, TRK, USD, + USD_MTLX, // native MaterialX materials instead of a portable mapping VTP, VTU, XYZDP, 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..a2b7954dc 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 +// 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); @@ -16,6 +22,10 @@ struct HDRImage unsigned width; unsigned height; unsigned numComponents; + // 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.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 98a351ba3..1cbc11eba 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -8,14 +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 // anari #include // std @@ -31,36 +25,35 @@ #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; -#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. -std::string pathOf(const std::string &filepath) +// 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(0, pos + 1); + return std::filesystem::path(filepath).filename().string(); } -std::string fileOf(const std::string &filepath) +// 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) { - size_t pos = filepath.find_last_of(path_sep); - if (pos == std::string::npos) - return ""; - return filepath.substr(pos + 1, filepath.size()); + return filepath.substr(0, filepath.size() - fileOf(filepath).size()); } std::string extensionOf(const std::string &filepath) @@ -94,11 +87,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) { @@ -117,396 +105,68 @@ tsd::scene::ArrayRef readArray( return retval; } -static SamplerRef makeTextureSampler( - Scene &scene, ArrayRef dataArray, const std::string &displayName) -{ - auto tex = scene.createObject(tokens::sampler::image2D); +// Texture import shims /////////////////////////////////////////////////////// - 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()); +// 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 they end in, they take the +// ImageCache alone, so no caller can put the Sampler in a Scene the image +// never reached. - return tex; -} +namespace { -static SamplerRef makeCompressedTextureSampler( - Scene &scene, ArrayRef dataArray, const std::string &displayName) +ColorSpace colorSpaceOf(bool isLinear) { - 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; + return isLinear ? ColorSpace::LINEAR : ColorSpace::SRGB; } -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 {}; - } - } +} // namespace - 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{}; -} - -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 {}; - } - - 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->setData(decodedData); - cache[textureId] = dataArray; - - stbi_image_free(decodedData); - } - - return dataArray; -} - -SamplerRef importStbTexture( - Scene &scene, std::string filepath, TextureCache &cache, 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); -} -#endif - -SamplerRef importTexture( - Scene &scene, std::string filepath, TextureCache &cache, bool isLinear) +SamplerRef importTexture(ImageCache &cache, + std::string filepath, + bool isLinear, + const SamplerSettings &settings) { 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 { - tex = importStbTexture(scene, filepath, cache, isLinear); - } - - return tex; + auto image = cache.acquire({filepath, colorSpaceOf(isLinear)}); + 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, - TextureCache &cache, bool isLinear, - const std::string &formatHint) + const std::string &formatHint, + const SamplerSettings &settings) { - 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(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, - TextureCache &cache, - bool isLinear) + bool isLinear, + const SamplerSettings &settings) { - 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(cache, image, displayName, settings); } SamplerRef makeDefaultColorMapSampler(Scene &scene, const float2 &range) @@ -972,17 +632,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; } @@ -991,7 +651,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'", @@ -1152,7 +813,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); @@ -1180,7 +843,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/importer_common.hpp b/tsd/src/tsd/io/importers/detail/importer_common.hpp index f399ac592..a14f5c18b 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,29 +28,29 @@ 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); -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, - TextureCache &cache, - bool isLinear = false); -tsd::scene::SamplerRef importTextureFromMemory(tsd::scene::Scene &scene, + bool isLinear = false, + const SamplerSettings &settings = {}); +tsd::scene::SamplerRef importTextureFromMemory(ImageCache &cache, const std::string &cacheKey, const std::string &displayName, const void *data, size_t numBytes, - TextureCache &cache, bool isLinear = false, - const std::string &formatHint = ""); -tsd::scene::SamplerRef importRawTexture2D(tsd::scene::Scene &scene, + const std::string &formatHint = "", + const SamplerSettings &settings = {}); +tsd::scene::SamplerRef importRawTexture2D(ImageCache &cache, const std::string &cacheKey, const std::string &displayName, const void *data, size_t width, size_t height, - TextureCache &cache, - bool isLinear = false); + bool isLinear = false, + const SamplerSettings &settings = {}); tsd::scene::SamplerRef makeDefaultColorMapSampler( tsd::scene::Scene &scene, const tsd::math::float2 &range); @@ -71,6 +71,8 @@ bool calcTangentsForTriangleMesh(const tsd::math::uint3 *indices, tsd::math::float4 *tangents, size_t numIndices, size_t numVertices, + // 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); 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..9ba9a3814 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.cpp @@ -0,0 +1,228 @@ +// 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" +#include "tsd/io/animation/UsdInstancerFileBinding.hpp" +#include "tsd/io/importers/detail/usd/UsdInstancing.h" +// 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; + +// 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. +// 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, + tsd::math::mat4 m0, + double t1, + 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 + +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) +{ + 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; + + // 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; + 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); + } + + addTransformStepBinding(ctx.animation(), + node, + frames, + normalizeSampleTimes(ctx.stage, times)); + ctx.reportAnimatedPrim(authoredTimes.size()); +} + +size_t pointInstancerSampleCount( + ImportContext &ctx, const pxr::SdfPath &primPath) +{ + 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) +{ + ctx.animation().emplaceFileBinding(ctx.scene, + ctx.session, + arrayNode, + transforms, + ctx.filePath, + primPath.GetString(), + prototypeIndex); +} + +void addDeformingGeometryAnimation(ImportContext &ctx, + const pxr::SdfPath &primPath, + ConvertedGeometry &converted) +{ + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim || converted.geometryByPart.empty()) + return; + + pxr::UsdGeomPointBased pointBased(prim); + if (!pointBased) + return; + + std::vector sampleTimes; + pointBased.GetPointsAttr().GetTimeSamples(&sampleTimes); + if (sampleTimes.size() < 2) + return; + + 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), re-resolved rather than + // re-converted (ADR 0022). + ctx.animation().emplaceFileBinding(ctx.scene, + ctx.session, + ctx.filePath, + primPath.GetString(), + std::move(parts), + converted.resolveOptions); + 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 new file mode 100644 index 000000000..3cca9f072 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdAnimation.h @@ -0,0 +1,51 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#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 +#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. +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); + +// 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/UsdDialect.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp new file mode 100644 index 000000000..36fdaf5da --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.cpp @@ -0,0 +1,216 @@ +// 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/io/importers.hpp" +#include "tsd/io/importers/detail/usd/UsdMaterials.h" +// usd +#include +#include +#include +#include +#include +// std +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +/////////////////////////////////////////////////////////////////////////////// +// 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 {}; +} + +// 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. 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) +{ + pxr::UsdShadeMaterialBindingAPI binding(prim); + if (!binding) + return {}; + auto usdMaterial = binding.ComputeBoundMaterial(); + if (!usdMaterial) + 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) +{ + 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, sceneIndex, scopePrim); + core::FlatMap perPartMaterials; + for (const auto &child : scopePrim.GetChildren()) { + auto childMaterial = boundMaterialOf(ctx, sceneIndex, 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; +} + +bool ClaimedPrims::claims(const pxr::SdfPath &path) const +{ + for (const auto &entry : entries) { + if (path == entry.path || path.HasPrefix(entry.path)) + return true; + } + return false; +} + +void importDialectPrims(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + 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, sceneIndex, prim, importRoot, claimed->renderSettings.root()); + break; + } + } +} + +} // 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..d0b45d77b --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdDialect.h @@ -0,0 +1,56 @@ +// 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; + + // 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); + +// Route Claimed Prims to the dialect's own importers. +void importDialectPrims(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const std::shared_ptr &claimed, + LayerNodeRef importRoot); + +} // 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..cc13faebe --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.cpp @@ -0,0 +1,206 @@ +// 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 +// std +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +// 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 pxr::HdSceneIndexPrim &prim) +{ + auto retval = ctx.scene->createObject(tokens::material::matte); + retval->setName((primPath.GetString() + "_displayColor").c_str()); + + const auto display = readDisplayColor(prim); + if (display.color) + retval->setParameter("color", *display.color); + if (display.opacity) + retval->setParameter("opacity", *display.opacity); + return retval; +} + +// 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 +{ + 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 ResolvedGeometry &resolved) +{ + MaterialPlan retval; + + const auto meshResolved = + resolveMaterial(ctx, sceneIndex, boundMaterialPathOf(prim)); + 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 = + meshResolved.uvPrimvarName.empty() ? "st" : meshResolved.uvPrimvarName; + retval.anyNonDefaultUv = meshUvName != "st"; + + const auto meshName = primPath.GetString(); + for (const auto &part : resolved.parts) { + if (part.name == meshName) { + retval.byPart.set(part.name, retval.fallback); + retval.uvNamesByPart.set(part.name, meshUvName); + continue; + } + + 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 subsetUvName = subsetResolved.uvPrimvarName.empty() + ? meshUvName + : subsetResolved.uvPrimvarName; + retval.uvNamesByPart.set(part.name, subsetUvName); + retval.anyNonDefaultUv = + retval.anyNonDefaultUv || subsetUvName != "st"; + } + + return retval; +} + +// 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) +{ + FlatMap sharedArrays; + + for (const auto &part : resolved.parts) { + auto geometry = ctx.scene->createObject(part.subtype); + geometry->setName(part.name.c_str()); + + for (const auto &attribute : part.attributes) { + if (!attribute.valid()) + continue; + + ArrayRef array; + 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); + } + + for (const auto &[name, value] : part.scalars) + geometry->setParameter(name, value); + + auto *found = materials.byPart.at(part.name); + auto material = found ? *found : materials.fallback; + + out.geometryByPart.emplace_back(part.name, geometry); + out.surfaces.push_back( + ctx.scene->createSurface(part.name.c_str(), geometry, material)); + } +} + +} // namespace + +ConvertedGeometry convertGeometry(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &primPath, + const pxr::HdSceneIndexPrim &prim, + const tsd::math::mat4 &bakeXform) +{ + ConvertedGeometry retval; + + // 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); + + // 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; +} + +} // 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..f855eeab7 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdGeometry.h @@ -0,0 +1,46 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#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 { + +/* + * 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). +ConvertedGeometry 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.cpp b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp new file mode 100644 index 000000000..c5dd43d9c --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.cpp @@ -0,0 +1,65 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/importers/detail/usd/UsdDialect.h" +// usd +#include +// std +#include +#include + +namespace tsd::io::usd { + +tsd::animation::Animation &ImportContext::animation() +{ + if (importAnimationIndex == NO_ANIMATION) { + animMgr->addAnimation(filePath); + importAnimationIndex = animMgr->animations().size() - 1; + } + return animMgr->animations()[importAnimationIndex]; +} + +void ImportContext::reportAnimatedPrim(size_t sampleCount) +{ + report->animatedPrims++; + 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) + 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 new file mode 100644 index 000000000..3b34c73a1 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdImportContext.h @@ -0,0 +1,188 @@ +// 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/io/usd/UsdDataSource.h" +#include "tsd/io/usd/UsdStageSession.h" +#include "tsd/scene/Scene.hpp" +// usd +#include +#include +#include +#include +// std +#include +#include +#include +#include + +namespace tsd::animation { +struct Animation; +struct AnimationManager; +} // namespace tsd::animation + +namespace tsd::io::usd { + +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, + * 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. + * + * 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}; + * ctx.reportSkip(primPath, "cylinderLight", + * UsdSkipReason::UNSUPPORTED_LIGHT_TYPE); + */ +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}; + UsdImportReport *report{nullptr}; + std::shared_ptr session; + 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()}; + + // 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(); + + // 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(). 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}; + std::unordered_map materialCache; + + 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); + +// 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 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, + 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..57327834a --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.cpp @@ -0,0 +1,483 @@ +// Copyright 2026 NVIDIA Corporation +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std +#include +#include +#include + +namespace tsd::io::usd { + +using namespace tsd::core; + +namespace { + +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); + + // 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)) { + if (ctx.isClaimed(path)) + continue; + 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).surfaces) + 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; +} + +// Expanded fallback for Prototypes whose internal transforms are animated and +// so cannot be baked: mirror the Prototype subtree beneath each placement, +// still referencing the objects converted once by convertPrototype(). +void expandPrototype(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + const pxr::SdfPath &prototypeRoot, + const PrototypeContent &content, + LayerNodeRef parent) +{ + 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()); + } +} + +// 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; +} + +// 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 +{ + 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; + + 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) +{ + InstancerPlacements 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()); + } + + // 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) + instanceCount = std::max(instanceCount, size_t(i) + 1); + } + retval.transforms = readInstanceTransforms(prim, instanceCount); + + 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; +} + +InstancerRegistry::InstancerRegistry( + const pxr::HdSceneIndexBaseRefPtr &sceneIndex) +{ + // 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; + const 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, + const pxr::HdSceneIndexPrim &prim, + LayerNodeRef node, + InstancerRegistry ®istry) +{ + 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 (!placementsOfPrim.instanceLocations.empty()) + return; + + const auto animatedSamples = pointInstancerSampleCount(ctx, primPath); + bool boundAnyPrototype = false; + + for (size_t protoIndex = 0; protoIndex < placementsOfPrim.prototypes.size(); + ++protoIndex) { + auto content = convertPrototype( + ctx, sceneIndex, placementsOfPrim.prototypes[protoIndex], registry); + + const auto placements = placementsOfPrim.forPrototype(protoIndex); + 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, + placementsOfPrim.prototypes[protoIndex], + *content, + 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()); + + // 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); + boundAnyPrototype = true; + } + } + + // One animated prim, however many Prototypes it scatters. + if (boundAnyPrototype) + ctx.reportAnimatedPrim(animatedSamples); +} + +void attachNativeInstances(ImportContext &ctx, + const pxr::HdSceneIndexBaseRefPtr &sceneIndex, + InstancerRegistry ®istry, + LayerNodeRef importRoot) +{ + for (const pxr::SdfPath &path : nativeInstancerPaths(sceneIndex)) { + if (ctx.isClaimed(path)) + continue; + + const auto placements = readInstancerPlacements(sceneIndex->GetPrim(path)); + if (placements.prototypes.empty() || placements.instanceLocations.empty()) + continue; + + auto content = + convertPrototype(ctx, sceneIndex, placements.prototypes[0], registry); + + const auto &indices = placements.instanceIndices.empty() + ? pxr::VtIntArray() + : placements.instanceIndices[0]; + + for (size_t i = 0; i < placements.instanceLocations.size(); ++i) { + const int index = size_t(i) < indices.size() ? indices[i] : int(i); + 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. + auto placementNode = + registry.nodeFor(placements.instanceLocations[i], importRoot); + + if (content->internalTransformsAnimated) { + expandPrototype( + ctx, sceneIndex, placements.prototypes[0], *content, 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..e87d686c0 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdInstancing.h @@ -0,0 +1,130 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/TypeMacros.hpp" +#include "tsd/io/importers/detail/usd/UsdImportContext.h" +// usd +#include +#include +#include +// std +#include +#include +#include +#include +#include + +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 +// could not be baked and must be expanded per placement instead. +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}; +}; + +/* + * 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(). 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 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 +{ + 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. + 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 +// `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..75aee38f5 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.cpp @@ -0,0 +1,497 @@ +// 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" +#include "tsd/io/importers/detail/usd/UsdAnimation.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; +} + +// 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, + 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( + 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; + + // 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); + // 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, + image.rowOrder, + rgb.data()); + return acquired.texels; +} + +} // namespace + +bool isLightPrimType(const pxr::TfToken &primType) +{ + return pxr::HdPrimTypeIsLight(primType); +} + +LightRef convertLight(ImportContext &ctx, + const pxr::SdfPath &primPath, + 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) { + *skipDetail = + "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); + return makeLight( + ctx, tokens::light::directional, primPath, radiometry, "irradiance"); + } + + 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 = + 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)); + 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 = makeLight( + ctx, tokens::light::spot, primPath, radiometry, "intensity"); + light->setParameter("openingAngle", openingAngle); + light->setParameter("falloffAngle", falloffAngle); + return light; + } + + auto light = makeLight(ctx, + isDisk ? tokens::light::ring : tokens::light::point, + primPath, + 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(ctx.importTime); + 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; + } + + return {}; +} + +/////////////////////////////////////////////////////////////////////////////// +// Cameras //////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +void convertCamera(ImportContext &ctx, const pxr::SdfPath &primPath) +{ + 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(ctx.importTime); + 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(ctx.importTime); + 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, 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(), + 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..254bdfc75 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdLights.h @@ -0,0 +1,29 @@ +// 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 { + +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; +// `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, + 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. +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 new file mode 100644 index 000000000..0d86166ec --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.cpp @@ -0,0 +1,1085 @@ +// 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 +#if TSD_USD_HAS_MATERIALX +#include +#include +#include +#include +#include +#endif +// std +#include +#include +#include +#include +#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"); + +// 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 +{ + 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; + std::string stringParameter( + 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(); +} + +// 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 +{ + 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) + return walker.stringParameter(current, "varname"); + if (id != TRANSFORM_2D_ID) + return {}; + current = walker.connectedNode(current, "in"); + } + return {}; +} + +// 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::float2 s(1.f, 1.f); + const auto scale = walker.parameter(transformNode, "scale"); + if (scale.IsHolding()) { + 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 value = translation.UncheckedGet(); + t = math::float2(value[0], value[1]); + } + + 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, + const pxr::TfToken &texturePath, + const char *input) +{ + const auto mode = walker.stringParameter(texturePath, input); + 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 space = walker.stringParameter(texturePath, "sourceColorSpace"); + 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; +} + +// 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 +// 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 {}; +} + +// 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.textureCache, file, !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; +} + +// 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 +// 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 {}; +} + +// 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 +// 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. +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); + 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 file = + anchoredAssetPath(ctx, value.UncheckedGet()); + if (file.empty()) + continue; + + 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. 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, + UsdSkipReason::TEXTURE_LOAD_FAILED, + 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; +} + +// 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 +// 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::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 {}; + + 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 {}; + + pxr::HdMtlxTexturePrimvarData textures; + auto document = pxr::HdMtlxCreateMtlxDocumentFromHdMaterialNetworkInterface( + &networkInterface, + terminalNode, + networkInterface.GetNodeInputConnectionNames(terminalNode), + pxr::HdMtlxStdLibraries(), + &textures); + if (!document) + return {}; + + 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. + const auto materialNodes = document->getMaterialNodes(); + std::string materialName; + if (!materialNodes.empty()) + materialName = materialNodes.front()->getName(); + + const auto xml = MaterialX::writeToXmlString(document); + 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); + retval->setName(materialPath.GetString().c_str()); + 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.textureCache, texture.file, 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; +} + +#endif + +// 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 (auto usdPrim = ctx.stage->GetPrimAtPath(materialPath)) { + for (const auto &descendant : usdPrim.GetDescendants()) { + 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 || attributeValueVaries(input.GetAttr()); + if (animated) { + ctx.reportSkip(materialPath, + primType, + UsdSkipReason::TIME_VARYING_VALUE_DROPPED, + "shader inputs are time-sampled; imported at one time"); + break; + } + } + } +} + +// 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(), + 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(materialPath.GetString().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 file = + anchoredAssetPath(ctx, fileValue.UncheckedGet()); + if (file.empty()) + return false; + + // Everything the binding varies goes in before the sampler is built: + // 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(); + settings.uvTransform = uvTransformOfTexture(walker, texturePath); + + const bool isLinear = textureIsLinear(walker, texturePath, colorRole); + auto sampler = importTexture(ctx.textureCache, file, isLinear, settings); + if (!sampler) { + ctx.reportSkip(materialPath, + prim.primType.GetString(), + UsdSkipReason::TEXTURE_LOAD_FAILED, + file); + return false; + } + + 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))); + } + // 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)) + setFloatIfPresent("roughness", "roughness"); + if (!bindTexture("opacity", "opacity", false)) + setFloatIfPresent("opacity", "opacity"); + setFloatIfPresent("clearcoat", "clearcoat"); + setFloatIfPresent("clearcoatRoughness", "clearcoatRoughness"); + setFloatIfPresent("ior", "ior"); + + 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 new file mode 100644 index 000000000..0319861bc --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdMaterials.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 +// std +#include + +namespace tsd::io::usd { + +// 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..959a482a3 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.cpp @@ -0,0 +1,404 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/importers/detail/usd/UsdSubdivision.h" +// tsd_core +#include "tsd/core/TSDMath.hpp" +// usd +#include +#include +#include +#include +#include +#include +// opensubdiv +#include +#include +// std +#include +#include +#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> refineVertexBuffer( + 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); +} + +// 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) +{ + const int maxLevel = refiner.GetMaxLevel(); + + size_t total = 0; + for (int level = 0; level <= maxLevel; ++level) + total += size_t(refiner.GetLevel(level).GetNumFVarValues(channel)); + + 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); +} + +// 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{nullptr}; + + template + std::vector> operator()(const float *source, size_t count) const + { + return refineVertexBuffer(*refiner, source, count); + } +}; + +struct FaceVaryingRefiner +{ + 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); + } +}; + +// 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) +{ + 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 pxr::UsdStageRefPtr &stage, + int refinementLevel, + const pxr::SdfPath &primPath) +{ + if (refinementLevel <= 0 || !stage) + return false; + + auto prim = 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 MeshPrimvars &primvars, + 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)); + + // 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; + + OpenSubdiv::Far::TopologyRefiner::UniformOptions options(refinementLevel); + options.fullTopologyInLastLevel = true; + refiner->RefineUniform(options); + + const int maxLevel = refiner->GetMaxLevel(); + const auto &lastLevel = refiner->GetLevel(maxLevel); + 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]); + } + + // 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.get()}; + + { + pxr::VtValue refinedPoints; + if (!refineFloatArray(pxr::VtValue(points), &refinedPoints, vertexRefiner)) + return retval; + retval.points = refinedPoints.UncheckedGet(); + } + + for (const auto &[name, value] : primvars.vertex) { + pxr::VtValue 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.get(), 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; + 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..9774ef28d --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdSubdivision.h @@ -0,0 +1,69 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/io/usd/UsdDataSource.h" +// usd +#include +#include +#include +#include +#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 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; + MeshPrimvars primvars; +}; + +// 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. 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. +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 MeshPrimvars &primvars, + int refinementLevel); + +} // 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..83cc30149 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.cpp @@ -0,0 +1,309 @@ +// Copyright 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 +#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, + std::string *skipDetail) +{ + auto prim = ctx.stage->GetPrimAtPath(primPath); + if (!prim) { + *skipDetail = "volume has no Stage 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()) { + *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) { + *skipDetail = "field file '" + filePath + "' could not be loaded"; + 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..6489309f8 --- /dev/null +++ b/tsd/src/tsd/io/importers/detail/usd/UsdVolume.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 +#include +// std +#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. +// 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_ASSIMP.cpp b/tsd/src/tsd/io/importers/import_ASSIMP.cpp index 3a21cbd68..ae7c0062b 100644 --- a/tsd/src/tsd/io/importers/import_ASSIMP.cpp +++ b/tsd/src/tsd/io/importers/import_ASSIMP.cpp @@ -8,6 +8,7 @@ #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 @@ -23,21 +24,20 @@ using namespace tsd::core; #if TSD_USE_ASSIMP -static SamplerRef importEmbeddedTexture( - Scene &scene, +static SamplerRef importEmbeddedTexture(ImageCache &cache, const aiTexture *embeddedTexture, int embeddedTextureIndex, - TextureCache &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 cacheKey = makeTextureCacheKey(textureId, isLinear); 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( @@ -48,22 +48,23 @@ 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 {}; } if (embeddedTexture->mHeight == 0) { - auto tex = importTextureFromMemory(scene, - cacheKey, + auto tex = importTextureFromMemory(cache, + textureId, displayName, embeddedTexture->pcData, 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); } @@ -72,7 +73,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; @@ -81,14 +83,14 @@ static SamplerRef importEmbeddedTexture( rgba[i * 4 + 3] = src.a; } - return importRawTexture2D(scene, - cacheKey, + return importRawTexture2D(cache, + textureId, displayName, rgba.data(), embeddedTexture->mWidth, embeddedTexture->mHeight, - cache, - isLinear); + isLinear, + settings); } static std::vector importASSIMPSurfaces(Scene &scene, @@ -110,29 +112,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]; @@ -243,7 +243,7 @@ static std::vector importASSIMPMaterials( { std::vector materials; - TextureCache cache; + ImageCache cache(&scene); std::string basePath = pathOf(filename); @@ -255,39 +255,52 @@ 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); + cache, embeddedTexture, embeddedTextureIndex, isLinear, settings); + } else { + tex = importTexture( + cache, basePath + texName.C_Str(), 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: 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 { + 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}); + // 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 {{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}}; + return settings; }; if (matType == aiShadingMode_PBR_BRDF) { @@ -297,10 +310,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; @@ -313,10 +326,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})); @@ -331,11 +344,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})); @@ -353,24 +366,22 @@ 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) { + 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})); @@ -411,10 +422,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; @@ -427,10 +438,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; @@ -444,10 +455,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; @@ -460,11 +471,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; @@ -479,10 +490,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); } } @@ -491,10 +502,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; @@ -514,10 +525,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); } } @@ -526,10 +537,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); } } @@ -544,10 +555,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); } } @@ -691,6 +702,8 @@ void import_ASSIMP(Scene &scene, Assimp::Importer importer; + // 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) 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_GLTF.cpp b/tsd/src/tsd/io/importers/import_GLTF.cpp index 369548ba7..d568f014f 100644 --- a/tsd/src/tsd/io/importers/import_GLTF.cpp +++ b/tsd/src/tsd/io/importers/import_GLTF.cpp @@ -101,12 +101,90 @@ static std::string attributeNameForTexCoord(int texCoord) return "attribute"s + std::to_string(texCoord); } -static SamplerRef importGLTFTexture(Scene &scene, +// 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) +{ + 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(ImageCache &cache, const tinygltf::Model &model, int textureIndex, - TextureCache &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, - bool flipNormalMapY = false, const char *samplerName = nullptr, int texCoord = 0) { @@ -118,169 +196,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); + if (elementType == ANARI_UNKNOWN) + return {}; - cache[cacheKey] = dataArray; - } + const ImageSource source{ + 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, + 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(cache, decoded, displayName, settings); } static void applyNormalTextureScale(SamplerRef sampler, float scale) @@ -292,7 +243,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 @@ -304,7 +255,8 @@ static std::vector importGLTFMaterials( // - KHR_materials_iridescence: iridescence factor, IOR, thickness std::vector materials; - TextureCache cache; + ImageCache cache(&scene); + const auto sourcePrefix = "gltf:"s + filename + ":"; for (const auto &gltfMaterial : model.materials) { MaterialRef material; @@ -319,11 +271,10 @@ 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, - false, + sourcePrefix, false, "baseColor", pbr.baseColorTexture.texCoord)) { @@ -340,12 +291,11 @@ 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, - false, "opacity", pbr.baseColorTexture.texCoord)) { sampler->setParameter("outTransform", @@ -360,12 +310,11 @@ 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, - false, "metallic", pbr.metallicRoughnessTexture.texCoord)) { // Metallic is in the blue channel for glTF @@ -381,12 +330,11 @@ 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, - false, "roughness", pbr.metallicRoughnessTexture.texCoord)) { // Roughness is in the green channel for glTF @@ -401,12 +349,11 @@ static std::vector importGLTFMaterials( } // Normal map - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, gltfMaterial.normalTexture.index, - cache, + sourcePrefix, true, - false, "normal", gltfMaterial.normalTexture.texCoord)) { float normalScale = gltfMaterial.normalTexture.scale; @@ -415,12 +362,11 @@ static std::vector importGLTFMaterials( } // Occlusion map - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, gltfMaterial.occlusionTexture.index, - cache, + sourcePrefix, true, - false, "occlusion", gltfMaterial.occlusionTexture.texCoord)) { material->setParameterObject("occlusion", *sampler); @@ -440,11 +386,10 @@ static std::vector importGLTFMaterials( GetValueOrDefault(emissiveStrengthExt, 1.0f, "emissiveStrength"); } - if (auto sampler = importGLTFTexture(scene, + if (auto sampler = importGLTFTexture(cache, model, gltfMaterial.emissiveTexture.index, - cache, - false, + sourcePrefix, false, "emissive", gltfMaterial.emissiveTexture.texCoord)) { @@ -487,12 +432,11 @@ 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, - false, "transmission", transmissionTexCoord)) { sampler->setParameter("outTransform", @@ -534,12 +478,11 @@ 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, - false, "thickness", thicknessTexCoord)) { sampler->setParameter("outTransform", @@ -584,12 +527,11 @@ 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, - false, "clearcoat", clearcoatTexCoord)) { sampler->setParameter("outTransform", @@ -611,12 +553,11 @@ 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, - false, "clearcoatRoughness", clearcoatRoughnessTexCoord)) { sampler->setParameter("outTransform", @@ -636,12 +577,11 @@ 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, - false, "clearcoatNormal", clearcoatNormalTexCoord)) { applyNormalTextureScale(sampler, clearcoatNormalScale); @@ -667,12 +607,11 @@ 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, - false, "specular", specularTexCoord)) { sampler->setParameter("outTransform", @@ -694,11 +633,10 @@ 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, - false, + sourcePrefix, false, "specularColor", specularColorTexCoord)) { @@ -732,11 +670,10 @@ 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, - false, + sourcePrefix, false, "sheenColor", sheenColorTexCoord)) { @@ -759,12 +696,11 @@ 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, - false, "sheenRoughness", sheenRoughnessTexCoord)) { sampler->setParameter("outTransform", @@ -797,12 +733,11 @@ 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, - false, "iridescence", iridescenceTexCoord)) { sampler->setParameter("outTransform", @@ -833,12 +768,11 @@ 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, - false, "iridescenceThickness", iridescenceThicknessTexCoord)) { sampler->setParameter("outTransform", @@ -1053,6 +987,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, which is ANARI's convention too, + // so it goes through as authored. vertexTexCoordArray->unmap(); const std::string attributeName = @@ -1475,7 +1411,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 4c481527e..a67382dfe 100644 --- a/tsd/src/tsd/io/importers/import_HDRI.cpp +++ b/tsd/src/tsd/io/importers/import_HDRI.cpp @@ -30,14 +30,27 @@ void import_HDRI(Scene &scene, } } - auto arr = scene.createArray(ANARI_FLOAT32_VEC3, img.width, img.height); - arr->setData(rgb.data()); + // 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 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 covers the images the + // cache owns and says so. + 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", *arr); + hdri->setParameterObject("radiance", *radiance); } else { tsd::core::logError("[import_HDRI] Failed to load file '%s'", filepath); } diff --git a/tsd/src/tsd/io/importers/import_NVDB.cpp b/tsd/src/tsd/io/importers/import_NVDB.cpp index 1784bbd57..eee449d7e 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,11 +23,44 @@ 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()) + + 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/src/tsd/io/importers/import_OBJ.cpp b/tsd/src/tsd/io/importers/import_OBJ.cpp index a17dd2669..187d27feb 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]; @@ -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); } @@ -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 2447ef094..6105dce0a 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,6 +216,7 @@ 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(); @@ -425,6 +425,7 @@ 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(); @@ -683,8 +684,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 +714,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,44 +906,42 @@ 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, 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; + 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), - 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)); + return settings; + + 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, 1.f - vs - vd, 0.f, 0.f)}; + return settings; } -static BakedTexture bakeTexture(Scene &scene, +static BakedTexture bakeTexture(ImageCache &texCache, const pbrt::Scene &pbrtScene, const std::string &textureName, - const std::string &basePath, - TextureCache &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, - TextureCache &texCache) + const std::string &basePath) { auto it = params.values.find(paramName); if (it == params.values.end()) { @@ -960,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 {}; } @@ -985,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, - TextureCache &texCache) + const std::string &basePath) { auto texIt = pbrtScene.textures.find(textureName); if (texIt == pbrtScene.textures.end()) { @@ -1013,10 +1013,10 @@ 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( + texCache, fullPath, 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 @@ -1057,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)); } @@ -1109,21 +1109,20 @@ 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, const pbrt::Scene &pbrtScene, const std::string &basePath, - TextureCache &texCache, + ImageCache &texCache, anari::DataType paramType = ANARI_FLOAT32_VEC3) { auto texName = matDef.params.getString(texParamName); 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; @@ -1185,7 +1184,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()) @@ -1209,66 +1208,61 @@ 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, - TextureCache &texCache) +static SamplerRef importHeightAsNormalMap( + ImageCache &texCache, const std::string &filepath, float heightScale) { - // 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(texCache, 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(texCache, image, fileOf(filepath) + "_bump"); } // Approximate normal-incidence reflectance for common PBRT named metal spectra. @@ -1348,7 +1342,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", @@ -1431,7 +1425,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()) @@ -1460,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"); @@ -1485,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, @@ -1509,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, @@ -1527,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, @@ -1544,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, @@ -1701,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); @@ -1748,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()); @@ -1765,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( @@ -1898,7 +1870,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 @@ -1917,7 +1889,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()) { @@ -1937,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 @@ -2114,10 +2086,8 @@ static void convertLight(Scene &scene, const pbrt::LightDef &lightDef, LayerNodeRef parent, const std::string &basePath, - TextureCache &texCache, float exposureScale = 1.f) { - (void)texCache; const auto &type = lightDef.type; const auto ¶ms = lightDef.params; const auto xfm = pbrtTransformToMat4(lightDef.lightToWorld); @@ -2183,7 +2153,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. @@ -2318,7 +2289,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; @@ -2410,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); 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_USD.cpp b/tsd/src/tsd/io/importers/import_USD.cpp index 1e0c790b3..aadd2d48d 100644 --- a/tsd/src/tsd/io/importers/import_USD.cpp +++ b/tsd/src/tsd/io/importers/import_USD.cpp @@ -1,65 +1,30 @@ // 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" +#include "tsd/io/importers/detail/usd/UsdVolume.h" +#include "tsd/io/usd/UsdStageSession.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 #endif // std -#include #include -#include #include namespace tsd::io { @@ -68,2236 +33,361 @@ 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) -{ - // 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; -} - -// 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) -{ - 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; +namespace { - MaterialRef mat; +using namespace tsd::io::usd; - // 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); - - if (!mat) - mat = scene.defaultMaterial(); +/////////////////////////////////////////////////////////////////////////////// +// Traversal ////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// - matCache[matPath] = mat; - return mat; -} - -// Helper to extract volume transfer function from USD material -struct VolumeTransferFunction +bool purposeIsIncluded( + const pxr::TfToken &purpose, const UsdPurposeSelection &selection) { - 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; -}; - -// 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; + 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; } -// 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) +// 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, + const std::string &primType, + UsdSkipReason reason, + const std::string &detail = "") { - if (authored.size() < 2) - return authored; + if (node) + ctx.scene->removeNode(node); - 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); - } + ctx.reportSkip(primPath, primType, reason, detail); - 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; + // insertChildNode() already leaves the node empty; setEmpty() would clear + // the name along with the value. + 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())); } -// 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) +struct Traversal { - std::vector triangleIndices; - size_t faceVertexOffset = 0; + ImportContext *ctx{nullptr}; + pxr::HdSceneIndexBaseRefPtr sceneIndex; + InstancerRegistry *instancers{nullptr}; - for (size_t face = 0; face < faceVertexCounts.size(); ++face) { - int vertsInFace = faceVertexCounts[face]; + void visit(const pxr::SdfPath &primPath, + LayerNodeRef parent, + bool hidden, + const tsd::math::mat4 &parentXform); - // 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; -} + tsd::math::mat4 localTransformOf( + const pxr::SdfPath &primPath, bool *resetsXformStack) const; + bool isHierarchyPrim(const pxr::SdfPath &primPath) const; +}; -// 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) +// 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; - 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; + *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: 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) +// 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 { - 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; + auto prim = ctx->stage->GetPrimAtPath(primPath); + return !prim || bool(pxr::UsdGeomImageable(prim)); } -// Helper: Import a UsdGeomMesh prim as a TSD mesh under the given parent node -static void importUsdMesh(Scene &scene, - const pxr::UsdPrim &prim, +void Traversal::visit(const pxr::SdfPath &primPath, LayerNodeRef parent, - const pxr::GfMatrix4d &usdXform, - const std::string &basePath, - MaterialCache &matCache, - TextureCache &texCache) + bool hidden, + const tsd::math::mat4 &parentXform) { - 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); -} - -// 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) -{ - 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()); + // 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)) return; - } - auto geom = scene.createObject(tokens::geometry::sphere); - geom->setName(primName.c_str()); - geom->setParameterObject("vertex.position", *firstPosArray); - geom->setParameterObject("vertex.radius", *firstRadArray); + auto prim = sceneIndex->GetPrim(primPath); - 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 = ""; + // Purpose // - // 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; - } - - // 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)) { + skipPrim(*ctx, + {}, + parent, + primPath, + prim.primType.GetString(), + UsdSkipReason::PURPOSE_EXCLUDED, + purpose.GetString()); 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]); - - auto firstPosArray = buildFrame(firstTC); - if (!firstPosArray) { - logStatus("[import_USD] Skipping BasisCurves with no point data: %s\n", - primName.c_str()); - return; - } - - // 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); - } - } -} - -// 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()); - - // 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 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}; - }; + // Visibility // - // 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()); + bool visible = true; + if (auto visibilitySchema = + pxr::HdVisibilitySchema::GetFromParent(prim.dataSource)) { + if (auto value = visibilitySchema.GetVisibility()) + visible = value->GetTypedValue(0); } - - 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()); + if (!visible && !hidden) { + ctx->reportSkip( + primPath, prim.primType.GetString(), UsdSkipReason::RESOLVED_INVISIBLE); } -} - -// 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)); - } - } - } - } + // 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 (attributeValueVaries(imageable.GetVisibilityAttr())) { + 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; - 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]; + // Node for this prim // - 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); + + // 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, nodeXform, primPath.GetName().c_str()); + if (resetsXformStack) + (*node)->setInstanceParameter("usd:resetXformStack", Any(true)); + if (subtreeHidden) + (*node)->setEnabled(false); + + // Content // + + bool convertedAnything = false; + if (prim.primType.IsEmpty()) { + if (!isHierarchyPrim(primPath)) { + skipPrim(*ctx, + node, + parent, + primPath, + ctx->stage->GetPrimAtPath(primPath).GetTypeName().GetString(), + 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)) { + 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()); + addDeformingGeometryAnimation(*ctx, primPath, converted); + convertedAnything = true; + } else if (prim.primType == pxr::HdPrimTypeTokens->instancer) { + convertInstancer(*ctx, sceneIndex, primPath, prim, node, *instancers); + convertedAnything = true; + } else if (isLightPrimType(prim.primType)) { + std::string skipDetail; + if (auto light = convertLight(*ctx, primPath, prim, &skipDetail)) { + ctx->scene->insertChildObjectNode( + node, light, primPath.GetName().c_str()); + convertedAnything = 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); + skipPrim(*ctx, + node, + parent, + primPath, + prim.primType.GetString(), + UsdSkipReason::UNSUPPORTED_LIGHT_TYPE, + skipDetail); + 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, 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); 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)) { + std::string skipDetail; + convertedAnything = convertVolume(*ctx, primPath, node, &skipDetail); + if (!convertedAnything) { + skipPrim(*ctx, + node, + parent, + primPath, + prim.primType.GetString(), + UsdSkipReason::FIELD_LOAD_FAILED, + skipDetail); + return; } - if (!caseFile.empty()) - break; - } - - if (caseFile.empty()) { - logWarning("[import_USD] EnSight scope '%s': no case file found", - primName.c_str()); + } else { + skipPrim(*ctx, + node, + parent, + primPath, + prim.primType.GetString(), + 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); - } + if (convertedAnything) + ctx->report->convertedPrims++; - // 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 = {}; + instancers->recordNode(primPath, node); + addTransformAnimation(*ctx, primPath, node); - // 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; - } - - logStatus( - "[import_USD] Importing EnSight dataset '%s' from '%s'" - " (%zu per-part material override(s))", - primName.c_str(), - caseFile.c_str(), - perPartMaterials.size()); - - 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, accumulatedXform); } -// ----------------------------------------------------------------------------- -// 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) -{ - // 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; - } - - // 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; - } - - // 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; - - // 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(); - - // Count children - size_t numChildren = 0; - for (const auto &child : prim.GetChildren()) - ++numChildren; - - // 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; - - // 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; - - tsd::math::mat4 tsdXform = toTsdMat4(usdLocalXform); - std::string primName = prim.GetName().GetString(); - if (primName.empty()) - primName = ""; + const char *filepath, + LayerNodeRef location, + const UsdImportOptions &options) +{ + UsdImportReport report; + + // 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; + + 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. + ctx.importTime = pxr::UsdTimeCode(session->startTimeCode()); + session->setTime(ctx.importTime); + + // 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); + ctx.claimedPrims = claimed.get(); + + auto sceneIndex = session->sceneIndex(); + + 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(sceneIndex); + 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))); - } - 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; + 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, tsd::math::IDENTITY_MAT4); } - } - - // 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, tsd::math::IDENTITY_MAT4); } - 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, sceneIndex, 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/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( diff --git a/tsd/src/tsd/io/importers/import_file.cpp b/tsd/src/tsd/io/importers/import_file.cpp index e9c76380a..23fde4451 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 { @@ -106,9 +110,15 @@ 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::VTP) + 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; + 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) { std::optional prop; 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/src/tsd/io/serialization/serialization_datatree.cpp b/tsd/src/tsd/io/serialization/serialization_datatree.cpp index 91bd457f7..bfd72e08b 100644 --- a/tsd/src/tsd/io/serialization/serialization_datatree.cpp +++ b/tsd/src/tsd/io/serialization/serialization_datatree.cpp @@ -8,6 +8,8 @@ #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/animation/UsdInstancerFileBinding.hpp" #include "tsd/io/archives/AnimationManagerArchive.hpp" #include "tsd/io/archives/CameraArchive.hpp" #include "tsd/io/archives/RendererArchive.hpp" @@ -208,6 +210,10 @@ void nodeToAnimation( std::move(data->parts), std::move(data->geoFiles), 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/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..f9e363ba1 --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdGeometryResolveOptions.h @@ -0,0 +1,44 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// tsd_core +#include "tsd/core/FlatMap.hpp" +#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` 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. + */ +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}; + 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 new file mode 100644 index 000000000..ac6e3d638 --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdResolvedGeometry.cpp @@ -0,0 +1,1066 @@ +// 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 +#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; +} + +// 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 = {}) +{ + if (type == ANARI_UNKNOWN || !value.IsArrayValued() + || value.GetArraySize() == 0) + return; + part.attributes.push_back( + {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 +// 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; +}; + +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. +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::vector *replaySlots, + 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]); + addTypedAttribute(part, + 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"); + + // 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) + break; + if (primvarName == normalsName || primvarName == colorName + || primvarName == uvName) + continue; + part.slotPrimvars.push_back(primvarName); + 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) +{ + 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, + 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, + replaySlotsFor(options, meshName), + 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), + replaySlotsFor(options, subsetName), + 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, + replaySlotsFor(options, meshName), + 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()) { + addTypedAttribute( + part, 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 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(); +} + +const ResolvedPart *ResolvedGeometry::part(const std::string &name) const +{ + for (const auto &p : parts) { + if (p.name == name) + return &p; + } + return nullptr; +} + +/////////////////////////////////////////////////////////////////////////////// +// 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 + || 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.color = float3(c[0][0], c[0][1], c[0][2]); + } + if (opacity.valid() && opacity.value.IsHolding()) + 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); +} + +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, + RefillCache &cache) +{ + if (geometry.subtype() != part.subtype) + return false; + + for (const auto &attribute : part.attributes) { + 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; + } + + // 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); + 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; +} + +} // 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..26407e086 --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdResolvedGeometry.h @@ -0,0 +1,138 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// tsd_core +#include "tsd/core/FlatMap.hpp" +#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 +#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). + * + * 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. Writing it back (refillGeometry, below) necessarily does. + */ + +// 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; + + // 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 +{ + 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 +{ + 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. + * 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, + RefillCache &cache); + +} // namespace tsd::io::usd diff --git a/tsd/src/tsd/io/usd/UsdStageSession.cpp b/tsd/src/tsd/io/usd/UsdStageSession.cpp new file mode 100644 index 000000000..72981766a --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdStageSession.cpp @@ -0,0 +1,208 @@ +// 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 +#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 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; + 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_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() +{ + // 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 +{ + 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(); +} + +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; + 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 {}; + + auto session = std::make_shared(key, filePath, stage); + 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..8b4fde89b --- /dev/null +++ b/tsd/src/tsd/io/usd/UsdStageSession.h @@ -0,0 +1,92 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// tsd_core +#include "tsd/core/TypeMacros.hpp" +// usd +#include +#include +#include +// std +#include +#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 +{ + 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; + const pxr::UsdStageRefPtr &stage() const; + const pxr::HdSceneIndexBaseRefPtr &sceneIndex() const; + + // The Stage's own clock. + double startTimeCode() const; + 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; + + pxr::UsdTimeCode currentTime() const; + void setTime(pxr::UsdTimeCode time); + + private: + std::string m_key; + 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}; + bool m_authoredTimeRange{false}; + bool m_sawSampleTimes{false}; +}; + +// 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..4b7ec31a8 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,43 @@ void RenderIndex::signalInvalidateCachedObjects() { signalRemoveAllObjects(); populate(); + requestWorldUpdate(); +} + +void RenderIndex::signalUpdateBatchBegin() +{ + m_updateBatchDepth++; +} + +void RenderIndex::signalUpdateBatchEnd() +{ + if (m_updateBatchDepth > 0) + m_updateBatchDepth--; + if (m_updateBatchDepth > 0) + return; + flushDeferredUpdates(); + if (!m_worldUpdateDeferred) + return; + m_worldUpdateDeferred = false; updateWorld(); } +void RenderIndex::requestWorldUpdate() +{ + if (m_updateBatchDepth > 0) + m_worldUpdateDeferred = true; + else + 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 5a7beffaa..f1061cd9b 100644 --- a/tsd/src/tsd/rendering/index/RenderIndex.hpp +++ b/tsd/src/tsd/rendering/index/RenderIndex.hpp @@ -71,10 +71,24 @@ 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(); + + 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; @@ -82,6 +96,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..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) - updateWorld(); + 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) @@ -98,22 +104,22 @@ 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(); } } void RenderIndexAllLayers::signalLayerTransformUpdated(const Layer *l) { if (m_instanceCache.contains(l)) { - syncLayerTransforms(l); - updateWorld(); + requestLayerTransformSync(l); + requestWorldUpdate(); } } @@ -122,7 +128,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 +147,7 @@ void RenderIndexAllLayers::signalObjectFilteringChanged() { if (m_filter || m_filterForceUpdate) { releaseAllInstances(); - updateWorld(); + requestWorldUpdate(); m_filterForceUpdate = false; } } @@ -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/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..0b962a59b 100644 --- a/tsd/src/tsd/scene/UpdateDelegate.hpp +++ b/tsd/src/tsd/scene/UpdateDelegate.hpp @@ -54,6 +54,14 @@ 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. 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) }; @@ -136,6 +144,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/src/tsd/scripting/bindings/IOBindings.cpp b/tsd/src/tsd/scripting/bindings/IOBindings.cpp index 3d6264217..76182c8f5 100644 --- a/tsd/src/tsd/scripting/bindings/IOBindings.cpp +++ b/tsd/src/tsd/scripting/bindings/IOBindings.cpp @@ -16,6 +16,47 @@ 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)`. +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"]) + retval.materialMode = tsd::io::usdMaterialModeFromString(*mode); + + if (sol::optional level = settings["refinementLevel"]) + retval.refinementLevel = *level; + if (sol::optional primPath = settings["primPath"]) + retval.primPath = *primPath; + + return retval; +} + +} // namespace + #define TSD_LUA_IMPORT_WRAP(import_call, filename) \ try { \ import_call; \ @@ -98,17 +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. + [importUSD](scene::Scene &s, + animation::AnimationManager &anim, + const std::string &f, + scene::LayerNodeRef loc, + sol::table settings) { + TSD_LUA_IMPORT_WRAP( + importUSD(s, anim, f, loc, usdImportOptionsFromLuaTable(settings)), + 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/src/tsd/ui/imgui/modals/ImportFileDialog.cpp b/tsd/src/tsd/ui/imgui/modals/ImportFileDialog.cpp index fe011be1a..5c25f389f 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_MTLX", tsd::io::ImporterType::USD_MTLX}, + {"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/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 86c31789c..95a55610e 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 @@ -31,9 +32,19 @@ project_add_executable( test_ObjectArchive.cpp test_ObjectUsePtr.cpp test_Parameter.cpp + test_RenderIndex.cpp test_Scene.cpp 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 + test_UsdImport_volumes.cpp ) if (TARGET tsd_scivis_studio_model) target_sources(${PROJECT_NAME} PRIVATE @@ -73,6 +84,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]") @@ -84,9 +96,11 @@ 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]" ) +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/UsdTestFixtures.h b/tsd/tests/UsdTestFixtures.h new file mode 100644 index 000000000..0f3bfb184 --- /dev/null +++ b/tsd/tests/UsdTestFixtures.h @@ -0,0 +1,222 @@ +// 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 + +// 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() +{ + static const ScopedFixtureDirectory 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 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) +{ + 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) +{ + // clang-format off + 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 + }; + // clang-format on + 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_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_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_ImageImport.cpp b/tsd/tests/test_ImageImport.cpp new file mode 100644 index 000000000..902e16dec --- /dev/null +++ b/tsd/tests/test_ImageImport.cpp @@ -0,0 +1,671 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// helium +#include +// 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 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() +{ + 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)); +} + +// 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; +} + +// 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 +// ANARI_FLOAT32_*, glTF keeps the file's 8-bit type and asks for an sRGB +// 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 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 +// 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 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); + 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::ImageCache cache(&scene); + + WHEN("It is imported through the shared texture path") + { + auto sampler = + tsd::io::importTexture(cache, texture.path(), /*isLinear=*/true); + + THEN("Row 0 of the array is the top 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(isTopRowColor(texelAsFloat3(image, 0))); + REQUIRE(isBottomRowColor(texelAsFloat3(image, 1))); + } + } + } +} + +// 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 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") + { + 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 are bound as the file authored them", + "[ImageImport]") +{ + // 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"); + 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(cache, texture.path()); + + THEN("The sampler leaves the coordinates alone") + { + 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)); + } + } + + 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::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( + cache, texture.path(), /*isLinear=*/false, settings); + + THEN("That transform reaches the sampler unchanged") + { + REQUIRE(sampler); + 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(cache, 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)); + } + } + } +} + +// 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. + +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)))); + } + } + } + +#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()); + 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 +} diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp index ab31cd41c..71bf529ad 100644 --- a/tsd/tests/test_Importers.cpp +++ b/tsd/tests/test_Importers.cpp @@ -5,12 +5,116 @@ #include "catch.hpp" // 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" // std +#include #include #include +#include + +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())); + } + } + + 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()); + REQUIRE(rejoins("volume.raw")); + } + } + + 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::pathOf("/a/b/") == "/a/b/"); + REQUIRE(tsd::io::fileOf("").empty()); + REQUIRE(tsd::io::pathOf("").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") == "/"); + REQUIRE(rejoins("/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]") @@ -64,3 +168,282 @@ 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); +} + +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 +// 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 +// 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::ImageCache cache(&scene); + + WHEN("It is imported as a linear texture") + { + auto sampler = + tsd::io::importTexture(cache, tiff.path(), /*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(cache, tiff.path(), /*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 + } + +#if TSD_USE_OIIO + GIVEN("A 1x1 grey+alpha TIFF file") + { + GreyAlphaTiffFixture tiff("tsd_test_1x1_greyalpha.tif"); + + tsd::scene::Scene scene; + tsd::io::ImageCache cache(&scene); + + WHEN("It is imported as an sRGB texture") + { + auto sampler = + tsd::io::importTexture(cache, tiff.path(), /*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 +} 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"); + } + } +} 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)); + } + } + } +} diff --git a/tsd/tests/test_UsdImport.cpp b/tsd/tests/test_UsdImport.cpp new file mode 100644 index 000000000..81e0397cb --- /dev/null +++ b/tsd/tests/test_UsdImport.cpp @@ -0,0 +1,481 @@ +// 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 + +// tsd_tests +#include "UsdTestFixtures.h" + +SCENARIO("A USD Stage's prim hierarchy is mirrored in the Layer", "[UsdImport]") +{ + GIVEN("A Stage nesting a mesh two Xforms deep") + { + ImportedStage 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"( + } + } +} +)"); + + WHEN("The Stage is imported") + { + auto *layer = stage.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(stage.report.skipped.empty()); + } + } + } +} + +SCENARIO( + "Guide and proxy Purpose content is excluded by default", "[UsdImport]") +{ + GIVEN("A Stage with one mesh per Purpose") + { + const std::string purposeStage = 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"( + } +} +)"; + + WHEN("The Stage is imported with default options") + { + ImportedStage stage("tsd_test_usd_purpose.usda", purposeStage); + + THEN("Only default and render Purpose content arrives") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 2); + } + + THEN("Each excluded prim is reported") + { + REQUIRE(stage.report.countOf(tsd::io::UsdSkipReason::PURPOSE_EXCLUDED) + == 2); + } + + THEN("Excluded prims leave a disabled Placeholder Node") + { + auto *layer = stage.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; + ImportedStage stage("tsd_test_usd_purpose.usda", purposeStage, options); + + THEN("Proxy content arrives too") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 3); + REQUIRE(stage.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") + { + ImportedStage 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"( + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The content still arrives so it can be toggled on") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + } + + THEN("Its node is disabled and the reason is reported") + { + auto *layer = stage.scene.defaultLayer(); + auto hidden = findNode(layer, "Hidden"); + REQUIRE(hidden); + REQUIRE_FALSE((*hidden)->isEnabled()); + REQUIRE(stage.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") + { + ImportedStage 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"( + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The supported content still arrives") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 0); + } + + THEN("The unsupported prim is named in the report") + { + 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 = stage.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") + { + const std::string siblingAssets = 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"( + } +} +)"; + + WHEN("The import is pointed at one subtree") + { + tsd::io::UsdImportOptions options; + options.primPath = "/AssetB"; + ImportedStage stage("tsd_test_usd_subtree.usda", siblingAssets, options); + + THEN("Only that subtree arrives") + { + 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")); + } + } + } +} + +SCENARIO( + "Stage framing metadata is recorded for the application", "[UsdImport]") +{ + GIVEN("A Z-up Stage authored in centimetres") + { + ImportedStage stage("tsd_test_usd_framing.usda", + std::string(R"(#usda 1.0 +( + upAxis = "Z" + metersPerUnit = 0.01 +) + +def Mesh "Quad" +{ +)") + QUAD_MESH_BODY + + R"( +} +)"); + + WHEN("The Stage is imported") + { + THEN("Up-axis and unit scale are recorded on the import root node") + { + auto *layer = stage.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 = stage.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 prim that resets the transform stack ignores its ancestors", + "[UsdImport]") +{ + GIVEN("A child that resets the transform stack under a moved parent") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("Composing parent and child lands where USD puts the child") + { + auto *layer = stage.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") + { + ImportedStage 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)] +} +)"); + + WHEN("The Stage is imported") + { + THEN("The caller is told the animation was not represented") + { + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::TIME_VARYING_VALUE_DROPPED) + == 1); + } + } + } +} + +#endif // TSD_USE_USD diff --git a/tsd/tests/test_UsdImport_animation.cpp b/tsd/tests/test_UsdImport_animation.cpp new file mode 100644 index 000000000..c53267875 --- /dev/null +++ b/tsd/tests/test_UsdImport_animation.cpp @@ -0,0 +1,876 @@ +// 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" +// std +#include +#include +#include + +SCENARIO("Animation is captured at the times actually authored", "[UsdImport]") +{ + GIVEN("A Stage with transforms keyed on a non-uniform time base") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The binding's time base mirrors the authored sample spacing") + { + 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); + 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") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("Extra samples are inserted so the rotation still animates") + { + REQUIRE(stage.animMgr.animations().size() == 1); + const auto &bindings = + stage.animMgr.animations()[0].transformBindings(); + REQUIRE(bindings.size() == 1); + REQUIRE(bindings[0].sampleCount() > 2); + } + } + } + + GIVEN("A prim translating between two keyframes") + { + ImportedStage 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"] +} +)"); + + WHEN("The Stage is imported") + { + THEN("No extra samples are invented") + { + const auto &bindings = + stage.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") + { + ImportedStage 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)], + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("Only one frame is eager; the rest is bound to the Stage") + { + 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 = stage.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") + { + stage.animMgr.setAnimationTime(1.0f); + + auto geometry = stage.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( + stage.animMgr, tree.root())); + + tsd::animation::AnimationManager restored(&stage.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 = stage.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. + ImportedStage 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)] +} +)"); + + WHEN("The Stage is imported") + { + 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(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE_FALSE(findNode(stage.scene.defaultLayer(), "part_one")); + } + } + } +} + +// 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. +// +// 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; + + { + std::ofstream caseFile(casePath); + caseFile << "FORMAT\n" + << "type: ensight gold\n" + << "GEOMETRY\n" + << "model: " << geoName << "\n"; + if (withScalarField) { + caseFile << "VARIABLE\n" + << "scalar per node: density " << scalarName << "\n"; + } + } + + const int32_t partIds[2] = {1, 2}; + const char *partDescriptions[2] = {"part_one", "part_two"}; + constexpr int numNodes = 3; + + { + 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(); +} + +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", false); + 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("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]") +{ + 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. + ImportedStage 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)] +} +)"); + + WHEN("The Stage is imported") + { + THEN("Nothing is reported as dropped") + { + REQUIRE(stage.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") + { + ImportedStage 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"] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("Both prims land in one Animation named for the file") + { + 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(stage.report.animatedPrims == 2); + } + } + } +} + +SCENARIO("An old-format geometry binding still reconstructs", "[UsdImport]") +{ + GIVEN("An Archive node carrying the dropped sampleTimes and timeBase fields") + { + ImportedStage 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)], + } +} +)"); + + 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. + 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 = stage.animMgr.addAnimation("legacy"); + REQUIRE(tsd::io::UsdGeometryFileBinding::addToAnimation( + anim, stage.scene, node) + != nullptr); + + THEN("It scrubs from the Stage's own clock") + { + stage.animMgr.setAnimationTime(1.0f); + + auto geometry = stage.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. + ImportedStage 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)], + } +} +)"); + + WHEN("The Stage is imported") + { + auto geometry = stage.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") + { + stage.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 = stage.scene.numberOfObjects(ANARI_SURFACE); + const auto geometriesBefore = + stage.scene.numberOfObjects(ANARI_GEOMETRY); + const auto materialsBefore = + stage.scene.numberOfObjects(ANARI_MATERIAL); + + 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(stage.scene.numberOfObjects(ANARI_SURFACE) == surfacesBefore); + REQUIRE( + stage.scene.numberOfObjects(ANARI_GEOMETRY) == geometriesBefore); + REQUIRE(stage.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") + { + ImportedStage 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] + } +} +)"); + + WHEN("The Stage is imported") + { + auto positionArrayOf = [&](size_t i) { + auto geometry = stage.scene.getObject(i); + return geometry ? geometry->parameterValueAsObject( + "vertex.position") + : nullptr; + }; + + 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") + { + 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") + { + stage.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..c8a124f89 --- /dev/null +++ b/tsd/tests/test_UsdImport_geometry.cpp @@ -0,0 +1,444 @@ +// 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") + { + ImportedStage stage("tsd_test_usd_single_mesh.usda", + std::string(R"(#usda 1.0 + +def Xform "World" +{ + def Mesh "Quad" + { +)") + QUAD_MESH_BODY + + R"( + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The mesh becomes one triangle-geometry surface") + { + REQUIRE(stage.report.stageOpened); + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(stage.scene.numberOfObjects(ANARI_GEOMETRY) == 1); + + auto geometry = stage.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(stage.report.skipped.empty()); + REQUIRE(stage.report.convertedPrims == 1); + } + } + } +} + +SCENARIO("Analytic quadrics stay analytic", "[UsdImport]") +{ + GIVEN("A Stage with a sphere and a cylinder") + { + ImportedStage 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" +} +)"); + + WHEN("The Stage is imported") + { + THEN("They map onto TSD's native quadric geometry, not meshes") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_GEOMETRY) == 2); + + auto ball = stage.scene.getObject(0); + REQUIRE(ball->subtype() == tsd::scene::tokens::geometry::sphere); + REQUIRE(ball->parameterValueAs("radius").value() == Approx(2.f)); + + 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. + 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(stage.report.skipped.empty()); + } + } + } +} + +SCENARIO( + "A non-convex polygon tessellates without spurious geometry", "[UsdImport]") +{ + GIVEN("A mesh with one concave five-sided face") + { + ImportedStage 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)] +} +)"); + + WHEN("The Stage is imported") + { + THEN("It becomes exactly n-2 triangles") + { + auto geometry = stage.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") + { + const std::string subdivCube = 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)] +} +)"; + + WHEN("The Stage is imported at the default refinement level") + { + ImportedStage stage("tsd_test_usd_subdiv.usda", subdivCube); + + THEN("The subdivision mesh gains vertices and the polygon mesh does not") + { + auto subdiv = stage.scene.getObject(0); + auto polygon = stage.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; + ImportedStage stage("tsd_test_usd_subdiv.usda", subdivCube, options); + + THEN("The subdivision mesh arrives at its authored resolution") + { + auto subdiv = stage.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") + { + ImportedStage 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" + ) +} +)"); + + WHEN("The Stage is imported") + { + THEN("The UVs survive refinement rather than being dropped") + { + auto geometry = stage.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(stage.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") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The widthless curve's radius scales with its bounds") + { + auto geometry = findGeometry(stage.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(stage.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(stage.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)); + } + } + } +} + +SCENARIO("Conversion leaves nothing behind for geometry it does not emit", + "[UsdImport]") +{ + GIVEN("A mesh with no points that nonetheless binds a material") + { + ImportedStage 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 = [] +} +)"); + + // 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") + { + THEN("No Surface, Geometry or Material is created for it") + { + 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") + { + ImportedStage 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 = + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("Only the material the drawn subset uses is created") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_SURFACE) == 1); + + bool sawUnused = false; + 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; + } + 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..691ec4cad --- /dev/null +++ b/tsd/tests/test_UsdImport_instancing.cpp @@ -0,0 +1,436 @@ +// 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") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The Prototype is imported once, not once per placement") + { + 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 = stage.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(stage.report.skipped.empty()); + } + } + } +} + +SCENARIO("USD Instances share objects across placements", "[UsdImport]") +{ + GIVEN("A Stage referencing one Prototype from two instanceable prims") + { + ImportedStage 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"] +} +)"); + + WHEN("The Stage is imported") + { + 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(stage.scene.numberOfObjects(ANARI_SURFACE) == 2); + } + + THEN("Each placement is a node referencing the shared objects") + { + auto *layer = stage.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") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("One Animation holds the instancer's binding") + { + 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(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(stage.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(stage.scene.defaultLayer(), "Swarm"); + REQUIRE(before != nullptr); + + stage.animMgr.setAnimationTime(1.0f); + + 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)); + REQUIRE(m[2][0].x == Approx(2.f)); + } + + THEN("A time between authored samples is interpolated, not snapped") + { + stage.animMgr.setAnimationTime(0.5f); + + auto *transforms = + findTransformArray(stage.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") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported and scrubbed past the change") + { + auto *before = findTransformArray(stage.scene.defaultLayer(), "Growing"); + REQUIRE(before != nullptr); + REQUIRE(before->size() == 2); + + stage.animMgr.setAnimationTime(1.0f); + + THEN("The node is re-pointed at a right-sized Array") + { + auto *after = findTransformArray(stage.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") + { + ImportedStage 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)] + } +} +)"); + + WHEN("The animation manager round-trips through an Archive") + { + tsd::core::DataTree tree; + REQUIRE(tsd::io::serialize_AnimationManagerArchive( + stage.animMgr, tree.root())); + + tsd::animation::AnimationManager restored(&stage.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(stage.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. + ImportedStage 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)] + } +} +)"); + + WHEN("The Stage is imported and scrubbed to the end") + { + stage.animMgr.setAnimationTime(1.0f); + + THEN("The authored samples define the range that time maps onto") + { + auto *transforms = + findTransformArray(stage.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") + { + ImportedStage 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)], + } +} +)"); + + WHEN("The Stage is imported and scrubbed to the end") + { + stage.animMgr.setAnimationTime(1.0f); + + THEN("The authored samples define the range that time maps onto") + { + auto geometry = stage.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..bb0486193 --- /dev/null +++ b/tsd/tests/test_UsdImport_lights.cpp @@ -0,0 +1,291 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Lights and cameras. + +#if TSD_USE_USD + +// catch +#include "catch.hpp" +// helium +#include +// tsd_tests +#include "UsdTestFixtures.h" +// std +#include + +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). + ImportedStage 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) +} +)"); + + WHEN("The Stage is imported") + { + THEN("The light's intensity accounts for both") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_LIGHT) == 1); + auto light = stage.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(stage.report.skipped.empty()); + } + } + } +} + +SCENARIO("A shaped sphere light becomes a spot light", "[UsdImport]") +{ + GIVEN("A sphere light carrying shaping attributes") + { + ImportedStage 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 +} +)"); + + WHEN("The Stage is imported") + { + THEN("Spot lighting survives the import") + { + auto light = stage.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)); + } + } + } +} + +// 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") + { + ImportedStage 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 + } +} +)"); + + // 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") + { + THEN("The authored viewpoint is available and animated") + { + REQUIRE(stage.scene.numberOfObjects(ANARI_CAMERA) == camerasBefore + 1); + + bool hasCameraAnimation = false; + for (const auto &animation : stage.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..71e8959ba --- /dev/null +++ b/tsd/tests/test_UsdImport_materials.cpp @@ -0,0 +1,438 @@ +// 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") + { + const std::string previewSurface = 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") + { + ImportedStage stage("tsd_test_usd_preview_material.usda", previewSurface); + + THEN("A portable physically-based material is emitted") + { + REQUIRE(boundMaterial(stage.scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE(stage.report.skipped.empty()); + } + } + + WHEN("A native passthrough is asked for that this material cannot give") + { + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MDL; + ImportedStage stage( + "tsd_test_usd_preview_material.usda", previewSurface, options); + + THEN("The fallback to a portable mapping is reported, not silent") + { + REQUIRE(boundMaterial(stage.scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + == 1); + } + } + +#if TSD_USD_HAS_MATERIALX + WHEN("MaterialX emission is asked for") + { + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + 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(stage.scene)->subtype() + == tsd::scene::tokens::material::physicallyBased); + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE) + == 1); + } + } +#endif + } + +#if TSD_USD_HAS_MATERIALX + GIVEN("A Stage with an authored MaterialX network") + { + const std::string materialxNetwork = 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::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + ImportedStage stage( + "tsd_test_usd_materialx.usda", materialxNetwork, options); + + THEN("The network passes through as an inline MaterialX document") + { + auto *material = boundMaterial(stage.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") + { + 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()}); + + 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") + { + 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()}); + + 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"); + + const std::string texturedNetwork = 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::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + ImportedStage stage( + "tsd_test_usd_materialx_textures.usda", texturedNetwork, options); + + const auto source = stringParameter(boundMaterial(stage.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 : stage.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(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(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. + REQUIRE(boundName == "_/Present/file"); + } + + THEN("A tile set binds nothing, and says so") + { + REQUIRE( + stage.report.contains(tsd::io::UsdSkipReason::TEXTURE_LOAD_FAILED)); + // Only the one loadable texture became a sampler. + REQUIRE(stage.scene.numberOfObjects(ANARI_SAMPLER) == 1); + } + } + } + + GIVEN("A MaterialX network naming a texture that is not there") + { + const std::string missingTexture = 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::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + ImportedStage stage( + "tsd_test_usd_materialx_missing.usda", missingTexture, options); + + THEN("The Import Report names it rather than leaving it to the device") + { + REQUIRE( + stage.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") + { + const std::string mistypedNetwork = 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::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MATERIALX; + ImportedStage stage( + "tsd_test_usd_materialx_mistyped.usda", mistypedNetwork, options); + + THEN("The Import Report names it rather than leaving it to the device") + { + REQUIRE(stage.report.contains( + tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED)); + } + + THEN("The portable mapping is what arrives, not a document") + { + REQUIRE(boundMaterial(stage.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..d17741ff0 --- /dev/null +++ b/tsd/tests/test_UsdImport_materials_portable.cpp @@ -0,0 +1,447 @@ +// 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'") + { + ImportedStage 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 = + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The named primvar lands on the geometry's first attribute") + { + auto geometry = stage.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") + { + ImportedStage 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") + { + THEN("The Import Report names the material exactly once") + { + REQUIRE(stage.report.countOf( + tsd::io::UsdSkipReason::MATERIAL_RESOLUTION_FAILED) + == 1); + } + + THEN("Every mesh still arrives") + { + REQUIRE(stage.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") + { + const std::string omniPbrStage = 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") + { + ImportedStage stage("tsd_test_usd_omnipbr.usda", omniPbrStage); + auto *material = boundMaterial(stage.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(!stage.report.contains( + tsd::io::UsdSkipReason::RICHER_MATERIAL_AVAILABLE)); + } + } + + WHEN("MDL passthrough is asked for instead") + { + tsd::io::UsdImportOptions options; + options.materialMode = tsd::io::UsdMaterialMode::MDL; + ImportedStage stage("tsd_test_usd_omnipbr.usda", omniPbrStage, options); + + THEN("The native shader still wins over the portable mapping") + { + REQUIRE(boundMaterial(stage.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"); + + ImportedStage 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") + { + auto *material = boundMaterial(stage.scene); + + THEN("The texture named on the shader input is bound") + { + REQUIRE( + material->parameterValueAsObject("baseColor") + != nullptr); + REQUIRE(!stage.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") + { + ImportedStage 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") + { + 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(stage.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") + { + ImportedStage 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" + ) +} +)"); + + WHEN("The Stage is imported") + { + THEN("The Surface's material carries the display values") + { + auto surface = stage.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") + { + ImportedStage 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" + ) +} +)"); + + WHEN("The Stage is imported") + { + THEN("Its material carries the display colour, not TSD's default") + { + auto surface = stage.scene.getObject(0); + REQUIRE(surface); + auto *material = surface->parameterValueAsObject( + tsd::scene::tokens::surface::material); + REQUIRE(material != nullptr); + REQUIRE(material != stage.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 = stage.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..789ccde67 --- /dev/null +++ b/tsd/tests/test_UsdImport_subsets.cpp @@ -0,0 +1,402 @@ +// 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") + { + ImportedStage 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 = + } + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("There is one Surface per subset") + { + 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(stage.scene, "/World/Strip"); + REQUIRE(leftover); + auto *index = leftover->parameterValueAsObject( + "primitive.index"); + REQUIRE(index != nullptr); + REQUIRE(index->size() == 2); + REQUIRE(stage.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; + 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( + "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") + { + ImportedStage 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] + } + } +} +)"); + + WHEN("The Stage is imported") + { + 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(stage.scene, "/World/Quad/Left"); + auto right = findGeometry(stage.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(stage.scene, "/World/Quad/Left"); + auto right = findGeometry(stage.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. + ImportedStage 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" + ) + } +} +)"); + + WHEN("The Stage is imported") + { + THEN("The UVs arrive flattened, one value per triangle corner") + { + auto geometry = findGeometry(stage.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(stage.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") + { + ImportedStage 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 = + } + } +} +)"); + + WHEN("The Stage is imported") + { + 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(stage.scene, "/World/Quad/Left"); + auto right = findGeometry(stage.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 diff --git a/tsd/tests/test_UsdImport_volumes.cpp b/tsd/tests/test_UsdImport_volumes.cpp new file mode 100644 index 000000000..64e8c8c4a --- /dev/null +++ b/tsd/tests/test_UsdImport_volumes.cpp @@ -0,0 +1,352 @@ +// 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. +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; +}; + +// 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. +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("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]") +{ + 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 + +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 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")); + } + } + } +} + +#endif // TSD_USE_USD