First Cover Quad painted titles into an atlas. BookNest's real cover art now arrives as six 256 x 384 PNG files, exported from Canvas 2D's cover designer (Cover Designer), and goes into one texture with six layers. copyExternalImageToTexture() writes each decoded bitmap into its own layer through the origin's third coordinate, and the fragment shader picks the layer by the instance's book index:
<canvas id="gpu" width="600" height="150"></canvas>
<script type="module">
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
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', // 6 layers
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT }); // required by copyExternalImage...
bitmaps.forEach((source, layer) => device.queue.copyExternalImageToTexture(
{ source }, { texture: covers, origin: [0, 0, layer] }, [256, 384]));
const code = /* wgsl */ `
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f,
@location(1) @interpolate(flat) layer: u32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) book: u32) -> Out {
let q = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1),
vec2f(0, 1))[v];
let p = vec2f(-0.95 + f32(book) * 0.32 + q.x * 0.30, q.y * 1.8 - 0.9);
return Out(vec4f(p, 0, 1), vec2f(q.x, 1 - q.y), book);
}
@group(0) @binding(0) var covers: texture_2d_array<f32>;
@group(0) @binding(1) var smp: sampler;
@fragment fn fs(in: Out) -> @location(0) vec4f {
return textureSample(covers, smp, in.uv, in.layer); // the layer picks the book
}`;
const context = gpu.getContext('webgpu'), format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module },
fragment: { module, targets: [{ format }] } });
const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: covers.createView() }, { binding: 1, resource: sampler }] });
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.draw(6, 6), pass.end();
device.queue.submit([encoder.finish()]);
await device.queue.onSubmittedWorkDone();
window.__done = true;
</script>
One bind group, one draw call and six covers, each drawn from its own layer. The covers are shrunk to about a third of their size without mipmaps, so the small author names break into jagged pixels: Cover Mipmaps generates mip levels for this texture and compares the two.