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.
On this page#
Relationship and Consumption Model#
ovphysx provides a C API and Python bindings for ovstage-driven PhysX simulation. Use ovphysx when you need USD-based physics simulation outside of Omniverse Kit with a small native SDK.
Python:
pip install ovphysxandfrom ovphysx import PhysXC/C++: download the ovphysx SDK and matching native ovstage archive as described in the SDK Quickstart, keep the package roots separate, and link through CMake
Porting a 0.6 application: Migration from 0.6
CPU note (x86_64): pre-built ovphysx binaries require AVX. Missing AVX is
detected at ovphysx_initialize() with a clear error. Refer to
System Requirements in the overview for
compatibility details and how to verify AVX on Linux x86_64.
This guide focuses on the runtime and integration surface. For authoring the USD physics content that ovphysx simulates and the concepts behind it (scenes, colliders, rigid bodies, joints, articulations, deformables, particles), refer to the Simulation Setup pages starting with Physics Scene, and the tuning Guides.
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/andtests/python_samples/. The Python wheel installs the Python samples underovphysx/samples/python_samples/; the C/C++ SDK installs the C/C++ samples undersamples/c_samples/. Both artifacts install shared sample data under their respectivesamples/data/directory.
Python Samples (tests/python_samples/):
Sample |
Tutorial |
Feature |
|---|---|---|
|
Load USD + step (minimal workflow) |
|
|
Read/write simulation data through tensor bindings (deprecated; use |
|
|
ovstage-driven kinematic supports, surface-velocity conveyors, and combined rider motion |
|
|
Read contact forces through sensor/filter bindings |
|
|
Build lightweight view wrappers on TensorBindingsAPI (advanced; deprecated) |
|
|
Record physics internals to .ovd files |
|
|
Closed loop: author control into ovstage, drain it explicitly, step, and read rigid-body position and velocity from |
|
|
Write |
|
|
Clone a subtree before the first step and read rigid-body positions |
|
|
Chrome trace and NVTX profiler backends |
|
|
Read and write vehicle controls and wheel state |
Extra Python Samples (tests/python_samples_extra/):
Sample |
Tutorial |
Feature |
|---|---|---|
|
Visualize rigid body simulation with Rerun |
C/C++ Samples (tests/c_samples/):
Sample |
Tutorial |
Feature |
|---|---|---|
|
Minimal C hello world |
|
|
CPU tensor read/write (deprecated; use |
|
|
GPU tensor read/write with CUDA (deprecated) |
|
|
ovstage-driven kinematic supports, surface-velocity conveyors, and combined rider motion |
|
|
Contact force reading |
|
|
Record physics internals to .ovd files |
|
|
Direct PhysX SDK pointer access |
|
|
Closed loop on the same scene; queries several output types, including |
|
|
Same three-target clone in C |
|
|
Vehicle session read/write, including |
|
|
Publish simulated world transforms back to ovstage |
|
|
Link the SDK from a source build |
For SDK setup, refer to SDK Quickstart.
The tensor-binding API is deprecated; new code should use the session read/write API
(ovphysx_read / ovphysx_write). For the deprecated tensor binding shape/read/write semantics,
refer to Tensor Bindings.
Canonical enum-level definitions are in include/ovphysx/ovphysx_legacy_types.h (ovphysx_tensor_type_t).
Execution Model#
ovphysx preserves submission and data ordering. Existing task-producing calls
return a receipt: ovphysx_enqueue_result_t in C and Task in Python. Keep the
whole receipt and use ovphysx_wait_task() / PhysX.wait_task() to collect that
operation’s outcome. A receipt reports admission, not execution success.
step() / ovphysx_step() starts asynchronous simulation. clone(),
attach_ovstage(), update_from_ovstage() and detach_ovstage() currently
finish inline but still return receipts; their execution
failures are reported when collected. Native query, read/write session open,
fetch, commit, release and articulation-kinematic update also return receipts and
currently execute inline. No host-nonblocking guarantee follows from a task receipt.
Keep caller-owned inputs and output slots alive until completion; inspect output
handles and groups only after a successful wait. A successful fetch with a null
group is EOF. Rejected submissions instead return no
task and expose an immediate error. step_sync() / ovphysx_step_sync() steps
and waits in one call. Repeat it in a local loop when a workload uses decimation.
In-stream ovphysx operations preserve their existing synchronization and data ordering. Before external CPU access, rendering, logging or other consumers need the result, wait on the relevant task. Explicit CUDA producer-event and consumer-stream dependencies remain necessary where documented; collecting a task does not replace those handoffs.
Python tensor-binding, contact-binding, and SDF calls that accept caller-owned
CUDA device or managed-memory buffers finish pending work on the current Warp
stream or use the producer’s
__dlpack_device__ / __dlpack__(stream=...) handoff before accessing storage.
This includes output-buffer initialization and cached binding reuse. For pinned
host buffers, callers must finish pending CUDA work before the call. Raw
DLTensor descriptors carry no producer stream: callers must finish pending work
or pass the API’s explicit stream/event arguments where available. Session write
commits still use their explicit CUDA stream/event arguments.
A failed task does not automatically make a later task fail. Waiting on successful
B reports only B; an earlier failed A remains reportable through its own wait or
wait_all(), even when A’s original receipt was discarded. Later calls still
validate actual simulation state and prerequisites.
Run custom application code directly after successfully waiting on the relevant
task. Use wait_all() when that code needs every earlier outcome collected;
it also acknowledges earlier failures. The task API exposes completion of native
operations and does not schedule application callbacks. Log,
object-change and contact callbacks keep their own documented contracts.
Stage lifecycle completion#
Attach, update and detach use the same admission and completion contract as other tasks. Invalid arguments, an already-attached instance or an update without an attachment reject before changing the attachment. Detach also rejects conflicting companion transitions before task admission. Runtime failures after admission belong to the task’s outcome. Wait successfully before using a newly attached scene or the result of an update. Keep the Stage and the sealed ordinal data needed by attach/update alive until the task finishes. An attached Stage stays alive until successful detach or instance destruction. A failed teardown may still own the Stage and may already have retired some resources; keep it alive while handling the failure.
detach_ovstage() replaces the removed reset_stage(). It tears down physics,
preserves accumulated simulation time, and does not restore initial poses.
Repeated detach succeeds with a new task receipt. To restore episode state,
write the required poses, velocities and joint state; to rebuild physics,
successfully detach and then attach the desired Stage snapshot.
is_stage_attached() remains synchronous: it reads current attachment ownership
without waiting, creating a task, or collecting earlier outcomes. It is useful
after failures but does not establish simulation readiness or resource validity.
Wait for the relevant lifecycle task before asking for its settled state. A false
snapshot while attach is pending does not permit destroying that Stage.
Configuration#
Process-global keys (ovphysx_global_config_key_t / GlobalConfigKey) are
configured independently of instances. Set startup-only keys (CPU_ONLY,
OVD_RECORDING_CAPABLE, profiler, and cooked-cache location) before the first
instance. Other keys remain writable only while their backend can honor the change; scene-creation
keys reject writes while a stage is attached. Configure every setting with a
GlobalConfigKey through set_global_config, and create the instance with no
config arguments for those settings.
The only current per-instance key is CUDA GPU selection
(OVPHYSX_CONFIG_ACTIVE_CUDA_GPUS / ConfigKey.ACTIVE_CUDA_GPUS): set it
after create and before attach. After attach, changing it returns
OVPHYSX_API_INVALID_STATE and keeps the prior value.
Environment variables stay authoritative over the matching global setter.
Known overrides: OVPHYSX_DISABLE_GPU (CPU_ONLY),
OVPHYSX_PROFILER (PROFILER_BACKEND), and
OVPHYSX_PROFILER_OUTPUT (PROFILER_OUTPUT_PATH).
get_global_config reports the effective value.
PhysXConfig, typed config-entry arrays, and ovphysx_create_args /
ovphysx_create_instance_with_args are the legacy path. New code uses the
scoped setters below where a matching key exists, including OVD_RECORDING_CAPABLE; see
OmniPVD Recording.
C#
#include <ovphysx/ovphysx.h>
#include <ovphysx/ovphysx_config.h>
int main(void)
{
if (ovphysx_initialize().status != OVPHYSX_API_SUCCESS)
return 1;
ovphysx_result_t result = ovphysx_set_global_config(
OVPHYSX_GLOBAL_CONFIG_DISABLE_CONTACT_PROCESSING,
ovphysx_config_value_bool(true));
if (result.status != OVPHYSX_API_SUCCESS)
return 1;
result = ovphysx_set_global_config(
OVPHYSX_GLOBAL_CONFIG_NUM_THREADS, ovphysx_config_value_int64(4));
if (result.status != OVPHYSX_API_SUCCESS)
return 1;
ovphysx_instance_handle_t handle = OVPHYSX_INVALID_HANDLE;
if (ovphysx_create_instance(&handle).status != OVPHYSX_API_SUCCESS) {
ovphysx_shutdown();
return 1;
}
result = ovphysx_set_config(
handle,
OVPHYSX_CONFIG_ACTIVE_CUDA_GPUS,
ovphysx_config_value_string(OVPHYSX_LITERAL("0")));
ovphysx_config_value_t threads = ovphysx_config_value_int64(0);
if (result.status == OVPHYSX_API_SUCCESS)
result = ovphysx_get_global_config(OVPHYSX_GLOBAL_CONFIG_NUM_THREADS, &threads);
ovphysx_result_t destroy_result = ovphysx_destroy_instance(handle);
ovphysx_result_t shutdown_result = ovphysx_shutdown();
return result.status == OVPHYSX_API_SUCCESS &&
destroy_result.status == OVPHYSX_API_SUCCESS &&
shutdown_result.status == OVPHYSX_API_SUCCESS
? 0
: 1;
}
Python#
from ovphysx import ConfigKey, GlobalConfigKey, PhysX, get_global_config, set_global_config
def read_configuration() -> tuple[int | None, bool | None]:
set_global_config(GlobalConfigKey.DISABLE_CONTACT_PROCESSING, True)
set_global_config(GlobalConfigKey.NUM_THREADS, 4)
physx = PhysX()
try:
physx.set_config(ConfigKey.ACTIVE_CUDA_GPUS, "0")
threads = get_global_config(GlobalConfigKey.NUM_THREADS)
enabled = get_global_config(GlobalConfigKey.DISABLE_CONTACT_PROCESSING)
assert threads is None or isinstance(threads, int)
assert enabled is None or isinstance(enabled, bool)
return threads, enabled
finally:
physx.destroy()
Global keys are process-wide. ACTIVE_CUDA_GPUS is retained per instance until
attach.
Cooked-Collider Cache (UJITSO)#
Collision “cooking” converts a mesh into the representation PhysX simulates (convex hull, triangle mesh, SDF, or convex decomposition). It is CPU-expensive, so ovphysx caches the cooked result on disk and reuses it on the next run through UJITSO, a local content-addressed cache. The cache key includes the mesh geometry, the cooking parameters, the cooking type, and a cooker/PhysX version token, so a changed mesh, changed parameters, or a different PhysX build automatically re-cooks instead of returning a stale shape.
The cache directory is application-provided: ovphysx does not derive a persistent cache location from the environment and does not persist to a location of its own choosing. Set it to enable cross-run persistence:
from ovphysx import GlobalConfigKey, PhysX, set_global_config
def create_physx_with_cache(cache_directory: str) -> PhysX:
set_global_config(GlobalConfigKey.COOKED_COLLIDER_CACHE_DIRECTORY, cache_directory)
return PhysX()
In C, set OVPHYSX_GLOBAL_CONFIG_COOKED_COLLIDER_CACHE_DIRECTORY before
ovphysx_create_instance(). The first run with
a given cache cooks and stores the result; later runs with an unchanged scene are served from the
cache without re-cooking.
With no directory configured, nothing is persisted: cooked colliders go to a process-private
directory under the OS temp directory, so every run re-cooks. After all instances are destroyed,
ovphysx_shutdown() stops the datastore and removes that directory on Linux. Windows retains
the datastore until normal process exit. Its framework release callback attempts removal
after the datastore stops, but Windows cleanup remains best-effort: another dependency’s
terminating exit hook can prevent that callback from running. Leftover private directories
may require normal temporary-directory cleanup. Python destroys still-live
PhysX instances from atexit, including after
an uncaught Ctrl-C (KeyboardInterrupt), then shuts down the process lifecycle even if a
read() Warp array is still globally reachable, so cleanup follows the same last-instance
shutdown path as explicit destroy(). Abrupt termination that cannot run exit handlers, such as SIGKILL or a
hard crash, may leave the directory for the host’s temp-directory cleanup. The default SIGTERM
action also bypasses Python atexit; applications that need graceful SIGTERM cleanup should
translate it into their normal shutdown flow. Do not os.fork() or use
multiprocessing with the fork start method after constructing PhysX. The
runtime, CUDA, and Carbonite are not fork-safe; a child that inherited live
instances would run the parent’s atexit teardown against native state it does
not own. Start workers with spawn, or use a process that never constructed
PhysX. The cache needs a real path while the datastore is active. Its background writers and
open files must be released before directory removal. Filesystem removal
errors are reported on stderr. The temp location follows the platform’s TMPDIR/TMP/TEMP convention; what
ovphysx will not do is derive a persistent cache location from the environment. The same
process-private fallback is used when a configured directory cannot be created or written.
The configured directory is applied when the framework first creates its datastore.
On Linux, destroy all instances and call ovphysx_shutdown(), then initialize and configure
the next lifecycle to change directories. Windows retains its datastore until process exit;
use a separate process for a different directory. Python’s final PhysX.destroy() completes
SDK shutdown when no other lifecycle owners remain.
Cooked colliders are not reused across runs when:
No cooked-collider cache directory is configured: cooking still runs, but nothing is persisted, so it re-cooks every run.
ovphysx cannot create or write the configured directory: it falls back to the process-private cache, so nothing is persisted (a warning is logged).
The mesh, the cooking parameters/type, or the PhysX build changed: the key differs, so the shape is re-cooked (this is correct, not a fault).
Collision cooking has been disabled (it is enabled by default).
ovphysx uses UJITSO as a local, in-process cache. (UJITSO can also operate as a distributed cache;
ovphysx does not use that.) The cache is per-machine, shared only through the directory you
configure. It is bounded: the datastore runs disk garbage collection against
/UJITSO/datastore/localDataStore/largeChunkDiskBudgetMB (default 102,400 MB) and
smallChunkDiskBudgetMB (default 1,024 MB), evicting toward the budget once it is exceeded.
Raise those settings for a larger cache, or delete the directory to reclaim space immediately. If
collision cooking is enabled but the required UJITSO cache plugins are not loaded, instance creation
fails fast with a clear error rather than silently cooking uncached.
Process-Global Lifecycle#
C callers can use ovphysx_initialize() to initialize process-global lifecycle
state, and ovphysx_shutdown() to shut it down. Only one initialize can be
active at a time; a second initialize before shutdown returns
OVPHYSX_API_ERROR. C callers must call ovphysx_initialize() before
ovphysx_create_instance().
ovphysx_shutdown() does not destroy live handles and does not balance
ovphysx_create_instance(). Call ovphysx_destroy_instance() for every handle
returned by ovphysx_create_instance(). A successful shutdown always disables
and drains the application log callback, so its callback and user data may be
released immediately. A handle retained across shutdown is supported only for
explicit destruction; do not step it or start other instance work, and do not
expect its destruction-time records through the disabled callback.
Multi-Instance Support#
The SDK exposes a per-instance handle (ovphysx_instance_handle_t). Wrapper-owned state – tensor and contact bindings, output query/read/write sessions, object-change subscriptions, task errors, debug-render settings, and scene-query hit buffers – is isolated per handle. Foreign subordinate handles return OVPHYSX_API_NOT_FOUND and leave the owner untouched. Debug rendering is OVPHYSX_QUERY_TARGET_DEBUG_RENDER on that handle (settings and geometry through the read/write sessions); geometry is limited to scenes of the instance’s attach.
Each instance holds at most one ovstage attach. Several instances may be attached at the same time, including stages that use the same prim paths. Attaching, stepping, detaching, or destroying one instance does not detach the others. The same ovstage instance cannot be attached twice.
Concretely, this means:
Multiple
ovphysx_instance_handle_tvalues can coexist. Instance operations take that handle first (ovphysx_subscribe_object_changes,ovphysx_get_last_task_error, and the rest of the instance surface).Process-global functions omit the handle: initialize/shutdown,
ovphysx_create_instance, version queries, CPU-mode and global config, logging,ovphysx_get_last_error,ovphysx_destroy_wait_task_result,ovphysx_writability.The hard CPU-only policy, process-global config, logging, the SDK tolerance scale, and the recording transport are process-wide. Each attachment captures its own dispatcher and CUDA options at attach. Outside hard CPU-only mode, CPU/GPU dynamics are authored per scene.
A single instance is not thread-safe: serialize calls on one handle. Different instances may step their own attachments at the same time. A recording start/stop needs every attached instance idle and returns an error if one is still stepping.
The same ovstage instance must not be attached to two ovphysx instances. Use a separate stage per instance.
Multiple UsdPhysicsScene prims within the same loaded stage are supported. ovphysx_step() advances all scenes together; per-scene independent stepping is not currently exposed through the ovphysx C API.
In-process destroy/create cycles are supported within one active process
lifecycle: per-instance destruction leaves Carbonite and the direct PhysX
runtime available for the next instance. The hard CPU-only request is the
exception: as soon as setting OVPHYSX_GLOBAL_CONFIG_CPU_ONLY to true
succeeds, it is sticky and
cannot be reverted in that process. Its CPU-only and ovphysx no-CUDA guarantees
require setting it before the first instance. Outside hard CPU-only mode, CPU/GPU
dynamics remain authored per scene in USD rather than selected by
ovphysx_create_instance(). Call ovphysx_shutdown() once when the current
process-lifecycle scope is done. With no live handles it clears the
ovphysx_initialize() token and drains the direct PhysX runtime. Linux also releases its
Carbonite framework and dependency plugins, preserving earlier host atexit handlers and
implicit buffered-file flushing. Windows retains Carbonite’s process-exit hook for safe
OVStage/Fabric DLL teardown; Windows hosts must flush buffered files explicitly before exit.
Independently loaded libraries such as OVStage may install their own terminating exit
handlers on Linux too. Explicitly flush host output when using those libraries;
ovphysx shutdown does not remove their handlers.
A later ovphysx_initialize() can start another lifecycle on either platform.
If ovphysx reused a framework already populated by a foreign host, that host retains
responsibility for its framework and datastore teardown.
Task Receipts and Polling#
A receipt can be copied and needs no destructor. Each terminal outcome is
collected once: wait_task(task) collects only that task, and a repeated direct
wait returns NOT_FOUND (RuntimeError in Python). Internal synchronization
retires execution resources without collecting outcomes or discarding errors.
wait_all() covers tasks accepted when the call begins. It scans uncollected
outcomes in submission order with one readiness deadline. If that deadline
expires, it stops at the earliest unfinished task; earlier completed outcomes
are collected, and later outcomes remain available. Previously collected
failures are not reported again. Waiting on an empty set succeeds.
C and C++ callers use ovphysx_timeout_t, a uint64_t nanosecond type. Pass
OVPHYSX_TIMEOUT_POLL for a readiness poll, a positive value for a finite wait,
or OVPHYSX_TIMEOUT_INFINITE for an infinite wait. Python uses timeout_ns=0,
a positive integer, or None. Ready-at-deadline wins. Finalization may take
longer than a finite readiness timeout. Timeout leaves unfinished tasks retryable.
C waits require a non-null, zero-initialized ovphysx_wait_task_result_t.
error_tasks contains full failed receipts, and pending_task is zero when
nothing is pending. Check the returned ovphysx_result_t separately: TIMEOUT
takes precedence over collected errors, ERROR reports execution or internal
failure, and SUCCESS means no collected task failed. Destroy every output with
ovphysx_destroy_wait_task_result(), including timeout outputs. Destruction
zeros the result and accepts null or already-cleaned results. Never shallow-copy
an owning wait result or reuse a nonempty one; C++ WaitResult is move-only.
Task errors belong to their wait result. ovphysx_get_last_task_error(instance, task) is read-only and returns text retained by a live result; missing causes
return empty text. Borrowed text survives unrelated waits and instance teardown
until its result is destroyed. Fresh lookup requires a live matching instance.
Receipts and retained results do not keep simulation or CUDA resources alive.
Python returns immutable Task values from step, clone, attach_ovstage,
update_from_ovstage and detach_ovstage.
Wait using physx.wait_task(task, timeout_ns=None) or
physx.wait_all(timeout_ns=None); both return None on success. TaskError
contains immutable (Task, message) entries in errors. TaskTimeoutError
contains errors collected before the timeout and pending_task. Python copies
those values before native cleanup, so exception details remain valid afterwards.
The Python session API returns an Operation from query, read, write,
fetch_next, commit, and close / release, without waiting. An operation holds
its real native receipts in tasks (task when there is exactly one; a close with
nothing native to release has none). operation.wait() collects them and returns the
typed result, raising TaskError for a failure. operation.result never waits: it
raises until the outcome has been collected. wait_all() also collects registered
operations, and a later wait() or result returns the cached result or raises the
cached failure again. The typed result is built once, at the first wait() or
result access, which must happen on the submitting thread; read arrays are ordered
onto the Warp stream current at that access. Inputs and output slots stay alive until
an operation is collected, even when the operation object is dropped, so call
wait_all() periodically in fire-and-forget loops to bound retained storage and
observe failures. The session model shows a
complete query, read, write and commit sequence.
read_sync, read_tokens_sync, write_sync, commit_sync, close_sync,
step_sync, update_articulations_kinematic and evaluate_sdf_shapes are the
waiting conveniences.
Closing a session or query, leaving its with block, and cleanup of an abandoned
object submit releases without waiting; wait_all() or instance destruction observes
their failures. A session returned by read_sync or write_sync waits for the
releases it submits when its with block exits. Read storage that arrays still
borrow is released only after the result and every alias are dropped, and a later
wait_all() collects that release. CUDA stream/event dependencies still apply.
Context exit preserves an exception from the body and chains cleanup failures.
Deferred finalization never calls native session release from a garbage-collector
thread; the owning thread drains it.
from ovphysx import PhysX, TaskError, TaskTimeoutError
def poll_step(physx: PhysX, dt: float) -> bool:
task = physx.step(dt)
try:
physx.wait_task(task, timeout_ns=0)
except TaskTimeoutError:
# The unfinished task is still valid; collect it with an infinite wait.
physx.wait_task(task)
return False
except TaskError as error:
for failed_task, message in error.errors:
print(f"Task {failed_task.op_index}: {message}")
raise
return True
Threading#
A single instance is not thread-safe. Serialize access externally.
Calls may move between threads when externally serialized; do not wait on the same task concurrently.
Different instances may step their own attachments at the same time. A recording start/stop needs every attached instance idle.
Python: concurrent step() and tensor I/O#
Warning: the GIL does not protect ovphysx calls. The Python API reaches native code through ctypes. The GIL is released during those calls, so two Python threads can execute ovphysx C code at the same time. Calling
step()/step_sync()on one thread while another thread callsTensorBinding.read()/write()(or any other simulation API on the same instance) is a data race. Results range from torn reads to segfaults. This pattern is common in multi-threaded RL frameworks that dispatch simulation steps and observation collection to separate thread pools.
Serialize all simulation and tensor I/O on a single instance (for example with
a threading.Lock). If different threads participate, complete the step first
and only read or write tensors after that step is finished:
import threading
def run_workers(physx, binding, output_buffer, dt):
lock = threading.Lock()
def sim_thread():
for _ in range(1000):
with lock:
physx.step_sync(dt) # mutation completes before lock is released
def read_thread():
for _ in range(1000):
with lock:
binding.read(output_buffer) # no concurrent step on another thread
sim_worker = threading.Thread(target=sim_thread)
read_worker = threading.Thread(target=read_thread)
sim_worker.start()
read_worker.start()
sim_worker.join()
read_worker.join()
When using asynchronous step(), the same rule applies: do not call
binding.read() / binding.write() concurrently with an in-flight step.
Either wait first (wait_task() / wait_all() / step_sync()), or keep all
step and tensor calls on one thread. In-stream tensor reads submitted after
step() on the same thread are stream-ordered and safe without an explicit
wait; cross-thread use always requires external serialization.
Tensor Binding Diagnostics#
Pattern bindings can intentionally match zero physics objects, so expected TensorAPI no-match diagnostics are quieted on the simulation view used to create that binding.
For rigid-body and articulation pattern bindings, TensorAPI resolves candidate paths before filtering them to the requested physics-object type. Same-named candidates of another type are skipped without per-candidate warnings.
Explicit object paths supplied through
prim_pathskeep the default error-level no-match diagnostics for typo detection.A single pattern component (the text between two slashes; a parenthesized group counts as one component even if it contains a slash) may be at most 4096 characters long. Every pattern-taking call rejects a longer component up front with
OVPHYSX_API_INVALID_ARGUMENT(RuntimeErrorin Python).To detect partial misses programmatically, compare requested explicit paths with Python
binding.prim_pathsor Covphysx_tensor_binding_get_prim_paths()after binding creation.
Ownership and Lifetimes#
Tensor bindings, contact bindings, and attribute bindings own internal resources. They are automatically destroyed when the parent instance is destroyed through
ovphysx_destroy_instance(). Explicit per-binding destruction (ovphysx_destroy_tensor_binding,ovphysx_destroy_contact_binding) is available for releasing resources earlier.In Python, close a binding deterministically with a
withblock or an explicitbinding.destroy(). New code should exchange simulation state through the session read/write API, whose queries and sessions are context managers (with physx.read_sync(...) as result:/with physx.write_sync(...) as session:, or theQuery,ReadResultandWriteSessionreturned by the defaultquery/read/writeoperations); the tensor-binding formwith physx.create_tensor_binding("/World/robot*", tensor_type=TensorType.RIGID_BODY_POSE) as binding:is deprecated but still works during the deprecation window. Contact bindings —with physx.create_contact_binding(["/World/sensor*"]) as binding:— are a deprecated compatibility surface (C declarations live inovphysx_legacy.h). If garbage collection cleans up aTensorBindingorContactBindingfirst, ovphysx emitsResourceWarning. Python suppresses this warning category by default; run with-W defaultor setPYTHONWARNINGS=defaultto show it.Create tensor and contact bindings once outside simulation loops and reuse them across steps. Creating a new binding every step allocates new native resources and is slower than reusing the existing binding.
Tensor and contact bindings are views of the currently realized physics objects.
step()andstep_sync()do not invalidate them, but application-owned stage lifecycle changes do. Beforedetach_ovstage(), before removing USD data that contains bound objects, or before replacing/reparsing the stage so bound objects are destroyed and recreated, destroy cached bindings when practical. If a stale binding survives one of those lifecycle operations, only destroy it; do not read or write through it. Create replacement bindings after the operation has completed. Do not keep cached bindings across topology changes and probe them defensively; the application knows when it changed the stage.For admission or synchronous API failures, read
ovphysx_get_last_error()before the next call on that thread. Accepted task failures belong to their wait result.Native session release transfers ownership only when admitted. A rejected release can be retried; an accepted release must never be resubmitted even if its wait reports execution failure. The SDK owns remaining cleanup. Finish external buffer consumers before releasing their read session.
A
physxPointervalue is a borrowed address. Do not callrelease()on it. Refer to PhysX Pointer Interop for lifetime details.PhysX instance lifecycle (Python): call
physx.destroy()explicitly, normally from afinallyblock, to guarantee deterministic cleanup of native resources. Drop all output-read arrays and downstream views first; otherwisedestroy()raises before teardown and leaves the instance usable for a retry.destroy()is checked, so a cleanup failure raised fromfinallybecomes the active exception while Python preserves any in-flight exception as chained context. Applications that need different exception precedence must catch and log cleanup failures explicitly. ovphysx 0.6 removes the formerrelease()spelling and the main-object context-manager protocol. If an instance is garbage-collected during the run (dropped mid-program rather than held to interpreter shutdown),__del__emits aResourceWarningbefore native cleanup. The warning is silent by default (filtered out unlesspython -W default::ResourceWarningor a test suite captures it) and mirrors Python file-object semantics. If both best-effort native cleanup attempts fail before returning a status, the finalizer emits a default-visibleRuntimeWarning; the native instance may remain registered and unreachable. A process-exit hook applies the same non-throwing finalizer policy to still-live instances before interpreter finalization. This is a safety net for normal exit, not a replacement for deterministicdestroy(): abrupt termination, aforkafter construction, and cleanup failures can still leave native or disk resources.
Dependency Management#
The native distribution consists of two separate package roots: the ovphysx SDK
and the matching native ovstage archive. The Python distribution instead uses
the ovphysx wheel and its exact ovstage wheel dependency:
ovphysx runtime dependencies are loaded from its packaged plugins directory (
_install/plugins/in a source install,ovphysx/plugins/in the wheel).OVStage supplies its own headers, shared library, CMake package, and runtime tree, in a package root kept separate from OVPhysX’s. Native users either put their own on
CMAKE_PREFIX_PATHor letfind_package(ovphysx)fetch the pinned build.The ovphysx wheel includes the physics runtime. It does not bundle or load OmniClient or its connection library.
The exact
ovstagewheel supplies the ovstage runtime together with OmniClient, its connection library, the USD resolver, and the internal namespaced OpenUSD runtime ovstage uses to ingest USD scenes; the ovphysx wheel ships no OpenUSD library, USD plugin registry, or resolver of its own.Python’s bulk data access is the session read/write API (
PhysX.read/PhysX.write); the deprecatedTensorBindingsAPIremains for the deprecation window, and the legacyovphysx.tensorscompatibility layer is no longer shipped.pip install ovphysxresolves the OVStage wheel automatically.The Newton USD schema is not a package dependency. Scenes that author
newton:*attributes (read as fallbacks for the PhysX spellings) needpip install newton-usd-schemas, or the schema downloaded from https://github.com/newton-physics/newton-usd-schemas, registered with ovstage next to the codeless PhysX schemas; refer to Physics Schemas.Auto-detects library location through
getLibraryDirectory()On Linux, pre-loads
libovstage.sowithRTLD_GLOBALsolibovphysx.socan bind its ovstage dependency from the separate Python packageOffline-capable once both native packages or both declared wheels are installed
Source builds still fetch a namespaced monolithic OpenUSD package as a build-time
dependency (the ovruntime subproject’s own USD dependency). The Python tests get
their python USD from stock pip usd-core; no internal USD monolith is fetched.
ovphysx itself links, loads, and pins no USD.
libovphysx and libovphysx_internal have no link-time dependency on USD, and
neither the SDK nor the wheel ships a USD runtime, a USD plugin registry, or a
config.toml. ovphysx never loads, preloads, or version-checks USD: ovstage
ingests USD scenes through its own internal namespaced OpenUSD runtime, and the
application owns whatever USD it authors with. There is no USD-linked variant
of ovphysx; scenes are attached through ovstage only, so ovphysx itself loads no
USD beyond ovstage’s namespaced OpenUSD runtime – any stock usd-core the
application authors with is loaded and owned separately by that application.
Error Handling#
For C:
Check
result.statuson every call. For an accepted task, wait on the whole receipt and check that result before consuming any outputs.On admission or synchronous API 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 task waits, inspect
error_taskseven on timeout, retrieve each cause withovphysx_get_last_task_error(instance, task), then free the output withovphysx_destroy_wait_task_result(). Retain the result for as long as borrowed causes are needed.
For Python:
Rejected submissions and wait validation/internal failures raise ordinary runtime errors. Collected execution failures raise
TaskError; unfinished waits raiseTaskTimeoutError, which can also contain partial failures.Use try/finally or context managers to ensure bindings are destroyed.
Pass
raise_if_empty=Truetocreate_tensor_binding()when a zero-count binding should be treated as a configuration error.For optional or broad queries, keep the default
raise_if_empty=Falseand checkbinding.countbefore reading.
Warmup and Determinism#
Deprecated tensor-binding reads require a warmup step that initializes PhysX
structures (and DirectGPU buffers in GPU mode). That binding path warms up
automatically on its first tensor operation. Session reads and writes never
auto-warm; complete the first step before reading DirectGPU device state or
committing a DirectGPU write. See
Step-first precondition.
If deterministic initial state matters, call ovphysx_warmup() explicitly after
scene attachment and before the first legacy tensor-binding read.
Ordering constraint: all
clone()calls must happen before warmup. Callingclone()afterwarmup()or after the firststep()/step_sync()reallocates physics structures and corrupts already-initialized solver state. The C runtime rejects this ordering withOVPHYSX_API_INVALID_ARGUMENT, surfaced in Python asRuntimeError. If you need to clone after warmup, calldetach_ovstage(), wait for it to complete, then reload the source scene or reattach its ovstage before cloning again.
GPU dynamics and DirectGPU TensorAPI are separate choices.
Process-level CPU-only mode must be set before creating any instance. To keep ovphysx itself from opening the CUDA driver (for example on a CPU-only deployment):
from ovphysx import GlobalConfigKey, PhysX, get_global_config, set_global_config
def create_cpu_only_physx() -> PhysX:
set_global_config(GlobalConfigKey.CPU_ONLY, True)
assert get_global_config(GlobalConfigKey.CPU_ONLY) is True
return PhysX()
get_global_config(GlobalConfigKey.CPU_ONLY) /
ovphysx_get_global_config(OVPHYSX_GLOBAL_CONFIG_CPU_ONLY, ...) report the
effective hard CPU-only policy (a configured true value or
OVPHYSX_DISABLE_GPU). They do not
report per-scene USD physxScene:enableGPUDynamics, a CUDA ordinal, or
attach-time resolved CPU versus GPU dynamics – those remain separate controls
by design. Successful instance creation also emits an INFO line containing
process_cpu_only=... (and, when not hard CPU-only, GPU-selection intent such as
active_cuda_gpus=no_override when ACTIVE_CUDA_GPUS is unset versus -1 for explicit
automatic selection). This hard-policy slice does not replace a host that still
needs attach-time confirmation of scene dynamics before dropping a defensive
set_cpu_mode(True).
OVPHYSX_DISABLE_GPU is read live before ovphysx_initialize and latched when
initialize succeeds, so an observational pre-init get_cpu_mode() cannot
permanently miss a later setenv before init.
For deployment environments, setting OVPHYSX_DISABLE_GPU before ovphysx
initialization provides the equivalent process-wide CPU-only policy.
Other libraries in the process may still open the CUDA driver. Loading an
ovstage-backed stage currently does, and constructing the warp.array values of a
session read or write does when Warp was built with CUDA. Use a
CPU-only Warp build to keep those Python paths driverless.
This forces all PhysX scenes in the process to use CPU dynamics, overriding
any physxScene:enableGPUDynamics=true in the USD stage. The mode is sticky
for the process lifetime as soon as enabling it succeeds. For per-scene CPU
control (mixed CPU/GPU process without this flag), author each scene explicitly:
physxScene:enableGPUDynamics=false and physxScene:broadphaseType="MBP".
GPU dynamics (whether the PhysX simulation pipeline runs on GPU) is enabled
by default (physxScene:enableGPUDynamics defaults to true in the PhysX
schema). ovphysx does not read or write this setting itself.
High-throughput CUDA TensorBinding and ContactBinding views additionally require opting into DirectGPU before instance creation:
from ovphysx import PhysX, PhysXConfig
def create_direct_gpu_physx() -> PhysX:
# /physics/suppressReadback is still a Carbonite override on the legacy
# PhysXConfig / create_args path; there is no scoped GlobalConfigKey for it.
return PhysX(
config=PhysXConfig(
carbonite_overrides={
"/physics/suppressReadback": True,
},
),
)
Use this for IsaacLab-style tensor workloads that read state or contact tensors every step. Contact bindings continue to provide contact data with DirectGPU enabled. Leave DirectGPU off if you need CPU-scene contact events. Contact modification, such as surface velocity or custom contact modification callbacks, also requires DirectGPU to be off.
After creating a tensor binding, query Python binding.native_device or C
ovphysx_get_tensor_binding_native_device() before allocating its DLPack
buffer. The result is the binding’s actual no-staging TensorAPI placement:
state bindings are CUDA only with DirectGPU and are CPU otherwise, even when
the scene uses GPU dynamics. CPU-only property bindings remain on the host in
DirectGPU mode. Treat this binding query as authoritative instead of inferring
placement from active_cuda_gpus or /physics/suppressReadback.
Scene Cloning#
Two paths duplicate a scene subtree for large-scale parallel simulation:
Direct ovphysx API. After an ovstage-backed scene has been attached and drained, callers may use
ovphysx_clone()(C),PhysX::clone()(C++), orPhysX.clone()(Python). Pass a source path, a list of target paths, and an optional flat array of per-target transforms(px, py, pz, qx, qy, qz, qw)(position + imaginary-first quaternion;NULLco-locates every clone on the source). Eachanchor_transformsentry is the absolute world pose of the exact target subtree root. For example, when cloning/env0/Robotto/env1/Robot, pass the final world pose of/env1/Robot, not the world pose of/env1. Descendants keep their poses relative to the source subtree root. PhysX environment ids isolate clones only under GPU dynamics + GPU broadphase. In CPU mode, co-located clones share one collision space, so pass spatially disjoint anchor transforms. The runtime logsEnvIds requested but gpu dynamic is disabledwhen the isolation asked for is not in effect; that record reaches the Carbonite log rather than Python’swarningsmodule, so observe it throughenable_python_logging()orovphysx_set_log_callback()(Logging). Replication executes inline. C and Python return an already-complete task receipt, also returned directly by the C++ wrapper. Wait on the receipt to collect completion and any execution failure. Cloning is driven by the PhysX replicator: each target becomes a real physics object — articulations are reconstructed, not flattened — addressable by its target path. It happens in the simulation’s internal representation only; no USD prims are authored at the target paths. When one logical environment is assembled from several clone calls (a heterogeneous ClonePlan: first every environment’s robot, then every environment’s object), pass the optional per-targetenv_idswith the same ids in every call — objects that share an id share a runtime environment and keep colliding with each other while staying isolated from other environments. Withoutenv_idseach call numbers its copies afresh, so same-environment objects cloned by different calls would never collide. The cloning tutorial includes the complete Python and C examples.Build the scene up front through ovstage. Apps that own the ovstage
Stagecan duplicate the source subtree on that Stage (ovstage_clone) beforeupdate_from_ovstage(), then hand the populated scene to ovphysx. This is scenegraph-level authoring that keeps all scene edits in one producer-owned stream and sidesteps the “clone before warmup” ordering hazard.
Use whichever fits your call-site. The direct API provides inline multi-target convenience after the scene has been attached and builds full physics (including articulations) for each clone; up-front ovstage duplication is the choice when your app already orchestrates every scene edit through ovstage.
Neither cloning nor step() implicitly drains later ovstage edits. Applications
must seal those edit ordinals and call ovphysx_update_from_ovstage() before
cloning or stepping.
Remote USD Loading#
ovstage population accepts any URI that the Omniverse Client Library supports:
Local paths:
/path/to/scene.usd(as before)Omniverse Nucleus:
omniverse://server/path/scene.usdS3 (HTTPS):
https://my-bucket.s3.us-east-1.amazonaws.com/path/scene.usdAzure Blob:
https://account.blob.core.windows.net/container/scene.usd
ovstage loads its client library when the application creates an ovstage
Stage. 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 through HTTPS and adds AWS authentication automatically when credentials are configured.
Configuring credentials#
Remote-asset credentials live on the Omniverse Client Library (OmniClient) — the asset resolver ovstage population uses to fetch S3 / Azure assets — not on ovphysx. The application owns population, so it configures credentials directly on OmniClient before populating ovstage from a private URL. ovphysx does not wrap this (it only consumes an already-populated Stage). The examples below use placeholder strings; do not commit real credentials in source code.
Kit-hosted Python: when the host provides the
omni.clientmodule, useomni.client.set_s3_configurationandomni.client.set_azure_sas_token.Standalone Python / C / C++: create the ovstage
Stagefirst so ovstage loads its client, then bind or call OmniClient directly throughomniClientSetS3Configuration2andomniClientSetAzureSASToken.
import ovphysx
from ovphysx.samples.python_samples.schema_support import physx_schema_paths
import ovstage
def step_remote_scene(scene_url: str) -> None:
# Register external PhysX and Newton schemas before population.
ovstage.population.register_usd_schemas(
[*[str(p) for p in physx_schema_paths()], str(ovphysx.newton_schema_root())]
)
stage = ovstage.Stage("remote-scene")
physx = None
try:
# For a private URL, configure OmniClient credentials here. Creating
# the Stage has loaded the client, and population has not started yet.
physx = ovphysx.PhysX()
ovstage.population.open_usd(
stage,
scene_url,
ordinal=1,
domains=ovstage.PopulationDomain.ALL,
)
# attach_ovstage() reads at a sealed ordinal.
stage.advance_write_floor(ordinal=1).wait()
physx.wait_task(physx.attach_ovstage(stage, read_ordinal=1))
physx.step_sync(1.0 / 60.0)
physx.wait_task(physx.detach_ovstage())
finally:
if physx is not None:
physx.destroy()
stage.destroy()
Notes#
OmniClient credentials are process-global — configure them before ovstage population of the remote URL.
The
domainsmask in thestep_remote_sceneexample isALL(equivalentlyPHYSICS | RENDERING), the choice for arbitrary content.PHYSICSalone, as in the shipped samples, is enough when the USD is known not to put physics under native scene-graph instances — refer to Population domains.OmniClient must be loaded before credentials are configured. Kit-hosted Python can import the host-provided
omni.clientmodule. Standalone callers create the ovstageStagefirst, then bind the client API that ovstage loaded before configuring credentials and callingopen_usd(). ConstructingPhysX()neither loads nor validates OmniClient.carb.omniclient.pluginis not part of the static ovphysx bootstrap path.
Scene Queries#
Scene queries are a deprecated compatibility surface declared in
include/ovphysx/ovphysx_legacy.h. ovphysx.h still includes that header so
existing #include <ovphysx/ovphysx.h> consumers compile. New code should
include the legacy header explicitly.
ovphysx provides three scene query functions covering raycast, sweep, and overlap:
Function |
Purpose |
Geometry input |
|---|---|---|
|
Cast a ray |
Origin + direction |
|
Move a shape along a direction |
Geometry desc + direction |
|
Test shape overlap at a position |
Geometry desc |
Each function accepts a mode parameter:
CLOSEST– return the single closest hit (0 or 1 result). Valid forraycastandsweeponly; overlap has no direction, so the Python API raisesValueErrorforoverlap(mode=CLOSEST). The C API treatsCLOSESTasALLfor overlap.ANY– return whether any hit exists (0 or 1, hit fields zeroed)ALL– return all hits
Geometry types for sweep/overlap:
SPHERE– radius + center positionBOX– half-extents + position + orientation quaternionSHAPE– 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. C and C++ callers holding the
raw pointer from ovphysx_raycast() / ovphysx_sweep() / ovphysx_overlap() must
copy any hits they need before issuing another query.
The Python API copies hit fields into Python-owned dicts before returning, so
retaining results from earlier raycast() / sweep() / overlap() calls across
subsequent queries is safe.
Python Example#
import ovphysx
from ovphysx import SceneQueryMode, SceneQueryGeometryType
def run_scene_queries(physx: ovphysx.PhysX) -> None:
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']}")
physx.sweep(
geometry_type=SceneQueryGeometryType.SPHERE,
direction=[1, 0, 0],
distance=50.0,
radius=0.5,
position=[0, 1, 0],
)
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 an opaque
omni::physics::parse::ObjectKey.handle – a runtime-assigned identity, not a
uint64-encoded SdfPath. There is no client-side bit-cast that reproduces this
encoding, so a consumer cannot compare a hit’s identity fields against a known
prim path by re-deriving the old path hash. Resolve these fields with
ovphysx_scene_query_get_paths_from_ids() (Python:
PhysX.get_scene_query_paths_from_ids()), the scene-query counterpart of
ovphysx_contact_binding_get_other_actor_paths_from_ids() for contact
bindings. Refer to the C API Reference for the scene-query hit and
path-resolver contracts.
PhysX Pointer Interop#
Native PhysX addresses are the read-only physxPointer column
(OVPHYSX_ATTR_PHYSX_POINTER). Query the matching ovphysx_query_target_t
and read that attribute. The column is a host uint64, one borrowed address
per matched object. Python reads it through PhysX.read as a Warp uint64
array. There is no separate pointer lookup.
Query type |
Address |
|---|---|
|
|
|
|
|
|
|
compound-shape wrapper |
|
|
|
standalone |
|
plugin custom joint |
|
|
|
|
|
|
|
particle system |
|
particle set |
|
process-global |
OVPHYSX_QUERY_TARGET_PHYSICS is one row. Its path-list entry is /, the absolute
root, and that entry is not a USD prim. Prim-set selection
does not change that row. The other new types above publish only
physxPointer. OVPHYSX_QUERY_TARGET_RIGID_BODY stays dynamics-only and does not
carry actor pointers, including statics.
A missing object is absent from the result. A query type with no
physxPointer attribute skips that name the same way it skips any other
unknown attribute. A destroyed object disappears from the next query. A
uint64 already copied into a live read session is not cleared.
The read is an ordinary read task. Drain pending simulation before using an address, and use it only after that task completes. Do not call PhysX on the pointer during an in-flight step.
The query target is the classification. Standalone joints, custom joints, and
articulation joints are OVPHYSX_QUERY_TARGET_JOINT,
OVPHYSX_QUERY_TARGET_CUSTOM_JOINT, and
OVPHYSX_QUERY_TARGET_ARTICULATION_JOINT. A path of another kind is absent
from that query. ovphysx_physx_type_t names the kind carried by
object-change callbacks.
Casting requires the PhysX SDK C++ headers shipped under include/physx/.
find_package(ovphysx) sets ovphysx_PHYSX_INCLUDE_DIR. No PhysX library
linking is needed.
Refer to the PhysX Interop tutorial for a
sample that reads physxPointer and calls setKinematicTarget().
Pointer Lifetime#
The address names a borrowed PhysX object. Reading it does not grant
ownership, extend lifetime, or permit an SDK call the simulation or device
mode already forbids. Treat the object as invalid after stage detachment,
instance destruction, or that object’s own destruction, and
reacquire it after attaching and initializing another stage. A completed
ovphysx_step() does not invalidate the object. The numeric column stays
valid until the read session is released, and a later step does not rewrite
it. Do not call release() on the address. These rules also apply to the
process-global PxPhysics pointer. Objects that callers explicitly create
through PxPhysics follow the PhysX SDK’s ownership rules.
Cached addresses are invalidated by the instance-scoped object-destroyed and
all-objects-destroyed callbacks. ovphysx_clone() does not emit a creation
callback for the clones. Refresh cached addresses after the clone task
completes.
Thread Safety#
PhysX APIs on returned pointers must only be called between simulation
steps — specifically after wait_task() 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.
Disabling simulation#
Do not set or clear PxActorFlag::eDISABLE_SIMULATION on a
PxRigidDynamic* read from physxPointer. That path is
unsupported: ovphysx does not observe the change, and on DirectGPU scenes
the next read or write may address the wrong body without error.
Use the official path instead: ovstage disableSimulation through ovphysx_write() /
OVPHYSX_ATTR_DISABLE_SIMULATION.
That path disables a standalone rigid body. A point-instancer instance cannot be disabled
individually in this release – disableSimulation is not an instancer-writable column and there is
no per-instance tensor route – so disabling a single instance of an instancer is unsupported.
Object-Change Notifications#
To manage cached pointers across the events that invalidate them, subscribe
to physics-object change notifications. The C API exposes
ovphysx_subscribe_object_changes() / ovphysx_unsubscribe_object_changes();
the experimental C++ wrapper returns an RAII
ovphysx::ObjectChangeSubscription. Subscribers receive three optional
callbacks:
on_object_created— fires AFTER the object exists. Safe to readphysxPointerfrom a deferred handler.on_object_destroyed— fires BEFORE the object is destroyed. Drop any cached pointer before the callback returns; it is unusable after that return. Do not callrelease()on it.on_all_objects_destroyed— fires BEFORE a bulk teardown, for exampleovphysx_detach_ovstage(). Flush the entire pointer cache before returning; every cached pointer is unusable after that return, and no per-object destruction events follow.
The pathless PxPhysics object is not identified by per-path create or destroy
callbacks. Invalidate any cached PxPhysics pointer at the explicit detach
and instance-destruction boundaries described above.
Subscriptions are per instance: pass the owning ovphysx_instance_handle_t. Callbacks fire only while that instance’s own attachment is live, and only for that attachment. A peer instance cannot unsubscribe another instance’s subscription (OVPHYSX_API_NOT_FOUND). Callback payloads still carry prim_path (and type), not a stage identifier – key caches by instance plus path, because two live stages may use the same paths.
ovphysx_unsubscribe_object_changes() returns OVPHYSX_API_SUCCESS only after that subscription’s callbacks have finished, including ones already running on another thread. user_data can be freed after that return. Calling unsubscribe from inside any object-change callback returns OVPHYSX_API_INVALID_STATE and leaves the subscription active. That check follows the Carbonite task when the callback is running as one, and the native thread otherwise. These notifications stay callbacks. They are not query or read records.
#include <ovphysx/ovphysx.h>
typedef struct pointer_cache_t {
unsigned int destroyed_count;
} pointer_cache_t;
static void on_destroyed(
ovphysx_string_t path, ovphysx_physx_type_t type, void* user_data)
{
(void)path;
(void)type;
pointer_cache_t* cache = (pointer_cache_t*)user_data;
++cache->destroyed_count;
}
static void on_all_destroyed(void* user_data)
{
pointer_cache_t* cache = (pointer_cache_t*)user_data;
cache->destroyed_count = 0;
}
static ovphysx_result_t start_observing_object_changes(
ovphysx_instance_handle_t handle,
pointer_cache_t* cache,
ovphysx_subscription_id_t* out_subscription)
{
ovphysx_object_change_callbacks_t callbacks = {0};
callbacks.on_object_destroyed = on_destroyed;
callbacks.on_all_objects_destroyed = on_all_destroyed;
callbacks.user_data = cache;
return ovphysx_subscribe_object_changes(handle, &callbacks, out_subscription);
}
static ovphysx_result_t stop_observing_object_changes(
ovphysx_instance_handle_t handle,
ovphysx_subscription_id_t subscription)
{
return ovphysx_unsubscribe_object_changes(handle, subscription);
}
#include <ovphysx/experimental/ovphysx.hpp>
#include <string_view>
#include <utility>
struct PointerCache {
void drop(std::string_view) {}
void clear() {}
};
static ovphysx::ObjectChangeSubscription observe_object_changes(
ovphysx_instance_handle_t handle, PointerCache& cache)
{
ovphysx::ObjectChangeCallbacks callbacks;
callbacks.onDestroyed = [&cache](
std::string_view path, ovphysx_physx_type_t) { cache.drop(path); };
callbacks.onAllDestroyed = [&cache]() { cache.clear(); };
return ovphysx::subscribeObjectChanges(handle, std::move(callbacks));
}
Threading: callbacks may fire from internal worker threads during
ovphysx_step(), ovphysx_step_sync(), or
ovphysx_detach_ovstage(). Do not call other ovphysx APIs from inside a
callback – defer follow-up work until the operation’s task wait succeeds, or
until ovphysx_step_sync() returns successfully. Unsubscribe from inside any
object-change callback returns OVPHYSX_API_INVALID_STATE instead of waiting.
That check follows the Carbonite task when the callback is running as one, and
the native thread otherwise. Another task on the same worker is not inside the
callback. Destroying the C++ handle there closes that subscription without
waiting, skips later callbacks, and frees its state after that callback has
unwound, including when the callback throws.
Clone and population: ovphysx_clone() does not emit on_object_created.
Pointer caches that need to track cloned objects must be refreshed after the
clone task wait succeeds. The initial ovstage attach/update population is also
silent. ovphysx_detach_ovstage() emits on_all_objects_destroyed and is the
supported path for bulk pointer invalidation.
Contact Data#
Use the session read API for new contact-data consumers: query CONTACT_SENSOR
for net forces, raw contact detail, or CPU contact events, or use
ovphysx_query_contacts to pair sensors with filters. In Python, use
PhysX.query with SimObjectType.CONTACT_SENSOR and the contacts= argument. Query creation can return an optional discovery summary, and reads can
report omitted attributes in their optional session summary. See
Contact queries and
Contact sensors for the complete contract.
The compatibility APIs below remain available for existing consumers:
Contact Binding |
|
|---|---|
Use when |
You need aggregate force tensors for RL rewards/safety, or per-contact-point geometry with actor identities |
Data shape |
|
API style |
Create a binding, then read tensors each step |
Device modes |
CPU dynamics and GPU dynamics, including DirectGPU |
USD requirement |
Sensor prims must have |
Key functions |
|
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.
Every authored USD prim named by sensor_patterns must have
PhysxContactReportAPI applied. Runtime-only clones use the schema metadata
copied from the corresponding source object, so an untagged collider cannot
become a sensor just because its parent body has the schema. Filter prims need no
extra schema. Filter patterns are still resolved against physics-registered objects, though (a
collider, rigid body, or other physics registration) — a pattern that only
names an arbitrary non-physics USD prim, for example a plain visual Mesh with
no physics schema at all, matches nothing.
On CPU simulation and GPU dynamics with CPU readback, a tagged child collider is eligible as a sensor but does not enable its owning body’s reporting. Also enable reporting on the body (or the owning articulation link or root) to avoid depending on reports from the other actor in the pair. If neither actor reports matching contacts, the child remains eligible but reads zero force. See Child collider sensors for the authoring example.
For these modes, enable reporting on the owner but select only the child as a binding sensor. Selecting the owner as a sensor in this or another live contact binding on the same attachment gives the owner 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.
Refer to 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.CUDA output tensors require DirectGPU TensorAPI;
physxScene:enableGPUDynamics=trueby itself only selects GPU dynamics. Refer to Warmup and Determinism.dtfor impulse-to-force conversion is taken automatically from the last successfulstep()orstep_sync()call that simulated. A zero-dt step simulates nothing: it leaves both the contacts and thatdtunchanged, so reads across it are identical.
Running With Other Carbonite Users#
ovphysx can share a process with other OV libraries that use Carbonite. ovphysx loads and links no USD library of its own, so it adds no USD image to the process and does not inspect the ones other libraries, including ovstage, bring with them.
ovphysx does not reuse a PhysX runtime loaded by another Carbonite user.
IPhysxSimulation is an internal direct runtime table, not a Carbonite-acquired
interface. ovphysx still uses Carbonite to load plugin binaries and dependencies,
then starts its statically linked OvruntimePhysX runtime directly. A foreign Carbonite
framework is allowed by default, but OVPHYSX_COEXIST_REFUSE=1 makes ovphysx fail
fast, and foundation-interface mismatches still fail with clear probe errors.
Compatible OV libraries can share Carbonite without sharing the PhysX runtime.
Applications install physx-usd-schemas separately and register both
resource directories before the first USD schema read, including population or
export. The application recipes in the samples locate and validate the required
resources without importing USD; see
Physics Schemas for Python and native setup.
Register the separately installed Newton USD schema
(ovphysx.newton_schema_root()) in the same call when scenes author newton:*
attributes.
When another USD-aware subsystem, such as ovrtx, shares the process, register
its schemas through its own documented mechanism in the same early window,
before any subsystem populates a stage or otherwise touches the USD schema
registry; API names and initialization requirements are subsystem-specific.
There is no implicit registration: ovphysx_create_instance() does not
register the schemas, and a late registration cannot repair an already-built
schema registry. Refer to Physics Schemas.
Important process rules:
ovphysx brings no USD library into the process and does not check which USD libraries other subsystems loaded.
Register every subsystem’s USD schemas before the first stage population or schema-registry access. Late calls cannot repair an already-populated schema registry.
Sharing a pre-loaded Carbonite PhysX runtime stack is not supported.
Kit or Isaac Sim processes that already loaded their physics extensions are not supported ovphysx embedding targets.
Set
ACTIVE_CUDA_GPUSafter create and before attach for CUDA device selection. ovphysx applies an explicit selection as part of serialized scene attachment; other subsystems must not reconfigure physics GPU selection during that attachment. A non-empty value takes precedence overSCENE_MULTI_GPU_MODE. Unset makes no ovphysx ordinal override (a fresh/default PhysX process selects automatically);"-1"explicitly requests automatic single-device selection. Applications that require a deterministic ordinal pass it explicitly before the first GPU scene attaches. An already-created PhysX CUDA context manager is not retargeted; use a new process to select a different ordinal. The expert DirectGPU setting/physics/suppressReadbackis host-managed (legacy Carbonite override on create-args /PhysXConfig) and should be configured before instance creation rather than changed while physics is active.If another Carbonite user already set the app directory, ovphysx preserves it.
Schema-path registration governs coexistence in the process. If the subsystems
also share one ovstage Stage, that Stage must be populated with ALL (or
PHYSICS | RENDERING) — refer to
Population domains.
Logging#
ovphysx uses Carbonite as its internal logging backend.
By default, the process-scoped level for libovphysx’s named Carbonite sources,
omni_physx_sdk, omni.physx, and ovphysx_internal, is
LogLevel.WARNING. Any unnamed source and every host and dependency source
remain unchanged. The application callback observes the Carbonite process log
stream; its own minimum severity and channel filter control records from every
source. LogLevel.NONE mutes only the three named sources; it is not a
whole-runtime or process mute.
Controlling the Log Level#
Python#
import ovphysx
ovphysx.set_global_config(ovphysx.GlobalConfigKey.LOG_LEVEL, ovphysx.LogLevel.VERBOSE)
print(ovphysx.get_global_config(ovphysx.GlobalConfigKey.LOG_LEVEL))
C#
#include <ovphysx/ovphysx.h>
#include <ovphysx/ovphysx_config.h>
int main(void) {
/* Applies to the omni_physx_sdk, omni.physx, and ovphysx_internal sources. */
if (ovphysx_set_global_config(
OVPHYSX_GLOBAL_CONFIG_LOG_LEVEL,
ovphysx_config_value_int64(OVPHYSX_LOG_VERBOSE)).status != OVPHYSX_API_SUCCESS)
return 1;
ovphysx_config_value_t current = {0};
if (ovphysx_get_global_config(
OVPHYSX_GLOBAL_CONFIG_LOG_LEVEL, ¤t).status != OVPHYSX_API_SUCCESS)
return 1;
return current.value.int64_value == OVPHYSX_LOG_VERBOSE ? 0 : 1;
}
Custom Log Callbacks (C)#
Set the single application callback to receive log messages programmatically.
Setting another callback replaces it; passing NULL disables it after accepted
invocations drain. Calls for one registration are serialized. The optional
comma-separated channel=level filter uses raw-prefix matching. The longest
matching prefix wins, with later equal-length rules winning ties. Message and
channel are valid, NUL-terminated views for the callback duration; timestamp is
Unix-epoch seconds.
#include <ovphysx/ovphysx.h>
#include <ovphysx/ovphysx_config.h>
#include <stdint.h>
#include <stdio.h>
void my_logger(ovphysx_log_level_t level,
ovphysx_string_t message,
ovphysx_string_t channel,
double timestamp,
void* user_data) {
(void)user_data;
fprintf(stderr, "[%.*s][%d][%.3f] %.*s\n",
(int)channel.length, channel.ptr, (int)level, timestamp,
(int)message.length, message.ptr);
}
int main(void) {
ovphysx_string_t filter = OVPHYSX_LITERAL(
"omni_physx_sdk=verbose,omni.physx=verbose,ovphysx_internal=warning");
if (ovphysx_set_global_config(
OVPHYSX_GLOBAL_CONFIG_LOG_LEVEL,
ovphysx_config_value_int64(OVPHYSX_LOG_VERBOSE)).status != OVPHYSX_API_SUCCESS)
return 1;
if (ovphysx_set_log_callback(
OVPHYSX_LOG_WARNING, &filter, my_logger, NULL).status != OVPHYSX_API_SUCCESS)
return 1;
/* No simulation work is required to demonstrate callback configuration. */
if (ovphysx_flush_log(OVPHYSX_TIMEOUT_INFINITE).status != OVPHYSX_API_SUCCESS)
return 1;
if (ovphysx_set_log_callback(
OVPHYSX_LOG_DEFAULT, NULL, NULL, NULL).status != OVPHYSX_API_SUCCESS)
return 1;
/* Safe to destroy callback resources here. */
return 0;
}
Invalid severity or filter arguments leave the prior callback unchanged. Any other error may be reported after a replacement was published; conservatively keep the candidate callback and user-data resources alive until a later successful replace, disable, or shutdown drains the slot.
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.
OVPHYSX_GLOBAL_CONFIG_DEFAULT_LOG_OUTPUT controls Carbonite’s process-global
built-in console logger, so disabling it affects every Carbonite tenant in the
process. Multi-tenant hosts should normally own that policy themselves. A
standalone application can suppress the built-in logger as follows:
Python#
import ovphysx
ovphysx.enable_python_logging()
ovphysx.set_global_config(ovphysx.GlobalConfigKey.DEFAULT_LOG_OUTPUT, False)
C#
#include <ovphysx/ovphysx.h>
#include <ovphysx/ovphysx_config.h>
int configure_logging(ovphysx_log_callback_t callback) {
if (ovphysx_set_log_callback(
OVPHYSX_LOG_VERBOSE, NULL, callback, NULL).status != OVPHYSX_API_SUCCESS)
return 1;
if (ovphysx_set_global_config(
OVPHYSX_GLOBAL_CONFIG_DEFAULT_LOG_OUTPUT,
ovphysx_config_value_bool(false)).status != OVPHYSX_API_SUCCESS)
return 1;
return 0;
}
Python Logging Bridge#
Route native log messages into Python’s standard logging module. The bridge
owns the single native callback slot, so enabling it replaces any C-level
callback previously set through the same process. Its default callback
threshold is LogLevel.VERBOSE, so the libovphysx source threshold remains the
only additional filter for omni_physx_sdk, omni.physx, and
ovphysx_internal records unless the application passes a stricter
min_severity:
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)
# Instance startup and shutdown emit native messages through the bridge.
physx = ovphysx.PhysX()
physx.destroy()
ovphysx.disable_python_logging()
Destroying the final PhysX instance successfully shuts down the current
Python process-lifecycle scope and disables the bridge. Call
enable_python_logging() again after creating an instance in a new lifecycle
scope. Final shutdown waits for an already-started bridge transition and keeps
the Python callback owner alive until native callback draining returns; bridge
enable/disable calls started during shutdown fail as overlapping transitions.
Calling PhysX() or PhysX.destroy() from inside the native log callback is
rejected before it can wait on or change process-lifecycle state; retry after
the callback returns. Reading process-global configuration from that callback
is supported, including the bound profiler. Setting a startup-only
process-global value from it is rejected and does not wait on instance creation. Process initialization and final shutdown publish
transitions without holding the process-lifecycle lock while native code may
deliver or drain callbacks. Concurrent constructors wait on a condition while
process initialization finishes. Construction racing final shutdown fails fast
so a callback dependency cannot wait on the shutdown that is draining it. A
callback must not synchronously wait for work that may emit into the same
serialized callback registration. If Python is interrupted when native process
initialization may already have committed, the wrapper conservatively owns and
shuts down that lifecycle before propagating the interruption. If a native
callback change may already have committed, the bridge retains every possibly
published callback owner until a later successful replace, disable, or shutdown
drains the native slot.
OmniPVD Recording#
ovphysx supports recording PhysX simulation internals to .ovd files through
OmniPVD. The resulting files can be opened in a compatible Kit application with
the OmniPVD extension for frame-by-frame inspection of shapes, contacts, and
solver state. The reader must support both the recording’s OmniPVD stream
version and its independent PhysX OVD integration version; refer to the full
tutorial for the current versions and compatibility policy.
Enable recording capability with module-level
set_global_config(GlobalConfigKey.OVD_RECORDING_CAPABLE, True) before
PhysX(). Native callers initialize, then call
ovphysx_set_global_config(OVPHYSX_GLOBAL_CONFIG_OVD_RECORDING_CAPABLE, ovphysx_config_value_bool(true)) before ovphysx_create_instance(&handle).
The BOOL key defaults to false, is process-wide, and leaves output dormant
until explicit start. Its setter is rejected after the first successful create
until successful shutdown with no live instances, even after all instances are
destroyed. Shutdown while handles remain live does not reopen the setter.
The configured value is retained across shutdown, and the getter reports it.
Old typed and raw OVD startup configuration returns INVALID_ARGUMENT with
migration guidance; its numeric slots remain reserved. Python startup fields,
including PhysXConfig.omnipvd_recording_capable, are removed.
Python releases its native lifecycle when the last owner is destroyed.
Attach the scene, then use start_recording() with
an explicit FILE or TCP OmniPvdDestination; start before the first step to
capture the first simulation frame. Creation, attach, and reattach never start
output automatically. Stop before teardown, or start a new
recording on the same capable runtime.
Native callers use ovphysx_instance_handle_t and the
ovphysx_ovd_start_recording, ovphysx_ovd_stop_recording, and
ovphysx_ovd_is_recording entry points. The destination layout and ownership
are unchanged. Python retains start_recording, stop_recording, and
is_recording.
The recording tutorial contains complete examples and the dormant capability cost. Existing startup-output callers should follow the migration guide.
Profiling#
ovphysx can publish its profile zones to one of two consumers, so you can see what your process was doing. Three groups of zones appear:
Group |
Contents |
|---|---|
|
The ovphysx API calls: |
Runtime zones |
The host-side work between them: USD parsing and stage load, collision cooking, PhysX task dispatch, per-step orchestration |
PhysX SDK zones |
The SDK’s own profile zones, nested inside the step zones above, for both CPU and GPU work |
The instrumentation is compiled into release builds and the shipped wheel, so profiling needs no rebuild. No backend is selected by default, and while none is bound a zone costs an atomic load and a branch.
Select one with the environment variable, which needs no change to your code:
OVPHYSX_PROFILER=trace OVPHYSX_PROFILER_OUTPUT=run.json python my_workload.py
OVPHYSX_PROFILER=nvtx nsys profile -t nvtx,cuda python my_workload.py
Or programmatically, which is useful when the process decides at runtime whether it is being profiled:
from ovphysx import GlobalConfigKey, PhysX, ProfilerBackend, set_global_config
set_global_config(GlobalConfigKey.PROFILER_BACKEND, int(ProfilerBackend.TRACE))
set_global_config(GlobalConfigKey.PROFILER_OUTPUT_PATH, "run.json")
physx = PhysX()
In C, call ovphysx_set_global_config(OVPHYSX_GLOBAL_CONFIG_PROFILER_BACKEND, ovphysx_config_value_int64(OVPHYSX_PROFILER_BACKEND_TRACE)) before
ovphysx_create_instance().
Both forms resolve to the process-wide /physics/profilerBackend Carbonite setting and must be in place before the instance is created, because the backend is bound and the PhysX SDK profiler callback installed while the SDK is created. Before a backend is bound, ovphysx_get_global_config(OVPHYSX_GLOBAL_CONFIG_PROFILER_BACKEND, &value) (Python: get_global_config(GlobalConfigKey.PROFILER_BACKEND)) reports the environment value when OVPHYSX_PROFILER is set, otherwise the setter. After ovphysx binds a backend, the getter reports that bound backend and, for the trace backend, the output path captured at bind. A later environment change does not change the getter until the last instance shuts down. A failed bind reports NONE and an unset output path. If another Carbonite profiler already owns the process, the getter also reports NONE: the ovphysx selection is not the publisher, and the zones still go to the host profiler.
Because this is a create-time decision, profile a slice of a long run by scoping the collection with Nsight’s delay, duration, and capture-range options rather than by toggling the instrumentation.
Like all Carbonite settings, /physics/profilerBackend is process-global and sticky: once a process selects a backend, a later instance created with no setter and no environment variable keeps it. Turning it off again is explicit, with OVPHYSX_PROFILER=none or PROFILER_BACKEND set to NONE. The first backend to bind also wins for as long as any instance is alive; a later instance asking for a different one, or for NONE, is told at WARN which is actually bound and publishes to that one. Once the last instance shuts down the profiler is released and the latch clears, so a process that destroys every instance can select a different backend for the next one.
Notes:
The trace file is written when the last instance in the process shuts down, because that is when the profiler is released. It is plain JSON, not gzip.
The PhysX SDK zones depend on the profile zones being compiled into the PhysX SDK that ovphysx links, which is the case for the shipped
checkedPhysX libraries. A build against a PhysXreleasepackage has no zones to forward, and that group stays silent while the others still work.The SDK’s twelve cross-thread phases (
Basic.simulate,Basic.collision, …) are not reported at all, under any backend. The Carbonite profiler interface models a zone as a per-thread begin/end pair and cannot express a range closing on another thread, so the runtime discards them rather than corrupt a thread’s zone stack.Basic.simulatespans the same work asovphysx_stepthroughovphysx_wait_task, which is the closest substitute for the outermost one.ovphysx_steponly enqueues work, so its zone is short and the simulation cost appears underovphysx_wait_task. That is the asynchronous execution model, not a measurement artifact.The runtime zones include Carbonite’s own task-system zones (
Running fiber,Executing task), which wrap everything else and so dominate a summary sorted by total time. They are also not free to record: several build their names per prim, so a profiled stage attach is measurably slower than an unprofiled one.If the surrounding process already bound a Carbonite profiler backend, that one keeps the zones and ovphysx logs which backend bound, at
INFO.The SDK zones reach the Carbonite profiler (
/physics/exposeProfilerData, on by default) and from there whichever backend is bound, so they follow the same one route as every other zone.
For a runnable sample, memory-traffic attribution, and troubleshooting, refer to Profiling. For recording options and the timeline UI, refer to the Nsight Systems User Guide.
You now have the core integration rules for building, running, and operating ovphysx. For the full C API reference, refer to the C API Reference. For Python, refer to the Python API Reference.