Your First Lidar Sensor#

Configure a lidar, render a scan and read its point cloud using the Lidar generic core model. The code comes from the runnable Python and C examples.

A sensor needs a USD prim that defines its pose and settings, plus a RenderProduct that requests its outputs. The workflow is:

  1. Create the sensor prim and apply the schema that selects its model.

  2. Override the attribute values you need.

  3. Configure the output: a RenderProduct and a RenderVar.

  4. Create the renderer.

  5. Load the scene.

  6. Warm up and step.

  7. Read the output.

  8. Display the output.

Radar, acoustic sensors and cameras use the same workflow with different prim types, model selection and outputs. See Sensor Configuration.

You need ovrtx and ovstage installed; refer to Getting Started in Python or Getting Started in C.

Sensor components#

Component

Role

Sensor prim

A USD prim containing the sensor’s pose and settings: Camera, OmniLidar, OmniRadar or OmniAcoustic.

Sensor model

The simulation that interprets the settings and produces outputs. Non-visual models use the prim type and applied API schema. Camera models are selected by the requested outputs. See Sensor Configuration.

RenderProduct

Binds the sensor through rel camera, lists outputs in rel orderedVars and holds render settings. Cameras and the Lidar generic wpm model can share a product using tiles.

RenderVar

Selects an output by sourceName: for example, LdrColor for an image, PointCloud for lidar or radar, or SignalWays for acoustic.

Channel

A named tensor inside a composite output, such as Coordinates or Counts. See Output Configuration.

For a point cloud:

  • Request each channel you need. Counts and Flags are always delivered; other channels are produced only when requested.

  • Read only the entries indicated by Counts. A tensor’s shape describes its capacity, not the number of returns in this frame.

  • If invalid points are included, check the VALID bit (0x40) in Flags before using an entry. See Sensor Outputs.

The lidar prim and PointCloud RenderVar linked by a RenderProduct, followed by the step result, frame, output and mapped channels.

Configure the sensor and requested output in USD. Then step the RenderProduct, find the output by its RenderVar path and map its tensors.#

The scene#

Both examples use lidar_example.usda, a Z-up scene measured in meters:

  • Lidar at (0, 0, 1), looking along world +X.

  • Ground plane from -200 to 200 m along both X and Y.

  • A 1.5 m cube centered at (10, 0, 0.75), resting on the ground.

The ground and cube have non-visual material labels asphalt and concrete. These determine their lidar response; no visual surface shader is needed. See Non-Visual Sensor Materials.

Top view of the example scene: the lidar at the origin looking along +X, the cube ten meters ahead, the ground plane extending 200 meters in every direction

The example scene from above: the lidar one meter above the origin, looking along +X at the cube ten meters ahead, on a 400 m by 400 m ground plane.#

Author the sensor#

Define the sensor, its settings and its output in the scene’s USD layer.

Create the prim and apply the schema#

The example’s lidar prim, as authored in lidar_example.usda:

lidar_example.usda, from the runnable Python lidar example#
#usda 1.0
    def OmniLidar "Lidar" (
        prepend apiSchemas = ["OmniSensorGenericLidarCoreAPI"]
    )
    {
        token omni:sensor:Core:elementsCoordsType = "CARTESIAN"
        bool omni:sensor:Core:includeInvalidPoints = false
        double2 omni:sensor:frameRate = (10, 1)

        double3 xformOp:translate = (0, 0, 1)
        float3 xformOp:rotateXYZ = (90, 0, -90)
        uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ"]
    }

OmniLidar is the prim type. OmniSensorGenericLidarCoreAPI selects the Lidar generic core model and supplies its attribute defaults. The default omni:sensor:Core:scanType is ROTARY. See Lidar generic core model for the model’s behavior.

Override values#

The example sets three attributes:

  • omni:sensor:Core:elementsCoordsType = "CARTESIAN" returns x, y and z. The default, SPHERICAL, returns azimuth, elevation and distance.

  • omni:sensor:Core:includeInvalidPoints = false removes invalid returns. This is the default, so the reader below can use every delivered entry without checking Flags.

  • omni:sensor:frameRate = (10, 1) sets 10 Hz, or one scan every 0.1 s. The value is a rational number, numerator divided by denominator. This is also the default; only the coordinate type changes a default here.

Sensor prims look along local -Z with +Y up. The translation places this lidar 1 m above the ground. xformOp:rotateXYZ = (90, 0, -90) maps local -Z to world +X and local +Y to world +Z.

