GPU-Driven Frustum Culling

Frustum culling skips objects outside the camera's view volume. Its six planes come from the view-projection matrix (the Gribb-Hartmann method): with rows r0 to r3, left and right are r3 + r0 and r3 - r0, bottom and top r3 + r1 and r3 - r1, and, because WebGPU's clip depth runs from 0 to w, near is r2 and far is r3 - r2. A sphere of center p and radius r is outside a plane (a, b, c, d) when a p.x + b p.y + c p.z + d < -r |(a, b, c)|.

One invocation tests one object. The test is conservative: a sphere near a frustum corner can pass all six planes while outside, so a few hidden objects are drawn, never the reverse.

A top-down aisle of 1,200 books tested against the six frustum planes of a turning camera, one compute invocation per bookHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="360"></canvas>
  <canvas id="labels" width="600" height="360"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const COUNT = 1200;
const code = /* wgsl */ `
struct Camera { eye: vec3f, yaw: f32, aspect: f32 }
@group(0) @binding(0) var<uniform> cam: Camera;
@group(0) @binding(1) var<storage, read_write> visible: array<u32>;
fn viewProj() -> mat4x4f {                            // perspective (fov 1 rad) * view, WebGPU depth 0..1
  let f = 1 / tan(0.5);
  let s = sin(cam.yaw);  let c = cos(cam.yaw);
  let fwd = vec4f(s, 0, -c, -dot(vec3f(s, 0, -c), cam.eye));
  let right = vec4f(c, 0, s, -dot(vec3f(c, 0, s), cam.eye));
  let up = vec4f(0, 1, 0, -cam.eye.y);
  let z = fwd * 20 / 19.9 - vec4f(0, 0, 0, 2 / 19.9);  // near 0.1, far 20
  return transpose(mat4x4f(right * f / cam.aspect, up * f, z, fwd));
}
fn book(i: u32) -> vec3f {                            // a grid of books on the floor (x, 0, z)
  return vec3f(f32(i % 40) * 0.5 - 9.75, 0, f32(i / 40) * 0.5 - 7.25);
}
@compute @workgroup_size(64) fn cull(@builtin(global_invocation_id) id: vec3u) {
  if (id.x >= ${COUNT}) { return; }
  let center = vec4f(book(id.x), 1);
  let radius = 0.2;                                   // bounding sphere
  let m = transpose(viewProj());                      // m[k] = row k (Gribb-Hartmann)
  let planes = array(m[3] + m[0], m[3] - m[0], m[3] + m[1], m[3] - m[1], m[2], m[3] - m[2]);
  var inside = 1u;
  for (var k = 0; k < 6; k++) {                       // left, right, bottom, top, near, far
    if (dot(planes[k], center) < -radius * length(planes[k].xyz)) { inside = 0u; }
  }
  visible[id.x] = inside;
}
@group(0) @binding(1) var<storage> shown: array<u32>;
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let q = (vec2f(f32(v & 1), f32(v >> 1)) - 0.5) * 0.3;
  let b = book(i);
  let p = vec2f(b.x + q.x, b.z + q.y) / vec2f(10.5, 8) * vec2f(0.6, -0.9) + vec2f(-0.35, 0);   // top-down map
  return Out(vec4f(p, 0, 1), select(vec3f(0.82, 0.81, 0.78), vec3f(0.08, 0.40, 0.75), shown[i] == 1u));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const B = GPUBufferUsage;
  const camera = device.createBuffer({ size: 32, usage: B.UNIFORM | B.COPY_DST });
  const visible = device.createBuffer({ size: COUNT * 4, usage: B.STORAGE | B.COPY_SRC });
  const readback = [0, 1].map(() => device.createBuffer({ size: COUNT * 4, usage: B.COPY_DST | B.MAP_READ }));
  const module = device.createShaderModule({ code });
  const cull = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'cull' } });
  const render = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module, entryPoint: 'vs' }, fragment: { module, entryPoint: 'fs', targets: [{ format }] } });
  const cullGroup = device.createBindGroup({ layout: cull.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: camera } }, { binding: 1, resource: { buffer: visible } }] });
  const drawGroup = device.createBindGroup({ layout: render.getBindGroupLayout(0), entries: [{ binding: 1, resource: { buffer: visible } }] });

  let kept = '?';
  const toMap = (x, z) => [((x / 10.5) * 0.6 - 0.35 + 1) * 300, (1 + (z / 8) * 0.9) * 180];
  function frame(now) {
    const yaw = Math.sin(now / 2500) * 1.2, eye = [Math.sin(now / 3700) * 3, 1.2, 6.5];
    device.queue.writeBuffer(camera, 0, new Float32Array([...eye, yaw, 16 / 9]));
    const encoder = device.createCommandEncoder();
    const cp = encoder.beginComputePass();
    cp.setPipeline(cull); cp.setBindGroup(0, cullGroup); cp.dispatchWorkgroups(Math.ceil(COUNT / 64)); cp.end();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(render); pass.setBindGroup(0, drawGroup); pass.draw(4, COUNT);
    pass.end();
    const staging = readback.find((b) => b.mapState === 'unmapped');
    if (staging) encoder.copyBufferToBuffer(visible, 0, staging, 0, COUNT * 4);
    device.queue.submit([encoder.finish()]);
    if (staging) staging.mapAsync(GPUMapMode.READ).then(() => { kept = new Uint32Array(staging.getMappedRange()).reduce((a, b) => a + b, 0); staging.unmap(); });

    // The camera and its view wedge, drawn for reference (horizontal fov from fov 1 rad and aspect 16:9)
    ink.clearRect(0, 0, 600, 360);
    const half = Math.atan(Math.tan(0.5) * 16 / 9);
    const [ex, ez] = toMap(eye[0], eye[2]);
    ink.strokeStyle = '#d0801e'; ink.lineWidth = 1.5; ink.beginPath();
    for (const a of [yaw - half, yaw + half]) { const [fx, fz] = toMap(eye[0] + Math.sin(a) * 20, eye[2] - Math.cos(a) * 20); ink.moveTo(ex, ez); ink.lineTo(fx, fz); }
    ink.stroke();
    ink.fillStyle = '#d0801e'; ink.beginPath(); ink.arc(ex, ez, 5, 0, 7); ink.fill();
    ink.fillStyle = 'rgba(247,244,238,0.94)'; ink.fillRect(410, 0, 190, 360);
    ink.font = '12.5px system-ui, sans-serif'; ink.fillStyle = '#222';
    ['Top-down view of the aisle.', 'Orange: the camera and its', 'horizontal field of view.', '',
     'Planes from viewProj rows:', 'left r3 + r0, right r3 - r0,', 'bottom r3 + r1, top r3 - r1,', 'near r2, far r3 - r2', '',
     `inside or crossing: ${kept} of ${COUNT}`, '(blue, read back a frame late)', '', 'Conservative: a few hidden', 'books pass, never the reverse.']
      .forEach((l, i) => ink.fillText(l, 420, 30 + i * 21));
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>