Computer Graphics · Texturing

Mip Chain Construction by Box Reduction

What averaging four texels into one actually preserves, where the filter sits against the ideal low-pass, and why sRGB, normal maps, and odd dimensions each need different treatment.

A mip chain is a prefiltered pyramid: level 0 is the texture at full resolution, level $n$ has dimensions halved $n$ times, and each texel of level $n+1$ summarizes a 2×2 block of level $n$. The pyramid exists because minification cannot be fixed at sample time. When a pixel covers 64 texels, no practical number of point taps recovers the average of those 64 texels; the sampler would have to read all of them. Precomputing the averages costs memory proportional to the original, since the geometric series $1 + 1/4 + 1/16 + \dots$ sums to $4/3$, and turns the minified fetch back into a four-tap operation.

The 2×2 average and what it preserves

The standard construction is a box reduction: every 2×2 block of level $n$ becomes one texel of level $n+1$, with the value being the arithmetic mean of the four.

/* Box-reduce an RGBA8 image by 2 in each dimension.
   Odd dimensions are handled by clamping the second sample to the edge. */
void reduce2x(const unsigned char *src, int w, int h,
              unsigned char *dst, int dw, int dh)
{
    for (int y = 0; y < dh; ++y) {
        int sy0 = 2 * y, sy1 = (sy0 + 1 < h) ? sy0 + 1 : sy0;
        for (int x = 0; x < dw; ++x) {
            int sx0 = 2 * x, sx1 = (sx0 + 1 < w) ? sx0 + 1 : sx0;
            for (int c = 0; c < 4; ++c) {
                int sum = src[(sy0 * w + sx0) * 4 + c]
                        + src[(sy0 * w + sx1) * 4 + c]
                        + src[(sy1 * w + sx0) * 4 + c]
                        + src[(sy1 * w + sx1) * 4 + c];
                /* +2 rounds the division by 4 to nearest instead of down. */
                dst[(y * dw + x) * 4 + c] = (unsigned char)((sum + 2) >> 2);
            }
        }
    }
}

Four properties follow from the averaging. The mean of the whole level is preserved, because a box reduction is a partition of the input into equal-weight groups. Constant regions are preserved exactly, so a flat-coloured texture mips to the same colour at every level. The filter is separable and isotropic within each axis-aligned step, so it treats horizontal and vertical detail identically. And the operation is linear, which means it commutes with any linear colour-space transform but not with a nonlinear one.

The filter is not, however, a good low-pass. Averaging two adjacent samples is a two-tap filter whose response in each dimension is a cosine: it is only 3 dB down at the new Nyquist frequency, and it reaches its first zero at the original Nyquist frequency, which is exactly where the first aliased replica begins. Detail between the new and the old Nyquist limit is therefore attenuated by between 3 dB and nothing at all, and folds into the next level down as aliasing. A windowed-sinc or Gaussian kernel with a wider footprint produces cleaner downsizing, at the cost of more taps per output texel and the need to handle a support wider than 2×2.

Because the reduction is per-level and each level is built from the level above rather than from the original, errors accumulate. A level-8 texel has been filtered by eight successive box filters. Each level multiplies the spectrum by another cosine, so the accumulated response after eight levels is the eighth power of that curve in each dimension, a passband far softer than one wide kernel applied once. Successive halving is cheap and cache-friendly; building each level directly from level 0 with a properly scaled kernel gives sharper results for the same level count. The standard trade is that resampling from level 0 costs a full-texture read per level and cannot be done in a render pass that only has the previous level bound.

Colour space decides whether the average is physical

Averaging stored values is only averaging light if the stored values are linear in light. An 8-bit sRGB texture is not: the encoding is close to a power function with exponent 2.2, so the midpoint of the stored range is far darker than the midpoint of the light range. Reducing sRGB values directly makes every level darker toward the mean, and the effect compounds over the chain because each level re-averages the already-too-dark result.

The correct reduction decodes to linear, averages, and re-encodes:

/* Average of two sRGB-encoded values, done in light-linear space. */
static float srgb_to_linear(float c) {
    return (c <= 0.04045f) ? c / 12.92f
                           : powf((c + 0.055f) / 1.055f, 2.4f);
}
static float linear_to_srgb(float c) {
    return (c <= 0.0031308f) ? 12.92f * c
                             : 1.055f * powf(c, 1.0f / 2.4f) - 0.055f;
}

