Contact Binding – Reading Contact Forces#

Contact bindings let you read contact forces between sensor bodies and filter bodies. A sensor is a rigid body prim (or a set of prims matched by a USD path pattern) whose contacts you want to measure. A filter is a second set of bodies whose contacts with each sensor you want to isolate.

Contact reporting is opt-in: every prim matched by sensor_patterns must have PhysxContactReportAPI applied. A matched prim without it is dropped from the binding — the runtime logs Failed to find contact report API at '<path>' — and if that leaves no sensors at all, create_contact_binding() fails. Filter prims need no extra schema, and runtime-only clones (which have no USD prim) inherit contact reporting from the source actor.

Prerequisites#

  • Complete the Tensor Bindings (deprecated) tutorial.

  • Your USD scene has rigid body prims in contact (or that come into contact during simulation).

  • Every prim you name in sensor_patterns has PhysxContactReportAPI applied:

    def Mesh "box" (
        prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsCollisionAPI", "PhysxContactReportAPI"]
    )
    {
        float physxContactReport:threshold = 0
    }
    

    The optional physxContactReport:threshold is the force below which contacts are not reported; the bundled sample scene uses 0 so every contact is reported.

    The schema must sit on the prim you actually name as the sensor. When the rigid body and its collider are separate prims, applying it to the body but naming the collider (or the reverse) matches nothing — the two must agree.

  • For CUDA output tensors, enable DirectGPU TensorAPI before creating the PhysX instance; physxScene:enableGPUDynamics=true alone only selects GPU dynamics. Refer to Warmup and Determinism.

Key Concepts#

  • Create the binding before the first step whose contacts you want to observe. The binding registers an internal contact-report callback. No contact data exists until at least one step(), step_sync(), or step_n_sync() call has completed.

  • Create contact bindings once outside simulation loops and reuse them. In Python, use the context-manager form or call cb.destroy() when finished; otherwise garbage collection emits ResourceWarning when it eventually releases the native binding.

  • Reading before the first step returns all-zeros tensors.

  • dt for the impulse-to-force conversion (force = impulse / dt) is taken automatically from the last successful step(), step_sync(), or step_n_sync() call. You do not pass it manually.

  • Result tensor shapes:

    • Net forces: [S, 3] – one 3-D force vector per matched sensor object.

    • Force matrix: [S, F, 3] — force vectors per (sensor, filter) pair.

    • Detailed contact data: contact forces and separations use [C, 1]; positions and normals use [C, 3]; all are indexed by [S, F] count/start-index tensors.

    • Detailed friction data: friction forces and points use [C, 3] buffers indexed by [S, F] count/start-index tensors.

Python#

Full Binding + Destroy#

