Anti-Aliasing Strategies Reference

Overview

Aliasing — jagged edges, shimmering, Moiré patterns — occurs when a continuous signal is sampled at discrete pixel locations. Anti-aliasing (AA) techniques smooth these artifacts. In shader art, AA is especially important for SDFs, sharp edges, and high-frequency patterns.

Analytical Anti-Aliasing

The cheapest and best method: use smoothstep to soften edges over a pixel-width band.

// Anti-aliased edge from SDF
float aaEdge(float d, vec2 resolution) {
  float pixelWidth = 1.0 / resolution.y;  // approximate pixel size in UV space
  return smoothstep(pixelWidth, -pixelWidth, d);
}

// For arbitrary functions: use fwidth() for automatic pixel-scale derivative
float aaEdgeFwidth(float d) {
  float fw = fwidth(d);  // screen-space rate of change
  return smoothstep(fw, -fw, d);
}

// Anti-aliased line
float aaLine(float d, float thickness) {
  float fw = fwidth(d);
  return smoothstep(thickness + fw, thickness - fw, abs(d));
}

// Anti-aliased circle (from SDF)
float aaCircle(vec2 p, float r) {
  float d = length(p) - r;
  return 1.0 - smoothstep(-fwidth(d), fwidth(d), d);
}

fwidth() Explained

fwidth(x) returns abs(dFdx(x)) + abs(dFdy(x)) — the rate of change of x across neighboring pixels. This gives you the pixel scale at any point, accounting for perspective, zoom, and UV distortion. It’s the recommended way to compute anti-aliasing widths.

// Pattern anti-aliasing: smooth version of step()
float aaStep(float edge, float x) {
  float fw = fwidth(x);
  return smoothstep(edge - fw * 0.5, edge + fw * 0.5, x);
}

// Smooth version of mod-based patterns
float aaGrid(vec2 p, float lineWidth) {
  vec2 fw = fwidth(p);
  vec2 grid = abs(fract(p - 0.5) - 0.5) / fw;
  float line = min(grid.x, grid.y);
  return 1.0 - min(line, 1.0);
}

Supersampling (SSAA / MSAA)

Render multiple sub-pixel samples and average them. Brute-force but universally effective.

Fixed Grid (2×2)

void main() {
  vec3 total = vec3(0.0);
  vec2 pixelSize = 1.0 / u_resolution;

  // 2×2 grid: 4 samples per pixel
  for (int y = 0; y < 2; y++) {
    for (int x = 0; x < 2; x++) {
      vec2 offset = (vec2(x, y) - 0.5) * 0.5;
      vec2 uv = (gl_FragCoord.xy + offset) / u_resolution;
      total += computeColor(uv);
    }
  }

  gl_FragColor = vec4(total / 4.0, 1.0);
}

Rotated Grid (RGSS — 4 samples, better quality than 2×2)

void main() {
  vec3 total = vec3(0.0);

  // Rotated grid offsets (better coverage than axis-aligned)
  const vec2 offsets[4] = vec2[4](
    vec2(-0.125, -0.375),
    vec2( 0.375, -0.125),
    vec2(-0.375,  0.125),
    vec2( 0.125,  0.375)
  );

  for (int i = 0; i < 4; i++) {
    vec2 uv = (gl_FragCoord.xy + offsets[i]) / u_resolution;
    total += computeColor(uv);
  }

  gl_FragColor = vec4(total / 4.0, 1.0);
}

Stochastic / Jittered

Random sub-pixel offsets (requires good PRNG, see path-tracing.md):

void main() {
  vec3 total = vec3(0.0);
  int samples = 8;

  for (int i = 0; i < samples; i++) {
    vec2 jitter = hash22(gl_FragCoord.xy + float(i) * 100.0) - 0.5;
    vec2 uv = (gl_FragCoord.xy + jitter) / u_resolution;
    total += computeColor(uv);
  }

  gl_FragColor = vec4(total / float(samples), 1.0);
}

Adaptive Supersampling

Only supersample where needed (edges, high contrast areas):

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 center = computeColor(uv);

  // Check if neighbors differ significantly
  vec3 right = computeColor(uv + vec2(1, 0) / u_resolution);
  vec3 up = computeColor(uv + vec2(0, 1) / u_resolution);
  float diff = length(center - right) + length(center - up);

  if (diff > 0.1) {
    // High contrast: supersample
    vec3 total = center;
    total += computeColor(uv + vec2(-0.25, -0.25) / u_resolution);
    total += computeColor(uv + vec2( 0.25, -0.25) / u_resolution);
    total += computeColor(uv + vec2( 0.25,  0.25) / u_resolution);
    center = total / 4.0;
  }

  gl_FragColor = vec4(center, 1.0);
}

Temporal Anti-Aliasing (TAA)

Accumulate sub-pixel jitter across frames (pairs naturally with path tracing):

// Each frame uses a different sub-pixel offset
uniform int u_frame;

void main() {
  // Halton-like sequence for temporal jitter
  vec2 jitter = vec2(
    fract(float(u_frame) * 0.5 + 0.25),
    fract(float(u_frame) * 0.333 + 0.125)
  ) - 0.5;

  vec2 uv = (gl_FragCoord.xy + jitter * 0.5) / u_resolution;
  vec3 color = computeColor(uv);

  // Blend with previous frame (exponential moving average)
  vec3 prev = texture2D(u_prevFrame, gl_FragCoord.xy / u_resolution).rgb;
  color = mix(prev, color, 0.1);  // 0.1 = strong temporal smoothing

  gl_FragColor = vec4(color, 1.0);
}

When to Use Which

TechniqueCostQualityBest For
smoothstep/fwidthFreeGood for edgesSDF edges, grid lines, patterns
2×2 gridDecentSimple shaders where cost is acceptable
Rotated grid (RGSS)Better than 2×2General purpose, diagonal edges
StochasticGood (noisy)Complex scenes, pairs with denoising
Temporal AA~FreeExcellent (ghosting risk)Progressive renders, path tracing
Adaptive1–4×GoodComplex scenes with large flat areas

Key References