Film halation
How intense light that passes through the emulsion can scatter back from the film base, exposing nearby image-forming material and leaving a soft, often red-orange halo around highlights.
Visible effect
A warm fringe just outside an intense highlight
Film halation appears immediately outside a very bright image region: a lamp, reflection, or overexposed edge gains a diffuse halo that is often red-orange on colour negative film. It follows highlight geometry and is most legible where the surrounding image is dark.
This is not lens flare. Flare originates in the lens and can create ghosts or veil broad areas of the frame; halation is produced within the film structure after image-forming light reaches the emulsion and support. It is also not bloom from a digital sensor or camera tube, even though the visible symptoms can overlap.
Physics
Transmitted image light returns through the emulsion
Not every photon is absorbed where it first enters the emulsion. Intense image light can pass into the transparent support, reflect or scatter, and return laterally into image-forming layers. That secondary exposure spreads beyond the original highlight boundary and reduces apparent sharpness there.
On colour negative film the visible halo is often red-orange because red-sensitive material sits deeper in the layer stack, so returning light can expose it preferentially. The exact colour and width remain stock-, exposure-, processing-, and scan-dependent rather than a universal red ring.
Mathematics
Extract highlights, spread them, then add a warm return
The source is decoded from sRGB before a soft highlight maskm is created from linear luminance Y and threshold T. A normalised Gaussian is applied in horizontal and vertical passes. Its standard deviation is one third of radius r, so the control describes the approximate three-sigma support rather than an arbitrary sample-ring distance. Subtracting part of the local mask keeps the visible evidence outside the source highlight instead of painting the whole bright core orange.
Intensity I scales the returned light, warmthW moves its tint from neutral toward red-orange, and mixM blends the effect. This is a convolution-style image model, not a spectral transport simulation of a particular stock.
Shader
GLSL extraction, separable diffusion, and composite
The runtime compiles the exact three-stage implementation below. First it writes a linear-light highlight mask into a half-resolution intermediate texture. Two framebuffer passes convolve that mask horizontally and vertically. A final full-resolution pass samples the original source and diffused mask, then composites in linear light before encoding the display result.
// WHAT: Extract intense image light, diffuse it, and add a warm return halo.
// HOW: Decode sRGB before thresholding, blur the mask with two separable
// Gaussian passes, suppress the local core, then composite and encode once.
// WHY: Halation is scattered exposure around highlights, not a warm blur of the
// whole image; separating extraction, diffusion, and composite preserves that.
float halationSrgbToLinearChannel(float value) {
if (value <= 0.04045) return value / 12.92;
return pow((value + 0.055) / 1.055, 2.4);
}
float halationLinearToSrgbChannel(float value) {
if (value <= 0.0031308) return value * 12.92;
return 1.055 * pow(max(value, 0.0), 1.0 / 2.4) - 0.055;
}
vec3 halationSrgbToLinear(vec3 encoded) {
return vec3(
halationSrgbToLinearChannel(encoded.r),
halationSrgbToLinearChannel(encoded.g),
halationSrgbToLinearChannel(encoded.b)
);
}
vec3 halationLinearToSrgb(vec3 linearColour) {
return vec3(
halationLinearToSrgbChannel(linearColour.r),
halationLinearToSrgbChannel(linearColour.g),
halationLinearToSrgbChannel(linearColour.b)
);
}
float filmHalationExtract(vec3 sourceSrgb, float threshold) {
// Threshold linear luminance so the mask follows light energy, not display gamma.
vec3 sourceLinear = halationSrgbToLinear(sourceSrgb);
float luminance = dot(
sourceLinear,
vec3(0.2126, 0.7152, 0.0722)
);
float lower = clamp(threshold, 0.0, 1.0);
// A soft 0.18-wide transition avoids a brittle contour around the highlight.
return smoothstep(
lower,
min(1.0, lower + 0.18),
clamp(luminance, 0.0, 1.0)
);
}
float filmHalationBlur(
sampler2D maskTexture,
vec2 uv,
vec2 texelDirection,
float radiusPixels
) {
// radiusPixels describes a three-sigma support, bounded for browser cost.
float support = clamp(ceil(radiusPixels), 1.0, 24.0);
float sigma = max(support / 3.0, 0.5);
float weightedMask = 0.0;
float totalWeight = 0.0;
for (int index = -24; index <= 24; index += 1) {
float offset = float(index);
if (abs(offset) > support) continue;
// The same function runs horizontally and vertically via texelDirection.
float weight = exp(
-(offset * offset) / (2.0 * sigma * sigma)
);
vec2 sampleUv = clamp(
uv + texelDirection * offset,
vec2(0.0),
vec2(1.0)
);
weightedMask += texture(maskTexture, sampleUv).r * weight;
totalWeight += weight;
}
// Normalize so increasing the radius spreads energy without amplifying it.
return weightedMask / max(totalWeight, 1e-6);
}
vec3 filmHalationComposite(
vec3 sourceSrgb,
float blurredHighlight,
float localHighlight,
float intensity,
float warmth,
float effectMix
) {
vec3 sourceLinear = halationSrgbToLinear(sourceSrgb);
// Removing part of the local seed moves evidence outside the bright core.
float halo = max(blurredHighlight - 0.35 * localHighlight, 0.0);
vec3 tint = mix(
vec3(1.0),
vec3(1.0, 0.24, 0.055),
clamp(warmth, 0.0, 1.0)
);
vec3 halatedLinear = clamp(
sourceLinear + tint * halo * max(intensity, 0.0),
0.0,
1.0
);
// Mix in linear light, then encode only the final display result.
vec3 resultLinear = mix(
sourceLinear,
halatedLinear,
clamp(effectMix, 0.0, 1.0)
);
return halationLinearToSrgb(resultLinear);
}- Source texturesRGB · full resolution
- Pass 1 · Highlight extractionHalf-resolution framebuffer
- Aspect-fill sample
- Decode sRGB
- Extract the highlight mask
- Pass 2 · Horizontal diffusionHalf-resolution framebuffer
- Apply the horizontal Gaussian
- Pass 3 · Vertical diffusionHalf-resolution framebuffer
- Apply the vertical Gaussian
- Pass 4 · CompositeFull-resolution default framebuffer
- Resample the source
- Add the warm halo in linear light
- Encode to sRGB
- Display outputsRGB · full resolution
Why these steps are here
- Decode before thresholding. Highlight selection follows linear-light energy instead of gamma-encoded display values.
- Threshold before spreading. Midtones do not become a general warm blur; only bright source regions seed the halo.
- Blur in two framebuffer passes. A normalised separable Gaussian produces a smooth isotropic footprint at bounded cost.
- Suppress part of the local core. The characteristic evidence remains the light immediately outside the bright boundary.
- Tint the scattered contribution. Warmth changes the return path, not the source colour globally.
- Mix last. Zero mix and zero intensity are tested identities.
The portable RGBA8 intermediate textures require no floating-point render-target extension.
Notes
- The shader does not model film-layer thickness, wavelength-dependent scattering, exposure history, development, printing, or scanner flare.
- The half-resolution separable Gaussian is a real diffusion pass, but production work may use a multiscale pyramid for much wider halos or a measured stock-specific kernel.
- Radius is expressed in uploaded source pixels. A production pipeline should define its relationship to negative format, scan resolution, crop, and output enlargement.
- Do not add halation before every bright object indiscriminately: modern anti-halation protection can make it subtle, and clipped digital highlights do not prove a film-origin halo.
References
Kodak — Exploring the Color Image — manufacturer educational reference for subtractive dye layers, colour reproduction, exposure, and image-forming light spread.