Skip to content

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 SimState objects;
  • one Control and one Contacts object;
  • an optional CollisionPipeline call 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:

solver.step(state_in, state_out, control, contacts, dt)

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_count and world_origins;
  • per-world range methods such as body_range_for_world() and joint_dof_range_for_world();
  • helpers such as joint_q_for_world(), joint_qd_for_world(), set_joint_state_for_world(), and set_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:

runner.reset_envs([0, 2], update_fk=True)
runner.reset_all()

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:

runner.collision_enabled = True
runner.step(dt, substeps=4)

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.

API Reference