Kinematic Support Geometry#

Kinematic support geometry is moved by an external controller while dynamic bodies respond to it through contact. Pallets, fixtures, end-effector-held supports, and conveyors all fit this description, but they use two different motion mechanisms.

Translating Supports and Kinematic Targets#

Use a kinematic rigid body when the support itself moves through space.

Before you begin, confirm the following:

  • The stage authors a PhysicsScene prim, the support body, and a dynamic rider body resting on the support (refer to Physics Scene).

  • DirectGPU (eENABLE_DIRECT_GPU_API) is disabled. The ovstage transform-update path that drives a kinematic target is not applied to rigid actors under DirectGPU.

  • The producer that publishes transforms can write both the local and the resolved world transform of the support at the same ovstage ordinal.

Author and drive the support as follows:

  1. Apply PhysicsRigidBodyAPI and PhysicsCollisionAPI to the body.

  2. Set physics:kinematicEnabled = true.

  3. Bind a physics material with enough static and dynamic friction for the load.

  4. Write a new target pose before each step.

The support is driven correctly when a read of RIGID_BODY_POSE places the support at the pose you wrote for that step, and the rider’s position along the motion axis has advanced with it over successive steps. A rider displacement near zero while the support moves means the support was teleported rather than driven to a kinematic target, which is the failure the shipped samples check for.

Publish the body’s local omni:xform and resolved omni:fabric:worldMatrix attributes through ovstage at a new control ordinal, seal the ordinal, and pass it to update_from_ovstage() before stepping. For a kinematic body, the runtime converts that transform update to PxRigidDynamic::setKinematicTarget(). Preserve the body’s authored scale in both matrices; changing scale is structural and reconstructs the PhysX actor instead of updating its target.

The local and world matrices are equal only when the body’s parent has an identity world transform, as in the shipped samples. Under a transformed parent, compose omni:fabric:worldMatrix from the local transform and the parent’s resolved world transform. If a producer moves a parent, it must also republish the affected descendants’ resolved world matrices. A gRPC or other transport should express the same operation as consistent local and world ovstage writes, seal the control ordinal, and drain it before stepping.

This transform-update path is not applied to rigid actors after DirectGPU (eENABLE_DIRECT_GPU_API) is enabled. Leave DirectGPU disabled when controlling kinematic supports through ovstage. GPU dynamics without DirectGPU can still be used.

Do not substitute a legacy RIGID_BODY_POSE tensor write. That path calls setGlobalPose and teleports the body; it does not provide the contact velocity needed to carry a rider. The tensor-binding write surface is deprecated (superseded by the session write API, ovphysx_write / PhysX.write) and does not expose a separate kinematic-target tensor.

C++ applications can also use ovphysx_get_physx_ptr() and call PxRigidDynamic::setKinematicTarget() directly. Refer to PhysX Interop.

Stationary Conveyors and Surface Velocity#

Use PhysxSurfaceVelocityAPI when the support stays in place but its contact surface moves:

def Cube "Conveyor" (
    prepend apiSchemas = [
        "PhysicsCollisionAPI",
        "PhysicsRigidBodyAPI",
        "PhysxSurfaceVelocityAPI"
    ]
)
{
    bool physics:kinematicEnabled = true
    bool physxSurfaceVelocity:surfaceVelocityEnabled = true
    vector3f physxSurfaceVelocity:surfaceVelocity = (1, 0, 0)
    bool physxSurfaceVelocity:surfaceVelocityLocalSpace = false
}

Apply the surface-velocity API to the rigid-body prim, not only to a child collider. Runtime changes use the same physxSurfaceVelocity:* attributes through the ovstage control-ordinal path. Nonzero physics:velocity and physics:angularVelocity on a kinematic body are retained as a legacy surface-velocity shortcut, but new code should use PhysxSurfaceVelocityAPI.

Surface velocity uses contact modification. Leave DirectGPU disabled for scenes that need it. GPU dynamics without DirectGPU can still be used.

Combining Target Motion and Surface Velocity#

The two mechanisms are additive. A kinematic target contributes the support’s physical step velocity; PhysxSurfaceVelocityAPI contributes an additional contact-target velocity. Use both only when the intended surface motion is relative to an already-moving support. Adding surface velocity to compensate for a teleported pallet is not equivalent to driving a kinematic target and can double-drive cargo after the target path is corrected.

