Filtering & mipmaps
How the GPU blends texels when a texture is drawn larger or smaller than its size, and why distant textures shimmer without mipmaps.
How it works
Pixels on screen and texelsOne element of a texture, the texture's version of a pixel. A sample blends nearby texels or returns the nearest one. Texel One element of a texture, the texture's version of a pixel. A sample blends nearby texels or returns the nearest one.texel size = 1 / textureSize(sampler, 0)
Magnification: nearest or linear
When a texture is drawn larger than its size, each pixel lands somewhere between texel centers.
- Nearest returns the closest texel. Edges stay hard and texels become visible squares: the right choice for pixel art and for data that must not be blended, such as indices into a palette.
- Linear (bilinear) blends the four nearest texels, weighted by how close the sample is to each center. The image gets soft instead of blocky. It is the default choice for photos and most surfaces.
Bilinear filtering is also a free blend: a sample placed exactly between two texels returns their average for the price of one read. Blur kernels use that to halve their number of reads.
Minification and aliasing
When a texture is drawn smaller than its size, bilinear filtering still reads only four texels per pixel and skips everything between. Which texels get read changes as the surface moves by a fraction of a pixel, so fine detail turns into noise that crawls and shimmers, and regular patterns such as bricks or stripes produce moiré. This is aliasing: the texture has more detail than the pixels can show, and point samples pick it up at random.
Mipmaps
A mipmapA chain of copies of a texture, each half the size of the one before. The GPU reads smaller copies for distant surfaces so they don't shimmer. Mipmap A chain of copies of a texture, each half the size of the one before. The GPU reads smaller copies for distant surfaces so they don't shimmer.level = log2(texels per pixel)
The GPU picks a level per pixel from how many texels the pixel covers. It measures that by comparing the texture coordinate across each 2×2 block of pixels it shades together: if the coordinate moves 4 texels from one pixel to the next, level 2 (4 = 2²) has about one texel per pixel. The level is the base-2 log of that footprint:
// at file scope:
uniform sampler2D uImage;
// inside main(): roughly the level the GPU would pick
vec2 texCoord = uv * vec2(textureSize(uImage, 0)); // in texels
vec2 dx = dFdx(texCoord); // change to the next pixel right
vec2 dy = dFdy(texCoord); // change to the next pixel up
float level = 0.5 * log2(max(dot(dx, dx), dot(dy, dy)));
The modes that use mipmaps differ in how they treat levels:
- Nearest mipmap (for example
LINEAR_MIPMAP_NEAREST) reads one level, bilinear inside it. Cheap, but the switch between levels can show as a visible line across a floor. - Trilinear (
LINEAR_MIPMAP_LINEAR) reads the two nearest levels bilinearly and blends them by the fractional level. It costs two bilinear reads and hides the switch.
In WebGL a texture’s minification filter starts as NEAREST_MIPMAP_LINEAR. Until you call gl.generateMipmap or set
a filter without mipmaps, the texture is incomplete and every read returns black. Textures you render into have no
mipmaps either, unless you generate them after each render.
Anisotropic filtering
On a floor seen at a grazing angle, a pixel’s footprint is a long thin strip: it covers many texels in depth and few
across. The level is chosen from the longer side, so the texture gets blurry across as well. Anisotropic filtering
takes several samples along the long side of the footprint from a sharper level, which keeps grazing surfaces crisp.
It is a sampler setting; in WebGL2 it comes from the EXT_texture_filter_anisotropic extension, with a maximum of 16
samples on most hardware.
Taking control in the shader
Three GLSL calls override the automatic choice:
texture(sampler, uv, bias)addsbiasto the level: negative is sharper (and more aliased), positive is softer.textureLod(sampler, uv, level)reads an exact level. It needs no neighboring pixels, so it is safe inside branches and loops, and it is how blur chains read a specific downsampled level.textureGrad(sampler, uv, dPdx, dPdy)takes the rate of change you give it instead of measuring it.
textureGrad fixes the seam that tiling with fract() leaves. At each tile border the wrapped coordinate jumps from 1
to 0, the measured change looks enormous, and the GPU reads the smallest level for one row of pixels. Measure the
change on the coordinate before wrapping it:
vec2 tiled = uv * 4.0;
vec4 c = textureGrad(uImage, fract(tiled), dFdx(tiled), dFdy(tiled)); // no seam
Math-only patterns have no mipmaps, so a fine procedural stripe aliases exactly like an unfiltered texture. The same derivatives that pick a mip level let a pattern blur itself by the width of a pixel, which the derivatives page covers.
Further reading
- Mipmap (opens in a new tab)Reference
- Anisotropic filtering (opens in a new tab)Reference
- textureLod (opens in a new tab)Reference