Batched mesh rendering¶
Efficiently render many instances of the same mesh with different transforms and colors.
This example demonstrates batched mesh rendering, which is essential for visualizing large numbers of similar objects like particles, forest scenes, or crowd simulations. Batched rendering is dramatically more efficient than creating individual scene objects.
Key features:
viser.SceneApi.add_batched_meshes_simple()
for instanced mesh renderingviser.SceneApi.add_batched_axes()
for coordinate frame instancesPer-instance transforms (position, rotation, scale)
Per-instance colors with the batched_colors parameter (supports both per-instance and shared colors)
Level-of-detail (LOD) optimization for performance
Real-time animation of instance properties
Batched meshes have some limitations: GLB animations are not supported, hierarchy is flattened, and each mesh in a GLB is instanced separately. However, they excel at rendering thousands of objects efficiently.
Note
This example requires external assets. To download them, run:
git clone -b v1.0.11 https://github.com/nerfstudio-project/viser.git
cd viser/examples
./assets/download_assets.sh
python 01_scene/05_meshes_batched.py # With viser installed.
Note
For loading GLB files directly, see add_batched_glb()
.
For working with trimesh objects, see add_batched_meshes_trimesh()
.
Source: examples/01_scene/05_meshes_batched.py

Code¶
1from __future__ import annotations
2
3import time
4from pathlib import Path
5
6import numpy as np
7import trimesh
8
9import viser
10
11
12def create_grid_transforms(
13 num_instances: int,
14) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
15 grid_size = int(np.ceil(np.sqrt(num_instances)))
16
17 # Create grid positions.
18 x = np.arange(grid_size) - (grid_size - 1) / 2
19 y = np.arange(grid_size) - (grid_size - 1) / 2
20 xx, yy = np.meshgrid(x, y)
21
22 positions = np.zeros((grid_size * grid_size, 3), dtype=np.float32)
23 positions[:, 0] = xx.flatten()
24 positions[:, 1] = yy.flatten()
25 positions[:, 2] = 1.0
26 positions = positions[:num_instances]
27
28 # All instances have identity rotation.
29 rotations = np.zeros((num_instances, 4), dtype=np.float32)
30 rotations[:, 0] = 1.0 # w component = 1
31
32 # Initial scales.
33 scales = np.linalg.norm(positions, axis=-1)
34 scales = np.sin(scales * 1.5) * 0.5 + 1.0
35 return positions, rotations, scales.astype(np.float32)
36
37
38def generate_per_instance_colors(
39 positions: np.ndarray, color_mode: str = "rainbow"
40) -> np.ndarray:
41 n = positions.shape[0]
42
43 if color_mode == "rainbow":
44 # Rainbow colors based on instance index.
45 hues = np.linspace(0, 1, n, endpoint=False)
46 colors = np.zeros((n, 3))
47 for i, hue in enumerate(hues):
48 # Convert HSV to RGB (simplified).
49 c = 1.0 # Saturation.
50 x = c * (1 - abs((hue * 6) % 2 - 1))
51
52 if hue < 1 / 6:
53 colors[i] = [c, x, 0]
54 elif hue < 2 / 6:
55 colors[i] = [x, c, 0]
56 elif hue < 3 / 6:
57 colors[i] = [0, c, x]
58 elif hue < 4 / 6:
59 colors[i] = [0, x, c]
60 elif hue < 5 / 6:
61 colors[i] = [x, 0, c]
62 else:
63 colors[i] = [c, 0, x]
64 return (colors * 255).astype(np.uint8)
65
66 elif color_mode == "position":
67 # Colors based on position (cosine of position for smooth gradients).
68 colors = (np.cos(positions) * 0.5 + 0.5) * 255
69 return colors.astype(np.uint8)
70
71 else:
72 # Default to white.
73 return np.full((n, 3), 255, dtype=np.uint8)
74
75
76def generate_shared_color(color_rgb: tuple[int, int, int]) -> np.ndarray:
77 return np.array(color_rgb, dtype=np.uint8)
78
79
80def generate_animated_colors(
81 positions: np.ndarray, t: float, animation_mode: str = "wave"
82) -> np.ndarray:
83 n = positions.shape[0]
84
85 if animation_mode == "wave":
86 # Wave pattern based on distance from center.
87 distances = np.linalg.norm(positions[:, :2], axis=1)
88 wave = np.sin(distances * 2) * 0.5 + 0.5
89 colors = np.zeros((n, 3))
90 colors[:, 0] = wave # Red channel.
91 colors[:, 1] = np.sin(distances * 2 + np.pi / 3) * 0.5 + 0.5 # Green.
92 colors[:, 2] = np.sin(distances * 2 + 2 * np.pi / 3) * 0.5 + 0.5 # Blue.
93 return (colors * 255).astype(np.uint8)
94
95 elif animation_mode == "pulse":
96 # Pulsing color based on position.
97 pulse = np.sin(t * 2) * 0.5 + 0.5
98 colors = (np.cos(positions) * 0.5 + 0.5) * pulse
99 return (colors * 255).astype(np.uint8)
100
101 elif animation_mode == "cycle":
102 # Cycling through hues over time.
103 hue_shift = (t * 0.5) % 1.0
104 hues = (np.linspace(0, 1, n, endpoint=False) + hue_shift) % 1.0
105 colors = np.zeros((n, 3))
106 for i, hue in enumerate(hues):
107 # Convert HSV to RGB (simplified).
108 c = 1.0 # Saturation.
109 x = c * (1 - abs((hue * 6) % 2 - 1))
110
111 if hue < 1 / 6:
112 colors[i] = [c, x, 0]
113 elif hue < 2 / 6:
114 colors[i] = [x, c, 0]
115 elif hue < 3 / 6:
116 colors[i] = [0, c, x]
117 elif hue < 4 / 6:
118 colors[i] = [0, x, c]
119 elif hue < 5 / 6:
120 colors[i] = [x, 0, c]
121 else:
122 colors[i] = [c, 0, x]
123 return (colors * 255).astype(np.uint8)
124
125 else:
126 # Default to white.
127 return np.full((n, 3), 255, dtype=np.uint8)
128
129
130def main():
131 # Load and prepare mesh data.
132 dragon_mesh = trimesh.load_mesh(str(Path(__file__).parent / "../assets/dragon.obj"))
133 assert isinstance(dragon_mesh, trimesh.Trimesh)
134 dragon_mesh.apply_scale(0.005)
135 dragon_mesh.vertices -= dragon_mesh.centroid
136
137 dragon_mesh.apply_transform(
138 trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])
139 )
140 dragon_mesh.apply_translation(-dragon_mesh.centroid)
141
142 server = viser.ViserServer()
143 server.scene.configure_default_lights()
144 grid_handle = server.scene.add_grid(name="grid", width=12, height=12)
145
146 # Add GUI controls.
147 instance_count_slider = server.gui.add_slider(
148 "# of instances", min=1, max=1000, step=1, initial_value=100
149 )
150
151 animate_checkbox = server.gui.add_checkbox("Animate", initial_value=True)
152 per_axis_scale_checkbox = server.gui.add_checkbox(
153 "Per-axis scale during animation", initial_value=True
154 )
155 lod_checkbox = server.gui.add_checkbox("Enable LOD", initial_value=True)
156 cast_shadow_checkbox = server.gui.add_checkbox("Cast shadow", initial_value=True)
157
158 # Color controls.
159 color_mode_dropdown = server.gui.add_dropdown(
160 "Color mode",
161 options=("Per-instance", "Shared", "Animated"),
162 initial_value="Per-instance",
163 )
164
165 # Per-instance color controls.
166 per_instance_color_dropdown = server.gui.add_dropdown(
167 "Per-instance style",
168 options=("Rainbow", "Position"),
169 initial_value="Rainbow",
170 )
171
172 # Shared color controls.
173 shared_color_rgb = server.gui.add_rgb("Shared color", initial_value=(255, 0, 255))
174
175 # Animated color controls.
176 animated_color_dropdown = server.gui.add_dropdown(
177 "Animation style",
178 options=("Wave", "Pulse", "Cycle"),
179 initial_value="Wave",
180 )
181
182 # Initialize transforms.
183 positions, rotations, scales = create_grid_transforms(instance_count_slider.value)
184 positions_orig = positions.copy()
185
186 # Create batched mesh visualization.
187 axes_handle = server.scene.add_batched_axes(
188 name="axes",
189 batched_positions=positions,
190 batched_wxyzs=rotations,
191 batched_scales=scales,
192 )
193
194 # Create initial colors based on default mode.
195 initial_colors = generate_per_instance_colors(positions, color_mode="rainbow")
196
197 mesh_handle = server.scene.add_batched_meshes_simple(
198 name="dragon",
199 vertices=dragon_mesh.vertices,
200 faces=dragon_mesh.faces,
201 batched_positions=positions,
202 batched_wxyzs=rotations,
203 batched_scales=scales,
204 batched_colors=initial_colors,
205 lod="auto",
206 )
207
208 # Track previous color mode to avoid redundant disabled state updates.
209 prev_color_mode = color_mode_dropdown.value
210
211 # Animation loop.
212 while True:
213 n = instance_count_slider.value
214
215 # Update props based on GUI controls.
216 mesh_handle.lod = "auto" if lod_checkbox.value else "off"
217 mesh_handle.cast_shadow = cast_shadow_checkbox.value
218
219 # Recreate transforms if instance count changed.
220 if positions.shape[0] != n:
221 positions, rotations, scales = create_grid_transforms(n)
222 positions_orig = positions.copy()
223 grid_size = int(np.ceil(np.sqrt(n)))
224
225 with server.atomic():
226 # Update grid size.
227 grid_handle.width = grid_handle.height = grid_size + 2
228
229 # Update all transforms.
230 mesh_handle.batched_positions = axes_handle.batched_positions = (
231 positions
232 )
233 mesh_handle.batched_wxyzs = axes_handle.batched_wxyzs = rotations
234 mesh_handle.batched_scales = axes_handle.batched_scales = scales
235
236 # Colors will be overwritten below; we'll just put them in a valid state.
237 mesh_handle.batched_colors = np.zeros(3, dtype=np.uint8)
238
239 # Generate colors based on current mode.
240 color_mode = color_mode_dropdown.value
241
242 # Update disabled state for color controls only when mode changes.
243 if color_mode != prev_color_mode:
244 per_instance_color_dropdown.disabled = color_mode != "Per-instance"
245 shared_color_rgb.disabled = color_mode != "Shared"
246 animated_color_dropdown.disabled = color_mode != "Animated"
247 prev_color_mode = color_mode
248
249 if color_mode == "Per-instance":
250 # Per-instance colors with different styles.
251 per_instance_style = per_instance_color_dropdown.value.lower()
252 colors = generate_per_instance_colors(
253 positions, color_mode=per_instance_style
254 )
255 elif color_mode == "Shared":
256 # Single shared color for all instances.
257 colors = generate_shared_color(shared_color_rgb.value)
258 elif color_mode == "Animated":
259 # Animated colors with time-based effects.
260 t = time.perf_counter()
261 animation_style = animated_color_dropdown.value.lower()
262 colors = generate_animated_colors(
263 positions, t, animation_mode=animation_style
264 )
265 else:
266 # Default fallback.
267 colors = generate_per_instance_colors(positions, color_mode="rainbow")
268
269 # Animate if enabled.
270 if animate_checkbox.value:
271 # Animate positions.
272 t = time.time() * 2.0
273 positions[:] = positions_orig
274 positions[:, 0] += np.cos(t * 0.5)
275 positions[:, 1] += np.sin(t * 0.5)
276
277 # Animate scales with wave effect.
278 if per_axis_scale_checkbox.value:
279 scales = np.linalg.norm(positions, axis=-1)
280 scales = np.stack(
281 [
282 np.sin(scales * 1.5) * 0.5 + 1.0,
283 np.sin(scales * 1.5 + np.pi / 2.0) * 0.5 + 1.0,
284 np.sin(scales * 1.5 + np.pi) * 0.5 + 1.0,
285 ],
286 axis=-1,
287 )
288 assert scales.shape == (n, 3)
289 else:
290 scales = np.linalg.norm(positions, axis=-1)
291 scales = np.sin(scales * 1.5 - t) * 0.5 + 1.0
292 assert scales.shape == (n,)
293
294 # Update colors for animated mode during animation.
295 if color_mode == "Animated":
296 animation_style = animated_color_dropdown.value.lower()
297 colors = generate_animated_colors(
298 positions, t, animation_mode=animation_style
299 )
300
301 # Update mesh properties.
302 with server.atomic():
303 mesh_handle.batched_positions = positions
304 mesh_handle.batched_scales = scales
305 mesh_handle.batched_colors = colors
306
307 axes_handle.batched_positions = positions
308 axes_handle.batched_scales = scales
309
310 time.sleep(1.0 / 60.0)
311
312
313if __name__ == "__main__":
314 main()