Shader Lab
07.Chromatic aberration

Chromatic aberration

Arc 2 · 2D Screen-Space · Lesson 7 of 35
coordinate signal color
0.0040

Purpose

A "sample" isn't a single atomic thing — it's three independent lookups for R, G, B. Offset those lookups differently and you get the prismatic color-fringe that defines lens error, cheap VHS, and digital glitch.

Key insight

Up to now we've treated each sample as one call returning one color. In practice the GPU returns three numbers at once (plus alpha), but nothing stops the shader from doing three separate samples and keeping only one channel of each.

If the red sample is at UV + a tiny radial offset, the green sample at UV, and the blue sample at UV − that offset, the three channels disagree on where things are — and the eye reads that disagreement as rainbow fringing on every edge. Real lenses do this naturally because different wavelengths refract differently; most CRT, film, and retro looks bolt it on because it sells analog-ness cheaply.

Break it

Crank fringe to the maximum. The image separates into three clearly distinguishable red, green, and blue ghosts. Teaches: the three samples are literally three copies of the image. At subtle offsets your brain fuses them into a single image with fringes; at large offsets the fusion fails and you see the three independent lookups that were there the whole time. This is the cleanest possible demonstration that an RGB sample is three samples stapled together.

Direct Claude

"lens polish / cinematic edges" fringe very low (~0.001) "noticeable color fringe / retro camera" fringe medium (~0.003) "broken-signal RGB split / glitch" fringe high "stronger at the edges than the center" offset magnitude scaled by distance from UV center (radial CA — the physically real version) "only the red drifts" keep G and B aligned; offset only the R sample
Meta-phrase you gain here: "per-channel sampling." Any time you want channels to disagree about anything — position, brightness, timing — this is the pattern.
Combines with: L6 (apply CA to a wobbling image), L4 (CA inside, vignette outside — the standard polish stack), L12 (a CRT's fringe is this lesson, usually radial).
the fragment shader running above
uniform float u_fringe;

vec3 scene(vec2 uv) {
  vec3 col = mix(vec3(0.18, 0.10, 0.28), vec3(0.95, 0.48, 0.32), uv.y);
  col = mix(col, vec3(0.25, 0.60, 0.85), smoothstep(0.55, 0.98, uv.y));
  float sun = 1.0 - smoothstep(0.09, 0.11, length(uv - vec2(0.5, 0.55)));
  col = mix(col, vec3(1.0, 0.93, 0.68), sun);
  float mntn = 0.40 + 0.05 * sin(uv.x * 18.0) + 0.08 * sin(uv.x * 5.0 + 2.0);
  col = mix(col, vec3(0.10, 0.06, 0.20), step(uv.y, mntn) * step(uv.y, 0.5));
  if (uv.y < 0.5) {
    float yy = (0.5 - uv.y);
    float hz = smoothstep(0.004, 0.0, abs(fract(pow(yy, 0.7) * 8.0 - u_time * 0.25) - 0.5));
    float vl = smoothstep(0.004, 0.0, abs(fract((uv.x - 0.5) / max(yy, 0.02) * 4.0) - 0.5));
    col = mix(col, vec3(0.95, 0.30, 0.75), max(hz, vl) * 0.9);
  }
  return col;
}

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

  // Radial offset direction (from center). This makes CA strongest at the edges —
  // the physically accurate behavior of a real lens.
  vec2 dir = uv - 0.5;

  // COORDINATE LAYER — three separate samples, each at a different offset.
  // COLOR LAYER — keep only one channel from each sample and recombine.
  float r = scene(uv + dir * u_fringe).r;
  float g = scene(uv).g;
  float b = scene(uv - dir * u_fringe).b;

  gl_FragColor = vec4(r, g, b, 1.0);
}