Cloning – Replicate Environments#

This tutorial shows how to use the clone API to replicate sub-sections of a USD scene. Cloning creates copies in the internal physics representation (not USD prims), optimized for large-scale parallel simulation.

Prerequisites#

  • Complete the Hello World tutorial.

  • A USD scene with a hierarchy suitable for cloning (for example /World/envs/env0). This tutorial uses basic_simulation.usda, which ships with every package under ovphysx/samples/data/ in the wheel, <sdk-root>/samples/data/ in the C/C++ SDK, and tests/data/ in a repository checkout.

Key Concepts#

Two ways to replicate. ovphysx offers two cloning paths:

  • Direct ovphysx API (PhysX.clone() / ovphysx_clone()) — the approach used in this tutorial. After an ovstage-backed scene is attached and drained, clone() copies a source subtree in the internal physics representation. No USD prims are created, so it stays fast and memory-light at large environment counts.

  • Up-front ovstage duplication (ovstage_clone) — applications that own the ovstage Stage can duplicate the source subtree on the stage before attaching it. This keeps every scene edit on the producer-owned stream and avoids the warmup ordering constraint described in Warmup and Determinism.

Refer to Scene Cloning for the full comparison.

Tensor-binding path patterns include direct ovphysx runtime clones even when an intermediate target path has no authored USD prim. For example, after cloning /World/envs/env0/robot to /World/envs/env1/robot, the pattern /World/envs/env*/robot resolves both objects.

Clone before warmup. All clone() calls must happen before warmup and before the first step(). Cloning after warmup() or the first step reallocates physics structures and would corrupt already-initialized solver state, so the runtime rejects it with OVPHYSX_API_INVALID_ARGUMENT (surfaced in Python as RuntimeError). If you must clone later, call reset_stage(), wait for it to complete, then reload the source scene or reattach its ovstage before cloning again. Refer to Warmup and Determinism.

Grouping copies with env_ids. A single clone() call numbers its copies automatically. One logical environment is sometimes assembled from several clone() calls — for example, first every environment’s robot, then every environment’s object. In that case, pass the optional per-target env_ids with the same ids in every call. Copies sharing an id land in the same runtime environment and can collide, and without env_ids, objects cloned by different calls never share an environment.

Environment ids provide cross-environment collision isolation only when the scene uses GPU dynamics and GPU broadphase. They do not isolate CPU clones. In CPU mode, give every clone a spatially disjoint anchor_transforms pose; co-located clones share one collision space and can push each other apart.

When env ids are requested but the scene runs CPU dynamics or a CPU broadphase, the runtime logs EnvIds requested but gpu dynamic is disabled and EnvIds requested but gpu broadphase is not set. Those records go to the Carbonite log, not to Python’s warnings module or sys.stderr, so a caller who wants to observe, assert on, or escalate them has to attach a log consumer: ovphysx.enable_python_logging() routes them to the ovphysx Python logger, and ovphysx_set_log_callback() delivers them to a C callback. Refer to Logging.

Code Language#

Python#

This complete sample attaches basic_simulation.usda, clones /World/envs/env0 into three spatially disjoint targets before the first step, runs 10 steps, and reads back one rigid-body position per environment:

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# @implements REQ-PYTHON-CLONE-001
# @covers AC-1

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

"""
Clone sample demonstrating scene replication with the clone API.

This sample demonstrates:
1. Loading a USD scene with an environment hierarchy
2. Cloning the environment to create multiple copies
3. Running simulation with all clones
"""

from pathlib import Path

import numpy as np

import ovphysx
from ovphysx import PhysX
from ovphysx.types import ObjectScope, SimObjectType


def _to_host(column):
    """A read column as host NumPy, whether it came back as NumPy (CPU) or a Warp array (GPU)."""
    return column if isinstance(column, np.ndarray) else column.numpy()


_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


