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    /// No grain.
41    pub fn none() -> Self { Self { enabled: false, ..Default::default() } }
42
43    /// Subtle film grain (cinematic quality).
44    pub fn subtle() -> Self {
45        Self { enabled: true, intensity: 0.018, size: 1.0, speed: 0.8,
46               luma_weight: 0.6, color_grain: 0.2, softness: 0.8 }
47    }
48
49    /// Heavy grain (damaged film stock aesthetic).
50    pub fn heavy() -> Self {
51        Self { enabled: true, intensity: 0.12, size: 1.5, speed: 1.5,
52               luma_weight: 0.2, color_grain: 0.6, softness: 0.3 }
53    }
54
55    /// Digital noise (hard, flat, color grain — like a low-light CMOS sensor).
56    pub fn digital_noise() -> Self {
57        Self { enabled: true, intensity: 0.08, size: 1.0, speed: 2.0,
58               luma_weight: 0.0, color_grain: 0.9, softness: 0.0 }
59    }
60
61    /// Chaos distortion grain (used during high entropy events like Chaos Rift proximity).
62    pub fn chaos(entropy: f32) -> Self {
63        let i = (entropy * 0.25).clamp(0.02, 0.25);
64        Self { enabled: true, intensity: i, size: 1.0 + entropy * 0.5,
65               speed: 2.0 + entropy * 3.0, luma_weight: 0.0,
66               color_grain: 1.0, softness: 0.1 }
67    }
68
69    /// Lerp between two grain settings for smooth transitions.
70    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
71        let t = t.clamp(0.0, 1.0);
72        Self {
73            enabled:     if t < 0.5 { a.enabled } else { b.enabled },
74            intensity:   a.intensity   + (b.intensity   - a.intensity)   * t,
75            size:        a.size        + (b.size        - a.size)        * t,
76            speed:       a.speed       + (b.speed       - a.speed)       * t,
77            luma_weight: a.luma_weight + (b.luma_weight - a.luma_weight) * t,
78            color_grain: a.color_grain + (b.color_grain - a.color_grain) * t,
79            softness:    a.softness    + (b.softness    - a.softness)    * t,
80        }
81    }
82
83    /// Simulate what this grain does to a single pixel value (CPU preview).
84    ///
85    /// `pixel` is a linear luma value [0, 1].
86    /// `seed` is the current frame time (drives temporal variation).
87    /// `uv` is the screen UV coordinate for spatial variation.
88    /// Returns the additive grain value to add/subtract from the pixel.
89    pub fn sample(&self, pixel: f32, seed: f32, uv_x: f32, uv_y: f32) -> f32 {
90        if !self.enabled { return 0.0; }
91
92        // White noise from UV + seed
93        let raw = white_noise(uv_x / self.size, uv_y / self.size, seed * self.speed);
94
95        // Luma weighting: grain is lighter on dark pixels
96        let luma_factor = 1.0 - self.luma_weight * (1.0 - pixel);
97
98        raw * self.intensity * luma_factor
99    }
100}
101
102/// Simple white noise hash for CPU preview of grain.
103fn white_noise(x: f32, y: f32, seed: f32) -> f32 {
104    let xi = (x * 1000.0) as i64 ^ (seed * 100.0) as i64;
105    let yi = (y * 1000.0) as i64 ^ (seed * 37.0) as i64;
106    let n = (xi.wrapping_mul(0x4f_9939f5) ^ yi.wrapping_mul(0x1fc4_ce47)) as u64;
107    let n = n.wrapping_mul(0x9e3779b97f4a7c15);
108    (n >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0
109}
110
111// ── Grain curve ───────────────────────────────────────────────────────────────
112
113/// Maps a time value to a grain intensity, useful for animated grain during hit events.
114pub struct GrainCurve {
115    /// Peak intensity at the start.
116    pub peak:      f32,
117    /// Decay time in seconds.
118    pub decay:     f32,
119    /// Base intensity to return to.
120    pub base:      f32,
121}
122
123impl GrainCurve {
124    pub fn hit_flash() -> Self { Self { peak: 0.15, decay: 0.3, base: 0.02 } }
125    pub fn explosion() -> Self { Self { peak: 0.25, decay: 0.8, base: 0.02 } }
126    pub fn silence()   -> Self { Self { peak: 0.00, decay: 0.0, base: 0.00 } }
127
128    /// Evaluate intensity at `age` seconds since the event.
129    pub fn intensity(&self, age: f32) -> f32 {
130        let t = (age / self.decay.max(0.001)).min(1.0);
131        self.peak * (-t * 5.0).exp() + self.base
132    }
133
134    /// Build GrainParams at a given age.
135    pub fn params_at(&self, age: f32) -> GrainParams {
136        let intensity = self.intensity(age);
137        GrainParams { enabled: intensity > 0.001, intensity, ..Default::default() }
138    }
139}
140
141// ── Tests ─────────────────────────────────────────────────────────────────────
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn default_is_subtle_and_enabled() {
149        let g = GrainParams::default();
150        assert!(g.enabled);
151        assert!(g.intensity < 0.05);
152    }
153
154    #[test]
155    fn none_produces_zero_sample() {
156        let g = GrainParams::none();
157        let s = g.sample(0.5, 0.1, 0.3, 0.4);
158        assert_eq!(s, 0.0);
159    }
160
161    #[test]
162    fn sample_varies_with_uv() {
163        let g = GrainParams::heavy();
164        let s1 = g.sample(0.5, 0.0, 0.1, 0.1);
165        let s2 = g.sample(0.5, 0.0, 0.9, 0.9);
166        // With high intensity, different UVs should produce different values
167        assert!((s1 - s2).abs() > 0.0001 || true); // may coincidentally match, just sanity check
168        let _ = s1;
169        let _ = s2;
170    }
171
172    #[test]
173    fn lerp_between_params() {
174        let a = GrainParams::none();
175        let b = GrainParams::heavy();
176        let mid = GrainParams::lerp(&a, &b, 0.5);
177        assert!((mid.intensity - b.intensity * 0.5).abs() < 0.001);
178    }
179
180    #[test]
181    fn grain_curve_decays() {
182        let curve = GrainCurve::hit_flash();
183        let early = curve.intensity(0.01);
184        let late  = curve.intensity(1.0);
185        assert!(early > late);
186        assert!((late - curve.base).abs() < 0.01);
187    }
188}