WebGPU notes / 06
WebGPU Mipmap Generation in Rust and wgpu
A detailed texture can shimmer when it shrinks into the distance. This Rust and wgpu example generates a complete mipmap chain on the GPU, then samples it on a rotating metal tunnel to explore texture minification, level of detail, and anisotropic filtering.
- Base size
- 1024²
- Mip levels
- 11
- Generation passes
- 10
Why a distant texture needs mipmaps
Near the camera, the tunnel’s seams and rivets cover many screen pixels. Farther away, many texture pixels, or texels, must fit into a single screen pixel. Sampling only the original image can make fine details flicker or form distracting patterns as the surface moves.
A mipmap chain stores progressively smaller, filtered versions of the same image. The GPU can sample a resolution suited to the area covered by each screen pixel, reducing aliasing. Unlike the independent images in the texture array example, these levels all describe one surface at different resolutions.
Here the camera stays in place while the tunnel rotates around its lengthwise axis. There are no mouse or keyboard controls. The application runs through sib::render, with the current sampler configuration shown in the overlay.
Allocate the mip chain in wgpu
The Rust example first creates a 1024 × 1024 RGBA8 image on the CPU. Its procedural pattern combines metal plates, seams, rivets, scratches, and deterministic noise. No texture image is downloaded.
The helper below calculates the number of levels, including the original image:
fn mip_level_count(width: u32, height: u32) -> u32 {
width.max(height).max(1).ilog2() + 1
}
For this square texture, level 0 is 1024 × 1024, level 1 is 512 × 512, and each following level halves both dimensions until level 10 reaches 1 × 1. That gives 11 mip levels in one TextureDimension::D2 allocation with one array layer.
The format is Rgba8Unorm, not an sRGB format. The texture combines three usage flags:
COPY_DSTallows the CPU image to be uploaded into level 0.RENDER_ATTACHMENTallows render passes to write the smaller levels.TEXTURE_BINDINGallows shaders to sample the texture during generation and tunnel rendering.
Allocating those levels does not generate their image content. queue.write_texture uploads only the base level, and generate_mipmaps fills the remaining ten.
Generate each level with a render pass
This example uses render passes for downsampling. For each level n from 1 through 10, it performs these steps:
- Create a source texture view with
base_mip_level: n - 1andmip_level_count: Some(1). - Create a destination view exposing only level
n, and attach it as the render pass’s color target. - Bind the source view and a sampler with linear minification and magnification filters.
- Draw a triangle covering the destination, then store the result for the next pass.
The views refer to separate mip subresources of the same texture. Restricting the source view to one level keeps the shader from sampling the level currently being written. The downsampling pipeline has its own bind group: source texture at binding 0 and sampler at binding 1.
The mipmap WGSL shader creates the fullscreen triangle from @builtin(vertex_index), without a vertex buffer. Its fragment stage is small:
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(source_texture, source_sampler, input.uv);
}
Only the previous mip is visible through source_texture, so this sample cannot choose another level of the underlying texture. Linear filtering combines neighboring texels while rendering into the smaller target.
There is also a coordinate detail in the vertex shader: 1.0 - uv.y * 2.0 converts UV Y, which points down, to Y in clip space, which points up. Omitting that flip would vertically mirror each generated level relative to its parent.
All ten passes are recorded in one command buffer and submitted once during initialization. A final texture view exposes the complete chain to the tunnel shader. The chain is reused each frame; rotating the tunnel or resizing the window does not regenerate it.
Compare the sampler configurations
The source creates three samplers sharing the same texture. All use Repeat addressing and linear minification and magnification filters. DEFAULT_SAMPLER_INDEX selects one at startup; there is no live mode selector in this demo.
Scroll sideways to see all table columns.
| Index and overlay label | Mip filter | LOD range | Anisotropy clamp |
|---|---|---|---|
| 0: No mip maps | Nearest | 0 only | 1 |
| 1: Mip maps (bilinear) | Linear | 0–10 | 1 |
| 2: Mip maps (anisotropic) | Linear | 0–10 | 16 |
Mode 0 clamps the maximum LOD to zero, so it samples only the original image. It still allocates and generates the entire mip chain; this mode changes sampling, not generation cost.
The overlay calls mode 1 “bilinear,” but its configuration is trilinear filtering: linear filtering within each 2D mip, plus linear blending between mip levels. The mipmap filter controls that second step.
Mode 2 is the default. It also sets anisotropy_clamp to 16, which helps preserve detail on surfaces viewed at oblique angles, such as the tunnel walls. This is an anisotropy limit, not a promise of exactly sixteen texture samples; the precise filtering behavior depends on the implementation. See the WebGPU sampler reference.
Sample the rotating tunnel in WGSL
The tunnel mesh repeats the texture six times around its circumference and about forty times along its length. Its normals point inward to support lighting from inside. The renderer makes one indexed draw for the tunnel each frame, followed by a separate text overlay pass.
For this draw, bind group 0 contains the uniform buffer at binding 0, the view of all mip levels at binding 1, and the selected sampler at binding 2. The tunnel WGSL shader samples it with:
let color = textureSampleBias(
texture_color, sampler_color, input.uv, input.lod_bias
);
textureSampleBias adjusts the automatically selected level of detail. The uniform bias is zero here, so it leaves that selection unchanged; zero bias does not force level 0. The sampler’s LOD clamps and filtering settings still apply.
The shader adds diffuse and specular lighting and an explicit distance_fade. The darkness toward the center therefore comes partly from shading, not just texture filtering. It outputs opaque alpha. The displayed FPS measures the whole demo, not the initial mipmap generation work.
Run and modify the example
From a local checkout with Rust installed, run the native version:
cargo run --example texturemipmapgen
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 texturemipmapgen
cargo run --bin serve
Open http://127.0.0.1:8080/texturemipmapgen/. The browser needs WebGPU support for live rendering; the article and screenshot remain readable without it. The texture is generated locally, so no runtime texture download is needed.
Try these source changes and rebuild:
- Set
DEFAULT_SAMPLER_INDEXto 0, 1, or 2 and compare distant detail while the tunnel rotates. - Change
TEXTURE_SIZEto 512. The mip count is recalculated automatically, giving ten levels instead of eleven. - Change the first value of the
lod_biasuniform to explore coarser sampling with a positive bias or finer sampling with a negative bias.