The cover array of Cover Texture Array now gets mipLevelCount: 9 and one call to generateMipmaps(). The same six covers draw twice at about a fifth of their size: the top row through a view of level 0 only, the bottom row through the full chain with mipmapFilter: 'linear'. A 2D canvas magnifies the second cover of each row:
<canvas id="gpu" width="360" height="176"></canvas>
<canvas id="zoom" width="250" height="176"></canvas>
<script src="mipmaps.js"></script>
<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',
mipLevelCount: 9, // floor(log2(384)) + 1
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); // from mipmaps.js (Section 4.13.2)
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); // picks the level from derivatives
}`;
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 groups = [[{ mipLevelCount: 1 }, {}], [{}, { mipmapFilter: 'linear' }]].map(
([view, mips]) => device.createBindGroup({ layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: covers.createView(view) }, { binding: 1, resource:
device.createSampler({ magFilter: 'linear', minFilter: 'linear', ...mips }) }] }));
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);
groups.forEach((group, row) => { // top row: level 0, bottom: all 9
pass.setBindGroup(0, group), pass.setViewport(0, row * 88, 360, 88, 0, 1), pass.draw(6, 6);
});
pass.end();
device.queue.submit([encoder.finish()]);
const ctx = zoom.getContext('2d'); // same task as the submit
ctx.imageSmoothingEnabled = false; // magnify the second cover 2.2 times
for (const row of [0, 1]) {
ctx.drawImage(gpu, 66, row * 88 + 4, 55, 80, row * 131, 0, 119, 176);
}
await device.queue.onSubmittedWorkDone();
window.__done = true;
</script>
Without mipmaps (top row, left crop) the thin strokes of the titles and author names break into scattered pixels, because each screen pixel samples 4 of the roughly 20 texels under it; with the mip chain (bottom row, right crop) the hardware reads level 2 or 3, whose texels already average the block, so the strokes stay whole, softer but steady when the covers move. The textureSample() call is unchanged: the GPU picks the level from the screen-space derivatives of the texture coordinates.
<!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="230"></canvas>
<canvas id="labels" width="600" height="230"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = 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);
}
// generateMipmaps(device, texture): each level drawn from the one above (see the mipmap blit page).
const mipPipelines = new Map();
function generateMipmaps(device, texture) {
let pipeline = mipPipelines.get(texture.format);
if (!pipeline) {
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); }` });
pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module }, fragment: { module, targets: [{ format: texture.format }] } });
mipPipelines.set(texture.format, pipeline);
}
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', 'A. Marlowe', '#1f5f8b'], ['Small Steps to', 'Big Summits', 'J. Reyes', '#5c4099'],
["The Clockmaker's", 'Paradox', 'E. Lindqvist', '#e0990f'], ['Salt and', 'Saffron', 'R. Okafor', '#b5452e'],
['Night', 'Trains', 'M. Duval', '#2d5d2a'], ['Paper', 'Gardens', 'S. Ito', '#299e8f']];
const shelf = /* 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); // picks the level from derivatives
}`;
const zoom = /* wgsl */ `
@group(0) @binding(0) var small: texture_2d<f32>;
@group(0) @binding(1) var nearest: sampler;
@group(0) @binding(2) var<uniform> crop: vec4f; // x, y, w, h in texels of 'small'
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
let q = vec2f(f32(v & 1), f32(v >> 1));
return Out(vec4f(q * 2 - 1, 0, 1), (crop.xy + vec2f(q.x, 1 - q.y) * crop.zw) / vec2f(360, 176));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(small, nearest, in.uv); }`;
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 });
const T = GPUTextureUsage;
// Covers painted on canvases (standing in for booknest-cover-N.png), with thin title strokes.
const texture = device.createTexture({ size: [256, 384, 6], format: 'rgba8unorm', mipLevelCount: 9, // floor(log2(384)) + 1
usage: T.TEXTURE_BINDING | T.COPY_DST | T.RENDER_ATTACHMENT });
covers.forEach(([a, b, author, color], layer) => {
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 = '30px Georgia, serif'; g.textAlign = 'center';
g.fillText(a, 128, 160); g.fillText(b, 128, 200);
g.fillStyle = '#f5ecd7'; g.font = '24px Georgia, serif'; g.fillText(author, 128, 320);
device.queue.copyExternalImageToTexture({ source: art }, { texture, origin: [0, 0, layer] }, [256, 384]);
});
generateMipmaps(device, texture);
const shelfModule = device.createShaderModule({ code: shelf });
const shelfPipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: shelfModule }, fragment: { module: shelfModule, targets: [{ format: 'rgba8unorm' }] } });
const groups = [[{ mipLevelCount: 1 }, {}], [{}, { mipmapFilter: 'linear' }]].map(([view, mips]) =>
device.createBindGroup({ layout: shelfPipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView(view) },
{ binding: 1, resource: device.createSampler({ magFilter: 'linear', minFilter: 'linear', ...mips }) }] }));
const small = device.createTexture({ size: [360, 176], format: 'rgba8unorm', usage: T.RENDER_ATTACHMENT | T.TEXTURE_BINDING });
const zoomModule = device.createShaderModule({ code: zoom });
const zoomPipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module: zoomModule }, fragment: { module: zoomModule, targets: [{ format }] } });
const crops = [0, 1].map((row) => {
const b = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(b, 0, new Float32Array([66, row * 88 + 4, 55, 80])); // the second cover of each row
return b;
});
const nearest = device.createSampler();
const zoomGroup = (buffer, whole) => device.createBindGroup({ layout: zoomPipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: small.createView() }, { binding: 1, resource: nearest }, { binding: 2, resource: { buffer } }] });
const wholeBuffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(wholeBuffer, 0, new Float32Array([0, 0, 360, 176]));
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: small.createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0.96, 0.94, 0.90, 1] }] });
pass.setPipeline(shelfPipeline);
groups.forEach((group, row) => { // top row: level 0 only, bottom row: all 9
pass.setBindGroup(0, group); pass.setViewport(0, row * 88, 360, 88, 0, 1); pass.draw(6, 6);
});
pass.end();
const out = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
out.setPipeline(zoomPipeline);
out.setViewport(4, 30, 360, 176, 0, 1); out.setBindGroup(0, zoomGroup(wholeBuffer)); out.draw(4); // 1:1
crops.forEach((buffer, row) => { // magnified 2.2x, texels kept square
out.setViewport(372 + row * 114, 30, 110, 160, 0, 1); out.setBindGroup(0, zoomGroup(buffer)); out.draw(4);
});
out.end();
device.queue.submit([encoder.finish()]);
ink.font = '12px system-ui, sans-serif'; ink.fillStyle = '#222';
ink.fillText('top: level 0 only | bottom: mipmapped', 4, 20);
ink.fillText('no mips', 400, 206); ink.fillText('mipmapped', 506, 206);
ink.fillStyle = '#555'; ink.font = '11px system-ui, sans-serif';
ink.fillText('Without mips the thin strokes break into scattered pixels; with them they stay whole.', 4, 224);
}
main();
</script>