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 authored USD 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 tutorial.

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

  • Every authored USD 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 GPU 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 prim.

    • 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#

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#

# NOTE: This file is included verbatim in documentation via literalinclude.
"""
ContactBinding sample: reading contact forces between sensor and filter bodies.

This sample demonstrates:
1. Creating a contact binding before the first simulation step
2. Reading per-sensor net contact forces  [S, 3]
3. Reading a sensor x filter force matrix [S, F, 3]
4. Using the context-manager form to ensure proper cleanup
"""

import numpy as np
from pathlib import Path

from ovphysx import PhysX


def attach_scene(physx, usd_path, stage_name):
    import ovstage

    if not ovstage.population.available():
        raise RuntimeError("ovstage population bridge is unavailable")

    stage = ovstage.Stage(stage_name)
    ordinal = 1
    try:
        ovstage.population.open_usd(stage, str(usd_path), ordinal=ordinal, domains=ovstage.PopulationDomain.PHYSICS)
        # Population does not seal: the caller owns ordinal lifecycle, and
        # attach_ovstage() reads at a sealed ordinal.
        stage.advance_write_floor(ordinal=ordinal).wait()
        physx.attach_ovstage(stage, read_ordinal=ordinal)
        return stage
    except Exception:
        stage.destroy()
        raise


def main():
    # --- 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).
        # 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/GroundPlane/CollisionMesh"],
            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)

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

        # Context-manager usage (alternative to manual destroy):
        # Reset the stage so we can reuse the same PhysX instance.
        physx.reset_stage()
        physx.wait_all()
        physx.detach_ovstage()
        stage.destroy()
        stage = None
        stage = attach_scene(physx, data_dir / "boxes_falling_on_groundplane.usda", "ovphysx-contact-sample-reload")
        physx.wait_all()

        with physx.create_contact_binding(sensor_patterns=["/World/Cube1"]) as cb2:
            for _ in range(60):
                physx.step(1.0 / 60.0)
            physx.wait_all()
            out = np.zeros((cb2.sensor_count, 3), dtype=np.float32)
            cb2.read_net_forces(out)
            print("Net forces (context manager):", out)
        # cb2 is automatically destroyed here

        print("Contact binding sample completed successfully")
    finally:
        if stage is not None:
            physx.detach_ovstage()
            stage.destroy()
        physx.release()
        print("Cleanup complete")


if __name__ == "__main__":
    main()

C#

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
//

// NOTE: This file is included verbatim in documentation via literalinclude.

#include <ovphysx/ovphysx.h>
#include <ovphysx/ovphysx_types.h>
#include "ovstage_sample.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

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

static int check_result(ovphysx_result_t r, const char* ctx)
{
    if (r.status != OVPHYSX_API_SUCCESS) {
        fprintf(stderr, "ERROR in %s: ", ctx);
        ovphysx_string_t err = ovphysx_get_last_error();
        if (err.ptr && err.length > 0)
            fprintf(stderr, "%.*s\n", (int)err.length, err.ptr);
        else
            fprintf(stderr, "status=%d\n", (int)r.status);
        return 0;
    }
    return 1;
}

static int check_enqueue(ovphysx_enqueue_result_t r, const char* ctx)
{
    if (r.status != OVPHYSX_API_SUCCESS) {
        fprintf(stderr, "ERROR in %s: ", ctx);
        ovphysx_string_t err = ovphysx_get_last_error();
        if (err.ptr && err.length > 0)
            fprintf(stderr, "%.*s\n", (int)err.length, err.ptr);
        else
            fprintf(stderr, "status=%d\n", (int)r.status);
        return 0;
    }
    return 1;
}

static int wait_op(ovphysx_handle_t handle, ovphysx_op_index_t op_index, const char* ctx)
{
    ovphysx_op_wait_result_t wait_result = {0};
    ovphysx_result_t r = ovphysx_wait_op(handle, op_index, UINT64_MAX, &wait_result);
    int has_errors = (wait_result.num_errors > 0);
    ovphysx_destroy_wait_result(&wait_result);
    if (has_errors) {
        fprintf(stderr, "ERROR in %s: async operation failed\n", ctx);
        return 0;
    }
    if (r.status != OVPHYSX_API_SUCCESS) {
        fprintf(stderr, "ERROR in %s: wait failed (status=%d)\n", ctx, (int)r.status);
        return 0;
    }
    return 1;
}

