Loading WebGPU example...
More info
Back to the demo ↑

WebGPU notes   /   15

WebGPU Indirect Draw in Rust: 24,576 Plants with wgpu

Load 12 plant meshes, repeat each 2,048 times, and let a 240-byte command buffer supply the indexed draw parameters for a 24,576-object WebGPU scene.

Plant objects
24,576
Indirect commands
12
Command buffer
240 B

Move draw arguments into a buffer

The WebGPU instancing example passes an index range and instance range directly to draw_indexed. Indirect drawing keeps the same indexed, instanced work, but moves those arguments into a GPU-readable buffer. A render pass selects a byte offset, and WebGPU reads the draw parameters from that record.

The original indirect draw commit introduced the complete jungle scene. The current Rust example, WGSL shader, and five scene assets keep the same rendering design. Later changes improved concurrent browser loading, error reporting, and projection setup. The demo runs through sib::render with a fixed camera and no keyboard, pointer, touch, or animation controls.

Dense WebGPU jungle with palms, red bromeliads, green ground cover, and an indirect-draw statistics overlay.
Twelve plant submeshes form a dense jungle of 24,576 objects. The overlay reports the exact 12 indirect plant draws used to submit them.

Load and flatten the plant assets

Before renderer initialization, the current asset loader reads five bundled resources. Native builds use worker threads; WebAssembly creates a worker and fetches the same five URLs concurrently with Promise.all. The glTF files embed their binary buffers as Base64 data URIs, so they do not trigger additional requests.

Scroll sideways to see all table columns.

Indirect draw runtime assets
AssetFile sizeRuntime role
plants.gltf502,730 bytes12 nodes, meshes, primitives, and materials
plane_circle.gltf10,876 bytes25-unit ground disc
sphere.gltf141,450 bytesInside-facing gradient sky
texturearray_plants_rgba.ktx16,777,336 bytes12 foliage layers with 10 mip levels
ground_dry_rgba.ktx1,398,236 bytesRepeating ground texture with 10 mip levels

The five files total 18,830,628 bytes, about 17.96 MiB. The plant loader takes the first triangle primitive from each scene node, applies the node transform, flips the Y axis and UV V coordinate, and folds vertex color together with the material base-color factor. It appends everything to one 9,372-vertex, 26,610-index mesh while retaining 12 index ranges. Those ranges contain 8,870 template triangles in total.

The same loader flattens every primitive from the ground and sky files. The ground contributes 191 vertices and 192 indices, or 64 triangles. The sphere contributes 2,399 vertices and 13,536 indices, or 4,512 triangles. Every GPU mesh uses a 44-byte vertex record containing position, normal, UV, and RGB color at shader locations 0 through 3.

Build 12 groups of plant instances

Each plant type receives 2,048 records. InstanceData stores a three-component position, three rotation values, one scale, and a signed i32 texture layer in a 32-byte stride. Shader locations 4 through 7 use VertexStepMode::Instance, so one record applies to every vertex of one plant.

A deterministic linear congruential generator starts from 0x1d1e_c7a5. It places every instance on the XZ plane inside a 25-unit radius, chooses a scale from 1 to 3, and supplies a Y rotation from 0 to π. Each group stores its draw index from 0 through 11 as the texture layer. The 12 contiguous groups contain 24,576 records and occupy a static 786,432-byte vertex buffer.

The current vertex shader scales a plant, adds its instance position, and then applies the transposed rotation matrix. That ordering rotates the translated position around the origin as well as orienting the mesh. The buffer never changes after initialization, so the jungle remains still while only the FPS overlay is refreshed.

Encode indexed indirect commands

WebGPU defines five values for an indexed indirect draw. The Rust structure mirrors their exact 20-byte memory layout:

#[repr(C)]
struct DrawIndexedIndirectCommand {
    index_count: u32,
    instance_count: u32,
    first_index: u32,
    base_vertex: i32,
    first_instance: u32,
}

Initialization creates one record for each plant index range. index_count and first_index select that species inside the concatenated mesh; instance_count is always 2,048; base_vertex and first_instance are zero. Twelve records produce a 240-byte buffer with INDIRECT | COPY_DST usage.

