Developer Guide#

Use this guide when you integrate, build, and operate ovphysx in your own application. You learn how to build the SDK, run samples, and apply core runtime rules for synchronization, threading, and resource ownership.

Relationship and Consumption Model#

ovphysx provides a stable C API and Python bindings on top of the Omni PhysX runtime, which itself uses the PhysX SDK. Use ovphysx when you need USD-based physics simulation outside Kit with a small, self-contained SDK.

  • Python: pip install ovphysx and from ovphysx import PhysX

  • C/C++: download the SDK from the GitHub Releases page, include headers from include/ovphysx/, and link against ovphysx::ovphysx via CMake

Samples and Tutorials#

The ovphysx samples are runnable references for SDK and wheel usage, designed to run in a clean environment matching how end users consume the wheel or SDK.

Note: In the source repository, samples live under tests/c_samples/ and tests/python_samples/. In the SDK package, these are installed to samples/c_samples/ and samples/python_samples/ respectively.

Python samples (tests/python_samples/):

Sample

Tutorial

Feature

hello_world.py

Hello World

Load USD + step (minimal workflow)

tensor_bindings.py

Tensor Bindings

Read/write simulation data via tensor bindings

clone.py

Cloning

Replicate environments with the clone API

contact_binding.py

Contact Binding

Read contact forces via sensor/filter bindings

tensor_bindings_views.py

Build “TensorAPI-like” view wrappers (advanced)

Extra Python samples (tests/python_samples_extra/):

Sample

Tutorial

Feature

visual_rerunio_sample/visualize_rigid_bodies.py

Rendering Handoff

Visualize rigid body simulation with Rerun

C/C++ samples (tests/c_samples/):

Sample

Tutorial

Feature

hello_world_c/

Hello World

Minimal C hello world

tensor_bindings_c/

Tensor Bindings

CPU tensor read/write

tensor_bindings_gpu_c/

GPU tensor read/write with CUDA

clone_c/

Cloning

Scene cloning

contact_binding_c/

Contact Binding

Contact force reading

physx_interop_cpp/

PhysX Interop

Direct PhysX SDK pointer access

For SDK setup, see SDK Quickstart.

For tensor binding shape/read/write semantics, see Tensor Bindings. Canonical enum-level definitions are in include/ovphysx/ovphysx_types.h (ovphysx_tensor_type_t).

Execution Model#

ovphysx uses a stream-ordered execution model:

  • Calls are enqueued in submission order and observe prior writes without extra sync.

  • Asynchronous calls return an op_index; wait on it before consuming results outside the stream.

  • Synchronous calls complete before returning and do not yield an op_index.

Use ovphysx_wait_op() (or PhysX.wait_op() in Python) to:

  • synchronize before reading or modifying tensors on CPU/GPU if they’re currently accessed by asynchronous operations inside ovphysx

  • ensure correctness before external side-effects (logging, rendering, network I/O)

Configuration#

The SDK uses a typed config system for known settings, plus a Carbonite escape hatch for arbitrary paths. Config entries are built with convenience functions from ovphysx_config.h and passed at initialization or set at runtime.

C#

#include "ovphysx/ovphysx_config.h"

// At initialization
ovphysx_create_args args = OVPHYSX_CREATE_ARGS_DEFAULT;
ovphysx_config_entry_t entries[] = {
    ovphysx_config_entry_disable_contact_processing(true),
    ovphysx_config_entry_num_threads(4),
    ovphysx_config_entry_carbonite(
        OVPHYSX_LITERAL("/physics/fabricUpdateVelocities"),
        OVPHYSX_LITERAL("true")),
};
args.config_entries = entries;
args.config_entry_count = 3;
ovphysx_create_instance(&args, &handle);

// At runtime
ovphysx_set_global_config(ovphysx_config_entry_num_threads(8));

// Typed getter
int32_t threads;
ovphysx_get_global_config_int32(OVPHYSX_CONFIG_NUM_THREADS, &threads);

Python#

from ovphysx import PhysX, PhysXConfig, ConfigBool, ConfigInt32

# At initialization
physx = PhysX(config=PhysXConfig(
    disable_contact_processing=True,
    num_threads=4,
    carbonite_overrides={"/physics/fabricUpdateVelocities": True},
))

