Syncing Cesium 3D Tiles with WebGPU Compute Buffers

The specific sub-problem here is the hand-off boundary: a Cesium 3D Tile arrives as a compressed binary blob (b3dm, i3dm, or pnts), and a WGSL compute pass needs to read its vertices as a correctly-strided storage buffer in the same animation frame, without a main-thread stall and without a read-after-write hazard against the previous frame’s results. Get the staging-buffer alignment wrong and the second vertex onward folds into garbage; submit the copy and the dispatch in the wrong order and the compute pass reads last frame’s bytes; forget the completion fence and you evict a tile on the CPU while the GPU is still transforming it. This page is the end-to-end contract for that hand-off — the buffer usage flags, the copy ordering, the WGSL LOD kernel, the queue-submission fence, and the Python packer that produces bytes the GPU can memcpy straight into a storage buffer. It is one stage of CesiumJS mapping pipeline optimization, and it assumes you have already acquired a device per WebGPU device initialization for GIS workloads.

Per-frame hand-off from a Python tile packer to a WebGPU compute pass A Python packer serialises Cesium tile vertices as little-endian vec4 f32 records and Zstd-compresses them, streaming the payload over HTTP/2 or WebSocket. On the browser the decoded bytes are written into a 256-byte-aligned MAP_WRITE staging buffer, copied via copyBufferToBuffer into a device-local STORAGE tile_vertices buffer of array vec4 f32. A WGSL compute pass at workgroup size 64, fed a transform uniform, reads tile_vertices and writes lod_indices into a double-buffered STORAGE output keyed on frame parity (lodA or lodB). The mandatory per-frame ordering is write transform, then copy, then dispatch, then submit, then fence on onSubmittedWorkDone before the CPU evicts the source tile. Python tile packer struct.pack("<f") → Zstd vec4<f32> records, little-endian HTTP/2 · WebSocket Browser — GPUDevice / GPUQueue Staging buffer MAP_WRITE | COPY_SRC 256-byte aligned copyBuffer tile_vertices STORAGE | COPY_DST array<vec4<f32>> device-local read each frame transform UBO mat4x4<f32> uniform WGSL compute pass @workgroup_size(64) ceil(V / 64) dispatches arrayLength guard on the tail lod_indices Double-buffered output lodA — even frames lodB — odd frames queue.submit + fence onSubmittedWorkDone() gate before tile eviction Per-frame ordering (must hold): 1 · write 2 · copy 3 · dispatch 4 · submit 5 · fence

Runnable reference implementation

The flow below is the whole hand-off for a single tile within one frame: stage the decoded payload, copy it into device-local storage, dispatch the LOD-selection kernel against a compute pipeline, then fence completion before the CPU touches the tile again. Every byte-offset choice is driven by WebGPU’s memory alignment rules for spatial data buffers.

typescript
// Cesium tile payload, already decoded to interleaved vec4<f32> positions.
// Positions are promoted to vec4 (xyz + 1.0 in w) so each record is exactly
// 16 bytes — the vec3<f32> 12-vs-16 trap is removed before it reaches the GPU.
const ALIGN = 256; // copyBufferToBuffer offset/size granularity
const align256 = (n: number): number => Math.ceil(n / ALIGN) * ALIGN;

interface TileBuffers {
  storage: GPUBuffer;   // device-local compute input
  lodA: GPUBuffer;      // double-buffered output (even frames)
  lodB: GPUBuffer;      // double-buffered output (odd frames)
  vertexCount: number;
}

function uploadTile(device: GPUDevice, payload: ArrayBuffer): GPUBuffer {
  // Staging buffer: CPU-writable, copy source only. Size rounded to 256 so the
  // subsequent copyBufferToBuffer size is legal on every adapter.
  const staging = device.createBuffer({
    size: align256(payload.byteLength),
    usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
    mappedAtCreation: true,
  });
  new Uint8Array(staging.getMappedRange()).set(new Uint8Array(payload));
  staging.unmap();

  // Device-local storage: the compute pass reads this, never the staging buffer.
  const storage = device.createBuffer({
    size: align256(payload.byteLength),
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });

  const enc = device.createCommandEncoder();
  enc.copyBufferToBuffer(staging, 0, storage, 0, storage.size);
  device.queue.submit([enc.finish()]);
  staging.destroy(); // freed once the copy is enqueued; queue retains the data
  return storage;
}

