Uploading Raster Tiles into a texture_2d_array
The exact sub-problem here is the hand-off between a fetched image and an array layer: a 512-pixel PNG arrives over the network, and a specific layer z of an existing texture_2d_array has to hold its pixels before the next frame draws, without decoding on the main thread and without a row-stride mistake that shears the image. There are two viable paths — copyExternalImageToTexture from an ImageBitmap, and writeTexture from raw bytes — and they suit different tile types. This page is the complete implementation of both, the rules that govern the copy parameters, and the small set of errors each path can raise. It is one stage of texture and tile atlas management in WebGPU.
Runnable reference implementation
The decode belongs off the main thread. createImageBitmap is available in workers, returns a transferable object, and does the PNG or JPEG work on a background thread; transferring the bitmap back costs no copy. Only the final copyExternalImageToTexture has to happen where the device lives.
// --- worker.ts: decode only, no GPU access ---
self.onmessage = async (e: MessageEvent<{ url: string; key: string }>) => {
const res = await fetch(e.data.url);
const blob = await res.blob();
// premultiplyAlpha "none" keeps the source values intact for data tiles;
// use "premultiply" for imagery that will be alpha-blended.
const bitmap = await createImageBitmap(blob, { premultiplyAlpha: "none" });
(self as unknown as Worker).postMessage({ key: e.data.key, bitmap }, [bitmap]);
};
// --- main thread: the copy into one array layer ---
const TILE = 512;
function uploadBitmapToLayer(
device: GPUDevice,
tileArray: GPUTexture,
layer: number,
bitmap: ImageBitmap,
): void {
if (bitmap.width !== TILE || bitmap.height !== TILE) {
throw new Error(`tile is ${bitmap.width}x${bitmap.height}, expected ${TILE}`);
}
device.queue.copyExternalImageToTexture(
{ source: bitmap, flipY: false },
{ texture: tileArray, origin: { x: 0, y: 0, z: layer }, mipLevel: 0 },
{ width: TILE, height: TILE, depthOrArrayLayers: 1 },
);
bitmap.close(); // release the decoded pixels immediately
}
For a tile whose bytes are not an image — an elevation grid of f32 heights, or a classification raster — writeTexture takes the typed array directly and the row stride becomes the caller’s responsibility.
function uploadRawToLayer(
device: GPUDevice,
tileArray: GPUTexture, // format: "r32float"
layer: number,
heights: Float32Array, // TILE * TILE values, row-major
): void {
const bytesPerRow = TILE * Float32Array.BYTES_PER_ELEMENT; // 2048
if (heights.length !== TILE * TILE) {
throw new Error(`expected ${TILE * TILE} samples, got ${heights.length}`);
}
device.queue.writeTexture(
{ texture: tileArray, origin: { x: 0, y: 0, z: layer }, mipLevel: 0 },
heights,
{ offset: 0, bytesPerRow, rowsPerImage: TILE },
{ width: TILE, height: TILE, depthOrArrayLayers: 1 },
);
}
bitmap.close() matters more than it looks. An ImageBitmap holds decoded pixels — a megabyte for a 512-pixel RGBA tile — outside the JavaScript heap, and leaving them to the garbage collector means a basemap pan can hold a few hundred megabytes of decoded images that the GPU already has its own copy of. Closing immediately after the copy releases them deterministically.
Parameter and configuration reference
| Parameter | Value here | Guidance for map tiles |
|---|---|---|
bytesPerRow |
width × bytesPerPixel |
Must be a multiple of 256 for buffer-sourced copies; writeTexture from a typed array relaxes it, but matching the rule keeps the two paths interchangeable. |
rowsPerImage |
tile height | Only meaningful when copying more than one layer at a time; set it anyway so a multi-layer copy is a one-line change. |
origin.z |
the array layer | This is the whole addressing scheme — x and y stay zero for a full-tile copy. |
mipLevel |
0 |
Higher levels are written by the mip chain, never by the upload. |
flipY |
false |
Web map tiles are already top-left origin; flipping produces a vertically mirrored basemap that looks plausible at a glance. |
premultiplyAlpha |
"none" for data, "premultiply" for imagery |
Premultiplying a data tile corrupts every channel wherever alpha is not 1. |
colorSpaceConversion |
"none" |
Prevents the browser applying an embedded ICC profile the shader does not expect. |
The bytesPerRow rule is the one worth internalising. It exists because the copy engine reads rows at an alignment the hardware likes, and a source whose rows are packed tighter than the declared stride is read at the wrong offsets. The symptom is not an error.
Where the upload sits in the frame
An upload is a queue operation, not a pass, and that distinction decides where it belongs. writeTexture and copyExternalImageToTexture are both issued on the queue directly rather than recorded into a command encoder, which means they are ordered against submissions but are not part of one. In practice the queue processes them in call order relative to the submits around them, so an upload issued before queue.submit() is visible to that submission and one issued after is not.
The consequence for a tile pipeline is that uploads should be drained at a single point each frame, before the encoder for that frame is finished. Scattering them through the code — one when a fetch resolves, another when a worker posts back, a third from a cache warm-up — makes the set of tiles a given frame can see depend on network timing, and produces the intermittent bug where a tile appears one frame later than its neighbours on slow connections but never on a fast one.
Draining also gives a natural place to bound the work. A pan that crosses several tile boundaries at once can resolve twenty tiles in a single frame, and twenty megabytes of copies in one frame is a visible hitch. Taking a fixed number per frame — four is a reasonable starting point at 512 pixels — spreads the cost across a few frames and, because the missing tiles simply render at the coarser zoom level below them, costs nothing a user notices.
Failure modes specific to this upload
- The tile renders as a diagonal smear.
bytesPerRowis larger or smaller than the true row stride of the source, so each successive row starts at the wrong offset and the image shears progressively. Detection: the shear angle is constant and the first row is correct. Fix: computebytesPerRowfrom the format’s bytes-per-texel rather than assuming four. GPUValidationError: Destination origin is out of range.origin.zis at or pastdepthOrArrayLayers. This is almost always an off-by-one in the layer allocator rather than a copy bug. Fix: assert the layer index against the texture’s declared layer count at the allocation site, where the error is attributable.- The basemap is upside down.
flipY: trueon tiles that already use a top-left origin. Web Mercator tile schemes are top-left; TMS schemes are bottom-left, and mixing the two in one basemap is the usual cause of a single flipped layer among correct ones. - Imagery looks washed out where it is translucent. Alpha was premultiplied twice — once by
createImageBitmapand once by the blend state. Pick one: premultiply at decode and use a premultiplied blend, or neither. - Memory climbs during a pan and never falls.
ImageBitmapobjects are not being closed. Detection: memory rises in steps of exactly one tile’s decoded size. Fix:close()immediately after the copy.
Backend / Python interop note
Where the tiles are produced by a Python service, two decisions on that side remove work from the browser entirely.
The first is to emit a fixed tile size and a fixed format for a whole layer, and to say so in the layer metadata. A client that has to branch on tile dimensions cannot use a texture_2d_array at all, and discovering a 256-pixel tile in a 512-pixel array at upload time is a runtime failure rather than a configuration one.
The second is to pre-encode elevation rather than shipping f32. Writing heights as three channels of an 8-bit RGB tile — the Terrarium encoding is height = (r * 256 + g + b / 256) - 32768 — quarters nothing on the wire, since f32 is also four bytes, but it removes the dependency on the float32-filterable optional feature and works on every adapter. With rasterio and numpy the encode is a handful of array operations:
import numpy as np
def encode_terrarium(heights: np.ndarray) -> np.ndarray:
"""heights: 2-D float32 array of metres -> (H, W, 3) uint8 tile."""
v = np.clip(heights + 32768.0, 0.0, 65535.99)
r = np.floor(v / 256.0)
g = np.floor(v - r * 256.0)
b = np.floor((v - np.floor(v)) * 256.0)
return np.stack([r, g, b], axis=-1).astype(np.uint8)
The client then samples with nearest, decodes in the shader with the inverse expression, and gets metres back — with the caveat that no interpolation is possible without decoding neighbouring texels manually, which is exactly the trade the r32float path avoids.
Related
- Texture and tile atlas management in WebGPU — the topic this page belongs to.
- Generating mipmaps for WebGPU map tiles — what happens to a layer after it is uploaded.
- Evicting tile textures without stalling the queue — how a layer becomes free again.
- VRAM budget management across tile zoom levels — how many layers you can afford.
- Initializing WebGPU devices for GIS workloads — where the device and its limits come from.