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