Runtime Loop#

This guide drives an ovstage scene headlessly — no renderer attached: load a USD scene into the runtime table through population, read prims back to confirm, update the live stage, and re-read. The application owns the ordinal lifecycle throughout: populate at one ordinal, then write updates at higher ordinals, sealing each tick with advance_write_floor.

Note

This walkthrough uses the population capability. The runtime-loop examples are pre-release and provisional (“Draft — API in flux”); the transform-write recipe below uses the omni:xform attribute and MATRIX semantic and is subject to change.

Setup#

import pathlib
import sys

import numpy as np

import ovstage
from ovstage import (AttributeSemantic, DLDataType, DLDataTypeCode, OrdinalRange, OvstageError,
                     PopulationDomain, make_dltensor, population)
#include <ovstage/ovstage.h>
#include <ovstage/ovstage_population.h>
#include <ovx/path_dictionary/path_dictionary.h>
#include <ovx/path_dictionary/path_dictionary_utils.h>
#include <dlpack/dlpack.h>

#include <cmath>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>

Two Update Paths#

After a scene is live there are two ways to change it:

  1. Write into the runtime table at a new ordinal — a direct edit with no USD round-trip (for example, animate a prim’s omni:xform transform over frames).

  2. Edit the USD source with add_usd_reference and propagate it through with apply_usd_changes.

1. Populate#

Load the scene at ordinal 1 and seal it:

# Load a USD file and populate the runtime table in one op, at the
# caller-owned ordinal 1, then seal that ordinal so reads can see it.
# The application owns the ordinal lifecycle; population never opens
# its own.
population.open_usd(
    stage, str(SCENE), ordinal=1, time_code=0.0, domains=PopulationDomain.RENDERING
)
stage.advance_write_floor(ordinal=1).wait()
// One population op loads the USD file into the runtime table at ordinal 1;
// sealing that ordinal makes it readable. An explicit time code keeps later
// USD-change synchronization on the same supported timeline sample;
// RENDERING selects meshes/lights/cameras.
ovstage_population_enqueue_result_t popEnq =
    ovstage_population_open_usd_from_file(stage, ovx_string_t{ scenePath.c_str(), scenePath.size() },
                                          /*ordinal*/ 1, /*time*/ 0.0, OVSTAGE_POPULATION_DOMAIN_RENDERING);
waitPop(stage, popEnq, "open_usd");

ovstage_write_floor_desc_t floor1{};
floor1.ordinal = 1;
floor1.scope = OVSTAGE_SCOPE_ALL;
ovstage_enqueue_result_t enq = ovstage_advance_write_floor(stage, &floor1);
waitOp(stage, enq, "advance_write_floor");

2. Read Back to Confirm#

Prove the populate landed by reading the reserved usd-prim-type metadata over the scene query and resolving each token to its type name:

# Read the reserved usd-prim-type metadata ovstage auto-maintains for
# every populated prim.
print("populated prim types:", ", ".join(_prim_types(stage, paths, scene_query, prim_type, 1)))
// Read the reserved usd-prim-type column over a query and return the resolved
// type names as-is, in stage order -- proof that the prims populated.
// usd-prim-type is one token per prim (is_array=false); the column crosses as
// uint64 token ids resolved back to strings through the path dictionary.
static std::vector<std::string> readPrimTypes(ovstage_instance_t* stage, path_dictionary_instance_t* dict,
                                              ovstage_query_handle_t query, ovx_token_t primType,
                                              ovstage_ordinal_t endOrdinal)
{
    ovstage_ordinal_range_t range{};
    range.end_ordinal = endOrdinal;
    range.has_start_ordinal = false;
    ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
    ovstage_enqueue_result_t enq = ovstage_read_attributes(stage, query, &primType, 1, range, &read);
    waitOp(stage, enq, "read_attributes");

    std::vector<std::string> typeNames;
    for (;;)
    {
        ovstage_read_group_t group{};
        const ovstage_api_status_t fetched = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
        if (fetched == OVSTAGE_ERROR_END_OF_ITERATION)
            break; // the normal end of the group stream
        check(stage, fetched, "fetch_read_next");
        if (group.data.tensor_count == 1 && group.data.tensors[0].data && group.data.tensors[0].ndim > 0)
        {
            const uint64_t* ids = static_cast<const uint64_t*>(group.data.tensors[0].data);
            for (int64_t i = 0; i < group.data.tensors[0].shape[0]; ++i)
            {
                ovx_token_t token = static_cast<ovx_token_t>(ids[i]);
                ovx_string_t name{};
                ovx_api_result_t ovxResult = path_dictionary_get_strings_from_tokens(dict, &token, 1, &name);
                checkOvx(dict, ovxResult, "resolve-type");
                typeNames.push_back(std::string(name.ptr ? name.ptr : "", name.length));
            }
        }
        ovstage_release_group(stage, &group);
    }
    enq = ovstage_release_read(stage, read);
    waitOp(stage, enq, "release_read");
    return typeNames;
}

