What is USD

USD stands for “Universal Scene Description” and at this point is an umbrella term that includes an open standard https://openusd.org/ to collaboratively edit and create 3D scenes and assets, apply physically correct properties to the 3D assets, describe the materials to apply, define the hierarchies (e.g. which asset goes under which other asset to create the 3D model of a car), define how properties vary with time (e.g. for an animation of a 3D model) and much more.

Among other things the USD standard also describes a serialization file format (in the simplest case to describe how data is laid out in .usd files) which defines how to save that information to a single or multiple files that can be shared with other users locally or across a network to collaborate on authoring (i.e. ‘editing’) and visualizing 3D scenes.

A consortium called the Alliance for OpenUSD has recently been created and among other tasks they’re working on creating a formal specification (much like the C++’s standard) for OpenUSD and its file formats.

What is the goal of USD

There used to be no consensus among modeling software programs (e.g. Blender, 3D Studio Max or Maya) and programs operating on assets needed to compose 3D scenes (e.g. programs to aid in material designing like Adobe Substance Painter) on how to communicate with each other (especially if from different vendors). These programs are commonly referred to as DCC tools, i.e. “Digital Content Creator” tools.

USD aims at filling that gap and providing a single source of truth to exchange files seamlessly and let multiple people work on the same 3D scene/model at the same time.

If a 3D content creator program like Blender or 3D Studio Max or Maya has a USD connector available (be it a plugin, an addon or whatever integrated or external piece of code usually developed by NVIDIA), it means the program is able to export its content (be it a 3D model, a texture, or even just plain text data) to a USD file. And that file can be later read and imported by another program which has a USD connector. Plus there are tools to visualize those USD files, to edit them, to compose them and so on.

USD is complex but comprehensive

USD is a very descriptive, complex and powerful 3D representation format, some of its key points:

  • It was initially developed by Pixar and later open-sourced (free to use under Apache 2.0), so it’s rather battle-tested for large-scale usage

  • It is often addressed as the ‘Photoshop of 3D graphics’ since it uses layers to compose a final component of a scene (e.g. the color of an asset can be overridden in a stronger layer, but the other colors are retained as well - one can always go back to it or compose them together) and it allows for multiple people editing the same 3D scene collaboratively and/or in real-time (if the underlying software allows it). E.g. see this image where layer2 (a USD file itself) which gives the cube a red color with a stronger opinion is muted and the renderer transitions the cube to a blue color (given by layer1 which has a weaker opinion on the color given by the layers’ hierarchy)

  • It is extensible. USD allows for new schemas to be defined: a schema is a textual document of rules. E.g. there could be a ‘light schema’ which defines light properties like the light’s color, intensity and direction. The schema should afterwards be parsed and interpreted by a software (like Omniverse) which can later load a USD file with primitives (for now just think of ‘primitives’ as 3D assets) which have that schema applied to them (much like fulfilling an interface contract in programming languages or inheriting from a pure virtual class) and have the properties defined by that schema defined and instantiated into them. So to make it simple a software can read a ‘LightBulb’ prim (3D asset) which has the ‘Light’ schema applied on it, and therefore it must have color, intensity and direction defined in order for the renderer to actually render it on the scene. This is the real light schema in usda format (USD format in ASCII - i.e. human readable, not binary nor compressed), as you can see it is pretty complex but there is no need to grasp that complexity right now.

    Schemas are a very powerful mechanism which allows the USD format to expand, evolve and represent complex 3D objects properties (e.g. physics properties!).

Pixar has also open-sourced with the same license a reference implementation (i.e. nothing stops you from conforming to the specifications and writing a USD implementation yourself) of the USD standard, i.e. they provide a rather large C++ library hosted at github.com/PixarAnimationStudios/USD that users can download, compile, contribute to, etc.. the library is written in C++ but python bindings are also available in that same repository to call USD APIs from Python programs.

It has to be noted that this library doesn’t provide any renderer in itself, i.e. even if you clone the repository, build it, generate the python bindings and write code to generate a cube on an empty stage and save it to a USD file as follows

from pxr import Usd, UsdGeom

# Create a new stage
stage = Usd.Stage.CreateNew('helloWorld.usd')

# Define a new Cube primitive
cube = UsdGeom.Cube.Define(stage, '/HelloWorldCube')

# Set the size of the cube
cube.GetExtentAttr().Set([(1.0, 1.0, 1.0)])

# Save the stage
stage.GetRootLayer().Save()

you will be able to execute this python USD code and get a USD output file with a stage with a cube on it, but you will not have any way to visually inspect and render that cube. USD defines another rather complex specification for a rendering architecture called Hydra that renderer programmers can abide by to have their own renderer integrate with USD scenes. There is however a small tool called usdView in the same official USD repo based on PyQt that allows you to quickly render USD stages (mostly for debugging purposes and to understand how USD works).

Performance considerations

Even though USD was originally created by Pixar for its own filmmaking needs, USD is a great choice even for more complex realtime applications (in fact it is also used in Omniverse physical large-scale simulations).

Advanced and performance-intensive graphical applications in Omniverse would use something called USDRT and its underlying library Fabric. The goal of Fabric is to act as a fast runtime cache for USD data and it enables massive performance gains by still leveraging all the capabilities of the USD format. USDRT can be thought as a wrapper on top of Fabric with an API that mimicks USD’s one (so that you can just plug in Fabric under the hood without changing your USD code).

So bottom line is: USD is still a great choice for any performance intensive application even though it was originally conceived as an offline scene composing format thanks to the work that NVIDIA has been putting into it.

In the next pages we will introduce a powerful tool that we’ll be using to render the contents of USD files and to execute USD python commands dynamically on a stage.

Using Omniverse to learn USD

Dabbling with USD is the quickest and easiest way to learn it, much like copy-pasting and changing your first C++ hello world source code. One of the best ways to do this is to use NVIDIA Omniverse for this. It is highly recommended that you download and install Omniverse Standard (specifically Omniverse USD Composer) for free from the NVIDIA website.

Let’s spend some words introducing what is Omniverse exactly. Feel free to skip this page if you know that already.

What is Omniverse

Omniverse is a platform and a series of technologies developed by NVIDIA around the USD standard (although they’re rapidly evolving in other directions as well).

Omniverse comprises technologies and applications to work with 3D graphics, collaborating on creating 3D assets and scenes, using AI to create stunning visual effects or improve the process of creating 3D contents, adding real-time and physically correct physics behaviors to 3D contents, rendering in a physically-correct way with ray tracing or path tracing in real-time, etc.

NVIDIA doesn’t impose any workflow or dictate how Omniverse tools and technologies should be used (they can be used to create photorealistic render images that you later use commercially, they can be used to let multiple 3D artists work on a 3D scene simultaneously without interfering with each other’s modifications, they can be used to ‘predict’ the mechanical ‘wear’ in a ‘digital twin’ 3D representation of a mechanical part in 3D with accurate physics after many simulation steps, they can be used to create a server-side web service which renders something complex and streams the result as a video back to the user’s browser, etc.).

The foundation of many Omniverse applications is called Kit and it’s an extensible framework and SDK developed by NVIDIA which provides a series of libraries and APIs to let users write extensions (i.e. kit-based libraries written in Python, C++, both or in other languages as well) so that they can use NVIDIA’s best-in-breed technologies (e.g. RTX raytracers, PhysX, AI integrations, etc.) to do useful graphical work for them. Official docs for Kit can be found here.

An example: a user can write a Kit extension (here’s a good list of extensions used in Omniverse) which acts as a web service: whenever a HTTP request is received, the extension can fire up a complex 3D scene, generate the assets or the scene requested via the HTTP request with chatGPT, render it and send the rendered result back to the user. Another extension could show up a small UI panel in the Omniverse Composer application (more on this later) which allows a 3D artist to click a button, process the scene geometries with an AI application and apply effects, shaders and much more. Extensions are the core of Omniverse as we will later discover.

Composer & Presenter

Two of the most famous applications (and some of the very first ones a newcomer might try out) in Omniverse are USD Composer (formerly Create) and USD Presenter (formerly View).

The former is a 3D authoring program which allows users to compose complex scenes from 3D assets, applying physical properties to them, simulating and rendering, applying photorealistic materials and much more

Composer/Create is usually not equipped with 3D creation tools to model single 3D assets (think Blender) but rather orchestrates composition of a USD scene from external assets (although it could even become a modeling tool with the right extensions).

Presenter/View instead focuses on visualizing already composed environments and inspecting USD scenes (it doesn’t feature advanced authoring tools as Composer).

There is some overlap in the UI elements of the two applications, as there is in some of the core extensions used for a simple reason: both Composer and Presenter are just a bunch of highly complex Kit based extensions. Kit is both a SDK that programmers can leverage to build their own extensions and also a portable cross-platform executable that can ‘bootstrap’ their extensions with a bare minimum skeleton environment. Composer and Presenter are both instances of the Kit platform executable but they load different extensions. These extensions can also be altered (and dependencies broken, if the user so desires..), changed or custom ones loaded. This is the beauty of Omniverse: you can compose it in any way you want. You’re not satisfied with a particular workflow? Create your own extension and fire it up from a Kit CLI command or from the Composer UI panel.

Nucleus

Omniverse isn’t just a collection of Kit extensions though but much more. For instance: to foster collaboration between lots of 3D artists working together on a complex 3D scene (think Pixar..), together with the USD specification and file format (which features “photoshop layer-like behavior” for 3D contents) Omniverse also provides Nucleus which is a distributed object storage optimized for graphical assets.

With nucleus users can reference omniverse://asset_paths from external repositories or internal organization repos, use or modify those assets and seamlessly have them streamed back to other users which might be using them. Dynamically.

Pricing and requirements

Two things newcomers usually care a lot about: pricing and requirements.

Omniverse is free to use for individuals, but a license must be purchased for team use: Omniverse Licensing.

More in detail (from the official discord):

Quote

Omniverse users are welcome to sell their extensions for whatever they please. The end users must have a license of Omniverse to use their extensions with, but that can be the free Omniverse Individual version.

So programmers are free to write and sell their Omniverse extensions. Users can buy and use those extensions as long as they do it abiding by the Omniverse license (i.e. if they’re working as a team of 20 people with Omniverse, an enterprise license must be purchased). If they’re working as individuals (or teams of 2 people), no license is necessary and Omniverse is totally free.

What about 3D content I create with e.g. Omniverse Composer? Can I sell a rendered video of a Physical simulation made with Omniverse?

Quote

Content and or code\extensions\apps created using OVI (Omniverse Individual license, i.e. abiding by the 2-users-tops requirement) for small teams, using desktops or cloud resources is allowed and can be used for commercial purposes.

So yes: you can create a video using Omniverse and you can sell it for whatever you want.

Can I use Omniverse in my own private cloud?

Quote

For the free version, you are allowed to put Omniverse in the cloud for your own purposes. For example, you are allowed to put OV apps on Azure or AWS VM, create 3D projects and render out those projects using Omniverse Farm which can also be on an Azure VM for free.

The EULA is designed that once you scale the number of users working together and you need support, you should get the enterprise license.

Other licensing example, You can also use the Omniverse Individual version to create, build, sell your own extensions and or apps for free. The user leveraging that extension or app just needs to follow the same EULA.

The only other restriction pertains to letting users use your “abiding OVI individual license” Omniverse apps as cloud services:

Quote

Lets say for example, you put USD Composer in the cloud and allow anyone to use it for free as a streamed application. This would not be allowed, using OVI as a service to users outside your company.

For any other question or clarification please read the final paragraph of this post and get in contact with NVIDIA sales for a special license tailored to your needs: omniverse-license-questions@nvidia.com will get you in touch with a developer relations manager that can work with you.

Regarding Omniverse requirements, each application that works on the Omniverse platform might have different system requirements. The suggested way to get up-to-date information is to browse the NVIDIA website for the app you’re specifically interested in, e.g. the USD Composer/Create page lists requirements like a RTX class card as minimum viable hardware.

Support, learning, official resources

Official channels to learn more about Omniverse, post questions regarding its official applications and main extensions (e.g. related to omni.physx) and get in touch with the great NVIDIA Omniverse community (friendly and available, NVIDIA is doing its best to foster a good community) are the Omniverse discord server, the YouTube Omniverse channel, the developer blog articles and, for critical bugs/issues, the official Omniverse forum (less chatty, more support-y).

Any non-Omniverse related question should not be asked in the above channels but rather in the NVIDIA customer support forum.

Hello USD

Let’s start right away and let us visualize a very simple cube mesh and a distant white light (to actually see the cube in the dark of the scene) in a very minimal setup for Omniverse Composer.

We assume that you have already installed Omniverse Composer on your local system.

Hello USD(a)

This is the usda file that we’ll be using

#usda 1.0
(
    metersPerUnit = 0.01
    upAxis = "Y"
)

def Mesh "Cube"
{
    int[] faceVertexCounts = [4, 4, 4, 4, 4, 4]
    int[] faceVertexIndices = [0, 1, 3, 2, 4, 6, 7, 5, 6, 2, 3, 7, 4, 5, 1, 0, 4, 0, 2, 6, 5, 7, 3, 1]
    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), (0, 1, 0), (0, 1, 0), (0, 1, 0), (0, 1, 0), (0, -1, 0), (0, -1, 0), (0, -1, 0), (0, -1, 0), (-1, 0, 0), (-1, 0, 0), (-1, 0, 0), (-1, 0, 0), (1, 0, 0), (1, 0, 0), (1, 0, 0), (1, 0, 0)] (
        interpolation = "faceVarying"
    )
    point3f[] points = [(-50, -50, 50), (50, -50, 50), (-50, 50, 50), (50, 50, 50), (-50, -50, -50), (50, -50, -50), (-50, 50, -50), (50, 50, -50)]
    texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1), (1, 0), (1, 1), (0, 1), (0, 0), (0, 1), (0, 0), (1, 0), (1, 1), (0, 0), (1, 0), (1, 1), (0, 1), (0, 0), (1, 0), (1, 1), (0, 1), (1, 0), (1, 1), (0, 1), (0, 0)] (
        interpolation = "faceVarying"
    )
    uniform token subdivisionScheme = "none"
    double3 xformOp:rotateXYZ = (0, 0, 0)
    double3 xformOp:scale = (1, 1, 1)
    double3 xformOp:translate = (0, 50, 0)
    uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale"]
}

def DistantLight "DistantLight" (
    apiSchemas = ["ShapingAPI"]
)
{
    float inputs:angle = 1
    float inputs:intensity = 3000
    float inputs:shaping:cone:angle = 180
    float inputs:shaping:cone:softness
    float inputs:shaping:focus
    color3f inputs:shaping:focusTint
    asset inputs:shaping:ies:file
    double3 xformOp:rotateXYZ = (315, 0, 0)
    double3 xformOp:scale = (1, 1, 1)
    double3 xformOp:translate = (0, 0, 0)
    uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale"]
}

some elements might already be familiar to computer graphics programmers: normals, point coordinates, UV coords, etc.

USD extensions can be confusing at times: the following file extensions and formats are defined in USD:

ExtensionFormat of the data inside the file
.usda (or .usd)ASCII human readable file. It can get quite large with complex ‘flattened’ scenes (i.e. scenes containing everything they reference outside collected into one single file). This can be written in a text editor.
.usdc (or .usd)‘c’ stands for ‘crate’, denotes compressed binary files. Designed for minimal parsing on file load (structure is set up at the top). Uses LZ4 in some parts. Uses memory mapping for fast load.
.usdzUncompressed zip archive that can contain other usda/usdc/usd or image or audio files inside. Meant for publishing only and should not be used for editing but as a read-only format. Again: it’s uncompressed, the zip aspect is just for bundling purposes.

USDA files always start with #usda 1.0 at the beginning while usdc have a magic number. .usd is an alias extension: internally it can be either a usda or usdc file.

Remember that there are various tools that you can use to inspect USD files and operate on them, e.g. usdview, usdcat, usddiff and a complete toolset from OpenUSD. We’ll be using Omniverse tools instead.

If we save the previous text file as hello_cube.usda on disk with a simple text editor and open it with OV Composer, it will display a simple lit cube mesh.

A usdview clone with OV Composer

As we previously said Omniverse allows you to develop your own extensions and write small and self-contained Kit applications for your own graphical needs.

Let’s figure out on our local system where OV composer was installed (if we installed it via the official NVIDIA launcher of course). In our OV Composer install folder there are two important directories: apps and kit. The kit folder contains the Kit executable while the apps folder contains some .kit files that will be fed to the kit executable as command line parameters, for example:

~/.local/share/ov/pkg/create-2023.2.0$ ./kit/kit ./apps/omni.create.kit

The multiple omni.app.full.sh or omni.app.full.bat files are usually just to set up directories and launch the right kit files. The NVIDIA documentation has great resources on kit files here.

To launch a full OV Composer app, a omni.create.kit file is usually used, more or less of the form (remember that create is the former name of composer):

[package]
title = "USD Composer"
description = "An Authoring application for USD and Omniverse Content"
version = "2023.2.0"
keywords = ["experience", "app", "usd"]

# All extensions needed to show the viewport, the menu bars, the splash screen, etc..
[dependencies]
"omni.kit.uiapp" = {}
"omni.kit.renderer.core" = {}

# Status Bar
"omni.kit.window.status_bar" = {}

"omni.create.app.resources" = {}
"omni.create.app.setup" = { order = 1000 } # we are running that at the end

# Splash runner
"omni.kit.splash.carousel" = {}

"omni.kit.menu.utils" = {}
"omni.kit.menu.file" = {}
"omni.kit.menu.edit" = {}
"omni.kit.menu.create" = {}
"omni.kit.menu.common" = {}
"omni.kit.menu.stage" = {}

"omni.kit.window.file" = {}
"omni.kit.context_menu" = {}

"omni.kit.selection" = {}
"omni.kit.stage_templates" = {}
"omni.kit.stage.mdl_converter" = {}