# Typed getter
threads = physx.get_config_int32(ConfigInt32.NUM_THREADS)
enabled = physx.get_config_bool(ConfigBool.DISABLE_CONTACT_PROCESSING)

The carbonite_overrides dict accepts arbitrary Carbonite setting paths for settings not yet covered by the typed fields. Value types are auto-detected from the string representation. Using a carbonite_overrides key that targets a path already covered by a typed field raises ValueError.

Note: carbonite_overrides is write-only. There is no getter for arbitrary Carbonite paths; use the typed getters (get_config_bool, get_config_int32, etc.) for known settings.

Config is process-global (Carbonite-backed). All instances in the same process share the same config state.

Multi-Instance Support#

The SDK supports multiple independent PhysX instances running concurrently within the same process. Config is internally handled by Carbonite and therefore per-process, so different instances cannot use different config values in the same process.

Important: The Carbonite framework and its embedded plugin stack (including the Python interpreter) cannot be cleanly finalized and re-initialized within the same process. This means destroying all instances and then creating new ones is not supported as a general pattern. If you need isolated create/destroy cycles (e.g., for testing), run each cycle in a separate subprocess.

Operation Indices and Polling#

op_index values are single-use. After a successful wait, the index is consumed and must not be used again. Polling is supported by passing timeout_ns = 0 to wait_op.

Threading#

  • Multiple ovphysx instances are safe to use concurrently across threads.

  • A single instance is not thread-safe. Serialize access externally.

  • Do not wait on the same op_index from multiple threads.

Ownership and Lifetimes#

  • Tensor bindings, contact bindings, and attribute bindings own internal resources. They are automatically destroyed when the parent instance is destroyed via ovphysx_destroy_instance(). Explicit per-binding destruction (ovphysx_destroy_tensor_binding, ovphysx_destroy_contact_binding) is available for releasing resources earlier but is not required before instance teardown.

  • On failure, call ovphysx_get_last_error() on the same thread to retrieve the error string (valid until the next ovphysx call on that thread).

  • PhysX pointers from ovphysx_get_physx_ptr() are owned by ovphysx — do not call release() on them. See PhysX Pointer Interop for lifetime details.

  • Contact report buffers from ovphysx_get_contact_report() are valid until the next simulation step.

Dependency Management#

For SDK and wheel users, dependencies are bundled with the package:

  • Runtime dependencies are loaded from libDir/deps/ in the installed layout.

  • The wheel includes the native runtime stack and required plugins.

  • No additional dependency fetch step is required for normal package usage.

  • Auto-detects library location via getLibraryDirectory()

  • Sets PYTHONHOME for scripting plugin support

  • Pre-loads shared libraries with RTLD_GLOBAL for plugin symbol resolution

  • Offline-capable

Error Handling#

For C:

  • Check result.status on every call.

  • On failure, call ovphysx_get_last_error() on the same thread to retrieve the error string (valid until the next ovphysx call on that thread).

  • For ovphysx_wait_op(), iterate over error_op_indices and call ovphysx_get_last_op_error() per failed op index, then free the result with ovphysx_destroy_wait_result().

For Python:

  • Runtime errors are raised on failed calls.

  • Use try/finally or context managers to ensure bindings are destroyed.

GPU Warmup and Determinism#

GPU tensor reads require a warmup step that initializes DirectGPU buffers. This is done automatically on the first tensor operation. If deterministic initial state matters, call ovphysx_warmup_gpu() explicitly after USD load and before the first tensor read.

Scene Cloning#

The SDK provides a clone API for replicating sub-sections of a scene:

  • Clones are created in the internal representation (no USD prims)

  • Optimized for large-scale replication and simulation throughput

  • Preserves physics properties, materials, and constraints

  • Non-blocking async execution with explicit completion tracking

For examples see the Cloning tutorials.

Requirements:

  • Source hierarchy must exist in the loaded USD stage

  • Source prims must have physics components

  • Target paths must not already exist

Remote USD Loading#

add_usd() accepts any URI that the Omniverse Client Library supports:

  • Local paths: /path/to/scene.usd (as before)

  • Omniverse Nucleus: omniverse://server/path/scene.usd

  • S3 (HTTPS): https://my-bucket.s3.us-east-1.amazonaws.com/path/scene.usd

  • Azure Blob: https://account.blob.core.windows.net/container/scene.usd

