MultiMesh¶
Thousands of cubes in one draw call.
▶ Run in browserTags: 3d
Drawing the same mesh many times is what instancing is for. A MultiMesh
holds one mesh and a transform per instance, and a MultiMeshInstance3D
draws every instance in a single batch with one shared material, so the cost
is one draw call however many copies there are.
cubes = MultiMesh(mesh=Mesh.box(1.0), instance_count=1600)
self.add_child(MultiMeshInstance3D(multi_mesh=cubes, material=material))
Here a 40 by 40 field of cubes sits on a gentle sine-wave surface, and every
cube spins about its own vertical axis at its own rate. Nothing is done per
cube in Python: each frame the angles advance as one NumPy array, turn into
quaternions, and batch_mat4_from_trs builds every model matrix at once.
MultiMesh.set_all_transforms takes the (N, 4, 4) result in one call.
set_instance_transform sets a single instance when only a few change.
The OrbitCamera3D takes its own input, so the scene only sets where it
starts, its far plane and how far it may zoom out. Resetting the view assigns
those starting values back to distance, pitch and yaw.
Controls: Drag / right stick - Orbit the camera Scroll / pinch / triggers - Zoom R / Reset view button - Reset the camera
Source¶
1# /// simvx
2# web = { width = 1280, height = 720 }
3# ///
4"""MultiMesh: Thousands of cubes in one draw call.
5
6Drawing the same mesh many times is what instancing is for. A ``MultiMesh``
7holds one mesh and a transform per instance, and a ``MultiMeshInstance3D``
8draws every instance in a single batch with one shared material, so the cost
9is one draw call however many copies there are.
10
11 cubes = MultiMesh(mesh=Mesh.box(1.0), instance_count=1600)
12 self.add_child(MultiMeshInstance3D(multi_mesh=cubes, material=material))
13
14Here a 40 by 40 field of cubes sits on a gentle sine-wave surface, and every
15cube spins about its own vertical axis at its own rate. Nothing is done per
16cube in Python: each frame the angles advance as one NumPy array, turn into
17quaternions, and ``batch_mat4_from_trs`` builds every model matrix at once.
18``MultiMesh.set_all_transforms`` takes the ``(N, 4, 4)`` result in one call.
19``set_instance_transform`` sets a single instance when only a few change.
20
21The ``OrbitCamera3D`` takes its own input, so the scene only sets where it
22starts, its far plane and how far it may zoom out. Resetting the view assigns
23those starting values back to ``distance``, ``pitch`` and ``yaw``.
24
25Controls:
26 Drag / right stick - Orbit the camera
27 Scroll / pinch / triggers - Zoom
28 R / Reset view button - Reset the camera
29"""
30
31import math
32
33import numpy as np
34
35from simvx.core import (
36 Anchor,
37 Button,
38 CanvasLayer,
39 DirectionalLight3D,
40 Key,
41 Label,
42 Material,
43 Mesh,
44 MultiMesh,
45 MultiMeshInstance3D,
46 Node3D,
47 OrbitCamera3D,
48 on_input,
49)
50from simvx.core.math import batch_mat4_from_trs
51from simvx.graphics import App
52
53GRID = 40
54SPACING = 2.5
55HOME_DISTANCE, HOME_PITCH, HOME_YAW = 80.0, math.radians(-45), math.radians(30)
56
57
58class MultiMeshDemo(Node3D):
59 input_context = {"reset_camera": [Key.R]}
60
61 def on_ready(self):
62 # The default far plane (100) would clip the far corners of the field.
63 self.camera = self.add_child(
64 OrbitCamera3D(distance=HOME_DISTANCE, pitch=HOME_PITCH, yaw=HOME_YAW, far=400.0, max_distance=200.0)
65 )
66 self.add_child(DirectionalLight3D(intensity=1.2, direction=(-1, -2, -1)))
67
68 coords = (np.arange(GRID, dtype=np.float32) - (GRID - 1) / 2) * SPACING
69 x, z = (axis.ravel() for axis in np.meshgrid(coords, coords))
70 self.positions = np.column_stack([x, np.sin(x * 0.15) * np.cos(z * 0.15) * 2.0, z])
71 self.scales = np.ones_like(self.positions)
72 rng = np.random.default_rng(42)
73 self.angles = rng.uniform(0.0, math.tau, GRID * GRID).astype(np.float32)
74 self.spin_rates = rng.uniform(0.5, 1.8, GRID * GRID).astype(np.float32)
75
76 self.cubes = MultiMesh(mesh=Mesh.box(1.0), instance_count=GRID * GRID)
77 material = Material(colour=(0.45, 0.7, 0.85), roughness=0.5, metallic=0.1)
78 self.add_child(MultiMeshInstance3D(name="CubeField", multi_mesh=self.cubes, material=material))
79
80 hud = self.add_child(CanvasLayer())
81 self.stats = hud.add_child(Label("FPS: --", anchor=Anchor.TOP_LEFT, inset=10, font_size=20))
82 self.frames, self.elapsed = 0, 0.0
83
84 # Example help overlay: not part of the technique shown.
85 hud.add_child(Label("Drag: orbit Scroll or pinch: zoom R: reset", anchor=Anchor.BOTTOM_LEFT, inset=10))
86 hud.add_child(Button("Reset view", action="reset_camera", anchor=Anchor.BOTTOM_RIGHT, inset=10))
87
88 def on_update(self, dt):
89 # A rotation of angle a about +Y is the quaternion (w, x, y, z) = (cos a/2, 0, sin a/2, 0).
90 self.angles += self.spin_rates * dt
91 half = self.angles / 2
92 zero = np.zeros_like(half)
93 rotations = np.column_stack([np.cos(half), zero, np.sin(half), zero])
94 self.cubes.set_all_transforms(batch_mat4_from_trs(self.positions, rotations, self.scales))
95
96 self.frames += 1
97 self.elapsed += dt
98 if self.elapsed >= 0.5:
99 self.stats.text = f"FPS: {self.frames / self.elapsed:.0f} {self.cubes.instance_count} instances"
100 self.frames, self.elapsed = 0, 0.0
101
102 @on_input("reset_camera")
103 def reset_camera(self, _event):
104 self.camera.distance, self.camera.pitch, self.camera.yaw = HOME_DISTANCE, HOME_PITCH, HOME_YAW
105
106
107if __name__ == "__main__":
108 App(title="MultiMesh", width=1280, height=720).run(MultiMeshDemo())