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 rendering

  • viser.SceneApi.add_batched_axes() for coordinate frame instances

  • Per-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

Batched mesh rendering

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