Ovstage Integration and Physics Output Read#
ovphysx consumes a caller-owned ovstage Stage as its orchestration surface: the application authors scene edits into ovstage, ovphysx drains committed edits into the running simulation, steps, and the application reads the simulation output back. This page explains how ordinals couple the two directions and — the key principle — how physics avoids consuming the changes it produced.
For how to author the physics content in the stage (scenes, rigid bodies, joints, articulations, deformables, particles), refer to the Simulation Setup pages.
The data surface: ovstage + ordinals#
An ovstage Stage is a versioned, columnar data store. Every committed write lands at an ordinal — a monotonically increasing version number that the application owns and advances. A write floor seals ordinals at or below a value: sealed data never changes and is what reads observe.
ovphysx never advances ordinals or writes to the Stage on its own. It exposes exactly three ordinal-aware operations:
ovphysx call |
Direction |
Ordinal role |
|---|---|---|
|
attach |
|
|
app → physics |
drains committed edits in the |
read API ( |
physics → app |
reads the latest step’s output; the app writes it back at an ordinal it chooses |
Seal population changes before attaching or draining#
ovstage.population.apply_usd_changes() blocks until population work finishes,
but it does not advance the Stage write floor. Seal and wait for the same
ordinal before passing it to ovphysx:
import ovstage
def drain_population_change(stage, physx, ordinal):
ovstage.population.apply_usd_changes(stage, ordinal=ordinal)
stage.advance_write_floor(ordinal=ordinal).wait()
physx.update_from_ovstage(ordinal, ordinal)
The same rule applies to initial population: seal and wait for read_ordinal
before attach_ovstage(). That initial ordinal was then already parsed by the
attach. For a later edit at ordinal 2, drain (2, 2), not (1, 2): including
ordinal 1 replays the initial scene changes instead of applying only the new
delta.
Scene-graph instancing currently has one snapshot caveat: ovstage’s public
prototype/instance topology queries and resolved instance-material data expose
the latest committed state rather than an ordinal range. Finish population
before attach and keep instance topology stable for that attachment; historical
instance topology cannot be reconstructed from read_ordinal.
The key principle: physics must not get its own changes#
Both the application’s control edits (poses, joint targets, gravity, …) and the physics output (simulated poses, velocities, mesh points, …) live in the same ovstage Stage. If physics drained the ordinals where its own output was written, it would re-ingest its last result as if it were a new authored change — corrupting the simulation.
The rule that prevents this is simple and the application enforces it through the
ordinals it passes to ovphysx_update_from_ovstage:
App→physics edits flow through
ovphysx_update_from_ovstage. Physics→app output is written at ordinals thatovphysx_update_from_ovstagenever covers.
Concretely, partition the ordinal line into two interleaved lanes:
Control ordinals — where the application authors edits physics should process. These are the only ordinals ever passed to
ovphysx_update_from_ovstage.Output ordinals — where the application writes the physics output it read back. These are never included in any
ovphysx_update_from_ovstagerange.
Because the two lanes never overlap and output ordinals are excluded from every drain range, physics only ever ingests application-authored control edits — never its own output.
Ordinal timeline#
A typical per-frame schedule, with the scene parsed at ordinal 1:
ordinal: 1 2 3 4 5 6 later
| | | | | |
parse CTRL OUT CTRL OUT CTRL
(attach) (app) (physics) (app) (physics) (app)
| ^ | ^ |
| | | | |
update_from_ovstage(2,2) | update_from_ovstage(4,4) then repeat
| |
app writes physics app writes physics
output here, NOT output here, NOT
drained by physics drained by physics
Frame N: app authors control edits at an even (“CTRL”) ordinal and seals it, passes the single-ordinal range
[ctrl, ctrl]toovphysx_update_from_ovstage, then callsovphysx_step.App reads the output and writes it back at the next odd (“OUT”) ordinal.
Frame N+1:
ovphysx_update_from_ovstageadvances to the next control ordinal only — the intervening output ordinal is skipped, so physics never sees it.
You choose the partitioning scheme (even/odd, fixed stride, two counters — any scheme works) as long as output ordinals are never inside a drain range.
Reading simulation output#
The read API mirrors the ovstage read idiom — open a query over a simulated type, read named attributes, iterate typed column groups, release — so the columns feed straight back into ovstage with no repack. It is ovstage-native: only meaningful while an ovstage Stage is attached.
ovphysx_query(handle, type, scope, &query)
-> ovphysx_fetch_query_result(handle, query, &result) // attribute / total-prim discovery
-> ovphysx_query_shared_dictionary(handle, query, &dict) // ovstage's shared dict: resolve interned tokens / prim lists
-> ovphysx_read(handle, query, attrs, n, &read) // attrs = ovx_string_or_token_t[] (name or token)
-> loop ovphysx_fetch_read_next(handle, read, &group_ptr) // group_ptr is producer-owned (borrowed)
until it returns OVPHYSX_API_END_OF_ITERATION
-> ovphysx_release_group(handle, read, group_ptr->read_group_id)
-> ovphysx_release_read(handle, read)
ovphysx_release_query(handle, query)
The read uses ovstage’s own types directly (no ovphysx mirror): a group is an
ovstage_read_group_t, discovery is an ovstage_query_result_t, and attribute
names are ovx_string_or_token_t (a string name OR an interned token from
discovery). The ovstage-native shape passes through unflattened:
data.tensors/data.tensor_count— a borrowedDLTensorarray. A fixed group has one tensor stacked over its prims (e.g. per-body world transforms); an array group (is_array) has one tensor per prim (e.g. a point-instancer’spositionsor a deformable mesh’spoints). Tuple width is carried intensors[i].dtype.lanes(a vec3 column is lanes=3, shape=[N] — not a trailing shape dim).prims.list/prims.offset/prims.count/prims.index_map— the interned prim set (resolve through the query dictionary, or feedprims.liststraight intoovstage_query_from_path_listfor the write-back).attributeis likewise an interned token.data.index_map/data.mask— sparsity over the outer logical element axis (ovstage’s per-prim/per-tensor axis), never rows inside a single tensor. Point-instancer rigid-body output therefore always emits the instancer’s FULL instance array (by-index,index_map == NULL) for both ALL and ACTIVE scope, so the group forwards intoovstage_query_from_path_listverbatim; ACTIVE scope only selects WHICH instancers are emitted, not a sparse subset of instances.data.cuda_sync— producer stream/event synchronization. Output groups are CPU-backed and emit{0, 0}. With DirectGPU enabled, rigid-body and articulation-link pose and velocity columns are gathered synchronously from live GPU state, and volume and surface deformable points and velocities are copied from live GPU sim-mesh buffers and synchronized before emission; where sleeping is enabled, ALL refreshes sleepers while ACTIVE excludes them. Preserve the field when forwarding a group.is_arrayfollows the source attribute kind (a ragged / USD-array / byte-string column), not whether the per-element dims happen to be uniform — a fixed-width array attribute is stillis_array. When forwarding a group toovstage_write_attribute, setovstage_write_data_t.is_arrayto this value; tensor count and shape do not infer the attribute kind.semantic,ordinal(0 on this path),meta.attribute_write_floor_ordinal, andmeta.layout_generation. The current output producer zero-initializes both metadata fields; rebuild cached queries and layouts explicitly after a known structural edit rather than using them as invalidation or sealing signals.is_deleteis always false (physics output never emits tombstones). The queried object type is NOT on the group — the read is opened over one type, so the caller already knows it.
Ownership & lifetime: the group is producer-owned — ovphysx_fetch_read_next
hands back a borrowed const ovstage_read_group_t*, you do not allocate it. The
struct and every field it points at are valid until the group’s read_group_id is
released through ovphysx_release_group (or ovphysx_release_read); fetching further
groups does NOT invalidate earlier ones, and an intervening ovphysx_step does NOT
either (columns are gathered into session-owned storage at read time).
Output attributes by type#
|
Attributes |
Group shape |
|---|---|---|
|
|
fixed (standalone) + one array group per point-instancer prim (instancer-local) |
|
|
fixed |
|
|
array per joint (one row per unlocked DOF axis) |
|
|
fixed (per wheel-root prim) |
|
|
array per sim-mesh prim (mesh-local) |
|
|
array per particle-set prim |
OVPHYSX_SCOPE_ACTIVE restricts the query to objects the solver moved last step
(single-frame — re-query each step); OVPHYSX_SCOPE_ALL returns every object of
the type.
For OVPHYSX_OBJECT_ARTICULATION_JOINT, the array carries one row per unlocked
reduced-coordinate DOF axis of the joint, in PxArticulationAxis enum order —
independent of whether a JointStateAPI is authored. Angular axes are reported in
degrees and linear (prismatic) axes in the stage’s base length unit; an authored
JointStateAPI overrides the per-axis degree/radian convention where present.
Worked example: one closed-loop frame#
The example drives one frame of the loop above with explicit ordinals, showing the control lane (drained by physics) and the output lane (never drained).
#include <ovphysx/ovphysx.h>
#include <ovstage/ovstage.h> // application owns the ovstage Stage
#include <stdint.h>
typedef void (*author_control_fn)(ovstage_instance_t*, ovstage_ordinal_t);
typedef void (*write_output_fn)(
ovstage_instance_t*, ovstage_ordinal_t, const ovstage_read_group_t*);
void run_closed_loop(
ovphysx_handle_t h,
ovstage_instance_t* stage,
int num_frames,
author_control_fn author_control_edits,
write_output_fn write_output_to_ovstage)
{
// Attach: the scene was authored and sealed at ordinal 1.
ovphysx_attach_ovstage(h, stage, /*read_ordinal=*/1);
ovstage_ordinal_t control_ord = 2; // even lane: app edits physics processes
ovstage_ordinal_t output_ord = 3; // odd lane: physics output (NEVER drained)
for (int frame = 0; frame < num_frames; ++frame)
{
// App authors control edits (e.g. a new kinematic target) at
// `control_ord` and seals them through the ovstage write API.
author_control_edits(stage, control_ord);
// App->physics: drain ONLY the control ordinal (closed range). The
// previous output ordinal is below it but is never named here.
ovstage_ordinal_range_t ctrl = {
control_ord, control_ord, /*has_start_ordinal=*/true
};
ovphysx_update_from_ovstage(h, ctrl);
// Step the simulation.
ovphysx_enqueue_result_t step_result = ovphysx_step(h, 1.0f / 60.0f);
ovphysx_op_wait_result_t step_wait = { 0 };
ovphysx_wait_op(h, step_result.op_index, UINT64_MAX, &step_wait);
ovphysx_destroy_wait_result(&step_wait);
// Physics->app: read the step output.
ovphysx_query_handle_t q;
ovphysx_query(h, OVPHYSX_OBJECT_RIGID_BODY, OVPHYSX_SCOPE_ACTIVE, &q);
// Attribute selectors are names or interned tokens.
const ovx_string_or_token_t names[] = {
{ .token = 0,
.string = { .ptr = OVPHYSX_ATTR_POSITION,
.length = sizeof(OVPHYSX_ATTR_POSITION) - 1 } },
{ .token = 0,
.string = { .ptr = OVPHYSX_ATTR_ORIENTATION,
.length = sizeof(OVPHYSX_ATTR_ORIENTATION) - 1 } },
};
ovphysx_read_handle_t r;
ovphysx_read(h, q, names, 2, &r);
for (;;)
{
const ovstage_read_group_t* g = NULL; // producer-owned borrowed pointer
const ovphysx_result_t fetch_result = ovphysx_fetch_read_next(h, r, &g);
if (fetch_result.status == OVPHYSX_API_END_OF_ITERATION)
break;
if (fetch_result.status != OVPHYSX_API_SUCCESS || !g)
{
ovphysx_release_read(h, r);
ovphysx_release_query(h, q);
return;
}
// App writes the output at `output_ord` and seals it, reusing
// g->prims.list, g->data.tensors, and g->is_array with no repack.
write_output_to_ovstage(stage, output_ord, g);
ovphysx_release_group(h, r, g->read_group_id);
}
ovphysx_release_read(h, r);
ovphysx_release_query(h, q);
// Advance the lanes. The output ordinal is skipped forever.
control_ord += 2;
output_ord += 2;
}
}
The invariant to hold onto: every ordinal passed to
ovphysx_update_from_ovstage is a control ordinal the application authored;
output ordinals are never named in a drain range. That single rule is
what keeps physics from consuming its own output.
Notes#
The read is ovstage-only: it requires an attached ovstage Stage, else
ovphysx_queryfails. A zeroout_querymeans FAILURE only — an empty match is still a valid, nonzero query whose read reaches end-of-iteration immediately (ovphysx_fetch_query_resultthen reportstotal_prim_count == 0).A query is a reusable selector (type + scope), not a captured membership snapshot: matched prims and values are evaluated lazily (count at
ovphysx_fetch_query_result, columns atovphysx_read), each observing the most recently completed step. A step between query and read is fine — the read reflects the newer step.Borrowed lifetime: the producer-owned group and its
tensors/index_map/prim_listare valid until that group’sread_group_idis released throughovphysx_release_group— fetching further groups (or stepping) does not invalidate earlier ones. Copy out anything you must retain past release (writing it back into ovstage within the window is the default path).OVPHYSX_SCOPE_ACTIVEis single-frame; re-open the query each step.
See also: Developer Guide for the async/ordinal execution model, and API Reference for the full C surface.