The chapter's shelf now uses every piece of this section. A 2D OffscreenCanvas paints six titled covers into one 960 x 240 atlas, which copyExternalImageToTexture() uploads (Textures and Samplers covers textures). One indexed quad in a per-vertex buffer, one packed 8-byte record per book in a per-instance buffer, and one drawIndexed(6, 6) put them on screen, with each book's page count widening its spine:
<canvas id="gpu" width="600" height="200"></canvas>
<script type="module">
const titles = ['The Quiet Harbor', 'Patterns of the Deep Web', 'Salt and Saffron',
'Small Steps to Big Summits', "The Clockmaker's Paradox", 'Gardens in Glass'];
const colors = ['#1466bf', '#2e7d33', '#f06b00', '#6b1c99', '#38474f', '#c72929']; // 4.2.4
const atlas = new OffscreenCanvas(6 * 160, 240), ctx = atlas.getContext('2d'); // 6 cells
titles.forEach((title, i) => {
ctx.fillStyle = colors[i], ctx.fillRect(i * 160, 0, 160, 240);
ctx.fillStyle = '#fff', ctx.font = 'bold 20px Georgia', ctx.textAlign = 'center';
let line = '', y = 70;
for (const word of title.split(' ')) { // wrap the title at 130 px
if (ctx.measureText(line + word).width > 130) {
ctx.fillText(line, i * 160 + 84, y), line = '', y += 26;
}
line += word + ' ';
}
ctx.fillText(line, i * 160 + 84, y);
});
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const { TEXTURE_BINDING, RENDER_ATTACHMENT } = GPUTextureUsage, size = [960, 240];
const texture = device.createTexture({ size, format: 'rgba8unorm', // Section 4.12
usage: TEXTURE_BINDING | GPUTextureUsage.COPY_DST | RENDER_ATTACHMENT });
device.queue.copyExternalImageToTexture({ source: atlas }, { texture }, size);
const buffer = (data, usage) => {
const b = device.createBuffer({ size: data.byteLength,
usage: usage | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(b, 0, data);
return b;
};
const corners = buffer(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), GPUBufferUsage.VERTEX);
const indices = buffer(new Uint16Array([0, 1, 2, 0, 2, 3]), GPUBufferUsage.INDEX);
const pages = [312, 428, 256, 198, 344, 176];
const perBook = new ArrayBuffer(6 * 8), view = new DataView(perBook); // 8 bytes a book
pages.forEach((p, i) => {
view.setFloat32(i * 8, -0.93 + i * 0.31, true); // left edge, float32
view.setUint16(i * 8 + 4, p, true); // page count, uint16
view.setUint8(i * 8 + 6, i); // atlas cell, uint8
});
const instances = buffer(perBook, GPUBufferUsage.VERTEX);
const code = /* wgsl */ `
struct In { @location(0) uv: vec2f, @location(1) left: f32, @location(2) pages: u32,
@location(3) cell: u32 }
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f,
@location(1) @interpolate(flat) cell: u32, @location(2) thick: f32 }
@vertex fn vs(in: In) -> Out {
let p = vec2f(in.left, -0.85) + in.uv * vec2f(0.27, 1.7);
return Out(vec4f(p, 0, 1), vec2f(in.uv.x, 1 - in.uv.y), in.cell, f32(in.pages) / 428);
}
@group(0) @binding(0) var atlas: texture_2d<f32>;
@group(0) @binding(1) var linear: sampler;
@fragment fn fs(in: Out) -> @location(0) vec4f {
let color = textureSample(atlas, linear, vec2f((f32(in.cell) + in.uv.x) / 6, in.uv.y));
let spine = select(1.0, 0.65, in.uv.x < 0.1 * in.thick); // thicker books, wider spine
return vec4f(color.rgb * spine * (1 - 0.15 * in.uv.y), 1);
}`;
const context = gpu.getContext('webgpu'), format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', fragment: { module,
targets: [{ format }] }, vertex: { module, buffers: [
{ arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] },
{ arrayStride: 8, stepMode: 'instance', attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32' },
{ shaderLocation: 2, offset: 4, format: 'uint16' },
{ shaderLocation: 3, offset: 6, format: 'uint8' }] }] } });
const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: device.createSampler({ magFilter: 'linear' }) }] });
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, group);
pass.setVertexBuffer(0, corners), pass.setVertexBuffer(1, instances);
pass.setIndexBuffer(indices, 'uint16');
pass.drawIndexed(6, 6); // 6 indices per quad, 6 book instances
pass.end();
device.queue.submit([encoder.finish()]);
await device.queue.onSubmittedWorkDone();
window.__done = true;
</script>
The texture carries the typography, which WGSL cannot draw; the GPU adds shading and spines. One trap hit here: GPUTextureUsage.COPY_DST is 2, which as a buffer usage means MAP_WRITE, so mixing the two destructured namespaces made every buffer invalid. Depth and Multisampling adds depth and anti-aliasing to the shelf.