See USD Attributes for settings and output coordinate frames, Runtime Parameter Updates for runtime changes, and Sensor Configuration for USD layer overrides.

Configure the output#

The example’s RenderProduct and RenderVar, authored in the same scene:

#usda 1.0
    def "Render"
    {
        def "Products"
        {
            def RenderProduct "LidarProduct"
            {
                rel camera = </World/Lidar>
                rel orderedVars = [<../../Vars/PointCloud>]
            }
        }

        def "Vars"
        {
            def RenderVar "PointCloud"
            {
                uniform string sourceName = "PointCloud"
                token[] channels = [
                    "Coordinates",
                    "Intensity",
                    "Counts",
                    "TimeOffsetNs"
                ]
            }
        }
    }

LidarProduct binds the lidar through rel camera and lists the PointCloud RenderVar in rel orderedVars. The RenderVar sets sourceName = "PointCloud" and requests these channels:

  • Coordinates: each return’s x, y and z position in meters, relative to the sensor’s origin. This example uses the sensor frame: +X forward, +Y left, +Z up.

  • Intensity: unitless return strength.

  • Counts: number of returns. Listing it is optional because it is always delivered.

  • TimeOffsetNs: each return’s firing time relative to the point-cloud frame start. See Timing: TimeOffsetNs.

Flags is also delivered automatically. After rendering, look up this output by its RenderVar path: /World/Render/Vars/PointCloud.

See Output Configuration for channel selection and for adding products through an overlay layer without editing the scene.

Render the sensor#

Create the renderer, load the USD scene into an attached ovstage stage, then step the product and read its output.

Create the renderer#

The renderer is created once, before any scene is loaded. The highlighted line enables the motion BVH.

main.py, from the runnable Python lidar example#
# Enable motion BVH: without it the lidar traces every ray from its end-of-frame pose.
log_file_path = None
if args.log is not None:
    args.log.parent.mkdir(parents=True, exist_ok=True)
    log_file_path = str(args.log)
renderer = ovrtx.Renderer(
    ovrtx.RendererConfig(
        log_file_path=log_file_path,
        log_level="info",
        motion_bvh=ovrtx.MotionBvh.ENABLE,
    )
)
stage = ovstage.Stage("ovrtx.example.lidar")
renderer.attach_ovstage(stage)
main.cpp, from the runnable C lidar example#
// The STATIC ovrtx loader resolves the ${executable_dir} token to the running
// executable's directory; ovrtx_setup_runtime() links the package bin beside
// the exe as "ovrtx/", which we hand the loader as the binary package root.
ovx_string_t ovrtx_package_root = {
    OVX_CONFIG_EXECUTABLE_DIR_TOKEN "/ovrtx",
    sizeof(OVX_CONFIG_EXECUTABLE_DIR_TOKEN "/ovrtx") - 1};
// Enable motion BVH: without it the lidar traces every ray from its
// end-of-frame pose. Sensor output fetch/map stays on the ovrtx APIs; ovstage
// only takes over scene ingest and drives stepping via ovrtx_step_with_stage.
ovrtx_config_entry_t config_entries[] = {
    ovrtx_config_entry_binary_package_root_path(ovrtx_package_root),
    ovrtx_config_entry_motion_bvh(OVRTX_MOTION_BVH_ENABLE),
};
ovrtx_config_t config = { config_entries, 2 };

// Create the renderer before attaching a stage or populating the scene.
result = ovrtx_create_renderer(&config, &renderer);
if (!check_api_result(result, "create_renderer")) {
    return 1;
}

Enable the motion BVH when creating the renderer. See Motion BVH for sensor requirements and Motion Artifacts for its effect on lidar.

The Python snippet also attaches an ovstage stage and optionally sets a log file. The C snippet sets the renderer’s binary package location; it creates and attaches the stage in the next step.

Load the scene#

The scene goes into the attached stage, not into the renderer directly.