/* Allocate a 2-D float32 DLTensor on the CPU. Caller frees data and shape. */
static DLTensor make_tensor_f32_2d(size_t rows, size_t cols, float** out_data, int64_t** out_shape)
{
    DLTensor t;
    memset(&t, 0, sizeof(DLTensor));
    *out_data  = (float*)calloc(rows * cols, sizeof(float));
    *out_shape = (int64_t*)malloc(2 * sizeof(int64_t));
    (*out_shape)[0] = (int64_t)rows;
    (*out_shape)[1] = (int64_t)cols;
    t.data         = *out_data;
    t.ndim         = 2;
    t.shape        = *out_shape;
    t.strides      = NULL;
    t.byte_offset  = 0;
    t.dtype.code   = kDLFloat;
    t.dtype.bits   = 32;
    t.dtype.lanes  = 1;
    t.device.device_type = kDLCPU;
    t.device.device_id   = 0;
    return t;
}

/* Allocate a 3-D float32 DLTensor on the CPU. Caller frees data and shape. */
static DLTensor make_tensor_f32_3d(size_t d0, size_t d1, size_t d2,
                                   float** out_data, int64_t** out_shape)
{
    DLTensor t;
    memset(&t, 0, sizeof(DLTensor));
    *out_data  = (float*)calloc(d0 * d1 * d2, sizeof(float));
    *out_shape = (int64_t*)malloc(3 * sizeof(int64_t));
    (*out_shape)[0] = (int64_t)d0;
    (*out_shape)[1] = (int64_t)d1;
    (*out_shape)[2] = (int64_t)d2;
    t.data         = *out_data;
    t.ndim         = 3;
    t.shape        = *out_shape;
    t.strides      = NULL;
    t.byte_offset  = 0;
    t.dtype.code   = kDLFloat;
    t.dtype.bits   = 32;
    t.dtype.lanes  = 1;
    t.device.device_type = kDLCPU;
    t.device.device_id   = 0;
    return t;
}

static int run(void)
{
    ovphysx_result_t r;
    ovphysx_enqueue_result_t er;

    /* 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 contact binding BEFORE the first step.
     *    sensor: the falling box.  filter: the ground plane. */
    ovphysx_string_t sensors[1];
    sensors[0] = ovphysx_cstr("/World/Cube1");

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

    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, /* flat contact-data capacity */
                                       &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 net contact forces: shape [S, 3].
     *    dt is taken automatically 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 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]);
        }
    }
    free(mat_data); free(mat_shp);

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

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

    ovphysx_sample_destroy_stage(handle, &stage_attachment);
    ovphysx_destroy_instance(handle);
    ovphysx_shutdown();
    printf("Cleanup complete\n");

    return 0;
}

int main(void) {
    int rc = run();
    return rc;
}

Unfiltered Contacts#

Pass filter_patterns=None and filters_per_sensor=0 to collect contacts with all bodies:

cb = physx.create_contact_binding(
    sensor_patterns=["/World/robot/ee"],
    max_contact_data_count=512,
)

In C:

ovphysx_string_t sensors[] = { ovphysx_cstr("/World/robot/ee") };
ovphysx_contact_binding_handle_t cb;
ovphysx_create_contact_binding(handle, sensors, 1, NULL, 0, 512, &cb);

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:

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

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 may be int32 or uint32; NumPy’s default integer dtype is usually int64, so allocate these arrays with an explicit dtype.

If the flat buffers are too small, the read raises RuntimeError instead of returning incomplete data. Do not use the payload arrays after that error. counts and start_indices still contain the full required layout. Use int(np.max(start_indices.astype(np.int64) + counts.astype(np.int64))) as max_contact_data_count when recreating the binding for subsequent simulation steps. Recreating a binding does not recover the overflowing step’s payload.

C = cb.max_contact_data_count
contact_forces = np.zeros((C, 1), dtype=np.float32)
positions = np.zeros((C, 3), dtype=np.float32)
normals = np.zeros((C, 3), dtype=np.float32)
separations = np.zeros((C, 1), dtype=np.float32)
counts = np.zeros((cb.sensor_count, cb.filter_count), dtype=np.int32)
starts = np.zeros((cb.sensor_count, cb.filter_count), dtype=np.int32)

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

s = 0
f = 0
start = starts[s, f]
stop = start + counts[s, f]
sensor_filter_positions = positions[start:stop]

friction_forces = np.zeros((C, 3), dtype=np.float32)
friction_points = np.zeros((C, 3), dtype=np.float32)
friction_counts = np.zeros((cb.sensor_count, cb.filter_count), dtype=np.int32)
friction_starts = np.zeros((cb.sensor_count, cb.filter_count), dtype=np.int32)

cb.read_friction_data(
    friction_forces,
    friction_points,
    friction_counts,
    friction_starts,
)

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, ...] view in application code only if that layout is useful for a specific algorithm.