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