# The USDA scene defines the lidar, material bindings, and render product.
print(f"Loading lidar scene from {args.scene}...")
ordinal = 1
ovstage.population.open_usd(stage, str(args.scene), ordinal=ordinal)
stage.advance_write_floor(ordinal, ovstage.Scope.ALL).wait()
// Initialize the STATIC ovstage loader with its package root, mirroring the
// ovrtx setup above. ovstage_setup_runtime() links the package bin beside the
// exe as "ovstage/" (next to "ovrtx/"). ovrtx_create_renderer() already loaded
// the shared usd_ms runtime; ovstage.dll loads lazily on the first ovstage
// call and reuses it.
ovx_string_t ovstage_package_root = {
    OVX_CONFIG_EXECUTABLE_DIR_TOKEN "/ovstage",
    sizeof(OVX_CONFIG_EXECUTABLE_DIR_TOKEN "/ovstage") - 1};
ovstage_config_entry_t stage_config_entries[] = {
    ovstage_config_entry_binary_package_root_path(ovstage_package_root),
};
ovstage_config_t stage_config {};
stage_config.entries = stage_config_entries;
stage_config.entry_count =
    sizeof(stage_config_entries) / sizeof(stage_config_entries[0]);
ovstage_api_status_t stage_init_status = ovstage_initialize(&stage_config);
if (stage_init_status != OVSTAGE_OK) {
    print_ovstage_error(nullptr, stage_init_status, "initialize");
    return cleanup(1);
}

// Create the ovstage instance and attach it to the renderer.
ovstage_instance_desc_t stage_desc {};
stage_desc.name = "lidar";
ovstage_api_status_t stage_result = ovstage_create_instance(&stage_desc, &stage);
if (stage_result != OVSTAGE_OK) {
    print_ovstage_error(stage, stage_result, "create_instance");
    return cleanup(1);
}

result = ovrtx_attach_ovstage(renderer, stage);
if (!check_api_result(result, "attach_ovstage")) {
    return cleanup(1);
}
stage_attached = true;

// Populate the stage from the local USD file at our ordinal, then commit the
// write floor so the attached renderer can see it.
std::cout << "Loading lidar scene from " << sceneFilePath << "...\n";
ovstage_population_enqueue_result_t populate_result =
    ovstage_population_open_usd_from_file(stage,
                                          to_ovx_string(sceneFilePath),
                                          stage_ordinal,
                                          /* time = */ 0.0,
                                          OVSTAGE_POPULATION_DOMAIN_RENDERING);
if (wait_population_op(stage, populate_result, "population_open_usd_from_file") ||
    commit_ovstage_ordinal(stage, stage_ordinal)) {
    return cleanup(1);
}

Load the scene at an ordinal, then advance the stage’s write floor to make those changes visible to the renderer. Render that same ordinal with Renderer.step(..., ordinal=...) in Python or ovrtx_step_with_stage in C. See ovstage Integration and Application Flow for the update lifecycle.

Warm up and step#

Run three warm-up steps before reading the output. See Output Configuration for warm-up guidance.

# Warm up the sensor before reading the point cloud frame.
for _ in range(WARMUP_STEPS):
    renderer.step(render_products={RENDER_PRODUCT}, delta_time=STEP_DT_SECONDS, ordinal=ordinal)
// Render a few warmup frames without reading outputs. In attach mode we drive
// the renderer with ovrtx_step_with_stage(), rendering the ordinal we
// committed above.
for (int i = 0; i < kWarmupStepCount; ++i) {
    ovrtx_step_result_handle_t warmup_handle{};

    enqueue_result =
        ovrtx_step_with_stage(renderer,
                              render_products,
                              kStepDeltaTimeSeconds,
                              stage_ordinal,
                              &warmup_handle);
    if (!check_api_result(enqueue_result, "step") ||
        !wait_for_success(renderer, enqueue_result.op_index, "step")) {
        destroy_step_results(renderer, &warmup_handle);
        return cleanup(1);
    }
    if (!destroy_step_results(renderer, &warmup_handle)) {
        return cleanup(1);
    }
}

Step once more to capture a frame. Python passes the first frame to the reader below. C fetches the result, finds PointCloud, maps it to the CPU, calls its reader and unmaps it.

# Render one lidar frame, summarize scalar channels, and color points by intensity.
products = renderer.step(
    render_products={RENDER_PRODUCT}, delta_time=STEP_DT_SECONDS, ordinal=ordinal
)
frame = products[RENDER_PRODUCT].frames[0]
points, intensity, time_offset_ns = read_lidar_pointcloud(frame)

if len(points) == 0:
    print("valid points=0")
    del frame, products
    renderer.detach_ovstage()
    stage.destroy()
    renderer.destroy()
    return

