Skip to main content

ffcine_filters/
grain.rs

1//! Film grain: luma-weighted, optionally coarse (value noise), fresh every frame. A pointwise
2//! kernel at [`Point::Output`](crate::Point::Output), run as an mpv `.hook` on the grade pass's
3//! `OUTPUT` stage so it costs no dispatch of its own: libplacebo folds it into the pass that
4//! writes the target.
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
9#[serde(default, deny_unknown_fields)]
10pub struct Grain {
11    /// Grain sigma in 8-bit code values at mid-grey; 0 = off.
12    pub strength: f32,
13    /// Grain cell size in output pixels (1 = per pixel, 2-3 = coarser, more filmic).
14    pub size: f32,
15    /// Chroma grain relative to luma grain (0 = monochrome).
16    pub chroma: f32,
17}
18
19impl Default for Grain {
20    fn default() -> Self {
21        Self {
22            strength: 3.0,
23            size: 1.5,
24            chroma: 0.0,
25        }
26    }
27}
28
29/// `vec3 ffc_grain(vec2 pos, uint seed)` and its helpers; `pos` in output pixels. Standard
30/// normal per channel (r = luma, gb = chroma), as bilinear value noise over cells of `size`.
31fn noise_glsl(size: f32) -> String {
32    format!(
33        r"
34uint ffc_hash(uvec3 v) {{
35    v = v * 1664525u + 1013904223u;
36    v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
37    v ^= v >> 16u;
38    v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
39    return v.x ^ v.y ^ v.z;
40}}
41vec3 ffc_gauss(ivec2 c, uint seed) {{
42    vec3 g = vec3(0.0);
43    for (uint i = 0u; i < 4u; i++) {{
44        uint h = ffc_hash(uvec3(uvec2(c), seed * 4u + i));
45        g += vec3(h & 1023u, (h >> 10) & 1023u, (h >> 20) & 1023u) / 1023.0;
46    }}
47    return (g - 2.0) * 1.7320508;
48}}
49vec3 ffc_grain(vec2 pos, uint seed) {{
50    vec2 p = pos / {size:.4} - 0.5;
51    ivec2 i = ivec2(floor(p));
52    vec2 f = fract(p);
53    f = f * f * (3.0 - 2.0 * f);
54    vec3 a = mix(ffc_gauss(i, seed), ffc_gauss(i + ivec2(1, 0), seed), f.x);
55    vec3 b = mix(ffc_gauss(i + ivec2(0, 1), seed), ffc_gauss(i + ivec2(1, 1), seed), f.x);
56    // Interpolation lowers the variance; put it back so `strength` stays the sigma.
57    return mix(a, b, f.y) * {gain:.4};
58}}
59",
60        size = size.max(1.0),
61        gain = if size > 1.0 { 1.6 } else { 1.0 },
62    )
63}
64
65/// The grain as an mpv user shader on `OUTPUT` (graded, scaled RGB, before dither). `None` when
66/// off.
67pub fn hook(g: &Grain) -> Option<String> {
68    if g.strength <= 0.0 {
69        return None;
70    }
71    Some(format!(
72        r"//!HOOK OUTPUT
73//!BIND HOOKED
74//!DESC ffcine grain strength={s} size={z} chroma={c}
75{noise}
76vec4 hook() {{
77    vec4 c = HOOKED_texOff(vec2(0.0));
78    float y = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
79    // Strongest in the midtones, as on film; never zero, so shadows don't look plastic.
80    float resp = mix(0.35, 1.0, 4.0 * y * (1.0 - y));
81    vec3 n = ffc_grain(HOOKED_pos * HOOKED_size, uint(frame)) * ({s:.4} / 255.0) * resp;
82    c.rgb += n.r + {c:.4} * vec3(n.g, -0.5 * (n.g + n.b), n.b);
83    return c;
84}}
85",
86        s = g.strength,
87        z = g.size,
88        c = g.chroma,
89        noise = noise_glsl(g.size),
90    ))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn hook_off_and_on() {
99        assert!(
100            hook(&Grain {
101                strength: 0.0,
102                ..Grain::default()
103            })
104            .is_none()
105        );
106        let h = hook(&Grain::default()).unwrap();
107        assert!(h.starts_with("//!HOOK OUTPUT"));
108        assert!(h.contains("uint(frame)"));
109    }
110}