This sample creates a contact binding on boxes_falling_on_groundplane.usda, steps until the sensor lands, reads both the net forces and the force matrix, then destroys the binding explicitly:


    # --- 1. Initialize SDK and load scene ---
    PhysX.set_cpu_mode(True)
    physx = PhysX()
    stage = None
    data_dir = Path(__file__).resolve().parent.parent / "data"

    try:
        stage = attach_scene(physx, data_dir / "boxes_falling_on_groundplane.usda", "ovphysx-contact-sample")
        physx.wait_all()

        # --- 2. Create a contact binding BEFORE the first step ---
        # sensor_patterns: bodies whose contact forces you want to read.
        # filter_patterns: bodies to measure contacts against (one per sensor).
        # Cube1 lands on the static collider /World/BigBase, not the ground
        # plane, so the filter must name BigBase or the force matrix is zeros
        # while net_forces is not.
        # The binding must be created before any step() call whose contacts you
        # want to observe.
        cb = physx.create_contact_binding(
            sensor_patterns=["/World/Cube1"],
            filter_patterns=["/World/BigBase"],
            filters_per_sensor=1,
            max_contact_data_count=256,
        )

        sensor_count = cb.sensor_count   # number of matched sensor prims
        filter_count = cb.filter_count   # number of filter prims per sensor

        print(f"Sensors: {sensor_count}, filters per sensor: {filter_count}")

        # --- 3. Simulate until boxes land ---
        for _ in range(120):
            physx.step(1.0 / 60.0)
        physx.wait_all()

        # --- 4. Read net contact forces: shape [S, 3] ---
        # dt is taken automatically from the last successful stepping call.
        net_forces = np.zeros((sensor_count, 3), dtype=np.float32)
        cb.read_net_forces(net_forces)
        print("Net contact forces [S, 3]:", net_forces)

        # --- 5. Read contact force matrix: shape [S, F, 3] ---
        force_matrix = np.zeros((sensor_count, filter_count, 3), dtype=np.float32)
        cb.read_force_matrix(force_matrix)
        print("Contact force matrix [S, F, 3]:", force_matrix)
        # 1x1 matrix equals net force here (Cube1 vs BigBase). Fail loudly if
        # the filter again names a body Cube1 is not touching.
        assert np.linalg.norm(force_matrix) > 1.0, (
            "expected Cube1 vs BigBase contact after 120 steps; "
            f"force matrix was {force_matrix}"
        )

        # --- 6. Clean up first demo ---
        cb.destroy()

After 120 steps both read_net_forces() and read_force_matrix() print a non-zero vector on the order of Cube1’s weight (about 9810 N in Z). In boxes_falling_on_groundplane.usda, Cube1 lands on /World/BigBase, not the ground plane, so the sample’s filter is that prim. A filter naming the ground still creates a valid 1x1 binding; the matrix is then all zeros while net force is not, because the pair never touches.

C#