The C and Python samples in Runnable Samples exercise three isolated lanes in one scene:

  • a translating platform driven by ovstage transform updates;

  • a stationary platform driven only by surface velocity;

  • a translating platform with additional surface velocity.

Each lane checks rider displacement, and the combined lane checks that its motion is observably greater than either independent lane.

Sleep, Friction, and Ordering#

A surface-velocity change does not wake a body that has settled to sleep. Disable sleeping for continuously controlled loads by setting physxRigidBody:sleepThreshold = 0, or call ovphysx_rigid_body_view_wake_up() (TensorBinding.wake_up() in Python) immediately before motion begins. Kinematic support motion and surface velocity also require sufficient friction to transmit tangential motion; bind an explicit physics material instead of relying on defaults.

Before you enter the control loop, confirm the following:

  • The stage is attached, and the first ordinal the loop writes is above the ordinal that attach_ovstage() consumed.

  • Sleeping is disabled on the controlled loads, or the loop wakes them before motion begins.

  • DirectGPU is disabled, as required by both mechanisms on this page.

Then, for each frame:

  1. Publish and seal ovstage transform and surface-velocity control edits.

  2. Call update_from_ovstage() for exactly those control ordinals.

  3. Step and wait for completion.

  4. Read poses and velocities.

The loop is correct when each read pose matches the edit published for that ordinal, and the rider’s displacement grows monotonically across frames instead of stalling after the first one. A rider that stops advancing while edits keep arriving indicates a sleeping load or insufficient friction.

Reset and Readback#

Use reset_stage() and reload or reattach the baseline scene for deterministic reruns. Replaying only poses and velocities from an in-contact mid-run state does not restore PhysX contact caches and is not a deterministic reset.

Read resolved runtime state with RIGID_BODY_POSE and RIGID_BODY_VELOCITY, or use the ovstage output-read API. Reading the source USD only shows authored values; it does not prove that the live PhysX actor received a target or that contact carried the rider.

Runnable Samples#

Python:

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# @implements REQ-USD-KINEMATIC-SUPPORT-001
# @covers AC-1 AC-3

"""Kinematic support sample: transform motion, surface velocity, and both."""

from pathlib import Path

import numpy as np
import ovstage

import ovphysx
from ovphysx import PhysX
from ovphysx.types import TensorType


_physx_schemas_registered = False


def attach_scene(physx, usd_path):
    if not ovstage.population.available():
        raise RuntimeError("ovstage population bridge is unavailable")
    # ovphysx ships its PhysX USD schemas as codeless resources and does not register
    # them itself. Register them with ovstage once, before the first population
    # call in the process. The Newton USD schema (pip package newton-usd-schemas)
    # is registered alongside so authored newton:* attributes reach the parser.
    global _physx_schemas_registered
    if not _physx_schemas_registered:
        ovstage.population.register_usd_schemas(
            [str(ovphysx.codeless_schema_root()), str(ovphysx.newton_schema_root())]
        )
        _physx_schemas_registered = True
    stage = ovstage.Stage("ovphysx-kinematic-support-sample")
    try:
        ovstage.population.open_usd(
            stage, str(usd_path), ordinal=1, domains=ovstage.PopulationDomain.PHYSICS
        )
        stage.advance_write_floor(ordinal=1).wait()
        physx.attach_ovstage(stage, read_ordinal=1)
        return stage
    except Exception:
        stage.destroy()
        raise


def make_binding(physx, path, tensor_type):
    binding = physx.create_tensor_binding(pattern=path, tensor_type=tensor_type)
    if binding.count != 1:
        binding.destroy()
        raise RuntimeError(f"Expected one rigid body at {path}, found {binding.count}")
    return binding


# NOTE: this sample uses the deprecated tensor-binding API. New code should use the session
# read/write API (PhysX.read / PhysX.write).
def read_x(binding):
    poses = np.zeros(binding.shape, dtype=np.float32)
    binding.read(poses)
    return float(poses[0, 0])


