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:
Small levels first. Upload a tiny preview (or the bottom mip levels from a KTX2 file) at once, and the large levels later. A view with baseMipLevel sees only the levels that have arrived, so bind covers.createView({ baseMipLevel: 4 }) until level 0 is in, then switch to the full view. A sampler's lodMinClamp does the same per sampler.
Budget by screen size. A cover drawn 50 pixels wide never needs a level wider than 64; load levels on demand from the distance or the projected size, and evict the largest levels of textures that leave the view.
Fixed-size pools. Allocate texture arrays with a fixed layer count and recycle layers instead of creating and destroying textures; texture.destroy() frees memory at once, without waiting for garbage collection.
Placeholders. Bind a 1 x 1 neutral texture or a blurred preview while decoding, so pipelines and bind groups never wait for the network.
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.
<!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>