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.7 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(
145 name="grid",
146 width=12,
147 height=12,
148 width_segments=12,
149 height_segments=12,
150 )
151
152 # Add GUI controls.
153 instance_count_slider = server.gui.add_slider(
154 "# of instances", min=1, max=1000, step=1, initial_value=100
155 )
156
157 animate_checkbox = server.gui.add_checkbox("Animate", initial_value=True)
158 per_axis_scale_checkbox = server.gui.add_checkbox(
159 "Per-axis scale during animation", initial_value=True
160 )
161 lod_checkbox = server.gui.add_checkbox("Enable LOD", initial_value=True)
162 cast_shadow_checkbox = server.gui.add_checkbox("Cast shadow", initial_value=True)
163
164 # Color controls.
165 color_mode_dropdown = server.gui.add_dropdown(
166 "Color mode",
167 options=("Per-instance", "Shared", "Animated"),
168 initial_value="Per-instance",
169 )
170
171 # Per-instance color controls.
172 per_instance_color_dropdown = server.gui.add_dropdown(
173 "Per-instance style",
174 options=("Rainbow", "Position"),
175 initial_value="Rainbow",
176 )
177
178 # Shared color controls.
179 shared_color_rgb = server.gui.add_rgb("Shared color", initial_value=(255, 0, 255))
180
181 # Animated color controls.
182 animated_color_dropdown = server.gui.add_dropdown(
183 "Animation style",
184 options=("Wave", "Pulse", "Cycle"),
185 initial_value="Wave",
186 )
187
188 # Initialize transforms.
189 positions, rotations, scales = create_grid_transforms(instance_count_slider.value)
190 positions_orig = positions.copy()
191
192 # Create batched mesh visualization.
193 axes_handle = server.scene.add_batched_axes(
194 name="axes",
195 batched_positions=positions,
196 batched_wxyzs=rotations,
197 batched_scales=scales,
198 )
199
200 # Create initial colors based on default mode.
201 initial_colors = generate_per_instance_colors(positions, color_mode="rainbow")
202
203 mesh_handle = server.scene.add_batched_meshes_simple(
204 name="dragon",
205 vertices=dragon_mesh.vertices,
206 faces=dragon_mesh.faces,
207 batched_positions=positions,
208 batched_wxyzs=rotations,
209 batched_scales=scales,
210 batched_colors=initial_colors,
211 lod="auto",
212 )
213
214 # Track previous color mode to avoid redundant disabled state updates.
215 prev_color_mode = color_mode_dropdown.value
216
217 # Animation loop.
218 while True:
219 n = instance_count_slider.value
220
221 # Update props based on GUI controls.
222 mesh_handle.lod = "auto" if lod_checkbox.value else "off"
223 mesh_handle.cast_shadow = cast_shadow_checkbox.value
224
225 # Recreate transforms if instance count changed.
226 if positions.shape[0] != n:
227 positions, rotations, scales = create_grid_transforms(n)
228 positions_orig = positions.copy()
229 grid_size = int(np.ceil(np.sqrt(n)))
230
231 with server.atomic():
232 # Update grid size.
233 grid_handle.width = grid_handle.height = grid_size + 2
234 grid_handle.width_segments = grid_handle.height_segments = grid_size + 2
235
236 # Update all transforms.
237 mesh_handle.batched_positions = axes_handle.batched_positions = (
238 positions
239 )
240 mesh_handle.batched_wxyzs = axes_handle.batched_wxyzs = rotations
241 mesh_handle.batched_scales = axes_handle.batched_scales = scales
242
243 # Colors will be overwritten below; we'll just put them in a valid state.
244 mesh_handle.batched_colors = np.zeros(3, dtype=np.uint8)
245
246 # Generate colors based on current mode.
247 color_mode = color_mode_dropdown.value
248
249 # Update disabled state for color controls only when mode changes.
250 if color_mode != prev_color_mode:
251 per_instance_color_dropdown.disabled = color_mode != "Per-instance"
252 shared_color_rgb.disabled = color_mode != "Shared"
253 animated_color_dropdown.disabled = color_mode != "Animated"
254 prev_color_mode = color_mode
255
256 if color_mode == "Per-instance":
257 # Per-instance colors with different styles.
258 per_instance_style = per_instance_color_dropdown.value.lower()
259 colors = generate_per_instance_colors(
260 positions, color_mode=per_instance_style
261 )
262 elif color_mode == "Shared":
263 # Single shared color for all instances.
264 colors = generate_shared_color(shared_color_rgb.value)
265 elif color_mode == "Animated":
266 # Animated colors with time-based effects.
267 t = time.perf_counter()
268 animation_style = animated_color_dropdown.value.lower()
269 colors = generate_animated_colors(
270 positions, t, animation_mode=animation_style
271 )
272 else:
273 # Default fallback.
274 colors = generate_per_instance_colors(positions, color_mode="rainbow")
275
276 # Animate if enabled.
277 if animate_checkbox.value:
278 # Animate positions.
279 t = time.time() * 2.0
280 positions[:] = positions_orig
281 positions[:, 0] += np.cos(t * 0.5)
282 positions[:, 1] += np.sin(t * 0.5)
283
284 # Animate scales with wave effect.
285 if per_axis_scale_checkbox.value:
286 scales = np.linalg.norm(positions, axis=-1)
287 scales = np.stack(
288 [
289 np.sin(scales * 1.5) * 0.5 + 1.0,
290 np.sin(scales * 1.5 + np.pi / 2.0) * 0.5 + 1.0,
291 np.sin(scales * 1.5 + np.pi) * 0.5 + 1.0,
292 ],
293 axis=-1,
294 )
295 assert scales.shape == (n, 3)
296 else:
297 scales = np.linalg.norm(positions, axis=-1)
298 scales = np.sin(scales * 1.5 - t) * 0.5 + 1.0
299 assert scales.shape == (n,)
300
301 # Update colors for animated mode during animation.
302 if color_mode == "Animated":
303 animation_style = animated_color_dropdown.value.lower()
304 colors = generate_animated_colors(
305 positions, t, animation_mode=animation_style
306 )
307
308 # Update mesh properties.
309 with server.atomic():
310 mesh_handle.batched_positions = positions
311 mesh_handle.batched_scales = scales
312 mesh_handle.batched_colors = colors
313
314 axes_handle.batched_positions = positions
315 axes_handle.batched_scales = scales
316
317 time.sleep(1.0 / 60.0)
318
319
320if __name__ == "__main__":
321 main()