The client library is loaded automatically when an ovphysx instance is created. No additional setup is needed for Nucleus URIs when the server allows anonymous access.

Note: Use HTTPS S3 URLs (virtual-hosted style), not s3:// URIs. The OmniClient library resolves S3 assets via HTTPS and adds AWS authentication automatically when credentials are configured.

Configuring S3 Credentials#

For private S3 buckets, configure credentials before calling add_usd():

Python#

import ovphysx

physx = ovphysx.PhysX()

ovphysx.configure_s3(
    host="my-bucket.s3.us-east-1.amazonaws.com",
    bucket="my-bucket",
    region="us-east-1",
    access_key_id="AKIA...",
    secret_access_key="...",
    session_token=None,       # optional, for temporary credentials
)

physx.add_usd("https://my-bucket.s3.us-east-1.amazonaws.com/scenes/robot.usd")

C#

ovphysx_configure_s3(
    "my-bucket.s3.us-east-1.amazonaws.com",  /* host */
    "my-bucket",      /* bucket */
    "us-east-1",      /* region */
    "AKIA...",         /* access_key_id */
    "...",             /* secret_access_key */
    NULL               /* session_token (optional) */
);

C++#

ovphysx::PhysX::configureS3(
    "my-bucket.s3.us-east-1.amazonaws.com", "my-bucket", "us-east-1",
    "AKIA...", "...");

Configuring Azure SAS Tokens#

Python#

ovphysx.configure_azure_sas(
    host="myaccount.blob.core.windows.net",
    container="usd-assets",
    sas_token="sv=2021-06-08&ss=b&...",
)

C#

ovphysx_configure_azure_sas(
    "myaccount.blob.core.windows.net",
    "usd-assets",
    "sv=2021-06-08&ss=b&..."
);

Notes#

  • Credentials are process-global — they apply to all ovphysx instances.

  • Configure credentials before add_usd().

  • An ovphysx instance must exist before calling credential functions (the runtime must be loaded).

  • The client library dependency is shared with ovrtx (Kit rendering) via the Carbonite plugin system — no static linking or version conflicts.

Scene Queries#

ovphysx provides three scene query functions covering raycast, sweep, and overlap:

Function

Purpose

Geometry input

ovphysx_raycast

Cast a ray

Origin + direction

ovphysx_sweep

Move a shape along a direction

Geometry desc + direction

ovphysx_overlap

Test shape overlap at a position

Geometry desc

Each function accepts a mode parameter:

  • CLOSEST – return the single closest hit (0 or 1 result)

  • ANY – return whether any hit exists (0 or 1, hit fields zeroed)

  • ALL – return all hits

Geometry types for sweep/overlap:

  • SPHERE – radius + center position

  • BOX – half-extents + position + orientation quaternion

  • SHAPE – any UsdGeomGPrim by prim path (meshes use convex approximation internally)

Hit results are stored in an internal buffer owned by the ovphysx instance, valid until the next scene query call on the same instance.

Python example#

import ovphysx
from ovphysx import SceneQueryMode, SceneQueryGeometryType

# Raycast downward
hits = physx.raycast(
    origin=[0, 10, 0],
    direction=[0, -1, 0],
    distance=100.0,
    mode=SceneQueryMode.CLOSEST,
)
if hits:
    print(f"Hit at distance {hits[0]['distance']}")

# Sweep a sphere
hits = physx.sweep(
    geometry_type=SceneQueryGeometryType.SPHERE,
    direction=[1, 0, 0],
    distance=50.0,
    radius=0.5,
    position=[0, 1, 0],
)

# Overlap test
hits = physx.overlap(
    geometry_type=SceneQueryGeometryType.BOX,
    half_extent=[1, 1, 1],
    position=[0, 0, 0],
    rotation=[0, 0, 0, 1],
)

Path encoding#

Hit results contain collision, rigid_body, and material fields as uint64-encoded SdfPaths matching the internal Omni PhysX representation. Consumers that need to compare hit paths against known prims should use the same encoding.

PhysX Pointer Interop#

For advanced use cases that go beyond the TensorBindingsAPI – such as custom joint manipulation or direct body property access – ovphysx can return raw PhysX SDK object pointers by USD prim path.

void* ptr = NULL;
ovphysx_result_t r = ovphysx_get_physx_ptr(
    handle, "/World/physicsScene", OVPHYSX_PHYSX_TYPE_SCENE, &ptr);