// Per-frame: write transform -> dispatch LOD pass -> fence before eviction.
async function syncFrame(
  device: GPUDevice,
  pipeline: GPUComputePipeline,
  tiles: TileBuffers,
  transform: Float32Array, // 16 f32, column-major mat4x4
  frameParity: number,
): Promise<GPUBuffer> {
  const lodOut = frameParity % 2 === 0 ? tiles.lodA : tiles.lodB;

  // Uniform write MUST be submitted before the dispatch that reads it.
  device.queue.writeBuffer(transformUBO, 0, transform);

  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: tiles.storage } },
      { binding: 1, resource: { buffer: lodOut } },
      { binding: 2, resource: { buffer: transformUBO } },
    ],
  });

  const enc = device.createCommandEncoder();
  const pass = enc.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(Math.ceil(tiles.vertexCount / 64));
  pass.end();
  device.queue.submit([enc.finish()]);

  // Fence: resolve only after the GPU has finished this submission, so the
  // caller can safely evict the source tile or recycle the staging slot.
  await device.queue.onSubmittedWorkDone();
  return lodOut;
}

The companion WGSL kernel reads the storage buffer as array<vec4<f32>>, transforms each position into the working frame, and writes a per-vertex LOD index. The explicit arrayLength guard is mandatory — WGSL does not clamp out-of-bounds storage reads, and a dispatch rounded up to a multiple of 64 always over-runs the real vertex count.

wgsl
@group(0) @binding(0) var<storage, read>       tile_vertices: array<vec4<f32>>;
@group(0) @binding(1) var<storage, read_write> lod_indices:   array<u32>;
@group(0) @binding(2) var<uniform>             transform:     mat4x4<f32>;

fn select_lod(view_pos: vec4<f32>) -> u32 {
    let dist = length(view_pos.xyz);
    if (dist < 100.0)  { return 0u; } // near: full-resolution tile
    if (dist < 500.0)  { return 1u; } // mid:  decimated mesh
    return 2u;                         // far:  imposter / billboard
}

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
    let i = gid.x;
    if (i >= arrayLength(&tile_vertices)) { return; } // guard the tail workgroup
    lod_indices[i] = select_lod(transform * tile_vertices[i]);
}

LOD distance bands are evaluated against the view-space position , and the dispatch count is workgroups for vertices at a @workgroup_size of 64.

Parameter and configuration reference

The per-frame ordering the hand-off depends on Five ordered operations inside one command encoder. First write the transform uniform, so the dispatch reads this frame’s camera. Second copy the newly decoded tile bytes from the staging buffer into the device-local storage buffer. Third dispatch the level-of-detail compute pass, which now reads both the fresh transform and the fresh vertices. Fourth submit the encoder. Fifth, and only after onSubmittedWorkDone resolves, allow the CPU to recycle the staging buffer and evict the source tile. PER-FRAME ORDER · ONE ENCODER 1 Write the transform uniform queue.writeBuffer this frame’s camera 2 Copy staging → storage copyBufferToBuffer 256-byte aligned offsets 3 Dispatch the LOD pass reads fresh transform + vertices ordering is guaranteed here 4 Submit one command buffer nothing has been read back 5 Recycle after the fence await onSubmittedWorkDone only now may the CPU evict If the staging buffer is recycled before the fence, the corruption appears in whichever tile arrived next.
Steps one to four are recorded, not executed, and their ordering is enforced by the queue. Step five is the only genuine synchronisation, and skipping it is what turns a tile eviction into corrupted geometry.
Value Where Default / used Spatial-workload guidance
ALIGN (copy granularity) align256 256 bytes Required granularity for copyBufferToBuffer offset and size. Round every staging and storage buffer up to it.
Vertex record stride host packer + WGSL 16 bytes (vec4<f32>) Promote vec3 positions to vec4; carry intensity/classification in w instead of wasting the pad.
@workgroup_size WGSL kernel 64 Safe portable value; a multiple of subgroup width on most adapters. Profile 64/128/256 for your device class.
Dispatch count dispatchWorkgroups ceil(vertexCount / 64) Must cover the tail; pair with the arrayLength guard or the last group corrupts memory.
LOD bands select_lod 100 / 500 m Tie to camera velocity and tile geometric error, not fixed metres, for steady frame pacing.
Output buffers lodA / lodB 2 (double-buffer) Prevents read-after-write between the frame producing indices and the frame consuming them.
maxStorageBufferBindingSize device limits 128 MiB A single tile’s vertex buffer must fit one binding; chunk oversize tiles into multiple dispatches.
maxBufferSize device limits 256 MiB Caps total tile residency; drives the eviction policy fenced by onSubmittedWorkDone.

