MultiMesh

Play Demo

MultiMeshInstance3D Demo — 1600 cubes rendered via instancing.

Demonstrates mass instancing of identical meshes using MultiMeshInstance3D. 1600 cubes are laid out as a ground-level field on the XZ plane with slight sine-wave height variation and random rotation for visual variety. Rendered with a single shared material, camera orbits from above.

Run: uv run python packages/graphics/examples/3d_multimesh.py Test: uv run python packages/graphics/examples/3d_multimesh.py –test

Controls: Mouse drag - Orbit camera Scroll - Zoom in/out R - Reset camera

Source Code

  1"""
  2MultiMeshInstance3D Demo — 1600 cubes rendered via instancing.
  3
  4Demonstrates mass instancing of identical meshes using MultiMeshInstance3D.
  51600 cubes are laid out as a ground-level field on the XZ plane with slight
  6sine-wave height variation and random rotation for visual variety. Rendered
  7with a single shared material, camera orbits from above.
  8
  9Run:  uv run python packages/graphics/examples/3d_multimesh.py
 10Test: uv run python packages/graphics/examples/3d_multimesh.py --test
 11
 12Controls:
 13    Mouse drag  - Orbit camera
 14    Scroll      - Zoom in/out
 15    R           - Reset camera
 16"""
 17
 18
 19import math
 20import sys
 21
 22import numpy as np
 23
 24from simvx.core import (
 25    DirectionalLight3D,
 26    Input,
 27    InputMap,
 28    Key,
 29    Material,
 30    Mesh,
 31    MultiMesh,
 32    MultiMeshInstance3D,
 33    Node3D,
 34    OrbitCamera3D,
 35    Text2D,
 36    Vec3,
 37)
 38from simvx.core.math.matrices import batch_mat4_from_trs
 39from simvx.graphics import App
 40
 41GRID_SIZE = 40  # 40x40 = 1600 instances
 42SPACING = 2.5
 43
 44
 45class MultiMeshDemo(Node3D):
 46    """Scene with a large instanced grid of cubes and an orbit camera."""
 47
 48    def ready(self):
 49        InputMap.add_action("reset_camera", [Key.R])
 50        InputMap.add_action("quit", [Key.ESCAPE])
 51
 52        # Orbit camera. Default far plane (100) clips the far corners of a
 53        # 40×40 / 2.5-spacing field viewed from distance 80; bump it.
 54        self.camera = self.add_child(OrbitCamera3D(name="Camera", far=400.0))
 55        self.camera.distance = 80.0
 56        self.camera.pitch = math.radians(-45.0)
 57        self.camera.yaw = math.radians(30.0)
 58        self.camera._update_transform()
 59
 60        # Directional light for shading
 61        light = self.add_child(DirectionalLight3D(name="Sun"))
 62        light.look_at(Vec3(-1, -2, -1))
 63        light.intensity = 1.2
 64
 65        # Build the multimesh — 1600 cubes in a grid (vectorized)
 66        count = GRID_SIZE * GRID_SIZE
 67        self._instance_count = count
 68        mm = MultiMesh(mesh=Mesh.cube(size=1.0), instance_count=count)
 69
 70        half = (GRID_SIZE - 1) * SPACING / 2.0
 71        gx = np.arange(GRID_SIZE, dtype=np.float32)
 72        gz = np.arange(GRID_SIZE, dtype=np.float32)
 73        gx_grid, gz_grid = np.meshgrid(gx, gz)  # (GRID, GRID)
 74        xs = (gx_grid.ravel() * SPACING - half).astype(np.float32)
 75        zs = (gz_grid.ravel() * SPACING - half).astype(np.float32)
 76        ys = (np.sin(xs * 0.15) * np.cos(zs * 0.15) * 2.0).astype(np.float32)
 77
 78        self._positions = np.column_stack([xs, ys, zs])
 79        self._scales = np.ones((count, 3), dtype=np.float32)
 80
 81        # Each cube gets its own rotation axis + spin rate. We seed the base
 82        # Euler-Y phase from random noise and advance it in process(), so every
 83        # cube rotates independently while the whole field is still one draw
 84        # call (set_all_transforms rebuilds the transform buffer in-place).
 85        rng = np.random.default_rng(42)
 86        self._phase = rng.uniform(0.0, math.tau, count).astype(np.float32)
 87        self._rate = rng.uniform(0.5, 1.8, count).astype(np.float32)
 88
 89        self._mm = mm
 90        self._update_transforms(yaw=self._phase)
 91
 92        # Single shared material for performance (avoids per-instance material overhead)
 93        mat = Material(colour=(0.45, 0.7, 0.85, 1.0), roughness=0.5, metallic=0.1)
 94        node = MultiMeshInstance3D(multi_mesh=mm, material=mat, name="CubeField")
 95        self.add_child(node)
 96
 97        # FPS display
 98        self._fps_text = self.add_child(Text2D(text="FPS: --", x=10, y=10, font_scale=1.5))
 99        self._frame_count = 0
