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 sRGB gamma 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.
| `GRAIN_PARAM_MIN` | `0.0` | Lower bound for amount/size validation | Pure schema value; changing it would change the accepted preset range. |
| `GRAIN_PARAM_MAX` | `100.0` | Upper bound for amount/size validation | Same — bumping it widens the slider but doesn't recalibrate downstream constants, so the rest of the math would have to be retuned. |
| `GRAIN_DEFAULT_SIZE` | `50.0` | Default grain size when omitted | Sets the "no `size` specified" feel to a balanced middle. Lower defaults push the omitted case toward fine grain; higher toward coarse. |
| `GRAIN_SIZE_CURVE_EXPONENT` | `1.5` | Shapes the size-to-sigma curve | Higher 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_SCALE` | `0.5` | Scales luminance falloff sensitivity | Doubling it makes shadow-vs-highlight grain emphasis snap harder; halving it flattens grain across the tonal range. |
| `GRAIN_BLUR_SIGMA_THRESHOLD` | `0.3` | Skips blur below this sigma | Threshold 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_SIGMA` | `1.0` | Maximum sigma at size 100 | The single biggest knob for "how coarse can grain get?". Raising to `2.0` doubles the visible grain footprint at `size = 100`. |
| `GRAIN_REF_RESOLUTION` | `2000.0` | Reference long-edge resolution for sigma scaling | Defines the "1× zoom" image. Lower references make grain scale up more aggressively on large images; higher makes it shrink more. |
| `GRAIN_STRENGTH_MULT` | `0.04` | Maps amount to the final modulation strength | A 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_END` | `0.1` | End of the additive-grain shadow region | Defines where additive shadow grain stops fading in. Raising it pulls the shadow grain band higher up the tonal range. |
| `GRAIN_MULTIPLICATIVE_START` | `0.2` | Start of the multiplicative-grain midtone region | Pairs with the previous knob; together they set the smooth handoff from additive (shadows) to multiplicative (mid+) grain. |
| `GRAIN_ADDITIVE_SCALE` | `0.35` | Scales the additive delta in deep shadows | Direct multiplier on shadow grain visibility; halving it makes shadows almost grain-free. |
| `GRAIN_FALLOFF_REDUCTION` | `0.4` | Reduces luminance falloff as amount rises | At high `amount` values, this dampens the shadow-emphasis falloff so heavy grain spreads into highlights instead of just blowing out the dark areas. |
| `Fine` | `0.95` | `2.5` | `0.05` | `0.7` | The softest preset. It keeps contrast low, pushes grain out of highlights, and keeps channel decorrelation barely visible. |
| `Silver` | `1.2` | `1.5` | `0.10` | `0.6` | The default stock-like preset. It balances visible grain with enough chromatic separation to feel filmic without looking digital. |
| `Harsh` | `1.5` | `0.8` | `0.15` | `0.5` | The 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
- **CPU (Rust):** [`crates/agx/src/adjust/grain.rs`](https://github.com/zhjngli/AgX/blob/main/crates/agx/src/adjust/grain.rs)
- **GPU (WGSL):**
- [`grain_noise_gen.wgsl`](https://github.com/zhjngli/AgX/blob/main/crates/agx/src/shaders/grain_noise_gen.wgsl)
- [`grain_apply.wgsl`](https://github.com/zhjngli/AgX/blob/main/crates/agx/src/shaders/grain_apply.wgsl)
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](https://github.com/zhjngli/AgX/blob/main/docs/plans/2026-03-23-grain-design.md), [grain size fix](https://github.com/zhjngli/AgX/blob/main/docs/plans/2026-03-27-grain-size-fix-design.md), and [chromatic grain](https://github.com/zhjngli/AgX/blob/main/docs/plans/2026-03-29-chromatic-grain-design.md).