Note

This example reads back the transform it wrote itself. Reading a populated implementation-defined derived scene transform (omni:fabric:localMatrix / omni:fabric:worldMatrix) back through read_attributes is not part of this validated flow.

3. Update the Table#

Animate a prim by writing omni:xform (a 4×4 matrix, MATRIX semantic) straight into the table over successive ordinals, advancing the floor each step:

# Write the Torus transform into the ovstage table over 24 frames (one
# ordinal per frame), no USD round-trip. omni:xform is a 4x4 matrix
# column -> semantic=MATRIX.
for frame in range(FRAMES):
    ordinal = 2 + frame
    tx = 100.0 * frame / (FRAMES - 1)  # 0 -> 100 across the animation
    stage.write_attribute(
        torus_query, xform, ordinal=ordinal,
        tensors=_translate_matrix(tx, 25.0, 0.0),
        is_array=False, semantic=AttributeSemantic.MATRIX,
    ).wait()
    stage.advance_write_floor(ordinal=ordinal).wait()

# Read back the final frame's transform (our own written column).
# The read is a context manager -- block exit releases its handle even
# when a guard raises -- and the fetched group is released in a finally.
with stage.read_attributes(torus_query, [xform], OrdinalRange.latest(2 + FRAMES - 1)) as read:
    read.wait()
    group = read.fetch_next()
    if group is None:  # fetch_next() returns None when the read yields no group
        raise SystemExit("no data group returned for the final-frame omni:xform read")
    try:
        m = np.asarray(group.array(0)).ravel()  # row-major 4x4
        if m.size != 16:  # single-path query -> exactly one 16-lane matrix element
            raise SystemExit(f"unexpected omni:xform read layout: {m.size} values, expected 16")
        print("final Torus xform translation (row [3][0:3]):", m[12:15])  # -> [100. 25. 0.]
    finally:
        stage.release_group(group)
// Write the Torus transform over 24 frames (one sealed ordinal per frame),
// no USD round-trip. omni:xform is a 4x4 double matrix (row-vector
// convention: translation in elements [12..14]) -> semantic = MATRIX.
// The current implementation stores it as ONE 16-lane element per prim, so the tensor is dtype
// {kDLFloat, 64, 16} / shape={1} -- NOT a 4x4 of lanes=1.
double matrix[16] = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
const int64_t matShape[] = { 1 };
const int64_t matStrides[] = { 1 };
DLTensor matTensor{};
matTensor.data = matrix;
matTensor.device = { kDLCPU, 0 };
matTensor.ndim = 1;
matTensor.dtype = { kDLFloat, 64, 16 };
matTensor.shape = const_cast<int64_t*>(matShape);
matTensor.strides = const_cast<int64_t*>(matStrides);
ovstage_write_data_t xformWrite = writeData(&matTensor, OVSTAGE_SEMANTIC_MATRIX);

matrix[13] = 25.0; // the scene is Y-up: keep the Torus's authored y offset
for (int frame = 0; frame < kFrames; ++frame)
{
    const ovstage_ordinal_t ordinal = static_cast<ovstage_ordinal_t>(2 + frame);
    matrix[12] = 100.0 * frame / (kFrames - 1); // slide along +X: tx 0 -> 100
    enq = ovstage_write_attribute(stage, torusQuery, { xform, {} }, ordinal, xformWrite, OVSTAGE_PRIM_MODE_UPSERT);
    waitOp(stage, enq, "write_attribute");
    ovstage_write_floor_desc_t frameFloor{};
    frameFloor.ordinal = ordinal;
    frameFloor.scope = OVSTAGE_SCOPE_ALL;
    enq = ovstage_advance_write_floor(stage, &frameFloor);
    waitOp(stage, enq, "advance_write_floor");
}

