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.

One iteration of the chain, and its three rules Three ordered rules for each level of the chain. First create two single-level views, one for the level being read and one for the level being written, because a view that spans both is a read-write hazard the validator rejects. Second open a render pass whose colour attachment is the destination view and draw one full-screen triangle that samples the source with a linear filter. Third record every level into the same command encoder, so the ordering guarantee inside one command buffer resolves the dependency between consecutive levels without an explicit barrier. ONE LEVEL · THREE RULES 1 Two single-level views mipLevelCount: 1 on both an overlapping view is rejected 2 One full-screen triangle linear sampler averages 2×2 no vertex buffer needed 3 All levels, one encoder ordering resolves the chain no barrier of your own Building the chain per newly-arrived tile keeps the pass count per frame bounded.
The three rules are all consequences of the same fact: WebGPU will not let one pass read and write the same subresource, and it will order passes inside a command buffer for you. Work with both and the chain is nine lines.

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.

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

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

Memory and passes for one 512-pixel tile chain A bar chart showing what each mip level of a 512-pixel RGBA tile costs in kibibytes. Level zero is 1024 kibibytes. Level one is 256, level two 64, level three 16, level four 4, level five 1 and level six a quarter of a kibibyte. The levels below 64 pixels together account for under half a percent of the chain, which is why stopping the chain early saves render passes rather than memory. MIP LEVEL SIZE · KiB L0 · 512 px 1024 KiB L1 · 256 px 256 KiB L2 · 128 px 64 KiB L3 · 64 px 16 KiB L4 · 32 px 4 KiB L5 · 16 px 1 KiB 0 275 550 825 1100 KiB most of the chain worth having nearly free A full chain is 4/3 of the base level — the standard result, and it holds here.
The whole chain adds about a third to level zero, and almost all of that is level one. The tail levels cost nothing in memory and one render pass each, which is the resource actually worth counting.

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 with mipLevelCount: 1 and different baseMipLevels. A view created with the default (all levels) will always overlap.
  • Every level below zero is black. The texture lacks RENDER_ATTACHMENT usage, 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.
Mip chain failures and how each announces itself A table of four mip-chain failures with their signature and fix. A validation error naming the texture as both attachment and binding means the source and destination views overlap, fixed by giving each view a single mip level. Levels below zero rendering black means the texture lacks RENDER_ATTACHMENT usage, fixed at creation. Aliasing as bad as no chain means the downsample sampler is nearest rather than linear. A seam grid appearing when zoomed out means the chain was generated across an atlas boundary rather than per array layer. CHAIN FAILURE · SIGNATURE · FIX Signature Fix overlapping views validation error mipLevelCount: 1 all levels black only L0 samples add RENDER_ATTACHMENT aliases anyway shimmer on pan linear downsample filter seam grid zoomed out faint tile borders array layers, not an atlas Check a chain by drawing each level flat at the same size — a wrong level is obvious that way.
Only the first two fail loudly. The second pair render, look nearly right, and get attributed to the tile source far more often than to the chain that produced them.

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.

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