# rust-usd
OpenUSD ships as a hefty C++ library in the pxr namespace. There has not been a usable Rust binding for it. This repository is one. It contains two crates that together cover what a USD aware tool needs to read assets, edit them without mutating the original, and hand them off to Hydra for rendering.
The split is intentional. `rust-usd` is the core. It opens stages, walks prims, reads typed mesh attributes, follows payloads, switches variants, authors edits into a sidecar layer, and emits material graphs that render in usdview, Karma, or RenderMan. `hydra-rs` is the renderer plumbing. It feeds a stage into a `UsdImagingStageSceneIndex`, enumerates registered render delegates, and exposes a `render_to_rgba` call wired up through `UsdImagingGLEngine`. The Hydra side builds and links cleanly. The render call segfaults headlessly on macOS until the graphics context plumbing is finished. The README is honest about that state.
## What works today
The eight examples under `examples/` are the proof for `rust-usd`.
`open_stage` walks the prim tree of a `.usda`, `.usdc`, or `.usdz` file. `dump_meshes` reads points, face vertex counts and indices, normals with their interpolation, primvars `st` with optional indices, the world space transform from `UsdGeomXformCache`, and the subdivision scheme. `dump_textures` walks a bound material's shader graph and surfaces the `inputs:file` paths from any `UsdUVTexture` nodes. `payload_control` opens a stage with `InitialLoadSet::None` and then loads paths on demand. `forge_demo` installs a Rust side resolver for `forge://` URIs through an `AssetResolver` trait that USD calls back into during stage composition.
The interesting half is the authoring side. `variants_demo` opens an asset with `Stage::open_for_painting`, which mounts the asset as a sublayer of an empty edit layer and points the edit target at the edit layer. Switching the active variant writes to the edit layer. `save_edit_layer` writes only the edit layer to disk. The asset stays byte identical. `paint_demo` does the same dance for arbitrary primvars, authoring `primvars:wear` and `primvars:dustColor` into a sidecar. `shade_demo` builds a `UsdPreviewSurface` plus a `UsdUVTexture` whose `inputs:file` carries a `<UDIM>` token, binds the material to the cube, saves, and the asset file still hashes the same as before. The generated edit layer is renderable USD that any compliant renderer can open directly.
A note on primvar interpolation. For each primvar reachable through `Mesh::primvar(name)`, the returned `Primvar` handle exposes `interpolation()` returning the schema string (`"vertex"`, `"faceVarying"`, `"varying"`, `"uniform"`, `"constant"`). Consumers should always check this before laying out the primvar's flat values into a GPU buffer, because the same primvar can be authored with different interpolations on different assets and the array length depends on which one is in effect. The convenience accessor `Mesh::normals_interpolation()` exists for the `normals` attribute (which is not a primvar in USD's strict sense but has an interpolation token that follows the same rules).
## How to build it
There is no vendored USD here. You point the build script at an existing OpenUSD install through environment variables. The crate has been verified against the USD that ships with Houdini 21.0.631 on macOS, which is upstream USD 25.05.
For Houdini on macOS the variables look like this.
```
USD_INCLUDE_DIR=/Applications/Houdini/Houdini21.0.631/Frameworks/Houdini.framework/Versions/21.0/Resources/toolkit/include
USD_LIB_DIR=/Applications/Houdini/Houdini21.0.631/Frameworks/Houdini.framework/Versions/21.0/Libraries
USD_LIB_PREFIX=pxr_
USD_PYTHON_INCLUDE_DIR=/Applications/Houdini/Houdini21.0.631/Frameworks/Houdini.framework/Versions/21.0/Resources/toolkit/include/python3.11
USD_LINK_PYTHON=framework
USD_PYTHON_FRAMEWORK_DIR=/Applications/Houdini/Houdini21.0.631/Frameworks
```
For a vanilla `build_usd.py` install you can usually get away with `USD_INSTALL_DIR` pointing at the install root, leaving the prefix empty, and skipping the Python framework variables.
The Python variables are not optional even if your code never touches Python. USD's headers transitively include `Python.h` through `tf/pyLock.h`, and template instantiations pull `pxr_boost::python` converters into your object files. Rather than fight this we link `pxr_python` and `pxr_boost` and feed the build a CPython include directory. On macOS we also embed an rpath that points inside `Python.framework`, because Houdini's `libpxr_python.dylib` install name is the bare `@rpath/Python` rather than the framework style path.
Once the variables are set, `cargo build --examples` should build everything. Each example writes its output next to the input asset, so `cargo run --example shade_demo` from the `rust-usd` crate root produces `examples/paint_edits_shade.usda` that you can open in `usdview` or any USD aware renderer.
## Downstream consumers
`rust-usd`'s build script emits the `-Wl,-rpath,...` link arguments its own examples need so they can find `libpxr_*.dylib` at runtime. Cargo only applies those arguments to the immediate target though, so any crate that depends on `rust-usd` and ships its own binaries needs to re-emit the same rpath, otherwise the dynamic loader fails at startup with `Library not loaded: @rpath/libpxr_usd.dylib`.
The `rust-usd-build` helper crate exists to make that one line of boilerplate. Add it as a build dependency:
```toml
[build-dependencies]
rust-usd-build = "0.0.1"
```
```rust
fn main() {
rust_usd_build::emit_runtime_rpath();
}
```
It reads the same environment variables the main crate does and emits the matching `cargo:rustc-link-arg` directives. Idempotent if no relevant env vars are set, so leaving the call in unconditionally is safe.
## Asset resolvers
There are two ways to resolve custom URI schemes through `rust-usd`.
The first is the `AssetResolver` trait. Implement `resolve(uri) -> Option<String>` and call `install_uri_resolver(&["scheme1", "scheme2"], resolver)` once before any `Stage::open`. The resolver claims the listed URI schemes; paths whose scheme matches go through the trait, everything else falls through to USD's default behavior. The `forge_demo` example uses the older `install_forge_resolver(resolver)` convenience which is just shorthand for `install_uri_resolver(&["forge"], resolver)`.
The first call writes a small `plugInfo.json` into `std::env::temp_dir()` so USD's plug registry can reach the resolver type. The temp file is per process and reaped by the OS, so consumer deploy trees stay clean.
The second route is to ship your own C++ `ArResolver` plugin (or rely on one already present in your asset pipeline) and point `PXR_PLUGINPATH_NAME` at the directory containing its `plugInfo.json` before launching the binary. In that setup `Stage::open("forge://asset/foo")` works without any `install_uri_resolver` call, because USD's plug system finds and uses the existing resolver. Pick whichever is cleaner for your pipeline.
## hydra-rs
The Hydra crate sits in `hydra-rs/` as a workspace member. Its `hydra_inspect` example opens a stage, enumerates render delegate plugins, and walks the prim paths that Hydra's scene index actually exposes. On the test machine that prints `HdStormRendererPlugin` and six prim paths for a stage that has eight authored prims, because Hydra's scene index correctly hides shaders inside materials.
`hydra_render` actually renders pixels through Storm. It opens a stage, constructs `UsdImagingGLEngine` with an explicit `Hgi::CreatePlatformDefaultHgi`, disables presentation so `HdxPresentTask` does not try to copy the Metal texture into a non existent GL context, runs a convergence loop, and reads the half float color AOV back as RGBA8. The example is gated behind `HYDRA_RENDER_ENABLE=1` so the long story does not surprise people running `cargo run --example` for the first time. To see it work:
```
cd hydra-rs
USD_INCLUDE_DIR=... USD_LIB_DIR=... USD_LIB_PREFIX=pxr_ \
USD_PYTHON_INCLUDE_DIR=... USD_LINK_PYTHON=framework USD_PYTHON_FRAMEWORK_DIR=... \
HYDRA_RENDER_ENABLE=1 cargo run --example hydra_render
ffmpeg -y -f rawvideo -pixel_format rgba -video_size 256x256 \
-i examples/hydra_render.rgba examples/hydra_render.png
open examples/hydra_render.png
```
Stdout reports a non background pixel count and a mean RGBA. The `hydra_test.usda` asset is a sphere with `displayColor = (0.85, 0.2, 0.2)` and an authored `DistantLight`. With `enableLighting = false` the unlit displayColor reaches output, so the rendered image is a red sphere on a dark background. The lighting path on top of that is still being worked through.
## What this repo also is
A written record of the unobvious things you trip over when you bind USD from another language. A short list, with apologies for the density.
USD's `AR_DEFINE_RESOLVER` macro emits unqualified `TfType` and friends, so you have to call it from a scope that has issued `PXR_NAMESPACE_USING_DIRECTIVE`. USD 25 also refuses to instantiate a registered `ArResolver` derived `TfType` unless a `plugInfo.json` claims ownership, even when the type is registered through static initialization. The fix in this crate is to write the json next to the running executable and call `PlugRegistry::RegisterPlugins` lazily on first stage open, with `LibraryPath` pointing at `current_exe()`. On macOS that works because dlopen of an already loaded Mach O image is a no op and the type is already in the registry.
cxx's `self: &T` becomes `const T&` on the C++ side. Several pxr handle types like `UsdVariantSet` and `UsdShadeShader` have non const mutating methods that author opinions on the underlying layer rather than mutating the wrapper. You can call them from a const Rust method by declaring the wrapped pxr handle `mutable`. A few pxr handles also have private default constructors, which means `std::make_unique<Wrapper>()` does not compile. Give the wrapper an explicit `Wrapper(Handle)` constructor and call `std::make_unique<Wrapper>(std::move(handle))` instead.
## Status
`rust-usd` is at `0.0.1` and covers Tiers 1, 2, and 3 of the painter spec end to end. `hydra-rs` is at `0.0.1` and covers Tier 4 plumbing, with the actual render path stubbed out. Both crates have `publish = false` in their `Cargo.toml`. Flip that and run `cargo publish` when you are ready.