The COPY_DST flag would allow later command updates, but this example never rewrites the buffer. Its commands are generated by Rust once, not by a compute shader, visibility pass, or GPU counter.

Issue the 12 indirect draws

The render pass binds the shared plant mesh once. For each plant type, it moves the instance stream to that type's 65,536-byte group and advances the indirect-command offset by 20 bytes:

for draw_index in 0..draw_count {
    pass.set_vertex_buffer(
        1,
        instance_buffer.slice(draw_index as u64 * instance_group_stride..),
    );
    pass.draw_indexed_indirect(
        indirect_buffer,
        draw_index as u64 * indirect_stride,
    );
}

This is 12 indirect API calls from a CPU loop, not one multi-draw command. Indirection changes where each call reads its draw arguments; it does not reduce this example to one plant draw. Across all species, the GPU consumes 54,497,280 index references and submits 18,165,760 plant triangles per frame.

The design is a useful starting point for GPU-driven rendering because a compute pass could compact visible instances and update each command's instance_count. The current example performs no frustum culling, occlusion culling, level-of-detail selection, compaction, or indirect-count execution. All 24,576 plants are submitted every frame.

Texture and light alpha-cutout foliage

texturearray_plants_rgba.ktx is an uncompressed KTX1 texture with 12 512×512 RGBA layers and a complete 10-level mip chain down to 1×1. Rust validates the header and copies all 16,777,200 texel bytes into an Rgba8Unorm texture_2d_array. It is linear rather than sRGB, uses linear minification and magnification, selects mip levels with nearest filtering, and clamps at the layer edges.

The signed layer stored in each instance selects the corresponding plant image. The fragment shader discards texels whose alpha is below 0.5, then combines 65% ambient light with Lambert diffuse lighting from (0, -5, 0). It writes opaque color without blending. Plant culling is disabled so thin, alpha-cutout leaves remain visible from both sides.

let layer = i32(input.uv_layer.z);
let color = textureSample(
    plants_texture,
    plants_sampler,
    input.uv_layer.xy,
    layer,
);

if (color.a < 0.5) {
    discard;
}

Compose the ground, sky, and depth pass

Three pipelines share one bind group containing a 128-byte projection/view uniform, the plant texture array and sampler, plus the ground texture and sampler. The scene uses triangle lists, one sample, no color blending, and a Depth32Float attachment:

Scroll sideways to see all table columns.

Indirect draw scene pipelines
PipelineSubmissionTrianglesDepth writeCull mode
Sky sphere1 direct indexed draw4,512NoFront
Ground disc1 direct indexed draw64YesBack
Plant field12 indexed indirect draws18,165,760YesNone

The sky renders first from inside its front-face-culled sphere and removes translation from the view matrix, keeping the gradient centered on the camera. The circular ground repeats its single-layer 512×512 KTX texture 32 times across its UVs. Ground and plants then write depth with LessEqual. Together, the scene submits 18,170,336 triangles in 14 draw calls before a second color-load pass composites the text overlay.

The fixed camera uses a 60° perspective projection with near and far planes of 0.1 and 512, view rotations of −12° around X and 159° around Y, and a translation of (0.4, 1.25, 0). Resizing recreates depth and rewrites only the projection/view uniform. There is no scene animation; the regular update path changes the overlay's GPU name, estimated FPS, object count, and indirect-draw count. The FPS value comes from CPU frame sampling rather than a GPU timestamp query.

Run and extend the example

From a local checkout with Rust installed, run the native example:

cargo run --example indirectdraw

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 indirectdraw
cargo run --bin serve

Open http://127.0.0.1:8080/indirectdraw/ in a browser with WebGPU support. The page downloads the five model and texture files described above. WGSL and the Vazirmatn overlay font are compiled into the WebAssembly binary, while the article and screenshot remain readable without WebGPU.

Useful changes to try:

  • Add a compute pass that tests plant bounds against the camera frustum, compacts visible instances, and writes the 12 indirect command counts.
  • Partition the field into spatial cells so occlusion or distance tests can skip groups instead of submitting every plant.
  • Replace the projected spherical placement formula with an area-uniform disc distribution and compare density near the center and edge.
  • Add wind animation, normal-aware foliage lighting, or alpha-to-coverage multisampling while keeping the indirect submission path.