Sensor Scheduling#

Sensors can capture at different rates within one simulation. You configure each sensor’s capture rate, then step the simulation at a cadence that suits your workflow. The renderer schedules captures and returns the outputs that are ready. Your loop does not need to track which sensor is due.

Cameras are unscheduled by default: they render on each renderer step rather than at an independent capture rate. To schedule a camera, apply OmniSensorAPI to its USD Camera prim and set omni:sensor:frameRate to a positive rate. Applying the API alone leaves the rate unset. Camera sensor-model schemas already include this API; non-visual sensor model schemas include it and supply a positive default rate.

The Radar generic wpmDmat model and the Acoustic generic wpm model declare no delivery policy of their own. They take their scan rate from the same omni:sensor:frameRate attribute. What a radar step that fires no scan delivers is on the Outputs page of the Radar generic wpmDmat model; the Outputs page of the Acoustic generic wpm model describes its frames, each one complete firing cycle. The Generic wpm model publishes a complete selected pattern per capture and exposes no partial-output policy. Its firing offsets affect motion sampling within that capture; they do not split a scan over later frames.

Camera and lidar outputs at different capture rates during a simulation stepped at 40 Hz

Two cameras and two lidars running at different capture rates in the Python example. The simulation advances in 25 ms steps and requests all four sensors on every step. With partial outputs disabled, each sensor returns only completed captures; blank cells mark steps with no output. Lidar points are projected from the rolling-shutter camera’s viewpoint.#

Frame rate, step and delivery#

The simulation step, a sensor’s capture period and output delivery are three separate things:

  • delta_time advances the common sensor simulation clock, in seconds.

  • omni:sensor:frameRate schedules each sensor independently, in rational Hz.

  • omni:sensor:Core:partialOutputs controls whether an unfinished capture can produce intermediate outputs.

A positive omni:sensor:frameRate schedules captures at that rate, independently of how often you step. omni:sensor:startTime sets the start offset. See Shared attributes for defaults.

Without a positive rate:

  • A camera is unscheduled and renders one frame per step. delta_time alone does not establish a fixed capture rate.

  • Non-visual sensors retain a default 30 Hz capture schedule. Each capture spans 1/30 s; a step that advances time delivers the captures within it. Every non-visual schema supplies a positive rate, so this fallback requires you to author (0, 1) explicitly.

The non-visual models handle those captures differently:

For example, a 20 Hz scanning lidar needs two 1/40 s steps to complete one scan. Keep it selected during both steps, including the first step, which delivers no completed output. A 40 Hz lidar completes a scan on every step.

Eight simulation steps aligned with 40 Hz and 20 Hz lidar scans, comparing complete-scan and partial-scan output delivery

Simulation steps advance a shared clock. Each lidar captures at its own configured rate; partialOutputs changes when data is returned without changing those capture periods.#

Capture rate and output delivery#

Author the rate and the output policy on the sensor prim, not on its RenderProduct. Give each sensor its own RenderProduct, as Output Configuration describes under “Independent RenderProducts”. No single default of the delivery policy holds for every model, so the table lists, per model, where the controls are declared and what they default to:

Sensor

USD declaration

Behavior

Every sensor

double2 omni:sensor:frameRate = (0, 1) in OmniSensorAPI

A positive numerator/denominator sets the capture frequency in Hz; each model’s default is under “Shared attributes” on Sensor Configuration.

Lidar generic core model

bool omni:sensor:Core:partialOutputs = false in OmniSensorGenericLidarCoreAPI

What false and true deliver is under Partial outputs below. Its interaction with instantLidar and includeInvalidPoints, and that it takes a live edit, are on the model’s USD attributes page.

Lidar generic wpm model

bool omni:sensor:LidarWpm:partialOutputs = false in OmniSensorGenericLidarWpmAPI

The model declares the policy in its own namespace; its USD attributes page describes it.

Generic wpm model

No partial-output attribute.

Delivers the complete selected pattern in each capture. Firing offsets select motion samples within it; see Outputs.

Cameras

No schema declares the attribute on a Camera prim.

ovrtx looks for omni:sensor:Core:partialOutputs, with a capital C, on the camera prim. Without a value, the camera behaves as true. A camera takes the value through the runtime-stage write shown in Partial outputs.

The following lidar captures forty scans per simulated second and delivers only completed scans; the Python tab authors the same attributes with OpenUSD:

