Cloning Environments#
Parallel simulation at scale needs many environments stepping side by side —
hundreds, sometimes tens of thousands per GPU — and authoring each of those envs
by hand would be hopelessly slow. Cloning is Isaac Lab’s answer: you author a
small representative scene under /World/envs/env_n and the cloner expands it
across the rest of the env population for you, optionally with per-env variation.
The expansion itself is performed by USD and the active physics backend’s native
replicator, wrapped by Isaac Lab’s core isaaclab.cloner module behind a
single uniform surface.
The Backend Layer#
At the bottom of the stack, each backend exposes a raw function that takes a flat description of the world layout. These functions are useful for standalone tools and tests and deliberately have parallel signatures:
backend_replicate(stage, sources, destinations, env_ids, selection, positions=None, quaternions=None, ...)
The arguments are parallel arrays describing the layout:
sources— source prim paths already authored on the stage.destinations— destination templates containing"{}", formatted with each env id.env_ids— NumPy integer array of target env indices.selection— NumPy boolean array of shape[len(sources), num_envs];selection[i, j]isTruewhen envjshould be populated from sourcei. The raw USD function names this argumentmask; physics functions name itmapping.positions/quaternions— optional per-env world transforms.
Production scene construction stores those arrays once in a
ClonePlan. Simulation-owned backend contexts consume the
same value through context.replicate(plan); no backend rebuilds the mapping
from a second queue of array arguments.
Standalone Examples#
Direct calls into the backend functions, for tooling or tests that need full control. Production code reaches for one of the ways in Cloning in a Backend-Agnostic Way instead.
USD — clone a visual cube across envs:
import numpy as np
import isaaclab.sim as sim_utils
from isaaclab.cloner import usd_replicate
num_envs = 128
stage = sim_utils.get_current_stage()
cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1))
cube_cfg.func("/World/envs/env_0/Cube", cube_cfg)
usd_replicate(
stage,
sources=("/World/envs/env_0/Cube",),
destinations=("/World/envs/env_{}/Cube",),
env_ids=np.arange(num_envs),
mask=np.ones((1, num_envs), dtype=np.bool_),
)
PhysX — call PhysX and USD on the same sources and destinations (either order):
from isaaclab_physx.cloner import physx_replicate
sources = ("/World/envs/env_0/Cube",)
destinations = ("/World/envs/env_{}/Cube",)
env_ids = np.arange(num_envs)
mapping = np.ones((1, num_envs), dtype=np.bool_)
physx_replicate(stage, sources, destinations, env_ids, mapping=mapping)
usd_replicate(stage, sources, destinations, env_ids, mask=mapping)
Newton:
from isaaclab_newton.cloner import newton_physics_replicate
newton_physics_replicate(stage, sources, destinations, env_ids, mapping=mapping)
OvPhysX:
from isaaclab_ov.cloner import ovphysx_replicate
ovphysx_replicate(stage, sources, destinations, env_ids, mapping=mapping)
Cloning in a Backend-Agnostic Way#
Authoring every prim in every env by hand would be prohibitively slow and would
also tie scene code to whichever physics engine happens to be active. Isaac Lab
sidesteps both problems with a single central abstraction:
ClonePlan — a compact description of how a small set of
prim-level prototypes maps onto the full population of envs, with each prototype
free to land in some envs and not others. A plan is built once, fed to each backend, and
lets every engine take its own fastest replication path: USD instancing for
visuals, PhysX’s native replicator for rigid bodies and articulations, Newton’s
world system for its parallel pipeline. The same plan drives all of them, so user
code never branches on the backend.
ClonePlan#
A plan holds the parallel arrays used by production clone contexts — sources, destinations, mask, env ids — in one place. Conceptually it is a small table where each row describes one distinct prototype-to-destination mapping; the fields listed below are that table’s columns:
Field |
Meaning |
|---|---|
|
Source prim paths, one per replication row. |
|
Destination templates with |
|
NumPy boolean array |
|
Optional NumPy integer array of target env ids; execution requires it. |
|
Optional per-env world positions [m], shape |
|
Unique prim paths for scene assets shared by every env and therefore not replicated. |
|
Clone-context types mapped to the rows they consume. |
The plan does not own a stage. Simulation-owned contexts supply their own runtime when they consume it.
When every env is a copy of env_0:
sources = ("/World/envs/env_0",)
destinations = ("/World/envs/env_{}",)
clone_mask = [[True, True, ..., True]]
global_paths = ("/World/Ground", "/World/Light")
When envs differ — say a cartpole in every env plus a 2-variant obstacle (box into envs 0/1, sphere into envs 2/3):
sources = ("/World/envs/env_0/Cartpole",
"/World/envs/env_0/Obstacle_0", # box prototype
"/World/envs/env_0/Obstacle_1") # sphere prototype
destinations = ("/World/envs/env_{}/Cartpole",
"/World/envs/env_{}/Obstacle",
"/World/envs/env_{}/Obstacle")
clone_mask = [[1, 1, 1, 1],
[1, 1, 0, 0],
[0, 0, 1, 1]]
Querying a plan#
Anything that has to follow an asset between the two sides of that table — a sensor
resolving its prim_path back to the prototype it should read, a ray caster
loading one mesh per variant — asks isaaclab.cloner.query rather than
manipulating path strings itself:
from isaaclab import cloner
# where does this prototype land in env 2?
cloner.query.path_to_clone(plan, "/World/envs/env_0/Obstacle_1", env_id=2)
# -> "/World/envs/env_2/Obstacle"
# which envs does this prototype reach at all?
cloner.query.path_env_ids(plan, "/World/envs/env_0/Obstacle_1")
# -> (2, 3)
# which prototype is env 2's obstacle cloned from?
cloner.query.path_to_source(plan, "/World/envs/env_2/Obstacle")
# -> ("/World/envs/env_0/Obstacle_1", "/World/envs/env_*/Obstacle", "")
Two obstacle variants share one destination template, so the template alone does not
identify a prototype — the environment does. A concrete path carries it in the clone
slot; a env_.* wildcard does not, and resolves to one representative variant
unless you pass env_id. Use iter_sources() when you
need every variant behind a template. Note that environment ids are not mask columns:
column j stands for env_ids[j], and the queries speak ids throughout.
A plan is the what. Putting one together and handing it to the backends is
the how. Both Manager-based and Direct environments normally declare their
assets on InteractiveSceneCfg; the scene owns the one
clone lifecycle. The lower-level APIs remain available to standalone tools and
tests that deliberately do not depend on InteractiveScene.
ReplicateSession#
ReplicateSession is the context manager used by
InteractiveScene to bracket the whole cloning lifecycle.
Entering the block builds and publishes the plan, the body constructs assets at
their planned source paths, and exiting dispatches that same plan:
with cloner.ReplicateSession(cfgs, num_clones=N, env_spacing=2.0):
for cfg in cfgs:
cfg.class_type(cfg)
This is what InteractiveScene runs when you declare assets
in an InteractiveSceneCfg:
@configclass
class MySceneCfg(InteractiveSceneCfg):
robot = CARTPOLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot")
light = AssetBaseCfg(
prim_path="/World/Light",
spawn=sim_utils.DistantLightCfg(intensity=3000.0),
)
scene = InteractiveScene(MySceneCfg(num_envs=128, env_spacing=2.0))
When envs need to differ across the population, use
MultiAssetSpawnerCfg or
MultiUsdFileCfg; see
Spawning Multiple Assets.
clone_plan_from_env_0 + replicate#
For a standalone homogeneous workflow where every env is one copy of env_0,
pass a CloneCfg and a flat tuple of asset and sensor
cfgs. clone_plan_from_env_0() publishes the plan and
assigns prototype spawn paths before construction:
asset_cfgs = (robot_cfg, ground_cfg, light_cfg)
plan = cloner.clone_plan_from_env_0(clone_cfg, asset_cfgs, num_envs=128, env_spacing=2.0)
robot, _, _ = [cfg.class_type(cfg) for cfg in asset_cfgs]
cloner.replicate(plan, replicate_physics=clone_cfg.replicate_physics)
Every env receives the same prototype. The tuple is deliberately flat: the
cloner does not inspect a task or scene cfg tree. Prefer
InteractiveSceneCfg for environment implementations and
heterogeneous scenes.
Under the Hood#
Planning maps each cfg to rows in cfg_rows and each participating backend to
its subset in context_rows. The active physics manager registers its clone
context during simulation initialization. Assets use that context by default;
cloning_contexts can select an explicitly
registered context instead. Planning also registers
UsdReplicateContext for spawned assets when Kit is
available.
The backend packages expose different context implementations behind one execution contract:
UsdReplicateContext # replicates USD prim subtrees
PhysxReplicateContext # replicates PhysX rigid bodies and articulations
NewtonReplicateContext # replicates Newton bodies in its parallel pipeline
replicate() resolves these types through the
SimulationContext backend registry, orders them by
replicate_priority, and passes the published plan to each one:
plan = published_clone_plan
for context_type in plan.context_rows:
simulation_backends[context_type].replicate(plan)
Every maintained lifecycle publishes its plan before asset construction. The simulation accepts one plan and each backend receives that exact object.
USD runs before native physics contexts so the destination topology exists when they consume it. No fallback context is constructed during dispatch.
Collision Filtering#
PhysX models per-env isolation through collision groups, so PhysX scenes need a filtering pass after cloning to keep envs from colliding with each other while still letting them collide with global prims (terrain, ground planes, lights).
InteractiveScene runs that pass automatically when
filter_collisions=True and the backend is PhysX. For direct PhysX pipelines,
call filter_collisions() after the replicate:
from isaaclab.cloner import filter_collisions
filter_collisions(
stage=stage,
physicsscene_path="/physicsScene",
collision_root_path="/World/collisions",
prim_paths=[f"/World/envs/env_{i}" for i in range(num_envs)],
global_paths=["/World/ground"],
)
Newton isolates envs through its world system and does not need this pass.