Praxis · M2 · ticket #153 · pull requests #174, #173

Why distant textures need help

A field guide to mips, anisotropic filtering, normal maps and Toksvig, told through the two Reference scenes that test them: a glossy floor that stretches to the horizon, and a wall of 10,000 tiny textures.

Chapter 1

Texels and pixels

A texture is an image wrapped onto a surface. Its own pixels are called texels, to keep them apart from the screen's pixels. The minification floor uses a 256 × 256 checkerboard, repeated every metre across a floor 100 metres long.

For every screen pixel, the GPU asks one question: which colour of the texture belongs here? When the surface is close, one texel covers several pixels and the answer is easy. When the surface is far away, the opposite happens: one pixel covers many texels. This is minification, and the scene is named after it.

Close up: magnification 4 texels, 64 pixels (blue grid) Far away: minification one pixel (blue) over 64 texels One sample per pixel The orange dot is the only texel read. Black or white by luck of position: neighbouring pixels disagree at random. What the eye expects is the average of all 64: an even grey. the right answer
The core problem. Reading one texel per pixel is fine up close and wrong far away, where a pixel spans many texels and its colour should be their average.
Chapter 2

Mips: the texture, pre-shrunk

Averaging 64 texels for every pixel of every frame would be far too slow. Instead, the cooker does the averaging once, ahead of time. Next to the full image it stores a half-size copy where each texel is the average of a 2 × 2 block, then a quarter-size copy, and so on down to a single texel. These copies are mips (from multum in parvo, "much in little"). Together they form the mip chain.

At draw time the GPU measures how many texels the pixel spans and reads the mip whose texels are about one pixel big. It blends the two nearest mips so there is no visible seam between them. That blending is the trilinear filtering the engine uses.

A checker's mip chain, each level drawn at the same size. Checks of 8 texels survive until the 8 × 8 level, where each texel holds exactly one check. One level further, every texel averages a black and a white check, so the rest of the chain is uniform grey.

Average in light, not in numbers

Image files store brightness through the sRGB curve, which spends more numbers on dark shades because our eyes are sensitive there. Average the stored numbers of black (0) and white (255) and you get 128, which displays noticeably too dark. Average the actual light and you get half the light, which sRGB stores as 188. The cooker builds mips in linear light for this reason, and a cook test checks for 188.

fine checker
average numbers: 128
average light: 188
Squint at the checker. From a distance it blends to the lighter grey on the right, not the middle one.

Try it: one sample, or mips

This is a small software render of a checker floor stretching away from a camera 1.2 m above it. With one sample per pixel the distance dissolves into moiré, interference rings that were never in the texture. With mips each pixel shows the average of what it covers, and the distance settles into grey.

Live demo. "Averaged" here takes 36 samples per pixel, a brute-force stand-in for the true average that mips approximate cheaply. On the GPU, mips get close to it with eight texel reads from two pre-built levels. Near the horizon, where one pixel spans hundreds of checks, even the 36 samples leave faint rings.
Chapter 3

Anisotropy: footprints that are long and thin

A pixel is square on the screen, but its footprint on a floor seen at a grazing angle is not. It stays narrow across the floor and stretches far into the distance. Plain mips can only pick one square texel size. If it matches the long side, the texture blurs across. If it matches the short side, it aliases along.

