Object Handling#
Objects in a facility change carriers over time: parts on robots, pallets on automated guided vehicles (AGVs), bottles on conveyors. This guide describes how to represent those carrier changes in OpenUSD (Universal Scene Description), what each representation costs, and how to evaluate them at runtime.
Sample Implementation#
Repository location: VFI Object Handling Scripts (GitHub)
The samples generate a scene, evaluate it, and drive it in the viewport. The sample scene has 1,000 instances that ride one animated carrier, transfer one at a time to a second carrier, and ride the second carrier away, keeping their prim paths throughout.
Component |
Purpose |
|---|---|
Generates the scene, in carrier and baked form |
|
Evaluates the arrays offline, bakes them, or verifies them |
|
Attaches instances to a different carrier |
|
Extension driving the instancer from the timeline |
Generate a stage with create_carrier_scene.py, then open it in an Omniverse Kit
app with the extension enabled. The stage has its own camera at
/Factory/DemoCamera. Click Play.
The console reports the instance count and the result of reading the positions and orientations back out of Fabric, the runtime data store the renderer reads. A successful read-back confirms that the extension is driving the stage.
Problem Statement#
The prim hierarchy is the only mechanism for spatial parenting in OpenUSD. When an ancestor moves, its descendants move with it. A prim’s path, and therefore its parent, is the same at every time code. That stability is what makes caching, composition, and deterministic playback work.
As a result, a prim cannot change parents over time. You cannot author “this bottle is parented to the robot through frame 100 and to the AGV from frame 101”.
UsdNamespaceEditor and relocates can reparent a prim, but they are authoring
operations. They are not animatable and cannot be triggered at runtime.
Two workarounds are common: duplicating the object under every carrier and toggling visibility, and baking world-space transforms at every time sample. Neither scales to facility size, and visibility toggling is the one to avoid outright, for the reasons under Approaches to Avoid. The carrier pattern stores neither.
Solution#
Represent transported objects as instances of a UsdGeomPointInstancer and
record which carrier each instance is on, rather than where it is.
That takes two pieces, and the rest of this guide follows the split.
Authoring / OpenUSD. Custom attributes on the instancer, naming the carriers, which carrier each instance is attached to at each time code, and each instance’s offset and orientation in that carrier’s space. They are custom attributes rather than part of an OpenUSD schema, and the renderer does not read them.
Runtime / Fabric. A Kit extension that reads those custom attributes each frame, composes each instance’s world position and orientation from its offset and the transform of the carrier it is on, and writes the result into Fabric as
positions,orientationsfandorientations. Those are the attributes the renderer reads.
No world position is stored in the stage. The extension computes one every frame.
2. Runtime / Fabric#
Authoring and evaluation produce a correct stage. Rendering it takes one more step.
The renderer reads Fabric, not USD. Authoring positions into USD every frame
is slow, and the values do not reach the renderer directly. Compute the arrays,
then write them into Fabric.
Write Persistence#
A carrier-driven instancer holds static USD data. Its positions and orientations come from the carrier’s transform, not from authored values, so no USD attribute changes between frames.
The Fabric Scene Delegate re-populates a prim only when its USD data changed since the last frame. A carrier-driven prim does not change during playback, so the delegate leaves it alone and the values written each frame survive to render time.
Evaluating One Frame#
Discover the instancers by traversing USD, and subscribe to the application update stream unconditionally. Then, for each instancer:
import numpy as np
import usdrt
from pxr import UsdGeom
def quat_multiply(a, b):
# Hamilton product of two (N, 4) arrays of (w, x, y, z) quaternions.
aw, ax, ay, az = a[:, 0], a[:, 1], a[:, 2], a[:, 3]
bw, bx, by, bz = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
return np.stack([
aw * bw - ax * bx - ay * by - az * bz,
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
], axis=1)
def write_frame(stage, rt_stage, instancer, frame):
prim = instancer.GetPrim()
indices = np.array(prim.GetAttribute("instanceCarriers").Get(frame), dtype=np.int32)
offsets = np.array(prim.GetAttribute("instanceOffsets").Get(frame), dtype=np.float32)
base = np.array(prim.GetAttribute("instanceOrientations").Get(frame), dtype=np.float32)
# Each carrier's transform in the instancer's space, in relationship
# order. positions and orientations are in the instancer's own space and
# the renderer applies the instancer's local-to-world on top, so a
# transform on a shared ancestor lands twice unless it is divided out
# here.
cache = UsdGeom.XformCache(frame)
to_instancer = cache.GetLocalToWorldTransform(prim).GetInverse()
carriers = [cache.GetLocalToWorldTransform(stage.GetPrimAtPath(target)) * to_instancer
for target in prim.GetRelationship("carriers").GetTargets()]
mats = np.array([[[m[r][c] for c in range(4)] for r in range(4)]
for m in carriers], dtype=np.float32)
rotations, translations = mats[:, :3, :3], mats[:, 3, :3]
# USD composes row-vector first, so p' = p * R + t. Composing only the
# carrier's translation leaves instances behind when a carrier rotates.
positions = (np.einsum("ni,nij->nj", offsets, rotations[indices])
+ translations[indices])
# The NumPy view of a quaternion array is (i, j, k, real) while the math
# here is (w, x, y, z), so both boundaries reorder. The carrier's rotation
# is applied after the instance's own.
carrier_q = np.array([[q.GetReal(), *q.GetImaginary()]
for q in (m.ExtractRotationQuat() for m in carriers)],
dtype=np.float32)
orientations = quat_multiply(carrier_q[indices], base[:, [3, 0, 1, 2]])
rt_prim = rt_stage.GetPrimAtPath(str(prim.GetPath()))
rt_prim.GetAttribute("positions").Set(
usdrt.Vt.Vec3fArray(np.ascontiguousarray(positions, dtype=np.float32)))
xyzw = orientations[:, [1, 2, 3, 0]]
rt_prim.GetAttribute("orientationsf").Set(
usdrt.Vt.QuatfArray(np.ascontiguousarray(xyzw, dtype=np.float32)))
# The array every current release renders. orientationsf is written
# alongside it for releases that begin reading it.
rt_prim.GetAttribute("orientations").Set(
usdrt.Vt.QuathArray(np.ascontiguousarray(xyzw, dtype=np.float16)))
Two properties of the stage are required:
Keep the carrier’s transform rigid.
Offsets compose with the carrier’s whole transform, so a scale on the carrier
scales the instances with it. Put visual geometry that needs scaling on a child
prim, below the carrier. The instancer’s own transform is divided out of the
result, so keep the instancer’s transform rigid too.
Author the position and orientation attributes in USD.
Fabric mirrors the attributes the stage has. An attribute absent from USD is
absent from the Fabric prim, and the write is a no-op. Placeholder values are
enough; the runtime overwrites them.
Verifying the Write#
Write once, read positions and orientationsf back out of Fabric, and
compare them against what was written. A read-back is a few lines of code, and it
shows that the values reached Fabric.
Approaches to Avoid#
Two representations express carrier-attached objects without computing anything at runtime. Neither scales to facility size.
Visibility toggling
Add the object under every carrier it can attach to, as a reference or an
instance, and toggle visibility at each handoff. Avoid this approach. It
increases both storage and runtime cost:
Prim count multiplies by carrier count, which raises the complexity of the whole stage.
Hidden prims are still animated and evaluated, so the work happens whether or not the object is visible.
The visibility change itself is costly. On a deep subtree, it is slow enough to be visible as geometry loading.
Identity splits across the duplicates, so the object is no longer a single persistent object.
Authoring those duplicates as copies, not as references or instances, compounds every one of those costs.
Baked world-space transforms
Author absolute transforms at every time sample. Playback computes nothing, so
reading them back is fast. The carrier relationship is discarded, so any edit to
a carrier’s motion means re-baking, and file size grows with object count
multiplied by frame count.
Performance#
Array Operations#
Compute positions and orientationsf with array operations, and convert
the results to the runtime array types by passing their buffers directly to the
array constructors. Neither step should iterate instances in Python.
Measured over 200 frames, streaming positions and both orientation arrays to Fabric:
Instances |
Processor |
Compute (ms) |
Write (ms) |
Total (ms) |
|---|---|---|---|---|
1,000 |
CPU |
0.488 |
0.155 |
0.642 |
10,000 |
CPU |
1.199 |
0.423 |
1.622 |
100,000 |
CPU |
7.309 |
2.730 |
10.039 |
100,000 |
GPU |
3.222 |
2.768 |
5.990 |
Per-instance cost falls as the count rises, because reading the carrier transforms costs the same at any instance count. Reading the instance arrays and the carrier transforms is the larger half of the total. The arithmetic and the write are both small.
A GPU kernel is worth using above roughly 50,000 instances. Below that, the host-to-device copy costs more than the arithmetic it replaces, and the vectorized CPU path is faster.
The conversion is where an implementation most easily loses that speed. Building the runtime array element by element costs 3.8 ms at 1,000 instances, compared with 0.004 ms for passing the buffer to its constructor.
These are the costs of computing the arrays and writing them. Frame time also includes rendering the instances and everything else the application does, and is not measured here.
Storage#
Two carriers over 200 frames, each instance transferring at its own time. The baked stage holds every instance position and orientation at every frame:
Instances |
Carrier pattern |
Baked |
Ratio |
|---|---|---|---|
1,000 |
690 KB |
9.2 MB |
13.7 times smaller |
10,000 |
6.5 MB |
91.5 MB |
14.1 times smaller |
The three representations scale differently:
Representation |
Storage scales with |
|---|---|
Visibility toggling |
objects × carriers × frames |
Baked |
objects × frames |
Carrier pattern |
objects × distinct handoff times |
Because array attributes are dense (see
Local Offsets), authoring
instanceCarriers at a time code stores every element, even when one instance
changed. Storage scales with the number of distinct handoff times, not the
number of handoffs.
The figures above are the worst case: every instance transfers at its own time,
so the attribute holds a time sample at all 200 frames and the only saving is the
width of an int against a point3f and a quatf. Lengthening the
animation grows both stages together. Where handoffs cluster onto a few time
codes, storage stops tracking the frame count and the gap widens.
Scaling#
Runtime cost grows with instance count, sub-linearly at the sizes measured, and is independent of animation length. Storage cost is linear in instances multiplied by distinct handoff times. Neither depends on carrier count.
Limitations#
Scale
This was tested with up to 10,000 instances.
Relationships are not time-sampled
Relationship targets cannot vary over time, which is why the pattern pairs static
targets with an animated index.
Multi-carrier attachment
This guide does not cover an instance attached to two carriers at once.
Instance addressability
The pattern preserves per-instance identity in the data. Selecting an instance in
the Viewport still requires editor tooling.