Aperture-shaped bokeh

Why an out-of-focus point copies the aperture outline — and how a regular-polygon convolution kernel turns night highlights into visible iris shapes.

Visible effect

Defocused highlights reveal the iris outline

Bright points outside the focus plane expand into small images of the entrance pupil. A nearly circular opening produces round highlights; visible iris blades turn the same highlights into polygons.

The outline becomes easier to read as the blur footprint grows. Real stopping down also changes depth of field and exposure, but this playground isolates only the shape of the point-spread kernel.

Physics

The sensor records a cross-section of the ray bundle

An out-of-focus point records the aperture cross-section Rays from one scene point pass through a six-sided iris, converge behind the sensor, and form a six-sided footprint where the sensor intersects the ray bundle.scene pointsix-blade irissensor planebest focusaperture-shapedblur footprint
The sensor cuts through the ray bundle before it converges. That cross-section retains the iris outline, so a defocused point becomes a small image of the aperture rather than an arbitrary blur.

Every point outside the focus plane sends a finite bundle of rays to the sensor. The aperture clips that bundle. If the sensor intersects it before or after its narrowest point, the recorded footprint keeps the opening’s silhouette: circular for a round pupil, polygonal when iris blades define visible edges.

Mathematics

A regular polygon bounds the convolution support

Let A be the normalized aperture region andr its display-space radius. The blurred image is the average of samples whose offsets fall inside that region. For a regular N-gon, the allowed radius depends on angular distance δ from the nearest vertex:

Rotation shifts the angular sectors without changing their area. Fewer blades make the outline obvious; as the blade count rises, the polygon approaches a circle. The discrete shader divides by the number of accepted samples so the kernel keeps constant energy.

Shader

GLSL aperture gather

The gather begins with deterministic, area-uniform disk candidates. A radial boundary test rejects candidates outside the chosen aperture before the remaining samples are averaged in linear light.

apertureGatherGLSL
// WHAT: Approximate aperture-shaped defocus with samples from a bounded disk.
// HOW: Distribute candidates with a golden-angle spiral, reject points outside
// the blade polygon, and average the accepted source energy in linear light.
// WHY: Uniform disk coverage avoids visible rings, while polygon rejection
// makes the blur footprint explain the aperture-blade control.
const int APERTURE_MAX_SAMPLES = 96;
const float APERTURE_GOLDEN_ANGLE = 2.39996323;
const float APERTURE_PI = 3.14159265;
const float APERTURE_TAU = 6.28318531;

float srgbToLinearChannel(float value) {
  if (value <= 0.04045) return value / 12.92;
  return pow((value + 0.055) / 1.055, 2.4);
}

vec3 srgbToLinear(vec3 value) {
  return vec3(
    srgbToLinearChannel(value.r),
    srgbToLinearChannel(value.g),
    srgbToLinearChannel(value.b)
  );
}

bool insideRegularAperture(vec2 point, int blades, float rotation) {
  // A blade count below three intentionally falls back to a circular aperture.
  float radius = length(point);
  if (radius > 1.0) return false;
  if (blades < 3) return true;

  float sector = APERTURE_TAU / float(blades);
  float halfSector = sector * 0.5;
  float angle = atan(point.y, point.x) - rotation;
  float localAngle = mod(angle + halfSector, sector) - halfSector;
  float distanceFromEdgeNormal = halfSector - abs(localAngle);
  float allowedRadius = cos(halfSector) /
    max(cos(distanceFromEdgeNormal), 0.0001);
  return radius <= allowedRadius;
}

vec3 apertureGather(
  sampler2D source,
  vec2 sourceUv,
  vec2 sampleStep,
  float radiusPx,
  int sampleCount,
  int blades,
  float rotation
) {
  vec3 accumulated = vec3(0.0);
  float accepted = 0.0;
  float count = float(max(sampleCount, 1));
  // A coarser mip level prefilters wide gathers so sparse taps do not shimmer.
  float sampleLod = max(0.0, log2(max(radiusPx, 1.0)) - 2.0);

  for (int index = 0; index < APERTURE_MAX_SAMPLES; index += 1) {
    if (index >= sampleCount) break;
    // sqrt maps equal index intervals to equal-area annuli.
    float fraction = (float(index) + 0.5) / count;
    float sampleRadius = sqrt(fraction);
    float angle = float(index) * APERTURE_GOLDEN_ANGLE;
    vec2 disk = vec2(cos(angle), sin(angle)) * sampleRadius;
    if (!insideRegularAperture(disk, blades, rotation)) continue;

    vec3 encoded = textureLod(
      source,
      sourceUv + disk * sampleStep * radiusPx,
      sampleLod
    ).rgb;
    accumulated += srgbToLinear(encoded);
    accepted += 1.0;
  }

  // Normalize only by accepted points so blade count does not alter exposure.
  return accumulated / max(accepted, 1.0);
}
Processing pipelineBoxes mark actual render-pass boundaries.
  1. Source texturesRGB
  2. Aperture gatherOne fullscreen render pass
    • Generate disk candidates
    • Reject samples outside the aperture
    • Average in linear light
    • Mix the effect
    • Encode to sRGB
  3. Display output

Why these steps are here

  1. Start with uniform disk candidates. Square-root radial placement gives approximately equal density per unit area.
  2. Test the actual support. The regular-polygon boundary turns the circular candidate set into the selected iris silhouette.
  3. Count accepted taps. Rejecting samples without renormalizing would make low-blade kernels artificially darker.
  4. Rotate in kernel space. Rotation changes only the aperture orientation, not the source image or sampling density.
  5. Average in linear light. Bright night highlights retain more plausible energy than they would under direct sRGB averaging.

The shader caps the candidate count at 96 for predictable browser cost.

Notes

  • The default night-road source makes the kernel easiest to inspect; the night-drive video shows whether the shape remains stable in motion.
  • Stopping a real lens down changes exposure and depth of field as well as the iris outline. Those coupled changes are intentionally omitted here.
  • Rounded, curved, damaged, or anamorphically stretched apertures need a different boundary function, but the gather structure stays the same.
  • Large kernels need more samples or a multi-pass approximation. Sparse gathers can make individual copies of bright points visible.

References

ZEISS — Depth of field and bokeh — manufacturer technical article on defocus, iris images, and bokeh appearance.