Texture and Tile Atlas Management in WebGPU

A raster basemap arrives as thousands of small images and has to become a small number of GPU objects, because a draw call that binds one texture per tile spends more time changing state than drawing. The classic answer is a texture atlas: pack many tiles into one large texture and index into it with UV offsets. WebGPU offers a better one for this specific shape of problem — a texture_2d_array, where every tile is its own layer of a single texture object, addressed by an integer index rather than by arithmetic on UV coordinates. This page covers when each is right, how tiles are uploaded into either, how mipmaps get generated when WebGPU has no generateMipmap call, which sampler settings keep tile seams from bleeding, and how a texture is evicted while the GPU may still be reading it. It assumes a device acquired per WebGPU device initialization for GIS workloads, and it sits alongside the buffer-side rules in memory alignment for spatial data buffers.

Prerequisites

  1. A device and queue in hand. Texture creation and writeTexture both go through the device acquired during device negotiation. Nothing on this page needs an optional feature, though texture-compression-bc changes the arithmetic considerably where it is available.
  2. A known tile size. Everything below assumes a fixed tile edge — 256 or 512 pixels — because a texture_2d_array requires every layer to share dimensions. Mixed tile sizes force an atlas instead.
  3. A tile cache with an eviction policy. Textures are the largest single consumer of VRAM in a raster map, so this page composes directly with VRAM budget management across tile zoom levels.
  4. Familiarity with bind groups. A texture is bound through a bind group alongside its sampler, and the layout is fixed at pipeline creation.

Array layers or an atlas?

The two approaches solve the same state-change problem and fail differently.

Property texture_2d_array Single-texture atlas
Addressing integer layer index UV offset + scale arithmetic
Tile sizes must be identical may vary
Mipmaps per layer, clean bleed across neighbours
Sampler wrap per layer, correct wraps into the neighbour
Layer count limit maxTextureArrayLayers (256 guaranteed) limited by maxTextureDimension2D
Partial update one layer, one writeTexture a sub-rectangle

The decisive rows are mipmaps and wrapping. In an atlas, a mip level averages texels across the packed boundary between two unrelated tiles, so a zoomed-out basemap shows a faint grid of wrong colours along every tile seam; the usual mitigation is a gutter of duplicated border pixels, which costs memory and still fails at the coarsest levels. Array layers have no such problem because each layer is an independent image. The cost is the layer limit: 256 layers is the spec-guaranteed floor, which covers a viewport comfortably but not a whole resident pyramid, so a real implementation keeps one array per zoom level or recycles layers under an eviction policy.

Array layers against a packed atlas A comparison of a texture 2D array and a single packed atlas across five properties. Addressing is an integer layer index for the array and UV offset arithmetic for the atlas. Tile sizes must be identical in the array and may vary in the atlas. Mipmaps are clean per layer in the array and bleed across neighbours in the atlas. Sampler wrapping is correct per layer in the array and wraps into the neighbouring tile in the atlas. The layer count is capped at the maxTextureArrayLayers limit, guaranteed to be at least 256, while the atlas is capped only by the maximum 2D texture dimension. ONE TEXTURE OBJECT, TWO WAYS texture_2d_array packed atlas Addressing integer layer index UV offset arithmetic Tile sizes must match may vary Mipmaps clean per layer bleed across seams Sampler wrap correct per layer wraps into neighbours Capacity 256 layers guaranteed one big 2D texture Mixed tile sizes are the one case that forces an atlas — everything else favours array layers.
The two failing rows are the ones that decide it for map data. Mip bleed and wrap-around both produce artefacts exactly at tile boundaries, which is where a reader looks when checking whether a basemap is correct.

Uploading a tile

queue.writeTexture is the whole upload path. It takes the destination — texture, mip level, and an origin whose z component is the array layer — a source ArrayBuffer with its bytesPerRow and rowsPerImage, and the extent to copy. The one rule that catches everyone is that bytesPerRow must be a multiple of 256 when the copy goes through a buffer; writeTexture from a typed array relaxes it, but staying aligned keeps the path identical if you later switch to copyBufferToTexture.