// Cast: physx::PxScene* scene = static_cast<physx::PxScene*>(ptr);

The ovphysx_physx_type_t enum specifies which PhysX type to look up (scene, actor, articulation link, joint, shape, material, etc.). The C API returns void*; the caller casts to the appropriate PhysX SDK type.

Casting requires the PhysX SDK C++ headers (e.g. PxScene.h, PxRigidDynamic.h). The ovphysx SDK ships these headers under include/physx/; find_package(ovphysx) sets ovphysx_PHYSX_INCLUDE_DIR to point there. No PhysX library linking is needed.

See the PhysX Interop tutorial for a complete sample that uses setKinematicTarget() on a PxRigidDynamic*.

The C++ experimental API provides a type-safe overload that deduces the enum from the pointer type, preventing mismatches at compile time:

physx::PxScene* scene = nullptr;
physx.getPhysXPtr("/World/physicsScene", scene);  // enum auto-deduced

Python returns integer pointer addresses for passing to C/C++ code:

ptr = physx.get_physx_ptr("/World/physicsScene", ovphysx.PhysXType.SCENE)

Pointer Lifetime#

Returned pointers are valid from acquisition until ovphysx_remove_usd(), ovphysx_reset(), or instance destruction. Calls to ovphysx_step() and ovphysx_clone() do not invalidate existing pointers. Do not call release() on returned pointers — ovphysx owns them.

Thread Safety#

PhysX APIs on returned pointers must only be called between simulation steps — specifically after wait_op() completes for the preceding step and before the next ovphysx_step() call. Calling PhysX APIs while a step is in-flight is a data race.

Contact Data#

ovphysx provides two complementary APIs for reading contact information. Choose the one that fits your use case:

Contact Binding

Contact Report

Use when

You need aggregate force tensors for RL rewards, safety limits, or force monitoring

You need per-contact-point geometry (position, normal, impulse) for custom contact sensors or collision analysis

Data shape

[S, 3] net forces or [S, F, 3] force matrix (DLPack tensors, GPU-compatible)

Variable-length arrays of event headers + contact points (raw buffers)

API style

Create a binding, then read tensors each step

Call once per step, receive pointers to internal buffers

USD requirement

No extra schema — just rigid body prims

Prims must have PhysxContactReportAPI applied

Key functions

create_contact_binding(), read_contact_net_forces(), read_contact_force_matrix()

get_contact_report()

Contact Binding (aggregate force tensors)#

Contact bindings give you aggregate net force vectors between sets of sensor and filter bodies, delivered as DLPack tensors that work on both CPU and GPU. No extra USD schema is required.

See the Contact Binding tutorial for a full walkthrough. Key points:

  • Create the binding before the first step whose contacts you want to observe.

  • Net forces shape: [S, 3] — one force vector per matched sensor.

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

  • dt for impulse-to-force conversion is taken automatically from the last step.

Contact Report (per-point event data)#

The contact report exposes the raw per-step contact events collected by the Omni PhysX runtime. This gives you every individual contact point with position, normal, impulse, and separation — useful for custom contact sensors, collision debugging, or building higher-level contact processing.

Prims must have PhysxContactReportAPI applied in the USD stage for contacts to be reported.

const ovphysx_contact_event_header_t* headers = NULL;
const ovphysx_contact_point_t* data = NULL;
uint32_t num_headers = 0, num_data = 0;

// Basic contact report (headers + contact points)
ovphysx_get_contact_report(handle, &headers, &num_headers, &data, &num_data,
                           NULL, NULL);
// Access fields directly: headers[0].actor0, data[0].position[1], etc.

// With friction anchors
const ovphysx_friction_anchor_t* anchors = NULL;
uint32_t num_anchors = 0;
ovphysx_get_contact_report(handle, &headers, &num_headers, &data, &num_data,
                           &anchors, &num_anchors);

The friction anchor parameters are optional – pass NULL to skip them.

The C API returns typed struct pointers defined in ovphysx_types.h. The C++ experimental API provides aliases (PhysX::ContactEventHeader, PhysX::ContactPoint, PhysX::FrictionAnchor) and a typed getContactReport() method.

