Sorting BookNest's six prices on the GPU would be absurd: the upload, dispatch and read-back cost far more than a JavaScript sort(). The GPU pays off when one small operation runs over many items and the result stays on the GPU to be drawn, as in a catalog of tens of thousands of titles redrawn as the shopper types. This chapter treats the six books as a stand-in for that catalog, kept in one storage buffer that compute and render pipelines both read, so nothing returns to JavaScript between filtering a list and drawing it:
| BookNest job | Pipeline | Section |
|---|---|---|
| Covers as textured, mipmapped quads | Render | 4.10.11, 4.12.11, 4.13.8 |
| An instanced shelf, culled and drawn indirectly | Render | 4.14.10, 4.15.8 |
| Sort by price, filter, blur cover art | Compute | 4.17.8, 4.17.9, 4.17.11 |
| Flying book pages, a GPU-culled shelf | Both | 4.18.4, 4.18.10 |
Ask two questions of any job: is there enough parallel work to fill thousands of cores, and does the result feed a draw call rather than JavaScript, avoiding the mapAsync() read-back of Buffers and Bind Groups?
<!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 labels = 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 = 32768;
const code = /* wgsl */ `
struct Book { price: f32, rating: f32, visible: f32, pad: f32 }
struct Filter { maxPrice: f32, minRating: f32 }
@group(0) @binding(0) var<storage, read_write> books: array<Book>;
@group(0) @binding(1) var<uniform> rule: Filter;
@compute @workgroup_size(64) fn filterBooks(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= arrayLength(&books)) { return; }
let b = books[id.x];
books[id.x].visible = select(0.0, 1.0, b.price <= rule.maxPrice && b.rating >= rule.minRating);
}
@group(0) @binding(0) var<storage> shown: array<Book>; // the same buffer, read-only
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec4f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let b = shown[i];
let corner = vec2f(f32(v & 1), f32(v >> 1)) * 2 - 1;
let p = vec2f((b.price - 5) / 45 * 1.8 - 0.9, (b.rating - 1) / 4 * 1.6 - 0.8);
let color = select(vec4f(0.5, 0.5, 0.5, 0.12), vec4f(0.08, 0.40, 0.75, 0.8), b.visible > 0);
return Out(vec4f(p + corner * vec2f(0.005, 0.008), 0, 1), color);
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color.rgb * in.color.a, in.color.a); }`;
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 });
// A made-up catalog: price $5-$50, rating 1-5 stars, uploaded once.
const data = new Float32Array(COUNT * 4);
for (let i = 0; i < COUNT; i++) {
data[i * 4] = 5 + 45 * Math.random() ** 1.6;
data[i * 4 + 1] = Math.min(5, 1 + 4 * Math.sqrt(Math.random()));
}
const books = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(books, 0, data);
const rule = device.createBuffer({ size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const module = device.createShaderModule({ code });
const filter = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'filterBooks' } });
const blend = { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' };
const draw = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format, blend: { color: blend, alpha: blend } }] } });
const filterGroup = device.createBindGroup({ layout: filter.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: books } }, { binding: 1, resource: { buffer: rule } }] });
const drawGroup = device.createBindGroup({ layout: draw.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: books } }] });
function frame(now) {
const maxPrice = 27.5 + 20 * Math.sin(now / 1500), minRating = 3 + Math.sin(now / 2300);
device.queue.writeBuffer(rule, 0, new Float32Array([maxPrice, minRating]));
const encoder = device.createCommandEncoder();
const compute = encoder.beginComputePass(); // filter on the GPU...
compute.setPipeline(filter);
compute.setBindGroup(0, filterGroup);
compute.dispatchWorkgroups(COUNT / 64);
compute.end();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(draw); // ...and draw the result straight away
pass.setBindGroup(0, drawGroup);
pass.draw(4, COUNT);
pass.end();
device.queue.submit([encoder.finish()]);
labels.clearRect(0, 0, 600, 360);
labels.font = '13px system-ui, sans-serif'; labels.fillStyle = '#222';
labels.fillText(`32,768 titles | shown: price <= $${maxPrice.toFixed(2)}, rating >= ${minRating.toFixed(1)}`, 12, 20);
labels.fillText('Filtered and drawn on the GPU every frame: 0 bytes read back', 12, 350);
labels.fillStyle = '#666'; labels.fillText('price $5 to $50', 480, 340);
labels.save(); labels.translate(20, 250); labels.rotate(-Math.PI / 2); labels.fillText('rating 1 to 5', 0, 0); labels.restore();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>