typescript
const TILE = 512;
const BYTES_PER_PIXEL = 4;                       // rgba8unorm

const tileArray = device.createTexture({
  label: "basemap-z14",
  size: { width: TILE, height: TILE, depthOrArrayLayers: 64 },
  format: "rgba8unorm",
  mipLevelCount: 1,                               // raised below once mips exist
  usage: GPUTextureUsage.TEXTURE_BINDING
       | GPUTextureUsage.COPY_DST
       | GPUTextureUsage.RENDER_ATTACHMENT,       // needed to generate mipmaps
});

function uploadTile(layer: number, pixels: Uint8Array): void {
  device.queue.writeTexture(
    { texture: tileArray, origin: { x: 0, y: 0, z: layer } },
    pixels,
    { bytesPerRow: TILE * BYTES_PER_PIXEL, rowsPerImage: TILE },
    { width: TILE, height: TILE, depthOrArrayLayers: 1 },
  );
}

RENDER_ATTACHMENT in the usage flags is not decoration. WebGPU has no generateMipmap, so mip levels are produced by rendering each level from the one above it, which means the texture has to be attachable as a render target. Adding the flag after the fact means recreating the texture, so it is worth declaring up front on anything that will ever be sampled at a distance.

The decode step deserves a note. createImageBitmap on a worker thread, followed by copyExternalImageToTexture, avoids ever materialising the pixels as a JavaScript array and is the faster path for PNG and JPEG basemaps. writeTexture from a typed array is the right path when the tile arrives as raw bytes — an elevation tile of f32 heights, or a data tile whose channels encode something other than colour.

Generating mipmaps

Without mip levels, a tile drawn smaller than its texel density aliases badly: a basemap zoomed out shows shimmering noise on every pan, because each fragment samples one texel out of many it covers. Generating them is a short render pass per level, and the loop below is the whole implementation.

typescript
function generateMipChain(
  device: GPUDevice,
  texture: GPUTexture,
  pipeline: GPURenderPipeline,     // full-screen triangle sampling mip N-1
  sampler: GPUSampler,
  layer: number,
  levels: number,
): void {
  const encoder = device.createCommandEncoder({ label: "mipchain" });
  for (let level = 1; level < levels; level++) {
    const src = texture.createView({
      dimension: "2d",
      baseMipLevel: level - 1, mipLevelCount: 1,
      baseArrayLayer: layer, arrayLayerCount: 1,
    });
    const dst = texture.createView({
      dimension: "2d",
      baseMipLevel: level, mipLevelCount: 1,
      baseArrayLayer: layer, arrayLayerCount: 1,
    });
    const pass = encoder.beginRenderPass({
      colorAttachments: [{ view: dst, 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: src }],
    }));
    pass.draw(3);                                  // one full-screen triangle
    pass.end();
  }
  device.queue.submit([encoder.finish()]);
}

Two details matter for map tiles specifically. Each level must be a separate view with mipLevelCount: 1, because a view that spans the level being written and the level being read is a read-write hazard the validator rejects. And the number of levels should stop short of 1×1: a basemap tile is never drawn at one texel, and the last two levels cost render passes for output nothing samples. Capping the chain at a 4×4 base saves two passes per tile across a whole pyramid.

Building a mip chain with render passes Four stages left to right. Level zero holds the uploaded 512 pixel tile. A render pass samples it and writes level one at 256 pixels. A second pass samples level one and writes level two at 128. The chain continues halving until it reaches a 4 pixel base, at which point it stops, because a basemap tile is never drawn small enough for the last two levels to be sampled. MIP CHAIN · ONE RENDER PASS PER LEVEL Level 0 512 px uploaded tile Level 1 256 px render pass from L0 Level 2 128 px render pass from L1 Stop at 4 px 7 levels total the last two are unused Each pass needs its own single-level view — a view spanning source and destination is rejected.
WebGPU has no generateMipmap call, so every level is a render pass that samples the level above. That makes the chain length a real cost rather than a free property of the texture.

