Contact Binding – Reading Contact Forces#
Deprecated. Contact bindings are a compatibility surface: C declarations live in
ovphysx_legacy.h(ovphysx.hstill includes that header), and PythonPhysX.create_contact_binding()emitsDeprecationWarning. This tutorial documents the supported behavior. No replacement API or removal date is implied.
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.
Sensor selection 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. Runtime-only clones (which have no USD prim) use the schema
metadata copied from the corresponding source object. A cloned collider whose
source collider lacks the schema is rejected even if its parent body has it.
On CPU simulation and GPU dynamics with CPU readback, tagging a child collider makes it eligible as a sensor but does not enable reporting for its owning body. Enable reporting on the body as well (or on the owning articulation link or root), or the sensor depends on reports from the other actor in each contact pair. If neither actor reports matching contacts, the child sensor reads zero force. See Child collider sensors for an authoring example and where to configure the reporting threshold and pairs.
For these modes, enable reporting on the owner but select only the child as a binding sensor. If the owner is also selected as a sensor in this or another live contact binding on the same attachment, the owner takes precedence: contacts are credited to the owner and the child reads zero. Check wildcard patterns for unintended owner matches, and establish the bindings before stepping. Session reads support selecting both the owner and child in these modes.
On DirectGPU, a separate child collider carrying PhysxContactReportAPI is
accepted by the deprecated contact binding, but the runtime logs an unsupported
collider warning and the child reads zero force. DirectGPU session queries omit
that child row; select the owning body or articulation link instead.
Prerequisites#
Complete the Tensor Bindings (deprecated) tutorial.
Your USD scene has rigid body prims in contact (or that come into contact during simulation). This tutorial uses
boxes_falling_on_groundplane.usda, which ships with every package underovphysx/samples/data/in the wheel,<sdk-root>/samples/data/in the C/C++ SDK, andtests/data/in a repository checkout.Every prim you name in
sensor_patternshasPhysxContactReportAPIapplied:def Mesh "box" ( prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsCollisionAPI", "PhysxContactReportAPI"] ) { float physxContactReport:threshold = 0 }
The optional
physxContactReport:thresholdis the force below which contacts are not reported; the bundled sample scene uses0to avoid excluding contacts by force.The schema must sit on the prim you actually name as the sensor. A schema on the owning body does not make an untagged child collider eligible. For a child sensor, apply the schema to the child and enable its owner’s reporting as described above. Do not also select the owner as a sensor in any live contact binding on the same attachment.
For CUDA output tensors, enable DirectGPU TensorAPI before creating the
PhysXinstance;physxScene:enableGPUDynamics=truealone only selects GPU dynamics. Refer to Warmup and Determinism.
Key Concepts#
Create the binding before the first step whose contacts you want to observe. No contact data exists until at least one
step()orstep_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 emitsResourceWarningwhen it eventually releases the native binding.Reading before the first step returns all-zeros tensors.
On CPU and GPU dynamics with CPU readback, sleeping pairs stop producing persistent contact reports. A settled body can return zero forces and no raw contacts while still touching another body. This is independent of environment IDs; DirectGPU disables sleeping. For continuous reporting, disable sleeping before attachment, or wake the bodies and step again before reading. See Sleeping contacts.
dtfor the impulse-to-force conversion (force = impulse / dt) is taken automatically from the last successfulstep()orstep_sync()call that simulated. You do not pass it manually.A step with
dt == 0is accepted but simulates nothing: it neither produces nor clears contacts, and it does not change the conversiondt. Every read after it returns what it returned before it – the contacts and forces of the last step with a positivedt.Result tensor shapes:
Net normal or net friction forces:
[S, 3]– one 3-D force vector per sensor.Normal or friction force matrix:
[S, F, 3]— force vectors per (sensor, filter) pair.Detailed contact data: scalar normal 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.
Force Components and Filtering#
Net normal and net friction forces are reported separately as world-space vectors for each sensor. Both include all reported contacts, regardless of the configured filters. Adding the two vectors from the same simulation step yields the total contact force.
The normal and friction force matrices contain only contributions from the configured sensor/filter pairs. Adding the two matrices from the same simulation step yields total contact forces for those pairs. For example, if a foot touches the floor and a wall but its filter selects only the floor, the friction matrix and detailed friction data omit the wall contribution. The net friction force includes both.
The net friction force is the vector sum of the forces at the friction anchors. Opposing forces can cancel while still producing a torque, such as when a box rotates on the ground without translating. The net friction force does not represent that torque.
Python#
Full Binding + Destroy#
This sample creates a contact binding on boxes_falling_on_groundplane.usda,
steps until the sensor lands, and prints the normal, friction, and total contact
forces for both net forces and the force matrix. It then destroys the binding
explicitly:
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# NOTE: This file is included verbatim in documentation via literalinclude.
# Markers [tutorial-*] define the included ranges.
"""
ContactBinding sample: reading contact forces between sensor and filter bodies.
This sample exercises the deprecated contact-binding API during its compatibility period.
This sample demonstrates:
1. Creating a contact binding before the first simulation step
2. Reading net normal and friction forces per sensor [S, 3] and adding them
3. Reading normal and friction force matrices [S, F, 3] and adding them
4. Using the context-manager form to ensure proper cleanup
"""
# @implements REQ-CAPI-OVSTAGE-XFORM-001
# @covers AC-1
# @implements REQ-PYTHON-SAMPLE-002
# @covers AC-1 AC-2
import numpy as np
from pathlib import Path
import ovphysx
from ovphysx.samples.python_samples.schema_support import physx_schema_paths
from ovphysx import PhysX
_physx_schemas_registered = False
def attach_scene(physx, usd_path, stage_name):
import ovstage
if not ovstage.population.available():
raise RuntimeError("ovstage population bridge is unavailable")
# Register external PhysX and Newton schemas before the first population.
global _physx_schemas_registered
if not _physx_schemas_registered:
ovstage.population.register_usd_schemas(
[*[str(p) for p in physx_schema_paths()], str(ovphysx.newton_schema_root())]
)
_physx_schemas_registered = True
stage = ovstage.Stage(stage_name)
ordinal = 1
try:
ovstage.population.open_usd(stage, str(usd_path), ordinal=ordinal, domains=ovstage.PopulationDomain.ALL)
stage.advance_write_floor(ordinal=ordinal).wait()
physx.wait_task(physx.attach_ovstage(stage, read_ordinal=ordinal))
return stage
except Exception:
if physx.is_stage_attached(stage):
physx.wait_task(physx.detach_ovstage())
stage.destroy()
raise
def main():
# [tutorial-start]
# --- 1. Initialize SDK and load scene ---
# Prefer package data so a copied sample works. Fall back to the checked-in
# sample's adjacent data directory when package data is absent. Resolve it
# before creating an instance so a missing scene does not skip destroy().
scene_name = "boxes_falling_on_groundplane.usda"
usd_path = Path(ovphysx.__file__).resolve().parent / "samples" / "data" / scene_name
if not usd_path.is_file():
usd_path = Path(__file__).resolve().parent.parent / "data" / scene_name
if not usd_path.is_file():
raise FileNotFoundError(f"ovphysx sample data is missing: {usd_path}")
data_dir = usd_path.parent
PhysX.set_cpu_mode(True)
physx = PhysX()
stage = None
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 normal and friction forces: shape [S, 3] ---
# dt is taken automatically from the last successful stepping call.
# Read both components from the same step before adding them.
net_normal_forces = np.zeros((sensor_count, 3), dtype=np.float32)
net_friction_forces = np.zeros_like(net_normal_forces)
cb.read_net_normal_forces(net_normal_forces)
cb.read_net_friction_forces(net_friction_forces)
net_forces = net_normal_forces + net_friction_forces
print("Net normal contact forces [S, 3]:", net_normal_forces)
print("Net friction contact forces [S, 3]:", net_friction_forces)
print("Total net contact forces [S, 3]:", net_forces)
# --- 5. Read normal and friction force matrices: shape [S, F, 3] ---
normal_force_matrix = np.zeros((sensor_count, filter_count, 3), dtype=np.float32)
friction_force_matrix = np.zeros_like(normal_force_matrix)
cb.read_normal_force_matrix(normal_force_matrix)
cb.read_friction_force_matrix(friction_force_matrix)
force_matrix = normal_force_matrix + friction_force_matrix
print("Normal contact force matrix [S, F, 3]:", normal_force_matrix)
print("Friction contact force matrix [S, F, 3]:", friction_force_matrix)
print("Total 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()
# [tutorial-end]
# Context-manager usage (alternative to manual destroy):
# Detach the stage so the same PhysX instance can be reused.
physx.wait_task(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()
# [tutorial-context-manager]
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_normal_forces(out)
print("Net normal contact forces (context manager):", out)
# cb2 is automatically destroyed here
# [tutorial-context-manager-end]
print("Contact binding sample completed successfully")
finally:
if stage is not None:
physx.wait_task(physx.detach_ovstage())
stage.destroy()
physx.destroy()
print("Cleanup complete")
if __name__ == "__main__":
main()
After 120 steps the normal and total contact force vectors are on the order of
Cube1’s weight (about 9810 N in Z); friction is approximately zero for the settled
box. The sample prints these components for both net forces and the force matrix. 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.
Recommended Context-Manager Form#
The context-manager form releases the native binding on scope exit, so no
explicit destroy() call is required:
import numpy as np
def read_contacts_with_context_manager(physx):
with physx.create_contact_binding(sensor_patterns=["/World/Cube1"]) as binding:
for _ in range(60):
physx.step_sync(1.0 / 60.0)
forces = np.zeros((binding.sensor_count, 3), dtype=np.float32)
binding.read_net_normal_forces(forces)
return forces
Reading Normal and Friction Forces#
read_net_normal_forces() returns the net normal force on each sensor.
read_net_friction_forces() returns the corresponding net friction force.
Both methods work without filters or detailed-contact buffer capacity.
read_normal_force_matrix() and read_friction_force_matrix() return the
corresponding force components for each configured sensor/filter pair. Both
matrices are independent of detailed-contact buffer capacity.
For an existing binding and output arrays of shape [sensor_count, 3], the
total contact force is obtained by adding the results from the same simulation
step:
binding.read_net_normal_forces(normal_forces)
binding.read_net_friction_forces(friction_forces)
total_forces = normal_forces + friction_forces
For output arrays of shape [sensor_count, filter_count, 3], the corresponding
total forces for the configured pairs are obtained as follows:
binding.read_normal_force_matrix(normal_matrix)
binding.read_friction_force_matrix(friction_matrix)
total_force_matrix = normal_matrix + friction_matrix
read_net_forces(), read_force_matrix(), and read_contact_data() remain as
deprecated aliases of read_net_normal_forces(), read_normal_force_matrix(),
and read_normal_contact_data(), respectively. Their force values remain
normal-only; existing calculations that add separately reported friction retain
their meaning.
read_friction_data() is a deprecated alias of read_friction_contact_data()
with unchanged results.
C#
The C sample performs the same create, step, read, and destroy sequence through the C API:
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// NOTE: This file is included in the documentation via literalinclude.
// The tutorial marker comments below define the included range.
// This sample exercises the deprecated contact-binding API during its compatibility period.
#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_task(ovphysx_instance_handle_t handle, ovphysx_enqueue_result_t task, const char* ctx)
{
ovphysx_wait_task_result_t wait_result = {0};
ovphysx_result_t r = ovphysx_wait_task(
handle, task, OVPHYSX_TIMEOUT_INFINITE, &wait_result);
int has_errors = (wait_result.num_errors > 0);
ovphysx_destroy_wait_task_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;
// [tutorial-start]
/* 1. Initialize SDK */
r = ovphysx_initialize();
if (!check_result(r, "ovphysx_initialize")) return 1;
ovphysx_instance_handle_t handle = 0;
r = ovphysx_create_instance(&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_task(handle, er, "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 normal and friction forces, shape [S, 3].
* dt is taken from the last successful stepping call.
* Read both components from the same step before adding them. */
float* net_normal_data = NULL;
int64_t* net_normal_shp = NULL;
DLTensor net_normal_tensor = make_tensor_f32_2d(
(size_t)sensor_count, 3, &net_normal_data, &net_normal_shp);
float* net_friction_data = NULL;
int64_t* net_friction_shp = NULL;
DLTensor net_friction_tensor = make_tensor_f32_2d(
(size_t)sensor_count, 3, &net_friction_data, &net_friction_shp);
if (!check_result(ovphysx_read_contact_net_normal_forces(handle, cb, &net_normal_tensor),
"ovphysx_read_contact_net_normal_forces") ||
!check_result(ovphysx_read_contact_net_friction_forces(handle, cb, &net_friction_tensor),
"ovphysx_read_contact_net_friction_forces")) {
free(net_normal_data); free(net_normal_shp);
free(net_friction_data); free(net_friction_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 normal: fx=%.3f fy=%.3f fz=%.3f\n",
s, net_normal_data[s * 3], net_normal_data[s * 3 + 1], net_normal_data[s * 3 + 2]);
printf(" sensor %d friction: fx=%.3f fy=%.3f fz=%.3f\n",
s, net_friction_data[s * 3], net_friction_data[s * 3 + 1], net_friction_data[s * 3 + 2]);
printf(" sensor %d total: fx=%.3f fy=%.3f fz=%.3f\n",
s,
net_normal_data[s * 3] + net_friction_data[s * 3],
net_normal_data[s * 3 + 1] + net_friction_data[s * 3 + 1],
net_normal_data[s * 3 + 2] + net_friction_data[s * 3 + 2]);
}
free(net_normal_data); free(net_normal_shp);
free(net_friction_data); free(net_friction_shp);
/* 7. Read normal and friction force matrices, shape [S, F, 3]. */
float* mat_normal_data = NULL;
int64_t* mat_normal_shp = NULL;
DLTensor mat_normal_tensor = make_tensor_f32_3d(
(size_t)sensor_count, (size_t)filter_count, 3,
&mat_normal_data, &mat_normal_shp);
float* mat_friction_data = NULL;
int64_t* mat_friction_shp = NULL;
DLTensor mat_friction_tensor = make_tensor_f32_3d(
(size_t)sensor_count, (size_t)filter_count, 3,
&mat_friction_data, &mat_friction_shp);
if (!check_result(ovphysx_read_contact_normal_force_matrix(handle, cb, &mat_normal_tensor),
"ovphysx_read_contact_normal_force_matrix") ||
!check_result(ovphysx_read_contact_friction_force_matrix(handle, cb, &mat_friction_tensor),
"ovphysx_read_contact_friction_force_matrix")) {
free(mat_normal_data); free(mat_normal_shp);
free(mat_friction_data); free(mat_friction_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] normal: fx=%.3f fy=%.3f fz=%.3f\n",
s, f, mat_normal_data[base], mat_normal_data[base + 1], mat_normal_data[base + 2]);
printf(" [%d][%d] friction: fx=%.3f fy=%.3f fz=%.3f\n",
s, f, mat_friction_data[base], mat_friction_data[base + 1], mat_friction_data[base + 2]);
printf(" [%d][%d] total: fx=%.3f fy=%.3f fz=%.3f\n",
s, f,
mat_normal_data[base] + mat_friction_data[base],
mat_normal_data[base + 1] + mat_friction_data[base + 1],
mat_normal_data[base + 2] + mat_friction_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_normal_data[0] < 0.f ? -mat_normal_data[0] : mat_normal_data[0];
float ay = mat_normal_data[1] < 0.f ? -mat_normal_data[1] : mat_normal_data[1];
float az = mat_normal_data[2] < 0.f ? -mat_normal_data[2] : mat_normal_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_normal_data[0], mat_normal_data[1], mat_normal_data[2]);
free(mat_normal_data); free(mat_normal_shp);
free(mat_friction_data); free(mat_friction_shp);
ovphysx_destroy_contact_binding(handle, cb);
ovphysx_sample_destroy_stage(handle, &stage_attachment);
ovphysx_destroy_instance(handle);
ovphysx_shutdown();
return 1;
}
}
free(mat_normal_data); free(mat_normal_shp);
free(mat_friction_data); free(mat_friction_shp);
printf("Contact binding sample completed successfully\n");
/* 8. Destroy the contact binding. */
ovphysx_destroy_contact_binding(handle, cb);
// [tutorial-end]
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. 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_instance_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#
read_normal_contact_data() returns scalar normal forces, contact normals, points,
and separations. Each normal force vector is obtained by multiplying the scalar
force by its corresponding contact normal. read_friction_contact_data() returns
friction force vectors and their anchor points. Both methods report data for
the configured sensor/filter pairs.
Friction anchors need not correspond one-to-one with contact points. Each method’s count/start-index tensors describe its own data. Summing detailed friction data reproduces the net friction only when the filters cover all relevant contacts without double counting and the buffers contain all reported anchors.
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_normal_contact_data() or
read_friction_contact_data(). The aggregate read_net_normal_forces(),
read_net_friction_forces(), read_normal_force_matrix(), and
read_friction_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)
required_contacts = binding.read_normal_contact_data(
contact_forces,
positions,
normals,
separations,
counts,
starts,
)
# required_contacts is the complete demand. If it exceeds capacity, counts/starts
# still describe only the in-range prefix that was written.
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)
required_friction = binding.read_friction_contact_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 indexactor_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.
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, as shown in the raw-read helper above.
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. A non-zero ID resolving to an empty path therefore identifies a stale
actor; an ID of zero means there was no actor in that slot.
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.
import numpy as np
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 — followed
by out_required_contact_count. That output is the total demand before truncation. If
it exceeds the binding’s max_contact_data_count, the function returns
OVPHYSX_API_BUFFER_TOO_SMALL while leaving a valid prefix in the tensors. Refer to the
ovphysx_read_raw_contact_data declaration for the full parameter
documentation.