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.0 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 - t * 3) * 0.5 + 0.5
89 colors = np.zeros((n, 3))
90 colors[:, 0] = wave # Red channel.
91 colors[:, 1] = np.sin(distances * 2 - t * 3 + np.pi / 3) * 0.5 + 0.5 # Green.
92 colors[:, 2] = (
93 np.sin(distances * 2 - t * 3 + 2 * np.pi / 3) * 0.5 + 0.5
94 ) # Blue.
95 return (colors * 255).astype(np.uint8)
96
97 elif animation_mode == "pulse":
98 # Pulsing color based on position.
99 pulse = np.sin(t * 2) * 0.5 + 0.5
100 colors = (np.cos(positions) * 0.5 + 0.5) * pulse
101 return (colors * 255).astype(np.uint8)
102
103 elif animation_mode == "cycle":
104 # Cycling through hues over time.
105 hue_shift = (t * 0.5) % 1.0
106 hues = (np.linspace(0, 1, n, endpoint=False) + hue_shift) % 1.0
107 colors = np.zeros((n, 3))
108 for i, hue in enumerate(hues):
109 # Convert HSV to RGB (simplified).
110 c = 1.0 # Saturation.
111 x = c * (1 - abs((hue * 6) % 2 - 1))
112
113 if hue < 1 / 6:
114 colors[i] = [c, x, 0]
115 elif hue < 2 / 6:
116 colors[i] = [x, c, 0]
117 elif hue < 3 / 6:
118 colors[i] = [0, c, x]
119 elif hue < 4 / 6:
120 colors[i] = [0, x, c]
121 elif hue < 5 / 6:
122 colors[i] = [x, 0, c]
123 else:
124 colors[i] = [c, 0, x]
125 return (colors * 255).astype(np.uint8)
126
127 else:
128 # Default to white.
129 return np.full((n, 3), 255, dtype=np.uint8)
130
131
132def main():
133 # Load and prepare mesh data.
134 dragon_mesh = trimesh.load_mesh(str(Path(__file__).parent / "../assets/dragon.obj"))
135 assert isinstance(dragon_mesh, trimesh.Trimesh)
136 dragon_mesh.apply_scale(0.005)
137 dragon_mesh.vertices -= dragon_mesh.centroid
138
139 dragon_mesh.apply_transform(
140 trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])
141 )
142 dragon_mesh.apply_translation(-dragon_mesh.centroid)
143
144 server = viser.ViserServer()
145 server.scene.configure_default_lights()
146 grid_handle = server.scene.add_grid(
147 name="grid",
148 width=12,
149 height=12,
150 width_segments=12,
151 height_segments=12,
152 )
153
154 # Add GUI controls.
155 instance_count_slider = server.gui.add_slider(
156 "# of instances", min=1, max=1000, step=1, initial_value=100
157 )
158
159 animate_checkbox = server.gui.add_checkbox("Animate", initial_value=True)
160 per_axis_scale_checkbox = server.gui.add_checkbox(
161 "Per-axis scale during animation", initial_value=True
162 )
163 lod_checkbox = server.gui.add_checkbox("Enable LOD", initial_value=True)
164 cast_shadow_checkbox = server.gui.add_checkbox("Cast shadow", initial_value=True)
165
166 # Color controls.
167 color_mode_dropdown = server.gui.add_dropdown(
168 "Color mode",
169 options=("Per-instance", "Shared", "Animated"),
170 initial_value="Per-instance",
171 )
172
173 # Per-instance color controls.
174 per_instance_color_dropdown = server.gui.add_dropdown(
175 "Per-instance style",
176 options=("Rainbow", "Position"),
177 initial_value="Rainbow",
178 )
179
180 # Shared color controls.
181 shared_color_rgb = server.gui.add_rgb("Shared color", initial_value=(255, 0, 255))
182
183 # Animated color controls.
184 animated_color_dropdown = server.gui.add_dropdown(
185 "Animation style",
186 options=("Wave", "Pulse", "Cycle"),
187 initial_value="Wave",
188 )
189
190 # Initialize transforms.
191 positions, rotations, scales = create_grid_transforms(instance_count_slider.value)
192 positions_orig = positions.copy()
193
194 # Create batched mesh visualization.
195 axes_handle = server.scene.add_batched_axes(
196 name="axes",
197 batched_positions=positions,
198 batched_wxyzs=rotations,
199 batched_scales=scales,
200 )
201
202 # Create initial colors based on default mode.
203 initial_colors = generate_per_instance_colors(positions, color_mode="rainbow")
204
205 mesh_handle = server.scene.add_batched_meshes_simple(
206 name="dragon",
207 vertices=dragon_mesh.vertices,
208 faces=dragon_mesh.faces,
209 batched_positions=positions,
210 batched_wxyzs=rotations,
211 batched_scales=scales,
212 batched_colors=initial_colors,
213 lod="auto",
214 )
215
216 # Track previous color mode to avoid redundant disabled state updates.
217 prev_color_mode = color_mode_dropdown.value
218
219 # Animation loop.
220 while True:
221 n = instance_count_slider.value
222
223 # Update props based on GUI controls.
224 mesh_handle.lod = "auto" if lod_checkbox.value else "off"
225 mesh_handle.cast_shadow = cast_shadow_checkbox.value
226
227 # Recreate transforms if instance count changed.
228 if positions.shape[0] != n:
229 positions, rotations, scales = create_grid_transforms(n)
230 positions_orig = positions.copy()
231 grid_size = int(np.ceil(np.sqrt(n)))
232
233 with server.atomic():
234 # Update grid size.
235 grid_handle.width = grid_handle.height = grid_size + 2
236 grid_handle.width_segments = grid_handle.height_segments = grid_size + 2
237
238 # Update all transforms.
239 mesh_handle.batched_positions = axes_handle.batched_positions = (
240 positions
241 )
242 mesh_handle.batched_wxyzs = axes_handle.batched_wxyzs = rotations
243 mesh_handle.batched_scales = axes_handle.batched_scales = scales
244
245 # Colors will be overwritten below; we'll just put them in a valid state.
246 mesh_handle.batched_colors = np.zeros(3, dtype=np.uint8)
247
248 # Generate colors based on current mode.
249 color_mode = color_mode_dropdown.value
250
251 # Update disabled state for color controls only when mode changes.
252 if color_mode != prev_color_mode:
253 per_instance_color_dropdown.disabled = color_mode != "Per-instance"
254 shared_color_rgb.disabled = color_mode != "Shared"
255 animated_color_dropdown.disabled = color_mode != "Animated"
256 prev_color_mode = color_mode
257
258 if color_mode == "Per-instance":
259 # Per-instance colors with different styles.
260 per_instance_style = per_instance_color_dropdown.value.lower()
261 colors = generate_per_instance_colors(
262 positions, color_mode=per_instance_style
263 )
264 elif color_mode == "Shared":
265 # Single shared color for all instances.
266 colors = generate_shared_color(shared_color_rgb.value)
267 elif color_mode == "Animated":
268 # Animated colors with time-based effects.
269 t = time.perf_counter()
270 animation_style = animated_color_dropdown.value.lower()
271 colors = generate_animated_colors(
272 positions, t, animation_mode=animation_style
273 )
274 else:
275 # Default fallback.
276 colors = generate_per_instance_colors(positions, color_mode="rainbow")
277
278 # Animate if enabled.
279 if animate_checkbox.value:
280 # Animate positions.
281 t = time.perf_counter() * 2.0
282 positions[:] = positions_orig
283 positions[:, 0] += np.cos(t * 0.5)
284 positions[:, 1] += np.sin(t * 0.5)
285
286 # Animate scales with wave effect.
287 if per_axis_scale_checkbox.value:
288 scales = np.linalg.norm(positions, axis=-1)
289 scales = np.stack(
290 [
291 np.sin(scales * 1.5 - t) * 0.5 + 1.0,
292 np.sin(scales * 1.5 - t + np.pi / 2.0) * 0.5 + 1.0,
293 np.sin(scales * 1.5 - t + np.pi) * 0.5 + 1.0,
294 ],
295 axis=-1,
296 )
297 assert scales.shape == (n, 3)
298 else:
299 scales = np.linalg.norm(positions, axis=-1)
300 scales = np.sin(scales * 1.5 - t) * 0.5 + 1.0
301 assert scales.shape == (n,)
302
303 # Update colors for animated mode during animation.
304 if color_mode == "Animated":
305 animation_style = animated_color_dropdown.value.lower()
306 colors = generate_animated_colors(
307 positions, t, animation_mode=animation_style
308 )
309
310 # Update mesh properties.
311 with server.atomic():
312 mesh_handle.batched_positions = positions
313 mesh_handle.batched_scales = scales
314 mesh_handle.batched_colors = colors
315
316 axes_handle.batched_positions = positions
317 axes_handle.batched_scales = scales
318
319 time.sleep(1.0 / 60.0)
320
321
322if __name__ == "__main__":
323 main()