Texture Streaming

Texture Streaming and Level-of-Detail Strategies

A catalog with thousands of covers cannot upload every full-size image before the first frame. Streaming loads what is visible, at the detail it needs:

Virtual texturing, which pages texture tiles in and out through an indirection texture, is the high end of this idea; it is rarely worth the complexity in a browser.

Streaming covers small levels first: a placeholder, then a view whose baseMipLevel drops as each larger level arrivesHTMLLive
<!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="300"></canvas>
  <canvas id="labels" width="600" height="300"></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);
}

const W = 128, H = 192, LEVELS = 8;              // floor(log2(192)) + 1 = 8
const titles = [['The Quiet', 'Harbor', '#1f5f8b'], ['Small Steps', 'to Summits', '#5c4099'], ["Clockmaker's", 'Paradox', '#e0990f'],
                ['Salt and', 'Saffron', '#b5452e'], ['Night', 'Trains', '#2d5d2a'], ['Paper', 'Gardens', '#299e8f']];
function paintCover(g, w, h, [a, b, color]) {    // one cover at any size (each level painted directly)
  const s = w / W;
  g.fillStyle = color; g.fillRect(0, 0, w, h);
  g.fillStyle = '#f5ecd7'; g.fillRect(0, 60 * s, w, 60 * s);
  g.fillStyle = '#222'; g.font = `bold ${17 * s}px Georgia, serif`; g.textAlign = 'center';
  g.fillText(a, w / 2, 86 * s); g.fillText(b, w / 2, 108 * s);
}

const code = /* wgsl */ `
@group(0) @binding(0) var covers: texture_2d_array<f32>;
@group(0) @binding(1) var smp: sampler;
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f, @location(1) @interpolate(flat) i: u32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let q = vec2f(f32(v & 1), f32(v >> 1));
  return Out(vec4f(-0.95 + f32(i) * 0.323 + q.x * 0.29, -0.6 + q.y * 1.0, 0, 1), vec2f(q.x, 1 - q.y), i);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
  return textureSample(covers, smp, in.uv, in.i % textureNumLayers(covers));   // the placeholder has 1 layer
}`;

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 module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module }, fragment: { module, targets: [{ format }] } });
  const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
  const usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT;

  // Placeholder: a 1 x 1 neutral texture, so the pipeline and bind group never wait.
  const placeholder = device.createTexture({ size: [1, 1, 1], format: 'rgba8unorm', usage });
  device.queue.writeTexture({ texture: placeholder }, new Uint8Array([200, 194, 184, 255]), {}, [1, 1, 1]);
  const bind = (view) => device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
    { binding: 0, resource: view }, { binding: 1, resource: sampler }] });
  const placeholderGroup = bind(placeholder.createView({ dimension: '2d-array' }));

  let covers, groups, arrived, next = 0, phase = 'start';
  function startStream() {
    covers?.destroy();                                   // frees the memory at once
    covers = device.createTexture({ size: [W, H, 6], mipLevelCount: LEVELS, format: 'rgba8unorm', usage });
    // One view per possible "finest level present": baseMipLevel hides the levels not yet loaded.
    groups = Array.from({ length: LEVELS }, (_, base) => bind(covers.createView({ dimension: '2d-array', baseMipLevel: base })));
    arrived = LEVELS;                                    // nothing yet
  }
  function uploadLevel(level) {                          // "download" one level of all six covers
    const w = W >> level, h = H >> level;
    titles.forEach((t, layer) => {
      const art = new OffscreenCanvas(w, h);
      paintCover(art.getContext('2d'), w, h, t);
      device.queue.copyExternalImageToTexture({ source: art }, { texture: covers, mipLevel: level, origin: [0, 0, layer] }, [w, h]);
    });
  }

  function frame(now) {
    if (now > next) {
      if (phase === 'start') { startStream(); phase = 'stream'; next = now + 700; }
      else if (arrived > 0) {
        const level = arrived === LEVELS ? 4 : arrived - 1;   // first the four smallest at once, then one by one
        for (let l = arrived - 1; l >= level; l--) uploadLevel(l);
        arrived = level; next = now + 650;
      } else { phase = 'start'; next = now + 2000; }
    }
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, arrived === LEVELS ? placeholderGroup : groups[arrived]);
    pass.draw(4, 6);
    pass.end();
    device.queue.submit([encoder.finish()]);

    ink.clearRect(0, 0, 600, 300);
    ink.font = '12.5px ui-monospace, monospace'; ink.fillStyle = '#222';
    ink.fillText(arrived === LEVELS ? 'placeholder: a 1 x 1 neutral texture while decoding'
      : `covers.createView({ baseMipLevel: ${arrived} })  finest level present: ${W >> arrived} x ${H >> arrived}`, 12, 20);
    for (let l = 0; l < LEVELS; l++) {
      ink.fillStyle = l >= arrived ? '#2a8f4d' : '#d6d1c8'; ink.fillRect(12 + l * 40, 272, 34, 14);
      ink.fillStyle = '#444'; ink.font = '10px system-ui, sans-serif'; ink.fillText(`L${l}`, 12 + l * 40, 268);
    }
    ink.fillText('levels loaded (small first)', 340, 283);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>