OmniPVD Recording – Capture Physics Internals to FILE or TCP#
This tutorial shows how to record OmniPVD data from ovphysx to .ovd files
for offline inspection or stream it live to a TCP listener. OmniPVD captures
selected PhysX object and contact state each frame so you can debug and
visualize physics behavior.
Prerequisites#
Install ovphysx and confirm native libraries load.
A USD scene with physics objects. This tutorial uses
links_chain_sample.usda, which ships with every package underovphysx/samples/data/in the wheel,<sdk-root>/samples/data/in the C/C++ SDK, andtests/data/in a repository checkout.For offline inspection: a compatible Kit application with the OmniPVD extension.
Required Config#
Startup OmniPVD output and late-recording capability are selected at instance creation. The default FILE startup transport also uses a recording directory:
Startup output and late-recording capability fields
Config field (Python) |
C builder |
Description |
|---|---|---|
|
|
Writable directory where |
|
|
Enables OmniPVD data capture |
|
|
Permits recording to start later without enabling startup output |
Set omnipvd_output_enabled before creating the PhysX instance. For FILE, also set
omnipvd_ovd_recording_directory; TCP does not use it. Pass fields through PhysXConfig
(Python) or config_entries in ovphysx_create_args (C/C++).
Startup output implicitly installs late-recording capability. The capability is
process-wide and fixed when the shared runtime is created. With startup output
and capability both disabled, the default instance deliberately passes a null
PxOmniPvd to PhysX and cannot start recording later; this avoids incremental
sampler, factory-listener, and sampling-mutex overhead. This is the only
zero-OmniPVD-overhead configuration. Explicit late-recording capability creates
the provider and sampler at PxPhysics creation and enables OVD and collision
readback on attached scenes even while recording is idle; DirectGPU scenes
therefore retain that readback cost.
For FILE, the runtime auto-creates the recording directory if it does not exist. FILE is the default startup transport. TCP startup adds four fields:
TCP startup transport fields
Config field (Python) |
C builder |
Description |
|---|---|---|
|
|
Exact lowercase |
|
|
Ready listener address |
|
|
Listener port, 1 through 65535 |
|
|
Blocked-send timeout in milliseconds; 0 leaves it at the OS default and uses a 3000 ms connect window |
With TCP, ovphysx is the client. Start the listener before constructing the PhysX instance: the connection is synchronous. TCP is trusted plaintext, so use it only on a trusted network.
Code#
Python#
This complete sample enables startup FILE output with a recording directory,
attaches links_chain_sample.usda, steps for two seconds, destroys the instance
so the runtime finalizes the recording, and then lists the *_rec.ovd files it
produced:
import glob
import os
import tempfile
from pathlib import Path
import ovphysx
from ovphysx import PhysX, PhysXConfig
_physx_schemas_registered = False
def attach_scene(physx, usd_path, stage_name):
import ovstage
if not ovstage.population.available():
raise RuntimeError("ovstage population bridge is unavailable")
# ovphysx ships its PhysX USD schemas as codeless resources and does not register
# them itself. Register them with ovstage once, before the first population
# call in the process. The Newton USD schema (pip package newton-usd-schemas)
# is registered alongside so authored newton:* attributes reach the parser.
global _physx_schemas_registered
if not _physx_schemas_registered:
ovstage.population.register_usd_schemas(
[str(ovphysx.codeless_schema_root()), str(ovphysx.newton_schema_root())]
)
_physx_schemas_registered = True
stage = ovstage.Stage(stage_name)
ordinal = 1
try:
ovstage.population.open_usd(stage, str(usd_path), ordinal=ordinal, domains=ovstage.PopulationDomain.PHYSICS)
stage.advance_write_floor(ordinal=ordinal).wait()
physx.attach_ovstage(stage, read_ordinal=ordinal)
return stage
except Exception:
stage.destroy()
raise
# Use a temporary directory for recording output.
# Replace with your own path for persistent recordings.
output_dir = tempfile.mkdtemp(prefix="ovphysx_pvd_")
print(f"OmniPVD recording directory: {output_dir}")
# Initialize PhysX with OmniPVD recording enabled.
# IMPORTANT: Both config fields must be passed at initialization, before the
# physics engine is created internally. The recording directory must be
# a valid, writable path.
physx = PhysX(
config=PhysXConfig(
omnipvd_ovd_recording_directory=output_dir,
omnipvd_output_enabled=True,
)
)
# Prefer package data so a copied sample works. Fall back to the checked-in
# sample's adjacent data directory when package data is absent.
usd_path = (
Path(ovphysx.__file__).resolve().parent
/ "samples"
/ "data"
/ "links_chain_sample.usda"
)
if not usd_path.is_file():
usd_path = Path(__file__).resolve().parent.parent / "data" / "links_chain_sample.usda"
if not usd_path.is_file():
raise FileNotFoundError(f"ovphysx sample data is missing: {usd_path}")
print(f"Loading USD scene: {usd_path}")
stage = attach_scene(physx, usd_path, "ovphysx-omnipvd-sample")
physx.wait_all()
# Run the simulation. OmniPVD captures each frame automatically.
dt = 1.0 / 60.0
n_steps = 120 # 2 seconds at 60 Hz
print(f"Simulating {n_steps} steps...")
for i in range(n_steps):
physx.step_sync(dt)
print("Simulation complete.")
# Destroying the instance finalizes the recording:
# the runtime renames tmp.ovd -> <timestamp>_rec.ovd.
physx.detach_ovstage()
stage.destroy()
physx.destroy()
print("Runtime cleanup complete; recording retained for inspection")
# List the produced .ovd files
ovd_files = glob.glob(os.path.join(output_dir, "*_rec.ovd"))
if ovd_files:
print(f"\nRecorded {len(ovd_files)} OVD file(s):")
for f in ovd_files:
size_kb = os.path.getsize(f) / 1024
print(f" {os.path.basename(f)} ({size_kb:.1f} KB)")
print("\nOpen these files in a Kit app with OmniPVD to inspect simulation data.")
else:
print("\nWARNING: No .ovd files found. Check runtime logs for errors.")
C++#
CMakeLists.txt
The C++ sample uses the same find_package(ovphysx) and ovphysx::ovphysx
target as every other sample:
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.16)
project(OmniPvdRecordingCpp CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(ovphysx REQUIRED)
add_executable(omnipvd_recording_cpp main.cpp)
target_link_libraries(omnipvd_recording_cpp PRIVATE ovphysx::ovphysx ovphysx::ovstage)
target_include_directories(omnipvd_recording_cpp PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../common")
get_filename_component(OVPHYSX_TEST_DATA_DIR "${CMAKE_CURRENT_LIST_DIR}/../../data" ABSOLUTE)
target_compile_definitions(omnipvd_recording_cpp PRIVATE
OVPHYSX_TEST_DATA="${OVPHYSX_TEST_DATA_DIR}"
)
if(WIN32)
ovphysx_copy_runtime_dlls(omnipvd_recording_cpp)
endif()
TCP Startup#
To stream to a TCP listener from instance creation, pass the transport and listener fields together:
physx = PhysX(config=PhysXConfig(
omnipvd_output_enabled=True,
omnipvd_transport="tcp",
omnipvd_tcp_address="127.0.0.1",
omnipvd_tcp_port=5425,
omnipvd_tcp_timeout_ms=3000,
))
The equivalent compact C config is:
ovphysx_config_entry_t config[] = {
ovphysx_config_entry_omnipvd_output_enabled(true),
ovphysx_config_entry_omnipvd_transport(OVPHYSX_OMNIPVD_TRANSPORT_TCP_NAME),
ovphysx_config_entry_omnipvd_tcp_address(OVPHYSX_LITERAL("127.0.0.1")),
ovphysx_config_entry_omnipvd_tcp_port(5425),
ovphysx_config_entry_omnipvd_tcp_timeout_ms(3000),
};
ovphysx_create_args args = OVPHYSX_CREATE_ARGS_DEFAULT;
args.config_entries = config;
args.config_entry_count = 5;
Source
The C++ sample records startup FILE output through the C API, but it attaches
simple_physics_scene.usda and runs 10 steps:
#include "ovphysx/ovphysx.h"
#include "ovphysx/ovphysx_config.h"
#include "ovstage_sample.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <string>
namespace fs = std::filesystem;
// Count files matching *_rec.ovd in the given directory.
static int count_ovd_files(const fs::path& dir) {
int count = 0;
std::error_code ec;
for (const auto& entry : fs::directory_iterator(dir, ec)) {
const auto name = entry.path().filename().string();
if (name.size() > 8 && name.substr(name.size() - 8) == "_rec.ovd")
++count;
}
return count;
}
static int run(void)
{
// Create a fresh output directory for this invocation in the caller-owned working directory.
std::error_code ec;
const fs::path working_dir = fs::current_path(ec);
if (ec) {
const std::string reason = ec.message();
fprintf(stderr, "Failed to resolve the working directory: %s\n", reason.c_str());
return 1;
}
const std::chrono::steady_clock::rep unique_suffix =
std::chrono::steady_clock::now().time_since_epoch().count();
fs::path output_dir =
working_dir / ("ovphysx_pvd_cpp_sample_" + std::to_string(unique_suffix));
if (!fs::create_directory(output_dir, ec)) {
const std::string reason = ec ? ec.message() : "directory already exists";
fprintf(stderr, "Failed to create directory '%s': %s\n",
output_dir.string().c_str(), reason.c_str());
return 1;
}
std::string dir_str = output_dir.string();
printf("OmniPVD recording directory: %s\n", dir_str.c_str());
// Configure OmniPVD recording via typed config entries. Both have to be set
// before instance creation, because the recording pipeline initializes
// during physics engine startup.
ovphysx_config_entry_t config[] = {
ovphysx_config_entry_omnipvd_ovd_recording_directory(ovphysx_cstr(dir_str.c_str())),
ovphysx_config_entry_omnipvd_output_enabled(true),
};
ovphysx_create_args create_args = OVPHYSX_CREATE_ARGS_DEFAULT;
create_args.config_entries = config;
create_args.config_entry_count = 2;
ovphysx_handle_t handle = 0;
ovphysx_result_t r = ovphysx_initialize();
if (r.status != OVPHYSX_API_SUCCESS) {
ovphysx_string_t err = ovphysx_get_last_error();
fprintf(stderr, "Failed to initialize ovphysx: %.*s\n",
(int)(err.ptr ? err.length : 0), err.ptr ? err.ptr : "");
return 1;
}
r = ovphysx_create_instance(&create_args, &handle);
if (r.status != OVPHYSX_API_SUCCESS) {
ovphysx_string_t err = ovphysx_get_last_error();
fprintf(stderr, "Failed to create PhysX instance: %.*s\n",
(int)(err.ptr ? err.length : 0), err.ptr ? err.ptr : "");
ovphysx_shutdown();
return 1;
}
ovphysx_sample_stage_attachment_t stage_attachment = {};
if (!ovphysx_sample_attach_usd_with_ovstage(
handle, OVPHYSX_TEST_DATA "/simple_physics_scene.usda", &stage_attachment)) {
ovphysx_destroy_instance(handle);
ovphysx_shutdown();
return 1;
}
// Run simulation steps. OmniPVD records each frame.
const float dt = 1.0f / 60.0f;
const int n_steps = 10;
printf("Running %d simulation steps...\n", n_steps);
for (int i = 0; i < n_steps; i++) {
ovphysx_result_t step_r = ovphysx_step_sync(handle, dt);
if (step_r.status != OVPHYSX_API_SUCCESS) {
ovphysx_string_t err = ovphysx_get_last_error();
fprintf(stderr, "Step %d failed: %.*s\n", i,
(int)(err.ptr ? err.length : 0), err.ptr ? err.ptr : "");
ovphysx_sample_destroy_stage(handle, &stage_attachment);
ovphysx_destroy_instance(handle);
ovphysx_shutdown();
return 1;
}
}
printf("Simulation complete.\n");
// Destroying the instance finalizes the recording. tmp.ovd is renamed to a
// timestamped *_rec.ovd file.
ovphysx_sample_destroy_stage(handle, &stage_attachment);
ovphysx_destroy_instance(handle);
ovphysx_shutdown();
// At least one OVD file has to exist. A non-zero return lets CI catch regressions.
int ovd_count = count_ovd_files(output_dir);
if (ovd_count > 0) {
printf("Recorded %d OVD file(s) in %s\n", ovd_count, dir_str.c_str());
printf("Runtime cleanup complete; recording retained for inspection\n");
return 0;
}
fprintf(stderr, "FAIL: No OVD files found in %s\n", dir_str.c_str());
return 1;
}
int main(void) {
int rc = run();
return rc;
}
Late TCP Recording#
Opt in when creating the first instance, before attaching and stepping the
scene. Start the trusted-plaintext TCP listener before calling
start_recording(); the connect is synchronous. This complete sequence
simulates before recording, records to TCP, stops, continues simulating, and
then records to a FILE destination. Replace scene.usda with a path to your own
USD scene, such as the links_chain_sample.usda file named in the
prerequisites:
import ovstage
from ovphysx import OmniPvdDestination, PhysX, PhysXConfig, codeless_schema_root, newton_schema_root
physx = PhysX(config=PhysXConfig(omnipvd_recording_capable=True))
# Register the codeless PhysX schemas and the Newton schema before the first population call.
ovstage.population.register_usd_schemas([str(codeless_schema_root()), str(newton_schema_root())])
stage = ovstage.Stage("late-recorded-scene")
ovstage.population.open_usd(
stage, "scene.usda", ordinal=1, domains=ovstage.PopulationDomain.PHYSICS
)
stage.advance_write_floor(ordinal=1).wait()
physx.attach_ovstage(stage, read_ordinal=1)
physx.step_sync(1 / 60) # work before recording is allowed
physx.start_recording(
OmniPvdDestination.tcp("127.0.0.1", 5425, timeout_ms=3000)
)
assert physx.is_recording()
physx.step_sync(1 / 60) # streamed to the TCP listener
physx.stop_recording()
physx.step_sync(1 / 60) # simulation continues without recording
physx.start_recording(OmniPvdDestination.file("capture.ovd"))
physx.step_sync(1 / 60) # written to capture.ovd
physx.stop_recording()
physx.detach_ovstage()
physx.destroy()
stage.destroy()
C and C++#
To adapt the complete C++ sample in Code, reuse its initialization, instance,
stage-attachment, and cleanup scaffolding. Remove its recording-directory
setup, startup-FILE comments, and final *_rec.ovd file-count check. Replace
the startup-output config with late-recording capability before creating the
first instance. After the existing stage, instance, and runtime cleanup, return
0 in place of the removed file-count block.
const ovphysx_config_entry_t config[] = {
ovphysx_config_entry_omnipvd_recording_capable(true),
};
ovphysx_create_args create_args = OVPHYSX_CREATE_ARGS_DEFAULT;
create_args.config_entries = config;
create_args.config_entry_count = 1;
After creating handle with those arguments and attaching a physics stage,
replace the sample’s simulation loop with the following sequence. Its first
successful step initializes lazy physics before the late start:
const ovphysx_omnipvd_destination_t destination = {
OVPHYSX_OMNIPVD_TRANSPORT_TCP,
OVPHYSX_LITERAL(""),
OVPHYSX_LITERAL("127.0.0.1"),
5425,
3000,
};
ovphysx_result_t result = ovphysx_step_sync(handle, 1.0f / 60.0f);
if (result.status != OVPHYSX_API_SUCCESS)
return 1;
result = ovphysx_start_recording(handle, &destination);
if (result.status != OVPHYSX_API_SUCCESS)
return 1;
bool recording = false;
result = ovphysx_is_recording(handle, &recording);
if (result.status != OVPHYSX_API_SUCCESS || !recording)
return 1;
result = ovphysx_step_sync(handle, 1.0f / 60.0f);
if (result.status != OVPHYSX_API_SUCCESS)
return 1;
result = ovphysx_stop_recording(handle);
if (result.status != OVPHYSX_API_SUCCESS)
return 1;
If the listener is not ready, start_recording() fails without consuming the
session; start the listener and retry. After stop_recording(), the same
instance can later record to another TCP listener or to an exact FILE path.
FILE uses the exact requested path, and TCP connects synchronously to an already-ready listener. Failed opens can be retried. Only one stream can be active in the shared runtime, and an active stream cannot be replaced. After stop, the owning instance can start another FILE or TCP session. Startup output is owned by the instance whose creation started it: that instance can query and publicly stop the startup session and restart to a late destination, while peer instances report inactive and cannot stop it. On cold startup the creator’s ownership is reserved when creation succeeds and becomes observable when the first stage attach starts sampling. Detaching the active stage finalizes the shared session and clears its public owner, including when a peer handle started it. After reattach, capability-only recording is dormant and can start immediately. Configured startup output instead starts a new startup session owned by the reattaching instance; stop it before selecting a late destination.
These synchronous APIs follow the ovphysx same-thread contract. The caller must serialize recording calls on each handle and recording/attach/detach/destroy transitions across all handles sharing the runtime; concurrent calls are not supported. Recording is supported on Windows x86_64 and Linux x86_64/aarch64. Startup, late, and restarted sessions each capture the current core PhysX, PhysXExtensions (including joints and custom geometry), and PhysXVehicle state. Stopping releases only the session’s telemetry handles; the next start takes a fresh full-state snapshot.
What Happens at Runtime#
A recording session moves through these stages:
Late FILE opens its exact path, and TCP connects its socket, before the runtime binds that stream to the OmniPVD writer. A late FILE path’s parent directory must already exist.
Startup FILE output creates
tmp.ovd; late FILE output opens the exact requested path. TCP connects to the already-ready listener.Each simulation step writes physics state to the selected stream.
Stop, active detach, or active-owner destruction cleanly finalizes the stream. Only startup FILE output renames
tmp.ovdtoYYYY_MM_DD_HH_MM_SS_CC_rec.ovdand updates the Kit import directory; late FILE keeps its exact path.
With FILE startup output, an empty recording directory disables startup
recording. omnipvd_output_enabled=false disables startup capture, but an
instance created with omnipvd_recording_capable=true can still start an exact
FILE or TCP session later.
Inspecting .ovd Files in Kit#
Before you begin, confirm two things. First, you need a finalized recording:
startup FILE output leaves a YYYY_MM_DD_HH_MM_SS_CC_rec.ovd file in the
recording directory after clean shutdown, and late FILE output leaves the exact
path you requested. A remaining tmp.ovd means startup output was not finalized.
Possible causes include an instance that was not destroyed cleanly, failure to
create the output directory, or failure to rename the temporary file. Check the
runtime log for a filesystem error and verify that the destination is writable
before continuing. Second, you need the compatible Kit-based application with
the OmniPVD extension named in the prerequisites.
Then inspect the recording:
Open a compatible Kit-based application (for example, USD Composer or Isaac Sim with a compatible OmniPVD extension generation).
Enable the OmniPVD extension (
omni.physx.pvd) from Window > Extensions.Use File > Open or the OmniPVD panel to load the
.ovdfile.Use the timeline scrubber to step through recorded frames and inspect shapes, contacts, and solver state.
Inspection succeeded when the OmniPVD panel lists your .ovd file as imported
and the timeline scrubber advances through recorded frames, with shapes,
contacts, and solver state updating as you scrub. A file the panel refuses to
import can be unreadable, malformed, truncated, or incompatible with the reader.
Check the reader’s reported error first. If it reports a format or version
mismatch, refer to Capture Format and Compatibility
for the two version checks the reader applies.
For more details on the Kit-side OmniPVD workflow, refer to the PhysX Visual Debugger documentation included with the Kit application you use for inspection.
Capture Format and Compatibility#
ovphysx OmniPVD capture is optional. When enabled, the runtime writes PhysX OmniPVD
recordings in the OVD binary format (startup FILE output is named
*_rec.ovd after clean finalization; late FILE output keeps its exact path).
OVD capture format and compatibility policy
This table records the format, version, and platform policy for ovphysx OmniPVD capture:
Item |
Policy |
|---|---|
Format |
PhysX OmniPVD OVD command stream (not USD, not a general capture/replay API) |
OmniPVD stream version |
Each recording begins with a 12-byte OmniPVD version header. The writer in the PhysX SDK pinned by this ovphysx build emits 0.4.0 ( |
PhysX OVD integration version |
The stream’s |
Canonical reader |
Kit OmniPVD extension |
Reader compatibility |
Both versions must be compatible. The OmniPVD runtime reader rejects a command-stream version newer than its own |
ovphysx version coupling |
ovphysx does not define a separate OVD version. Compatibility follows the PhysX / OmniPVD runtime pinned by that ovphysx build and the Kit OmniPVD extension that reads the file. Prefer matching generations: inspect with a Kit build whose OmniPVD extension supports both the recorded OmniPVD stream version and the PhysX OVD integration major. |
Platform |
OmniPVD recording is supported on Windows x86_64 and Linux x86_64/aarch64. PVDRuntime is statically linked into the shipped native library; no separate PVDRuntime |
This is the public compatibility story for ovphysx OmniPVD capture. If you need a guaranteed cross-release matrix beyond the two Reader compatibility checks in Capture Format and Compatibility, confirm with the OmniPVD owners before treating an older Kit reader as supported against a newer writer.
Troubleshooting#
These symptoms cover the configuration and lifecycle mistakes that stop a recording from appearing:
OmniPVD recording symptoms and fixes
Symptom |
Cause |
Fix |
|---|---|---|
No |
FILE directory or output enablement was omitted before instance creation |
Pass both FILE fields in |
|
Instance not properly destroyed |
Ensure |
Runtime error about directory |
Directory path is invalid or not writable |
Use an absolute path to a writable location |
TCP start fails |
Listener was not ready or address/port was wrong |
Start the trusted plaintext listener before creating the instance or calling |
Result#
After this tutorial you can stream live OmniPVD data over TCP or capture .ovd
recordings from an ovphysx simulation for offline inspection in Kit.