class TransformWriter:
    """Publish world transforms for one prim through an ovstage query."""

    def __init__(self, stage, path):
        self._stage = stage
        self._paths = ovstage.PathDictionary(stage)
        self._path_list = None
        self._query = None
        try:
            self._path_list = self._paths.create_path_list_from_strings([path])
            self._query = stage.query_from_path_list(self._path_list)
        except Exception:
            if self._path_list is not None:
                self._paths.destroy_path_list(self._path_list)
            self._paths.destroy()
            raise

    def write_attribute(self, attribute, tensor, ordinal):
        self._stage.write_attribute(
            self._query, attribute, ordinal, tensor, is_array=False
        ).wait()

    def write_pose(self, x, z, ordinal):
        target = np.eye(4, dtype=np.float64)
        target[0, 0] = 6.0
        target[2, 2] = 6.0
        target[3, :3] = (x, 0.0, z)
        tensor = ovstage.make_dltensor(
            target,
            dtype=ovstage.DLDataType(ovstage.DLDataTypeCode.kDLFloat, 64, 16),
            shape=[1],
            ndim=1,
        )
        for attribute in ("omni:xform", "omni:fabric:worldMatrix"):
            self.write_attribute(attribute, tensor, ordinal)

    def destroy(self):
        if self._query is not None:
            self._stage.release_query(self._query).wait()
            self._query = None
        if self._path_list is not None:
            self._paths.destroy_path_list(self._path_list)
            self._path_list = None
        self._paths.destroy()


def main():
    PhysX.set_cpu_mode(True)
    physx = PhysX()
    stage = None
    bindings = []
    writers = []
    try:
        usd_path = Path(__file__).resolve().parent / ".." / "data" / "kinematic_support.usda"
        stage = attach_scene(physx, usd_path)
        physx.wait_all()

        def add_binding(path, tensor_type):
            binding = make_binding(physx, path, tensor_type)
            bindings.append(binding)
            return binding

        target_platform = TransformWriter(stage, "/World/TargetPlatform")
        writers.append(target_platform)
        combined_platform = TransformWriter(stage, "/World/CombinedPlatform")
        writers.append(combined_platform)
        target_rider = add_binding("/World/TargetRider", TensorType.RIGID_BODY_POSE)
        conveyor_rider = add_binding("/World/ConveyorRider", TensorType.RIGID_BODY_POSE)
        combined_rider = add_binding("/World/CombinedRider", TensorType.RIGID_BODY_POSE)

        dt = 1.0 / 60.0
        for _ in range(10):
            physx.step_sync(dt)

        start = np.array(
            [read_x(target_rider), read_x(conveyor_rider), read_x(combined_rider)]
        )
        for frame in range(1, 121):
            x = frame * dt
            ordinal = frame + 1
            target_platform.write_pose(x, 0.0, ordinal)
            combined_platform.write_pose(x, 16.0, ordinal)
            stage.advance_write_floor(ordinal=ordinal).wait()
            physx.update_from_ovstage(ordinal, ordinal)
            physx.step_sync(dt)

        displacement = (
            np.array([read_x(target_rider), read_x(conveyor_rider), read_x(combined_rider)])
            - start
        )
        target_dx, conveyor_dx, combined_dx = displacement
        print(
            "Rider displacement: "
            f"transform={target_dx:.3f}, surface={conveyor_dx:.3f}, combined={combined_dx:.3f}"
        )

        if target_dx < 0.5:
            raise RuntimeError("ovstage transform did not carry its rider")
        if conveyor_dx < 0.5:
            raise RuntimeError("Surface velocity did not carry its rider")
        if combined_dx <= max(target_dx, conveyor_dx) + 0.2:
            raise RuntimeError("Combined transform and surface velocity were not observably additive")
    finally:
        for writer in reversed(writers):
            writer.destroy()
        for binding in reversed(bindings):
            binding.destroy()
        if stage is not None:
            physx.detach_ovstage()
            stage.destroy()
        physx.destroy()


if __name__ == "__main__":
    main()

C:

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
 * @implements REQ-USD-KINEMATIC-SUPPORT-001
 * @covers AC-1 AC-3
 */

// Kinematic support sample: transform motion, surface velocity, and both.