def main():
    # Initialize PhysX SDK
    PhysX.set_cpu_mode(True)
    physx = PhysX()
    stage = None

    try:
        # 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"
            / "basic_simulation.usda"
        )
        if not usd_path.is_file():
            usd_path = Path(__file__).resolve().parent.parent / "data" / "basic_simulation.usda"
        if not usd_path.is_file():
            raise FileNotFoundError(f"ovphysx sample data is missing: {usd_path}")

        print(f"Loading USD scene through ovstage: {usd_path}")
        stage = attach_scene(physx, usd_path, "ovphysx-clone-sample")
        physx.wait_all()

        # Clone env0 to create env1, env2, env3
        targets = ["/World/envs/env1", "/World/envs/env2", "/World/envs/env3"]
        anchor_transforms = [
            (4.0 * env_idx, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
            for env_idx in range(1, len(targets) + 1)
        ]
        print(f"Cloning /World/envs/env0 to {len(targets)} targets...")
        # CPU mode has no environment-id collision filtering, so place each
        # environment in a spatially disjoint lane.
        physx.clone("/World/envs/env0", targets, anchor_transforms=anchor_transforms)
        physx.wait_all()
        print(f"  Created {len(targets)} clones successfully")

        # Run simulation with all environments
        print("Running 10 simulation steps...")
        dt = 1.0 / 60.0
        for i in range(10):
            physx.step(dt)
        physx.wait_all()
        print("  All steps completed")

        # Read rigid-body positions across all environments via the session read API. After
        # cloning, the tables are the scene's only rigid bodies, so reading RIGID_BODY covers
        # exactly them, one row per environment.
        with physx.read(SimObjectType.RIGID_BODY, ["position"], scope=ObjectScope.ALL) as result:
            positions = np.concatenate(
                [_to_host(group.tensors[0]).reshape(group.prim_count, -1) for group in result.groups]
            )
        for env_idx in range(positions.shape[0]):
            px, py, pz = positions[env_idx, 0:3]
            print(f"  env{env_idx}: pos=({px:.4f}, {py:.4f}, {pz:.4f})")

        print("Clone sample completed successfully")

    finally:
        if stage is not None:
            physx.detach_ovstage()
            stage.destroy()
        physx.destroy()
        print("Cleanup complete")


if __name__ == "__main__":
    main()

C#

The C sample performs the same three-target clone, passing NULL for env_ids so the call numbers its copies automatically:

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

/**
 * @implements REQ-PYTHON-CLONE-001
 * @covers AC-1
 */

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

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

#ifdef _WIN32
#include <windows.h>
#define sleep_ms(ms) Sleep(ms)
#else
#include <unistd.h>
#define sleep_ms(ms) usleep((ms) * 1000)
#endif

// The sample exercises the plain C API, so a C++ compiler is rejected.
#ifdef __cplusplus
#error "This file must be compiled as C, not C++"
#endif

static int wait_op_success(
    ovphysx_handle_t handle,
    ovphysx_enqueue_result_t res,
    ovphysx_timeout_t timeout_ns) {
  if (res.status != OVPHYSX_API_SUCCESS) {
    return 0;
  }
  ovphysx_op_wait_result_t wait_result = {0};
  ovphysx_result_t wait_res = ovphysx_wait_op(handle, res.op_index, timeout_ns, &wait_result);
  int success = (wait_res.status == OVPHYSX_API_SUCCESS && wait_result.num_errors == 0);
  ovphysx_destroy_wait_result(&wait_result);
  return success;
}

static int run(void)
{
  printf("=== ovphysx Clone Example (C API) ===\n\n");

  ovphysx_result_t init_res = ovphysx_initialize();
  if (init_res.status != OVPHYSX_API_SUCCESS) {
    fprintf(stderr, "Failed to initialize ovphysx\n");
    return 1;
  }

  ovphysx_create_args create_args = OVPHYSX_CREATE_ARGS_DEFAULT;

  printf("Creating PhysX instance...\n");
  ovphysx_handle_t handle = 0;
  ovphysx_result_t create_res = ovphysx_create_instance(&create_args, &handle);
  if (create_res.status != OVPHYSX_API_SUCCESS) {
    fprintf(stderr, "Failed to create PhysX instance\n");
    ovphysx_shutdown();
    return 1;
  }
  printf("  [OK] PhysX instance created\n\n");

  // Populate an ovstage instance from the USD file and attach it to ovphysx.
  printf("Loading USD scene...\n");
  ovphysx_sample_stage_attachment_t stage_attachment = {0};
  if (!ovphysx_sample_attach_usd_with_ovstage(
          handle, OVPHYSX_TEST_DATA "/basic_simulation.usda", &stage_attachment)) {
    fprintf(stderr, "ovstage attach/update failed\n");
    ovphysx_destroy_instance(handle);
    ovphysx_shutdown();
    return 1;
  }
  printf("  [OK] USD scene loaded\n\n");

  // Clone env0 into three new environments.
  printf("Cloning /World/envs/env0 to create env1, env2, env3...\n");
  const char* clone_targets[] = {
    "/World/envs/env1",
    "/World/envs/env2",
    "/World/envs/env3"
  };
  enum { NUM_TARGETS = 3 };

  ovphysx_string_t target_strings[NUM_TARGETS];
  for (uint32_t i = 0; i < NUM_TARGETS; ++i) {
    target_strings[i] = ovphysx_cstr(clone_targets[i]);
  }

  // CPU mode has no environment-id collision filtering, so place each
  // environment in a spatially disjoint lane.
  const float anchor_transforms[NUM_TARGETS * 7] = {
      4.0f,  0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f,
      8.0f,  0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f,
      12.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f
  };

  ovphysx_enqueue_result_t clone_res = ovphysx_clone(
      handle,
      ovphysx_cstr("/World/envs/env0"),
      target_strings,
      NUM_TARGETS,
      anchor_transforms,
      NULL);  /* env_ids: NULL selects automatic per-call numbering. */
  if (!wait_op_success(handle, clone_res, 10ULL * 1000 * 1000 * 1000)) {
    fprintf(stderr, "Clone operation failed or timed out\n");
    ovphysx_sample_destroy_stage(handle, &stage_attachment);
    ovphysx_destroy_instance(handle);
    ovphysx_shutdown();
    return 1;
  }
  printf("  [OK] Created 3 clones successfully\n\n");

  // A few simulation steps confirm the clones simulate.
  printf("Running simulation with clones (10 steps)...\n");
  for (int i = 0; i < 10; i++) {
    ovphysx_enqueue_result_t step_res = ovphysx_step(handle, 1.0f/60.0f);
    if (!wait_op_success(handle, step_res, 10ULL * 1000 * 1000 * 1000)) {
      fprintf(stderr, "Failed to run simulation step %d\n", i);
      ovphysx_sample_destroy_stage(handle, &stage_attachment);
      ovphysx_destroy_instance(handle);
      ovphysx_shutdown();
      return 1;
    }
  }
  printf("  [OK] All 10 simulation steps completed successfully\n\n");

  printf("=== Clone Example Completed Successfully ===\n");

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

  return 0;
}

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

Result#

After this tutorial, you can replicate environments through the clone API and simulate all copies together.