The Texture Needs a Smaller Copy
A texture lookup looks innocent:
color = texture(u, v)
But a screen pixel is not a mathematical point. It is a small measurement window. If the surface mapping sends that window across six, twelve, or twenty texels, a single lookup is no longer asking a fair question. It is asking one texel to speak for a neighborhood.
That is where minification aliasing comes from. The renderer samples a high-frequency texture below the rate needed to represent it, and the lost frequencies reappear as crawl, shimmer, stripes, and moire.
The mipmap fix is almost embarrassingly concrete:
if one screen pixel covers many texels,
look up a smaller prefiltered copy of the texture
The smaller copy is not just a cache trick. It is a stored approximation to the area average the pixel was asking for all along.
A Pixel Asks For an Area
Let a texture be a function \(T(u,v)\). Let a screen pixel project to some region \(P\) in texture coordinates. The antialiasing answer is not:
\[T(u_0, v_0).\]It is closer to:
\[\frac{1}{|P|}\int_P T(u,v)\,du\,dv.\]That integral is the expensive thing. The footprint changes with depth, surface orientation, projection, UV mapping, and animation. A renderer could average all source texels under the footprint for every pixel, but that turns a texture lookup into a variable-size convolution.
Lance Williams’ 1983 “Pyramidal Parametrics” paper attacked this cost by precomputing a pyramid of filtered images.1 Each level halves the linear resolution of the previous level. Level 0 is the original texture. Level 1 stores averages for roughly 2-by-2 neighborhoods. Level 2 stores averages for 4-by-4 neighborhoods. Continue until the texture has collapsed to a tiny image.
For a square texture, the storage overhead is bounded by the geometric series:
\[\frac{1}{4}+\frac{1}{16}+\frac{1}{64}+\cdots = \frac{1}{3}.\]About one third more storage buys constant-time access to many approximate filter widths.
The Third Coordinate
A bilinear texture lookup has two coordinates, \(u\) and \(v\). A mipmapped lookup has a third coordinate: level of detail.
If a pixel footprint is about \(\rho\) texels wide, the usual scale heuristic is:
\[\ell = \log_2 \rho.\]So:
footprint 1 texel -> level 0
footprint 2 texels -> level 1
footprint 4 texels -> level 2
footprint 8 texels -> level 3
When \(\ell\) lands between levels, trilinear filtering blends the two adjacent levels: bilinear inside each image, linear between images. Williams described this as interpolation within and between the pyramid levels, with a coordinate often written as \(D\) for depth in the pyramid.1
The important part is not the letter. It is the contract:
choose the prefiltered image whose texel size resembles the pixel footprint
That converts a moving area-average problem into a few texture reads from a precomputed hierarchy.
Why This Is Only an Approximation
The ideal footprint is rarely a square. When a surface is seen at a grazing angle, one screen pixel may cover a long thin region in texture space. A normal mipmap is isotropic: each lower level filters equally in both texture axes. It can choose “smaller” or “larger”, but not “long in one direction and narrow in the other.”
Paul Heckbert’s 1989 report frames texture mapping and image warping as resampling problems, with the antialiasing filter generally varying across the image.2 That is the more honest theory: the right filter follows the mapping. A mipmap is a fast restricted family of filters.
Frank Crow’s summed-area tables are a useful contrast.3 Instead of storing many prefiltered images, a summed-area table stores cumulative sums so axis-aligned rectangular averages can be queried quickly. That is a different precomputation for a different filter family. The common idea is the same: expensive area integration can be moved partly out of the inner pixel loop.
So the evidence line is:
mipmaps are a practical prefiltering approximation, not the ideal texture filter
The engineering line is:
the approximation is cheap, stable, cache-friendly, and usually much better
than point sampling under minification
Lab: Make the Texture Smaller First
The lab below builds a procedural 256-by-256 grayscale texture, constructs a mipmap pyramid by repeated 2-by-2 averaging, then renders a minified strip three ways:
- point sampling from the original texture;
- trilinear mip sampling with \(\ell=\log_2(\rho)\);
- a slow supersampled reference that averages many samples over the pixel footprint.
The RMS errors are measured against that finite supersampled reference. This is not a full renderer. There is no perspective triangle rasterization, derivative tracking, gamma-correct filtering, compression, anisotropic filtering, EWA filtering, or GPU cache model. The point is narrower: when a pixel footprint covers multiple texels, prefiltering before lookup should move the result toward an area average.
With the default settings:
texture frequency: 18
pixel footprint: 6.0 texels
selected LOD: log2(6) = 2.585
point RMS: 0.10216
mipmap RMS: 0.05651
That is a 45% error reduction against the lab’s supersampled reference.
Reproducibility Check
The lab exposes the same computation as a small CommonJS module, so the headline numbers can be audited without a browser:
const lab = require("./assets/js/mipmap-lab.js");
const EXPECTED_TOTALS = {
rows: 4,
passed: 4,
total: 4,
passedChecks: 24,
totalChecks: 24
};
const EXPECTED_CASE_ROWS = [
"1:18:6:17:85:8:0.10216:0.05651:45:2.585:6/6:true",
"2:8:2:0:60:6:0.02676:0.00246:91:1.000:6/6:true",
"3:28:12:91:90:8:0.21106:0.02582:88:3.585:6/6:true",
"4:40:24:43:75:8:0.19227:0.00549:97:4.585:6/6:true"
];
const EXPECTED_CHECKS = [
{ name: "pyramid starts with 256 square texture", passed: true },
{ name: "pyramid sizes halve", passed: true },
{ name: "mipmap RMS does not exceed point RMS under minification", passed: true },
{ name: "sample means remain bounded", passed: true },
{ name: "selected LOD matches footprint", passed: true },
{ name: "downsampling preserves constants", passed: true }
];
function sameJson(actual, expected) {
return JSON.stringify(actual) === JSON.stringify(expected);
}
function rowDrifts(actualRows, expectedRows) {
const drifts = [];
const rows = Math.max(actualRows.length, expectedRows.length);
for (let index = 0; index < rows; index += 1) {
const actual = index < actualRows.length ? actualRows[index] : null;
const expected = index < expectedRows.length ? expectedRows[index] : null;
if (!sameJson(actual, expected)) {
drifts.push({ index, actual, expected });
}
}
return drifts;
}
const audit = lab.auditGrid();
const failed = audit.cases.filter(
(row) => !row.passed || row.passedChecks !== row.totalChecks
);
const totals = {
rows: audit.cases.length,
passed: audit.passed,
total: audit.total,
passedChecks: audit.passedChecks,
totalChecks: audit.totalChecks
};
const caseRows = audit.cases.map((row, index) => [
index + 1,
row.params.frequency,
row.params.footprint,
row.params.phase,
row.params.contrast,
row.params.levels,
row.pointRms.toFixed(5),
row.mipRms.toFixed(5),
Math.round(100 * row.errorReduction),
row.selectedLod.toFixed(3),
`${row.passedChecks}/${row.totalChecks}`,
row.passed
].join(":"));
const checkRows = audit.cases.map((row, index) => ({
caseId: index + 1,
checks: row.checks.map((check) => ({
name: check.name,
passed: check.passed
}))
}));
const expectedCheckRows = EXPECTED_CASE_ROWS.map((row) => ({
caseId: Number(row.split(":")[0]),
checks: EXPECTED_CHECKS
}));
const caseRowDrifts = rowDrifts(caseRows, EXPECTED_CASE_ROWS);
const checkRowDrifts = rowDrifts(checkRows, expectedCheckRows);
const driftShape = {
totals,
caseRowDrifts,
checkRowDrifts
};
const shapeErrors = [
sameJson(totals, EXPECTED_TOTALS) ? null : "totals",
caseRowDrifts.length === 0 ? null : "caseRows",
checkRowDrifts.length === 0 ? null : "checkRows"
].filter(Boolean);
if (shapeErrors.length) {
throw new Error(
`mipmap audit shape drifted in ${shapeErrors.join(", ")}:\n` +
JSON.stringify(driftShape, null, 2)
);
}
const summary =
`${audit.passed}/${audit.total} cases and ` +
`${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
failed.length ||
!audit.ok ||
audit.passed !== audit.total ||
audit.passedChecks !== audit.totalChecks
) {
throw new Error(`${summary}: ${JSON.stringify(failed, null, 2)}`);
}
console.table(audit.cases.map((row) => ({
frequency: row.params.frequency,
footprint: row.params.footprint,
checks: `${row.passedChecks}/${row.totalChecks}`,
pointRms: row.pointRms.toFixed(5),
mipRms: row.mipRms.toFixed(5),
reduction: `${Math.round(100 * row.errorReduction)}%`,
lod: row.selectedLod.toFixed(3),
passed: row.passed
})));
console.log(summary);
On my run, the cases are:
| Texture frequency | Footprint | Point RMS | Mipmap RMS | Error reduction | LOD |
|---|---|---|---|---|---|
| 18 | 6 | 0.10216 | 0.05651 | 45% | 2.585 |
| 8 | 2 | 0.02676 | 0.00246 | 91% | 1.000 |
| 28 | 12 | 0.21106 | 0.02582 | 88% | 3.585 |
| 40 | 24 | 0.19227 | 0.00549 | 97% | 4.585 |
These numbers should not be read as universal benchmark results. They are for this procedural texture, this square footprint, this finite supersampled reference, and this box-filtered pyramid. They do show the qualitative failure: point sampling has no way to know that the pixel covers many texels. The current audit grid covers four minification settings and 24 named checks: pyramid shape, minification error, bounded sample means, LOD selection, and constant-preserving downsampling.
What the Pyramid Hides
There are several places where a production renderer has to be more careful than this lab:
- Color values should be filtered in a space appropriate to the texture’s meaning. Averaging gamma-encoded color is not the same as averaging linear radiance.
- Normal maps, roughness maps, alpha masks, and categorical lookup textures do not all want the same averaging rule.
- A square mip level blurs both axes together. Grazing-angle surfaces need anisotropic footprints.
- The LOD formula has to come from the actual mapping derivatives, not from a user slider.
- Prefiltering reduces aliasing by removing high-frequency detail. It cannot preserve detail that the screen pixel no longer has sampling capacity to represent.
That last point is the psychological trap. A sharper minified texture is not automatically more correct. If the pixel cannot support the frequency, the choice is not between “sharp” and “blurry.” It is between a stable filtered answer and an aliased lie.
The small copy is a promise: when the renderer asks a smaller question, the texture will answer at the scale of the question.
-
Lance Williams, “Pyramidal Parametrics”, Computer Graphics 17(3), SIGGRAPH 1983. The paper describes prefiltered pyramid levels, the “mip” name, and interpolation within and between pyramid levels for texture filtering. ↩ ↩2
-
Paul S. Heckbert, “Fundamentals of Texture Mapping and Image Warping”, UCB/CSD-89-516, 1989. The report treats texture mapping and image warping as resampling problems and develops the space-varying antialiasing filter view. ↩
-
Franklin C. Crow, “Summed-area tables for texture mapping”, SIGGRAPH 1984. Crow’s abstract contrasts summed-area tables with mip maps as another way to make texture-area computations tractable through precomputation. ↩