#include <ovphysx/ovphysx.h>
#include <ovphysx/ovphysx_types.h>
#include <ovx/path_dictionary/path_dictionary.h>
#include "ovstage_sample.h"
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

#ifdef _WIN32
#include <windows.h>
#else
#include <stdatomic.h>
#endif

#ifdef __cplusplus
#error "This file must be compiled as C, not C++"
#endif

#ifdef _WIN32
typedef volatile LONG object_change_counter_t;
#else
typedef atomic_uint object_change_counter_t;
#endif

static void object_change_counter_init(object_change_counter_t* counter)
{
#ifdef _WIN32
    InterlockedExchange(counter, 0);
#else
    atomic_init(counter, 0);
#endif
}

static void object_change_counter_increment(object_change_counter_t* counter)
{
#ifdef _WIN32
    InterlockedIncrement(counter);
#else
    atomic_fetch_add_explicit(counter, 1, memory_order_relaxed);
#endif
}

static unsigned int object_change_counter_load(object_change_counter_t* counter)
{
#ifdef _WIN32
    return (unsigned int)InterlockedCompareExchange(counter, 0, 0);
#else
    return atomic_load_explicit(counter, memory_order_relaxed);
#endif
}

typedef struct path_query_t
{
    ovstage_query_handle_t query;
    ovx_primpath_list_t path_list;
} path_query_t;

typedef struct object_change_counts_t
{
    object_change_counter_t created;
    object_change_counter_t destroyed;
} object_change_counts_t;

static void on_object_created(
    ovphysx_string_t prim_path,
    ovphysx_physx_type_t type,
    void* user_data)
{
    (void)prim_path;
    (void)type;
    object_change_counts_t* counts = (object_change_counts_t*)user_data;
    object_change_counter_increment(&counts->created);
}

static void on_object_destroyed(
    ovphysx_string_t prim_path,
    ovphysx_physx_type_t type,
    void* user_data)
{
    (void)prim_path;
    (void)type;
    object_change_counts_t* counts = (object_change_counts_t*)user_data;
    object_change_counter_increment(&counts->destroyed);
}

static int check_result(ovphysx_result_t result, const char* operation)
{
    if (result.status == OVPHYSX_API_SUCCESS)
        return 1;
    const ovphysx_string_t error = ovphysx_get_last_error();
    fprintf(stderr, "%s failed: %.*s\n", operation, (int)error.length, error.ptr ? error.ptr : "");
    return 0;
}

static int wait_step(ovphysx_handle_t handle, float dt)
{
    const ovphysx_enqueue_result_t step = ovphysx_step(handle, dt);
    if (step.status != OVPHYSX_API_SUCCESS)
        return 0;

    ovphysx_op_wait_result_t wait_result = { 0 };
    const ovphysx_result_t waited =
        ovphysx_wait_op(handle, step.op_index, OVPHYSX_TIMEOUT_INFINITE, &wait_result);
    const int ok = waited.status == OVPHYSX_API_SUCCESS && wait_result.num_errors == 0;
    ovphysx_destroy_wait_result(&wait_result);
    return ok;
}

static int wait_ovstage(ovstage_instance_t* stage, ovstage_enqueue_result_t op)
{
    if (op.status != OVSTAGE_OK)
    {
        fprintf(stderr, "ovstage enqueue failed: %s\n", ovstage_get_error_string(stage, op.status));
        return 0;
    }
    if (op.op_index == OVSTAGE_INVALID_OP_ID)
        return 1;

    ovstage_op_wait_result_t wait_result;
    memset(&wait_result, 0, sizeof(wait_result));
    const ovstage_api_status_t status =
        ovstage_wait_op(stage, op.op_index, OVSTAGE_TIMEOUT_INFINITE, &wait_result);
    const int ok = status == OVSTAGE_OK && wait_result.error_op_id_count == 0;
    if (!ok)
    {
        fprintf(stderr, "ovstage operation failed: %s\n", ovstage_get_error_string(stage, status));
        for (size_t i = 0; i < wait_result.error_op_id_count; ++i)
        {
            const ovx_string_t error =
                ovstage_get_last_op_error(stage, wait_result.error_op_ids[i]);
            fprintf(stderr, "  %.*s\n", (int)error.length, error.ptr ? error.ptr : "");
        }
    }
    return ovstage_release_op(stage, op.op_index) == OVSTAGE_OK && ok;
}