Data is valid until the next simulation step. A typical usage pattern:

  1. Apply PhysxContactReportAPI to prims of interest.

  2. step() + wait_all().

  3. Call get_contact_report() to read that step’s contacts.

  4. Parse event headers for contact pairs; index into contact data for per-point details.

In Python:

report = physx.get_contact_report()
for i in range(report["num_headers"]):
    h = report["headers"][i]
    print(f"pair {i}: actor0={h.actor0:#x}, {h.numContactData} points")
for j in range(report["num_points"]):
    p = report["points"][j]
    print(f"  pos=({p.position[0]:.3f}, {p.position[1]:.3f}, {p.position[2]:.3f})")

Running Inside a Carbonite Host (Kit, Isaac Sim)#

ovphysx can run inside a Carbonite-based host application such as Kit or Isaac Sim. When the host has already bootstrapped the Carbonite framework and loaded the PhysX extension stack, ovphysx detects this automatically and adapts:

  • Plugin loading is skipped. The host owns the plugin lifecycle. ovphysx acquires the existing IPhysxSimulation and related interfaces directly.

  • Settings use setDefault*() so host-configured values are not overwritten. Device-related settings (/physics/cudaDevice, /physics/suppressReadback) produce a log warning if the ovphysx request doesn’t match the host value, but the host value takes precedence.

  • App directory is preserved. If the host already set an app directory path, ovphysx does not overwrite it.

  • USD version checks and preloading are skipped. The host manages its own USD runtime.

  • Device mode is inferred from the host. The device parameter to ovphysx_create_instance() is validated against the host’s running mode. A mismatch produces a warning but does not fail — the host’s device mode is used.

No special flags or “host mode” configuration is required. The same ovphysx build works in both standalone and host-embedded environments.

Requirements for host integration#

  • The host must have the PhysX extensions fully loaded (omni.physx, omni.physx.tensors, etc.) before ovphysx_create_instance() is called.

  • Device mode (CPU vs GPU) is process-global. ovphysx adopts whatever mode the host initialized.

  • Carbonite settings are shared. Changes made through ovphysx’s settings API are visible to the host and vice versa.

Logging#

ovphysx uses Carbonite as its internal logging backend. By default, the global log level is LogLevel.WARNING — only warnings and errors are emitted.

Controlling the Log Level#

Python#

import ovphysx

ovphysx.set_log_level(ovphysx.LogLevel.VERBOSE)
print(ovphysx.get_log_level())

C#

#include <ovphysx/ovphysx.h>

// Set before or after instance creation — applies globally to all outputs.
ovphysx_set_log_level(OVPHYSX_LOG_VERBOSE);

uint32_t current = ovphysx_get_log_level();

Custom Log Callbacks (C)#

Register one or more callbacks to receive log messages programmatically. The caller must ensure the callback and any resources it references remain valid until it is unregistered. If the callback and its resources naturally outlive the process (e.g. a static function with no user_data), calling ovphysx_unregister_log_callback() is not required. When called, it guarantees the callback is not running on any thread and will never be invoked again.

void my_logger(uint32_t level, const char* message, void* user_data) {
    fprintf(stderr, "[%u] %s\n", level, message);
}

ovphysx_register_log_callback(my_logger, NULL);
// ... run simulation ...
ovphysx_unregister_log_callback(my_logger, NULL);
// Safe to destroy any resources referenced by the callback here.

Controlling Default Console Output#

By default, Carbonite logs to the console. When a custom callback is registered that also writes to the console, output may be doubled. Use ovphysx_enable_default_log_output() to suppress the built-in console logger:

Python#

ovphysx.enable_python_logging()
ovphysx.enable_default_log_output(False)

C#

ovphysx_register_log_callback(my_logger, NULL);
ovphysx_enable_default_log_output(false);  // only my_logger receives messages now

Python Logging Bridge#

Route native log messages into Python’s standard logging module:

import logging
import ovphysx

# Route native messages to the "ovphysx" Python logger
ovphysx.enable_python_logging()

# Add a handler to see the output
logging.getLogger("ovphysx").addHandler(logging.StreamHandler())
logging.getLogger("ovphysx").setLevel(logging.DEBUG)

# ... run simulation — native messages appear in Python logging ...

ovphysx.disable_python_logging()

You now have the core integration rules for building, running, and operating ovphysx. For the full C API reference, see the C API Reference. For Python, see Python API Reference.