## PhysX stuff
"omni.physx.bundle" = {}
"omni.physx.zerogravity" = {}

... lots of other extensions ...

Let us create a new text file in this apps directory with name omni.minimal.kit:

[package]
title = "Kit Simple App"
description = "Minimal USD viewer."
version = "1.0.0"
keywords = ["app"]

[dependencies]
# The Main UI App. Bring everything needed for UI application.
"omni.kit.uiapp" = {}
"omni.hydra.pxr" = {}
"omni.kit.window.viewport" = {}
"omni.kit.window.stage" = {}

[settings]
# Open a stage on startup
app.content.emptyStageOnStart = true
app.window.title = "Kit Custom"
renderer.enabled='pxr'

pxr is the Pixar Storm renderer, a simple OpenGL hydra renderer (much simpler than the RTX renderer by NVIDIA which allows for far more powerful effects).

Now let’s create a USD python file to load our usda file at startup and save it somewhere with name main.py:

import omni.usd
import carb
import asyncio

usd_file_path = "/home/alex/Downloads/hello_cube.usda"

async def _open_usd(usd_file_path):
    await omni.usd.get_context().open_stage_async(usd_file_path)
    carb.log_warn(f"Stage opened from {usd_file_path}")

asyncio.ensure_future(_open_usd(usd_file_path))

This assumes that we saved our textual usda file in the path /home/alex/Downloads/hello_cube.usda. The code is rather simple:

  1. Schedules a python coroutine in background without waiting for it to finish
  2. The coroutine waits for the USD context provided by Kit to load a stage from file
  3. As soon as the stage is loaded, the coroutine resumes and prints a warning on the console (log_info would have been more appropriate but info-level logs are by default not shown unless a verbose parameter is passed)

Let’s put everything together and execute a simple kit app which loads the absolute default essential extensions to visualize in Omniverse our USD hello world file:

~/.local/share/ov/pkg/create-2023.2.0$ ./kit/kit ./apps/omni.minimal.kit --exec ./main.py

As you can see the same widgets that are available in OV Composer were used (i.e. viewport, stage hierarchy viewer, etc.). Omniverse Composer and View and virtually any other Omniverse Kit-based application only differ in the set of extensions loaded. You can load any extension which might suit you or create your own for your workflows.

Context, Stage and Layers

A stage in USD is the final composed result of opening one or more layers. Again on the Photoshop analogy: all image layers compose a final image. A stage references one or more layers and use them to compose a 3D scene.

Stage objects are owned by a USD context: a USD context is usually provided by the application you’re working in (e.g. python or C++ code running in an Omniverse extension can use something like omni.usd.get_context() to get the current active context) and is a container for resources and internal states needed by the stages. Multiple contexts can be created at the same time (or a context can be manually destroyed via code):

# Check if we already have a valid context when this code executes, or create one if there's none
usd_context = omni.usd.get_context()
if not usd_context:
    print("Context was not ready, creating one..")
    usd_context = omni.usd.create_context()

This snippet uses APIs provided by Kit and not available outside of Omniverse Kit (i.e. contexts are usually managed by applications which use OpenUSD libraries), in particular this API is provided by the omni.usd extension.

In the Hello USD section we used asynchronous routines to make sure a stage would be ready after we loaded up a .usda file, if we had executed a python script at startup with the same shell invocation

~/.local/share/ov/pkg/create-2023.2.0$ ./kit/kit ./apps/omni.minimal.kit --exec ./main.py

with just a stage check like this:

import omni.usd
import carb

usd_context = omni.usd.get_context()
stage = usd_context.get_stage()
if not stage:
    carb.log_error("stage was NOT ready")
else:
    carb.log_error("stage was ready")

we would have found out that by the time our script is executed (i.e. after Kit is loaded and all of the extension dependencies have been loaded), there should already be a context provided by Kit but probably no stage is loaded yet (a blank stage or a blank template stage is loaded shortly afterwards, but it might not be ready by the time we execute the code above).

We would have had both (a valid context and a valid stage) if we had executed the script above in USD Composer directly by using the Script Editor extension (you can activate this in Window->Script Editor - if you can’t find this make sure the extension is downloaded and activated from Window->Extensions.. in Omniverse everything is an extension).

Warning

Executing code in Kit by providing a .py file from the command line and/or using the script editor means you can make a lot of assumptions which are no longer valid when you’re developing your own Omniverse Kit extension. Extensions must pay attention to the availability of the resources they intend to use (e.g. do not assume there’s already a stage you can play with).

Ownership of USD stages

USD contexts are also the sole owners of a stage created in it or attached to it. Everything else referencing a stage should be a weakref (non-owning reference), this is by design to prevent leaking stages (which can get quite big).

Let’s see an example of this:

from pxr import Sdf, Usd, UsdUtils
import omni.usd

# 1
usd_context = omni.usd.get_context()
old_stage = usd_context.get_stage()  # Note: this is a weakref
print(old_stage)  # Valid stage

# 2
layer = Sdf.Layer.CreateAnonymous()
new_stage = Usd.Stage.CreateInMemory(layer.identifier)
print(old_stage)  # Stage is still valid here

# 3
cache = UsdUtils.StageCache.Get()
stage_id = cache.Insert(new_stage).ToLongInt()
usd_context.attach_stage_with_callback(stage_id, None)
print(old_stage)  # Old stage is NO LONGER valid here

Executing the code above in the Script Editor prints something like the following

(19:06:08)> /tmp/carb.VUw057/script_169285425.py
Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x107afbd0:World0.usd'), sessionLayer=Sdf.Find('anon:0x107afe00:World0-session.usda'), pathResolverContext=<invalid repr>)
Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x107afbd0:World0.usd'), sessionLayer=Sdf.Find('anon:0x107afe00:World0-session.usda'), pathResolverContext=<invalid repr>)
invalid null stage

plus you should see OV Composer reloading a blank stage.

The code does the following:

  1. The current context is obtained via omni.usd API (Kit proprietary - this is not standard OpenUSD), the current stage is obtained from the current context (this assumes there’s a stage already, just like the blank scene you’re presented in OV Composer at startup) and printed for debugging purposes
  2. A new anonymous layer is created. A layer is usually backed by a USD file, except for anonymous layers: these are layers used for debugging and/or storing runtime data for extensions. In this case we’re using an anonymous layer (so we avoid having it backed by a file) to create an in-memory stage and using that anonymous layer as the root layer (think of it as the starting layer for a stage, we’ll revisit this later). The gist of this part is that we’re creating a stage entirely in memory and ephemerally. The old stage reference is printed and it’s still valid (the context still has the old stage attached).
  3. Now we use an OpenUSD facility called the StageCache: this is a cache exposed by USD since stages can get pretty expensive after loading tons of resources and computing the final rendering results of lots and lots of layers, therefore stages can be cached and reused. In this demonstrative example we use this facility to insert the new stage we manually created into the fold and we finally attach it to the current USD context provided by Kit: this causes Kit to switch to the new empty stage. Finally we print the old_stage reference: now we get the null stage. Keep in mind that the StageCache API is exposed by USD as meant to be used by higher-level facilities (like Kit) to manage multiple stages themselves (i.e. you probably shouldn’t dabble with this unless you know what you’re doing).

Note

It has to be noted that stages which are created in your own Python code (Usd.Stage.Open for instance) return a strong ref. Stages which weren’t created in your Python code but just passed to your Python code (as in usd_context.get_stage()) return a weak ref. This is by design to prevent dangling references to stages owned by the context.

The behavior we just observed is in accordance to what we wrote at the beginning:

Quote

USD contexts are also the sole owners of a stage created in it or attached to it

If we had kept another reference to the old stage, we would have seen the following:

from pxr import Sdf, Usd, UsdUtils
import omni.usd

usd_context = omni.usd.get_context()
old_stage = usd_context.get_stage()
old_stage = Usd.Stage.Open(old_stage.GetRootLayer().identifier)  # Keep a reference to the old stage alive, this is a strong ref
print(old_stage)  # Valid stage

layer = Sdf.Layer.CreateAnonymous()
new_stage = Usd.Stage.CreateInMemory(layer.identifier)
print(old_stage)  # Stage is still valid here

cache = UsdUtils.StageCache.Get()
stage_id = cache.Insert(new_stage).ToLongInt()
usd_context.attach_stage_with_callback(stage_id, None)
print(old_stage)  # Still valid here!
(19:06:08)> /tmp/carb.VUw057/script_169285425.py
Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x107afbd0:World0.usd'), sessionLayer=Sdf.Find('anon:0x107afe00:World0-session.usda'), pathResolverContext=<invalid repr>)
Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x107afbd0:World0.usd'), sessionLayer=Sdf.Find('anon:0x107afe00:World0-session.usda'), pathResolverContext=<invalid repr>)
Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x107afbd0:World0.usd'), sessionLayer=Sdf.Find('anon:0x107afe00:World0-session.usda'), pathResolverContext=<invalid repr>)

even though we attached another stage to the USD context to own. This is usually not recommended as USD contexts should be the objects delegated to managing stages.

Layers

We will revisit layers later on multiple times, but for now a quick introduction linked to what we’ve just learned can be beneficial. Layers in USD are collections of prims (short for primitive in USD, for instance: a cube mesh) and their properties (for instance: the cube mesh’ display color) that can be saved to or loaded from disk or memory (book of USD defines them as “saveable hierarchy” which is an apt term for it).

Much like Photoshop layers, each layer can add to or override some properties defined in the layers below it. Each scene, as we’ve already seen, has a Root Layer, i.e. a main layer (backed by a USD file as well) on which the stage is initially opened. It is also the strongest override layer (except for a special layer called session layer which is like a scratch space for Kit and it’s by default hidden to the users), i.e. the layer where the ‘opinions’ on a prim property count more than all of its sublayers.

Tip

You can view the session layer contents in Omniverse Composer by checking the checkmark Session Layer from the hamburger menu in the Layer pane

Prims can be defined in a layer and have overridden properties in another:

Info

Note the small white triangle on World and Cube under layer3.usda: that symbol is a delta and indicates that only changed properties were registered in that layer and the entire assets were not completely duplicated

$ cat layer2.usda

#usda 1.0
(
    customLayerData = {
        ...
    }
)

def Xform "World"
{
    def Mesh "Cube"
    {
        double3 xformOp:translate = (-255.80417243728027, 0, 0)
        ...
    }
}

$ cat layer3.usda
#usda 1.0
(
    upAxis = "Y"
)

over "World"
{
    over "Cube"
    {
        double3 xformOp:translate = (177.0296449279361, 0, 0)  # <---- overrides this property for the prim /World/Cube
        ...
    }
}

Also note that in the hierarchy above layer3 is positioned above layer2 and therefore has a stronger opinion on the xformOp:translate property (which corresponds to the position of the prim) - layer3 is gonna win and place the cube at (177.0296449279361, 0, 0). The idea would be that layer2 defines a 3D model created by an artist who is still working out some parts of it, the Omniverse Nucleus service has already synchronized the file with the local OV Composer copy of another scene artist who decided that the 3D model looks better moved from (-255.80417243728027, 0, 0) to (177.0296449279361, 0, 0) and authored this property in layer3. This is an example of a non-destructive workflow: properties are changed, added and overridden without destroying the underlying layers. Artists, programmers, simulation engineers and whatnot can still work together within a USD scene by ensuring only their deltas are applied (and, eventually at the end of the work, maybe all changes flattened in the root layer, i.e. re-unifying all deltas and just getting everything together in the single layer which is the root layer).

Layers allow to create a gigantic amount of scenes without eating up a lot of space by reusing assets and just operating on changing properties via deltas.

Info

🐧 USD is a bit like git for computer graphics.

In the next sections we’ll take a deeper look at layers and their internal representations.

Foundational classes

You might have noticed from the few python code listings in the previous sections that the USD API is somewhat scattered throughout different classes/submodules with different prefixes from the pxr module:

from pxr import Usd, Sdf, UsdGeom, Vt, Gf

# Some examples of modules into the pxr
Sdf.Layer.FindOrOpen("/tmp/usd_file.usda")
Usd.Stage.Open(..)
xformable = UsdGeom.Xformable(prim)
xformable.AddTranslateOp().Set(Gf.Vec3d(0.0, 180.0, 0.0))
positions = Vt.Vec3fArray(positions_list)
material = UsdShade.Material(material_prim)

Each prefix represents a different module with a specific purpose and functionality. Here is a brief overview of the main ones (there are many others though):

ModuleMeaning of acronymDescription
SdfScene Description FormatLow-level core scene description format and data model API for USD. It provides classes for representing layers, paths, schemas, attributes, references, variants, and more. Sdf is the foundation of USD and is used by all other modules. These routines come in handy when you want to reason on specific parts (opinions) coming from layers that end up composing a prim on the stage (and on how they interact and are combined together).
GfGraphics FoundationBasic geometric types and operations, such as vectors, matrices, transforms, colors, quaternions, etc. Gf is used by other modules to perform computations and manipulations on geometric data.
TfTools FoundationLow-level utilities and foundation classes, such as python module initialization, string manipulation, error handling, debugging, type traits, smart pointers, etc. mostly OS-independent. Tf is used by other modules to implement common functionality and patterns.
VtValue TypesClasses useful to abstract away types and have collections of values, such as arrays, dictionaries, etc. Vt is used by other modules to store and access data in a generic way.
UsdUniversal Scene Description (Core)Core Usd module: high-level scenegraph API that exposes USD’s composition features to application code. It provides classes for accessing and editing stages, prims, properties, etc. Usd is the main entry point for most USD applications.
UsdGeomUSD Geometry SchemaCore geometry schemas for USD (we’ll revisit schemas later): it provides classes for representing common geometric primitives, such as meshes, curves, points, cameras, etc. UsdGeom also defines concepts such as transformation spaces, visibility, purpose, etc.
UsdShadeUSD Shading SchemaShading schema for USD: it provides classes for representing materials, shaders, textures, etc. UsdShade also defines a network of connectable nodes for describing shading effects.
UsdLuxUSD Lighting SchemaLighting schema for USD: it provides classes for representing lights, light filters, light links, etc. UsdLux also defines a set of built-in light types and filters that can be used by renderers.

Many other modules in the USD C++ API provide additional functionality and domain-specific schemas, you can take a look at them here.

Knowing at least the module which owns a method or a class can help understanding what areas that API is operating on.

C++ and Python in USD

As we’ve already stated the USD API has a reference implementation in C++ with a quite comprehensive API documentation. USD was born C++-first and Python bindings were added later. This means sometimes there are small differences between the C++ and Python APIs:

// Get the current stage (use weakrefs, the context owns a strong reference to the stage)
pxr::UsdStageWeakPtr stage = omni::usd::UsdContext::getContext()->getStage();
if (!stage) {
    return 1;
}

// Traverse the stage and print out all paths for the found prims
pxr::UsdPrimRange range = stage->Traverse();
for (pxr::UsdPrimRange::iterator iter = range.begin(); iter != range.end(); ++iter) {
    // Get the current prim
    pxr::UsdPrim prim = *iter;
    if (!prim)
        continue;

    pxr::SdfPath path = prim.GetPath();
    CARB_LOG_INFO("Found a prim with path: %s", path.GetText()); // Carbon API: Omniverse-specific
}
# Get the current stage (weakref)
stage = omni.usd.get_context().get_stage()
if not stage:
    return False

# Traverse the stage and print out all paths for the found prims
for prim in self.stage.Traverse():
    # Get the current prim
    if not prim:
        continue

    path = prim.GetPath()
    carb.log_info(f"Found a prim with path {str(path)}")  # Carbon API: Omniverse-specific

If you don’t remember the reason why we’re always getting a weakref to the stage, read up on the previous context and stages section.

There is a chapter dedicated to Translating Between the OpenUSD C++ and Python APIs in the official Omniverse documentation which might provide more insights when working with porting C++ and/or Python USD code. Omniverse supports both native C++ extensions and Python extensions (together with extensions having both Python and C++ in them, a common pattern is for example writing UI code in Python and core processing logic in C++ for performance reasons). In this book however we’ll mainly focus on Python APIs to dive into learning OpenUSD. All the concepts are easily applicabile to the C++ API as well.

Prims and Properties

Prims

A prim (short for primitive) is a dictionary of key-value pairs. Prims are the main components of a USD scene graph which gets composed from a stage after assembling all of the layers, resolving their opinions and deciding which final value a prim gets for e.g. its scaling or translation or rotation values.

Prims are hierarchical in the scene so you can have a wheel prim and a car_chassis prim and each one of those meshes containing: in their key field points the XYZ coordinates of each vertex for the mesh, in the faceVertexCounts the number of points composing each face, in faceVertexIndices the indices for each vertex so the face can have a face-up face-down orientation, the normals for each vertex, etc..

def Xform "World"
{
    def Xform "Car"
    {
        double3 xformOp:rotateXYZ = (0, 0, 0)
        double3 xformOp:scale = (1, 1, 1)
        double3 xformOp:translate = (0, 0, 0)
        uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale"]

        def Mesh "Wheel"
        {
            float3[] extent = [(-50, -50, -50), (50, 50, 50)]
            int[] faceVertexCounts = [3, 3, ...a lot of values...]
            int[] faceVertexIndices = [0, 1, 2, 0,....]
            normal3f[] normals = [(0, -50, 0),...](
                interpolation = "faceVarying"
            )
            point3f[] points = [(0, -50, 0), ...]
            texCoord2f[] primvars:st = [(1, 0), (1, 0.0625), ...]
             (
                interpolation = "faceVarying"
            )
            uniform token subdivisionScheme = "none"
            double3 xformOp:rotateXYZ = (0, 0, 0)
            double3 xformOp:scale = (1, 1, 1)
            double3 xformOp:translate = (46.61611128680149, -2.08943043408906, 63.14526081950993)
            uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale"]
        }

        def Mesh "Car_Chassis"
        {
            float3[] extent = [(-50, -50, -50), (50, 50, 50)]
            int[] faceVertexCounts = [4, 4, ...]
            int[] faceVertexIndices = [0, 1, 3, ...] (
                interpolation = "faceVarying"
            )
            point3f[] points = [(-50, -50, 50), ...]
            texCoord2f[] primvars:st = [(0, 0), ...] (
                interpolation = "faceVarying"
            )
            uniform token subdivisionScheme = "none"
            double3 xformOp:rotateXYZ = (0, 0, 0)
            double3 xformOp:scale = (1, 1, 3.1101341016510786)
            double3 xformOp:translate = (0, 50, 0)
            uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale"]
        }
    }
}