static ovx_string_or_token_t attribute_name(const char* name)
{
    ovx_string_or_token_t result;
    memset(&result, 0, sizeof(result));
    result.string.ptr = name;
    result.string.length = strnlen(name, 256);
    return result;
}

static int write_matrix(
    ovstage_instance_t* stage,
    ovstage_query_handle_t query,
    const char* attribute,
    ovstage_ordinal_t ordinal,
    double matrix[16])
{
    int64_t shape[1] = { 1 };
    DLTensor tensor;
    memset(&tensor, 0, sizeof(tensor));
    tensor.data = matrix;
    tensor.device.device_type = kDLCPU;
    tensor.dtype.code = kDLFloat;
    tensor.dtype.bits = 64;
    tensor.dtype.lanes = 16;
    tensor.ndim = 1;
    tensor.shape = shape;

    ovstage_write_data_t write;
    memset(&write, 0, sizeof(write));
    write.tensors = &tensor;
    write.tensor_count = 1;
    return wait_ovstage(
        stage,
        ovstage_write_attribute(
            stage, query, attribute_name(attribute), ordinal, write, OVSTAGE_PRIM_MODE_UPSERT));
}

static int write_transform(
    ovstage_instance_t* stage,
    ovstage_query_handle_t query,
    ovstage_ordinal_t ordinal,
    double x,
    double z)
{
    double matrix[16] = {
        6.0, 0.0, 0.0, 0.0,
        0.0, 1.0, 0.0, 0.0,
        0.0, 0.0, 6.0, 0.0,
        x,   0.0, z,   1.0
    };
    return write_matrix(stage, query, "omni:xform", ordinal, matrix) &&
           write_matrix(stage, query, "omni:fabric:worldMatrix", ordinal, matrix);
}

static int create_path_query(
    ovstage_instance_t* stage,
    const char* path,
    path_query_t* path_query)
{
    path_dictionary_instance_t* dictionary = ovstage_get_path_dictionary(stage);
    if (!dictionary)
        return 0;

    ovx_string_t path_string = { path, strnlen(path, 256) };
    ovx_api_result_t created =
        dictionary->vtable->create_path_list_from_strings(
            dictionary->context, &path_string, 1, &path_query->path_list);
    if (created.status != OVX_API_SUCCESS ||
        path_query->path_list == OVX_INVALID_PRIMPATH_LIST)
    {
        if (created.error.ptr)
            dictionary->vtable->release_error(dictionary->context, created.error);
        return 0;
    }

    const ovstage_api_status_t queried =
        ovstage_query_from_path_list(stage, path_query->path_list, &path_query->query);
    return queried == OVSTAGE_OK && path_query->query != OVSTAGE_INVALID_QUERY_HANDLE;
}

static int destroy_path_query(
    ovstage_instance_t* stage,
    path_query_t* path_query)
{
    int ok = 1;
    if (path_query->query != OVSTAGE_INVALID_QUERY_HANDLE)
    {
        ok = wait_ovstage(stage, ovstage_release_query(stage, path_query->query));
        path_query->query = OVSTAGE_INVALID_QUERY_HANDLE;
    }
    if (path_query->path_list != OVX_INVALID_PRIMPATH_LIST)
    {
        path_dictionary_instance_t* dictionary = ovstage_get_path_dictionary(stage);
        if (!dictionary)
            return 0;
        ovx_api_result_t released =
            dictionary->vtable->release_path_list_reference(
                dictionary->context, path_query->path_list);
        path_query->path_list = OVX_INVALID_PRIMPATH_LIST;
        if (released.error.ptr)
            dictionary->vtable->release_error(dictionary->context, released.error);
        ok = released.status == OVX_API_SUCCESS && ok;
    }
    return ok;
}

static int publish_control_ordinal(
    ovphysx_handle_t handle,
    ovstage_instance_t* stage,
    ovstage_ordinal_t ordinal)
{
    ovstage_write_floor_desc_t floor;
    memset(&floor, 0, sizeof(floor));
    floor.ordinal = ordinal;
    floor.scope = OVSTAGE_SCOPE_ALL;
    if (!wait_ovstage(stage, ovstage_advance_write_floor(stage, &floor)))
        return 0;

    const ovstage_ordinal_range_t range = { ordinal, ordinal, true };
    return check_result(
        ovphysx_update_from_ovstage(handle, range),
        "update from ovstage");
}

