Chemical spots and processing damage
How uneven contact with developer, bleach, fixer, wash water, or contaminants can change local emulsion density and leave soft-edged light, dark, or stained blotches fixed to individual film frames.
Visible effect
Irregular patches that ignore the photographed scene
Chemical processing damage appears as irregular patches whose density or colour differs from the image around them. Some are compact droplets with soft or tide-like borders; others merge into broad areas of uneven development. Their shapes do not follow objects in the photographed scene.
The sign depends on the material and reproduction path. A process that leaves more density on a negative can project or scan as a lighter image mark, while reduced negative density can appear darker after inversion. This article therefore teaches local density change with both polarities instead of assigning one universal display colour to every chemical spot.
Physics
Local chemistry changes the amount or character of developed image material
Development, bleaching, fixing, washing, and drying are intended to act uniformly across the emulsion. Air bubbles can shield a region from a bath; weak agitation, exhausted or unevenly replenished chemistry, oil, particles, or carry-over between baths can change local reaction rates. Hard-water residue and liquid retained into drying can leave a different surface mark.
Several different faults can therefore share the visible family of spots and blotches without sharing one chemistry. Kodak’s motion- picture processing guidance separately lists fuzzy spots from air bubbles, dark or light spots from bleach concentration, nonuniform density from poor agitation or retained material, and water spots from drying.
Mathematics
A thresholded Perlin field of soft density changes
Destination pixel position p is divided by spot sizes. Three octaves of gradient Perlin noiseN form a continuous field F, using film-frame seed k to select a stable pattern. CoverageC lowers the threshold, while edge-softnessS_f widens the transition around its positive and negative extrema.
Density D applies a signed exposure multiplier, stainS biases affected areas toward a bounded warm residue, and mix M blends the result. The model intentionally combines a visible family of processing faults; it is not a chemical simulation of one named bath failure.
Shader
GLSL frame-seeded Perlin density pass
The runtime compiles the exact function below. Its wrapper samples the source with aspect fill, decodes sRGB, supplies a quantised simulated film-frame seed and destination-pixel coordinates, then encodes the result once for display.
// WHAT: Place broad organic light and dark density stains on individual film frames.
// HOW: Threshold a frame-seeded, three-octave Perlin field, then modulate exposure
// and optional warm staining.
// WHY: Processing damage changes local emulsion density; it is not a layer of
// sharp opaque dust or a screen-space collection of perfectly circular decals.
// 1. REPEATABLE GRADIENT NOISE
// ----------------------------
// lattice identifies one integer noise-grid corner. frameSeed keeps a stain
// fixed within a film frame, while salt lets different octaves make independent
// choices. Numeric hash constants only decorrelate inputs; they are not chemistry.
float chemicalSpotHash(vec2 lattice, float frameSeed, float salt) {
return fract(
sin(dot(lattice, vec2(127.1, 311.7)) +
floor(frameSeed) * 74.7 +
salt * 19.19) *
43758.5453123
);
}
vec2 chemicalSpotGradient(
vec2 lattice,
float frameSeed,
float salt
) {
// Turn the hash into an angle, then return a unit direction for this corner.
float angle =
chemicalSpotHash(lattice, frameSeed, salt) * 6.28318530718;
return vec2(cos(angle), sin(angle));
}
float chemicalSpotPerlin(
vec2 position,
float frameSeed,
float salt
) {
vec2 lattice = floor(position);
vec2 local = fract(position);
// Quintic fade reaches zero slope and curvature at cell boundaries, avoiding
// visible seams when the four corner contributions are interpolated.
vec2 fade = local * local * local * (
local * (local * 6.0 - 15.0) + 10.0
);
float lowerLeft = dot(
chemicalSpotGradient(lattice, frameSeed, salt),
local
);
float lowerRight = dot(
chemicalSpotGradient(lattice + vec2(1.0, 0.0), frameSeed, salt),
local - vec2(1.0, 0.0)
);
float upperLeft = dot(
chemicalSpotGradient(lattice + vec2(0.0, 1.0), frameSeed, salt),
local - vec2(0.0, 1.0)
);
float upperRight = dot(
chemicalSpotGradient(lattice + vec2(1.0), frameSeed, salt),
local - vec2(1.0)
);
// Interpolate across x, then y. sqrt(2) approximately normalizes the range
// of 2D corner-gradient dot products.
return mix(
mix(lowerLeft, lowerRight, fade.x),
mix(upperLeft, upperRight, fade.x),
fade.y
) * 1.41421356237;
}
// 2. MULTI-SCALE ORGANIC FIELD
// ----------------------------
// fBm means adding several noise octaves: each octave doubles spatial frequency
// and halves amplitude. Broad and fine structure then belong to one stain mask.
float chemicalSpotFbm(vec2 position, float frameSeed) {
float field = 0.0;
float amplitude = 1.0;
float frequency = 1.0;
float normalization = 0.0;
for (int octave = 0; octave < 3; octave += 1) {
field += amplitude * chemicalSpotPerlin(
position * frequency,
frameSeed,
float(octave) * 17.17
);
normalization += amplitude;
amplitude *= 0.5;
frequency *= 2.0;
}
return field / normalization;
}
// 3. FIELD -> STAIN MASK
// ----------------------
vec2 chemicalSpotField(
vec2 pixelCoordinate,
float frameSeed,
float coverage,
float spotSize,
float softness
) {
// spotSize is measured in source pixels. Larger values divide coordinates by
// a larger number and therefore produce broader field features.
float safeSize = max(spotSize, 1.0);
float field = chemicalSpotFbm(
pixelCoordinate / safeSize,
frameSeed
);
float threshold = mix(
0.55,
0.08,
clamp(coverage, 0.0, 1.0)
);
float feather =
0.025 + clamp(softness, 0.0, 1.0) * 0.18;
// abs(field) selects both positive and negative lobes. smoothstep turns their
// threshold crossing into a soft mask instead of a hard contour.
float mask = smoothstep(
threshold,
threshold + feather,
abs(field)
);
float polarity = mask > 0.0 ? (field < 0.0 ? -1.0 : 1.0) : 0.0;
// x=coverage mask; y=sign telling the final stage whether the local density
// should become lighter or darker.
return vec2(clamp(mask, 0.0, 1.0), polarity);
}
// 4. MASK -> PHOTOGRAPHIC DAMAGE
// ------------------------------
vec3 filmChemicalSpots(
vec3 sourceLinear,
vec2 pixelCoordinate,
float frameSeed,
float coverage,
float spotSize,
float softness,
float density,
float stain,
float effectMix
) {
vec2 spot = chemicalSpotField(
pixelCoordinate,
frameSeed,
coverage,
spotSize,
softness
);
float safeDensity = max(density, 0.0);
// exp2 maps signed density strength to a multiplicative exposure change:
// negative polarity darkens and positive polarity lightens the image.
float exposureScale = exp2(
spot.y * spot.x * safeDensity * 1.25
);
float stainAmount =
clamp(stain, 0.0, 1.0) * spot.x * clamp(safeDensity, 0.0, 1.0);
// Optional warm tint distinguishes coloured processing stain from a neutral
// exposure variation. Values are an illustrative palette, not measured dye.
vec3 stainTint = mix(
vec3(1.0),
vec3(1.0, 0.82, 0.52),
stainAmount
);
vec3 damaged = clamp(
sourceLinear * exposureScale * stainTint,
0.0,
1.0
);
// effectMix=0 is an exact clean image; 1 shows the complete approximation.
return mix(
sourceLinear,
damaged,
clamp(effectMix, 0.0, 1.0)
);
}- Source texturesRGB · one texture read per pixel
- Frame-seeded Perlin density transformOne fullscreen render pass
- Aspect-fill sample
- Decode sRGB
- Build a three-octave Perlin field
- Threshold soft positive and negative extrema
- Apply signed density and staining
- Mix and encode to sRGB
- Display output
Why these steps are here
- Quantise the frame seed. A mark remains fixed to one simulated film frame instead of swimming continuously over the image.
- Accumulate three Perlin octaves. A continuous low-frequency field produces joined, non-geometric stains while retaining finer variation at bounded cost.
- Threshold the field softly. Coverage selects more extrema and softness controls the width of their feathered transitions.
- Allow two density polarities. The playground does not claim that every processing fault maps to one display sign.
- Apply damage in linear light. The multiplier represents a local exposure-density approximation rather than gamma-space paint.
- Mix last. Zero mix and zero density are tested identities.
Notes
- Thresholded Perlin noise produces a soft, organic visual family. The model seeds each simulated film frame instead of moving continuously through noise time and exposes both density polarities.
- The shader groups several spot-like processing failures into one controllable teaching model. Air-bubble shielding, bleach faults, drying residue, reticulation, biological growth, and long-term redox blemishes are physically distinct.
- Film dust is an opaque or translucent contaminant at reproduction; scratches are narrow mechanical damage. Neither should be replaced by this soft local-density field.
- The pattern changes at eight simulated film frames per second for observation. A production pipeline should key damage to real source-frame identity and preserve it through repeated or interpolated frames.
- Spot size is expressed in output pixels. A restoration or synthesis pipeline should instead tie scale to film gauge, scan resolution, crop, and enlargement.
References
Kodak — Processing KODAK Motion Picture Films, Module 15 — manufacturer reference for processing defects, contamination, drying, deposits, and laboratory control.