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.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

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