static int create_binding(
    ovphysx_handle_t handle,
    const char* path,
    ovphysx_tensor_type_t type,
    ovphysx_tensor_binding_handle_t* binding)
{
    ovphysx_tensor_binding_desc_t desc;
    memset(&desc, 0, sizeof(desc));
    desc.pattern.ptr = path;
    desc.pattern.length = strnlen(path, 256);
    desc.tensor_type = type;
    if (!check_result(ovphysx_create_tensor_binding(handle, &desc, binding), "create tensor binding"))
        return 0;

    ovphysx_tensor_spec_t spec;
    memset(&spec, 0, sizeof(spec));
    if (!check_result(ovphysx_get_tensor_binding_spec(handle, *binding, &spec), "get tensor binding spec"))
        return 0;
    if (spec.ndim != 2 || spec.shape[0] != 1 || spec.shape[1] != 7)
    {
        fprintf(stderr, "Unexpected binding shape for %s\n", path);
        return 0;
    }
    return 1;
}

static DLTensor make_pose_tensor(float data[7], int64_t shape[2])
{
    DLTensor tensor;
    memset(&tensor, 0, sizeof(tensor));
    tensor.data = data;
    tensor.device.device_type = kDLCPU;
    tensor.ndim = 2;
    tensor.dtype.code = kDLFloat;
    tensor.dtype.bits = 32;
    tensor.dtype.lanes = 1;
    tensor.shape = shape;
    return tensor;
}

// NOTE: this sample uses the deprecated tensor-binding API. New code should use the session
// read/write API (ovphysx_read / ovphysx_write).
static int read_x(
    ovphysx_handle_t handle,
    ovphysx_tensor_binding_handle_t binding,
    float* x)
{
    float data[7] = { 0 };
    int64_t shape[2] = { 1, 7 };
    DLTensor tensor = make_pose_tensor(data, shape);
    if (!check_result(ovphysx_read_tensor_binding(handle, binding, &tensor), "read rider pose"))
        return 0;
    *x = data[0];
    return 1;
}

