Multi-Environment SimulationΒΆ

Create the world with the desired environment count, register the same object id in each environment, and retrieve a batched view.

world = ke.sim.KangSimWorld(num_envs=16, sim_dt=1.0 / 120.0)

for env_id in range(16):
    world.add_rigid(
        box_data,
        env_id=env_id,
        obj_id=0,
        name=f"box_{env_id}",
        pos=positions[env_id],
    )

boxes = world.get_rigid_batch(obj_id=0)

State and reset values are batched along the first dimension:

positions = torch.zeros((16, 3), dtype=torch.float32)
rotations = torch.zeros((16, 4), dtype=torch.float32)
rotations[:, 3] = 1.0

boxes.set_root_state(None, positions, rotations)
root_pos = boxes.get_root_pos()  # shape: [16, 3]

Run:

python ./python/examples/sim_world_multi_env.py --num-envs 16
Complete source: sim_world_multi_env.py
  1"""Batched KangSimWorld example with multiple independent environments.
  2
  3This is the small ramp example: create many envs, spawn one rigid body per env,
  4step them together, and read batched state tensors from ``world.state``.
  5
  6The example also demonstrates runtime PhysX material changes on a sloped static
  7terrain.  All boxes start with a low-friction material; after a short delay,
  8half of the envs are switched to a high-friction material while the other half
  9remains slippery.  The UI shows the average downhill speed of both groups so
 10friction changes can be checked while the simulation is running.
 11"""
 12
 13from __future__ import annotations
 14
 15import argparse
 16import math
 17from pathlib import Path
 18
 19import torch
 20
 21import kangengine as ke
 22from kangengine import imgui, keys
 23
 24
 25def package_asset_path(*parts: str) -> str:
 26    return str(Path(ke.__file__).resolve().parent / "assets" / Path(*parts))
 27
 28
 29def grid_position(index: int, columns: int, spacing: float, height: float):
 30    row = index // columns
 31    col = index % columns
 32    x = (col - (columns - 1) * 0.5) * spacing
 33    y = row * spacing
 34    return [x, y, height]
 35
 36
 37# TDOO : make easy Cloner API
 38def grid_positions(num_envs: int, columns: int, spacing: float, height: float):
 39    env_ids = torch.arange(num_envs, dtype=torch.float32)
 40    rows = torch.div(env_ids, columns, rounding_mode="floor")
 41    cols = torch.remainder(env_ids, columns)
 42    x = (cols - (columns - 1) * 0.5) * spacing
 43    y = rows * spacing
 44    z = torch.full_like(x, float(height))
 45    return torch.stack((x, y, z), dim=1)
 46
 47
 48def y_axis_quat_xyzw(angle_rad: float):
 49    half = angle_rad * 0.5
 50    return [0.0, math.sin(half), 0.0, math.cos(half)]
 51
 52
 53def friction_group_color(env_id: int):
 54    # Even envs stay low-friction/slippery. Odd envs switch to high friction at
 55    # runtime. Keep colors grouped instead of using a gradient so the test reads
 56    # visually at a glance.
 57    if env_id % 2 == 0:
 58        return [0.15, 0.45, 1.0, 1.0]  # low friction: blue
 59    return [1.0, 0.42, 0.12, 1.0]  # runtime high friction: orange
 60
 61
 62class MultiEnvSimWorldApp(ke.App):
 63    def __init__(self, num_envs: int, friction_switch_time: float):
 64        super().__init__()
 65        self.num_envs = int(num_envs)
 66        self.friction_switch_time = float(friction_switch_time)
 67
 68    def setup(self):
 69        self.timing = self.configure_timing(
 70            ke.SimulationTimingConfig(
 71                render_hz=0.0,
 72                physics_hz=120.0,
 73                fixed_update_hz=60.0,
 74            )
 75        )
 76        self.set_simulation_hotkeys_enabled(True)
 77        self.sim_time = 0.0
 78        self.material_update_count = 0
 79        self.runtime_material_applied = False
 80        self.spacing = 0.8
 81        self.ramp_angle_deg = 18.0
 82        self.ramp_angle = math.radians(self.ramp_angle_deg)
 83        self.ramp_half_extents = [4.7, 4.0, 0.05]
 84        self.ramp_pos = [0.0, 0.0, 0.0]
 85        self.ramp_rot_xyzw = y_axis_quat_xyzw(self.ramp_angle)
 86        self.box_half_z = 0.12
 87        self.spawn_x = -2.4
 88        self.columns = max(1, int(math.ceil(math.sqrt(self.num_envs))))
 89        self.low_friction_envs = tuple(range(0, self.num_envs, 2))
 90        self.high_friction_envs = tuple(range(1, self.num_envs, 2))
 91        if not self.high_friction_envs and self.num_envs > 0:
 92            self.high_friction_envs = (0,)
 93            self.low_friction_envs = ()
 94        self.low_friction_material = ke.physics.PhysicsMaterialDesc([0.05, 0.05, 0.0])
 95        self.high_friction_material = ke.physics.PhysicsMaterialDesc([3.0, 2.5, 0.0])
 96
 97        self.standard_materials = self.create_standard_materials()
 98        self.set_camera_view([3.6, -5.0, 2.8], [0.0, 0.0, 0.6])
 99
