Frustum culling keeps everything in view, including objects hidden behind others. Occlusion culling removes those:
Occlusion queries: with an occlusionQuerySet on the render pass, wrap a cheap bounding-box draw in beginOcclusionQuery(i) and endOcclusionQuery() and resolve the results. A wall, a box behind it and a box beside it gave 1, 0 and 1; the specification promises only a nonzero value when any sample passed, so treat results as booleans and read them a frame late.
Hierarchical depth (Hi-Z): reduce last frame's depth buffer to a pyramid of maximum depths and let the cull shader compare each object's nearest depth with the level that covers its screen rectangle, all on the GPU.
Distance culling skips or fades objects beyond a range that grows with their size, and picks simpler meshes as distance grows. All of these fit in the frustum pass, before the atomicAdd() that appends a survivor.
<!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="330"></canvas>
<canvas id="labels" width="600" height="330"></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);
}
// Rectangles at a depth: instance 0 the wall (near), 1 a box sliding behind it, 2 a box beside it.
const code = /* wgsl */ `
@group(0) @binding(0) var<uniform> slide: f32;
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));
let rects = array(vec4f(-0.35, -0.55, 0.5, 1.1), vec4f(-0.75 + slide, -0.25, 0.3, 0.5), vec4f(0.68, -0.3, 0.26, 0.55));
let depth = array(0.3, 0.6, 0.6);
let colors = array(vec3f(0.55, 0.38, 0.22), vec3f(0.08, 0.40, 0.75), vec3f(0.16, 0.56, 0.30));
let r = rects[i];
return Out(vec4f(r.xy + q * r.zw, depth[i], 1), colors[i]);
}
@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 module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] },
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' } });
const depth = device.createTexture({ size: [600, 330], format: 'depth24plus', usage: GPUTextureUsage.RENDER_ATTACHMENT });
const slide = device.createBuffer({ size: 4, usage: B.UNIFORM | B.COPY_DST });
const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: slide } }] });
const querySet = device.createQuerySet({ type: 'occlusion', count: 3 });
const resolve = device.createBuffer({ size: 3 * 8, usage: B.QUERY_RESOLVE | B.COPY_SRC }); // 64-bit results
const ring = [0, 1, 2].map(() => device.createBuffer({ size: 3 * 8, usage: B.COPY_DST | B.MAP_READ }));
let results = [1n, 1n, 1n];
const boxX = (s) => ((-0.75 + s + 0.15) + 1) * 300;
function frame(now) {
const s = 0.55 + Math.sin(now / 1100) * 0.55; // box 1 slides from the left, behind the wall, and back
device.queue.writeBuffer(slide, 0, new Float32Array([s]));
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }],
depthStencilAttachment: { view: depth.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'discard' },
occlusionQuerySet: querySet });
pass.setPipeline(pipeline);
pass.setBindGroup(0, group);
for (let i = 0; i < 3; i++) {
pass.beginOcclusionQuery(i); // counts samples that pass the depth test
pass.draw(4, 1, 0, i); // wall first, so it can hide what follows
pass.endOcclusionQuery();
}
pass.end();
encoder.resolveQuerySet(querySet, 0, 3, resolve, 0);
const staging = ring.find((b) => b.mapState === 'unmapped');
if (staging) encoder.copyBufferToBuffer(resolve, 0, staging, 0, 24);
device.queue.submit([encoder.finish()]);
if (staging) staging.mapAsync(GPUMapMode.READ).then(() => { // a frame (or more) late
results = [...new BigUint64Array(staging.getMappedRange())]; staging.unmap();
});
ink.clearRect(0, 0, 600, 330);
ink.font = '12.5px system-ui, sans-serif'; ink.fillStyle = '#222';
ink.fillText('beginOcclusionQuery(i) / draw / endOcclusionQuery() for each object, resolved each frame', 12, 22);
ink.textAlign = 'center';
const names = ['wall', 'box behind', 'box beside'], xs = [225, boxX(s), 543];
names.forEach((n, i) => {
const visible = results[i] > 0n; // only "nonzero" is promised: treat as a boolean
ink.fillStyle = visible ? '#1e6b3a' : '#8a2b2b';
ink.fillText(`${n}: ${visible ? 'visible' : 'hidden (0)'}`, xs[i], i === 1 ? 272 : 296);
});
ink.textAlign = 'left'; ink.fillStyle = '#555'; ink.font = '11.5px system-ui, sans-serif';
ink.fillText('Hi-Z culling tests objects against last frame\'s depth pyramid; distance culling drops far, small objects.', 12, 322);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>