rust-usd 0.0.2

Rust bindings to OpenUSD (pxr C++): stage open, prim/mesh attrs, variants, sublayer authoring, UsdShade read+write, ArResolver hook.
docs.rs failed to build rust-usd-0.0.2
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

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:

[build-dependencies]
rust-usd-build = "0.0.1"
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 such as forge:// through rust-usd.

The first is the AssetResolver trait. Implement resolve(uri) -> Option<String> and call install_forge_resolver once before any Stage::open. That registers a Rust callback the C++ resolver invokes during stage composition. The forge_demo example walks this end to end. The first call to install_forge_resolver writes a small plugInfo.json into std::env::temp_dir() so USD's plug registry can reach the type. The temp file is per process and reaped by the OS.

The second 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 that contains its plugInfo.json before launching the binary. In that setup Stage::open("forge://asset/foo") works without any install_forge_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.