Sampler configuration at tile seams

The sampler decides what happens at the edge of a tile, and for map data the defaults are wrong in a way that is easy to miss until someone looks closely at a boundary.

Setting Default What a map wants
addressModeU/V "clamp-to-edge" keep it — repeat wraps the far side of the tile into view
magFilter "nearest" "linear" for imagery, "nearest" for data tiles
minFilter "nearest" "linear"
mipmapFilter "nearest" "linear" — otherwise levels pop visibly during zoom
maxAnisotropy 1 4–16 for a tilted camera; costs bandwidth, not correctness

clamp-to-edge is the default and the right answer, but it is worth understanding why: with repeat, a fragment that samples fractionally past the right edge of a tile gets a texel from the tile’s own left edge, which on a coastline reads as a thin strip of sea drawn on land. Clamping instead duplicates the border texel, which is invisible.

Data tiles are the exception to the linear-filtering rule. A tile whose red channel encodes a class id, or whose channels pack an elevation as a fixed-point integer, must be sampled with "nearest" in every filter — interpolating between class 3 and class 7 produces class 5, which is a different land cover, and interpolating a packed integer produces a height that was never in the data. Where genuine interpolation of elevation is wanted, decode first into an r32float texture and sample that, which needs the float32-filterable feature negotiated at device creation.

Eviction without stalling

Textures are the largest objects a raster map holds, so eviction happens constantly, and texture.destroy() on a texture the GPU is still sampling is a use-after-free the validator will reject. The safe pattern is the same one buffers use: never destroy inside the frame that submitted work referencing the resource.

Concretely, each array layer carries a lastUsedSubmission counter. On eviction the layer is marked free but the texture object is untouched, and the layer is only handed to a new tile once the submission that last read it has retired. Because layers are recycled rather than destroyed, the texture object itself lives for the session and destroy() is called only when the whole array is retired at a zoom change — which is a rare, deliberate event rather than a per-frame one.

Recycling a layer instead of destroying a texture Two lanes compare destroying a texture with recycling an array layer. In the destroy arrangement the cache decides to evict, calls destroy immediately, and the next submission that still referenced the texture raises a validation error. In the recycle arrangement the cache marks the layer free and records the submission index that last read it, waits until that submission has retired, and only then writes a new tile into the same layer — no object is ever destroyed and nothing stalls. EVICTION · DESTROY vs RECYCLE Destroy Recycle Evict decision budget exceeded texture.destroy() immediately Validation error still referenced in flight Evict decision budget exceeded Mark the layer free record the submission index Reuse after it retires writeTexture into the layer Destroy the whole array only when a zoom level is retired — a deliberate, rare event.
Recycling turns eviction into bookkeeping. The texture object lives for the session, the layer index is the unit of allocation, and no code path ever has to reason about whether a destroy is safe this frame.

Memory and performance implications

A 512×512 rgba8unorm tile is exactly 1 MiB, and a full mip chain adds a third, so 1.33 MiB per resident tile is the number to budget with. Sixty-four layers is 85 MiB for one zoom level — comfortable — and three levels resident is 256 MiB, which is already the whole spec-guaranteed buffer allowance on a modest device. That arithmetic is why the eviction policy matters more than any sampling optimisation on this page.

Compression changes it substantially where available. texture-compression-bc (desktop) or texture-compression-etc2 (mobile) cuts a tile to a quarter or an eighth of its uncompressed size, at the cost of transcoding on the server or shipping pre-compressed tiles. Neither feature is universal, so a deployment that uses them needs the uncompressed path anyway, which makes them an optimisation rather than an architecture — and one to gate behind the capability record described under fallback routing.