Prims can have types and much more too:

def Mesh "Car_Chassis" (apiSchemas = ["GiveThisPrimPropertiesToBehaveAsAMetalPiece"])
#1   #2       #3        #4
  1. specifier (whether this is a prim definition, an OVERride to override properties of another prim, etc.)
  2. type, this is the type of the prim (e.g. Mesh, Xform, DomeLight, etc..)
  3. name - this is the name (part of the SdfPath so it cannot contain spaces) of the prim
  4. prim metadata, API schemas, references, variantsets, etc. are specified here, together with user-defined metadata in a predefined dictionary here (i.e. customData)

A mesh is a type of prim meant to store render-able data (points, normals, maybe UV coords, etc.), an xform prim stores a transform matrix that applies to its child prims (that can be the identity prim - in that case the xform prim name can be useful to group other prims and have a name that is meaningful to human readers/artists, much like the Car xform prim that we used in the image above to group together the wheel and the car chassis), etc.

Properties

Properties are the key+value pairs that are contained in prims (e.g. normals is a property in the prims above, or maybe a radius can be a property of a sphere prim).

There are two types of properties:

  • Attributes: normals and radius fall in this category: key-value pairs with well-defined values (e.g. a float3 or a double). Attributes can be time-sampled, i.e. they can vary over time (so USD supports the concept of animations as well).

    def Sphere "Sphere"
    {
        float3[] extent = [(-30, -30, -30), (30, 30, 30)] # an attribute (not time-sampled)
        double radius = 50 # another attribute
        double radius.timeSamples = { # a time-sampled attribute
            1: 1, # set the sphere with a radius of 1 at frame time 1
            50: 50, # and with a radius of 50 at frame time 50
        } # the above values will be linearly interpolated by default
    }
    
  • Relationships: the classical example here is a material binding where a prim has a relationship property (currently not time sampled)

    def Xform "World"
    {
        def Mesh "Cube" (
            prepend apiSchemas = ["MaterialBindingAPI"]
        )
        {
            float3[] extent = [(-50, -50, -50), (50, 50, 50)]
            int[] faceVertexCounts = [4, 4, 4, 4, 4, 4]
            int[] faceVertexIndices = [0, 1, 3, 2, 4, 6, 7, 5, 6, 2, 3, 7, 4, 5, 1, 0, 4, 0, 2, 6, 5, 7, 3, 1]
            rel material:binding = </World/Materials/SimpleRedSurface> (  # This is a relationship property!
                bindMaterialAs = "weakerThanDescendants"
            )
        }
    
        def Scope "Materials"
        {
            def Material "SimpleRedSurface"
            {
                token outputs:displacement.connect = </World/Materials/SimpleRedSurface/Shader.outputs:displacement>
                token outputs:surface.connect = </World/Materials/SimpleRedSurface/Shader.outputs:surface>
    
                def Shader "Shader"
                {
                    uniform token info:id = "UsdPreviewSurface"
                    color3f inputs:diffuseColor = (1, 0, 0) (  # Red color
                        customData = {
                            float3 default = (0.18, 0.18, 0.18)
                        }
                        hidden = false
                        renderType = "color"
                    )
                }
            }
        }
    }
    

    The material:binding relationship is defined in the UsdShadeMaterialBindingAPI so it’s part of a schema as well for all those prims which are meant to have a material applied to them.

Sometimes “properties” and “attributes” are used somewhat interchangeably in USD code and documentations, but it’s important to know the difference (properties include relationships as well).

Prims can also have custom properties too (something that maybe holds meaning for a 3D modeling program) and those properties can (or can not) be part of a Schema (e.g. a prim with a CollisionAPI schema will have physical properties like contactOffset or similar physics-specific ones). In this example we have a prim with a MyCustomSchemaAPI applied (a schema that might be owned by our own Omniverse extension), a custom attribute and some custom metadata applied to the generated prim as well

def Mesh "Wheel" (
    prepend apiSchemas = ["PhysicsRigidBodyAPI", ... , "MyCustomSchemaAPI"]
    customData = {
        string thisIsMyCustomOmniverseExtensionMetadata = "This great prim was created by MyExtension v1.0"
    }
)
{
    float3[] extent = [(-50, -50, -50), (50, 50, 50)]
    int myCustomProperty = 22
    ...
}

Both prims and properties are identified by unique SdfPaths: examples for the above are

/World/Car/Wheel
/World/Car/Wheel.myCustomProperty
/World/Car/Wheel.material:binding

OV Composer has a handy property visualization pane which summarizes all properties of a prim and allows you to also copy the full SdfPaths by simply right-clicking on a visualized property

Sublayers: authoring time-sampled attributes

Before we proceed further let’s take a look at an example of what we’ve learned so far: we will generate through Python code multiple USD layers, save them to three different .usda files, compose them on a stage and use different time-sampled attributes to demonstrate how one property in a layer can be overridden in another stronger layer.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

# Create a temporary stage in memory for the root layer, the scale layer and the translate layer
root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
scale_stage : Usd.Stage = Usd.Stage.CreateInMemory("ScaleLayer.usda")
translate_stage : Usd.Stage = Usd.Stage.CreateInMemory("TranslateLayer.usda")

# Add stage metadata to inform OV Composer that it should set up a timeline from timecode 0 to timecode 50,
# roughly at 30 fps
root_stage.SetStartTimeCode(0)
root_stage.SetEndTimeCode(50)
root_stage.SetTimeCodesPerSecond(30)

# Create a Xform "World" and a Sphere "Sphere" prim in the scale stage

xform : UsdGeom.Xform = UsdGeom.Xform.Define(scale_stage, Sdf.Path("/World"))
# Overly verbose way of getting "/World" SdfPath to demonstrate API usage
sphere : UsdGeom.Sphere = UsdGeom.Sphere.Define(scale_stage, Sdf.Path(str(xform.GetPrim().GetPath()) + "/Sphere"))
# Set the extent of the sphere
extent = [(-30, -30, -30), (30, 30, 30)]
sphere.GetExtentAttr().Set(extent)
# Set the radius of the sphere
radius = sphere.GetRadiusAttr()
radius.Set(50) # Set as not timesampled
radius.Set(1, 1) # Set as timesampled at timesample 1
radius.Set(50, 50) # Set as timesampled at timesample 50

# Also create a DomeLight on the scale stage to be actually able to see the cube in OV Composer
# (otherwise it's gonna be pitch black)

# Create an Xform named "Environment"
environment_xform = UsdGeom.Xform.Define(scale_stage, "/Environment")
# Create a DomeLight named "DomeLight" within "Environment"
dome_light = UsdLux.DomeLight.Define(scale_stage, "/Environment/DomeLight")
# Set DomeLight attributes
dome_light.CreateIntensityAttr(1000)

# Now create some overrides in the translate stage

sphere_override_prim = translate_stage.OverridePrim("/World/Sphere")
sphere_override : UsdGeom.Sphere = UsdGeom.Sphere(sphere_override_prim) # Treat this as a UsdGeom.Sphere
# Define radius.timeSamples as not varying (always the same) - note that an override has no type
# so we cannot assume that the "type Sphere" schemas are applied - we have to create those attributes
# ourselves so they can override whatever attribute will be found when composing these layers
radius = sphere_override.CreateRadiusAttr()
radius.Set(10, 1)
radius.Set(10, 50)
# Define xformOp:translate.timeSamples as varying!
translate_op = UsdGeom.Xform(sphere_override).AddTranslateOp()
translate_op.Set(Gf.Vec3d(0.0, 0.0, 0.0), 0)
translate_op.Set(Gf.Vec3d(150.0, 0.0, 0.0), 50)

# Export scale and translate layers to file
scale_stage.GetRootLayer().Export(BASE_DIRECTORY + "/ScaleLayer.usda")
translate_stage.GetRootLayer().Export(BASE_DIRECTORY + "/TranslateLayer.usda")

# Add the translate stage and the scale stage as sublayers to the root layer in the root stage
# Note: this would be wrong since it'll make a reference to the in-memory anonymous layer
# '@anon:0x2a0052e0:TranslateLayer.usda@' which will be freed when this interpreter exits
#   root_stage.GetRootLayer().subLayerPaths.append(scale_stage.GetRootLayer().identifier)
# This is instead correct to reference a serialized layer on file (i.e. '@/tmp/TranslateLayer.usda@')
root_stage.GetRootLayer().subLayerPaths.append(BASE_DIRECTORY + "/TranslateLayer.usda")
# also add the scale stage AFTER the translate layer - ORDER IS IMPORTANT HERE!
root_stage.GetRootLayer().subLayerPaths.append(BASE_DIRECTORY + "/ScaleLayer.usda")

# Optional for demonstrative purposes: set the default prim to a prim in another layer
root_stage.SetDefaultPrim(xform.GetPrim())

# Export root layer to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

This rather verbose code listing can be executed in the Script Editor in OV Composer. If everything works correctly, you should have three .usda files in your /tmp directory:

  • RootLayer.usda - this will be our root layer, we will open this file with OV composer and reference the others through this.
  • TranslateLayer.usda - this will be the first (ORDER IS IMPORTANT) sublayer and it will contain a time-sampled override of the radius property (which will not change from timecodes 0 to 50) and a time-sampled override of the translate property (which will change continuously moving the sphere on the X axis when time flows from timecode 0 to 50).
  • ScaleLayer.usda - this will be the second sublayer and it will contain the definitions of the prims in the scene (i.e. the World xform, the Sphere sphere and the DomeLight light prim will be defined in this layer), plus there will be a time-sampled definition of the radius property which will make the sphere grow from a small radius to a huge radius in 50 timecodes.

Note

The opinion in a containing layer is always stronger than any opinions in its sublayers. E.g. the opinion for a property in the root layer is always stronger than any opinion for the same property in any sublayers of that root layer.

If you now try to open RootLayer.usda in OV composer you will see three layers and a very small ball on the white-lit viewport:

Note that the Root Layer is set as the Authoring Layer, i.e. if you try to make modifications to the scene (e.g. changing the scale of the sphere), a delta will be added to the root layer (and written to the RootLayer.usda file if you save it). The reason for this is that the /World/Sphere prim is defined (i.e. there is a def specifier directive in the ASCII text .usda file) in the ScaleLayer.usda: it is not defined in the current root layer, so if we modify anything in the scene and we force modifications to go to the Root Layer, they will be written there as a delta, i.e. the root layer will have a stronger opinion on those properties which will be either defined or defaulted to their default values in the defining ScaleLayer.

If we play the simulation in OV Composer we can see that the sphere slides on the X axis (we might have to move the camera to see it clearly or focus the view on the sphere prim by selecting it and pressing F in OV Composer) from a timecode from 0 to 50 (you can better visualize this in the Window->Animation->Timeline pane, you can even right-click on properties in the Property pane and select Set Key to “store” that value in the currently selected keyframe).

Tip

One side thing to note here: a Sphere prim type is different than a prim sphere of Mesh type, similarly a Cube prim type is different than a cube prim of Mesh type: a Mesh is just a collection of points rendered together while Sphere, Torus, Cube and some others are primitive prim types provided by USD and that have built-in properties that make sense for the shape in question (e.g. the radius property for the Sphere type). We could have structured our code above with a sphere of Mesh type but it would have been consideraly harder and more verbose because of all the points, normals, etc. boilerplate. For clarity reasons, we chose to use a Sphere prim type.

What’s interesting to see at this point is that if we run the simulation continuously and we mute the TranslateLayer (which is higher in the layers hierarchy under the root layer), the muted opinions disappear and USD recomposes the scene graph by updating all of the overriden properties: now the ScaleLayer has the winning opinion for the translate and, most importantly, radius property!

At this point you should have developed a pretty good sense of what can be accomplished with USD: non-destructive workflows, multiple scenes editing and a strongly typed and extensible system which enables applications to leverage graphical capabilities that weren’t possible before.

Composition Arcs

We already saw in the previous chapter how multiple sublayers each one with different opinions are composed together when referenced by another layer to compose a final stage (Sublayers: authoring time-sampled attributes): that is just one of the many ways USD places at your disposal to compose a scene graph and resolve which attribute value should be used amongst many.

There are other ways and operators that can be used to determine how layer stacks and their opinions are combined together, here’s an intuitive overview:

  • Sublayers - we’ve seen these already in the previous section, similar to Photoshop layers. Each one can be backed by a USD file on disk and provide - according to their order in the layer stack - different opinions.
  • References - they allow to put entire layers under prims to maximize prims reuse
  • VariantSet - switchable states for a prim: think of multiple colors, materials and even different wheel rims for a car in a configurator scene
  • Payload - lazily loaded references: these can be loaded or unloaded at runtime on user request (think LoD - Level of Detail - a high resolution model can be avoided to be loaded entirely if it makes sense on an already-quite-heavy USD scene)
  • Inherits - similar to inheritance in object oriented programming: prims can inherit from other prims. Changes in the base prim reflect immediately on the prims that derive from it.
  • Specializes - e.g. a Metal material can have some properties specialized to create a RustyMetalMaterial. Very similar to RustyMetalMaterial inheriting from Metal, but specialized properties are always the winning ones even if in some layer/other_composition we override those Metal’s base properties with a stronger opinion: a bit similar to CSS’s !important a specialization of a property cannot be overridden. With inherit you’re able to override a property multiple times instead. That’s the main difference.

Even if Specializes wins over other overrides, this composition arc is the last one and weakest to be scanned. This is by design.

In this chapter we will take a deeper coding look at each of these USD composition techniques, but first an overview into their evaluation order.

LIVRPS

This acronym means Local(and Sublayers), Inherits, VariantSets, References, Payloads and Specializes. This is the order in which the USD engine evaluates opinions.

When evaluating the value of an attribute on a prim or a metadata value, we iterate over PrimSpecs (which can be thought as a partial view of a prim in a layer or rather: the part of a prim which resides in a specific layer and, together with all the other PrimSpecs for that prim in the other layers, contributes in composing the final prim on the stage with all the ‘winning’ opinions for all attributes and metadata) in the LIVRPS order.

Example

The attribute radius for a type Sphere sphere prim needs to be evaluated on the final composed stage.

  1. First we search for an attribute opinion in the Local/Sublayers stack (same rules as before: root layer wins, otherwise sublayers in the order in which they appear under the root layer, etc.). If we find a winning one, we abort the search and use it.
  2. No opinion was found? We continue by scanning the inherits and recursively start again from 1 with the new added layers. Specializations are ignored though.
  3. No opinion was found? We scan variants and recursively start from 1. Specializations are ignored though.
  4. No opinion was found? We scan References and recursively start from 1. Specializations are ignored though.
  5. No opinion was found? We even scan the optional Payloads and recursively start from 1. Specializations are ignored though.
  6. Still no opinion was found? We scan Specializes for an opinion.

If after point 6 still no opinion could be found, we just take the default value for that attribute/metadata.

References

In the same fashion as the sublayers example we explored before, let’s create a prim with a references metadata that references another layer in a different USD file and open it in OV Composer (this script can be run in the Script Editor in OV Composer directly)

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
cube_stage : Usd.Stage = Usd.Stage.CreateInMemory("CubeLayer.usda")

# Create a Cube "Cube" prim in the cube stage and a light
xform : UsdGeom.Xform = UsdGeom.Xform.Define(cube_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(cube_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
# This time put the light under the "/World" prim - by referencing "/World" we will also
# import the lights as well since they're under "/World"
environment_xform = UsdGeom.Xform.Define(cube_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(cube_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)
# Export to file
cube_stage.GetRootLayer().Export(BASE_DIRECTORY + "/CubeLayer.usda")

# A pseudo-root prim (usually the '/' prim) exists solely to namespace all prims' paths under
# this prefix (e.g. "/World"). A UsdStage always has a pseudo-root prim (unless there was an
# error opening it)
root_prim : Usd.Prim = root_stage.GetPseudoRoot()
# Verbose to showcase API usage for "/RefPrim". Creates a generic UsdPrim (it will default
# to being an Xform)
ref_prim : Usd.Prim = root_stage.DefinePrim(str(root_prim.GetPath()) + "RefPrim")
# This would NOT work because as soon as this script finishes executing, the temporary stages
# would be destroyed (they're weakrefs). Let's open and reference the usd file for those
# layers instead
# nope: ref_prim.GetReferences().AddReference(cube_stage.GetRootLayer().identifier)
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/CubeLayer.usda")
ref_prim.GetReferences().AddReference(
    loaded_layer.identifier, # which in this case it's just the relative file path string
    "/World") # The prim which needs to be mapped at the ref_prim also needs to be specified

# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

Here’s the result in OV Composer: a prim is added to the root prim that references the outside layer (OV Composer adds a small orange arrow to the icon of the xform to indicate that the prim has a reference metadata and that references something during the stage composition)

If we inspect the generated USD files we can see the reference metadata

$ cat /tmp/CubeLayer.usda
#usda 1.0

def Xform "World"
{
    def Cube "Cube"
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        double size = 100
    }

    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }
}

$ cat /tmp/RootLayer.usda
#usda 1.0

def "RefPrim" (
    prepend references = @/tmp/CubeLayer.usda@</World>   # Here it is
)
{
}

Note what happens if we save the USD file with OV Composer as flattened:

$ cat /tmp/Flattened.usda
#usda 1.0
(
    doc = """Generated from Composed Stage of root layer /tmp/RootLayer.usda
"""
)

def Xform "RefPrim"
{
    def Cube "Cube"
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        double size = 100
    }

    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }
}

