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.

Two upload paths, chosen by what the tile carries A four-stage flow. A fetched tile is decoded either by createImageBitmap on a worker, for PNG and JPEG imagery, or read as raw bytes for data tiles such as elevation grids. The imagery path reaches the GPU through copyExternalImageToTexture, which never materialises pixels in JavaScript. The raw path reaches it through writeTexture, where the caller supplies bytesPerRow and rowsPerImage. Both write into one layer of the same texture 2D array, addressed by the z component of the copy origin. FETCHED TILE → ARRAY LAYER Fetch one tile, one request Decode createImageBitmap on a worker or raw bytes for data tiles Copy copyExternalImageToTexture or writeTexture Layer z origin.z is the address mipLevel 0 The decode is the only stage that benefits from a worker — the copy has to happen where the device is.
Imagery and data tiles diverge at the decode and converge again at the layer. Keeping both paths writing into the same array is what lets one bind group serve a basemap and an elevation layer without a second texture object.

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.

typescript
// --- 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]);
};
typescript
// --- 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.

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

What bytesPerRow describes, and how a mismatch shears Two strips show the first three rows of a tile as they sit in the source buffer. In the correct case the declared bytesPerRow matches the true row stride, so row one begins exactly where row zero ends and every row starts at the left edge of the image. In the mismatched case the declared stride is eight bytes longer than the true one, so row one begins eight bytes early, row two sixteen bytes early, and the image shears progressively further left with each row. 64-BYTE BLOCK 0 4 8 12 16 20 24 row 0 row 1 row 2 Correct stride matches each row starts where the previous one ended — the image is square row 0 off row 1 off row 2 Mismatched stride too long each row starts a little early — the error accumulates and the image shears Derive the stride from the format rather than assuming four bytes per texel.
A stride mismatch raises nothing. The copy is legal, the bytes are read, and the result is a picture that leans — which is why the shear is worth recognising on sight rather than debugging from first principles.

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. bytesPerRow is 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: compute bytesPerRow from the format’s bytes-per-texel rather than assuming four.
  • GPUValidationError: Destination origin is out of range. origin.z is at or past depthOrArrayLayers. 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: true on 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 createImageBitmap and 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. ImageBitmap objects are not being closed. Detection: memory rises in steps of exactly one tile’s decoded size. Fix: close() immediately after the copy.
Upload symptoms and where each one comes from A table of four upload symptoms with their cause and fix. A diagonal smear comes from a bytesPerRow that disagrees with the source stride, fixed by computing it from the format. An out-of-range validation error comes from a layer index past the array depth, fixed by asserting at the allocator. An upside-down basemap comes from flipY set on tiles that already use a top-left origin, fixed by turning it off. Memory that climbs during a pan comes from ImageBitmap objects that are never closed, fixed by closing after the copy. UPLOAD SYMPTOM · CAUSE · FIX Cause Fix diagonal smear stride mismatch derive from the format validation: out of range layer past the depth assert at the allocator basemap upside down flipY on a top-left scheme flipY: false memory climbs on pan bitmaps never closed bitmap.close() All four are visible within one pan of a fresh basemap — pan before shipping.
Only the second of these raises an error. The other three produce a map that renders, which is why each is worth recognising by its signature rather than waiting for the console to say something.

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:

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