The C sample performs the same create, step, read, and destroy sequence through the C API:

    /* 1. Initialize SDK */
    r = ovphysx_initialize();
    if (!check_result(r, "ovphysx_initialize")) return 1;

    ovphysx_create_args args = OVPHYSX_CREATE_ARGS_DEFAULT;

    ovphysx_handle_t handle = 0;
    r = ovphysx_create_instance(&args, &handle);
    if (!check_result(r, "ovphysx_create_instance")) { ovphysx_shutdown(); return 1; }

    /* 2. Populate ovstage from USD and attach it */
    ovphysx_sample_stage_attachment_t stage_attachment = {0};
    if (!ovphysx_sample_attach_usd_with_ovstage(
            handle, OVPHYSX_TEST_DATA "/boxes_falling_on_groundplane.usda", &stage_attachment)) {
        ovphysx_destroy_instance(handle); ovphysx_shutdown(); return 1;
    }

    /* 3. Create the contact binding before the first step. The sensor is the
     *    falling box. The filter is BigBase, the static collider it lands on,
     *    rather than the ground plane at z=0. */
    ovphysx_string_t sensors[1];
    sensors[0] = ovphysx_cstr("/World/Cube1");

    ovphysx_string_t filters[1];
    filters[0] = ovphysx_cstr("/World/BigBase");

    ovphysx_contact_binding_handle_t cb = 0;
    r = ovphysx_create_contact_binding(
        handle,
        sensors, 1,     /* 1 sensor pattern */
        filters, 1,     /* 1 filter pattern per sensor */
        256,            /* max raw contact pairs */
        &cb);
    if (!check_result(r, "ovphysx_create_contact_binding")) {
        ovphysx_sample_destroy_stage(handle, &stage_attachment);
        ovphysx_destroy_instance(handle); ovphysx_shutdown(); return 1;
    }

    /* 4. Query matched sensor / filter counts */
    int32_t sensor_count = 0, filter_count = 0;
    r = ovphysx_get_contact_binding_spec(handle, cb, &sensor_count, &filter_count);
    if (!check_result(r, "ovphysx_get_contact_binding_spec")) {
        ovphysx_destroy_contact_binding(handle, cb);
        ovphysx_sample_destroy_stage(handle, &stage_attachment);
        ovphysx_destroy_instance(handle);
        ovphysx_shutdown();
        return 1;
    }
    printf("Sensors: %d  Filters per sensor: %d\n", sensor_count, filter_count);

    /* 5. Simulate until the box lands */
    for (int i = 0; i < 120; i++) {
        er = ovphysx_step(handle, 1.0f / 60.0f);
        if (!check_enqueue(er, "ovphysx_step")) {
            ovphysx_destroy_contact_binding(handle, cb);
            ovphysx_sample_destroy_stage(handle, &stage_attachment);
            ovphysx_destroy_instance(handle);
            ovphysx_shutdown();
            return 1;
        }
    }
    if (!wait_op(handle, er.op_index, "step")) {
        ovphysx_destroy_contact_binding(handle, cb);
        ovphysx_sample_destroy_stage(handle, &stage_attachment);
        ovphysx_destroy_instance(handle);
        ovphysx_shutdown();
        return 1;
    }

    /* 6. Read the net contact forces, shape [S, 3].
     *    dt is taken from the last successful stepping call. */
    float* net_data   = NULL;
    int64_t* net_shp  = NULL;
    DLTensor net_tensor = make_tensor_f32_2d(
        (size_t)sensor_count, 3, &net_data, &net_shp);

    r = ovphysx_read_contact_net_forces(handle, cb, &net_tensor);
    if (!check_result(r, "ovphysx_read_contact_net_forces")) {
        free(net_data); free(net_shp);
        ovphysx_destroy_contact_binding(handle, cb);
        ovphysx_sample_destroy_stage(handle, &stage_attachment);
        ovphysx_destroy_instance(handle);
        ovphysx_shutdown();
        return 1;
    }
    printf("Net contact forces [%d, 3]:\n", sensor_count);
    for (int s = 0; s < sensor_count; s++) {
        printf("  sensor %d: fx=%.3f  fy=%.3f  fz=%.3f\n",
               s,
               net_data[s * 3 + 0],
               net_data[s * 3 + 1],
               net_data[s * 3 + 2]);
    }
    free(net_data); free(net_shp);

    /* 7. Read the contact force matrix, shape [S, F, 3]. */
    float* mat_data   = NULL;
    int64_t* mat_shp  = NULL;
    DLTensor mat_tensor = make_tensor_f32_3d(
        (size_t)sensor_count, (size_t)filter_count, 3,
        &mat_data, &mat_shp);

    r = ovphysx_read_contact_force_matrix(handle, cb, &mat_tensor);
    if (!check_result(r, "ovphysx_read_contact_force_matrix")) {
        free(mat_data); free(mat_shp);
        ovphysx_destroy_contact_binding(handle, cb);
        ovphysx_sample_destroy_stage(handle, &stage_attachment);
        ovphysx_destroy_instance(handle);
        ovphysx_shutdown();
        return 1;
    }
    printf("Contact force matrix [%d, %d, 3]:\n", sensor_count, filter_count);
    for (int s = 0; s < sensor_count; s++) {
        for (int f = 0; f < filter_count; f++) {
            int base = (s * filter_count + f) * 3;
            printf("  [%d][%d]: fx=%.3f  fy=%.3f  fz=%.3f\n",
                   s, f,
                   mat_data[base + 0],
                   mat_data[base + 1],
                   mat_data[base + 2]);
        }
    }
    /* Cube1 rests on BigBase, so the 1x1 matrix has to hold a real contact force.
     * The max-abs norm avoids libm, which the CI sample link does not pass with -lm. */
    if (sensor_count >= 1 && filter_count >= 1) {
        float ax = mat_data[0] < 0.f ? -mat_data[0] : mat_data[0];
        float ay = mat_data[1] < 0.f ? -mat_data[1] : mat_data[1];
        float az = mat_data[2] < 0.f ? -mat_data[2] : mat_data[2];
        float mag = ax > ay ? ax : ay;
        if (az > mag) mag = az;
        if (!(mag > 1.0f)) {
            fprintf(stderr,
                    "ERROR: expected Cube1 vs BigBase contact after 120 steps; "
                    "force matrix mag=%f (fx=%f fy=%f fz=%f)\n",
                    mag, mat_data[0], mat_data[1], mat_data[2]);
            free(mat_data); free(mat_shp);
            ovphysx_destroy_contact_binding(handle, cb);
            ovphysx_sample_destroy_stage(handle, &stage_attachment);
            ovphysx_destroy_instance(handle);
            ovphysx_shutdown();
            return 1;
        }
    }
    free(mat_data); free(mat_shp);

    printf("Contact binding sample completed successfully\n");

    /* 8. Destroy the contact binding. */
    ovphysx_destroy_contact_binding(handle, cb);