100        self._elapsed = 0.0
101
102    def _update_transforms(self, yaw: np.ndarray) -> None:
103        """Rebuild the multimesh transform buffer from per-cube Y-rotation.
104
105        Single vectorized call, single GPU draw — no per-instance Python
106        bookkeeping, so 1600 rotating cubes still cost one draw per frame.
107        """
108        hy = yaw * 0.5
109        cy = np.cos(hy).astype(np.float32)
110        sy = np.sin(hy).astype(np.float32)
111        zeros = np.zeros_like(cy)
112        quats = np.column_stack([cy, zeros, sy, zeros])  # (w, x, y, z) = (cos, 0, sin, 0)
113        self._mm.set_all_transforms(batch_mat4_from_trs(self._positions, quats, self._scales))
114
115    def process(self, dt: float):
116        if Input.is_action_just_pressed("quit"):
117            self.app.quit()
118            return
119        # FPS counter
120        self._frame_count += 1
121        self._elapsed += dt
122        if self._elapsed >= 0.5:
123            fps = self._frame_count / self._elapsed
124            # ``_vsync`` is a desktop-App internal; WebApp has no equivalent.
125            vsync_flag = getattr(self.app, "_vsync", None)
126            if vsync_flag is True:
127                vsync_txt = "vsync ON"
128            elif vsync_flag is False:
129                vsync_txt = "vsync OFF"
130            else:
131                vsync_txt = "browser rAF"  # web runtime — browser controls pacing
132            self._fps_text.text = (
133                f"FPS: {fps:.0f}  |  {self._instance_count} instances  |  {vsync_txt}"
134            )
135            self._frame_count = 0
136            self._elapsed = 0.0
137
138        # Spin every cube independently. phase += rate * dt ≈ 50 ops + one
139        # vectorized quat-build + one GPU upload.
140        self._phase = (self._phase + self._rate * dt).astype(np.float32)
141        self._update_transforms(self._phase)
142
143        # Camera controls
144        if Input.is_action_just_pressed("reset_camera"):
145            self.camera.distance = 80.0
146            self.camera.pitch = math.radians(-45.0)
147            self.camera.yaw = math.radians(30.0)
148            self.camera._update_transform()
149
150
151if __name__ == "__main__":
152    test_mode = "--test" in sys.argv
153    # Vsync ON by default (don't spin the GPU for no reason); press V in-demo
154    # to drop vsync and see the uncapped FPS for instancing stress-testing.
155    app = App(title="MultiMeshInstance3D Demo", width=1280, height=720, vsync=True)
156    if test_mode:
157        frames = app.run_headless(MultiMeshDemo(), frames=5, capture_frames=[4])
158        if frames:
159            frame = frames[0]
160            total = frame.shape[0] * frame.shape[1]
161            non_black = np.count_nonzero(np.any(frame[:, :, :3] > 10, axis=2))
162            ratio = non_black / total
163            print(f"Captured {frame.shape[1]}x{frame.shape[0]}, non-black: {ratio:.1%}")
164            assert ratio > 0.1, f"Scene appears mostly blank: {ratio:.1%}"
165            print("PASS: MultiMesh rendering verified")
166    else:
167        app.run(MultiMeshDemo())