Computer Graphics · Rasterization

Multisampling: Coverage Samples Without Shading Every Sample

MSAA asks the coverage question at several positions per pixel but shades only one, and the gap between the two is where its bandwidth advantage and its characteristic failures live.

Supersampling is the obvious answer to edge aliasing: render at four times the resolution and average. It is also the expensive answer, because every subpixel gets its own full fragment shader invocation, its own texture fetches, and its own depth test, and the shader is usually the dominant cost. Multisampling keeps the averaging but decouples it from shading: coverage is evaluated at $n$ positions per pixel, while the fragment shader runs once per pixel, and the one shading result is written to every covered sample. The arrangement captures most of the antialiasing benefit at a fraction of the shading cost, and its residual artifacts come directly from the decoupling.

The two grids in one pixel

A pixel under $4\times$ multisampling has four sample positions, each with its own depth value and its own coverage bit. The positions are not arbitrary; the API fixes a standard pattern, typically a rotated grid designed so that the samples are spread out rather than clustered along an axis, which gives better edge orientation coverage than a regular grid. The exact offsets are not something an application gets to choose, which is deliberate: the pattern must be identical for every triangle and every draw call so that shared edges resolve consistently.

The coverage mask for a fragment records which of the $n$ samples inside the pixel its triangle covers. A triangle that covers the whole pixel has a full mask, one that covers a sliver has one bit set, and the mask is a bitmask of $n$ bits that travels with the fragment through the rest of the pipeline. Depth testing per sample compares the incoming fragment’s depth at each covered sample against the stored value at that sample, and updates only the samples that pass. That per-sample depth storage is the first real memory cost: a $4\times$ target needs four depth values per pixel, and a $8\times$ target needs eight.

Shading happens once. The shader runs at the pixel’s shading sample position, which is typically the centroid of the covered samples rather than the geometric centre, and the resulting colour is broadcast to every covered sample of the pixel. The sample coverage masks from the half-space test are computed with the same top-left rules applied to each of the $n$ positions independently.

/* Resolve one pixel from per-sample colour and coverage.
   sample_color[] holds one colour per sample, each already shaded;
   here the blend weight is the fraction of samples covered. */
typedef struct { unsigned char r, g, b; } rgb_t;

rgb_t resolve(const rgb_t sample_color[4], unsigned mask)
{
    unsigned n = 0, r = 0, g = 0, b = 0;
    for (int i = 0; i < 4; i++) {
        if (!(mask & (1u << i))) continue;
        n++;
        r += sample_color[i].r;
        g += sample_color[i].g;
        b += sample_color[i].b;
    }
    if (n == 0) return (rgb_t){0, 0, 0};
    return (rgb_t){ (unsigned char)(r / n), (unsigned char)(g / n), (unsigned char)(b / n) };
}

The division in that resolve is the reason hardware uses power-of-two sample counts. Averaging $n$ values takes one multiply by $1/n$ when $n$ is a power of two, and the resolve pass over a full target is bandwidth-bound, so a cheap reciprocal matters.

Why the shading sample is a separate position

Using the centroid as the shading position rather than the pixel centre solves a specific problem. Consider a pixel whose centre falls outside a triangle but which contains a covered sample. If the shader evaluated at the centre, it would run at a position the triangle does not cover, and attributes interpolated at that position can be garbage: the perspective divide of a negative barycentric weight extrapolates outside the triangle, and for a triangle edge-on to the viewer that extrapolation can reach coordinates far from the surface. Shading at a position known to be inside avoids the case entirely.

The cost is that the shading position now varies per pixel. A triangle covering a pixel fully uses the centre; a triangle covering a sliver uses somewhere near the covered samples. Across the interior of a large triangle the positions are all centres and the output is identical to the non-multisampled case; along the edges, the positions shift slightly, which shows up as a subtle wobble in high-frequency shading such as sharp specular highlights or procedural patterns. The interpolation in the varying path must therefore be parameterized by the actual shading position, not by the pixel centre, or attributes will be evaluated at the wrong place and the shift vanishes.