Unfiltered Contacts#

Pass filter_patterns=None and filters_per_sensor=0 to collect contacts with all bodies. The binding-creation functions in this and the next section are standalone helpers; substitute your own scene’s prim paths for the illustrative /World/robot/ee and /World/obstacle_* paths:

def create_unfiltered_binding(physx):
    return physx.create_contact_binding(
        sensor_patterns=["/World/robot/ee"],
        max_contact_data_count=512,
    )

In C:

#include <ovphysx/ovphysx.h>

static ovphysx_result_t create_unfiltered_binding(
    ovphysx_handle_t handle,
    ovphysx_contact_binding_handle_t* out_binding)
{
    ovphysx_string_t sensors[] = { ovphysx_cstr("/World/robot/ee") };
    return ovphysx_create_contact_binding(
        handle, sensors, 1, NULL, 0, 512, out_binding);
}

Multiple Sensors and Filters#

The filter_patterns array is flat and must have length len(sensor_patterns) * filters_per_sensor. Each block of filters_per_sensor entries corresponds to one sensor:

def create_filtered_binding(physx):
    # 2 sensors, 2 filters each -> 4 filter entries total
    return physx.create_contact_binding(
        sensor_patterns=["/World/robot_0/ee", "/World/robot_1/ee"],
        filter_patterns=[
            "/World/obstacle_A", "/World/obstacle_B",  # robot_0/ee filters
            "/World/obstacle_A", "/World/obstacle_B",  # robot_1/ee filters
        ],
        filters_per_sensor=2,
    )

Detailed Contact and Friction Data#

Use cb.max_contact_data_count to allocate reusable flat buffers. For each sensor/filter pair, counts[s, f] and start_indices[s, f] identify the valid slice inside the flat buffers.

cb.sensor_paths returns the resolved sensor paths in row order. cb.filter_paths returns a nested [sensor][filter] list in column order.

Create the binding with filter_patterns, filters_per_sensor > 0, and max_contact_data_count > 0 before calling read_contact_data() or read_friction_data(). The aggregate read_net_forces() and read_force_matrix() calls do not require this detailed-contact capacity. counts and start_indices can be int32 or uint32; NumPy’s default integer dtype is usually int64, so allocate these arrays with an explicit dtype.

import numpy as np

def read_detailed_data(binding):
    capacity = binding.max_contact_data_count
    contact_forces = np.zeros((capacity, 1), dtype=np.float32)
    positions = np.zeros((capacity, 3), dtype=np.float32)
    normals = np.zeros((capacity, 3), dtype=np.float32)
    separations = np.zeros((capacity, 1), dtype=np.float32)
    shape = (binding.sensor_count, binding.filter_count)
    counts = np.zeros(shape, dtype=np.int32)
    starts = np.zeros(shape, dtype=np.int32)

    binding.read_contact_data(
        contact_forces,
        positions,
        normals,
        separations,
        counts,
        starts,
    )

    sensor = 0
    contact_filter = 0
    start = starts[sensor, contact_filter]
    stop = start + counts[sensor, contact_filter]
    sensor_filter_positions = positions[start:stop]

    friction_forces = np.zeros((capacity, 3), dtype=np.float32)
    friction_points = np.zeros((capacity, 3), dtype=np.float32)
    friction_counts = np.zeros(shape, dtype=np.int32)
    friction_starts = np.zeros(shape, dtype=np.int32)

    binding.read_friction_data(
        friction_forces,
        friction_points,
        friction_counts,
        friction_starts,
    )
    return sensor_filter_positions, friction_forces, friction_points

