A mipmap chain stores the image at half size, quarter size and so on down to 1 x 1: nine levels for a 256 x 384 cover (256 x 384, 128 x 192, ... 2 x 3, 1 x 1), a third more memory in total. When a texture is minified, the sampler reads the level whose texels are about the size of a screen pixel, so each lookup already averages the texels that fall into that pixel; without mips, a lookup picks a few texels out of many and small text shimmers (Mipmapping and generateMipmap covered the theory).
WebGL's gl.generateMipmap() hides real work: the driver runs a draw or blit per level, with a filter it chooses. WebGPU exposes no such hidden passes. How to downsample depends on the format (renderable? filterable? storage-capable?), on the filter you want (box, gamma-correct, alpha-weighted) and on whether you batch many textures into one command buffer, so the API leaves it to you, as Direct3D 12, Vulkan and Metal do at their core. You create the texture with mipLevelCount, and then fill the levels yourself: by rendering (Mipmap Blits), by a compute shader writing storage textures, or by uploading levels made offline (KTX2 and Basis). Libraries do it for you: Three.js 30,815 r186's WebGPURenderer generates mipmaps with render passes, the approach of the next listing (its WebGPUTexturePassUtils class).
<!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="340"></canvas>
<canvas id="labels" width="600" height="340"></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 SIZE = 128, LEVELS = Math.floor(Math.log2(SIZE)) + 1; // 8 levels: 128, 64, ..., 1
const tints = [[245, 240, 230], [120, 170, 230], [120, 200, 140], [240, 200, 90], [235, 130, 80], [190, 110, 190], [110, 110, 110], [60, 60, 60]];
// A floor of cover tiles in perspective: distant rows are minified, so higher levels are sampled.
const code = /* wgsl */ `
@group(0) @binding(0) var tiles: texture_2d<f32>;
@group(0) @binding(1) var smp: sampler;
@group(0) @binding(2) var<uniform> time: f32;
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));
let world = vec3f((q.x - 0.5) * 16, -1, 1 + q.y * 40); // a long floor
return Out(vec4f(world.x * 1.2, world.y * 1.6 + world.z * 0.8, 0.5 * world.z, world.z), vec2f(world.x, world.z - time));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(tiles, smp, in.uv * 2); }`;
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 });
// mipLevelCount allocates the chain; nothing fills it for you. Here every level is
// uploaded from JavaScript (a real app would downsample; see the next page).
const texture = device.createTexture({ size: [SIZE, SIZE], mipLevelCount: LEVELS, format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
for (let level = 0; level < LEVELS; level++) {
const n = SIZE >> level, px = new Uint8Array(n * n * 4);
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
const border = x < n / 8 || y < n / 8; // a darker tile edge
px.set([...tints[level].map((c) => (border ? c * 0.7 : c)), 255], (y * n + x) * 4);
}
device.queue.writeTexture({ texture, mipLevel: level }, px, { bytesPerRow: n * 4 }, [n, n]);
}
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } });
const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: device.createSampler({ addressModeU: 'repeat', addressModeV: 'repeat',
magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' }) },
{ binding: 2, resource: { buffer: time } }] });
ink.font = '12px system-ui, sans-serif';
tints.forEach((c, level) => {
ink.fillStyle = `rgb(${c.join(',')})`; ink.fillRect(12 + level * 73, 300, 22, 18);
ink.fillStyle = '#333'; ink.fillText(`L${level} ${SIZE >> level}px`, 38 + level * 73, 314);
});
ink.fillStyle = '#222'; ink.font = '13px system-ui, sans-serif';
ink.fillText('Each colour band is a mip level the sampler chose by the texels-per-pixel ratio.', 12, 22);
ink.fillText('WebGPU has no generateMipmap(): you fill the levels (render, compute or upload).', 12, 40);
function frame(now) {
device.queue.writeBuffer(time, 0, new Float32Array([now / 800]));
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, group);
pass.setViewport(0, 50, 600, 240, 0, 1);
pass.draw(4);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>