Failure modes and diagnostics

  • GPUValidationError on writeTexture: bytesPerRow. The source layout’s bytesPerRow must be at least width × bytesPerPixel, and a multiple of 256 for buffer-sourced copies. Detection: the error names the parameter. Fix: pad each row, or copy from a typed array where the restriction is relaxed.
  • A tile renders as a diagonal smear. bytesPerRow disagrees with the actual row stride of the source, so each row starts a few bytes late and the image shears. This is the visual signature worth memorising, because the copy itself is valid and raises nothing.
  • Shimmering during pan, sharp when still. No mip levels, or mipmapFilter: "nearest". Generate the chain and set the filter to linear.
  • A faint grid along tile edges when zoomed out. Mip levels averaging across an atlas boundary. Move to array layers, or add a gutter and stop the mip chain before the levels that reach across it.
  • Destroyed texture used in a submit. A texture destroyed while a submitted command buffer still referenced it. Fix: defer the destroy behind queue.onSubmittedWorkDone(), or recycle layers instead of destroying textures.

Choosing a texture format

Format is decided before the first upload and cannot be changed afterwards, so it is worth a moment’s thought rather than defaulting to rgba8unorm everywhere.

For photographic imagery — satellite, aerial, hillshaded relief — rgba8unorm is correct and bgra8unorm is not: the latter exists because it is the canvas’s preferred swap-chain format on some platforms, not because it suits sampled textures. Where the tiles genuinely carry sRGB-encoded colour, rgba8unorm-srgb is the better choice, because it makes the hardware do the sRGB-to-linear conversion during the sample rather than leaving the shader to do it badly or, more often, not at all. The visible difference is in how imagery blends and how mip levels average, and it is largest exactly where a basemap is darkest.

For data tiles the calculus is different. An elevation tile stored as r32float samples directly as metres, needs the float32-filterable feature to interpolate, and costs four bytes per texel. The same data packed into three channels of an rgba8unorm tile — the widely used Terrarium and Mapbox terrain encodings both do this — costs the same four bytes, works everywhere with no optional feature, and must be sampled with nearest and decoded in the shader. The encoded form is the more portable answer; the float form is the more convenient one, and it is worth choosing deliberately rather than inheriting whichever the tile server happened to produce.

Single-channel formats are underused. A mask layer, a shadow map, or a per-tile coverage channel needs r8unorm, which is a quarter of the memory of an rgba8unorm tile carrying the same information three times over. At the scale a basemap operates on, that difference is tens of mebibytes.

Where the raster path meets the vector one

Most production maps draw both raster and vector data, and the two paths share a frame but almost nothing else. Vector tiles become buffers and go through the layout rules in memory alignment for spatial data buffers; raster tiles become textures and go through this page. Three points of contact are worth stating explicitly, because they are where the two halves disagree.

The first is the tile key. Both paths cache by the same (z, x, y) triple, and they should share one cache index even though the payloads differ, so that a viewport change invalidates both together. Two independent caches drift, and the symptom is a frame where labels have loaded for a tile whose imagery has not.

The second is the budget. Textures and buffers draw from the same VRAM pool, and a texture cache tuned in isolation will happily consume the allowance the vector path needed. Counting both in one budget — bytes, not objects — is what keeps the pressure visible; the accounting is the same one described under VRAM budget management across tile zoom levels.

The third is the draw order. Raster tiles are opaque and want to be drawn front to back so the depth test rejects hidden fragments early; vector overlays are usually translucent and must be drawn back to front for blending to be correct. That means the raster pass and the vector pass are genuinely separate passes with different sort orders, not two halves of one loop — and trying to merge them is a recurring source of both z-fighting and washed-out labels.

Finally, a note on when to stop optimising this path. Texture upload is bandwidth-bound and largely out of the application’s hands: the decode happens in the browser’s image pipeline, the copy happens in the driver, and neither responds much to how the calling code is arranged. The levers that do move it are choosing a smaller format, shipping fewer tiles by capping the resident zoom depth, and avoiding a re-upload of a tile that is already resident. Everything else on this page is about correctness at seams and safety at eviction, which matter because they are visible, not because they are slow.

Continue in this section