#usda 1.0
def OmniLidar "Lidar40" (
    prepend apiSchemas = ["OmniSensorGenericLidarCoreAPI"]
)
{
    # A full scan completes in each 1/40-second simulation step.
    double2 omni:sensor:frameRate = (40, 1)
    bool omni:sensor:Core:partialOutputs = false
    bool omni:sensor:Core:includeInvalidPoints = false
    token omni:sensor:Core:elementsCoordsType = "CARTESIAN"
    # World-space points let both scans share the camera's viewpoint.
    token omni:sensor:Core:outputFrameOfReference = "WORLD"
    # Keep the emitter firing sequence shorter than each angular tick.
    float omni:sensor:Core:rotPatternFiringResDeg = 1
    double3 xformOp:translate = (-150, 150, 300)
    uniform token[] xformOpOrder = ["xformOp:translate"]
}
from pxr import Gf, Sdf, UsdGeom

lidar = stage.DefinePrim("/World/Lidar40", "OmniLidar")
lidar.SetMetadata("apiSchemas", Sdf.TokenListOp.Create(prependedItems=["OmniSensorGenericLidarCoreAPI"]))
# Rational Hz: numerator / denominator. Each scan takes one 1/40-second step.
lidar.CreateAttribute("omni:sensor:frameRate", Sdf.ValueTypeNames.Double2).Set(Gf.Vec2d(40, 1))
lidar.CreateAttribute("omni:sensor:Core:partialOutputs", Sdf.ValueTypeNames.Bool).Set(False)
lidar.CreateAttribute("omni:sensor:Core:includeInvalidPoints", Sdf.ValueTypeNames.Bool).Set(False)
lidar.CreateAttribute("omni:sensor:Core:elementsCoordsType", Sdf.ValueTypeNames.Token).Set("CARTESIAN")
# World-space points let both scans share the camera's viewpoint.
lidar.CreateAttribute("omni:sensor:Core:outputFrameOfReference", Sdf.ValueTypeNames.Token).Set("WORLD")
lidar.CreateAttribute("omni:sensor:Core:rotPatternFiringResDeg", Sdf.ValueTypeNames.Float).Set(1)
UsdGeom.Xformable(lidar).AddTranslateOp().Set(Gf.Vec3d(-150, 150, 300))

Apply OmniSensorAPI and the same frameRate attribute for a scheduled camera. For a rolling-shutter camera, also configure the exposure timing. This 20 Hz camera has a 50 ms exposure span at 240 rows: 239 / 9560 + 1 / 40 = 1 / 20 second, so each exposure spans exactly two 25 ms simulation steps:

#usda 1.0
def Camera "Camera20" (
    prepend apiSchemas = ["OmniSensorAPI", "OmniRtxRollingShutterAPI"]
)
{
    double2 omni:sensor:frameRate = (20, 1)
    float focalLength = 50
    float horizontalAperture = 20.955
    # At 240 rows, 239 / 9560 + 1 / 40 = 1 / 20 second.
    # Each exposure spans exactly two 25 ms simulation steps.
    double2 omni:rtx:rollingShutter:lineExposure = (1, 40)
    double2 omni:rtx:rollingShutter:lineOffset = (1, 9560)
    token omni:rtx:rollingShutter:scanOrder = "topToBottom"
    float3 xformOp:rotateXYZ = (-20, 0, 0)
    double3 xformOp:translate = (0, 350, 1000)
    uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ"]
}
from pxr import Gf, Sdf, UsdGeom

camera = UsdGeom.Camera.Define(stage, "/World/Camera20")
sensor = camera.GetPrim()
sensor.SetMetadata(
    "apiSchemas", Sdf.TokenListOp.Create(prependedItems=["OmniSensorAPI", "OmniRtxRollingShutterAPI"])
)
sensor.CreateAttribute("omni:sensor:frameRate", Sdf.ValueTypeNames.Double2).Set(Gf.Vec2d(20, 1))
# At 240 rows, 239 / 9560 + 1 / 40 = 1 / 20 second: two 25 ms steps.
sensor.CreateAttribute("omni:rtx:rollingShutter:lineExposure", Sdf.ValueTypeNames.Double2).Set(Gf.Vec2d(1, 40))
sensor.CreateAttribute("omni:rtx:rollingShutter:lineOffset", Sdf.ValueTypeNames.Double2).Set(Gf.Vec2d(1, 9560))
sensor.CreateAttribute("omni:rtx:rollingShutter:scanOrder", Sdf.ValueTypeNames.Token).Set("topToBottom")
camera.AddTranslateOp().Set(Gf.Vec3d(0, 350, 1000))
camera.AddRotateXYZOp().Set(Gf.Vec3f(-20, 0, 0))

The exposure span a scheduled camera may use and the timing attributes behind it are on Rolling Shutter. A rolling-shutter camera also needs the motion BVH; Motion BVH on Output Configuration gives the modes and which sensors need which.