Centroid sampling has a second cost with derivative computation. Derivatives are computed by differencing a value across a $2\times 2$ quad of fragments. When the four fragments of a quad have shading positions shifted by different centroid offsets, the difference is not a clean finite difference of the underlying function, and the derivative is distorted. This appears as a band of wrong lighting or wrong mip level selection along every geometric edge, and it is why implementations go to some trouble to keep quads coherent.

The masks, and what they cost to store

An $n$-sample target stores, per pixel, $n$ colour values, $n$ depth values, and a coverage mask. A naive implementation stores $n$ full colour values per pixel, and the framebuffer grows by a factor of $n$: an $8\times$ 4K target in 32-bit-per-sample colour is on the order of half a gigabyte before depth. Hardware reduces this through compression, since neighbouring samples inside a pixel are frequently identical, but the worst case is the uncompressed size and applications budget for it.

Depth can be stored more compactly because the coverage mask says which samples matter. A common arrangement keeps one depth value for samples that share it and splits into per-sample values only where a triangle edge passes through the pixel. The representation is transparent to the API, but it explains why early depth rejection under multisampling is subtler than the single-sample case: a fragment can pass at some samples and fail at others, so the depth test is per sample and the “reject the whole fragment” fast path is no longer available in the same form.

The resolve step, converting the multisampled target to a single-sample image, is where the averaging happens. It is a separate pass over the target, and its bandwidth is often comparable to the shading it saved: for $4\times$, reading four samples and writing one is five sample-sized memory operations per pixel against a single shading invocation. This is the central trade of MSAA and the reason it is not free at high sample counts.

What multisampling does not fix

The decoupling means every shading-related artifact survives intact. A shader that produces high-frequency detail, such as a fine procedural pattern or a specular highlight from a small light, is evaluated once per pixel and aliases exactly as it would without MSAA. MSAA antialiases geometry edges, not shading. This is not a defect to be worked around but a design boundary, and it is why MSAA is frequently combined with per-shader supersampling or with texture-level filtering rather than relied on alone.

Transparency is the other boundary. A blended surface covering half a pixel’s samples will blend its colour into those samples and leave the rest showing what was behind, so the result differs from a properly antialiased blend of a partially transparent edge. The samples do not carry independent alpha coverage in the standard resolve, and the artefact appears as a doubled edge: the geometry gets coverage antialiasing while its transparency does not. Alpha-to-coverage exists to bridge this case by converting a shader’s alpha output into a coverage mask, at the cost of a stochastic-looking dither.

Finally, sample count is not area. Four samples approximating a pixel’s coverage give a coverage quantized to five levels (zero through four samples), so an edge’s apparent position is quantized to those levels. The staircase does not disappear; it becomes a finer, less regular staircase. Raising the count narrows the quantization but does not remove it, and the analytic approach exists because exact area computation has no such quantization at all.

Choosing a sample count

The sample count is a bandwidth decision, not a quality dial. Doubling the samples doubles the colour and depth storage and roughly doubles the resolve bandwidth, while reducing edge aliasing by a diminishing amount because the residual error scales with the spacing between samples rather than with their count alone. The practical range is small: $2\times$ and $4\times$ cover most uses, $8\times$ is available where the target is small enough to afford it, and beyond that the cost of samples exceeds the cost of simply rendering larger and downsampling.

On hardware that supports it, the sample count is fixed at render-target creation, and a pipeline that expects a different count needs a different target. This couples the antialiasing setting to resource management rather than to draw-time state, which is why quality options that switch sample counts typically require recreating the targets and sometimes the whole swapchain.

References

  1. Multisample Antialiasing — OpenGL Specification, Khronos Group — multisample rasterization rules
  2. Direct3D 11 Multisample Anti-Aliasing — Microsoft
  3. A Pixel Is Not a Little Square, A Pixel Is Not a Little Square, A Pixel Is Not a Little Square — Alvy Ray Smith, Microsoft Technical Memo 6, 1995