int main(void)
{
    int exit_code = 1;
    ovphysx_handle_t handle = 0;
    ovphysx_sample_stage_attachment_t attachment;
    path_query_t target_platform = {
        OVSTAGE_INVALID_QUERY_HANDLE, OVX_INVALID_PRIMPATH_LIST
    };
    path_query_t combined_platform = {
        OVSTAGE_INVALID_QUERY_HANDLE, OVX_INVALID_PRIMPATH_LIST
    };
    object_change_counts_t object_changes;
    ovphysx_subscription_id_t object_change_subscription =
        OVPHYSX_INVALID_SUBSCRIPTION_ID;
    ovphysx_tensor_binding_handle_t target_rider = 0;
    ovphysx_tensor_binding_handle_t conveyor_rider = 0;
    ovphysx_tensor_binding_handle_t combined_rider = 0;
    memset(&attachment, 0, sizeof(attachment));
    object_change_counter_init(&object_changes.created);
    object_change_counter_init(&object_changes.destroyed);

    ovphysx_set_cpu_mode(true);
    if (!check_result(ovphysx_initialize(), "initialize"))
        return exit_code;

    ovphysx_create_args args = OVPHYSX_CREATE_ARGS_DEFAULT;
    if (!check_result(ovphysx_create_instance(&args, &handle), "create instance"))
        goto cleanup;
    if (!ovphysx_sample_attach_usd_with_ovstage(
            handle, OVPHYSX_TEST_DATA "/kinematic_support.usda", &attachment))
        goto cleanup;

    ovphysx_object_change_callbacks_t callbacks;
    memset(&callbacks, 0, sizeof(callbacks));
    callbacks.on_object_created = on_object_created;
    callbacks.on_object_destroyed = on_object_destroyed;
    callbacks.user_data = &object_changes;
    if (!check_result(
            ovphysx_subscribe_object_changes(
                &callbacks, &object_change_subscription),
            "subscribe to object changes"))
        goto cleanup;

    if (!create_path_query(attachment.stage, "/World/TargetPlatform", &target_platform) ||
        !create_path_query(attachment.stage, "/World/CombinedPlatform", &combined_platform) ||
        !create_binding(handle, "/World/TargetRider",
                        OVPHYSX_TENSOR_RIGID_BODY_POSE_F32, &target_rider) ||
        !create_binding(handle, "/World/ConveyorRider",
                        OVPHYSX_TENSOR_RIGID_BODY_POSE_F32, &conveyor_rider) ||
        !create_binding(handle, "/World/CombinedRider",
                        OVPHYSX_TENSOR_RIGID_BODY_POSE_F32, &combined_rider))
        goto cleanup;

    const float dt = 1.0f / 60.0f;
    for (int frame = 0; frame < 10; ++frame)
    {
        if (!wait_step(handle, dt))
            goto cleanup;
    }

    float target_start = 0.0f;
    float conveyor_start = 0.0f;
    float combined_start = 0.0f;
    if (!read_x(handle, target_rider, &target_start) ||
        !read_x(handle, conveyor_rider, &conveyor_start) ||
        !read_x(handle, combined_rider, &combined_start))
        goto cleanup;

    for (int frame = 1; frame <= 120; ++frame)
    {
        const float x = frame * dt;
        const ovstage_ordinal_t ordinal = (ovstage_ordinal_t)frame + attachment.ordinal;
        if (!write_transform(attachment.stage, target_platform.query, ordinal, x, 0.0) ||
            !write_transform(attachment.stage, combined_platform.query, ordinal, x, 16.0) ||
            !publish_control_ordinal(handle, attachment.stage, ordinal) ||
            !wait_step(handle, dt))
            goto cleanup;
    }

    float target_end = 0.0f;
    float conveyor_end = 0.0f;
    float combined_end = 0.0f;
    if (!read_x(handle, target_rider, &target_end) ||
        !read_x(handle, conveyor_rider, &conveyor_end) ||
        !read_x(handle, combined_rider, &combined_end))
        goto cleanup;

    const float target_dx = target_end - target_start;
    const float conveyor_dx = conveyor_end - conveyor_start;
    const float combined_dx = combined_end - combined_start;
    printf("Rider displacement: transform=%.3f, surface=%.3f, combined=%.3f\n",
           target_dx, conveyor_dx, combined_dx);

    const float independent_max = target_dx > conveyor_dx ? target_dx : conveyor_dx;
    const unsigned int created = object_change_counter_load(&object_changes.created);
    const unsigned int destroyed = object_change_counter_load(&object_changes.destroyed);
    printf("Physics object changes: created=%u, destroyed=%u\n", created, destroyed);
    if (created != 0 || destroyed != 0)
        fprintf(stderr,
                "Transform updates reconstructed physics objects "
                "(created=%u, destroyed=%u)\n",
                created, destroyed);
    else if (target_dx < 0.5f)
        fprintf(stderr, "ovstage transform did not carry its rider\n");
    else if (conveyor_dx < 0.5f)
        fprintf(stderr, "Surface velocity did not carry its rider\n");
    else if (combined_dx <= independent_max + 0.2f)
        fprintf(stderr, "Combined transform and surface velocity were not observably additive\n");
    else
        exit_code = 0;

cleanup:
    if (object_change_subscription != OVPHYSX_INVALID_SUBSCRIPTION_ID)
        check_result(
            ovphysx_unsubscribe_object_changes(object_change_subscription),
            "unsubscribe from object changes");
    if (attachment.stage)
    {
        destroy_path_query(attachment.stage, &target_platform);
        destroy_path_query(attachment.stage, &combined_platform);
    }
    if (target_rider)
        ovphysx_destroy_tensor_binding(handle, target_rider);
    if (conveyor_rider)
        ovphysx_destroy_tensor_binding(handle, conveyor_rider);
    if (combined_rider)
        ovphysx_destroy_tensor_binding(handle, combined_rider);
    if (handle)
    {
        ovphysx_sample_destroy_stage(handle, &attachment);
        ovphysx_destroy_instance(handle);
    }
    ovphysx_shutdown();
    return exit_code;
}