Step every RenderProduct on every step#

The RenderProduct input set identifies the sensors to simulate. Request every sensor in every step, and ovrtx returns the results that are ready on that step. Omitting a sensor discards its accumulated capture and invokes tear-down and reinitialization costs when it is requested again.

The snippets show one step of the example’s loop. The Python tab advances the scene’s USD animation to the step’s simulation time with ovstage.population.update_from_usd_time, publishes that state at a new ordinal, then steps all four products by 25 ms. The USD animation clock and the sensor clock advance together here, but they are separate things. The C tab does the same with ovstage_population_apply_usd_time and ovrtx_step_with_stage.

import ovstage

# Update scene animation to the next 25 ms simulation time.
ordinal += 1
simulation_time = (step_index + 1) / 40
ovstage.population.update_from_usd_time(stage, ordinal, simulation_time)
# Make the scene updates available to the renderer.
stage.advance_write_floor(ordinal, ovstage.Scope.ALL).wait()
# Advance every sensor by 25 ms, including those with no output this step.
# Capture rates and partialOutputs determine when outputs are returned.
# Keep all four sensors requested to avoid teardown and reinitialization.
products = renderer.step(
    render_products={
        "/Render/Lidar40",
        "/Render/Lidar20",
        "/Render/Camera10",
        "/Render/Camera20",
    },
    delta_time=1 / 40,
    ordinal=ordinal,
)
// Publish animated scene state before advancing the sensor clock.
++ordinal;
const double simulation_time = (step_index + 1) / 40.0;
const auto population = ovstage_population_apply_usd_time(stage_, ordinal, simulation_time);
ASSERT_EQ(population.status, OVSTAGE_OK);
docs_wait_ovstage_population_no_errors(stage_, population.op_index);
docs_ovstage_advance_write_floor(stage_, ordinal);

// Keep slow sensors selected even on steps with no completed output.
ovx_string_t paths[] = {
    ovx_str("/Render/Lidar40"), ovx_str("/Render/Lidar20"),
    ovx_str("/Render/Camera10"), ovx_str("/Render/Camera20"),
};
ovrtx_render_product_set_t products{};
products.render_products = paths;
products.num_render_products = 4;
ovrtx_step_result_handle_t step_handle{};
const auto operation = ovrtx_step_with_stage(
    renderer_, products, 1.0 / 40.0, ordinal, &step_handle);
ASSERT_API_SUCCESS(operation.status);

For attached-stage ordering and ordinals, see ovstage Integration under “Ordinals and Write-Floor Gates”. For fetching results and their lifetime, in C in particular, see Sensor Outputs under “Frames in a step result” and “Lifetime and ownership”.

Partial outputs#

When an exposure or scan spans several simulation steps, you can choose to receive only completed captures or also receive intermediate outputs while the capture is in progress. Cameras, the Lidar generic core model and the Lidar generic wpm model support this choice through partialOutputs. Radar and acoustic models do not expose this control.

With partialOutputs = false, a capture spanning several steps accumulates until it is complete. This is the right policy when a consumer needs a whole lidar scan or a completed camera exposure. It suppresses intermediate delivery; it does not pause the sensor’s simulation or remove the work of accumulating the capture.

With partialOutputs = true, a step can also deliver partial captures, so the output rate can exceed the authored capture rate. For a lidar, consume the delivered portion of the scan. Camera RenderVars differ in what an intermediate frame carries: the color outputs LdrColor and HdrColor of the camera output tables repeat the last completed image while an exposure is unfinished, and the frame that completes the exposure carries the newly completed image. Rolling Shutter tabulates the intermediate behavior of the RenderVars it lists. Nothing in a delivered image marks it as a repeat or as newly completed; the frame’s capture window does. Use partialOutputs = false when the consumer needs only newly completed images.

Cameras and lidar core use omni:sensor:Core:partialOutputs; lidar wpm uses omni:sensor:LidarWpm:partialOutputs. Both lidar models default to false; cameras default to true. The lidar schemas declare their attributes, so you can author them in USD.

For cameras, set omni:sensor:Core:partialOutputs through ovstage after loading the scene, preferably before the first render step.

On a Camera, authoring this attribute in USD alone has no effect by default: no supported camera schema declares it, so population omits it from the runtime stage and the policy remains true. Either write it through ovstage, as below, or set customLayerData.populateAllAuthoredAttributes = true on the root layer. See Loading USD, “Authored Attribute Population”.

The renderer reads the camera policy on every step. A later write therefore takes effect on the next step; writing before rendering is a recommendation, not a restriction. The Lidar generic core model also accepts live edits to partialOutputs. See Runtime Parameter Updates and its attributes.