i.e. only the composed elements are retained and the composition arcs are resolved into a single, flattened, hierarchy. This is very useful when dealing with e.g. huge Nucleus USD files which reference multiple-networks-scattered USD files and you want to save a copy (albeit probably huge) of the USD scene for debugging purposes on your local workstation: you flatten it out and save it to disk.

Note that overriding attributes works exactly as before: even if the cube had an opinion in the original CubeLayer

# Give it a red color (quick way to apply a 'debug' color without shaders or materials - accesses primvar variables)
cube.GetPrim().CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray).Set([(1.0, 0.0, 0.0)])
# Export to file
cube_stage.GetRootLayer().Export(BASE_DIRECTORY + "/CubeLayer.usda")

we could have overridden that attribute with an override (that would appear as a delta on the referenced layer) in the root layer

# Give it a blue color override in the root layer
override_cube_prim : Usd.Prim = root_stage.OverridePrim("/RefPrim/Cube")
override_cube_attr = override_cube_prim.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray)
override_cube_attr.Set([(0.0, 0.0, 1.0)])
# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

Info

In the code above we apply a displayColor in the original cube stage by accessing that attribute via the UsdGeomPrimvar schema. This is a schema that allows to access and modify attributes which are specific to geometry prims (i.e. prims like cameras and such do not implement such schema), stuff like visibility or interpolation are under control of this schema. It’s a quick way to visualize a simple shaded color for a geometric prim without setting a material.

The final color of the cube would have been blue: root layer wins in this case. Had the blue opinion come from a variant (lower in the LIVRPS ordering), the reference opinion would have won.

VariantSet

Now let’s see an example of VariantSet attribute override in action together with a reference attribute override and let’s remember the LIVRPS order of opinions evaluation:

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
cube_stage : Usd.Stage = Usd.Stage.CreateInMemory("CubeLayer.usda")

# Create a Cube "Cube" prim in the cube stage and a light

xform : UsdGeom.Xform = UsdGeom.Xform.Define(cube_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(cube_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
environment_xform = UsdGeom.Xform.Define(cube_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(cube_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)
# In the cube layer (the one where the cube is defined and that will be referenced by RootLayer),
# the cube has originally a red color
cube.GetPrim().CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray).Set([(1.0, 0.0, 0.0)])
# Export to file
cube_stage.GetRootLayer().Export(BASE_DIRECTORY + "/CubeLayer.usda")

# Now as before set up a "/ReferencedCube" prim which references the "/World/Cube" in the CubeLayer

ref_prim : Usd.Prim = root_stage.DefinePrim("/ReferencedCube")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/CubeLayer.usda")
ref_prim.GetReferences().AddReference(
    loaded_layer.identifier, # which in this case it's just the relative file path string
    "/World/Cube") # The prim which needs to be mapped at the ref_prim also needs to be specified

# At this point if graph composition were to end, the cube would just have an opinion in the referenced layer
# and would be a red colored one.

# This is where things get interesting: set up a variant set with sub-variants in the root layer
# (a VariantSet is like a new combo box to switch something in the prim, e.g. "colors for the car" or
# "level of damage of the car" or "types of wheel rims")
variant_set = ref_prim.GetVariantSets().AddVariantSet("differentColorsVariantSet")
# A variant is owned by a variant set, think of these as the different items that you can choose from the
# combo box (which is the VariantSet)
variant_set.AddVariant("blueColor")
variant_set.AddVariant("greenColor")

# Set the active variant
variant_set.SetVariantSelection("blueColor")
with variant_set.GetVariantEditContext():  # Set up an edit context (this is just like changing the authoring layer)
    # Give the cube in the root layer a blue color
    override_cube_prim : Usd.Prim = root_stage.OverridePrim("/ReferencedCube")
    override_cube_attr = override_cube_prim.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray)
    override_cube_attr.Set([(0.0, 0.0, 1.0)])

variant_set.SetVariantSelection("greenColor")
with variant_set.GetVariantEditContext():
    # Give the cube in the root layer a green color
    override_cube_prim : Usd.Prim = root_stage.OverridePrim("/ReferencedCube")
    override_cube_attr = override_cube_prim.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray)
    override_cube_attr.Set([(0.0, 1.0, 0.0)])

# Select the active variant after editing them
variant_set.SetVariantSelection("blueColor")

# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

If you execute this code, you should get a reference prim but with a blue color: even though the red color is authored and there’s an opinion in the referenced layer, the Variant opinion is stronger in the LIVRPS ordering and therefore wins (note that OV Composer has a very handy combo box in the Properties pane that shows the VariantSet along with its current active Variant)

USDA code is also provided for completeness

$ cat /tmp/RootLayer.usda
#usda 1.0

def "ReferencedCube" (
    prepend references = @/tmp/CubeLayer.usda@</World/Cube>
    variants = { # Active variants for multiple VariantSets are stored in the 'variants' metadata
        string differentColorsVariantSet = "blueColor"
    }
    prepend variantSets = "differentColorsVariantSet" # the additional VariantSets
)
{
    variantSet "differentColorsVariantSet" = { # Definition for the VariantSet which will override displayColor
        "blueColor" {
            color3f[] primvars:displayColor = [(0, 0, 1)]
        }
        "greenColor" {
            color3f[] primvars:displayColor = [(0, 1, 0)]
        }
    }
}

$ cat /tmp/CubeLayer.usda
#usda 1.0

def Xform "World"
{
    def Cube "Cube"
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        color3f[] primvars:displayColor = [(1, 0, 0)] # This opinion will be weaker due to LIVRPS composition
        double size = 100
    }

    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }
}

Payload

Let’s explore an example for payloads: reference-like arcs which can be loaded on demand and meant to be used with optional or resources-heavy assets.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

# Create the usual cube stage, let's pretend this is a VERY HEAVY USD stage
# full of high-poly high-textured huge assets

