Textures & Sampling
Wrap, filter and mip a texture, and see what each sampler setting actually costs you.
What you should come away with: Why a texture looks wrong at a distance, and what mipmapping is really trading away.
Assumes: Coordinate Spaces. It will still make sense without it, but that one comes first.
One image covers the plane below, and by the far end of it the image has stopped surviving the trip. The checker breaks into rings; the fine grey lines disappear and reappear somewhere they are not; move the camera and the whole distance crawls. Nothing is wrong with the image and nothing is wrong with the plane. What is wrong is the fit between them — a pixel on screen and a in the image are not the same size, are almost never the same shape, and change their ratio from one end of a triangle to the other.
The plane is a single quad, 28 units across and 61 deep, running away from the camera until it is nearly edge-on, and one 256-by-256 image covers all of it. Turn the tile count down and a texel in the foreground is several pixels wide; near the far edge, one pixel covers a strip of ground tens of texels across and hundreds deep. Every control in this lab is an answer to the question that gap asks.
A pixel covers a different number of texels everywhere you look#
The shader knows none of this. It builds a from the vertex position — vUv = aPosition * uRepeat + uOffset — and reads the texture at it, and that is the whole of the texturing code. What varies across the image is not the coordinate but its rate of change: how far the UV moves when you step one pixel to the right. The hardware measures that by differencing the value between neighbouring pixels, and every decision below is made from the result.
The fine grey lines in this texture are one texel wide and repeat every eight; they are there to fail first. A pattern with a period of eight texels needs a sample at least every four to survive, so the moment a pixel covers more than that the lines cannot be represented at all — and a sampler with no answers anyway, with whichever single texel it happened to land on. The rings and bands in the distance are that: the texel grid beating against the pixel grid. Move the camera and the answer changes every frame; that is the shimmer, and the rest of this page is about removing it.
Magnification and minification are two different failures#
You set two , and at any given pixel exactly one of them runs. The hardware compares the footprint against a single texel: smaller, and the texture is being magnified; larger, and it is being minified. You do not get to make that call per pixel, and you would not want to — as the figure above shows, it changes down the length of one triangle.
Nearest magnification is not a lower setting. It is the correct one whenever the texels are the artwork rather than samples of something continuous: pixel art, a glyph atlas at integer scale, a lookup table you are indexing rather than sampling. It is wrong for a photograph, where the texels stand in for a surface that had no squares in it.
There are only two values here because OpenGL offers only two. When one texel covers many pixels there is no smaller version of the image that would help; mipmaps are only ever an answer to minification.
A mipmap is the average taken in advance#
The far end of the plane needs the average of a few hundred texels per pixel, and reading a few hundred texels per pixel is not something a GPU will do at frame rate. So the averages are computed once, ahead of time, at every scale that might be wanted: the image at 256, then 128, 64, 32 and down to a single texel — nine levels for this texture, built by the one generateMipmap call that runs when the lab starts. A pixel whose footprint spans sixteen texels then reads level 4, on which every texel is already the average of a sixteen-by-sixteen block.
That costs memory, and the amount is fixed: each level is a quarter of the one above, so the chain adds exactly a third. 256 KiB of texture becomes 341. It is also, on almost any real scene, faster. A minifying sampler reading level 0 lands on texels scattered across the whole image and misses the texture cache on nearly every pixel; reading a level scaled to the footprint means neighbouring pixels read neighbouring texels. Mipmapping usually buys speed with the memory rather than spending both. The chain is built here whichever filter you pick, which is why the readout below says 1 used rather than 1 when you turn mipmapping off — the memory has already gone.
One thing it gets wrong. generateMipmap averages the bytes as they are stored, and for a colour texture those bytes are sRGB-encoded, which means they are not proportional to light. The light and dark squares of this checker are 232 and 44 in the red channel; averaged as stored they give 138, where averaging the light they stand for and re-encoding gives 173. Every level of the chain is therefore a little too dark. That is a small instance of the mistake Colour & Gamma is built around.
The five minification settings are not a scale from worse to better. Three of them name two independent decisions — how to filter inside a level, and how to move between levels.
N·mip N the handover is a hard horizontal boundary partway down the plane, with the ground visibly coarser above it than below. Filtering inside the level softens that boundary without moving it. Trilinear blends the two levels either side of it and there is no boundary left to find.#Open this in the full instrument Trilinear is both halves set to linear: bilinear in each of the two levels bracketing the footprint, then blended between them, at eight texel reads to nearest’s one. It is the usual default because the artefact it removes is the one you cannot stop noticing once you have seen it.
OpenGL has a sixth combination this lab leaves out, NEAREST_MIPMAP_LINEAR, and it is the value every texture starts with. That is worth knowing for one reason: the default minification filter reads the mip chain, so a texture uploaded without one and never configured samples as black.
A shallow angle stretches the footprint#
Everything above assumed the footprint is roughly square. It is square only when you are looking straight down at the surface. Tilt towards the horizon and it stretches along the viewing direction, until one pixel can cover ten texels across the plane and several hundred into it.
The level has to be chosen from one number. Choose it for the short axis of the footprint and the long axis ; choose it for the long axis, which is what the hardware does, and the short axis is blurred by exactly the footprint’s aspect ratio. A road surface at a grazing angle is the standard case, and it looks like mud.
Anisotropic filtering is the way out — several samples spread along the long axis, each taken from a finer level and averaged, up to sixteen taps for a sixteen-to-one footprint. In WebGL 1 it is an extension, EXT_texture_filter_anisotropic, and this lab does not enable it. What you are looking at is isotropic filtering doing the best it can.
Wrap decides what is outside 0 to 1#
The UVs here are not confined to 0 to 1 and were never going to be. They come straight from the vertex position, so at six tiles across, u runs from −3 to 3 and v from −12.9 to 0.2. The sampler needs an answer for all of it. is that answer, set separately per axis: S across the width of the plane, T along its length, running away from you.
CLAMP_TO_EDGE is what you have been watching: outside the range, take the nearest edge texel and keep taking it. It is the right behaviour for an image meant to be used once — a photograph on a billboard, a gradient ramp — and it is why the cyan border smears instead of the picture starting over.
REPEAT discards the whole-number part, so u = 3.4 and u = 0.4 read the same texel and the tile begins again. MIRRORED_REPEAT flips alternate tiles, so every seam meets its own reflection: the way to tile an image whose left edge does not match its right, at no extra cost. This texture’s borders match already — the same cyan on all four sides — so what you can actually see change when you switch to mirror is the amber L pointing the other way in every second tile.
Repeat, mirrored repeat and the mip chain all need a power-of-two texture in WebGL 1. This one is 256 square for that reason. A 257-pixel image gets clamping, no mipmaps, and no explanation.
What I got wrong here: a control that did nothing#
This lab shipped with a magnification filter that was wired correctly and never once mattered. The belief behind it was that a control which reads the right value and calls the right texParameteri is a working control — that the wiring is the feature. The note beside it described what happens up close, from a lab that opens looking down a heavily tiled plane and offers no view that is up close: every state it offered by name tiled the plane further, so every texel on screen was smaller than a pixel and the sampler was minifying everywhere.
The symptom was that flipping between Nearest and Linear changed nothing at all, and nothing is the one result a reader will not report. The difference this control makes is small where it makes one — a hard texel edge against a ramp a texel wide — so a reader who saw no change had an explanation ready: they were not looking closely enough, or this is a setting you take on faith. The page offered nothing to correct that with. A control with no work to do looked like a fine distinction.
What caught it was measurement rather than reading. A sweep on 8 September 2026 drove every control on every lab in a real browser and compared the canvas before and after: the magnification filter moved 0.000 per cent of the picture, the only control on the site that moved none.
The fix, in commit e49a799, was not to the filter. It was a preset, Close enough to magnify, that puts one tile under a camera looking down at it, and a line in the panel that says Nothing to see from here whenever the view is not one where the control has anything to do. It is guarded by the check moving a control changes the picture in test/render.smoke.ts, which applies that preset, flips the filter and measures the canvas: 26.4 per cent of the picture moves, against a noise floor of zero. Remove the preset and the number goes back to nothing and the check fails.
Now move all of it at once#
Below is the whole sampler, every setting of it live at once: both filters, both wrap axes, the tile count, the coordinate offset, and a camera you can drag.
Drag it. Every figure on this page is a still frame, and aliasing is mostly a motion artefact — standing still it is moiré, and the moment the camera moves the whole distance starts to boil. Set minification to Near, push the tile count up, orbit slowly, and then switch to Tri without touching anything else. The presets set states worth looking at and say what to look for once they land.
- orientation marker
- tile edge
What the sampler is doing
- texture
- 256²
- mip levels
- 9
- tiles across
- 6.0
- wrap
- R / R
The far end of this plane compresses hundreds of texels into one pixel. A sampler with no mip chain has to pick one of them and the choice changes every frame you move — which is the shimmer. A mipmap is the same image pre-shrunk nine times, so there is always a level where one texel is about one pixel. It costs a third more memory and removes an entire class of artefact, which is why it is almost always the right default.
Where that leaves you
You can now tell magnification from minification by looking at the picture, choose a filter for a reason rather than out of habit, and say what a mip chain costs in memory and usually gives back in cache.
This did not teach you where UV coordinates come from. The plane here builds its own out of the vertex position, which no real asset does — unwrapping a mesh, the seams that costs and the atlas the pieces are packed into are decided in a modelling tool long before a sampler sees them, and nothing on this site has one.