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

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