// Read back the final frame's transform (our own written column).
ovstage_ordinal_range_t range{};
range.end_ordinal = static_cast<ovstage_ordinal_t>(2 + kFrames - 1);
range.has_start_ordinal = false;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
enq = ovstage_read_attributes(stage, torusQuery, &xform, 1, range, &read);
waitOp(stage, enq, "read_attributes");
ovstage_read_group_t group{};
status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
check(stage, status, "fetch_read_next");
// Single-path query -> exactly one 16-lane matrix element.
if (group.data.tensor_count != 1 || !group.data.tensors[0].data || group.data.tensors[0].ndim != 1 ||
    group.data.tensors[0].shape[0] != 1 || group.data.tensors[0].dtype.lanes != 16)
{
    std::fprintf(stderr, "unexpected omni:xform read layout\n");
    return EXIT_FAILURE;
}
const double* m = static_cast<const double*>(group.data.tensors[0].data);
std::printf("final Torus xform translation (row [3][0:3]): %.1f %.1f %.1f\n", m[12], m[13], m[14]);
ovstage_release_group(stage, &group);
enq = ovstage_release_read(stage, read);
waitOp(stage, enq, "release_read");

4. Edit the USD Source#

Add a reference onto a prim, propagate it into the runtime table at a fresh ordinal, and re-read the prim types:

# Reference a cube onto a new /World/EditCube in the USD source, then
# propagate the change into the runtime table with apply_usd_changes
# at a fresh ordinal (above the animation's floor). The runtime stage
# now sees a prim that existed only in USD.
usd_ordinal = 2 + FRAMES
population.add_usd_reference_from_string(stage, EDIT_CUBE_USDA, "/World/EditCube")
population.apply_usd_changes(stage, ordinal=usd_ordinal)
stage.advance_write_floor(ordinal=usd_ordinal).wait()

expanded_paths = paths.create_path_list_from_strings(
    ["/World", "/World/Plane", "/World/Torus", "/World/EditCube"])
expanded_query = stage.query_from_path_list(expanded_paths)
print("after USD edit, prim types:",
      ", ".join(_prim_types(stage, paths, expanded_query, prim_type, usd_ordinal)))
// Reference a cube onto a new /World/EditCube in the USD source, then
// propagate the edit into the runtime table with apply_usd_changes at a
// fresh ordinal above the animation's floor. add-reference carries no
// ordinal; apply_usd_changes does.
const ovstage_ordinal_t usdOrdinal = static_cast<ovstage_ordinal_t>(2 + kFrames);
const ovx_string_t editLayer = kEditCubeUsda;
// The optional out-handle is only needed for a later remove_usd_reference;
// this reference lives for the stage's lifetime, so pass NULL (as the
// Python sibling does by discarding the returned handle).
popEnq = ovstage_population_add_usd_reference_from_string(stage, editLayer,
                                                          literal_to_ovx_string("/World/EditCube"), nullptr);
waitPop(stage, popEnq, "add_usd_reference");
popEnq = ovstage_population_apply_usd_changes(stage, usdOrdinal);
waitPop(stage, popEnq, "apply_usd_changes");

ovstage_write_floor_desc_t usdFloor{};
usdFloor.ordinal = usdOrdinal;
usdFloor.scope = OVSTAGE_SCOPE_ALL;
enq = ovstage_advance_write_floor(stage, &usdFloor);
waitOp(stage, enq, "advance_write_floor");

const ovx_string_t expanded[] = { literal_to_ovx_string("/World"), literal_to_ovx_string("/World/Plane"),
                                  literal_to_ovx_string("/World/Torus"),
                                  literal_to_ovx_string("/World/EditCube") };
ovx_primpath_list_t expandedPaths = OVX_INVALID_PRIMPATH_LIST;
ovxResult = path_dictionary_create_path_list_from_strings(dict, expanded, 4, &expandedPaths);
checkOvx(dict, ovxResult, "create-path-list");
ovstage_query_handle_t expandedQuery = OVSTAGE_INVALID_QUERY_HANDLE;
status = ovstage_query_from_path_list(stage, expandedPaths, &expandedQuery);
check(stage, status, "query_from_path_list");
const std::vector<std::string> editedTypes = readPrimTypes(stage, dict, expandedQuery, primType, usdOrdinal);

// Example plumbing: print the type names on one line; the Cube is the propagated edit.
std::printf("after USD edit, prim types:");
for (const std::string& name : editedTypes)
    std::printf(" %s", name.c_str());
std::printf("\n");

Expected Output#

populated prim types: Xform, Mesh, Mesh
final Torus xform translation (row [3][0:3]): [100.  25.   0.]
after USD edit, prim types: Xform, Mesh, Mesh, Cube
populated prim types: Xform Mesh Mesh
final Torus xform translation (row [3][0:3]): 100.0 25.0 0.0
after USD edit, prim types: Xform Mesh Mesh Cube

Where to Go Next#