Friction data is per friction anchor, not a pre-summed [S, F, 3] pair force. To build a pair-level friction force tensor, sum friction_forces[start:stop] for each (sensor, filter) pair using the matching friction_counts and friction_starts entries.

This flat representation matches the underlying PhysX tensor API and avoids a fixed per-pair contact-point dimension. Build a padded [S, F, K, D] view in application code only if that layout is useful for a specific algorithm.

Actor Identities in Raw Contact Data#

read_raw_contact_data() tells you which actor is touching which without a separate call. It takes six tensors: the four per-contact value buffers, plus two tensors whose columns carry the quantities that are only meaningful in pairs:

  • sensor_layout — [S, 2], per sensor: column 0 the contact count, column 1 its start index

  • actor_ids — [C, 2], per contact: column 0 the reporting sensor’s actor, column 1 the actor it contacted

Pairing them this way means the caller cannot get a count out of step with its start index, or a sensor id out of step with its other id. No filter dimension is required for an unfiltered binding. Slice the columns out as views when you want them separately — that costs no copy.

The examples in this section are sequential fragments. The path-resolution example reuses binding, sensor_actor_ids, and other_actor_ids from the raw read; the stale-ID example reuses its ids; and the filtering helper reuses the earlier numpy as np import.

import numpy as np

def read_raw_contact_data_with_ids(binding):
    capacity = binding.max_contact_data_count  # C
    S = binding.sensor_count                   # S (no filter dim needed)

    contact_forces = np.zeros((capacity, 1), dtype=np.float32)
    positions      = np.zeros((capacity, 3), dtype=np.float32)
    normals        = np.zeros((capacity, 3), dtype=np.float32)
    separations    = np.zeros((capacity, 1), dtype=np.float32)
    sensor_layout  = np.zeros((S, 2), dtype=np.int32)         # count, start
    actor_ids      = np.zeros((capacity, 2), dtype=np.uint64)  # sensor, other

    binding.read_raw_contact_data(
        contact_forces, positions, normals, separations,
        sensor_layout, actor_ids,
    )

    # Column views -- no copy.
    counts, starts = sensor_layout[:, 0], sensor_layout[:, 1]
    sensor_actor_ids, other_actor_ids = actor_ids[:, 0], actor_ids[:, 1]

    # Print the sensor and other-actor path for every contact point
    for sensor_idx in range(S):
        n = int(counts[sensor_idx])
        if n == 0:
            continue
        start = int(starts[sensor_idx])
        sensor_ids_slice = sensor_actor_ids[start : start + n]
        other_ids_slice  = other_actor_ids[start : start + n]

        # Resolve identities — both sensor and other IDs use the same namespace.
        # A column of actor_ids is a strided view, and the DLPack boundary requires
        # C-contiguous input, so make the slice contiguous before passing it.
        sensor_paths = binding.get_other_actor_paths_from_ids(np.ascontiguousarray(sensor_ids_slice))
        other_paths  = binding.get_other_actor_paths_from_ids(np.ascontiguousarray(other_ids_slice))

        for i in range(n):
            force = contact_forces[start + i, 0]
            print(f"  contact {start + i}: {sensor_paths[i]} ↔ {other_paths[i]}  force={force:.2f}")

Resolving Actor Paths#

