WebGPU notes / 14
WebGPU Instancing in Rust: 2,048 Asteroids with wgpu
Generate one rocky mesh, pair it with a compact buffer of 2,048 transforms and texture layers, then animate the entire asteroid field in one indexed draw.
- Asteroids
- 2,048
- Rock draw calls
- 1
- Rock triangles
- 442,368
One mesh, thousands of asteroids
The glTF skinning example reused one mesh while changing its vertices with a joint palette. Instancing solves a different repetition problem: many objects share identical vertex and index data, but each object needs its own position, rotation, scale, and material choice.
The original instancing commit introduced the procedural asteroid field. The current Rust example retains its 2,048-instance design and WGSL shader, while adding the central planet draw, direct perspective projection, bounded animation angles, and safer render errors. It runs through sib::render with automatic motion and no mouse, keyboard, camera, or speed controls.
Generate the asteroid and planet meshes
No glTF, OBJ, or image file is loaded. At startup, sphere_mesh samples latitude and longitude rings and emits position, radial normal, UV, and RGB color attributes. MeshVertex stores those 11 floats in a 44-byte stride at shader locations 0 through 3.
The asteroid starts as a sphere with a 0.34-unit radius, 9 latitude segments, and 12 longitude segments. A deterministic hash displaces each vertex radius by up to 55%, producing a rough silhouette. The generator emits 130 vertices and 648 u32 indices, or 216 submitted triangles. Twenty-four pole triangles have zero area because each pole ring repeats the same position, leaving 192 nondegenerate template triangles.
The current scene also generates a smooth 2.4-unit planet with 32 latitude and 48 longitude segments. It uses 1,617 vertices and 9,216 indices, or 3,072 submitted triangles. Mesh generation and GPU upload happen once during initialization; neither mesh buffer changes while the scene animates.
Pack transforms into an instance buffer
InstanceData contains eight floats: a three-component position, three rotation angles, one uniform scale, and one texture-layer index stored as f32. Its 32-byte layout occupies shader locations 4 through 7 and uses VertexStepMode::Instance, so wgpu advances to the next record once per asteroid instead of once per mesh vertex.
A seeded linear congruential generator makes the layout reproducible. The first 1,024 records use radii from 4.5 to 12 units; the second 1,024 use 8 to 18 units. Both bands receive a random angle, a vertical offset between −0.25 and +0.25, three starting rotations, a varied scale, and one of six texture layers.
All records occupy one 65,536-byte static vertex buffer. The render command combines it with the shared rock buffers:
pass.set_vertex_buffer(0, rock_vertex_buffer.slice(..));
pass.set_vertex_buffer(1, instance_buffer.slice(..));
pass.set_index_buffer(rock_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..648, 0, 0..2048);
Without instancing, the renderer would need thousands of object draws or a much larger buffer containing duplicated mesh data. Here, one draw reuses the 130 template vertices while the second vertex stream supplies the object-specific values.
Generate and select texture-array layers
Rust creates six 256×256 rock images from gray, charcoal, brown, and pale palettes. Layered sine-based noise produces grain and cracks. The six RGBA images become one Rgba8UnormSrgb texture_2d_array, using 1,572,864 bytes of source pixel data. A separate one-layer array holds the procedurally generated lava texture for the planet.
Each asteroid's floating-point layer value is constant across its vertices. The fragment shader rounds it, clamps it to 0–5, converts it to i32, and samples that array layer:
let layer = i32(clamp(round(input.texture_layer), 0.0, 5.0));
let sample_color = textureSample(
texture_array,
texture_sampler,
input.uv * 2.0,
layer,
);
Both arrays have one mip level and use linear filtering with clamp-to-edge addressing. Because the rock shader doubles UVs without a repeating sampler, coordinates above 1 clamp to the border rather than tile. Changing the sampler to Repeat or generating mipmaps would be useful when studying texture detail at this scale.
Animate every instance in WGSL
The instance buffer remains static. Each frame, Rust writes one 160-byte uniform containing projection, view, light position, local rotation, global rotation, and elapsed time. The frame delta is capped at 1/15 second. Local rotation advances by 0.35 radians per second, global orbit by 0.08 radians per second, and both angles wrap at one full turn.
vs_rocks composes three local rotations from the starting instance angles and the shared local speed. It scales the template vertex, rotates the rock around itself, adds the instance position, then applies a shared Y rotation to orbit the field. Normals receive the same local and global rotations without translation:
let local_position = local_rotation
* (input.position * input.instance_scale);
let world_position = vec4<f32>(
global_rotation * (local_position + input.instance_position),
1.0,
);
The fragment shader combines the procedural texture and per-vertex color with 40% ambient light, Lambert diffuse light, and a conditional exponent-16 specular highlight. Animation cost is concentrated in vertex math and one small uniform upload; Rust does not loop over or rewrite all 2,048 instance transforms every frame.
Compose the star field, planet, and rocks
Three graphics pipelines share one bind-group layout and execute inside the same color-and-depth render pass:
Scroll sideways to see all table columns.
| Draw | Geometry | Draw calls | Instances | Depth state |
|---|---|---|---|---|
| Star field | Full-screen triangle from vertex_index | 1 | 1 | No writes; Always |
| Lava planet | 3,072 submitted triangles | 1 | 1 | Write; LessEqual |
| Asteroid field | 216 template triangles | 1 | 2,048 | Write; LessEqual |
The star pipeline needs no vertex buffer. Its vertex shader expands three vertices into a full-screen triangle, and the fragment shader hashes moving coordinates to reveal rare bright points above a dark blue background. It renders first without changing depth. The rotating lava planet and asteroid instances then write to a single-sampled Depth32Float target.
All three pipelines disable face culling and alpha blending. The planet shader samples its one-layer array and applies broad diffuse and specular lighting. The star field reuses the planet bind group even though its shader only reads the uniform time. A second color-load pass renders the diagnostic overlay after the scene pass.
Landscape windows use a fixed camera at (5.5, 17, 30); portrait windows step back to (5, 23, 40). Both look at the origin through a 60° perspective projection with near and far planes of 0.1 and 256. Resizing recreates depth, chooses the appropriate camera, and rebuilds overlay placement.
The overlay literally begins with Vulkan Example - Instanced mesh rendering
, then reports frame time, estimated FPS, GPU information, and Rendering 2048 instances
. Its milliseconds become 1000 / fps after the default sampling interval, so they are not GPU timestamp measurements.
Run and modify the example
From a local checkout with Rust installed, run the native example:
cargo run --example instancing
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release instancing
cargo run --bin serve
Open http://127.0.0.1:8080/instancing/ in a browser with WebGPU support. Geometry and textures are generated at startup, while WGSL and the Vazirmatn overlay font are embedded in the program, so the demo has no runtime model or image download. The article and screenshot remain readable without WebGPU.
Useful changes to try:
- Increase
INSTANCE_COUNT, measure frame time, and compare the cost of a larger static instance buffer with the unchanged rock draw count. - Move positions and velocities into storage buffers, then update or cull instances with a compute shader.
- Generate mipmaps, use repeating texture coordinates, or add a normal-map texture array for sharper rock detail.
- Split the field into visible clusters and issue indirect draws after frustum or occlusion culling.