Cover Mipmaps

Generating Mipmaps for BookNest's Cover Textures

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:

demos/ch04/cover-mips.html: BookNest's covers without and with generated mipmapsHTML
<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>
Browser output of Listing 4.73
Browser output of 73

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.

BookNest's six covers drawn small without mipmaps (top) and with a generated chain (bottom), with a magnified crop of eachHTMLLive
<!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>