Anisotropic filtering handles the stretch. It reads the mip that matches the short side, then takes several samples along the long side and averages them. The engine allows up to 16x, sixteen samples along one footprint. Sharp detail survives across the floor, and the distance still averages along it. (Since #257, merged on 2026-10-22 as f4c7442, 16x is the limit for colour textures; data textures, such as normal and roughness maps, allow 4x. Anisotropy Explained tells why.)

On screen one square pixel On the floor: isotropic one big mip read covers far more than the footprint: blur On the floor: anisotropic up to 16 small reads along the footprint: sharp across, smooth along
The footprint of one pixel on a floor seen at a few degrees of elevation. At 16 m from the minification camera, in the scene's 640 × 360 Golden, it is about 9 texels wide and 126 long; at 2560 × 1440, about 2.4 by 31.
Chapter 4

Normal maps: bumps made of arrows

A surface's normal is the direction it faces. Lighting depends on it. A normal facing the light is bright; one facing away is dark. For a shiny surface, the highlight appears where the normal sits halfway between the direction to the light and the direction to your eye.

A normal map is a texture that stores a tilted normal per texel instead of a colour. The geometry stays perfectly flat, but lighting reads the stored tilts, so the surface looks bumpy. The minification floor's normal map holds a grid of round bumps, one every 8 texels, about 3 cm apart, with slopes up to 27°.

Real bumps (geometry) Flat surface + normal map Blue arrows: the normals lighting sees. The coloured strip on the right is the normal map itself.
Same arrows, same lighting. The normal map gives a flat floor the shading of real bumps without adding a single triangle. Each texel's colour encodes its arrow: red the tilt left or right, green the tilt along the floor's other direction (none in this side view, so the middle value), blue how straight up it points.

Roughness: sharp or spread highlights

Every point of a surface also has a roughness. A smooth surface (low roughness) is a near mirror and shows a light as a small, intense highlight. A rough surface scatters the light into a broad, dim glow. The total reflected light is about the same; rough surfaces spread it out. The floor's roughness is 0.2, fairly glossy, like polished stone.

Chapter 5

Toksvig: when bumps get too small to see

Mips solve colour textures, but they quietly break normal maps. Up close, each pixel covers part of one bump and sees one tilted normal: you get glints where bumps face the light. Far away, one pixel covers many bumps tilted every way. Physically, those many little mirrors scatter the light in all those directions. The pixel should show a broad, dim glow, just like a rough surface would.

Now look at what a mip does to that pixel. It averages the arrows. Arrows tilted left and right cancel out, and the average points straight up: flat. Flat and glossy is a near mirror. The distance renders as a razor-sharp, far too bright line, and it flickers as the camera moves, because tiny changes decide whether the mirror catches the light.

Michael Toksvig (NVIDIA, 2004) noticed a measurable clue. Average unit-length arrows that disagree and the result is shorter than 1. The more they disagree, the shorter it gets. That length tells you how bumpy the area under the pixel was, even after the bumps themselves have averaged away.

The cooker uses that clue. For every mip of the floor's roughness texture, it measures the arrows' disagreement in the matching mip of the normal map and raises the roughness by it. The floor's distance becomes rougher, and the light spreads about as widely as the real bumps would spread it. The roughness texture is "paired" with its normal map for this reason, and the pull request that added the minification scene (#174) extended that pairing to procedural scenes like this one.

Live demo. Left: one row of the normals under one pixel, seen from the side (blue), and the average of all of them (orange). Slide right to move the floor farther away: more bumps under each pixel, and the average shortens, then holds near 0.94 once a pixel spans about a whole bump. Right: how the highlight is spread over angle, in the side view's plane. Green is the truth, the average of every point's own glossy highlight. Red is plain mips, a flat mirror. Blue is Toksvig, roughness widened from the shortened average. Once several bumps share a pixel, red spikes far above the chart, while blue spreads the light over a similar range of angles to green: one smooth lobe where the bumps make two peaks, at their steepest tilts. Toksvig gets the highlight's width roughly right, not its shape.

The roughness widening has a short formula. If the averaged arrow has length |m|, the arrows' spread is σ² = (1 − |m|) / |m|. The highlight's width parameter (GGX α, which is roughness squared) grows as α′² = α² + 2σ². The second step comes from Kaplanyan and colleagues (HPG 2016). For the floor's bumps that turns roughness 0.2 into about 0.6 in the distance. That is the difference between a laser line and the soft glow in the Golden.

Many engines hide this flicker by blending several frames together (temporal anti-aliasing). Praxis avoids temporal tricks by design (ADR 0008), so every frame has to be right on its own, and Toksvig is the floor's main tool for that. (It is not the whole answer. Later work on #184 found that at 1440p the anisotropic filtering of chapter 3 read the normal and roughness maps from a finer mip, whose widening covers the footprint's width but not its length, so the highlight sparkled. Since #257 those maps are read at 4x, which removed the sparkle; Anisotropy Explained tells that story.)

Chapter 6 · PR #174

The minification scene

Everything above is packed into one Reference scene. A 100 m glossy checker floor, seen from 1.2 m up at a grazing angle. A bump normal map, and one point light hovering 1 m over the floor 30 m ahead. The floor mirrors that light about 16 m out, deep in the minified zone, so the reflection's shape depends on Toksvig.

Its image is a Golden: a reviewed render committed to the repository. Every test run renders the scene again and compares it pixel by pixel, allowing one step out of 255 for rounding. Each run compares two renders with the Golden: one at the origin, and one shifted 100 km along both horizontal axes. Both must match, which proves no large world coordinate reaches the GPU in low precision.

The minification Golden: a checker floor fading smoothly to grey with a soft reflected streakGolden, Metal (M4 Pro), 640 × 360, as recorded at M2
What correct looks like. Crisp checks near the bottom, glints where bumps face the light. The checks fade to a smooth grey toward the horizon, and the light's reflection is a soft streak that opens into a haze.

Proving the test can fail

A test that never fails proves nothing, so each safeguard was switched off on purpose to check that the Golden catches it. Each broken render was compared against the Golden. The difference image dims the broken render to a third and paints every pixel that differs by more than one step in red.

Cooked without mips: moiré rings across the floor and a noisy streakCooked without mips
Difference image: red almost everywhere past the near floorDifference: 119,995 pixels off (Metal), 99,497 (software Vulkan)
No mips. The cooker stored only the full-size textures. Moiré rings cover the floor, and the reflection breaks into noise, because each pixel picks one random bump tilt. Red covers nearly everything beyond a few metres.
Cooked without Toksvig: a hard saturated white line instead of a soft glowCooked without Toksvig
Difference image: a wide red fan around the reflectionDifference: 56,945 pixels off (Metal), 61,231 (software Vulkan)
No Toksvig. The mips are there, so the checker is fine, but the roughness is never widened. The averaged-flat normals turn the floor into a mirror and the soft glow collapses into a hard white line. The red fan is wide because roughness changes how light spreads over a whole region, not just the bright core.
Chapter 7 · PR #173

The texture wall: 10,000 textures in one list

A shader can only read textures it has been handed before drawing. Praxis hands over each kind of texture as one big numbered list, a binding array: one list for colour, one for normals, one for roughness, and so on. Each list is a Channel, and a position in it is a Layer. A material stores Layer numbers, so a draw needs no texture swapping at all. Slot 0 always holds a neutral default, so a material without, say, a normal map still has something harmless to read. An open world streams thousands of textures through these lists, so the scene asks: do they still work at 10,000?

Albedo Channel (one binding array) Quad k's Material stores Layer 101k + 1, so the 100 quads reach slots 1 to 10,000: both ends of the list. The other 9,900 textures are loaded and bound but never drawn: they are there to make the list full size.

Each of the 10,000 textures is 4 × 4 texels of one flat colour, and the colour spells out its own number. Red is its last digit, green its tens digit, each in steps of 28, and blue its hundreds. If any quad read the wrong slot, it would show the wrong colour, and the Golden would catch it.

Decode a colour. Neighbouring numbers always differ by at least 28 steps of red, so a slot read one off is never a near miss.
The texture-wall Golden: a 10 by 10 grid, red increasing to the right, green increasing downwardGolden, Metal, as recorded at M2
Off-by-one Layer: top-left quad white, first column redBug planted: every texture registered one slot too low
Reading the grid. Red grows left to right, green top to bottom, and blue across the whole grid. The top-left quad is texture 0, black; the bottom-right is texture 9,999, light grey. With the planted bug, every quad shows its neighbour's colour. Quad 10 now reads texture 1,009, whose last digit 9 means full red, so the first column turns red. Quad 0 reads slot 0, the neutral white. 57,999 pixels off.
Chapter 8 · the gate

What 10,000 bound textures cost

Each frame has a budget of 6 ms of CPU time on the M4 Pro (ADR 0013). The architecture decision ADR 0017 set a gate before streaming could build on binding arrays. Rebuilding the 10,000-entry list, plus drawing one frame with it bound, must fit in those 6 ms at the median and at p95 (the slowest frame in twenty).

Two costs showed up. Rebuilding the list happens when textures arrive or leave, and costs about 2.5 to 3 ms. Keeping it bound costs something every single frame. On Macs, wgpu, the graphics library Praxis uses, asks Metal to keep each texture in the list resident, one call per texture, each time the list is bound, so every frame. Those calls alone are almost half of a 2.1 ms bill (45% in #153's profile, with wgpu's own tracking of each texture another 17%), against 0.04 ms when only the 100 drawn textures and each Channel's default are bound. Metal can also make many textures resident at once, through heaps or residency sets, which is what the first option below is about.

Medians from three rounds on the M4 Pro (load average 6 to 10), worst p95 marked with a tick where one was recorded; each count includes the five defaults at Layer 0, one per Channel. Rebuild plus frame fits the 6 ms line at the median in every round, but its p95 touched 6.02 ms in one round, so the gate was recorded as marginal: at the line, not inside it. (Measured again the same day on a quiet machine at the end of milestone M2, in #186, it fitted in every run: at most 4.74 ms at the median and 5.01 ms at p95, with the bound list costing 1.1 to 1.8 ms a frame.) The GPU pays too: binding the full list adds about 0.35 ms to every main pass (445 µs against 98). (That is only what the main pass's own timer sees. Measured later, texture-wall's whole GPU frame takes about 15 ms, over the 12 ms GPU budget; the project puts that down to Metal making its 10,000 textures resident, an inference not yet profiled, and tracks it on #183.)

The open decision is what to do about the steady per-frame cost before the streaming milestone (M4). The options are cheaper residency on Metal, binding only the textures visible each frame, grouping textures into a few large arrays, or capping how many stay bound. The recommendation on #173 is to research those before committing.

Reference

Glossary

Texel
One pixel of a texture.
Minification
A surface so far or so tilted that one screen pixel covers many texels.
Aliasing, moiré
False patterns from reading too few samples of a detailed texture.
Mip, mip chain
Pre-averaged half-size copies of a texture, down to 1 × 1.
Trilinear filtering
Reading the two nearest mips and blending between them.
Anisotropic filtering
Several samples along a stretched footprint, up to 16 here.
Linear light
Brightness as physical light, before the sRGB storage curve.
Normal map
A texture of surface tilts that lighting treats as bumps.
Roughness
How much a surface spreads a highlight; 0 is a mirror.
Toksvig
Widening roughness by how much a normal map's averaged tilts disagree.
Cooking
Turning source images into the engine's compressed, mip-mapped files ahead of time.
Golden
A committed reference image every test run must reproduce.
Binding array, Channel, Layer
One numbered list of textures per kind; a Layer is a slot in it.
Median, p95
The typical frame, and the slowest frame in twenty.