Failure modes specific to this sub-topic

Why the copy offset has to be a multiple of 256 Two strips show a staging buffer region. In the first, three decoded tiles are packed back to back at their natural sizes, so the second tile starts at byte 336 and the third at 592 — neither is a multiple of 256, and copyBufferToBuffer rejects both offsets. In the second, each tile is padded up to the next 256-byte boundary, so the tiles begin at 0, 512 and 1024. The padding costs a few hundred bytes per tile and makes every copy legal. 256-BYTE UNIT 0 1 2 3 4 5 6 tile A tile B tile C Packed offsets rejected tile B starts at 336 and tile C at 592 — neither is a multiple of 256 tile A pad tile B pad tile C Padded offsets legal tiles begin at 0, 512 and 1024 — every copy offset is legal Round the slot size up once at allocation rather than checking alignment per copy.
The rounding waste is a few hundred bytes per tile and buys a guarantee: any tile can be copied from any slot without the host recomputing whether that particular offset happens to be legal.
  • Second-vertex corruption from a vec3 stride. Packing positions as 12-byte vec3<f32> while the shader reads array<vec4<f32>> shifts every record after the first; coordinates fold together with no validation error. Detection: geometry looks sheared, first vertex correct. Fix: pack vec4 records (16 bytes) on both sides, per the alignment reference.
  • Stale LOD indices (read-after-write hazard). Reusing a single output buffer means the render pass can sample indices the next compute pass is mid-overwrite. Detection: LOD popping or flicker correlated with camera motion. Fix: the lodA/lodB double-buffer keyed on frameParity, so the consumer reads last frame’s stable buffer.
  • Tile evicted while in flight (use-after-free). Freeing the source tile or staging slot before the dispatch completes invalidates bytes the GPU is still transforming. Detection: intermittent GPUValidationError or zeroed output under load. Fix: gate eviction on await device.queue.onSubmittedWorkDone() as in syncFrame.
  • Tail-workgroup over-read. A dispatch rounded up to ceil(V/64) invokes threads past the real vertex count; without the arrayLength guard WGSL reads out of bounds and writes garbage LOD slots. Detection: a handful of vertices at the buffer end carry wrong LOD. Fix: keep the if (i >= arrayLength(&tile_vertices)) { return; } early-out.
  • Device lost mid-stream. A driver reset or TDR timeout on an oversized tile dispatch invalidates every buffer and pipeline. Detection: device.lost resolves. Fix: re-acquire the device, re-upload resident tiles, and degrade through browser support and fallback routing strategies when re-acquisition fails.

Backend / Python interop note

The Python tile packer and the WGSL struct must agree byte-for-byte, or every fix above reappears on the wire. Pack positions as 16-byte vec4<f32> records (xyz + a 1.0 homogeneous lane, or a useful attribute), keep the same field order the shader binds, and pin little-endian so heterogeneous pre-serialization workers stay consistent. Compress with Zstd for transport, but the decompressed layout is what must match the storage buffer.

python
import struct
import zstandard as zstd

def pack_tile_payload(positions, w_lane):
    """Pack Cesium tile vertices as tightly strided vec4<f32> records.

    positions: flat list of floats, 3 per vertex (x, y, z in ECEF).
    w_lane:    per-vertex float carried in the 4th lane (intensity / class).
    Output decompresses to a byte-exact match for array<vec4<f32>> in WGSL.
    """
    n = len(positions) // 3
    assert len(w_lane) == n, "one w-lane value per vertex"
    flat = []
    for v in range(n):
        flat.extend(positions[v * 3 : v * 3 + 3])  # x, y, z
        flat.append(w_lane[v])                      # w -> vec4 lane
    raw = struct.pack(f"<{len(flat)}f", *flat)      # little-endian f32
    return zstd.ZstdCompressor(level=6).compress(raw)

When the source is GeoParquet or an Arrow RecordBatch, order the schema so the four lanes are contiguous float32 columns and convert with a zero-copy view rather than a per-column repack — the same byte-parity discipline that lets queue.writeBuffer or the staging copy land the upload without a host-side rebuild.

A final word on tile identity. Every buffer slot in the staging ring should carry the tile key it currently holds, not just its byte range, and the compute pass should read that key rather than assuming slot order matches load order. Tiles arrive out of order over HTTP/2 routinely, and a pipeline that assumes otherwise renders the right geometry in the wrong place — which is much harder to spot than rendering nothing at all.

Up: CesiumJS Mapping Pipeline Optimization