Multi-Environment Simulation¶
NovaPhy can store several logically independent worlds in one immutable
Model. MultiEnvRunner is a small convenience layer for stepping that
multi-world model through one bound solver. It owns the common runtime
plumbing:
- double-buffered
SimStateobjects; - one
Controland oneContactsobject; - an optional
CollisionPipelinecall before each substep; - per-environment episode counters and task-owned done flags.
It is not a World orchestrator, an RL task implementation, or a
replacement for the five-argument solver contract. Internally, every
substep still delegates to:
Observation construction, reward calculation, termination conditions, policy inference, and automatic reset remain in the user or task layer.
Build and Step a Replicated Model¶
Build one source environment, replicate it into model worlds, finalize once, and bind a solver to the resulting model:
import numpy as np
import novaphy
num_envs = 4
# One-world source scene.
source = novaphy.ModelBuilder()
source.set_gravity(np.zeros(3, dtype=np.float32))
body = source.add_link(
mass=1.0,
com=np.zeros(3, dtype=np.float32),
inertia=np.eye(3, dtype=np.float32) * 0.1,
)
joint = source.add_joint_revolute(
parent=-1,
child=body,
axis=np.array([0.0, 0.0, 1.0], dtype=np.float32),
)
source.add_articulation([joint])
# One immutable model with four world partitions.
builder = novaphy.ModelBuilder()
builder.replicate(
source,
num_worlds=num_envs,
spacing=np.array([2.0, 0.0, 0.0], dtype=np.float32),
)
model = builder.finalize()
solver = novaphy.solvers.SolverFeatherstone(model)
runner = novaphy.MultiEnvRunner(solver, enable_collision=False)
assert model.world_count == num_envs
assert runner.num_envs == num_envs
assert runner.action_dim() == 1
# A flat env-major feedforward joint-force vector.
actions = np.linspace(-0.4, 0.4, num_envs, dtype=np.float32)
runner.set_actions(actions)
for _ in range(120):
runner.step(1.0 / 120.0, substeps=4, collide=False)
for env_id in range(num_envs):
q = model.joint_q_for_world(runner.state, env_id)
qd = model.joint_qd_for_world(runner.state, env_id)
print(env_id, q, qd)
print(runner.episode_length) # [120, 120, 120, 120]
One call to runner.step(dt, substeps=N) represents one outer step. It calls
the solver N times with dt / N, swaps the runner's state buffers after
each substep, and increments every episode counter once.
The full runnable version is python/demos/demo_multi_env_physics.py:
python python/demos/demo_multi_env_physics.py --num-worlds 8 --steps 240
python python/demos/demo_multi_env_physics.py --visual --num-worlds 4
Model Worlds and Runtime Layout¶
ModelBuilder.replicate() creates the physical world partitions and their
flat-buffer ownership. The finalized model exposes:
world_countandworld_origins;- per-world range methods such as
body_range_for_world()andjoint_dof_range_for_world(); - helpers such as
joint_q_for_world(),joint_qd_for_world(),set_joint_state_for_world(), andset_joint_f_for_world().
MultiEnvLayout is separate runtime metadata for task UIs, debug displays,
and viewers:
layout = novaphy.MultiEnvLayout.grid(num_envs, spacing=3.0, columns=2)
runner = novaphy.MultiEnvRunner(solver, layout, enable_collision=False)
The layout must contain exactly model.world_count origins. Changing it with
set_env_origins(), set_linear_layout(), or set_grid_layout() does not
move bodies and does not mutate the immutable model.world_origins. To
change physical placement, use transforms with ModelBuilder.add_world() or
the spacing argument to ModelBuilder.replicate() before finalization.
Without an explicit layout, the runner initializes its layout from
model.world_origins.
Actions¶
runner.set_actions() writes feedforward generalized forces to
runner.control.joint_f. It does not perform action scaling, clipping,
position-target conversion, or policy inference.
For homogeneous worlds:
dim = runner.action_dim()
actions = np.zeros(runner.num_envs * dim, dtype=np.float32)
runner.set_actions(actions)
# Update only environments 1 and 3, still as a flat env-major vector.
selected = np.zeros(2 * dim, dtype=np.float32)
runner.set_actions(selected, env_ids=[1, 3])
runner.clear_actions(env_ids=[1, 3])
The Python binding accepts a one-dimensional array. action_dim() requires
all worlds to have the same joint-DOF count and raises when they differ. For
all worlds together, a flat vector of length model.joint_dof_count is also
accepted; this is the direct flat-control form for a heterogeneous
multi-world model.
Use the Model per-world helpers directly when a task needs target
positions, target velocities, activations, or other control channels instead
of feedforward joint force.
Reset and Episode State¶
The runner snapshots the model's initial runtime state when the runner is
constructed. reset_all() or reset_envs() restores from that snapshot,
clears the selected control slice, and resets the corresponding episode and
done state:
If a different reset distribution is required, write it explicitly with
model helpers such as set_joint_state_for_world() on both runner.state
and runner.next_state.
Done flags belong to the task layer:
terminated = np.array([1, 0, 0, 0], dtype=np.uint8)
truncated = np.array([0, 0, 0, 1], dtype=np.uint8)
runner.set_done_flags(terminated, truncated)
runner.step() does not infer, reset, or skip done environments.
The episode_length, terminated, truncated, and env_origins properties
return NumPy copies. Editing a returned array in place does not update the
runner. Use reset_*(), set_done_flags(), or a layout setter instead.
Collision Behavior¶
Collision is disabled by default:
runner = novaphy.MultiEnvRunner(solver, enable_collision=False)
runner.step(dt, collide=False) # solver receives contacts=None
Set enable_collision=True, toggle runner.collision_enabled, or pass
collide=True for one call. When enabled, the runner invokes its collision
pipeline before every numerical substep:
All model worlds share broadphase storage, but pairs whose two non-global
shapes have different world indices are filtered automatically. Shapes with
world=-1 are global and may interact with every world. Use physical spacing
for visualization or intentionally global geometry, not as the primary
isolation mechanism for ordinary per-world shapes.
When to Use the Runner¶
Use MultiEnvRunner when one solver can advance a multi-world model and the
worlds share a convenient action shape. It removes repetitive state swapping,
collision, and reset plumbing while leaving the solver contract visible.
Use independent (solver, state, control, contacts) tuples instead when each
rollout needs an independently allocated state, different solver settings, or
its own collision pipeline:
single_model = source.finalize()
envs = []
for _ in range(num_envs):
pipeline = novaphy.CollisionPipeline(single_model)
envs.append(
{
"solver": novaphy.solvers.SolverSemiImplicit(single_model),
"state": single_model.state(),
"control": single_model.control(),
"pipeline": pipeline,
"contacts": pipeline.contacts(),
}
)
Both approaches are solver-primary. Neither reintroduces the removed
World/WorldGroup orchestration API.