OpenUSD Mastery: From Composition to Pipeline — A SO-101 Arm Journey
25th March 2026
OpenUSD (Universal Scene Description) is not just a 3D modeling format — it’s a universal language for describing complex scenes, their relationships, and their properties. Think of it as JSON for 3D worlds, but infinitely more powerful.
This guide works through key OpenUSD concepts using a real robotic arm (SO-101) as the running example.
1. Composition Arcs — Combining USD Files
Imagine building a SO-101 robotic arm from multiple files:
base.usda— the mounting baseshoulder.usda— shoulder jointelbow.usda— elbow jointgripper.usda— end effectormaterials.usda— metal texturesphysics.usda— collision properties
When you combine these files, what happens if base.usda says the arm is red, but materials.usda says it’s silver? Which one wins?
OpenUSD uses LIVRPS strength ordering to resolve conflicts:
| Letter | Arc | Strength | SO-101 Example |
|---|---|---|---|
| L | Local opinions | Strongest | Direct edits in your final so101_arm.usda |
| I | Inherits | All joints inherit from a RoboticJoint class with default torque limits | |
| V | VariantSets | Gripper variants: parallel_jaw, suction_cup, magnetic | |
| R | References | Reference gripper.usda into your arm assembly | |
| P | Payloads | High-poly collision mesh loaded only when needed | |
| S | Sublayers | Weakest | Stack modeling + materials + physics layers |
# so101_arm.usda (Final assembly)
#usda 1.0
def Xform "SO101_Arm" (
sublayers = [
@./layers/modeling.usda@,
@./layers/materials.usda@,
@./layers/physics.usda@
]
)
{
def Xform "Gripper" (
references = @./assets/gripper_v2.usda@
)
{
variantSet "gripper_type" = {
"parallel_jaw" {}
"suction_cup" {}
}
# LOCAL OPINION (strongest) — overrides everything
color3f primvars:displayColor = (0.8, 0.8, 0.8)
}
}
Memory trick: “Live Very Rich People Sail” = LIVRPS. Local opinions are Loudest. Sublayers are Silent.
2. Asset Structure and Content Aggregation
Five teams working on SO-101 without structure means files overwriting each other and nobody can make progress. The four principles of asset structure solve this:
- Single Entry Point — one main file that references everything (
so101_arm.usd) - Clear Interfaces — public = joint transforms; private = internal mesh topology
- Encapsulation — gripper internals hidden, only expose “open/close” interface
- Parallel Workstreams — each team has their own layer, no conflicts
/assets/robots/so101_arm/
├── so101_arm.usd # Entry point
├── layers/
│ ├── modeling.usda # Modeling team
│ ├── materials.usda # Materials team
│ ├── rigging.usda # Rigging team
│ └── physics.usda # Physics team
├── components/
│ ├── base.usda
│ ├── shoulder.usda
│ ├── elbow.usda
│ └── gripper.usda
└── variants/
├── gripper_parallel.usda
└── gripper_suction.usda
With this structure: modeling team works Monday, materials team works Tuesday, rigging team Wednesday, physics team Thursday — all combining automatically in so101_arm.usd on Friday with no conflicts.
3. Custom Schemas — Extending USD for Robotics
Built-in USD has Xform, Mesh, Material — but nothing for robotics. You need joint torque limits, motor controller IDs, safety zones, PID parameters. The solution is custom schemas.
# schema.usda
class "RoboticJoint" (
inherits = </Xform>
)
{
float joint:torqueLimit = 50.0 (doc = "Maximum torque in Nm")
float joint:velocityLimit = 3.14 (doc = "Maximum velocity in rad/s")
int motor:controllerId = 0 (doc = "CAN bus motor controller ID")
float3 joint:axis = (0, 0, 1) (doc = "Rotation axis")
float2 joint:limits = (-180, 180) (doc = "Joint angle limits in degrees")
float3 pid:gains = (1.0, 0.1, 0.01) (doc = "PID controller gains (P, I, D)")
}
# so101_arm.usd
def RoboticJoint "Shoulder" (kind = "component")
{
float joint:torqueLimit = 100.0
float joint:velocityLimit = 2.0
int motor:controllerId = 1
float3 joint:axis = (0, 1, 0)
float2 joint:limits = (-90, 90)
float3 pid:gains = (2.0, 0.2, 0.05)
}
Use custom schemas for domain-specific properties (robotics, manufacturing, medical). Use built-in types for standard 3D properties.
4. Data Exchange — USD as Universal Translator
Your SO-101 arm needs to work in Maya (modeling), Blender (animation), Isaac Sim (simulation), ROS2 (robot control — needs URDF), and Unity (visualization — needs FBX). Without USD you’d create 5 versions manually. With USD you create once and convert automatically.
# USD → URDF (for ROS2)
from pxr import Usd, UsdGeom
import urdf_exporter
stage = Usd.Stage.Open("so101_arm.usd")
joints = []
for prim in stage.Traverse():
if prim.IsA(UsdGeom.Xform):
joints.append({
'name': prim.GetName(),
'parent': prim.GetParent().GetName(),
'axis': prim.GetAttribute('joint:axis').Get(),
'limits': prim.GetAttribute('joint:limits').Get()
})
urdf_exporter.write_urdf("so101_arm.urdf", joints)
Before exchanging data, validate it:
from pxr import Usd, UsdUtils
stage = Usd.Stage.Open("so101_arm.usd")
errors = UsdUtils.ComplianceChecker.CheckCompliance(stage)
for error in errors:
print(f"ERROR: {error.message} at {error.path}")
5. Modularity and Instancing — The LEGO Approach
A Physical AI training environment needs 100 SO-101 arms, 500 boxes, and 1000 bolts. Copying geometry 1000 times = 10 GB file, 5 minutes to load. Creating one prototype and instancing 1000 times = 10 MB file, 5 seconds to load.
There are three levels of instancing:
| Type | Use case | Analogy |
|---|---|---|
| Modularity | Reusable components referenced by multiple assets | LEGO blocks |
| Scenegraph Instancing | Dozens to hundreds of complex objects | Photocopies of a document |
| Point Instancing | Thousands of simple objects | Rubber stamp |
# Scenegraph instancing — 100 robots
def Xform "Warehouse"
{
def "Prototypes"
{
def Xform "SO101_Prototype" (
references = @./so101_arm.usd@
) { instanceable = true }
}
def Xform "RobotArmy"
{
def "Robot_001" (
instanceable = true
references = </Warehouse/Prototypes/SO101_Prototype>
) { double3 xformOp:translate = (0, 0, 0) }
def "Robot_002" (
instanceable = true
references = </Warehouse/Prototypes/SO101_Prototype>
) { double3 xformOp:translate = (2, 0, 0) }
}
}
# Point instancing — 10,000 bolts
from pxr import Usd, UsdGeom
import numpy as np
stage = Usd.Stage.CreateNew("warehouse_bolts.usd")
instancer = UsdGeom.PointInstancer.Define(stage, "/Bolts")
prototype = UsdGeom.Mesh.Define(stage, "/Prototypes/Bolt")
instancer.GetPrototypesRel().SetTargets([prototype.GetPath()])
positions = np.random.rand(10000, 3) * 100
instancer.GetPositionsAttr().Set(positions)
indices = np.zeros(10000, dtype=int)
instancer.GetProtoIndicesAttr().Set(indices)
stage.Save()
6. Debugging — Finding the Needle
Three common problems and how to solve them:
Gripper not appearing — open usdview, go to Tools → Composition, select the gripper prim and look for a missing reference path, inactive prim, or visibility = "invisible".
Wrong material applied — inspect the prim stack in Python:
from pxr import Usd
stage = Usd.Stage.Open("so101_arm.usd")
prim = stage.GetPrimAtPath("/SO101_Arm/Base")
material_binding = prim.GetRelationship("material:binding")
print(f"Material: {material_binding.GetTargets()}")
for spec in prim.GetPrimStack():
print(f"Layer: {spec.layer.identifier}")
Performance issues — count instances and find heavy payloads:
from pxr import Usd, UsdGeom
stage = Usd.Stage.Open("warehouse_training.usd")
total_prims = len(list(stage.Traverse()))
instances = sum(1 for p in stage.Traverse() if p.IsInstance())
payloads = [p.GetPath() for p in stage.Traverse() if p.HasPayload()]
print(f"Prims: {total_prims}, Instances: {instances}")
print(f"Payloads: {payloads}")
Debugging workflow: View in usdview → Inspect composition → Print prim stack (VIP).
7. Pipeline Automation
Manual setup for one training scenario takes about 2 hours. For 1000 scenarios that’s 2000 hours. Automated pipelines bring that to 10 minutes total.
# generate_training_scene.py
import random
from pxr import Usd, UsdGeom
def generate_warehouse_scene(num_robots, num_boxes, output_path):
stage = Usd.Stage.CreateNew(output_path)
warehouse = stage.DefinePrim("/Warehouse", "Xform")
warehouse.GetReferences().AddReference("./assets/warehouse_base.usd")
for i in range(num_robots):
robot = stage.DefinePrim(f"/Warehouse/Robots/SO101_{i:03d}", "Xform")
robot.GetReferences().AddReference("./assets/so101_arm.usd")
x = random.uniform(-20, 20)
y = random.uniform(-20, 20)
UsdGeom.Xformable(robot).AddTranslateOp().Set((x, y, 0))
stage.Save()
for i in range(1000):
generate_warehouse_scene(
num_robots=random.randint(10, 50),
num_boxes=random.randint(100, 500),
output_path=f"./training_scenes/scene_{i:04d}.usd"
)
8. Data Modeling — Designing Your Hierarchy
USD defines standard “kinds” for organizing your scene hierarchy:
| Kind | Use | SO-101 Example |
|---|---|---|
assembly | Top-level collection | Complete SO-101 arm |
component | Functional unit | Shoulder, elbow, gripper |
group | Organizational grouping | All robots in warehouse |
subcomponent | Part of a component | Gripper finger |
from pxr import Usd, Kind
stage = Usd.Stage.CreateNew("so101_arm.usd")
arm = stage.DefinePrim("/SO101_Arm", "Xform")
Usd.ModelAPI(arm).SetKind(Kind.Tokens.assembly)
shoulder = stage.DefinePrim("/SO101_Arm/Shoulder", "Xform")
Usd.ModelAPI(shoulder).SetKind(Kind.Tokens.component)
gripper = stage.DefinePrim("/SO101_Arm/Gripper", "Xform")
Usd.ModelAPI(gripper).SetKind(Kind.Tokens.component)
A flat hierarchy (/mesh_001, /mesh_002...) is hard to navigate and impossible to collaborate on. A hierarchy built around kinds and meaningful names scales to thousands of prims without confusion.
Putting It All Together
OpenUSD Concepts for SO-101:
COMPOSITION (LIVRPS)
├─ Which file wins?
└─ Priority rules
ASSET STRUCTURE
├─ Folder organization
└─ Team collaboration
CONTENT AGGREGATION
├─ Combine layers
└─ Parallel workstreams
CUSTOMIZING USD
├─ Custom schemas
└─ Robotics properties
DATA EXCHANGE
├─ USD ↔ URDF
├─ USD ↔ FBX
└─ Validation
MODULARITY & INSTANCING
├─ Reusable modules
├─ Scenegraph instances
└─ Point instances
DEBUGGING
├─ usdview inspection
└─ Python analysis
DATA MODELING
├─ Hierarchy design
└─ Model kinds
More recent articles
- OpenUSD: Advanced Patterns and Common Gotchas. - 28th March 2026
- Learning OpenUSD — From Curious Questions to Real Understanding - 19th March 2026