The snippets apply one policy to all four sensors of the example. Both write one boolean to the four sensor prims through an ovstage query at a new ordinal, then advance the write floor so that the renderer sees the write:

import numpy as np
import ovstage

# Set the delivery policy on all four sensors before the first step.
# False returns completed captures only; True also allows intermediate outputs.
ordinal += 1
with ovstage.PathDictionary(stage) as paths:
    path_list = paths.create_path_list_from_strings(
        [
            "/World/Lidar40",
            "/World/Lidar20",
            "/World/Camera10",
            "/World/Camera20",
        ]
    )
    with stage.query_from_path_list(path_list) as query:
        stage.write_attribute(
            query,
            "omni:sensor:Core:partialOutputs",
            ordinal=ordinal,
            tensors=np.full(4, args.partial_outputs, dtype=np.bool_),
            is_array=False,
        ).wait()
    paths.destroy_path_list(path_list)
stage.advance_write_floor(ordinal, ovstage.Scope.ALL).wait()
++ordinal;
auto* dictionary = ovstage_get_path_dictionary(stage_);
const ovx_string_t sensor_paths[] = {
    ovx_str("/World/Lidar40"), ovx_str("/World/Lidar20"),
    ovx_str("/World/Camera10"), ovx_str("/World/Camera20"),
};
ovx_primpath_list_t path_list = OVX_INVALID_PRIMPATH_LIST;
const auto path_result = path_dictionary_create_path_list_from_strings(
    dictionary, sensor_paths, 4, &path_list);
ASSERT_EQ(path_result.status, OVX_API_SUCCESS);
ovstage_query_handle_t query = OVSTAGE_INVALID_QUERY_HANDLE;
ASSERT_EQ(ovstage_query_from_path_list(stage_, path_list, &query), OVSTAGE_OK);

// Deliver only completed captures here. Use true to also deliver partial outputs.
bool partial_outputs[] = {false, false, false, false};
int64_t shape[] = {4};
DLTensor tensor{};
tensor.data = partial_outputs;
tensor.device = {kDLCPU, 0};
tensor.ndim = 1;
tensor.dtype = {kDLBool, 8, 1};
tensor.shape = shape;
ovstage_write_data_t data{};
data.tensors = &tensor;
data.tensor_count = 1;
data.is_array = false;
const ovx_token_or_string_t attribute{
    0, ovx_str("omni:sensor:Core:partialOutputs")
};
const auto write = ovstage_write_attribute(
    stage_, query, attribute, ordinal, data, OVSTAGE_PRIM_MODE_UPSERT);
ASSERT_EQ(write.status, OVSTAGE_OK);
docs_wait_ovstage_no_errors(stage_, write.op_index);
const auto release = ovstage_release_query(stage_, query);
ASSERT_EQ(release.status, OVSTAGE_OK);
docs_wait_ovstage_no_errors(stage_, release.op_index);
path_dictionary_release_path_list_reference(dictionary, path_list);
docs_ovstage_advance_write_floor(stage_, ordinal);

Process only the output that arrived: iterate every returned product and all of its frames, and place data in time with the frame’s capture window and the model’s params, as Sensor Outputs describes under “Frames in a step result”. For a lidar, bound every read by Counts[0] (Delivered entry count); a zero count is not a reason to drop the lidar from the next step (Step every RenderProduct on every step). The frame counters and timestamps a model publishes among its output params follow that model’s rules; for a point cloud they are on the Outputs pages of the Lidar generic core model and the Radar generic wpmDmat model.

Observe a paused simulation#

A camera with no positive omni:sensor:frameRate and no long exposure can act as an interactive observer of the same scene. After publishing the observer’s transform at a new ordinal, step with delta_time = 0; zero is a valid step length. This refreshes the view without advancing the common sensor clock. Do not advance object animation on these view-only updates.

Keep both the observer and the scheduled sensor products in the input set. Use partialOutputs = false on the simulated camera and hold its last completed image between captures. Stage ordinals order scene writes and renderer reads; they are independent of elapsed simulation time, as ovstage Integration explains under “Ordinals and Write-Floor Gates”.

Start this pattern with a positive simulation step. A zero-length step whose end falls exactly on a scheduled sensor’s initial omni:sensor:startTime reaches that capture time, and a sensor whose capture is instantaneous captures there; a zero-length step is therefore not a sensor-free warm-up at that boundary.

The C: Simulated Camera and Observer example combines a 30 Hz top-down sensor with a Vulkan observer, pause and resume controls, and a retained sensor inset.