print(
    f"valid points={len(points)}, "
    f"mean intensity={float(np.mean(intensity))}, "
    f"max time offset={int(np.max(time_offset_ns))} ns"
)
if rr is not None:
    log_lidar_points(rr, points, intensity)
ovrtx_step_result_handle_t step_handle{};

// Render one lidar frame from the committed ordinal and keep the result
// handle for fetching.
enqueue_result =
    ovrtx_step_with_stage(renderer,
                          render_products,
                          kStepDeltaTimeSeconds,
                          stage_ordinal,
                          &step_handle);
if (!check_api_result(enqueue_result, "step") ||
    !wait_for_success(renderer, enqueue_result.op_index, "step")) {
    destroy_step_results(renderer, &step_handle);
    return cleanup(1);
}

// Fetch the completed step and locate the PointCloud render variable.
ovrtx_render_product_set_outputs_t outputs = {};
result = ovrtx_fetch_results(
    renderer, step_handle, ovrtx_timeout_infinite, &outputs);
if (!check_api_result(result, "fetch_results")) {
    destroy_step_results(renderer, &step_handle);
    return cleanup(1);
}

ovrtx_render_var_output_handle_t pointcloud_handle =
    find_render_var_output(outputs, kPointCloudRenderVarPath);
if (pointcloud_handle == OVRTX_INVALID_RENDER_VAR_OUTPUT_HANDLE) {
    std::cerr << "PointCloud render var output not found\n";
    destroy_step_results(renderer, &step_handle);
    return cleanup(1);
}

// Map the composite PointCloud render variable to CPU. The mapped output
// exposes one DLTensor per channel requested in the USD.
ovrtx_map_output_description_t map_desc = {};
map_desc.device_type = OVRTX_MAP_DEVICE_TYPE_CPU;
ovrtx_render_var_output_t pointcloud_output = {};
result = ovrtx_map_render_var_output(renderer,
                                     pointcloud_handle,
                                     &map_desc,
                                     ovrtx_timeout_infinite,
                                     &pointcloud_output);
if (!check_api_result(result, "map_render_var_output")) {
    destroy_step_results(renderer, &step_handle);
    return cleanup(1);
}

const bool printed_summary = print_pointcloud_summary(pointcloud_output);

// Always unmap after reading the CPU DLTensor views.
ovrtx_cuda_sync_t no_sync = {};
result = ovrtx_unmap_render_var_output(
    renderer, pointcloud_output.map_handle, no_sync);
if (!check_api_result(result, "unmap_render_var_output") ||
    !printed_summary) {
    destroy_step_results(renderer, &step_handle);
    return cleanup(1);
}

if (!destroy_step_results(renderer, &step_handle)) {
    return cleanup(1);
}

Step the RenderProduct path. Each product returns a result containing frames; each frame contains outputs keyed by RenderVar path. Map an output before reading its tensors.

For multiple products or capture rates, see Output Configuration and Sensor Scheduling. Mapping modes are described in Sensor Outputs.

Warning

In C, destroy every step result, the warm-up results included (Sensor Outputs, Lifetime and ownership). In Python there is nothing to destroy.

Read the output#

The Python reader maps the output and reads the requested channels. The C reader receives an already-mapped output and reads Counts, Intensity and TimeOffsetNs. It skips Coordinates because it only prints statistics.