cube_stage : Usd.Stage = Usd.Stage.CreateInMemory("CubeLayer.usda")
xform : UsdGeom.Xform = UsdGeom.Xform.Define(cube_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(cube_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
# This time put the light under the "/World" prim - by referencing "/World" we will also
# import the lights as well since they're under "/World"
environment_xform = UsdGeom.Xform.Define(cube_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(cube_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)
# Do not define a defaultPrim - read more about this later
# cube_stage.SetDefaultPrim(xform.GetPrim())
# Export to file
cube_stage.GetRootLayer().Export(BASE_DIRECTORY + "/CubeLayer.usda")

# Create the root stage with a payload prim referencing the "heavy" cube stage

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
payload_prim : Usd.Prim = root_stage.DefinePrim("/PayloadPrim")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/CubeLayer.usda")
# Note that if we don't specify the target prim for the payload (i.e. "/World"), it will
# try to look for the 'defaultPrim' metadata in the loaded_layer (or fail to set a target if
# that metadata isn't even present). Layer-level metadata are just like prim metadata and are
# set at the beginning of a USD file between parentheses, e.g.
#
# $ cat ./usd_file.usda
# #usda 1.0
# (
#   defaultPrim = "/World"
# )
# ..
payload_prim.GetPayloads().AddPayload(loaded_layer.identifier, "/World")

# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
# Note the 'LOAD_NONE' which instructs Kit NOT to open Payloads.
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda", load_set=omni.usd.UsdContextInitialLoadSet.LOAD_NONE)

Pay attention to the named parameter load_set=omni.usd.UsdContextInitialLoadSet.LOAD_NONE that we added to the kit open_stage invocation: that instructs Kit to NOT load stage payloads during opening (otherwise it would have been loaded and resolved automatically).

If we run the code above in the Script Editor we’ll see a blank stage with a payload prim. Loading the payload can happen either via code

root_stage.Load("/PayloadPrim")
root_stage.Unload("/PayloadPrim")

or directly in the OV Composer UI through a checkbox which will triger the payload prim loading

Inherits

Let’s take a look at an example where a prim with a class specifier (you can find this in the .usda text file in the same prim definition lines where you used to read def or over: the class specifier indicates that a prim is to be considered as a base class for other prims) is inherited by other prims. A prim can inherit from multiple other prims at the same time.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

# Add a cube and a sphere to the scene

xform : UsdGeom.Xform = UsdGeom.Xform.Define(root_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
sphere : UsdGeom.Sphere = UsdGeom.Sphere.Define(root_stage, "/World/Sphere")
sphere.GetExtentAttr().Set(extent)
# Just to beautify the scene: move the sphere a bit and scale it
UsdGeom.Xformable(sphere.GetPrim()).AddTranslateOp().Set(Gf.Vec3d(100, 0, 0))
UsdGeom.Xformable(sphere.GetPrim()).AddScaleOp().Set(Gf.Vec3d(50, 50, 50))
environment_xform = UsdGeom.Xform.Define(root_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(root_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# The base class for all red prims: adds a red color `primvars:displayColor` attribute
red_prims : Usd.Prim = root_stage.CreateClassPrim("/_red_prims")
red_prims.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray).Set([(1.0, 0.0, 0.0)])

# Make both the sphere and the cube inherit from the _red_prims class prim
inherits: Usd.Inherits = cube.GetPrim().GetInherits()
inherits.AddInherit(red_prims.GetPath())

inherits: Usd.Inherits = sphere.GetPrim().GetInherits()
inherits.AddInherit(red_prims.GetPath())

# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

This will make both the cube and the sphere prims inherit from the _red_prims class prim, causing them both to inherit the opinion for the color attribute

This is the generated USDA for the stage, note the inherits metadata for the derived prims and the class specifier for the _red_prims

$ cat /tmp/RootLayer.usda
#usda 1.0

def Xform "World"
{
    def Cube "Cube" (
        prepend inherits = </_red_prims>
    )
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        double size = 100
    }

    def Sphere "Sphere" (
        prepend inherits = </_red_prims>
    )
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        float3 xformOp:scale = (50, 50, 50)
        double3 xformOp:translate = (100, 0, 0)
        uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
    }

    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }
}

class "_red_prims"
{
    custom color3f[] primvars:displayColor = [(1, 0, 0)]
}

Specializes

Understanding specializes requires a bit more work to build intuition. We’ve already said that it behaves similarly to inherits and that it has the weakest opinion strength in the LIVRPS ordering, but that it can prevent override of its attributes if the attributes in a base class are overridden.

To demonstrate this, let’s take a look at a more complex example where RootLayer.usda defines a /ReferencedWorld prim which references entirely another USD file CubeAndSphereLayer.usda. Inside CubeAndSphereLayer.usda there are a cube and a sphere and they inherit from two different prims: _base_prims which gives all prims a base red color and _rusty_prims which give all prims a rust-like color (light brown-orange-ish). _rusty_prims is not a derived class of _base_prims but rather a specializes of it: this means that if the _base_prims class changes (e.g. by switching to a green color instead of a red color), the properties authored in _rusty_prims will override those in _base_prims (but any non-authored property will still be inherited). And that’s exactly what happens in RootLayer.usda: the _base_prims class prim is overridden and the color set to green. This will cause the cube, which inherits from _base_prims, to have the opinion overridden and change color to green. The sphere instead, which inherited from _rusty_prims, will not care for the green color and remain rusty.

After executing the code below, inspecting the USDA files will probably be a lot more intuitive in understanding what’s happening

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

# Add a cube and a sphere to a CubeAndSphereLayer stage

cube_and_sphere_stage : Usd.Stage = Usd.Stage.CreateInMemory("CubeAndSphereLayer.usda")

xform : UsdGeom.Xform = UsdGeom.Xform.Define(cube_and_sphere_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(cube_and_sphere_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
sphere : UsdGeom.Sphere = UsdGeom.Sphere.Define(cube_and_sphere_stage, "/World/Sphere")
sphere.GetExtentAttr().Set(extent)
UsdGeom.Xformable(sphere.GetPrim()).AddTranslateOp().Set(Gf.Vec3d(100, 0, 0))
UsdGeom.Xformable(sphere.GetPrim()).AddScaleOp().Set(Gf.Vec3d(50, 50, 50))
environment_xform = UsdGeom.Xform.Define(cube_and_sphere_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(cube_and_sphere_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# The base class for all prims: adds a red color `primvars:displayColor` attribute
base_prims : Usd.Prim = cube_and_sphere_stage.CreateClassPrim("/World/_base_prims")
base_prims.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray).Set([(1.0, 0.0, 0.0)])

# Make the cube inherit from the _base_prims class prim (so it gets a red color)
inherits: Usd.Inherits = cube.GetPrim().GetInherits()
inherits.AddInherit(base_prims.GetPath())

# Define a 'rusty_prims` prim which has an opinion for the color of rusty metal and SPECIALIZES (*not* inherits)
# the _base_prims prim
rusty_prims : Usd.Prim = cube_and_sphere_stage.DefinePrim("/World/_rusty_prims")
rusty_prims.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray).Set([(0.718, 0.255, 0.055)])
specializes: Usd.Specializes = rusty_prims.GetSpecializes()
specializes.AddSpecialize(base_prims.GetPath())

# Make the sphere inherit from the _rusty_prims specialization prim
inherits: Usd.Inherits = sphere.GetPrim().GetInherits()
inherits.AddInherit(rusty_prims.GetPath())

# Export stage to file
cube_and_sphere_stage.GetRootLayer().Export(BASE_DIRECTORY + "/CubeAndSphereLayer.usda")


# Now create the RootLayer stage where the final scene will be composed: this will reference the entire CubeAndSphereLayer
# with ONE difference: it will have an override for the color of _base_prims-inheriting prims. This will cause all prims
# to get a green color EXCEPT for those inheriting from the rusty specialization!

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

ref_prim : Usd.Prim = root_stage.DefinePrim("/ReferencedWorld")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/CubeAndSphereLayer.usda")
ref_prim.GetReferences().AddReference(
    loaded_layer.identifier, # which in this case it's just the relative file path string
    "/World") # The prim which needs to be mapped at the ref_prim also needs to be specified

_base_prim_override = root_stage.OverridePrim("/ReferencedWorld/_base_prims")
_base_prim_override.CreateAttribute("primvars:displayColor", Sdf.ValueTypeNames.Color3fArray).Set([(0.0, 1.0, 0.0)])

root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

And here’s the generated .usda files:

$ cat /tmp/CubeAndSphereLayer.usda
#usda 1.0

def Xform "World"
{
    def Cube "Cube" (
        prepend inherits = </World/_base_prims>
    )
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        double size = 100
    }

    def Sphere "Sphere" (
        prepend inherits = </World/_rusty_prims>
    )
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        float3 xformOp:scale = (50, 50, 50)
        double3 xformOp:translate = (100, 0, 0)
        uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
    }

    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }

    class "_base_prims"
    {
        custom color3f[] primvars:displayColor = [(1, 0, 0)]
        # Added to showcases that specializes inherits whatever it does *not* override
        int someCustomProperty = 42;
    }

    def "_rusty_prims" (
        prepend specializes = </World/_base_prims>
    )
    {
        custom color3f[] primvars:displayColor = [(0.718, 0.255, 0.055)]
        # Note that this inherits someCustomProperty, and if that changes in _base_prims,
        # so it will here as well since we do _not_ have an overriding opinion on it.
    }
}

$ cat /tmp/RootLayer.usda
#usda 1.0

def "ReferencedWorld" (
    prepend references = @/tmp/CubeAndSphereLayer.usda@</World>
)
{
    over "_base_prims"
    {
        custom color3f[] primvars:displayColor = [(0, 1, 0)]
    }
}

Attributes

As we’ve already stated there are two kind of properties: attributes and relationships.

Let’s get back to /World/Cube:size and similar attributes that we’ve quickly covered in the previous sections and add a few more words on how to access them via code.

Attributes can be provided via a schema or be user-defined (to be precise even relationships can be schema-provided or user-defined, therefore it is more correct to state that properties can either be provided via a schema or be user-defined).

Tip

We will revisit schemas later, for now it suffices to think of them as “inherited prim rules to define which properties are present on a prim which implements that schema, and what values are allowed for those properties” E.g. a faceVertexIndices attribute for a prim which implements the UsdGeomMesh schema must have type VtArray<int>, i.e. be an array of integer values. This type constraint (and the existence of that value which must be present) is enforced by the UsdGeomMesh schema definition.

In the UsdGeomCube schema, the attribute size is schema-provided and can be accessed in code as follows

cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
attr = cube.GetSizeAttr()
attr_value = attr.Get()
attr.Set(200)  # change attribute value

in python USD there are always getters for attributes defined in schemas of the form .Get[ATTRIBUTENAME]Attr() and they return a UsdAttribute whose value can be extracted via Get() and set via Set() as shown in the code above. For time-varying attributes you can also query which timecode you’re interested in: .Get(Usd.TimeCode.EarliestTime()). Whether an attribute is time-varying can be queried via GetVariability().

Let’s see an example of a cube prim where a custom attribute is checked for existence in various ways and created/set.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

# Add a cube to the scene

xform : UsdGeom.Xform = UsdGeom.Xform.Define(root_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
environment_xform = UsdGeom.Xform.Define(root_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(root_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

cube_prim : Usd.Prim = cube.GetPrim()

# Check if a custom myAttribute attribute already exists
my_custom_attr : Usd.Attribute = cube_prim.GetAttribute("myCustomFloatAttribute")
if my_custom_attr.IsValid():
    carb.log_warn("Attribute exists!")
    # my_custom_attr.Set(42.8)
else:
    carb.log_warn("Attribute does not exist yet")

attr = cube_prim.CreateAttribute("myCustomFloatAttribute", Sdf.ValueTypeNames.Float)
attr.Set(42.8)

my_custom_attr : Usd.Attribute = cube_prim.GetAttribute("myCustomFloatAttribute")
if my_custom_attr:  # use the boolean override to check for existence as well
    carb.log_warn("Attribute exists!")
else:
    carb.log_warn("Attribute does not exist yet")

# Access a non-custom attribute defined in the UsdGeomCube schema
cube = UsdGeom.Cube(cube_prim)
size_value = cube.GetSizeAttr().Get()
carb.log_warn(f"cube has size attr of value: {size_value}")

# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

As already noted custom attributes are not part of a schema and marked as custom in USDA:

$ cat /tmp/RootLayer.usda
#usda 1.0

def Xform "World"
{
    def Cube "Cube"
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        custom float myCustomFloatAttribute = 42.8  # <---------
        double size = 100
    }

    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }
}

Whether an attribute is custom can be queried via IsCustom().

Primspecs

A Primspec is the part of a prim which resides in a layer only: if a layer only has an override for a prim’s attribute, the Primspec will just be a view over that over specifier with an AttributeSpec inside that redefines that attribute. Think of a Primspec as a git delta for a specific prim, on a specific layer.

Enumerating all the attributes of a prim by querying the UsdPrim on the composed (i.e. where all of the hard work of resolving opinions, etc. has already been done) stage is performed via

cube_prim = cube.GetPrim()
for attr in cube_prim.GetAttributes():
    print(attr)

and yields all of the attributes applied by schemas and the primvars attributes (used by the Hydra renderer usually):

Usd.Prim(</World/Cube>).GetAttribute('doubleSided')
Usd.Prim(</World/Cube>).GetAttribute('extent')
Usd.Prim(</World/Cube>).GetAttribute('orientation')
Usd.Prim(</World/Cube>).GetAttribute('primvars:displayColor')
Usd.Prim(</World/Cube>).GetAttribute('primvars:displayOpacity')
Usd.Prim(</World/Cube>).GetAttribute('purpose')
Usd.Prim(</World/Cube>).GetAttribute('size')
Usd.Prim(</World/Cube>).GetAttribute('testValue')
Usd.Prim(</World/Cube>).GetAttribute('visibility')
Usd.Prim(</World/Cube>).GetAttribute('xformOpOrder')

But if we enumerate the attributes in a Sdf.PrimSpec, we will find only the attributes that are effectively defined or authored in that specific layer: in the case of just an override for a radius attribute for a sphere in a another_layer, here’s what we would get:

$ cat /tmp/another_layer.usda
#usda 1.0
()

over "World"
{
    over "Sphere"
    {
        double radius = 200
    }
}
another_layer = Sdf.Layer.FindOrOpen("/tmp/another_layer.usda")
primSpec : Sdf.PrimSpec = another_layer.GetPrimAtPath("/World/Sphere")
for attr in primSpec.attributes:
    print(attr.GetAsText())  # prints "double radius = 200"

PrimSpecs allow us to enumerate the contributions of a layer without composing the stage first (which can be quite expensive due to the LIVRS and opinion resolution); they also allow us to create definitions, overrides, etc. in a single layer.

Here’s a rather involved example which defines a RootLayer which references AnotherLayer as a sublayer. RootLayer overrides the Sphere defined in the other layer and overrides the radius attribute. The sphere has a CollisionAPI applied, just to showcase how PrimSpecs can enumerate those as well. Finally also a new Cube prim is defined in the RootLayer through a newly created PrimSpec.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, UsdPhysics
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
another_stage : Usd.Stage = Usd.Stage.CreateInMemory("AnotherLayer.usda")

# Create a Xform "World" and a Sphere "Sphere" prim in the "another stage"

xform : UsdGeom.Xform = UsdGeom.Xform.Define(another_stage, Sdf.Path("/World"))
sphere : UsdGeom.Sphere = UsdGeom.Sphere.Define(another_stage, "/World/Sphere")
extent = [(-30, -30, -30), (30, 30, 30)]
sphere.GetExtentAttr().Set(extent)
radius = sphere.GetRadiusAttr()
radius.Set(50)
environment_xform = UsdGeom.Xform.Define(another_stage, "/Environment")
dome_light = UsdLux.DomeLight.Define(another_stage, "/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# Apply a physics CollisionAPI (defined in a schema) to the Sphere in the "another stage"
UsdPhysics.CollisionAPI.Apply(sphere.GetPrim())

# Set "another stage" as a sublayer in the "root layer"
another_stage.GetRootLayer().Export(BASE_DIRECTORY + "/AnotherLayer.usda")
root_stage.GetRootLayer().subLayerPaths.append(BASE_DIRECTORY + "/AnotherLayer.usda")

# Also add an override in the "root layer" for the radius defined in "another layer"
sphere_override_prim = root_stage.OverridePrim("/World/Sphere")
sphere_override : UsdGeom.Sphere = UsdGeom.Sphere(sphere_override_prim)
radius = sphere_override.CreateRadiusAttr()
radius.Set(200)

# Now for the interesting part: enumerate the primspecs in the root layer

ref_layer = root_stage.GetRootLayer()
primSpec : Sdf.PrimSpec = ref_layer.GetPrimAtPath("/World/Sphere")
print(ref_layer.GetObjectAtPath("/World/Sphere"))
for attr in primSpec.attributes:
    # attr is Sdf.AttributeSpec
    # Usd.Attribute and customAttributes have a GetTypeName() function,
    # while Sdf.AttributeSpec has a typeName field
    # print(attr.typeName)
    print(attr.GetAsText())  # pretty-print whatever this attributespec contains


# Create another primspec in this very same root layer - this defines a cube
cube = Sdf.PrimSpec(ref_layer.GetPrimAtPath("/World"), "Cube", # this is the name
    Sdf.SpecifierDef,
    "Cube") # this is the type
size = Sdf.AttributeSpec(cube, "size", Sdf.ValueTypeNames.Double)
size.default = 250.0

# Enumerate the primspecs for sphere in the "another layer" and get the 'apiSchemas'
ref_layer = another_stage.GetRootLayer()
prim_spec: Sdf.PrimSpec = ref_layer.GetObjectAtPath("/World/Sphere")
api_schemas_metadata : Sdf.ListOpType = prim_spec.GetInfo("apiSchemas")
for api in api_schemas_metadata.prependedItems:
    print(api)  # this should print the 'CollisionAPI' schema

# Export root layer to file and open it
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

USDA is provided for completeness

$ cat /tmp/RootLayer.usda
#usda 1.0
(
    subLayers = [
        @/tmp/AnotherLayer.usda@
    ]
)

over "World"
{
    over "Sphere"
    {
        double radius = 200
    }

    def Cube "Cube"
    {
        double size = 250
    }
}

$ cat /tmp/AnotherLayer.usda
#usda 1.0

def Xform "World"
{
    def Sphere "Sphere" (
        prepend apiSchemas = ["PhysicsCollisionAPI"]
    )
    {
        float3[] extent = [(-30, -30, -30), (30, 30, 30)]
        double radius = 50
    }
}

def Xform "Environment"
{
    def DomeLight "DomeLight"
    {
        float inputs:intensity = 1000
    }
}

Primspecs are an advanced USD concept but they allow for quite powerful manipulations of the scene graphs.

Traversing

Traversing means ‘scanning’ all (or a subset of) the prims on a stage.

The simplest form of traversal is:

def traverse_and_print_prim_paths(stage):
    for prim in stage.Traverse():
        print(prim.GetPath())

traverse_and_print_prim_paths(stage)

You can test this out by generating a random scene hierarchy of prims (this is mostly boilerplate code)

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb
import random
import string

# Function to generate a random identifier and make the sibling prim names unique
def generate_random_identifier(length=5):
    return ''.join(random.choice(string.ascii_letters) for _ in range(length))

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

xform : UsdGeom.Xform = UsdGeom.Xform.Define(stage, Sdf.Path("/World"))
environment_xform = UsdGeom.Xform.Define(stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)


# Function to generate a random scene hierarchy
def generate_random_hierarchy(parent_xform, depth):
    if depth <= 0:
        return

    for _ in range(random.randint(1, 4)):  # Randomly choose 1 to 4 children
        child_name = "Child_" + generate_random_identifier()  # Add a random identifier
        child_xform = UsdGeom.Xform.Define(stage, parent_xform.GetPath().AppendChild(child_name))
        if random.choice([True, False]):  # Randomly choose to add cube or sphere
            shape_name = "Cube_" + generate_random_identifier()  # Add a random identifier
            cube = UsdGeom.Cube.Define(stage, child_xform.GetPath().AppendChild(shape_name))
            extent = [(random.uniform(-50, 50), random.uniform(-50, 50), random.uniform(-50, 50)),
                      (random.uniform(-50, 50), random.uniform(-50, 50), random.uniform(-50, 50))]
            cube.GetExtentAttr().Set(extent)
            cube.GetSizeAttr().Set(random.uniform(10, 100))

            # Add random small offsets from the center
            offset = Gf.Vec3d(random.uniform(-100, 100), random.uniform(-100, 100), random.uniform(-100, 100))
            cube_center = Gf.Vec3d(0, 0, 0)  # Center of the scene
            cube_center += offset
            UsdGeom.Xformable(cube.GetPrim()).AddTranslateOp().Set(cube_center)

        else:
            shape_name = "Sphere_" + generate_random_identifier()  # Add a random identifier
            sphere = UsdGeom.Sphere.Define(stage, child_xform.GetPath().AppendChild(shape_name))
            extent = [(random.uniform(-50, 50), random.uniform(-50, 50), random.uniform(-50, 50)),
                      (random.uniform(-50, 50), random.uniform(-50, 50), random.uniform(-50, 50))]
            sphere.GetExtentAttr().Set(extent)

            # Add random small offsets from the center
            offset = Gf.Vec3d(random.uniform(-100, 100), random.uniform(-100, 100), random.uniform(-100, 100))
            sphere_center = Gf.Vec3d(0, 0, 0)  # Center of the scene
            sphere_center += offset
            UsdGeom.Xformable(sphere.GetPrim()).AddTranslateOp().Set(sphere_center)

            UsdGeom.Xformable(sphere.GetPrim()).AddScaleOp().Set(Gf.Vec3d(random.uniform(10, 100),
                                                                          random.uniform(10, 100),
                                                                          random.uniform(10, 100)))

        generate_random_hierarchy(child_xform, depth - 1)

# Generate the random hierarchy with a maximum depth of 5-6
generate_random_hierarchy(xform, random.randint(5, 6))


# Traversal!
####################################
def traverse_and_print_prim_paths(stage):
    for prim in stage.Traverse():
        print(prim.GetPath())

traverse_and_print_prim_paths(stage)
####################################


stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

Other common ways of scanning prims is by searching for specific types of prims (e.g. of Sphere type)

from typing import List, Type

def find_prims_by_type(stage: Usd.Stage, prim_type: Type[Usd.Typed]) -> List[Usd.Prim]:
    found_prims = [x for x in stage.Traverse() if x.IsA(prim_type)]
    return found_prims

prims: List[Usd.Prim] = find_prims_by_type(stage, UsdGeom.Sphere)
print(prims)

There are also UsdPrim::GetChild functions and UsdPrim::GetChildren() and UsdPrim::GetAllChildren() (the former returns the active, loaded, defined and non-abstract children of a prim, the latter returns any child).

Instance traversals

Recall that normal traversals don’t work with instances, in case you need to traverse instance proxies, you should use Usd.TraverseInstanceProxies

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

parking_lot_stage : Usd.Stage = Usd.Stage.CreateInMemory("ParkingLot.usda")
car_stage : Usd.Stage = Usd.Stage.CreateInMemory("Car.usda")

# Car stage
xform : Usd.Prim = car_stage.DefinePrim(Sdf.Path("/Car"))
xform.GetPrim().CreateAttribute("color", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(0, 0, 0))
body : UsdGeom.Mesh = UsdGeom.Mesh.Define(car_stage, Sdf.Path("/Car/Body"))
body.GetPrim().CreateAttribute("color", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(0, 0, 0))
Door : UsdGeom.Mesh = UsdGeom.Mesh.Define(car_stage, Sdf.Path("/Car/Door"))
car_stage.GetRootLayer().Export(BASE_DIRECTORY + "/Car.usda")

# Parking Lot stage
xform : Usd.Prim = parking_lot_stage.DefinePrim(Sdf.Path("/ParkingLot"))
car1_prim : Usd.Prim = parking_lot_stage.DefinePrim("/ParkingLot/Car_1")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/Car.usda")
car1_prim.GetReferences().AddReference(loaded_layer.identifier, "/Car")
car2_prim : Usd.Prim = parking_lot_stage.DefinePrim("/ParkingLot/Car_2")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/Car.usda")
car2_prim.GetReferences().AddReference(loaded_layer.identifier, "/Car")
car3_prim : Usd.Prim = parking_lot_stage.DefinePrim("/ParkingLot/Car_3")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/Car.usda")
car3_prim.GetReferences().AddReference(loaded_layer.identifier, "/Car")

# Mark with metadata Car_1 and Car_2 as instanceable, i.e. "these reference prims can be reused"
# while Car_3 is not marked
car1_prim.SetInstanceable(True)
car2_prim.SetInstanceable(True)


# Traversal!
####################################

# This will only return non-instance prims
# def traverse_and_print_prim_paths(stage):
#     for prim in stage.Traverse():
#         print(prim.GetPath())
# traverse_and_print_prim_paths(parking_lot_stage)

# This will allow you to traverse instanced prims thanks to instance proxies!
def traverse_and_print_all_prim_paths(stage):
    for prim in stage.Traverse(Usd.TraverseInstanceProxies()):
        print(prim.GetPath())
traverse_and_print_all_prim_paths(parking_lot_stage)

####################################

# Export root layer to file
parking_lot_stage.GetRootLayer().Export(BASE_DIRECTORY + "/ParkingLot.usda")
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/ParkingLot.usda")

Kind

The kind metadata can be authored via UsdModelAPI and it is often used to tag a prim:

def Xform "CameraMesh" (
    hide_in_stage_window = true # some metadata
    kind = "subcomponent" # kind metadata
    no_delete = true # some more metadata
)
{
    # usual rest of prim properties
    bool primvars:doNotCastShadows = 1
    bool primvars:omni:kit:isGizmo = 1
    ...
}

and is mainly used for two things:

  1. During traversals you might want to detect where a complete 3D model hierarchy starts (i.e. the topmost parent of the self-contained 3D asset model), or where a custom-tagged sub-hierarchy of prims starts

    from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, Kind
    import omni.usd
    import carb
    
    # Function to generate a random identifier and make the sibling prim names unique
    def generate_random_identifier(length=5):
        return ''.join(random.choice(string.ascii_letters) for _ in range(length))
    
    BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved
    
    stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
    
    xform : UsdGeom.Xform = UsdGeom.Xform.Define(stage, Sdf.Path("/World"))
    environment_xform = UsdGeom.Xform.Define(stage, "/World/Environment")
    dome_light = UsdLux.DomeLight.Define(stage, "/World/Environment/DomeLight")
    dome_light.CreateIntensityAttr(1000)
    
    
    model_prim = stage.DefinePrim("/World/ThisIsAModel", "Xform")
    model_API = Usd.ModelAPI(model_prim)
    model_API.SetKind(Kind.Tokens.model)
    
    custom_prim = stage.DefinePrim("/World/ThisIsACustomKind", "Xform")
    model_API = Usd.ModelAPI(custom_prim)
    model_API.SetKind("custom")
    
    """
    prim /World/ThisIsAModel has kind: model
    prim /World/ThisIsACustomKind has kind: custom
    """
    for prim in stage.Traverse():
        kind = Usd.ModelAPI(prim).GetKind()
        if kind:
            print(f"prim {str(prim.GetPath())} has kind: {kind}")
    
    
    stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")
    omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")
    

    There are also UsdPrim::IsModel and UsdPrim::IsGroup predicates that can be used during stage traversals (since they’re very commonly used).

  2. DCCs (digital content creation programs) that use USD might have a custom UI selection mode to facilitate the user in selecting something in their viewport. OV Composer uses this to switch between a select_any_prim_under_the_cursor and a select_only_model_prims_under_the_cursor

    This is better documented in the selection modes documentation for OV Composer; the same applies for any other DCC which uses USD and the kind selection mode switch.

If you’re developing a USD Omniverse connector or working in USD within a DCC you can define your own tags and register them via the plugin system. The default ones in OV Composer have usually the following meaning

KindDescription
modelThis shouldn’t be used directly as it’s the base kind for all models
assemblyIn a USD file representing a scene this is usually the kind of the topmost prim. This kind should be used for published (i.e. shared with other artists) assets which aggregate other published components or assemblies together. Assemblies can sometimes override materials on individual components to better visually integrate them together.
groupA group of 3D models. These are usually the intermediate Xforms under an assembly. It shouldn’t be used on the root of published assets since they’re helper kinds.
componentThe most basic asset published in a USD pipeline: it can be at the root prim of an asset and should not reference external geometries or models. The main difference between an assembly and a component is that an assembly can be made of more components and reference outside stuff as well. A component is self-contained.
subcomponentA subcomponent is usually a Xformable prim within a component model intentionally made available to apply some transformation.

This is a ten-thousand feet overview but if you’re interested into learning more there are in-depth documents in the usd-wg repo on the subject.

Instancing

Instancing allows you to reuse parts of a USD hierarchy so that those parts get loaded into memory only once (reducing memory usage and increasing performances of your USD stage scene).

Tip

The official OpenUSD documentation has a pretty good chapter on instancing

Let us create an example similar to the one found in the official docs: a ParkingLot.usda file and a Car.usda file: the idea is to only load the (pretend expensive) assets of Car once and reuse them for multiple cars in the ParkingLot scene.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

parking_lot_stage : Usd.Stage = Usd.Stage.CreateInMemory("ParkingLot.usda")
car_stage : Usd.Stage = Usd.Stage.CreateInMemory("Car.usda")

# Car stage
xform : Usd.Prim = car_stage.DefinePrim(Sdf.Path("/Car"))
xform.GetPrim().CreateAttribute("color", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(0, 0, 0))
body : UsdGeom.Mesh = UsdGeom.Mesh.Define(car_stage, Sdf.Path("/Car/Body"))
body.GetPrim().CreateAttribute("color", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(0, 0, 0))
Door : UsdGeom.Mesh = UsdGeom.Mesh.Define(car_stage, Sdf.Path("/Car/Door"))
car_stage.GetRootLayer().Export(BASE_DIRECTORY + "/Car.usda")

# Parking Lot stage
xform : Usd.Prim = parking_lot_stage.DefinePrim(Sdf.Path("/ParkingLot"))
car1_prim : Usd.Prim = parking_lot_stage.DefinePrim("/ParkingLot/Car_1")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/Car.usda")
car1_prim.GetReferences().AddReference(loaded_layer.identifier, "/Car")
car2_prim : Usd.Prim = parking_lot_stage.DefinePrim("/ParkingLot/Car_2")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/Car.usda")
car2_prim.GetReferences().AddReference(loaded_layer.identifier, "/Car")
car3_prim : Usd.Prim = parking_lot_stage.DefinePrim("/ParkingLot/Car_3")
loaded_layer = Sdf.Layer.FindOrOpen(BASE_DIRECTORY + "/Car.usda")
car3_prim.GetReferences().AddReference(loaded_layer.identifier, "/Car")

# Mark with metadata Car_1 and Car_2 as instanceable, i.e. "these reference prims can be reused"
# while Car_3 is not marked
car1_prim.SetInstanceable(True)
car2_prim.SetInstanceable(True)

# Export root layer to file
parking_lot_stage.GetRootLayer().Export(BASE_DIRECTORY + "/ParkingLot.usda")
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/ParkingLot.usda")
$ cat /tmp/Car.usda && cat /tmp/ParkingLot.usda
#usda 1.0

def "Car"
{
    custom color3f color = (0, 0, 0)

    def Mesh "Body"
    {
        custom color3f color = (0, 0, 0)
    }

    def Mesh "Door"
    {
    }
}

#usda 1.0

def "ParkingLot"
{
    def "Car_1" (
        instanceable = true
        prepend references = @/tmp/Car.usda@</Car>
    )
    {
    }

    def "Car_2" (
        instanceable = true
        prepend references = @/tmp/Car.usda@</Car>
    )
    {
    }

    def "Car_3" (
        prepend references = @/tmp/Car.usda@</Car>
    )
    {
    }
}

Let’s take a look at the stage in OV Composer

The icons in USD Composer help us identifying what a prim is made of: the orange/brown arrow on Car_1 indicates that the prim has a reference to another prim (just as we saw before). The new part is the I icon which is present for Car_1 and Car_2 but not Car_3: this is the Instanceable flag which is also exposed as a checkbox in the Property panel.

The user marks prims as instanceable which means “everything referenced and below this prim can be reused if there are multiple prims that are resolved to use that very same location”. The grayed out color means that no property can be changed for an instanceable prim (while prims under Car_3 are normal non-instance prims so they can have their properties edited as usual). If you want to change some properties of instance prims you’ll have to de-instanceable the parent prim (that will make sure that you have a USD prim you can write to, but it’ll cost more in terms of performances and memory, of course).

There are APIs to check if a prim is an instance or not (IsInstance) and even to query the Prototypes: a prototype prim is a special prim that gets created by the USD scene composition engine when there are instanceable prims in the stage. It is invisible to the normal OV Composer stage panel since it’s an internal USD detail and users aren’t supposed to dabble with them and it gets usually created in root-paths like /__Prototype_1 with internal names. They are treated specially because they represent, in this example, the /Car hierarchy which is read-only copied to all instanceable prims referencing it. Prototypes are considered ‘siblings’ to the pseudo-root and have no metadata nor properties.

One could use the code below to query prototypes and perform other manipulations on instanceable prims (you can paste this in the Script Editor once the scene above has been created and opened)

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

stage = omni.usd.get_context().get_stage()
car_1 = stage.GetPrimAtPath('/ParkingLot/Car_1')
print(car_1.IsInstance()) # True

# Consumers can query the instance's prototype for its child prims.
# this prints '[Usd.Prim(</__Prototype_1/Body>), Usd.Prim(</__Prototype_1/Door>)]'
print(car_1.GetPrototype().GetChildren())
# this prints '[Usd.Prim(</__Prototype_1>), Usd.Prim(</__Prototype_1/Body>), Usd.Prim(</__Prototype_1/Door>)]'
print(list(Usd.PrimRange(car_1.GetPrototype())))

Instance proxies

An instance proxy is a prim that doesn’t really exist in the scenegraph but is created when some APIs are used to allow users to traverse the hierarchy and reason on the scenegraph in an easier way rather than dealing with prototype prims.

A normal prims traversal for the entire stage would be

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

stage = omni.usd.get_context().get_stage()

for prim in stage.Traverse():
    print(prim)

and this would pick up Car_1 and Car_2 which are prims marked as instanceable, but nothing below those prims since it’s instanced from the invisible prototype prim (which is also not traversed with a normal traversal)

Usd.Prim(</ParkingLot>)
Usd.Prim(</ParkingLot/Car_1>) # nothing below this
Usd.Prim(</ParkingLot/Car_2>) # nothing below this
Usd.Prim(</ParkingLot/Car_3>) # this is not instanceable and everything below is NOT an instance
Usd.Prim(</ParkingLot/Car_3/Body>) # so they get printed
Usd.Prim(</ParkingLot/Car_3/Door>) # normally
# this is session layer stuff added by OV Composer
Usd.Prim(</OmniverseKit_Persp>)
Usd.Prim(</OmniverseKit_Front>)
Usd.Prim(</OmniverseKit_Top>)
Usd.Prim(</OmniverseKit_Right>)
Usd.Prim(</OmniKit_Viewport_LightRig>)
Usd.Prim(</OmniKit_Viewport_LightRig/Lights>)
Usd.Prim(</OmniKit_Viewport_LightRig/Lights/DomeLight>)
Usd.Prim(</OmniKit_Viewport_LightRig/Lights/DistantLight>)
Usd.Prim(</Render>)
Usd.Prim(</Render/RenderProduct_omni_kit_widget_viewport_ViewportTexture_0>)
Usd.Prim(</Render/Vars>)
Usd.Prim(</Render/Vars/LdrColor>)

to enable instance proxies traversal the following code can be used

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

stage = omni.usd.get_context().get_stage()

for prim in stage.Traverse(Usd.TraverseInstanceProxies(Usd.PrimIsActive and Usd.PrimIsDefined and Usd.PrimIsLoaded)):
    print(prim)
Usd.Prim(</ParkingLot>)
Usd.Prim(</ParkingLot/Car_1>)
Usd.Prim(</ParkingLot/Car_1/Body>) # now the instance proxies are visible
Usd.Prim(</ParkingLot/Car_1/Door>) # all of them
Usd.Prim(</ParkingLot/Car_2>)
Usd.Prim(</ParkingLot/Car_2/Body>) # ditto
Usd.Prim(</ParkingLot/Car_2/Door>) # ditto
Usd.Prim(</ParkingLot/Car_3>)
Usd.Prim(</ParkingLot/Car_3/Body>)
Usd.Prim(</ParkingLot/Car_3/Door>)
Usd.Prim(</OmniverseKit_Persp>)
Usd.Prim(</OmniverseKit_Front>)
...

There are similar APIs to query the children prims of a specific prim only and not traverse all of the stage in depth-first order and they similarly accept predicates that indicate whether they should iterate over instance proxies or not, e.g.

car_1.GetFilteredChildren(Usd.TraverseInstanceProxies())
[Usd.Prim(</ParkingLot/Car_1/Body>), Usd.Prim(</ParkingLot/Car_1/Door>)]

Point Instancers

There is also another kind of instancing in USD: PointInstancers. The UsdGeomPointInstancer class receives a variable number of geometries (classes derived by UsdGeom), creates a prototype prim (similarly to the instanceable method) and sets USD rel relationships (which are the other kind of properties, together with attributes) to point to those geometries. It also receives an attribute array of integers which are the indices (starting from 0) for which ones of those geometries should be rendered: this attribute also gives the order in which the relationships and another attribute that specifies different positions for each geometry, are to be combined.

Here is a simple explicative example from the developer-office-hours repository of USD code samples (the reader is highly encouraged to take a look at the listings contained therein as well):

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf
import omni.usd
import carb

stage = omni.usd.get_context().get_stage()

# Create a point instancer with three targets: a cube, a sphere and a cone.
# A 'UsdGeom.Scope' container is provided for clarity and better visual grouping in the hierarchy.

prim_path = Sdf.Path("/World/MyInstancer")
instancer: UsdGeom.PointInstancer = UsdGeom.PointInstancer.Define(stage, prim_path)
proto_container = UsdGeom.Scope.Define(stage, prim_path.AppendPath("Prototypes"))
shapes = []
shapes.append(UsdGeom.Cube.Define(stage, proto_container.GetPath().AppendPath("Cube")))
shapes.append(UsdGeom.Sphere.Define(stage, proto_container.GetPath().AppendPath("Sphere")))
shapes.append(UsdGeom.Cone.Define(stage, proto_container.GetPath().AppendPath("Cone")))
instancer.CreatePositionsAttr([Gf.Vec3f(0, 0, 0), Gf.Vec3f(2, 0, 0), Gf.Vec3f(4, 0, 0)])
instancer.CreatePrototypesRel().SetTargets([shape.GetPath() for shape in shapes])
instancer.CreateProtoIndicesAttr([0, 1, 2])
$ cat /tmp/PointInstancer.usda
#usda 1.0
()

def Xform "World"
{
    def PointInstancer "MyInstancer"
    {
        point3f[] positions = [(0, 0, 0), (2, 0, 0), (4, 0, 0)]
        int[] protoIndices = [0, 1, 2]
        rel prototypes = [
            </World/MyInstancer/Prototypes/Cube>,
            </World/MyInstancer/Prototypes/Sphere>,
            </World/MyInstancer/Prototypes/Cone>,
        ]

        def Scope "Prototypes"
        {
            def Cube "Cube"
            {
            }

            def Sphere "Sphere"
            {
            }

            def Cone "Cone"
            {
            }
        }
    }

    def DomeLight "DomeLight" ()
    {
        float inputs:intensity = 1000
    }
}

List Composition

In USD there is a concept called List Editing which can be seen in action when defining metadata (e.g. apiSchemas for a prim):

$ cat ./MyUSDLayer.usda
#usda 1.0
()
over "Kitchen_set"
{
    over "FlowerPotA_5" (
        prepend apiSchemas = ["PhysicsRigidBodyAPI"] # List-Editing in action
    )
    {}
}

We’ve already seen the prepend keyword but let’s take a deeper look at its meaning. A list can make use of append and/or prepend and/or delete and/or explicit actions when defining its items to change the order of the items (remember that in composition, just like in the layers stack, the order is important: in the layer stack, the higher the layer, the stronger the opinions - the root layer usually wins in fact). The same applies to these lists in USD: ["/cube", "/sphere", "/cone"] here in this list /cube has a stronger opinion than /cone. If you keep order in mind, the official documentation explanation makes sense:

  • append another value or values to the back of the resolved list; if the values already appear in the resolved list, they will be reshuffled to the back. An appended composition arc in a stronger layer of a LayerStack will therefore be weaker than all of the arcs of the same type appended from weaker layers, by default; however, the Usd API’s for adding composition arcs give you some flexibility here.

  • delete a value or values from the resolved list. A “delete” statement can be speculative, that is, it is not an error to attempt to delete a value that is not present in the resolved list.

  • prepend another value or values on the front of the resolved list; if the values already appear in the resolved list, they will be shuffled to the front. A prepended composition arc in a weaker layer of a LayerStack will still be stronger than any arcs of the same type that are appended from stronger layers.

  • reset to explicit , which is an “unqualified” operation, as in references = @myFile.usd@. This causes the resolved list to be reset to the provided value or values, ignoring all list ops from weaker layers.

To summarize in simpler words: if you have a stronger layer (think the root layer at the top of the layer stack) and it defines some list property in a append field, it means that the items must go to the end of the list and therefore the root layer wants that value to be weaker in the hierarchy (not deleted, but weaker than any other value of the same type). If the bottom layer (the weakest) adds an item to a list in the prepend field it means that weakest layer wants that item to be as strong as possible. But since it comes from the weakest layer, only if no other layer has any other opinion on it that is going to happen. If a higher layer (which is stronger) re-defines it in the same prepend, the game is already lost for the weakest layer: its item will not be at the forefront of the list because another layer added something else (unless it added the same exact value). delete does exactly what you think: it deletes an item from the list, and this is still subject to whatever precedence the layer has when applying it on a list. explicit causes the list to be reset to whatever values are explicitly provided in there - but still: this is only as good as the precedence of the currently applying layer.

Here’s a coding example:

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, UsdPhysics
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

# Create a temporary stage in memory for the root layer and another sublayer
top_layer : Usd.Stage = Usd.Stage.CreateInMemory("TopLayer.usda")
middle_layer : Usd.Stage = Usd.Stage.CreateInMemory("MiddleLayer.usda")
bottom_layer : Usd.Stage = Usd.Stage.CreateInMemory("BottomLayer.usda")

sphere : UsdGeom.Sphere = UsdGeom.Sphere.Define(top_layer, Sdf.Path("/World/Sphere"))
sphere_prim = sphere.GetPrim()
middle_override_prim = middle_layer.OverridePrim("/World/Sphere")
bottom_override_prim = bottom_layer.OverridePrim("/World/Sphere")

# Get a edit target for the top layer, get a prim spec for the /World/Sphere and
# *low-level* and *manually* add some items to the apiSchemas metadata
editTarget = top_layer.GetEditTarget()
primSpec = editTarget.GetPrimSpecForScenePath("/World/Sphere")
newListOp = Sdf.TokenListOp()
newListOp.deletedItems = ["PhysicsRigidBodyAPI"]
primSpec.SetInfo(Usd.Tokens.apiSchemas, newListOp)

# The above is conceptually equivalent to this (higher level) but only to _add_ an API:
#
# edit_target = top_layer.GetEditTargetForLocalLayer(top_layer.GetRootLayer())
# top_layer.SetEditTarget(edit_target)
# UsdPhysics.RigidBodyAPI.Apply(sphere_prim)
#
# but we get to modify deletedItems directly with the code above.

# Now add some prependedItems to the middle layer
editTarget = middle_layer.GetEditTarget()
primSpec = editTarget.GetPrimSpecForScenePath("/World/Sphere")
newListOp = Sdf.TokenListOp()
newListOp.prependedItems = ["PhysicsCollisionAPI", "PhysicsMassAPI"]
primSpec.SetInfo(Usd.Tokens.apiSchemas, newListOp)

# And finally add some appendedItems and a deletedItem to the bottom layer
editTarget = bottom_layer.GetEditTarget()
primSpec = editTarget.GetPrimSpecForScenePath("/World/Sphere")
newListOp = Sdf.TokenListOp()
newListOp.prependedItems = ["PhysicsRigidBodyAPI"]
newListOp.appendedItems = ["PhysicsMassAPI", "PhysicsArticulationRootAPI"]
primSpec.SetInfo(Usd.Tokens.apiSchemas, newListOp)

# Export all layers to file and set the sublayers relationships

top_layer.GetRootLayer().subLayerPaths.append(BASE_DIRECTORY + "/MiddleLayer.usda")
top_layer.GetRootLayer().subLayerPaths.append(BASE_DIRECTORY + "/BottomLayer.usda")


# Now query the COMPOSED final stage, not just the primspecs
# output:
# SdfTokenListOp(Explicit Items: [PhysicsCollisionAPI, PhysicsMassAPI, PhysicsArticulationAPI])
print(sphere_prim.GetMetadata("apiSchemas"))
# it basically went on like this:
#
# top_layer:    "I'm the most important, make sure that PhysicsRigidBodyAPI is DELETED"
# middle_layer: "I'm halfway important, it's imperative for me that PhysicsCollisionAPI and PhysicsMassAPI
#                are added as soon as possible to the list"
# bottom_layer: "I'm not important.. anyway if I have any voice in the matter I'd like PhysicsRigidBodyAPI
#                to be present and at the front of the list. Oh and also PhysicsMassAPI and
#                PhysicsArticulationRootAPI should be present but at the bottom of the list (both not very
#                important in my opinion).
#
# and the USD engine resolves things according to each layer's importance.


middle_layer.GetRootLayer().Export(BASE_DIRECTORY + "/MiddleLayer.usda")
bottom_layer.GetRootLayer().Export(BASE_DIRECTORY + "/BottomLayer.usda")
top_layer.GetRootLayer().Export(BASE_DIRECTORY + "/TopLayer.usda")

omni.usd.get_context().open_stage(BASE_DIRECTORY + "/TopLayer.usda")

The above is equivalent to (taken from here):

from pxr import Sdf
### Merging basics ###
path_list_op_layer_top = Sdf.PathListOp.Create(deletedItems = [Sdf.Path("/cube")])
path_list_op_layer_middle = Sdf.PathListOp.Create(prependedItems = [Sdf.Path("/disc"), Sdf.Path("/cone")])
path_list_op_layer_bottom = Sdf.PathListOp.Create(prependedItems = [Sdf.Path("/cube")], appendedItems = [Sdf.Path("/cone"),Sdf.Path("/sphere")])

result = Sdf.PathListOp()
result = result.ApplyOperations(path_list_op_layer_top)
result = result.ApplyOperations(path_list_op_layer_middle)
result = result.ApplyOperations(path_list_op_layer_bottom)
# Notice how on merge it makes sure that each sublist does not have the values of the other sublists, just like a Python set()
print(result) # Returns: SdfPathListOp(Deleted Items: [/cube], Prepended Items: [/disc, /cone], Appended Items: [/sphere])
# Get the flattened result. This does not apply the deleteItems, only ApplyOperations does that.
print(result.GetAddedOrExplicitItems()) # Returns: [Sdf.Path('/disc'), Sdf.Path('/cone'), Sdf.Path('/sphere')]

List composition can sometimes be daunting to newcomers but if explained properly it should be quite straightforward to grasp.

Transformations

Transformations in USD are represented with the UsdGeomXformOp class, e.g. xformOp:translate, xformOp:scale, xformOp:orient (to represent a rotation with a quaternion) or xformOp:rotateYXZ (to represent a rotation with euler angles applied in this order: Y, X and then finally Z). Note the property namespace xformOp where all operations are defined.

The transformations are stored along a prim and can be overridden in stronger layers and/or composed in the usual USD fashion. The class responsible for managing transformation on a prim is called UsdGeomXformable.

As we’ve already seen USD uses inheritance to model which functionalities a prim owns: in the following graph the green boxes represent classes from the UsdGeomImageable base class that provides attributes and functions for all prims that require rendering or visualization, to UsdGeomXformable which provides a stack of operations (translate/rotate/etc.) that will be applied to the prim before visualizing it, to UsdGeomGprim which is the base class for all geometric primitives (and encodes stuff like doubleSided or orientation and where the displayColor primvar also resides) to the UsdGeomCube class which we’ve already seen many times to create a Cube prim.

Let’s take a look at a simple example of an Xform with a child Cube prim: the Xform will have a scale and a translation transformations in its local stack and therefore those transformations will be inherited by the Cube as well.

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, Kind
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

xform : UsdGeom.Xform = UsdGeom.Xform.Define(stage, Sdf.Path("/World"))
environment_xform = UsdGeom.Xform.Define(stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# Create an Xform and a Cube as child of the Xform
xform : UsdGeom.Xform = UsdGeom.Xform.Define(stage, "/World/Xform")
cube : UsdGeom.Cube = UsdGeom.Cube.Define(stage, "/World/Xform/Cube")

if not xform.GetPrim().IsA(UsdGeom.Xformable):
    carb.log_error("Unexpected! A Xform derives from Xformable, as gprims do!")

# Define a scaling operation and a translation on the Xform
# These will be inherited by the cube's transformation

scale = Gf.Vec3f(50, 50, 50)
# Try to get the scale attribute or create it in case it doesn't exist yet
dstOp = UsdGeom.XformOp(xform.GetPrim().GetAttribute("xformOp:scale"))
if not dstOp:
    # Create this scale op
    xformable : UsdGeom.Xformable = UsdGeom.Xformable(xform)
    dstOp = xformable.AddXformOp(UsdGeom.XformOp.TypeScale, UsdGeom.XformOp.PrecisionFloat)
dstOp.Set(Gf.Vec3f(scale))

translate = Gf.Vec3f(20, 0, 0)
dstOp = UsdGeom.XformOp(xform.GetPrim().GetAttribute("xformOp:translate"))
if not dstOp:
    # Create this translate op
    xformable : UsdGeom.Xformable = UsdGeom.Xformable(xform)
    dstOp = xformable.AddXformOp(UsdGeom.XformOp.TypeTranslate, UsdGeom.XformOp.PrecisionFloat)
dstOp.Set(Gf.Vec3f(translate))

stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

If you inspect the generated .usda, you’ll find that the order of the XformOps as we added them was also stored:

$ cat /tmp/RootLayer.usda
#usda 1.0

def Xform "World"
{
    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }

    def Xform "Xform"
    {
        float3 xformOp:scale = (50, 50, 50)
        float3 xformOp:translate = (20, 0, 0)
        uniform token[] xformOpOrder = ["xformOp:scale", "xformOp:translate"]   # Order is stored

        def Cube "Cube"
        {
        }
    }
}

In case we wanted to apply first the translate operation and then the scale operation, the order could also have been changed by re-assigning those operations to the Xform local stack with SetXformOpOrder.

Reset Xform Stack

There is one particular operation called !resetXformOp! which acts like a boolean flag: if added to a prim

  • it is always added as first op in the xformOpOrder
  • it causes the prim not to inherit its parent’s transformation

Let’s see the same example as before but with a !resetXformOp! applied to the Cube (not to the Xform - it would mean that we don’t want the Xform to inherit transformations by /World, but that’s not what we’re trying to do here):

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, Kind
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

xform : UsdGeom.Xform = UsdGeom.Xform.Define(stage, Sdf.Path("/World"))
environment_xform = UsdGeom.Xform.Define(stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# Create an Xform and a Cube as child of the Xform
xform : UsdGeom.Xform = UsdGeom.Xform.Define(stage, "/World/Xform")
cube : UsdGeom.Cube = UsdGeom.Cube.Define(stage, "/World/Xform/Cube")

if not xform.GetPrim().IsA(UsdGeom.Xformable):
    carb.log_error("Unexpected! A Xform derives from Xformable, as gprims do!")

# Define a scaling operation and a translation on the Xform. But also set a
# resetXformStack op: the transformations will *NOT* be inherited by the cube

scale = Gf.Vec3f(50, 50, 50)
# Try to get the scale attribute or create it in case it doesn't exist yet
dstOp = UsdGeom.XformOp(xform.GetPrim().GetAttribute("xformOp:scale"))
if not dstOp:
    # Create this scale op
    xformable : UsdGeom.Xformable = UsdGeom.Xformable(xform)
    dstOp = xformable.AddXformOp(UsdGeom.XformOp.TypeScale, UsdGeom.XformOp.PrecisionFloat)
dstOp.Set(Gf.Vec3f(scale))

translate = Gf.Vec3f(20, 0, 0)
dstOp = UsdGeom.XformOp(xform.GetPrim().GetAttribute("xformOp:translate"))
if not dstOp:
    # Create this translate op
    xformable : UsdGeom.Xformable = UsdGeom.Xformable(xform)
    dstOp = xformable.AddXformOp(UsdGeom.XformOp.TypeTranslate, UsdGeom.XformOp.PrecisionFloat)
dstOp.Set(Gf.Vec3f(translate))

# Set the resetXformStack on the cube (NOT to the Xform.. otherwise the cube would have been affected)
xformable : UsdGeom.Xformable = UsdGeom.Xformable(cube.GetPrim())
xformable.SetResetXformStack(True)

stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")
$ cat /tmp/RootLayer.usda
#usda 1.0

def Xform "World"
{
    def Xform "Environment"
    {
        def DomeLight "DomeLight"
        {
            float inputs:intensity = 1000
        }
    }

    def Xform "Xform"
    {
        float3 xformOp:scale = (50, 50, 50)
        float3 xformOp:translate = (20, 0, 0)
        uniform token[] xformOpOrder = ["xformOp:scale", "xformOp:translate"]

        def Cube "Cube"
        {
            uniform token[] xformOpOrder = ["!resetXformStack!"]  # Note the resetXformStack
        }
    }
}

Note how the !resetXformStack! was declared as a uniform token: tokens are types of attributes in USD which can encode string values, paths or - as in this case - concepts that do not vary over time or across different instances of a prim (think of uniform pretty much like a GLSL shader’s uniform).

In this second example the cube will not inherit any scaling nor translation from its parent’s hierarchy and therefore remain with its default dimensions at origin (0;0;0).

Stage Population Masks

There’s an interesting technique to only load and compose parts of a stage instead of loading the entire stage and composing everything (and remember that for the same stage, rendering it is infinitely more expensive than just composing it and resolving opinions and references..): population masks.

For example let’s suppose we have a very large USD scene: we’ll use the IsaacWarehouse USD Composer showcase for this code sample. One can download all of the non-flattened USD assets to disk by loading it in Composer and then selecting File->Collect As.. and specifying a local temporary directory where to save a large quantity of data for that scene

Let’s suppose we want to find out the material associated with the main gate prim /World/Warehouse01/SM_Gate_C1, this involves a lookup for the direct material-binding relationship. From the Property Window UI of the loaded scene we can see that we’re looking for /World/Warehouse01/Looks/Gate_C

Let’s do this in code in two different ways:

  1. In this first way we’ll load the huge IsaacWarehouse.usd scene with Usd.Stage.Open() and compose it entirely before querying the gate for its directly associated material

    from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, UsdShade
    import omni.usd
    import carb
    import time
    
    # Record the start time
    start_time = time.time()
    
    ################################
    ### composition happens here ###
    ### POTENTIALLY VERY SLOW    ###
    ################################
    stage : Usd.Stage = Usd.Stage.Open("/tmp/big_usd_scene/Collected_IsaacWarehouse/IsaacWarehouse.usd")
    
    # Record the end time and print the elapsed time in seconds
    end_time = time.time()
    elapsed_time = end_time - start_time
    print(f"Elapsed time: {elapsed_time} seconds")
    
    # Continue with gathering the material bound to the gate prim
    
    gate_prim : Usd.Prim = stage.GetPrimAtPath("/World/Warehouse01/SM_Gate_C1")
    material_binding = UsdShade.MaterialBindingAPI(gate_prim)
    # Do not specify a specific 'purpose' in GetDirectBindingRel()
    # (a material can be bound in 'preview' and another one, heavier, for 'full' scene rendering),
    # just get any of them
    relationship : Usd.Relationship = material_binding.GetDirectBindingRel()
    direct_binding : UsdShade.MaterialBindingAPI.DirectBinding = UsdShade.MaterialBindingAPI.DirectBinding(relationship)
    
    if not direct_binding.GetMaterial():
        carb.log_error("No material directly associated")
    
    material_path : Sdf.Path = direct_binding.GetMaterialPath()
    prim : Usd.Prim = stage.GetPrimAtPath(material_path)
    
    material_bound_to_gate : UsdShade.Material = UsdShade.Material(prim)
    
    print(material_bound_to_gate.GetPath())  # /World/Warehouse01/Looks/Gate_C
    

    Output:

    Elapsed time: 0.02084561 seconds
    /World/Warehouse01/Looks/Gate_C
    
  2. Now let’s see an alternative way of getting the same data, but this time let’s use Usd.Stage.OpenMasked() which accepts a list of prim paths to load (all of their children will be loaded as well) so we can ‘prune’ the stage hierarchy and avoid loading and composing unnecessary stuff

    from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, UsdShade
    import omni.usd
    import carb
    import time
    
    # Record the start time
    start_time = time.time()
    
    #################################################
    ### composition happens here                  ###
    ### but only for the requested prim paths!    ###
    #################################################
    primpaths_to_load = ["/World/Warehouse01/SM_Gate_C1", "/World/Warehouse01/Looks"]
    population_mask = Usd.StagePopulationMask(primpaths_to_load)
    stage : Usd.Stage = Usd.Stage.OpenMasked("/tmp/big_usd_scene/Collected_IsaacWarehouse/IsaacWarehouse.usd", population_mask)
    
    # Record the end time and print the elapsed time in seconds
    end_time = time.time()
    elapsed_time = end_time - start_time
    print(f"Elapsed time: {elapsed_time} seconds")
    
    # Continue with gathering the material bound to the gate prim
    
    gate_prim : Usd.Prim = stage.GetPrimAtPath("/World/Warehouse01/SM_Gate_C1")
    material_binding = UsdShade.MaterialBindingAPI(gate_prim)
    # Do not specify a specific 'purpose' in GetDirectBindingRel()
    # (a material can be bound in 'preview' and another one, heavier, for 'full' scene rendering),
    # just get any of them
    relationship : Usd.Relationship = material_binding.GetDirectBindingRel()
    direct_binding : UsdShade.MaterialBindingAPI.DirectBinding = UsdShade.MaterialBindingAPI.DirectBinding(relationship)
    
    if not direct_binding.GetMaterial():
        carb.log_error("No material directly associated")
    
    material_path : Sdf.Path = direct_binding.GetMaterialPath()
    prim : Usd.Prim = stage.GetPrimAtPath(material_path)
    
    material_bound_to_gate : UsdShade.Material = UsdShade.Material(prim)
    
    print(material_bound_to_gate.GetPath())  # /World/Warehouse01/Looks/Gate_C
    

    Output:

    Elapsed time: 0.00132393 seconds
    /World/Warehouse01/Looks/Gate_C
    

Even though opening the stage and composing it only took ~20 milliseconds with Usd.Stage.Open(), Usd.Stage.OpenMasked() took ~1 millisecond: this technique can be quite effective to avoid loading and composing unnecessary parts of the stage.

A lower level technique would have been opening the layers manually (either by opening the .usd files or following references via code) and inspecting primspecs in those layers and looking for material binding relationships and resolving them to the final material path. Properly optimized code could have been even faster with low-level primspec parsing, but population masks are substantially easier to use and quite effective too.

Schemas

Schemas are a fundamental part in USD: a schema defines which properties (with fallback/default values) and which metadata a prim should have. A prim that uses the Cube schema is expected to have a size attribute and USD will also generated methods of the syntax Get[attr_name]Attr()/Create[attr_name]Attr, i.e. GetSizeAttr()/CreateSizeAttr().

Example

The UsdPhysics.usda schema in the official pixar USD repo defines a collisionEnabled attribute in the PhysicsCollisionAPI class so that each prim which inherits from the PhysicsCollisionAPI is going to have that attribute (which semantically controls whether the prim will participate or not in the physics engine simulation of collisions)

There are two types of schemas:

  • Typed Schemas (also called IsA-schemas): these can impart a typeName to a UsdPrim. An example is Cube: in the text usda the defined prim as type cube

    def Cube "Cube" # type is 'Cube'
    {
        float3[] extent = [(-50, -50, -50), (50, 50, 50)]
        double size = 100
    }
    

    The type of a prim can be inspected with IsA (which in C++ is a templated function for the inspecting type)

    cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
    extent = [(-50, -50, -50), (50, 50, 50)]
    cube.GetExtentAttr().Set(extent)
    cube.GetSizeAttr().Set(100)
    ..
    
    cube_prim : Usd.Prim = cube.GetPrim()
    
    print(cube_prim.IsA(UsdGeom.Cube)) # True
    

    By using the Tf module (recall that it’s used for type-related operations) we can do type manipulations as well and inspect the typed schema further (although this stuff is more advanced):

    # Query the typename and look it up in the internal USD registry of all known schemas up to
    # this point
    prim_type_name = cube_prim.GetTypeName()
    print(prim_type_name) # "Cube", this is the textual name of the prim type name
    # Get the Tf.Type (Tf is for internal type operations) from this 'Cube' type name
    prim_tftype_type : Tf.Type = Usd.SchemaRegistry.GetTypeFromName(prim_type_name)
    # Use it to define a new variabile (get its python class with `pythonClass`)
    # which points to the same Cube prim instance
    myObj : prim_tftype_type.pythonClass = cube
    print(myObj.GetSizeAttr().Get()) # "100"
    
    # Get the textual representation of its class type receiving 'cube_prim' in its constructor
    prim_typed_schema = prim_tftype_type.pythonClass(cube_prim)
    print(prim_typed_schema) # "UsdGeom.Cube(Usd.Prim(</World/Cube>))"
    

    Note that this kind of schemas can be Concrete or Abstract/non-concrete which means that it can be instantiated directly (e.g. Cube) or cannot be instantiated directly and you’ll have to either define or find a subclass which adds the missing required pieces (e.g. UsdGeom.Imageable).

  • API Schemas: these do not define a prim type and do not contribute to a prim’s definition but rather add methods and properties/metadata to have the prim behave in a certain way. There are two types of API schemas:

    • non-applied schemas (we’ll take a look at the kind non-applied schema later) which only provide an API to set and get data for a prim (and you usually just use that API to access it, e.g. the kind non-applied schema which is basically a “is-this-the-topmost-parent-prim-of-a-large-and-complete-3d-model?” field)
    • single-apply schemas: adds properties to a prim’s definition, e.g. the UsdCollisionAPI for physics collision behavior. These can be queried via UsdPrim::HasAPI<..>() or the equivalent Python Usd.Prim.HasAPI("CollisionAPI") method.
    • multiple-apply schemas: these can be applied to a prim more than once requiring an “instance name” to distinguish them, a typical example is the UsdShadeMaterialBindingAPI multiple-apply schema which can be applied multiple times to a prim to bind different materials to different subsets of a geometry.

    Here are some examples of what we just learned:

    root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")
    
    # Add a cube to the scene
    
    xform : UsdGeom.Xform = UsdGeom.Xform.Define(root_stage, Sdf.Path("/World"))
    cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
    extent = [(-50, -50, -50), (50, 50, 50)]
    cube.GetExtentAttr().Set(extent)
    cube.GetSizeAttr().Set(100)
    UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
    environment_xform = UsdGeom.Xform.Define(root_stage, "/World/Environment")
    dome_light = UsdLux.DomeLight.Define(root_stage, "/World/Environment/DomeLight")
    dome_light.CreateIntensityAttr(1000)
    
    cube_prim : Usd.Prim = cube.GetPrim()
    
    # Get the typed schema of the cube prim
    prim_type_name = cube_prim.GetTypeName()
    prim_tftype_type : Tf.Type = Usd.SchemaRegistry.GetTypeFromName(prim_type_name)
    prim_typed_schema = prim_tftype_type.pythonClass
    # Here the type can be used, as we saw before, to define references or new cube variables
    
    ## API Schemas ##
    
    # Get the non-applied schema 'kind'
    non_applied_api_schema = Usd.ModelAPI(cube_prim)
    # use it
    non_applied_api_schema.SetKind(Kind.Tokens.subcomponent) # we'll see what this means later
    
    # Get the applied schema CollisionAPI
    applied_api_schema = UsdPhysics.CollisionAPI(cube_prim)
    # use it
    applied_api_schema.GetCollisionEnabledAttr().Set(True)
    

    E.g. of usda for an API single-appy schema PhysicsRigidBodyAPI:

    #usda 1.0
    ()
    over "Kitchen_set"
    {
        over "FlowerPotA_5" (
            prepend apiSchemas = ["PhysicsRigidBodyAPI"]
        )
        {}
    }
    

And here is a table which intuitively maps the concepts that we’ve just learned about schemas to OOP (object oriented programming) concepts you might be familiar with:

USD ConceptSimilar OOP Concept
Non-concrete typed schemaAbstract class (non-instantiable directly)
Concrete typed schema(Instantiable) class
Non-applied API SchemaProvide methods to access/set non-defining properties (and you cannot manipulate its state directly) but does not contribute to type in any way
Single applied API SchemaA member variable inside your class that you can use - it has its state and methods
Multi-applied API SchemaAn array of member variables inside your class that you can use - each element inside the array has its own state even though they all have the same methods

Some of these can also be queried from the Usd.SchemaRegistry() - a registry that contains the list of all schema names, types and fallback/default values for all known schemas

registry = Usd.SchemaRegistry()
print(registry.IsTyped(UsdGeom.Cube))  # True
print(registry.IsTyped(UsdGeom.Imageable)) # True
print(registry.IsAbstract(UsdGeom.Imageable))  # True
print(registry.IsAbstract(UsdGeom.Cube)) # False
print(registry.IsConcrete(UsdGeom.Imageable))  # False
print(registry.IsConcrete(UsdGeom.Cube)) # True
print(registry.IsTyped("UsdGeomImageable"))  # True
print(registry.IsTyped("UsdGeomCube"))  # True
print(registry.IsAppliedAPISchema("CollisionAPI"))  # True
print(registry.IsMultipleApplyAPISchema("CollectionAPI"))  # True
print(registry.GetSchemaKind("Cube"))  # pxr.Usd.SchemaKind.ConcreteTyped
print(registry.GetSchemaKind("Imageable"))  # pxr.Usd.SchemaKind.AbstractTyped

Lastly, let’s take a look at a more complex example that removes a typed API schema from a primspec and uses List Composition:

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

def removeAPI(prim, api_name):
    if prim.IsInstanceProxy() or prim.IsInPrototype():
        return # invalid prim

    # Get a primspec on the root stage
    editTarget = root_stage.GetEditTarget()
    primSpec = editTarget.GetPrimSpecForScenePath(prim.GetPath())

    listOp = primSpec.GetInfo(Usd.Tokens.apiSchemas)

    # Look for the API in the prepended/appended/explicit lists
    if api_name not in listOp.prependedItems:
        if api_name not in listOp.explicitItems:
            if api_name not in listOp.appendedItems:
                return # not found, we're good

    # Create a new list with whatever it was already present MINUS the api_name we want to remove
    newPrepended = listOp.prependedItems
    newPrepended.remove(api_name)
    listOp.prependedItems = newPrepended

    result = listOp.ApplyOperations([])
    newListOp = Sdf.TokenListOp()
    newListOp.prependedItems = result # Reassignment is needed here due to legacy reasons
    # Write back the primspec again
    primSpec.SetInfo(Usd.Tokens.apiSchemas, newListOp)

# Add a cube to the scene
xform : UsdGeom.Xform = UsdGeom.Xform.Define(root_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
cube_prim : Usd.Prim = cube.GetPrim()
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
# Apply a CollisionAPI
UsdPhysics.CollisionAPI.Apply(cube_prim)
environment_xform = UsdGeom.Xform.Define(root_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(root_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# Remove the CollisionAPI
removeAPI(cube_prim, "PhysicsCollisionAPI")
print(cube_prim.GetMetadata("apiSchemas")) # SdfTokenListOp(Explicit Items: [])

Custom schemas

As previously stated USD is extensible. This means that custom schemas can be defined. This is rather common when dealing with a custom pipeline from a DCC (digital content creation software) that involves USD: a developer defines his own schemas to create custom prim types/API so that the prims have sets of attributes relevant to the DCC in question or to the kind of workflow intended.

There is a pretty good tutorial on the OpenUSD official website regarding schema generations, but we’ll summarize the steps here for clarity:

  • One would usually first figure out what kind of schema he’s after (is it a multiple-apply schema? a single-apply one?). Then a usda schema would usually be defined, e.g. the UsdPhysics schema. This is called the schema definition file.

  • A schema definition file can be contained within a USD plugin, indicating that schema definitions and associated code (if not codeless) will be included in the resulting C++ and Python libraries. A USD plugin is a shared library object (e.g. .dll or .so) that USD applications can load via the Plugin registry

  • A script called usdGenSchema provided by the official pxr repo can be used to generate C++ classes (and/or python bindings)

    $ usdGenSchema schema.usda .
    
    Processing schema classes:
    SimplePrim, ComplexPrim, ParamsAPI
    Loading Templates
    Writing Schema Tokens:
            unchanged extras/usd/examples/usdSchemaExamples/tokens.h
            unchanged extras/usd/examples/usdSchemaExamples/tokens.cpp
            unchanged extras/usd/examples/usdSchemaExamples/wrapTokens.cpp
    Generating Classes:
            unchanged extras/usd/examples/usdSchemaExamples/simple.h
            unchanged extras/usd/examples/usdSchemaExamples/simple.cpp
            unchanged extras/usd/examples/usdSchemaExamples/wrapSimple.cpp
            unchanged extras/usd/examples/usdSchemaExamples/complex.h
            unchanged extras/usd/examples/usdSchemaExamples/complex.cpp
            unchanged extras/usd/examples/usdSchemaExamples/wrapComplex.cpp
            unchanged extras/usd/examples/usdSchemaExamples/paramsAPI.h
            unchanged extras/usd/examples/usdSchemaExamples/paramsAPI.cpp
            unchanged extras/usd/examples/usdSchemaExamples/wrapParamsAPI.cpp
            unchanged extras/usd/examples/usdSchemaExamples/plugInfo.json
    Generating Schematics:
            unchanged extras/usd/examples/usdSchemaExamples/generatedSchema.usda
    
  • Stuff is then compiled to build the plugin shared library object that can be loaded

    $ cmake --build . --target install --config Release
    
  • Finally the environment variable PXR_PLUGINPATH_NAME can be used to indicate the location of the plugin’s resources directory so it can be loaded from conforming USD-based applications (e.g. Kit for Omniverse apps).

Events and Listeners

USD features an event system to notify and take action in case of added prims, removed prims, attribute changes, a stage was just loaded, etc.

Note

Your callback functions registered as listeners should be fast: think of ISVs (interrupt service vectors) - their actions should either be fast or another thread/entity should be delegate to take heavy actions later. This is by design since they run in the thread where the listener was created and that’s often the main thread.

The basic structure of a USD event listener is as follows (most of the facilities are in the Tf module - see Tools Foundations):

def ObjectsChanged_callback(notice, sender):
    print(notice.GetResyncedPaths())
    print(notice.GetChangedInfoOnlyPaths())

listener = Tf.Notice.RegisterGlobally(Usd.Notice.ObjectsChanged, ObjectsChanged_callback)

# do some USD manipulations here..

listener.Revoke() # cleanup
  • ResyncedPaths are structural changes that can invalidate entire subtrees of UsdObjects (both prims and properties). These happen when you delete or add a prim, or even if you add a property or metadata. Anything that relates to composition is considered a structural change.

  • ChangedInfoOnly are non-structural changes, e.g. when you change an attribute’s or metadata’s value.

Here is a code example of registering a listener and spewing out a long list of information regarding what just happened. At a high level overview, the following is going to happen:

  1. A listener is registered
  2. A /World Xform is added to the scene, this will cause a resync (it’s a structural change)
  3. /World/Cube is added, structural
  4. Some attributes (as mandated by the Cube typed schema) are changed in /World/Cube, this is non-structural
  5. /World/Environment and /World/Environment/DomeLight are created.. same as before..
  6. The attribute /World/Cube.myCustomFloatAttribute is created. Structural change.
  7. The default value of attribute /World/Cube.myCustomFloatAttribute is changed. Non-structural change.
  8. The listener is revoked

Here’s the full code and output with explicative comments on what’s happening

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, Tf
import omni.usd
import carb

BASE_DIRECTORY = "/tmp"  # This is where the .usda files will be saved

root_stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

def ObjectsChanged_callback(notice, sender):
    stage = notice.GetStage()
    print("---")
    print(">", notice, sender)
    print(">> (notice.GetResyncedPaths) - Updated paths", notice.GetResyncedPaths())
    print(">> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes", notice.GetChangedInfoOnlyPaths())

    prim = stage.GetPrimAtPath("/World/Cube")
    if prim:
        # path #1

        # Check if a specific UsdObject was affected
        print(">> (notice.AffectedObject) - Something changed for", prim.GetPath(), notice.AffectedObject(prim))
        print(">> (notice.ResyncedObject) - Updated path for", prim.GetPath(), notice.ResyncedObject(prim))
        print(">> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly", prim.GetPath(), notice.ChangedInfoOnly(prim))
        print(">> (notice.HasChangedFields) - Attribute/Metadata HasChanges", prim.GetPath(), notice.HasChangedFields(prim))
        print(">> (notice.GetChangedFields) - Attribute/Metadata ChangedFields", prim.GetPath(), notice.GetChangedFields(prim))

    attr = stage.GetAttributeAtPath("/World/Cube.myCustomFloatAttribute")
    if attr:
        # path #2

        # Check if a specific UsdObject was affected
        print(">> (notice.AffectedObject) - Something changed for", attr.GetPath(), notice.AffectedObject(attr))
        print(">> (notice.ResyncedObject) - Updated path for", attr.GetPath(), notice.ResyncedObject(attr))
        print(">> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly", attr.GetPath(), notice.ChangedInfoOnly(attr))
        print(">> (notice.HasChangedFields) - Attribute/Metadata HasChanges", attr.GetPath(), notice.HasChangedFields(attr))
        print(">> (notice.GetChangedFields) - Attribute/Metadata ChangedFields", attr.GetPath(), notice.GetChangedFields(attr))

listener = Tf.Notice.RegisterGlobally(Usd.Notice.ObjectsChanged, ObjectsChanged_callback)

# Add some prims to the scene
xform : UsdGeom.Xform = UsdGeom.Xform.Define(root_stage, Sdf.Path("/World"))
cube : UsdGeom.Cube = UsdGeom.Cube.Define(root_stage, "/World/Cube")
extent = [(-50, -50, -50), (50, 50, 50)]
cube.GetExtentAttr().Set(extent)
cube.GetSizeAttr().Set(100)
environment_xform = UsdGeom.Xform.Define(root_stage, "/World/Environment")
dome_light = UsdLux.DomeLight.Define(root_stage, "/World/Environment/DomeLight")
dome_light.CreateIntensityAttr(1000)

# Create an attribute
cube_prim : Usd.Prim = cube.GetPrim()
attr = cube_prim.CreateAttribute("myCustomFloatAttribute", Sdf.ValueTypeNames.Float)
attr.Set(42.8)

listener.Revoke()

# Export root stage to file
root_stage.GetRootLayer().Export(BASE_DIRECTORY + "/RootLayer.usda")

# Issue an 'open-stage' command to avoid doing this manually and free whatever stage
# was previously owned by this context
omni.usd.get_context().open_stage(BASE_DIRECTORY + "/RootLayer.usda")

Output:

# listener is registered here

# /World Xform is created
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []

# /World/Cube is created, path #1 begins to always be executed from now on
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Cube')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube True
>> (notice.ResyncedObject) - Updated path for /World/Cube True
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube True
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube ['specifier', 'typeName']

# /World/Cube.extent attribute is created (this is defined in the typed schema)
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Cube.extent')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []

# /World/Cube.extent attribute has its value changed
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths []
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes [Sdf.Path('/World/Cube.extent')]
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []

# /World/Cube.size is created
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Cube.size')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []

# /World/Cube.size has its value changed
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths []
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes [Sdf.Path('/World/Cube.size')]
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []

# /World/Environment is created
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Environment')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []

# /World/DomeLight is created... we'll skip all this part since it's similar to what we just explained
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Environment/DomeLight')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Environment/DomeLight.inputs:intensity')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths []
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes [Sdf.Path('/World/Environment/DomeLight.inputs:intensity')]
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []

# /World/Cube.myCustomFloatAttribute is created - path #2 is also executed from now on
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths [Sdf.Path('/World/Cube.myCustomFloatAttribute')]
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes []
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []
>> (notice.AffectedObject) - Something changed for /World/Cube.myCustomFloatAttribute True
>> (notice.ResyncedObject) - Updated path for /World/Cube.myCustomFloatAttribute True
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube.myCustomFloatAttribute False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube.myCustomFloatAttribute True
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube.myCustomFloatAttribute ['custom']

# /World/Cube.myCustomFloatAttribute has its default value changed
---
><pxr.Usd.ObjectsChanged object at 0x7feb381c4ea0> Usd.Stage.Open(rootLayer=Sdf.Find('anon:0x1db9dcf0:RootLayer.usda'), sessionLayer=Sdf.Find('anon:0xfb39dd0:RootLayer-session.usda'), pathResolverContext=<invalid repr>)
>> (notice.GetResyncedPaths) - Updated paths []
>> (notice.GetChangedInfoOnlyPaths) - Attribute/Metadata value changes [Sdf.Path('/World/Cube.myCustomFloatAttribute')]
>> (notice.AffectedObject) - Something changed for /World/Cube False
>> (notice.ResyncedObject) - Updated path for /World/Cube False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube False
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube False
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube []
>> (notice.AffectedObject) - Something changed for /World/Cube.myCustomFloatAttribute True
>> (notice.ResyncedObject) - Updated path for /World/Cube.myCustomFloatAttribute False
>> (notice.ChangedInfoOnly) - Attribute/Metadata ChangedInfoOnly /World/Cube.myCustomFloatAttribute True
>> (notice.HasChangedFields) - Attribute/Metadata HasChanges /World/Cube.myCustomFloatAttribute True
>> (notice.GetChangedFields) - Attribute/Metadata ChangedFields /World/Cube.myCustomFloatAttribute ['default']

# Listener is revoked here

As you can see the USD event system is quite comprehensive and allows you to inspect carefully what’s happening on the scene.

Here’s a link to a more complex example involving creating an Omniverse extension printing out prim paths in the viewport directly: this also uses a Usd.Notice.ObjectsChanged listener to be notified if anything changes in the selected prim so it can react and update accordingly: How to make an extension to display Object Info.

One last thing to pay attention to: stage callbacks are usually handled by USD contexts, therefore the right place to find such callbacks would be taking a look at USD contexts documentations (usually provided by the application you’re using USD in), in Omniverse that would be omni.usd.USDContext:

stage: Usd.Stage = Usd.Stage.Open(some_usd_url)
cache = UsdUtils.StageCache.Get()
# Retrieve a long int id from the singleton cache for all local USD clients
stage_id = cache.Insert(stage).ToLongInt()
def on_stage_opened(result, err):
    if result is True:
        print(f"There were errors opening the stage: {err}")
    else:
        print("no errors, stage opened!")
omni.usd.get_context().attach_stage_with_callback(stage_id=stage_id, on_finish_fn=stage_opened_fn)

Debugging

Debugging USD can be challenging, there are some Tf debugging facilities that can be used to make this job easier and print detailed log in composition phases and inspect what’s going on within multiple plugins

Here’s an excerpt from USD-Cookbook

from pxr import Sdf, UsdGeom, Usd, UsdLux, Gf, Tf
import omni.usd
import carb

stage : Usd.Stage = Usd.Stage.CreateInMemory("RootLayer.usda")

# Redirect debug output to stdout, it can be redirected to a file as well
Tf.Debug.SetOutputFile(sys.__stdout__)

# Actual symbols are defined in C++ across many files.
# You can query them using `Tf.Debug.GetDebugSymbolNames()` or by
# searching for files that call the `TF_DEBUG_CODES` macro in C++.
# (Usually this is in files named "debugCodes.h").
symbols = Tf.Debug.GetDebugSymbolNames()

# Check if debug symbols are enabled
# (on my machine, they're all False by default)
for symbol in symbols:
    print(symbol)
    print(Tf.Debug.IsDebugSymbolNameEnabled(symbol))

# A more detailed full description of everything
print("Descriptions start")
print(Tf.Debug.GetDebugSymbolDescriptions())
print("Descriptions end")

# Enable change processing so we can see something happening
# You can also use glob matching. Like "USD_*" to enable many flags
# at once.
Tf.Debug.SetDebugSymbolsByName("USD_CHANGES", True)

stage.DefinePrim("/SomePrim")  # This line will print multiple messages to stdout

Note that per-plugin debugging should be enabled if you’re doing this in OV Composer. To enable all of the debug codes that start with the prefix PLUG_ the following can be used

$ TF_DEBUG="PLUG_*" /home/alex/.local/share/ov/pkg/create-2023.2.0/omni.create.sh
...
# script above gets executed..
HandleLayersDidChange received (stage with rootLayer @anon:0x12035890:RootLayer.usda@, sessionLayer @anon:0x1c4ada40:RootLayer-session.usda@)
</SomePrim> in @anon:0x12035890:RootLayer.usda@ changed.
Changed field: specifier
Adding paths that use </SomePrim> in layer @anon:0x12035890:RootLayer.usda@: [ /SomePrim ]

ProcessPendingChanges (stage with rootLayer @anon:0x12035890:RootLayer.usda@, sessionLayer @anon:0x1c4ada40:RootLayer-session.usda@)
Did Change Significantly: /SomePrim
Recomposing: /SomePrim

A comprehensive list of debug codes can be found here: Debugging USD.

Credits

Created by Marco Alesiani.

License: CC BY 4.0

This book and its webGL frontpage use open source and royalty-free libraries and technologies. Some paragraphs also draw from freely shared material.

A huge thank you to all people who made this possible.