The shelf now shows three filtered views of the catalog, labeled "All books", "Under $20" and "Rated 4.5+". Book data (price, rating, stock) lives in one storage buffer; each shelf's height and filter live in a 12-byte uniform window of one buffer, moved by a dynamic offset; covers come from the mipmapped texture array of Cover Mipmaps. Covers that fail the shelf's filter, or are out of stock, fade into the background:
<div style="display: flex; font: bold 14px sans-serif; line-height: 110px">
<p style="width: 100px; margin: 0">All books<br>Under $20<br>Rated 4.5+</p>
<canvas id="gpu" width="480" height="330"></canvas></div>
<script src="mipmaps.js"></script>
<script type="module">
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const { STORAGE, UNIFORM, COPY_DST } = GPUBufferUsage;
const bitmaps = await Promise.all([1, 2, 3, 4, 5, 6].map(async (n) =>
createImageBitmap(await (await fetch(`booknest-cover-${n}.png`)).blob())));
const covers = device.createTexture({ size: [256, 384, 6], format: 'rgba8unorm',
mipLevelCount: 9, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT });
bitmaps.forEach((source, layer) => device.queue.copyExternalImageToTexture(
{ source }, { texture: covers, origin: [0, 0, layer] }, [256, 384]));
generateMipmaps(device, covers); // Section 4.13.2
const buffer = (data, usage) => {
const b = device.createBuffer({ size: data.byteLength, usage: usage | COPY_DST });
device.queue.writeBuffer(b, 0, data);
return b;
};
const books = buffer(new Float32Array([ // per instance: price, rating, stock
14.99, 4.6, 1, 39.50, 4.3, 1, 24.00, 4.8, 0, 18.75, 4.1, 1, 16.20, 4.5, 1,
21.30, 4.4, 1]), STORAGE); // a 12-byte struct: stride 12
const shelfData = new Float32Array(3 * 64); // three shelves, 256 bytes apart
[[0.64, 1e9, 0], [-0.03, 20, 0], [-0.69, 1e9, 4.5]].forEach((shelf, i) =>
shelfData.set(shelf, i * 64)); // y, maxPrice, minRating
const shelves = buffer(shelfData, UNIFORM);
const code = /* wgsl */ `
struct Book { price: f32, rating: f32, inStock: f32 }
struct Shelf { y: f32, maxPrice: f32, minRating: f32 }
@group(0) @binding(0) var<storage> books: array<Book>;
@group(0) @binding(1) var covers: texture_2d_array<f32>;
@group(0) @binding(2) var smp: sampler;
@group(1) @binding(0) var<uniform> shelf: Shelf; // moved by the dynamic offset
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f,
@location(1) @interpolate(flat) book: u32,
@location(2) @interpolate(flat) on: f32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1),
vec2f(0, 1))[v];
if (i == 6) { // instance 6: the shelf plank
return Out(vec4f(q.x * 2 - 1, shelf.y - 0.3 + q.y * 0.05, 0, 1), q, 6, 1);
}
let b = books[i];
let on = b.inStock > 0 && b.price < shelf.maxPrice && b.rating >= shelf.minRating;
let p = vec2f(-0.87 + f32(i) * 0.3 + q.x * 0.24, shelf.y - 0.26 + q.y * 0.52);
return Out(vec4f(p, 0, 1), vec2f(q.x, 1 - q.y), i, f32(on));
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let color = textureSample(covers, smp, in.uv, in.book).rgb; // sample before branching
let shade = mix(vec3f(0.96, 0.94, 0.90), color, 0.25 + 0.75 * in.on); // fade misses
return vec4f(select(shade, vec3f(0.55, 0.38, 0.22), in.book == 6), 1); // wood plank
}`;
const context = gpu.getContext('webgpu'), format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const module = device.createShaderModule({ code });
const V = GPUShaderStage.VERTEX, F = GPUShaderStage.FRAGMENT;
const sceneLayout = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: V, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: F, texture: { viewDimension: '2d-array' } },
{ binding: 2, visibility: F, sampler: {} }] });
const shelfLayout = device.createBindGroupLayout({ entries: [ // dynamic offsets need
{ binding: 0, visibility: V, buffer: { hasDynamicOffset: true } }] }); // explicit layouts
const pipeline = device.createRenderPipeline({ vertex: { module },
layout: device.createPipelineLayout({ bindGroupLayouts: [sceneLayout, shelfLayout] }),
fragment: { module, targets: [{ format }] } });
const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear',
mipmapFilter: 'linear' });
const scene = device.createBindGroup({ layout: sceneLayout, entries: [books,
covers.createView(), sampler].map((resource, binding) => ({ binding, resource })) });
const shelfGroup = device.createBindGroup({ layout: shelfLayout,
entries: [{ binding: 0, resource: { buffer: shelves, size: 12 } }] }); // a 12-byte window
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ loadOp: 'clear', storeOp: 'store',
view: context.getCurrentTexture().createView(), clearValue: [0.96, 0.94, 0.90, 1] }] });
pass.setPipeline(pipeline), pass.setBindGroup(0, scene);
for (let i = 0; i < 3; i++) {
pass.setBindGroup(1, shelfGroup, [i * 256]); // same group, next shelf's window
pass.draw(6, 7); // six covers and a plank
}
pass.end();
device.queue.submit([encoder.finish()]);
await device.queue.onSubmittedWorkDone();
window.__done = true;
</script>
Three draws produce 18 covers and 3 planks; Salt and Saffron is out of stock and faded everywhere. The shader samples before branching because textureSample() needs uniform control flow. Indirect Draws and Bundles moves even the loop and the counts to the GPU.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.shelf { display: flex; max-width: 600px; font: bold 14px sans-serif; line-height: 110px; }
.shelf p { width: 100px; margin: 0; flex: none; }
.shelf canvas { display: block; width: 100%; max-width: 480px; min-width: 0; }
</style>
<div class="shelf">
<p>All books<br>Under $20<br>Rated 4.5+</p>
<canvas id="gpu" width="480" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('gpu');
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 = '16px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
// generateMipmaps(): fills levels 1..n-1 of each layer from the level above (render-pass blit).
function generateMipmaps(device, texture) {
const module = device.createShaderModule({ code: /* wgsl */ `
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[v];
return Out(vec4f(p, 0, 1), vec2f(p.x + 1, 1 - p.y) / 2);
}
@group(0) @binding(0) var above: texture_2d<f32>;
@group(0) @binding(1) var smp: sampler;
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(above, smp, in.uv); }` });
const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module }, fragment: { module, targets: [{ format: texture.format }] } });
const sampler = device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
const view = (level, layer) => texture.createView({ dimension: '2d', baseMipLevel: level, mipLevelCount: 1, baseArrayLayer: layer, arrayLayerCount: 1 });
const encoder = device.createCommandEncoder();
for (let layer = 0; layer < texture.depthOrArrayLayers; layer++) {
for (let level = 1; level < texture.mipLevelCount; level++) {
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: view(level, layer), loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, device.createBindGroup({ layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: view(level - 1, layer) }, { binding: 1, resource: sampler }] }));
pass.draw(3);
pass.end();
}
}
device.queue.submit([encoder.finish()]);
}
const covers = [['The Quiet', 'Harbor', '#1f5f8b'], ['Small Steps to', 'Big Summits', '#5c4099'], ['Salt and', 'Saffron', '#b5452e'],
["The Clockmaker's", 'Paradox', '#e0990f'], ['Night', 'Trains', '#2d5d2a'], ['Paper', 'Gardens', '#299e8f']];
const code = /* wgsl */ `
struct Book { price: f32, rating: f32, inStock: f32 }
struct Shelf { y: f32, maxPrice: f32, minRating: f32 }
@group(0) @binding(0) var<storage> books: array<Book>;
@group(0) @binding(1) var covers: texture_2d_array<f32>;
@group(0) @binding(2) var smp: sampler;
@group(1) @binding(0) var<uniform> shelf: Shelf; // moved by the dynamic offset
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f,
@location(1) @interpolate(flat) book: u32,
@location(2) @interpolate(flat) on: f32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1), vec2f(0, 1))[v];
if (i == 6) { // instance 6: the shelf plank
return Out(vec4f(q.x * 2 - 1, shelf.y - 0.3 + q.y * 0.05, 0, 1), q, 6, 1);
}
let b = books[i];
let on = b.inStock > 0 && b.price < shelf.maxPrice && b.rating >= shelf.minRating;
let p = vec2f(-0.87 + f32(i) * 0.3 + q.x * 0.24, shelf.y - 0.26 + q.y * 0.52);
return Out(vec4f(p, 0, 1), vec2f(q.x, 1 - q.y), i, f32(on));
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let color = textureSample(covers, smp, in.uv, min(in.book, 5u)).rgb; // sample before branching
let shade = mix(vec3f(0.96, 0.94, 0.90), color, 0.25 + 0.75 * in.on); // fade misses
return vec4f(select(shade, vec3f(0.55, 0.38, 0.22), in.book == 6), 1); // wood plank
}`;
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 { STORAGE, UNIFORM, COPY_DST } = GPUBufferUsage;
const texture = device.createTexture({ size: [256, 384, 6], format: 'rgba8unorm', mipLevelCount: 9,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
covers.forEach(([a, b, color], layer) => { // stand-ins for booknest-cover-N.png
const art = new OffscreenCanvas(256, 384), g = art.getContext('2d');
g.fillStyle = color; g.fillRect(0, 0, 256, 384); g.fillStyle = '#f5ecd7'; g.fillRect(0, 110, 256, 120);
g.fillStyle = '#1b1b1b'; g.font = 'bold 30px Georgia, serif'; g.textAlign = 'center'; g.fillText(a, 128, 160); g.fillText(b, 128, 200);
device.queue.copyExternalImageToTexture({ source: art }, { texture, origin: [0, 0, layer] }, [256, 384]);
});
generateMipmaps(device, texture);
const buffer = (data, usage) => {
const b = device.createBuffer({ size: data.byteLength, usage: usage | COPY_DST });
device.queue.writeBuffer(b, 0, data);
return b;
};
const books = buffer(new Float32Array([ // per instance: price, rating, stock
14.99, 4.6, 1, 39.50, 4.3, 1, 24.00, 4.8, 0, 18.75, 4.1, 1, 16.20, 4.5, 1, 21.30, 4.4, 1]), STORAGE);
const SLOT = device.limits.minUniformBufferOffsetAlignment;
const shelfData = new Float32Array(3 * SLOT / 4); // three shelves, SLOT bytes apart
[[0.64, 1e9, 0], [-0.03, 20, 0], [-0.69, 1e9, 4.5]].forEach((s, i) => shelfData.set(s, i * SLOT / 4)); // y, maxPrice, minRating
const shelves = buffer(shelfData, UNIFORM);
const context = canvas.getContext('webgpu'), format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const module = device.createShaderModule({ code });
const V = GPUShaderStage.VERTEX, F = GPUShaderStage.FRAGMENT;
const sceneLayout = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: V, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: F, texture: { viewDimension: '2d-array' } },
{ binding: 2, visibility: F, sampler: {} }] });
const shelfLayout = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: V, buffer: { hasDynamicOffset: true } }] }); // dynamic offsets need explicit layouts
const pipeline = device.createRenderPipeline({ vertex: { module },
layout: device.createPipelineLayout({ bindGroupLayouts: [sceneLayout, shelfLayout] }),
fragment: { module, targets: [{ format }] } });
const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
const scene = device.createBindGroup({ layout: sceneLayout, entries: [{ buffer: books }, texture.createView(), sampler]
.map((resource, binding) => ({ binding, resource })) });
const shelfGroup = device.createBindGroup({ layout: shelfLayout,
entries: [{ binding: 0, resource: { buffer: shelves, size: 12 } }] }); // a 12-byte window
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ loadOp: 'clear', storeOp: 'store',
view: context.getCurrentTexture().createView(), clearValue: [0.96, 0.94, 0.90, 1] }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, scene);
for (let i = 0; i < 3; i++) {
pass.setBindGroup(1, shelfGroup, [i * SLOT]); // same group, next shelf's window
pass.draw(6, 7); // six covers and a plank
}
pass.end();
device.queue.submit([encoder.finish()]);
}
main();
</script>