Akshay Parkhi's Weblog

Subscribe

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 base
  • shoulder.usda — shoulder joint
  • elbow.usda — elbow joint
  • gripper.usda — end effector
  • materials.usda — metal textures
  • physics.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:

LetterArcStrengthSO-101 Example
LLocal opinionsStrongestDirect edits in your final so101_arm.usda
IInheritsAll joints inherit from a RoboticJoint class with default torque limits
VVariantSetsGripper variants: parallel_jaw, suction_cup, magnetic
RReferencesReference gripper.usda into your arm assembly
PPayloadsHigh-poly collision mesh loaded only when needed
SSublayersWeakestStack 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:

TypeUse caseAnalogy
ModularityReusable components referenced by multiple assetsLEGO blocks
Scenegraph InstancingDozens to hundreds of complex objectsPhotocopies of a document
Point InstancingThousands of simple objectsRubber 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:

KindUseSO-101 Example
assemblyTop-level collectionComplete SO-101 arm
componentFunctional unitShoulder, elbow, gripper
groupOrganizational groupingAll robots in warehouse
subcomponentPart of a componentGripper 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

This is OpenUSD Mastery: From Composition to Pipeline — A SO-101 Arm Journey by Akshay Parkhi, posted on 25th March 2026.

Next: OpenUSD: Advanced Patterns and Common Gotchas.

Previous: Learning OpenUSD — From Curious Questions to Real Understanding