MultiMesh¶
1600 cubes rendered via MultiMeshInstance3D instancing.
▶ Run in browserTags: 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())