MultiMesh¶
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())