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, each spinning at its own rate. They share one material and one draw call: every frame the transforms are rebuilt in a single vectorised numpy pass and uploaded with MultiMesh.set_all_transforms().

An OrbitCamera3D looks down on the field from a raised three-quarter angle. It derives its transform from pivot / distance / yaw / pitch, so the scene drives it through orbit() and zoom() rather than by assigning a position.

Run: uv run python examples/features/3d/multimesh.py

Controls: Left-drag - Orbit camera Scroll - Zoom in/out R - Reset camera Escape - Quit

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, each spinning at its own rate. They share one
 10material and one draw call: every frame the transforms are rebuilt in a single
 11vectorised numpy pass and uploaded with MultiMesh.set_all_transforms().
 12
 13An OrbitCamera3D looks down on the field from a raised three-quarter angle. It
 14derives its transform from pivot / distance / yaw / pitch, so the scene drives
 15it through orbit() and zoom() rather than by assigning a position.
 16
 17Run:  uv run python examples/features/3d/multimesh.py
 18
 19Controls:
 20    Left-drag   - Orbit camera
 21    Scroll      - Zoom in/out
 22    R           - Reset camera
 23    Escape      - Quit
 24"""
 25
 26import math
 27
 28import numpy as np
 29
 30from simvx.core import (
 31    DirectionalLight3D,
 32    Input,
 33    InputMap,
 34    Key,
 35    Material,
 36    Mesh,
 37    MouseButton,
 38    MultiMesh,
 39    MultiMeshInstance3D,
 40    Node3D,
 41    OrbitCamera3D,
 42    Text2D,
 43    Vec3,
 44)
 45from simvx.core.math import batch_mat4_from_trs
 46from simvx.graphics import App
 47
 48GRID_SIZE = 40  # 40x40 = 1600 instances
 49SPACING = 2.5
 50
 51
 52class MultiMeshDemo(Node3D):
 53    """Scene with a large instanced grid of cubes and an orbit camera."""
 54
 55    def on_ready(self):
 56        InputMap.add_action("reset_camera", [Key.R])
 57        InputMap.add_action("quit", [Key.ESCAPE])
 58
 59        # Orbit camera. Default far plane (100) clips the far corners of a
 60        # 40×40 / 2.5-spacing field viewed from distance 80; bump it.
 61        self.camera = self.add_child(OrbitCamera3D(name="Camera", far=400.0))
 62        self.camera.distance = 80.0
 63        self.camera.pitch = math.radians(-45.0)
 64        self.camera.yaw = math.radians(30.0)
 65        self.camera.update_transform()
 66
 67        # Directional light for shading
 68        light = self.add_child(DirectionalLight3D(name="Sun"))
 69        light.look_at(Vec3(-1, -2, -1))
 70        light.intensity = 1.2
 71
 72        # Build the multimesh: 1600 cubes in a grid (vectorized)
 73        count = GRID_SIZE * GRID_SIZE
 74        self._instance_count = count
 75        mm = MultiMesh(mesh=Mesh.cube(size=1.0), instance_count=count)
 76
 77        half = (GRID_SIZE - 1) * SPACING / 2.0
 78        gx = np.arange(GRID_SIZE, dtype=np.float32)
 79        gz = np.arange(GRID_SIZE, dtype=np.float32)
 80        gx_grid, gz_grid = np.meshgrid(gx, gz)  # (GRID, GRID)
 81        xs = (gx_grid.ravel() * SPACING - half).astype(np.float32)
 82        zs = (gz_grid.ravel() * SPACING - half).astype(np.float32)
 83        ys = (np.sin(xs * 0.15) * np.cos(zs * 0.15) * 2.0).astype(np.float32)
 84
 85        self._positions = np.column_stack([xs, ys, zs])
 86        self._scales = np.ones((count, 3), dtype=np.float32)
 87
 88        # Each cube gets its own rotation axis + spin rate. We seed the base
 89        # Euler-Y phase from random noise and advance it in on_update(), so every
 90        # cube rotates independently while the whole field is still one draw
 91        # call (set_all_transforms rebuilds the transform buffer in-place).
 92        rng = np.random.default_rng(42)
 93        self._phase = rng.uniform(0.0, math.tau, count).astype(np.float32)
 94        self._rate = rng.uniform(0.5, 1.8, count).astype(np.float32)
 95
 96        self._mm = mm
 97        self._update_transforms(yaw=self._phase)
 98
 99        # Single shared material for performance (avoids per-instance material overhead)
100        mat = Material(colour=(0.45, 0.7, 0.85, 1.0), roughness=0.5, metallic=0.1)
101        node = MultiMeshInstance3D(multi_mesh=mm, material=mat, name="CubeField")
102        self.add_child(node)
103
104        # FPS display + controls hint
105        self._fps_text = self.add_child(Text2D(text="FPS: --", position=(10, 10), font_scale=1.5))
106        self.add_child(
107            Text2D(
108                text="Drag: orbit | Scroll: zoom | R: reset camera | Esc: quit",
109                position=(10, 42),
110                font_scale=1.0,
111                colour=(0.8, 0.8, 0.8, 1.0),
112            )
113        )
114        self._frame_count = 0
115        self._elapsed = 0.0
116
117    def _update_transforms(self, yaw: np.ndarray) -> None:
118        """Rebuild the multimesh transform buffer from per-cube Y-rotation.
119
120        Single vectorized call, single GPU draw: no per-instance Python
121        bookkeeping, so 1600 rotating cubes still cost one draw per frame.
122        """
123        hy = yaw * 0.5
124        cy = np.cos(hy).astype(np.float32)
125        sy = np.sin(hy).astype(np.float32)
126        zeros = np.zeros_like(cy)
127        quats = np.column_stack([cy, zeros, sy, zeros])  # (w, x, y, z) = (cos, 0, sin, 0)
128        self._mm.set_all_transforms(batch_mat4_from_trs(self._positions, quats, self._scales))
129
130    def on_update(self, dt: float):
131        if Input.is_action_just_pressed("quit"):
132            self.app.quit()
133            return
134        # FPS counter
135        self._frame_count += 1
136        self._elapsed += dt
137        if self._elapsed >= 0.5:
138            fps = self._frame_count / self._elapsed
139            # Desktop App exposes a ``vsync`` property; the web runtime has no
140            # equivalent (the browser paces frames itself).
141            vsync_flag = getattr(self.app, "vsync", None)
142            if vsync_flag is True:
143                vsync_txt = "vsync ON"
144            elif vsync_flag is False:
145                vsync_txt = "vsync OFF"
146            else:
147                vsync_txt = "browser rAF"  # web runtime: browser controls pacing
148            self._fps_text.text = f"FPS: {fps:.0f}  |  {self._instance_count} instances  |  {vsync_txt}"
149            self._frame_count = 0
150            self._elapsed = 0.0
151
152        # Spin every cube independently. phase += rate * dt ≈ 50 ops + one
153        # vectorized quat-build + one GPU upload.
154        self._phase = (self._phase + self._rate * dt).astype(np.float32)
155        self._update_transforms(self._phase)
156
157        # Camera controls. OrbitCamera3D derives its transform from pivot /
158        # distance / yaw / pitch and exposes orbit() and zoom(); it does not
159        # read input itself, so the scene maps mouse drag and wheel onto them.
160        if Input.is_mouse_button_pressed(MouseButton.LEFT):
161            drag = Input.mouse_delta
162            if abs(drag.x) > 0.1 or abs(drag.y) > 0.1:
163                self.camera.orbit(math.radians(-float(drag.x) * 0.3), math.radians(-float(drag.y) * 0.3))
164
165        scroll_y = Input.scroll_delta[1]
166        if scroll_y != 0.0:
167            # zoom() clamps the near side at distance 1; cap the far side so the
168            # whole field stays inside the camera's far plane.
169            self.camera.zoom(max(scroll_y * 5.0, self.camera.distance - 200.0))
170
171        if Input.is_action_just_pressed("reset_camera"):
172            self.camera.distance = 80.0
173            self.camera.pitch = math.radians(-45.0)
174            self.camera.yaw = math.radians(30.0)
175            self.camera.update_transform()
176
177
178if __name__ == "__main__":
179    # Vsync ON by default (don't spin the GPU for no reason).
180    app = App(title="MultiMeshInstance3D Demo", width=1280, height=720, vsync=True)
181    app.run(MultiMeshDemo())