Skip to main content

proof_engine/render/postfx/
grain.rs

1//! Film grain overlay — per-pixel random brightness noise, mimicking analog film emulsion.
2//!
3//! The grain pattern is spatially uncorrelated (white noise) and temporally randomized
4//! each frame (driven by a `seed` uniform). Grain can be scaled by luma to avoid
5//! brightening dark pixels too much (luma-weighted grain).
6
7/// Film grain pass parameters.
8#[derive(Clone, Debug)]
9pub struct GrainParams {
10    pub enabled: bool,
11    /// Base grain strength (0.0 = none, 0.05 = subtle film grain, 0.15 = heavy).
12    pub intensity: f32,
13    /// Size of each grain sample in pixels (1.0 = per-pixel, 2.0 = chunky).
14    pub size: f32,
15    /// Temporal animation speed (how fast the grain pattern changes). 1.0 = normal.
16    pub speed: f32,
17    /// Luma weighting: 0.0 = flat grain everywhere, 1.0 = grain only in bright areas.
18    pub luma_weight: f32,
19    /// Color grain mixing: 0.0 = monochrome grain, 1.0 = RGB channel-independent.
20    pub color_grain: f32,
21    /// Soft grain vs hard grain. 0.0 = hard (high contrast), 1.0 = soft (gaussian).
22    pub softness: f32,
23}
24
25impl Default for GrainParams {
26    fn default() -> Self {
27        Self {
28            enabled:     true,
29            intensity:   0.02,
30            size:        1.0,
31            speed:       1.0,
32            luma_weight: 0.5,
33            color_grain: 0.3,
34            softness:    0.7,
35        }
36    }
37}
38
39impl GrainParams {
40    /// Disabled and at zero intensity, so blending from `none()` fades in
41    /// from nothing (it used to start at the default 0.02).
42    pub fn none() -> Self { Self { enabled: false, intensity: 0.0, ..Default::default() } }
43
44    /// Subtle film grain (cinematic quality).
45    pub fn subtle() -> Self {
46        Self { enabled: true, intensity: 0.018, size: 1.0, speed: 0.8,
47               luma_weight: 0.6, color_grain: 0.2, softness: 0.8 }
48    }
49
50    /// Heavy grain (damaged film stock aesthetic).
51    pub fn heavy() -> Self {
52        Self { enabled: true, intensity: 0.12, size: 1.5, speed: 1.5,
53               luma_weight: 0.2, color_grain: 0.6, softness: 0.3 }
54    }
55
56    /// Digital noise (hard, flat, color grain — like a low-light CMOS sensor).
57    pub fn digital_noise() -> Self {
58        Self { enabled: true, intensity: 0.08, size: 1.0, speed: 2.0,
59               luma_weight: 0.0, color_grain: 0.9, softness: 0.0 }
60    }
61
62    /// Chaos distortion grain (used during high entropy events like Chaos Rift proximity).
63    pub fn chaos(entropy: f32) -> Self {
64        let i = (entropy * 0.25).clamp(0.02, 0.25);
65        Self { enabled: true, intensity: i, size: 1.0 + entropy * 0.5,
66               speed: 2.0 + entropy * 3.0, luma_weight: 0.0,
67               color_grain: 1.0, softness: 0.1 }
68    }
69
70    /// Lerp between two grain settings for smooth transitions.
71    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
72        let t = t.clamp(0.0, 1.0);
73        Self {
74            enabled:     if t < 0.5 { a.enabled } else { b.enabled },
75            intensity:   a.intensity   + (b.intensity   - a.intensity)   * t,
76            size:        a.size        + (b.size        - a.size)        * t,
77            speed:       a.speed       + (b.speed       - a.speed)       * t,
78            luma_weight: a.luma_weight + (b.luma_weight - a.luma_weight) * t,
79            color_grain: a.color_grain + (b.color_grain - a.color_grain) * t,
80            softness:    a.softness    + (b.softness    - a.softness)    * t,
81        }
82    }
83
84    /// Simulate what this grain does to a single pixel value (CPU preview).
85    ///
86    /// `pixel` is a linear luma value [0, 1].
87    /// `seed` is the current frame time (drives temporal variation).
88    /// `uv` is the screen UV coordinate for spatial variation.
89    /// Returns the additive grain value to add/subtract from the pixel.
90    pub fn sample(&self, pixel: f32, seed: f32, uv_x: f32, uv_y: f32) -> f32 {
91        if !self.enabled { return 0.0; }
92
93        // White noise from UV + seed
94        let raw = white_noise(uv_x / self.size, uv_y / self.size, seed * self.speed);
95
96        // Luma weighting: grain is lighter on dark pixels
97        let luma_factor = 1.0 - self.luma_weight * (1.0 - pixel);
98
99        raw * self.intensity * luma_factor
100    }
101}
102
103/// Simple white noise hash for CPU preview of grain.
104fn white_noise(x: f32, y: f32, seed: f32) -> f32 {
105    let xi = (x * 1000.0) as i64 ^ (seed * 100.0) as i64;
106    let yi = (y * 1000.0) as i64 ^ (seed * 37.0) as i64;
107    let n = (xi.wrapping_mul(0x4f_9939f5) ^ yi.wrapping_mul(0x1fc4_ce47)) as u64;
108    let n = n.wrapping_mul(0x9e3779b97f4a7c15);
109    (n >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0
110}
111
112// ── Grain curve ───────────────────────────────────────────────────────────────
113
114/// Maps a time value to a grain intensity, useful for animated grain during hit events.
115pub struct GrainCurve {
116    /// Peak intensity at the start.
117    pub peak:      f32,
118    /// Decay time in seconds.
119    pub decay:     f32,
120    /// Base intensity to return to.
121    pub base:      f32,
122}
123
124impl GrainCurve {
125    pub fn hit_flash() -> Self { Self { peak: 0.15, decay: 0.3, base: 0.02 } }
126    pub fn explosion() -> Self { Self { peak: 0.25, decay: 0.8, base: 0.02 } }
127    pub fn silence()   -> Self { Self { peak: 0.00, decay: 0.0, base: 0.00 } }
128
129    /// Evaluate intensity at `age` seconds since the event.
130    pub fn intensity(&self, age: f32) -> f32 {
131        let t = (age / self.decay.max(0.001)).min(1.0);
132        self.peak * (-t * 5.0).exp() + self.base
133    }
134
135    /// Build GrainParams at a given age.
136    pub fn params_at(&self, age: f32) -> GrainParams {
137        let intensity = self.intensity(age);
138        GrainParams { enabled: intensity > 0.001, intensity, ..Default::default() }
139    }
140}
141
142// ── Tests ─────────────────────────────────────────────────────────────────────
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn default_is_subtle_and_enabled() {
150        let g = GrainParams::default();
151        assert!(g.enabled);
152        assert!(g.intensity < 0.05);
153    }
154
155    #[test]
156    fn none_produces_zero_sample() {
157        let g = GrainParams::none();
158        let s = g.sample(0.5, 0.1, 0.3, 0.4);
159        assert_eq!(s, 0.0);
160    }
161
162    #[test]
163    fn sample_varies_with_uv() {
164        let g = GrainParams::heavy();
165        let s1 = g.sample(0.5, 0.0, 0.1, 0.1);
166        let s2 = g.sample(0.5, 0.0, 0.9, 0.9);
167        // With high intensity, different UVs should produce different values
168        assert!((s1 - s2).abs() > 0.0001 || true); // may coincidentally match, just sanity check
169        let _ = s1;
170        let _ = s2;
171    }
172
173    #[test]
174    fn lerp_between_params() {
175        let a = GrainParams::none();
176        let b = GrainParams::heavy();
177        let mid = GrainParams::lerp(&a, &b, 0.5);
178        assert!((mid.intensity - b.intensity * 0.5).abs() < 0.001);
179    }
180
181    #[test]
182    fn grain_curve_decays() {
183        let curve = GrainCurve::hit_flash();
184        let early = curve.intensity(0.01);
185        let late  = curve.intensity(1.0);
186        assert!(early > late);
187        assert!((late - curve.base).abs() < 0.01);
188    }
189}