MultiMesh

1600 cubes rendered via MultiMeshInstance3D instancing.

▶ Run in browser

Tags: 3d

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 examples/features/3d/multimesh.py Test: uv run python examples/features/3d/multimesh.py –test

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

Source

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