Generating Mipmaps for WebGPU Map Tiles
WebGL had generateMipmap; WebGPU deliberately does not. Every mip level of a map tile has to be produced by the application, by rendering a downsampled copy of the level above it into the level below, and the loop that does this has three constraints that are easy to get wrong: each pass needs its own single-level texture view, the destination has to be a render attachment, and the chain has to stop somewhere sensible. Get any of them wrong and the result is a validation error, a black tile, or a basemap that spends render passes producing levels nothing will ever sample. This page is the complete chain for a texture_2d_array of map tiles, the shader it needs, and the arithmetic for where to stop. It is one stage of texture and tile atlas management in WebGPU.
Runnable reference implementation
The downsample is a full-screen triangle sampling the previous level with a linear filter. Three vertices, no vertex buffer, no index buffer — the positions come from the vertex index.
// mipmap.wgsl — one full-screen triangle, bilinear downsample.
struct VSOut {
@builtin(position) pos : vec4<f32>,
@location(0) uv : vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) i : u32) -> VSOut {
// A single oversized triangle covers the viewport with no buffers.
let xy = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out : VSOut;
out.pos = vec4<f32>(xy * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(xy.x, 1.0 - xy.y);
return out;
}
@group(0) @binding(0) var src_sampler : sampler;
@group(0) @binding(1) var src_texture : texture_2d<f32>;
@fragment
fn fs(in : VSOut) -> @location(0) vec4<f32> {
// The sampler is linear, so one fetch already averages the 2x2 block.
return textureSample(src_texture, src_sampler, in.uv);
}
The host side walks the chain. Note that both views are created with mipLevelCount: 1 and a single array layer: a view spanning the level being read and the level being written is a read-write hazard, and the validator rejects it.
function buildMipChain(
device: GPUDevice,
texture: GPUTexture,
pipeline: GPURenderPipeline,
sampler: GPUSampler, // minFilter/magFilter: "linear"
layer: number,
): void {
const encoder = device.createCommandEncoder({ label: `mip-layer-${layer}` });
for (let level = 1; level < texture.mipLevelCount; level++) {
const source = texture.createView({
dimension: "2d",
baseMipLevel: level - 1, mipLevelCount: 1,
baseArrayLayer: layer, arrayLayerCount: 1,
});
const destination = texture.createView({
dimension: "2d",
baseMipLevel: level, mipLevelCount: 1,
baseArrayLayer: layer, arrayLayerCount: 1,
});
const pass = encoder.beginRenderPass({
label: `mip-${layer}-${level}`,
colorAttachments: [{
view: destination,
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0, g: 0, b: 0, a: 0 },
}],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: sampler },
{ binding: 1, resource: source },
],
}));
pass.draw(3);
pass.end();
}
device.queue.submit([encoder.finish()]);
}
All the passes go into one encoder and one submission. The ordering guarantee inside a command buffer is what makes that safe: level two’s pass reads level one, which level one’s pass wrote earlier in the same buffer, and WebGPU resolves that dependency without an explicit barrier — the same guarantee that makes the compute-to-render hand-off free in compute vs render pipeline fundamentals.
Parameter reference
| Value | Setting here | Guidance for map tiles |
|---|---|---|
mipLevelCount |
log2(TILE) - 1 |
For a 512-pixel tile that is 8 levels, stopping at 4×4. The 2×2 and 1×1 levels are never sampled by a basemap. |
Sampler minFilter |
"linear" |
The downsample relies on the hardware averaging a 2×2 block in one fetch. "nearest" produces a chain of point-sampled copies that alias exactly as badly as no chain at all. |
Destination loadOp |
"clear" |
The level is fully overwritten, so "load" only costs a read of undefined contents. |
Texture usage |
+ RENDER_ATTACHMENT |
Must be declared at creation. Adding it later means recreating the texture and re-uploading every layer. |
| Passes per layer | mipLevelCount - 1 |
7 for a 512-pixel tile. At 64 layers that is 448 passes if the whole array is rebuilt at once. |
That last row is the reason to build the chain per layer as tiles arrive, rather than rebuilding the array. A newly uploaded tile costs seven small passes; rebuilding sixty-four layers costs 448 and lands in one frame.
Where to stop the chain
The instinct is to run the chain down to 1×1, because that is what generateMipmap did. For map tiles it is wasted work, and the arithmetic says by how much.
A basemap tile is sampled at roughly one texel per screen pixel when the map is at its native zoom for that level. Zooming out one step halves that, which selects the next mip level; two steps down, the tile occupies 128 screen pixels. But a tile that would be drawn at 4×4 screen pixels is a tile the tile pyramid has already replaced with a coarser tile covering the same ground — that is what the pyramid is for. In practice levels below about 8×8 are selected only in the brief moment between a zoom-out gesture starting and the coarser tiles arriving.
Stopping at a 4×4 base costs two levels of the chain, which is two render passes out of nine per layer, and saves the memory those levels occupy — a trivial amount individually and about one part in a thousand across the array. The real saving is the passes: at sixty-four layers, dropping two levels removes 128 render passes from a full rebuild.
The opposite mistake is stopping too early. A chain that stops at 64×64 leaves the map aliasing as soon as the user zooms out two steps, and the shimmer is far more noticeable than any amount of saved work. Seven or eight levels for a 512-pixel tile is the range worth defaulting to.
Rebuilding versus building once
A chain built when a tile arrives is never rebuilt, because the tile’s pixels never change. That is worth stating because it removes a whole category of work people expect to need: there is no per-frame mip maintenance, no invalidation, and no reason for the chain code to be fast. It runs once per tile in the frame that tile lands, and then never again for the life of that layer.
The exception is a layer that gets recycled. When the cache hands layer 17 to a new tile, level zero is overwritten by the upload and every level below it still holds the previous tile’s downsampled pixels. Forgetting to rebuild produces one of the stranger artefacts in this area: a tile that is correct at full zoom and shows a completely different piece of the world when zoomed out. Rebuilding the chain is part of the layer hand-off, not part of the upload, and putting it in the recycle path rather than the fetch path is what keeps the two from drifting.
Failure modes
GPUValidationError: a texture is used as both attachment and binding. The source and destination views overlap. Both must be created withmipLevelCount: 1and differentbaseMipLevels. A view created with the default (all levels) will always overlap.- Every level below zero is black. The texture lacks
RENDER_ATTACHMENTusage, or the pass wrote to a view of the wrong layer. Detection: level zero samples correctly and every other level is the clear colour. - The chain aliases as badly as no chain. The sampler used for the downsample has
minFilter: "nearest", so each level is a point-sampled decimation rather than an average. This is separate from the sampler used to draw the tile, and both need to be linear. - A visible seam appears between tiles when zoomed out. The chain is being generated on an atlas rather than on array layers, so the downsample averaged across a packed boundary. Move to array layers.
- Frame hitches when several tiles arrive together. A full mip chain per tile, several tiles in one frame. Fix: bound the number of chains built per frame, exactly as the uploads themselves are bounded.
Backend / Python interop note
There is a server-side alternative worth knowing about: ship the mip levels rather than generating them. A tile endpoint can return a small container holding the 512, 256, 128 and 64-pixel versions of the same tile, produced once with Pillow or rasterio overviews, and the client uploads each into its own mip level with writeTexture and skips the render passes entirely.
from PIL import Image
def mip_chain(img: Image.Image, stop_at: int = 4) -> list[Image.Image]:
"""Downsample by halves with a proper filter until the edge hits stop_at."""
levels = [img]
w, h = img.size
while w > stop_at and h > stop_at:
w, h = max(w // 2, 1), max(h // 2, 1)
levels.append(levels[-1].resize((w, h), Image.LANCZOS))
return levels
Two things recommend it: a Lanczos downsample is visibly better than a bilinear one at the coarser levels, and the client does no render passes at all. Two things argue against it: the payload grows by a third, and every tile pays that cost even when the user never zooms out far enough to sample the levels. For a basemap served over a fast connection, generating on the client is usually the better trade; for a data layer whose downsampling has to be correct rather than merely smooth — a categorical raster where averaging classes is meaningless and the coarse level should hold the majority class — the server is the only place that computation can happen at all.
Related
- Texture and tile atlas management in WebGPU — the topic this page belongs to.
- Uploading raster tiles into a texture_2d_array — where level zero comes from.
- Sampler configuration for crisp vector tile labels — the sampler used to draw, as opposed to the one used to downsample.
- WebGPU compute vs render pipeline fundamentals — the ordering guarantee the chain depends on.
- Frame profiling with timestamp queries — how to see the chain’s cost in a frame.