BookNest on the GPU

BookNest's Case for Moving Catalog Work onto the GPU

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:

How BookNest's catalog demo grows through WebGPU
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?

65,536 catalog titles filtered by a compute shader and drawn from the same storage buffer, with nothing read backHTMLLive
<!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>