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
- A device and queue in hand. Texture creation and
writeTextureboth go through the device acquired during device negotiation. Nothing on this page needs an optional feature, thoughtexture-compression-bcchanges the arithmetic considerably where it is available. - A known tile size. Everything below assumes a fixed tile edge — 256 or 512 pixels — because a
texture_2d_arrayrequires every layer to share dimensions. Mixed tile sizes force an atlas instead. - 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.
- 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.
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.
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.
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.
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.
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
GPUValidationErroronwriteTexture: bytesPerRow. The source layout’sbytesPerRowmust be at leastwidth × 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.
bytesPerRowdisagrees 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 behindqueue.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
- Uploading raster tiles into a texture_2d_array — the copy path end to end, including the worker-side decode.
- Generating mipmaps for WebGPU map tiles — the render-pass chain, and where to stop it.
- Sampler configuration for crisp vector tile labels — filtering, anisotropy, and why label glyphs need different settings from imagery.
- Evicting tile textures without stalling the queue — layer recycling, fences, and the counter that makes it safe.
Related
- WebGPU architecture for spatial visualization — the section this topic belongs to.
- Memory alignment for spatial data buffers — the buffer-side counterpart to these texture rules.
- VRAM budget management across tile zoom levels — where the texture budget is actually spent.
- Coordinate precision and projection on the GPU — what decides where a tile is drawn, once it is resident.
- Browser support and fallback routing strategies — gating compressed-texture features on what the adapter reports.