get_other_actor_paths_from_ids() accepts a 1-D int64/uint64 array of actor IDs and returns a list of USD path strings in the same order. Note that a column of actor_ids is a strided view: the DLPack boundary requires C-contiguous input, so wrap a column slice in np.ascontiguousarray() (or .copy()) before passing it. That copy is on the diagnostic path only — the per-contact read itself takes the whole contiguous tensor. It works for both sensor_actor_ids and other_actor_ids since both use the same identity namespace:

ids = np.array([sensor_actor_ids[0], other_actor_ids[0]], dtype=np.uint64)
paths = binding.get_other_actor_paths_from_ids(ids)
# paths[0] → "/World/sensor_cube"
# paths[1] → "/World/other_cube"

ID encoding: actor IDs are opaque runtime handles, not encoded paths. Do not try to decode one yourself — the only supported way to get a path back is get_other_actor_paths_from_ids(). The encoding is the same regardless of how the stage was attached, so IDs from a CPU scene and a GPU scene mean the same thing.

ID lifetime: handles are valid for the lifetime of the attach. Do not use an ID after the stage is detached or replaced, and do not persist one across runs.

Stale IDs resolve to empty, not to their old path. Every non-zero ID is checked against the attached stage before it is resolved, so if you hold an ID and the actor is later removed, get_other_actor_paths_from_ids() returns "" rather than the path it used to name. Because you hold the IDs, that is an unambiguous signal:

paths = binding.get_other_actor_paths_from_ids(ids)
for actor_id, path in zip(ids, paths):
    if actor_id == 0:
        continue          # no actor for this slot
    if not path:
        ...               # the actor is gone — the ID is stale

The check is as precise as the backend’s notion of existence. On an ovstage attach a removed prim reports stale. On a USD stage a merely deactivated prim still resolves, because existence there follows prim validity and USD returns a valid prim for an inactive one — so do not rely on deactivation alone to invalidate an ID.

Truncation: when a step produces more contacts than max_contact_data_count, the runtime fills as many entries as fit and logs a warning. Per-sensor counts report only the contacts actually written and start indices are clamped to max_contact_data_count, so [start, start + count) is always a valid (possibly empty) slice. Increase max_contact_data_count at binding creation if you see this warning.

Ordering: contacts are grouped by sensor. Within a sensor, the order of contact points is whatever the simulation produced for that step and is not stable across steps.

Threading: contact reads follow the general ovphysx rule — a single instance is not thread-safe, so serialize read_raw_contact_data() against step() and against other reads on the same instance. Refer to Threading. get_other_actor_paths_from_ids() additionally populates a per-binding path cache, so concurrent calls on the same binding must be serialized even when no step is in flight.

Filtering is caller-owned. There is no binding-creation option to exclude expected support contacts (a floor, a conveyor). Sensor and filter patterns select which bodies participate; they do not suppress individual contact points, so dropping the ones you expect is application policy.

Resolution only goes ID → path, so build the decision table lazily rather than up front: resolve each ID the first time you see it, record the verdict against the ID, and from then on filter with an integer lookup. IDs are stable for the lifetime of the attach, so this settles after the first step and does no string work in steady state.

ignored: dict[int, bool] = {}   # actor id -> "expected support contact"

def is_ignored(binding, actor_id: int) -> bool:
    verdict = ignored.get(actor_id)
    if verdict is None:
        path = binding.get_other_actor_paths_from_ids(
            np.array([actor_id], dtype=np.uint64)
        )[0]
        verdict = path.endswith("/floor") or path.endswith("/conveyor")
        ignored[actor_id] = verdict
    return verdict

Rebuild the table if you attach a different stage — an ID only means anything against the attach it was read from. Note the memoized verdict outlives the actor: if a body can be removed mid-run, re-resolve rather than trusting a cached entry, since a removed actor’s ID resolves to "" and would not match the path test that produced the original verdict.

In C, ovphysx_read_raw_contact_data takes the same six tensors in the same order — sensor_layout_tensor then actor_ids_tensor after the four value buffers. Refer to the ovphysx_read_raw_contact_data declaration for the full parameter documentation.