Diffraction stars
Why straight aperture edges turn bright point sources into directional rays — and how a brightness-gated line gather reproduces the pattern without blurring the whole frame.
Visible effect
Bright points grow rays while the surrounding image stays sharp
Small, intense lights can grow long rays while midtones and broad surfaces remain almost unchanged. Straight iris edges set the ray directions, and the blade count determines how many distinct spikes remain after opposite directions overlap.
The pattern becomes stronger as a real aperture closes. A circular opening has rotational symmetry, so it produces a circular diffraction pattern rather than the directional star approximated here.
Physics
Aperture edges concentrate energy along perpendicular directions
Light waves do not stop abruptly at an iris edge. Their wavefront bends and interferes after the opening. Straight edge segments concentrate part of the diffraction pattern along their normals. Parallel opposing edges share an axis, which is why even and odd blade counts produce different visible ray counts.
Mathematics
Fourier optics becomes a gated set of line integrals
In scalar Fraunhofer diffraction, the aperture transmissionA determines the point-spread function through a Fourier transform. The real-time approximation replaces that two-dimensional pattern with m line axes dₖ, a highlight gate G, length L, strength s, and a decaying weight w:
For an even blade count N, opposite edge normals overlap and create N rays on N/2 line axes. For an odd count they do not overlap, so N axes create2N rays.
Shader
GLSL highlight line gather
Each fragment searches both directions along every star axis. Only source samples above the selected peak threshold contribute, and a nonlinear falloff keeps the ray brightest near its source.
// WHAT: Add aperture-aligned diffraction rays seeded only by bright pixels.
// HOW: Walk both directions along every unique blade axis, gate the samples by
// highlight level, attenuate them with distance, and accumulate in linear light.
// WHY: Directional highlight energy explains starbursts; blurring the whole
// image would create streaks where diffraction has no bright source.
const int STAR_MAX_AXES = 9;
const int STAR_MAX_STEPS = 64;
const float STAR_PI = 3.14159265;
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)
);
}
float highlightGate(vec3 encoded, float threshold) {
// Peak-channel gating retains saturated coloured highlights.
float peak = max(encoded.r, max(encoded.g, encoded.b));
float high = min(1.0, threshold + 0.18);
return smoothstep(threshold, max(high, threshold + 0.001), peak);
}
float streakWeight(float normalizedDistance) {
// Combine a fast cubic fade with a bright, finite core near the source.
float t = clamp(normalizedDistance, 0.0, 1.0);
return pow(1.0 - t, 3.0) * inversesqrt(0.05 + t);
}
vec3 diffractionStars(
sampler2D source,
vec2 sourceUv,
vec2 pixelStep,
float lengthPx,
int blades,
float rotation,
float threshold,
float strength,
int stepCount
) {
if (blades < 3 || strength <= 0.0 || lengthPx < 1.0) {
return vec3(0.0);
}
// Opposite rays share an axis for even blade counts; odd counts do not.
int axisCount = (blades % 2 == 0) ? blades / 2 : blades;
vec3 accumulated = vec3(0.0);
float safeSteps = float(max(stepCount, 1));
for (int axis = 0; axis < STAR_MAX_AXES; axis += 1) {
if (axis >= axisCount) break;
float angle = rotation + float(axis) * STAR_PI / float(axisCount);
vec2 direction = vec2(cos(angle), sin(angle));
for (int step = 1; step <= STAR_MAX_STEPS; step += 1) {
if (step > stepCount) break;
float t = float(step) / safeSteps;
float distancePx = t * lengthPx;
float weight = streakWeight(t);
// Sampling forward and backward builds the two rays of one axis.
vec2 offset = direction * pixelStep * distancePx;
vec3 encodedForward = texture(source, sourceUv + offset).rgb;
vec3 encodedBackward = texture(source, sourceUv - offset).rgb;
accumulated += srgbToLinear(encodedForward) *
highlightGate(encodedForward, threshold) * weight;
accumulated += srgbToLinear(encodedBackward) *
highlightGate(encodedBackward, threshold) * weight;
}
}
// Axis normalization keeps brightness stable as blade count changes.
return accumulated * strength / float(max(axisCount, 1));
}- Source texturesRGB
- Directional highlight gatherOne fullscreen render pass
- Take directional samples
- Gate highlights
- Apply distance falloff
- Add in linear light
- Encode to sRGB
- Display output
Why these steps are here
- Gate the source first. Dim texture detail should not smear into rays merely because it lies on a star axis.
- Gather along line axes. Sampling both signs covers each opposing ray pair without storing duplicate directions.
- Fade nonlinearly. A cubic falloff with extra near-source emphasis avoids hard line endings.
- Keep source colour. The gathered highlight is accumulated in linear light rather than replaced by a white mask.
- Bound the loops. Nine axes and sixty-four steps per side set a predictable ceiling for interactive browser rendering.
Production export can downsample highlights and use separable, rotated passes for longer, smoother rays.
Notes
- The night-road image opens on isolated headlights for a repeatable static comparison; the night-drive video remains available to check whether moving rays stay stable.
- Blade curvature, surface finish, coatings, sensor bloom, and internal reflections can all change the recorded appearance.
- The threshold is a display-space artistic control, not a physical irradiance measurement.
- Long stars are expensive in a direct gather. A lower-resolution highlight buffer and multiple rotated one-dimensional passes trade memory for speed.
References
Canon — The world of cinema lenses — manufacturer discussion of diaphragm blades and diffraction from opposing aperture edges.