The 0.04045 breakpoint and the 12.92 slope are from the sRGB specification, which defines the transfer function as a linear segment near black joined to a 2.4-power curve. A cheaper approximation, squaring and square-rooting, is correct for a pure 2.2 gamma and leaves a small error near black, where the linear segment matters.

The same reasoning applies to alpha. Alpha in a premultiplied texture averages linearly, because it is already weighted by coverage. Alpha in a straight (non-premultiplied) texture does not: averaging colour and alpha separately in a region where alpha varies produces colour fringes at the edges, since texels with near-zero alpha contribute their full (possibly arbitrary) colour to the average. This is why loading paths premultiply before generating mips and why glTF-style assets flag which convention they use.

Normal maps, roughness, and channels that are not colours

A normal map stores a unit vector, not a colour. Averaging four unit vectors and not renormalizing yields a vector shorter than one, which a shader that assumes normalized input will treat as a valid but tilted normal; the lighting error is small per texel and systematic across a surface, showing as a subtle flattening of detail at distance. Renormalizing after the reduction is the minimum fix. A better fix is to reconstruct the third component from the two stored ones, which is only possible when the encoding permits it: a two-channel tangent-space normal map can rebuild $z = \sqrt{1 - x^2 - y^2}$, and that reconstruction must happen before averaging or after, but not mixed.

Roughness is a different problem. It is used as the width of a microfacet distribution, and the correct aggregate roughness of a region is not the average of the per-texel roughness values. The variance of the normals adds to the variance the distribution represents, so a properly filtered roughness map increases toward the mean as detail is lost. A commonly used approximation converts to variance, averages, and adds the normal variance, which requires access to the normal map at the same level. Generating roughness mips in isolation from normals gives levels that look too glossy at distance, and no amount of sampler-side work corrects it. This interaction is treated in detail in filtering normal maps without breaking the normals.

Metallic and occlusion channels are ordinary linear scalars; they average correctly under a box reduction and need none of the above. The general rule is that any channel that encodes a direction, a distribution parameter, or a nonlinearly encoded quantity needs channel-specific reduction, and only the ones that encode light or coverage can use the plain average.

Dimensions that do not halve cleanly

A texture with an odd dimension cannot be halved by an integer division. Three conventions exist. Round down with a floor, which discards the last row or column at that level; the discarded texels are then represented by nothing, so they are effectively cropped from the pyramid. Round up, which requires synthesizing a value for the missing half-block, typically by repeating the edge texel, which biases that level’s edge. Or pad the base texture to even dimensions before building the chain, which avoids the issue entirely but changes the texture’s addressing.

The chain also terminates at 1×1, which means a non-square texture ends as a sliver: a 1024×8 texture reaches 1×8, then 1×4 with the box collapsing along one axis only, and the final levels have a 1-texel dimension that can no longer be halved. Implementations either clamp the minimum dimension at 1 and keep halving the other axis, or stop the chain when either axis reaches 1. The first loses the average of the long axis at the final levels; the second stops at fewer levels than the LOD formula expects. Neither is wrong, but the two choices give different results for the highest LOD values, and a sampler that clamps the computed LOD to the chain length must know which convention the texture uses.

Storage cost is where the chain pays for itself twice. A complete RGBA8 chain adds a third of the base level: for 1024×1024 that is 4 MiB at level 0 and about 1.33 MiB more. Textures with long thin aspect ratios amortize worse, and arrays and cube maps multiply the total by the layer count. Compressed formats reduce the absolute cost but not the ratio, and the reduction must happen before compression, because averaging block-compressed texels requires decoding them first. That ordering constraint is one reason compressed formats are covered separately in block compression and the endpoints it stores.

References

  1. Pyramidal Parametrics — Lance Williams, SIGGRAPH 1983
  2. OpenGL Wiki: Sampler Object — Khronos Group
  3. Multimedia Systems and Equipment, Part 2-1: Default RGB Colour Space, sRGB — IEC 61966-2-1:1999