Point-Cloud LOD Compaction with Stream Compaction
Rendering a hundred-million-point LiDAR cloud at every zoom level wastes bandwidth: most points project to the same pixel and cost fill rate for nothing. The fix is a level-of-detail subset — keep one point per screen-space cell, or every -th point along a Morton curve — but that subset has to be dense in a buffer for the draw to be efficient, and it must be rebuilt whenever the view changes. Stream compaction is the primitive that turns a sparse per-point keep-flag into a gap-free survivor buffer in two GPU passes: an exclusive prefix sum assigns each survivor a unique output slot, then a scatter writes it there. Unlike an atomic-counter cull, the prefix-sum scatter is race-free and order-preserving, so the thinned cloud keeps its spatial ordering and stays coherent for a following pass. This page gives the complete WGSL and TypeScript, the order-preserving counterpart to the atomic compaction in on-GPU viewport culling for vector tiles and a concrete case of the compaction skeleton in WGSL spatial algorithms on the GPU.
The core is the exclusive prefix sum. Given a flag array where marks whether point survives, the output slot for a survivor is the count of survivors strictly before it:
and the total survivor count is . Because each survivor’s offset is unique, the scatter dst[offset_i] = src[i] never collides — no atomic is needed once the scan is correct.
Runnable reference implementation
Three WGSL entry points. flag_lod marks survivors: it keeps the first point that lands in each screen-space cell, which decimates dense regions while preserving sparse detail. scan_block is the work-efficient Blelloch prefix sum over a block, emitting per-point offsets and a per-block total. scatter_lod then adds each block’s base (itself the prefix over block totals) and writes survivors densely.
// ---- Pass 1: flag one survivor per screen-space cell ----
@group(0) @binding(0) var<storage, read> points: array<vec4<f32>>; // xyz + intensity
@group(0) @binding(1) var<storage, read_write> flags: array<u32>;
@group(0) @binding(2) var<storage, read_write> occupied: array<atomic<u32>>; // one flag per cell
@group(0) @binding(3) var<uniform> lod: vec4<f32>; // min_x,min_y,span,cells
@compute @workgroup_size(256)
fn flag_lod(@builtin(global_invocation_id) gid: vec3<u32>) {
let i = gid.x;
if (i >= arrayLength(&points)) { return; } // guard the ragged tail
let p = points[i].xy;
let cells = u32(lod.w);
let n = (p - lod.xy) / lod.z; // normalize to [0,1)
if (n.x < 0.0 || n.x >= 1.0 || n.y < 0.0 || n.y >= 1.0) { flags[i] = 0u; return; }
let cx = u32(n.x * f32(cells));
let cy = u32(n.y * f32(cells));
let cell = cy * cells + cx;
// First writer of this cell wins; the rest are decimated. exchange returns the old value.
let prev = atomicExchange(&occupied[cell], 1u);
flags[i] = select(1u, 0u, prev != 0u); // 1 only if this thread claimed the cell
}
// ---- Pass 2: exclusive Blelloch scan over a 512-element block ----
const BLOCK: u32 = 256u; // 2*BLOCK = 512 elements per block
var<workgroup> temp: array<u32, BLOCK * 2u>;
@group(0) @binding(0) var<storage, read> flags: array<u32>;
@group(0) @binding(1) var<storage, read_write> offsets: array<u32>;
@group(0) @binding(2) var<storage, read_write> blockTotal: array<u32>;
@compute @workgroup_size(BLOCK)
fn scan_block(@builtin(local_invocation_index) lid: u32,
@builtin(workgroup_id) wg: vec3<u32>) {
let base = wg.x * BLOCK * 2u;
temp[2u * lid] = flags[base + 2u * lid];
temp[2u * lid + 1u] = flags[base + 2u * lid + 1u];
var off = 1u;
for (var d = BLOCK; d > 0u; d >>= 1u) { // up-sweep
workgroupBarrier();
if (lid < d) {
let ai = off * (2u * lid + 1u) - 1u;
let bi = off * (2u * lid + 2u) - 1u;
temp[bi] += temp[ai];
}
off <<= 1u;
}
if (lid == 0u) {
blockTotal[wg.x] = temp[BLOCK * 2u - 1u]; // this block's survivor count
temp[BLOCK * 2u - 1u] = 0u; // seed the down-sweep
}
for (var d = 1u; d < BLOCK * 2u; d <<= 1u) { // down-sweep
off >>= 1u;
workgroupBarrier();
if (lid < d) {
let ai = off * (2u * lid + 1u) - 1u;
let bi = off * (2u * lid + 2u) - 1u;
let t = temp[ai];
temp[ai] = temp[bi];
temp[bi] += t;
}
}
workgroupBarrier();
offsets[base + 2u * lid] = temp[2u * lid]; // exclusive per-point offset
offsets[base + 2u * lid + 1u] = temp[2u * lid + 1u];
}
// ---- Pass 3: scatter survivors into a dense, order-preserving buffer ----
@group(0) @binding(0) var<storage, read> flags: array<u32>;
@group(0) @binding(1) var<storage, read> offsets: array<u32>;
@group(0) @binding(2) var<storage, read> blockBase: array<u32>; // prefix over blockTotal
@group(0) @binding(3) var<storage, read> points: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read_write> lodPoints: array<vec4<f32>>;
const BLOCK: u32 = 256u;
@compute @workgroup_size(BLOCK * 2u)
fn scatter_lod(@builtin(global_invocation_id) gid: vec3<u32>) {
let i = gid.x;
if (i >= arrayLength(&flags)) { return; }
if (flags[i] == 0u) { return; } // decimated point
let slot = blockBase[i / (BLOCK * 2u)] + offsets[i]; // unique, collision-free slot
lodPoints[slot] = points[i]; // preserves input order
}
The TypeScript driver clears the per-cell occupancy grid each frame, runs flag, scan, a short prefix over the block totals, then scatter. The blockBase prefix is a single small scan over blockTotal — for up to a few thousand blocks a one-workgroup scan covers it.
function buildLod(
device: GPUDevice,
pipelines: {
flag: GPUComputePipeline; scan: GPUComputePipeline;
blockScan: GPUComputePipeline; scatter: GPUComputePipeline;
},
binds: {
flag: GPUBindGroup; scan: GPUBindGroup;
blockScan: GPUBindGroup; scatter: GPUBindGroup;
},
occupied: GPUBuffer,
lod: Float32Array, // [min_x, min_y, span, cellsPerAxis]
lodUbo: GPUBuffer,
pointCount: number,
): void {
device.queue.writeBuffer(lodUbo, 0, lod);
const groups = Math.ceil(pointCount / 512); // 512 elements per scan block
const enc = device.createCommandEncoder();
enc.clearBuffer(occupied); // reset cell claims; stale claims drop points
const p1 = enc.beginComputePass();
p1.setPipeline(pipelines.flag); p1.setBindGroup(0, binds.flag);
p1.dispatchWorkgroups(Math.ceil(pointCount / 256)); p1.end();
const p2 = enc.beginComputePass();
p2.setPipeline(pipelines.scan); p2.setBindGroup(0, binds.scan);
p2.dispatchWorkgroups(groups); p2.end(); // per-block offsets + block totals
const p3 = enc.beginComputePass();
p3.setPipeline(pipelines.blockScan); p3.setBindGroup(0, binds.blockScan);
p3.dispatchWorkgroups(1); p3.end(); // prefix over block totals → blockBase
const p4 = enc.beginComputePass();
p4.setPipeline(pipelines.scatter); p4.setBindGroup(0, binds.scatter);
p4.dispatchWorkgroups(groups); p4.end(); // dense, ordered survivors
device.queue.submit([enc.finish()]);
// lodPoints is now bindable as vertex data — no readback of the survivor count.
}
Parameter reference
Every tunable value in the implementation above. These tables scroll horizontally on narrow viewports.
| Parameter | Typical value | Guidance |
|---|---|---|
cellsPerAxis |
256–2048 | Screen-space decimation grid. More cells keep more points; match it to viewport pixels over point radius. |
BLOCK |
256 (512 elements) | Scan block half-width. Must be a power of two; the shared temp array is 2 × BLOCK u32 = 2 KiB. |
occupied size |
cellsPerAxis² u32 |
One atomic flag per cell. A 1024² grid is 4 MiB and independent of point count. |
@workgroup_size (scan) |
256 | Fixed by BLOCK; the scatter uses 2 × BLOCK = 512 to cover a full block per workgroup. |
point stride |
16 bytes | vec4<f32> (xyz + intensity). Keep it 16-byte aligned; a vec3 payload still pads to 16. |
blockBase scan |
1 workgroup | Valid while blocks ≤ 2 × workgroup_size; beyond that, recurse the block scan. |
lodPoints capacity |
≈ cellsPerAxis² |
At most one survivor per cell, so the output never exceeds the cell count. |
Failure modes
- Colliding offsets, lost points. A
scan_blockmissing aworkgroupBarrier()between up-sweep and down-sweep emits repeated offsets, so two survivors scatter to the same slot and one is overwritten. Detection:lodPointshas duplicates and stale entries even though the survivor count looks right. Fix: keep both barrier phases; verify the exclusive scan of[1,0,1,1]gives[0,1,1,2]. - Dropped survivors past the first block. Skipping the
blockBaseprefix, or scanning more than2 × workgroup_sizein one workgroup, leaves every block writing from offset 0, so blocks overwrite each other. Detection: only the first ~512 survivors appear. Fix: run the block-totals prefix (Pass 3) and addblockBase[block]in the scatter. - Stale decimation across frames. Not clearing
occupiedleaves last frame’s cell claims, so every cell reads as already taken andflag_lodkeeps nothing. Detection: the thinned cloud is empty after the first frame. Fix:clearBuffer(occupied)before the flag pass. - Device lost on a huge scan. A single dispatch over a hundred million points can exceed
maxComputeWorkgroupsPerDimensionor trip the TDR watchdog. Detection:device.lostresolves, or the dispatch is silently clamped. Fix: cap points per dispatch and recover via the browser support fallback routing strategies; size dispatches against the limits negotiated during WebGPU device initialization for GIS workloads.
Backend / Python interop note
The point payload must arrive as a contiguous vec4<f32> array so the scatter writes and the downstream draw read the same 16-byte stride; a packed xyz-only vec3 layout drifts off alignment and corrupts the compacted output, the failure the memory alignment for spatial data buffers reference details. Pre-sort the cloud into Morton order server-side so that even before compaction, points that land in the same screen cell are contiguous in the buffer — the flag_lod first-writer rule then keeps a spatially representative point per cell rather than an arbitrary one, and the resulting subset is coherent for streaming. Cap each streamed batch to the reserved staging size using the ring-buffer discipline in reducing GPU memory fragmentation during spatial aggregation.
Related
- WGSL Spatial Algorithms on the GPU — the build-scan-compact skeleton this LOD pass specializes.
- On-GPU Viewport Culling for Vector Tiles — the atomic-counter compaction alternative when order does not matter.
- Reducing Register Pressure in WGSL Spatial Kernels — keeping the scan’s shared-memory and register budget within occupancy limits.
- Measuring Compute Pass Duration with Timestamp Query — timing the scan pass that dominates this pipeline.