Akshay Parkhi's Weblog

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 is a universal language that describes complex scenes, their relationships, and their properties. Think of it as JSON for 3D worlds, but one that can do infinitely more.

This guide explains key OpenUSD concepts with 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

When five teams work on SO-101 without structure, files overwrite 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 — joint transforms are public. Internal mesh topology is private
  • Encapsulation — the gripper hides its internals and exposes only the “open/close” interface
  • Parallel Workstreams — each team has its own layer, with 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, the teams work like this:

  • Modeling team works Monday
  • Materials team works Tuesday
  • Rigging team works Wednesday
  • Physics team works Thursday

Everything combines automatically in so101_arm.usd on Friday with no conflicts.

3. Custom Schemas — Extending USD for Robotics

Built-in USD has Xform, Mesh, and Material. It has nothing for robotics. You need joint torque limits, motor controller IDs, safety zones, and 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 five tools:

  • Maya (modeling)
  • Blender (animation)
  • Isaac Sim (simulation)
  • ROS2 (robot control, which needs URDF)
  • Unity (visualization, which needs FBX)

Without USD you would create 5 versions manually. With USD you create the arm once and convert it 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. If you copy the geometry 1000 times, you get a 10 GB file that takes 5 minutes to load. If you create one prototype and instance it 1000 times, you get a 10 MB file that takes 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 — inspect the gripper in usdview:

  1. Open usdview.
  2. Go to Tools → Composition.
  3. Select the gripper prim.
  4. Search for a missing reference path, an 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 reduce 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

More recent articles

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