Grain

Film grain gives digital images a less sterile surface by reintroducing the kind of irregularity that real sensors and film stocks always have. The effect works because the eye reads small, uneven density changes as texture and physical presence rather than as a perfectly smooth synthetic field.

How it works

grain.rs applies grain in the gamma Rec.2020 working space after the main tonal and detail work. The core pipeline is:

  1. Build a deterministic white-noise field from the render seed.
  2. Optionally Gaussian-blur that noise with sigma derived from size.
  3. Compute per-pixel luminance and use it to weight the grain strength.
  4. Blend the noise back into each RGB channel with a type-specific amount curve.

The current implementation has three grain presets: GrainType::{Fine,Silver,Harsh}. The original design considered six (adding Soft, Cubic, and Tabular for finer-grained stock emulation) but only the three above are implemented today; the others are deferred until preset authors actually need that resolution. Each preset maps to a fixed internal tuple in GrainTypeConfig:

  • contrast: scales the final noise amplitude.
  • luma_falloff: controls how quickly grain fades from shadows into highlights.
  • chromatic: controls how much the RGB channels diverge on saturated pixels.
  • amount_curve: shapes the user-facing amount slider before it reaches the noise.

The presets are intentionally simple:

  • Fine favors subtle texture: lower contrast, steeper luminance falloff, and the lightest chromatic split.
  • Silver is the default middle ground: moderate contrast, moderate falloff, and balanced chromatic behavior.
  • Harsh pushes the effect hardest: highest contrast, the weakest falloff, and the strongest chromatic separation.

Internally, the implementation generates one shared noise field plus three channel-specific noise fields. For neutral pixels, the shared field dominates and the grain stays monochrome. For colorful pixels, the channel-specific fields are mixed in more strongly, so the grain picks up the slight color disagreement that real emulsions tend to show.

Why we chose it

AgX uses blur-based sizing instead of a frequency-based size control. That removes the failure mode where extreme size values collapse into blotchy low-frequency artifacts. The blur approach keeps the visual result effectively the same for normal settings, but it is much easier to reason about and tune.

Chromatic variation also lives in the grain type itself rather than in a purely digital RGB-noise model. Real film layers are correlated, not independent, so a small amount of per-channel decorrelation is enough. That is why the implementation uses a mostly shared noise field with only a modest type-specific channel split.

Parameters and constants

The user-facing GrainParams fields are grain_type, amount, size, and seed. Everything below is internal and fixed in code.

ConstantValueRoleSensitivity
GRAIN_PARAM_MIN0.0Lower bound for amount/size validationPure schema value; changing it would change the accepted preset range.
GRAIN_PARAM_MAX100.0Upper bound for amount/size validationSame — bumping it widens the slider but doesn't recalibrate downstream constants, so the rest of the math would have to be retuned.
GRAIN_DEFAULT_SIZE50.0Default grain size when omittedSets the "no size specified" feel to a balanced middle. Lower defaults push the omitted case toward fine grain; higher toward coarse.
GRAIN_SIZE_CURVE_EXPONENT1.5Shapes the size-to-sigma curveHigher exponent makes low size values feel even finer and high values jump to coarse faster; 1.0 would be linear and feel too aggressive at low slider settings.
GRAIN_LUMINANCE_WEIGHT_SCALE0.5Scales luminance falloff sensitivityDoubling it makes shadow-vs-highlight grain emphasis snap harder; halving it flattens grain across the tonal range.
GRAIN_BLUR_SIGMA_THRESHOLD0.3Skips blur below this sigmaThreshold below which the Gaussian blur is skipped entirely (it would barely change the noise anyway); raising it makes more "fine" sizes leave the noise un-blurred.
GRAIN_MAX_SIGMA1.0Maximum sigma at size 100The single biggest knob for "how coarse can grain get?". Raising to 2.0 doubles the visible grain footprint at size = 100.
GRAIN_REF_RESOLUTION2000.0Reference long-edge resolution for sigma scalingDefines the "1× zoom" image. Lower references make grain scale up more aggressively on large images; higher makes it shrink more.
GRAIN_STRENGTH_MULT0.04Maps amount to the final modulation strengthA 25% change here is visibly different at the same amount slider; doubling makes mid-grain look harsh and Silver start to feel like Harsh.
GRAIN_ADDITIVE_END0.1End of the additive-grain shadow regionDefines where additive shadow grain stops fading in. Raising it pulls the shadow grain band higher up the tonal range.
GRAIN_MULTIPLICATIVE_START0.2Start of the multiplicative-grain midtone regionPairs with the previous knob; together they set the smooth handoff from additive (shadows) to multiplicative (mid+) grain.
GRAIN_ADDITIVE_SCALE0.35Scales the additive delta in deep shadowsDirect multiplier on shadow grain visibility; halving it makes shadows almost grain-free.
GRAIN_FALLOFF_REDUCTION0.4Reduces luminance falloff as amount risesAt high amount values, this dampens the shadow-emphasis falloff so heavy grain spreads into highlights instead of just blowing out the dark areas.
GrainTypecontrastluma_falloffchromaticamount_curveReasoning
Fine0.952.50.050.7The softest preset. It keeps contrast low, pushes grain out of highlights, and keeps channel decorrelation barely visible.
Silver1.21.50.100.6The default stock-like preset. It balances visible grain with enough chromatic separation to feel filmic without looking digital.
Harsh1.50.80.150.5The strongest preset. It preserves grain across more of the tonal range and allows the most visible channel disagreement on saturated pixels.

Beyond the expected range: preset validation rejects amount and size outside 0.0..=100.0, so out-of-range values never reach the algorithm. The seed field is Option<u64> so any non-negative integer is fine. grain_type accepts only the three string variants (fine, silver, harsh); anything else fails preset parsing with an explicit error.

Preset-slider mapping

In a preset TOML file, the [grain] block uses the serialized keys type, amount, size, and seed; that type key corresponds to the Rust GrainParams.grain_type field. The current implementation does not expose chromatic as a user slider; chromatic intensity is baked into the selected grain type.

amount is not linear. The code raises the normalized slider value to the preset's amount_curve before scaling the noise, so low settings stay subtle and the effect ramps in more gently than a straight linear blend would. In practice:

  • Low amount values keep the effect mostly invisible and are useful for a light texture pass.
  • Mid-range values produce the classic visible grain look.
  • High values become obvious quickly, especially for Harsh, because the amount curve is shallower and the contrast multiplier is higher.

size controls the Gaussian blur sigma applied to the noise field. Small values leave the noise nearly unblurred, which reads as fine grain. Larger values increase sigma nonlinearly, so the grain grows coarser without collapsing into the low-frequency blobs that the earlier frequency-based algorithm could produce.

Source

The CPU and GPU implementations line up on the user-facing controls, luminance weighting, and the non-chromatic preset behavior, but the current GPU path does not yet implement the CPU chromatic-grain split: the CPU mixes a shared noise field with per-channel chromatic noise, while the GPU applies a single noise field.

References

No canonical external paper applies — AgX's grain is an original implementation. The decisions above are recorded in the AgX design docs: grain, grain size fix, and chromatic grain.

See also