Far away, one pixel covers many texels, and sampling four of them skips the rest: detail turns into shimmering noise. A mipmap chain stores the image at half, quarter and smaller sizes down to 1 x 1 (a third more memory), and the GPU reads the level whose texels match the pixel. generateMipmap(gl.TEXTURE_2D) builds it, and LINEAR_MIPMAP_LINEAR (trilinear) blends two levels. The figure tiles a cover over a floor 40 units long:
<canvas id="c" width="1240" height="270" style="width:620px;height:135px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script type="module">
const gl = GLH.context('c'), { mat4, glMatrix: { toRadian } } = glMatrix;
const { attributes, uniforms } = GLH.load(gl, `#version 300 es
in vec3 aPosition; in vec2 aUV; uniform mat4 uViewProjection; out vec2 vUV;
void main() { vUV = aUV; gl_Position = uViewProjection * vec4(aPosition, 1.0); }`,
`#version 300 es
precision mediump float; uniform sampler2D uCover; in vec2 vUV; out vec4 c;
void main() { c = texture(uCover, vUV); }`);
const floor = [-2, 0, 0, 0, 0, 2, 0, 0, 6, 0, 2, 0, -40, 6, 40, -2, 0, -40, 0, 40];
const vao = GLH.vao(gl, attributes, new Float32Array(floor), [['aPosition', 3], ['aUV', 2]],
new Uint16Array([0, 1, 2, 0, 2, 3])); // uv runs to 6 x 40: the cover repeats
const cover = GLH.texture(gl, await GLH.image('booknest-cover-3.png'), { wrap: gl.REPEAT });
const ext = gl.getExtension('EXT_texture_filter_anisotropic'), T = gl.TEXTURE_2D;
gl.bindVertexArray(vao);
GLH.panes(gl, 3, (pane, aspect) => {
gl.texParameteri(T, gl.TEXTURE_MIN_FILTER, pane === 0 ? gl.LINEAR : gl.LINEAR_MIPMAP_LINEAR);
gl.texParameterf(T, ext.TEXTURE_MAX_ANISOTROPY_EXT, pane === 2 ? 16 : 1);
const projection = mat4.perspective([], toRadian(50), aspect, 0.1, 100);
uniforms.uViewProjection(mat4.multiply([], projection,
mat4.lookAt([], [0, 0.45, 0.4], [0, 0, -1.2], [0, 1, 0])));
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
});
window.__done = true;
</script>
Without mipmaps (left) the distant titles break into sparkling fragments. Trilinear filtering (middle) removes the noise but smears the floor a few rows back, because the level suits the pixel's longest footprint, its depth on a floor seen edge-on. Regenerate mipmaps after each full upload, never for per-frame textures.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font-family: monospace; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1200" height="300"></canvas>
<div class="names"><div>MIN_FILTER LINEAR (no mipmaps)</div><div>LINEAR_MIPMAP_LINEAR</div><div>+ 16x anisotropic</div></div>
<script>
const m4 = {
perspective(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
},
lookAt(eye, target, up = [0, 1, 0]) {
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const unit = (v) => v.map((c) => c / Math.hypot(...v));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
},
multiply(a, b) {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
},
};
// A cover with fine title text, drawn on a 2D canvas: detail that aliases when shrunk.
const cover = document.createElement('canvas');
cover.width = 256; cover.height = 384;
const g = cover.getContext('2d');
g.fillStyle = '#e09a10'; g.fillRect(0, 0, 256, 384);
g.fillStyle = '#5b3f99'; g.fillRect(12, 12, 232, 360);
g.fillStyle = '#f2ead8'; g.textAlign = 'center'; g.font = 'bold 40px Georgia';
g.fillText('Salt and', 128, 150); g.fillText('Saffron', 128, 196);
g.font = '18px sans-serif';
for (let i = 0; i < 6; i++) g.fillText('a BookNest cookbook', 128, 250 + i * 20);
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec2 aUV;
uniform mat4 uViewProjection; out vec2 vUV;
void main() { vUV = aUV; gl_Position = uViewProjection * vec4(aPosition, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform sampler2D uCover; in vec2 vUV; out vec4 c;
void main() { c = texture(uCover, vUV); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
// A floor 40 units long whose uv runs to 6 x 40: the cover repeats.
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-2, 0, 0, 0, 0, 2, 0, 0, 6, 0, 2, 0, -40, 6, 40,
-2, 0, 0, 0, 0, 2, 0, -40, 6, 40, -2, 0, -40, 0, 40]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 12); gl.enableVertexAttribArray(1);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, cover);
gl.generateMipmap(gl.TEXTURE_2D); // half, quarter, ... down to 1 x 1
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
const ext = gl.getExtension('EXT_texture_filter_anisotropic');
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uViewProjection'), false,
m4.multiply(m4.perspective(50, 400 / 300, 0.1, 100), m4.lookAt([0, 0.45, 0.4], [0, 0, -1.2])));
gl.enable(gl.SCISSOR_TEST);
for (let pane = 0; pane < 3; pane++) {
gl.viewport(pane * 400, 0, 400, 300); gl.scissor(pane * 400 + 3, 0, 394, 300);
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, pane === 0 ? gl.LINEAR : gl.LINEAR_MIPMAP_LINEAR);
if (ext) gl.texParameterf(gl.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, pane === 2 ? 16 : 1);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
</script>