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 each physics backend’s native replicator —
USD, PhysX, or Newton — wrapped by Isaac Lab’s core isaaclab.cloner module
behind a single uniform surface so the same user code works regardless of which
backend is active.
The Backend Layer#
At the bottom of the stack, each backend exposes a single function that takes a flat description of the world layout and materializes it on its runtime. The signatures are deliberately parallel so the layers above can target every backend through one interface:
backend_replicate(stage, sources, destinations, env_ids, mask, 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— long tensor of target env indices.mask— bool tensor of shape[len(sources), num_envs];mask[i, j]isTruewhen envjshould be populated from sourcei.positions/quaternions— optional per-env world transforms.
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 torch
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=torch.arange(num_envs, device="cuda:0"),
mask=torch.ones((1, num_envs), dtype=torch.bool, device="cuda:0"),
)
PhysX — call PhysX and USD on the same sources and destinations (either order):
from isaaclab_physx.cloner import physx_replicate
physx_replicate(stage, sources, destinations, env_ids, mask)
usd_replicate(stage, sources, destinations, env_ids, mask)
Newton:
from isaaclab_newton.cloner import newton_physics_replicate
newton_physics_replicate(stage, sources, destinations, env_ids, mapping=mask)
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 a backend replicate consumes — 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. Every entry point in
isaaclab.cloner either produces a plan, consumes a plan, or both, so a
quick look at the fields is the fastest way to build intuition for the rest of
this page:
Field |
Meaning |
|---|---|
|
Source prim paths, one per replication row. |
|
Destination templates with |
|
Bool tensor |
|
Long tensor of target env ids. |
|
Optional per-env world positions [m], shape |
The plan is stage-agnostic by design — the same instance can be replayed against a different stage, inspected by tooling, or serialized.
When every env is a copy of env_0:
sources = ("/World/envs/env_0",)
destinations = ("/World/envs/env_{}",)
clone_mask = [[True, True, ..., True]]
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, and Isaac Lab exposes three idiomatic ways to do that. All three end
in the same cloner.replicate(plan, stage=...) call, so the choice between
them is purely about ergonomics:
The first wraps both phases in a context manager and is what
InteractiveSceneruns under the hood. Reach for it when you want the lifecycle hidden and you are authoring assets through a scene config.The second spells the same flow out as plain function calls, leaving a moment between the build and the drain where you can inspect or mutate the plan. Reach for it when you are assembling a scene outside
InteractiveSceneor want fine control over timing.The third is a one-shot shortcut for the case where every env is just a copy of env_0. Reach for it in
DirectRLEnvand standalone scripts that hand-build the env-0 prototype prim by prim.
ReplicateSession#
ReplicateSession is a context manager that brackets the
whole cloning lifecycle. Entering the block builds the plan, the body is where
you construct your assets (each one registers itself as part of its constructor),
and exiting the block drains every registration against the plan:
with cloner.ReplicateSession(cfgs, num_clones=N, env_spacing=2.0,
device=device, stage=stage):
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.
make_clone_plan + replicate#
The same two phases as the session, written as separate function calls. The plan is built first, asset construction happens in between, and the drain runs explicitly at the end. The gap between the build and the drain is the point — that is where you can read the plan back, mutate it, log it, or otherwise intervene before replication actually happens:
plan = cloner.make_clone_plan(cfgs, num_clones=N, env_spacing=2.0, device=device)
for cfg in cfgs:
cfg.class_type(cfg)
cloner.replicate(plan, stage=stage)
clone_plan_from_env_0 + replicate#
Shortcut for the case where every env is just a copy of env_0.
clone_plan_from_env_0() builds the single-source plan in
one line by pointing at the prototype, and replicate()
finishes the setup. This is the pattern most DirectRLEnv
subclasses use — they author the env-0 prototype prim by prim in
_setup_scene and end the method with these four lines:
def _setup_scene(self):
self.cartpole = Articulation(self.cfg.robot_cfg)
spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg())
# ... any other assets ...
src, dest = "/World/envs/env_0", "/World/envs/env_{}"
pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0]
plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos)
cloner.replicate(plan, stage=self.scene.stage)
Every env receives the same prototype. When envs need to differ, use one of the other two.
Under the Hood#
To see how the backend-agnostic surface works, follow one asset through the
system. Suppose you write Articulation(cfg) for a PhysX articulation
somewhere inside a ReplicateSession. The constructor
does not actually clone anything yet — at that moment the plan describing how
the full env population should be laid out may not even exist. Instead the
constructor registers the asset with the cloner, the cloner files the
registration into a queue, and later — when the session exits and the cloner
runs replication — that registration is handed to the backend code that knows
how to replicate a PhysX articulation, with the plan telling it where each
clone goes.
The story has to look like this because the engines underneath disagree about when and how replication actually happens:
PhysX defers the real work to physics runtime. At construction time the only thing user code can do is register intent; PhysX replays those registrations entity by entity when the simulation comes up.
USD is declarative and immediate — calling
usd_replicate()materializes the clones in place, right then and there.Newton is also declarative and immediate, but it insists on replicating the whole world in one shot rather than asset by asset, so the framework cannot just hand it one cfg at a time — everything Newton-related has to be assembled first.
Isaac Lab reconciles these into one surface with two small pieces of plumbing.
Every backend supplies its own UsdReplicateContext /
PhysxReplicateContext / NewtonReplicateContext, a class that hides the
timing and granularity differences above behind a single uniform interface. A
shared REPLICATION_QUEUE then remembers which asset
cfgs participate until it is time to run. The three
subsections below explain the queue, the contexts, and the function that joins
them against a plan.
The registration queue#
Assets do not replicate inline. Construction only registers the asset’s cfg
through queue_replication(); the queue records which
cfgs participate:
REPLICATION_QUEUE
cartpole_cfg
cube_cfg
camera_cfg
...
How each cfg is cloned is resolved by replicate() at
dispatch. The physics side comes from the cfg’s
cloning_contexts when set, otherwise the
active backend’s default physics context, which each backend cloner exports as
PHYSICS_CONTEXT (PhysX and Newton replicate natively; OvPhysX replays its
own clones). replicate() adds
UsdReplicateContext automatically whenever a cfg has a
spawner and Kit is available, so USD clones accompany physics replication under
Kit and are skipped by default in headless runs. An explicit cfg override may
still request USD replication without Kit. With
replicate_physics disabled, cloning is
USD-only: every physics context is dropped and the physics engine parses the
per-env USD prims directly.
Deferring the work like this buys three things at once:
Replication can wait until the plan is fully built, so the final layout is known before any prims are spawned.
Every asset’s request is batched into a single backend call instead of one call per asset.
Asset code stays free of any branching on which backend is active — it just registers and lets the framework take it from there.
replicate_physics is piped into
replicate_physics and applied at dispatch;
an asset whose only cloning mechanism is physics replication is then simply
not cloned.
Backend contexts#
Each backend ships a small adapter class — its replicate context — that knows how to take a registered cfg and replicate it on the backend’s specific runtime:
UsdReplicateContext # replicates USD prim subtrees
PhysxReplicateContext # replicates PhysX rigid bodies and articulations
NewtonReplicateContext # replicates Newton bodies in its parallel pipeline
A single asset usually resolves to more than one context — PhysX pairs its context with USD so physics and visuals both follow; Newton’s default stack includes USD only under Kit, so kitless runs skip the authoring cost. This is where backend differences are absorbed: swapping a scene from PhysX to Newton swaps which default stack resolves at dispatch, while the cfgs and the rest of the user code stay unchanged.
Running replication#
replicate() is what actually runs the registered work.
The dispatch shape is roughly:
def replicate(plan, stage):
for context_cls, rows in group_queue_by_context(plan):
context_cls().replicate(rows=rows, stage=stage)
publish(plan)
Contexts run in a priority order that puts physics ahead of visuals, and the
plan is published to SimulationContext so the rest of the
framework can read the per-env layout back.
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.