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

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