def read_lidar_pointcloud(frame) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Map the PointCloud composite tensor to CPU and return valid point channels."""
    with frame.render_vars[POINTCLOUD_RENDER_VAR].map(device=ovrtx.Device.CPU) as pointcloud:
        coordinates = np.from_dlpack(pointcloud["Coordinates"])
        counts = np.from_dlpack(pointcloud["Counts"])
        intensity = np.from_dlpack(pointcloud["Intensity"])
        time_offset_ns = np.from_dlpack(pointcloud["TimeOffsetNs"])

        # Counts bounds the delivered entries. This scene sets
        # includeInvalidPoints=false, so the delivered entries are all valid.
        valid_count = int(counts[0])
        points = np.asarray(coordinates[:, :valid_count].T)
        return (
            points.copy(),
            np.asarray(intensity[:valid_count]).copy(),
            np.asarray(time_offset_ns[:valid_count]).copy(),
        )


// Read one mapped lidar PointCloud. Counts bounds the delivered entries. The
// scene sets includeInvalidPoints=false, so the delivered entries are valid.
bool print_pointcloud_summary(ovrtx_render_var_output_t const& output)
{
    // The USD also requests Coordinates; this example only needs scalar and
    // time channels for the printed statistics.
    int32_t const* counts_data = cpu_tensor_data<int32_t>(output, "Counts");
    float const* intensity_data = cpu_tensor_data<float>(output, "Intensity");
    int32_t const* time_offset_data =
        cpu_tensor_data<int32_t>(output, "TimeOffsetNs");

    if (!counts_data || !intensity_data || !time_offset_data) {
        return false;
    }

    // With invalid returns dropped by the scene, Counts is also the valid-point
    // count.
    int32_t const valid_point_count = counts_data[0];
    if (valid_point_count <= 0) {
        std::cerr << "Expected at least one valid lidar point, got "
                  << valid_point_count << "\n";
        return false;
    }

    double intensity_sum = 0.0;
    int32_t max_time_offset_ns = time_offset_data[0];

    // Only the first Counts entries are delivered in each per-point channel.
    for (int32_t i = 0; i < valid_point_count; ++i) {
        const float intensity = intensity_data[i];
        if (!std::isfinite(intensity)) {
            std::cerr << "Intensity tensor contained a non-finite value\n";
            return false;
        }
        intensity_sum += intensity;
        max_time_offset_ns = std::max(max_time_offset_ns, time_offset_data[i]);
    }

    const double mean_intensity =
        intensity_sum / static_cast<double>(valid_point_count);
    std::cout << "valid points=" << valid_point_count
              << ", mean intensity=" << mean_intensity
              << ", max point time offset to point cloud frame start=" << max_time_offset_ns << " ns\n";
    return true;
}

Read tensors by their exact channel names. Python uses NumPy’s DLPack support; C reads DLTensor data through helpers included with the example. The mapped output also exposes params.

Both readers use Counts[0] to limit per-point reads. Neither checks Flags because this scene sets includeInvalidPoints = false. See Reading the output for reading clouds that retain invalid points.

The Python expression coordinates[:, :valid_count].T converts the component-first coordinates into one row per point. Copies are optional in Python but required in C if the data must outlive the mapping. See Lifetime and ownership.

Display the output#

The Python example displays points in Rerun, colored by Intensity. Both examples print the point count, mean intensity and maximum time offset. Python prints:

valid points=..., mean intensity=..., max time offset=... ns

C labels the last value max point time offset to point cloud frame start. Expect a nonzero point count for this scene; the exact count varies between runs. See why counts vary. If no points are returned, Python prints valid points=0 and C exits with an error.

The lidar example's point cloud in the Rerun viewer, points shaded by intensity

The point cloud of lidar_example.usda as the Python example shows it, each point shaded by its Intensity.#

Next steps#

Run the complete examples:

  • Lidar: Python or C.

  • Radar: Python or C. These use the Radar generic wpmDmat model with a moving target and multiple ordinals.

  • Acoustic: Python. This reads one SignalWays frame from the Acoustic generic wpm model.

All three examples enable the motion BVH. Both AUTO and ENABLE support radar motion effects and acoustic simulation. See Motion BVH.

To customize the lidar, see its attributes and how-tos. The tutorials build profiles from vendor datasheets. Use Sensor Definition Validation to check sensor definitions before rendering.

Try a richer scene#

Both examples also ship lidar_example_panels.usda. It uses the same sensor settings and output request, with the lidar 1.5 m above asphalt:

  • Ahead: glass at 3 m, with a concrete wall behind it at 6 m.

  • Right: a silver panel at 3 m.

  • Left: a wide concrete wall at 4 m.

  • Behind: a concrete panel at 3 m.

Materials have both visual shaders and non-visual labels: asphalt, concrete, clear_glass and silver. See Non-Visual Sensor Materials. The scene also has a preview camera and CameraProduct; the lidar example does not step that product.

Select the scene with Python’s --scene option, or pass its path as the C example’s first argument. No code changes are needed.

cd examples/python/lidar
uv run main.py --scene lidar_example_panels.usda

Left: the scene viewed from a camera circling the lidar. Right: one lidar scan from the same viewpoints, colored by Intensity. White marks the lidar. The sparse points beyond the silver panel are reflections of the concrete wall.

A sample run returned about 440,000 valid points:

valid points=441505, mean intensity=0.3046911656856537, max time offset=99996722 ns

The exact count varies between runs.