100        self.world = ke.sim.KangSimWorld(
101            num_envs=self.num_envs,
102            sim_dt=self.timing.physics_dt,
103            add_ground=False,
104        )
105        self.world.physics.add_static_box(
106            self.ramp_half_extents,
107            self.ramp_pos,
108            self.ramp_rot_xyzw,
109            register_as_ground=True,
110        )
111        self.visual = ke.visual.sim.SimWorldVisualizer(self, self.world)
112        self._add_ramp_visual()
113        self.box_xml = package_asset_path("objects", "box.xml")
114        box_data = self.world.load_mjcf(self.box_xml)
115        for env_id in range(self.num_envs):
116            pos = self._ramp_spawn_position(env_id)
117            self.world.add_rigid(
118                box_data,
119                env_id=env_id,
120                obj_id=0,
121                name=f"box_{env_id}",
122                pos=pos,
123                density=60.0,
124            )
125
126        self.box = self.world.get_rigid_batch(obj_id=0)
127        # Runtime API smoke: start every rigid instance with a slippery material.
128        # Later, a subset is switched to high friction while the sim is running.
129        self.material_update_count = self.box.set_collision_material(
130            None, self.low_friction_material
131        )
132        self.box_visual_batch = self.visual.add(
133            self.box,
134            self.box_xml,
135            path="/group/box",
136            material=self.standard_materials.common,
137            color=[friction_group_color(env_id) for env_id in range(self.num_envs)],
138        )
139
140        self._reset()
141        print(f"Multi-env KangSimWorld example: num_envs={self.num_envs}")
142        print(f"root_pos tensor shape: {tuple(self.box.get_root_pos().shape)}")
143        print(
144            "Runtime friction demo: "
145            f"initial low-friction shapes updated={self.material_update_count}; "
146            f"switch high-friction envs={self.high_friction_envs} "
147            f"at t={self.friction_switch_time:.2f}s; "
148            f"ramp={self.ramp_angle_deg:.1f}deg"
149        )
150        self.check_error()
151
152    def _reset(self):
153        self.sim_time = 0.0
154        self.runtime_material_applied = False
155        self.material_update_count = self.box.set_collision_material(
156            None, self.low_friction_material
157        )
158        positions = torch.tensor(
159            [self._ramp_spawn_position(env_id) for env_id in range(self.num_envs)],
160            dtype=torch.float32,
161        )
162        rotations = torch.zeros((self.num_envs, 4), dtype=torch.float32)
163        rotations[:, 3] = 1.0  # quat xyzw
164
165        velocities = torch.zeros((self.num_envs, 3), dtype=torch.float32)
166        # +X is downhill for the ramp quaternion above.
167        velocities[:, 0] = 1.0
168        velocities[:, 1] = 0.0
169        velocities[:, 2] = 0.0
170
171        self.box.set_root_state(
172            None,
173            positions,
174            rotations,
175            linear_velocity=velocities,
176            angular_velocity=[0.0, 0.0, 0.0],
177        )
178        self.world.step(substeps=0, apply_commands=False)
179        self.visual.sync()
180
181    def pre_update(self):
182        if self.was_key_pressed(keys.R):
183            self._reset()
184
185    def fixed_update(self, fixed_dt):
186        self.world.advance(fixed_dt)
187        self.sim_time += fixed_dt
188        if (
189            not self.runtime_material_applied
190            and self.sim_time >= self.friction_switch_time
191        ):
192            self.material_update_count += self.box.set_collision_material(
193                self.high_friction_envs, self.high_friction_material
194            )
195            self.runtime_material_applied = True
196            print(
197                "Runtime friction switched: "
198                f"envs={self.high_friction_envs}, "
199                f"total_updated_shapes={self.material_update_count}"
200            )
201
202    def pre_render(self):
203        self.visual.sync()
204        self.check_error()
205
206    def render(self):
207        root_pos = self.box.get_root_pos()
208        root_vel = self.box.get_root_vel()
209        mean_height = float(root_pos[:, 2].mean().item())
210        min_height = float(root_pos[:, 2].min().item())
211        max_height = float(root_pos[:, 2].max().item())
212        downhill_speed = root_vel[:, 0]
213        low_speed = self._mean_for_envs(downhill_speed, self.low_friction_envs)
214        high_speed = self._mean_for_envs(downhill_speed, self.high_friction_envs)
215
216        imgui.begin("Multi-Env Sim World")
217        imgui.text("KangSimWorld batched state example")
218        imgui.text("Enter: play/pause    Space: pause/step    R: reset")
219        imgui.separator()
220        state = "paused" if self.is_simulation_paused() else "running"
221        imgui.text(f"State: {state}")
222        imgui.text(f"Envs: {self.num_envs}")
223        imgui.text(f"root_pos shape: {tuple(root_pos.shape)}")
224        imgui.text("height min/mean/max:")
225        imgui.same_line()
226        imgui.text(f"{min_height: .2f} / {mean_height: .2f} / {max_height: .2f}")
227        imgui.separator()
228        imgui.text("Runtime friction material test")
229        imgui.text(
230            f"switch: {'done' if self.runtime_material_applied else 'pending'} at t={self.friction_switch_time:.2f}s"
231        )
232        imgui.text(f"ramp angle: {self.ramp_angle_deg:.1f} deg")
233        imgui.text(f"updated shapes: {self.material_update_count}")
234        imgui.text(f"low friction envs: {self.low_friction_envs}")
235        imgui.text(f"high friction envs: {self.high_friction_envs}")
236        imgui.text(f"downhill velocity low/high: {low_speed: .3f} / {high_speed: .3f}")
237        imgui.end()
238
239    def _ramp_spawn_position(self, env_id: int):
240        row = env_id // self.columns
241        col = env_id % self.columns
242        y = (col - (self.columns - 1) * 0.5) * self.spacing
243        x = self.spawn_x - row * 0.45
244        z = self._ramp_top_z(x) + self.box_half_z + 0.04
245        return [x, y, z]
246
247    def _ramp_top_z(self, x: float) -> float:
248        half_t = self.ramp_half_extents[2]
249        return -math.tan(self.ramp_angle) * float(x) + half_t / math.cos(
250            self.ramp_angle
251        )
252
253    def _add_ramp_visual(self):
254        mesh_data = ke.geometry.create_box_data(
255            self.ramp_half_extents[0] * 2.0,
256            self.ramp_half_extents[1] * 2.0,
257            self.ramp_half_extents[2] * 2.0,
258        )
259        view = self.add_mesh(
260            "/terrain/inclined_box",
261            mesh_data,
262            self.standard_materials.common,
263            color=[0.45, 0.45, 0.5, 1.0],
264        )
265        view.prim.set_local_translation(ke.Vec3(self.ramp_pos))
266        # ke.Quat constructor is (w, x, y, z), while PhysX binding args above
267        # use xyzw lists.
268        view.prim.set_local_rotation(
269            ke.Quat(
270                self.ramp_rot_xyzw[3],
271                self.ramp_rot_xyzw[0],
272                self.ramp_rot_xyzw[1],
273                self.ramp_rot_xyzw[2],
274            )
275        )
276        return view
277
278    @staticmethod
279    def _mean_for_envs(values: torch.Tensor, env_ids: tuple[int, ...]) -> float:
280        if not env_ids:
281            return float("nan")
282        index = torch.tensor(env_ids, dtype=torch.long, device=values.device)
283        return float(values.index_select(0, index).mean().item())
284
285    def cleanup(self):
286        if hasattr(self, "world"):
287            self.world.release()
288
289
290def parse_args():
291    parser = argparse.ArgumentParser(description=__doc__)
292    parser.add_argument("--num-envs", type=int, default=16)
293    parser.add_argument("--width", type=int, default=1280)
294    parser.add_argument("--height", type=int, default=720)
295    parser.add_argument(
296        "--friction-switch-time",
297        type=float,
298        default=1.0,
299        help="Seconds before switching odd envs to high friction at runtime.",
300    )
301    return parser.parse_args()
302
303
304def main():
305    args = parse_args()
306    app = MultiEnvSimWorldApp(args.num_envs, args.friction_switch_time)
307    app.initialize(args.width, args.height, False, ke.UpAxis.Z)
308    app.start()
309
310
311if __name__ == "__main__":
312    main()

The example compares low- and high-friction groups on a ramp and displays batched state statistics.

Batched ramp environments