Skip to main content

ffcine_filters/
denoise.rs

1//! Motion-compensated temporal denoise plus a spatial NLM, as Vulkan compute kernels.
2//!
3//! The chain per output frame (`docs/denoise.md`), every kernel on the grade's `pl_gpu`:
4//!
5//! 1. **ingest** (once per decoded frame): [`copy_kernel`] copies each source plane into the ring
6//!    slot, [`quarter_kernel`] writes a quarter-resolution luma for motion search.
7//! 2. **motion** ([`motion_kernel`], one dispatch per neighbour): block matching, one vector per
8//!    16x16 luma block. Exhaustive SAD search at quarter resolution around a predictor (the vector
9//!    to the nearer neighbour, scaled by distance) and zero, then a +-2 px full-resolution refine.
10//!    Writes `(dx, dy, mad)`: the flow from the centre to the neighbour in luma pixels and the
11//!    block's mean absolute difference (8-bit code values) at that vector, the match confidence.
12//! 3. **merge** ([`merge_kernel`], one dispatch per plane): warps each neighbour onto the centre
13//!    (bilinear vectors and samples) and averages with per-pixel weights from the 3x3 patch
14//!    distance and the block confidence. A pixel whose neighbours all mismatch (occlusion, cut,
15//!    bad vector) keeps the centre value, which is what keeps it from ghosting.
16//! 4. **spatial** ([`spatial_kernel`], one dispatch per plane): NLM with a 3x3 patch over a
17//!    `(2r+1)^2` search window, read from a shared-memory tile.
18//!
19//! Distances are in 8-bit code values of the normalised signal (`FFC_NORM` turns a sampled value
20//! into 0..1 for LSB-aligned formats), so strengths mean the same for 8-, 10- and 16-bit input.
21//! Planes are sampled as stored (`r16`/`rg16` unorm for P010/P210), never converted to 8 bits.
22
23use std::fmt::Write as _;
24
25use serde::{Deserialize, Serialize};
26
27#[allow(clippy::trivially_copy_pass_by_ref)] // serde's signature
28fn is_zero(v: &f32) -> bool {
29    *v == 0.0
30}
31
32/// Temporal (motion-compensated) part.
33#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34#[serde(default, deny_unknown_fields)]
35pub struct Temporal {
36    /// Frames on each side, 1..=5.
37    pub radius: u32,
38    /// Motion source.
39    pub motion: Motion,
40    /// Luma strength: the noise sigma (8-bit code values) treated as "the same pixel".
41    pub strength: f32,
42    /// Chroma strength; `None` = `strength`.
43    pub chroma: Option<f32>,
44    /// Point protection (luma): how far, in strengths, a warped neighbour may fall below a bright
45    /// centre pixel before it stops counting; 0 = off (`docs/denoise.md#point-protection`).
46    #[serde(skip_serializing_if = "is_zero")]
47    pub protect: f32,
48}
49
50impl Default for Temporal {
51    fn default() -> Self {
52        Self {
53            radius: 2,
54            motion: Motion::default(),
55            strength: 3.0,
56            chroma: None,
57            protect: 0.0,
58        }
59    }
60}
61
62impl Temporal {
63    pub const MAX_RADIUS: u32 = 5;
64
65    pub fn chroma_strength(&self) -> f32 {
66        self.chroma.unwrap_or(self.strength)
67    }
68}
69
70/// Spatial NLM part.
71#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
72#[serde(default, deny_unknown_fields)]
73pub struct Spatial {
74    /// Search radius (window `2r+1` square), 1..=4.
75    pub radius: u32,
76    /// Luma strength, 8-bit code values; 0 skips the luma plane.
77    pub luma: f32,
78    /// Chroma strength; 0 skips the chroma planes.
79    pub chroma: f32,
80    /// Point protection (luma), as in [`Temporal::protect`].
81    #[serde(skip_serializing_if = "is_zero")]
82    pub protect: f32,
83}
84
85impl Default for Spatial {
86    fn default() -> Self {
87        Self {
88            radius: 2,
89            luma: 2.0,
90            chroma: 3.0,
91            protect: 0.0,
92        }
93    }
94}
95
96impl Spatial {
97    pub const MAX_RADIUS: u32 = 4;
98}
99
100/// A denoise stage: temporal, then spatial on its result. Either may be off.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
102#[serde(default)]
103pub struct Denoise {
104    pub temporal: Option<Temporal>,
105    pub spatial: Option<Spatial>,
106}
107
108impl Denoise {
109    pub fn is_off(&self) -> bool {
110        self.temporal.is_none() && self.spatial.is_none()
111    }
112
113    /// Frames of lookahead (and history) the stage needs.
114    pub fn radius(&self) -> u32 {
115        self.temporal.map_or(0, |t| t.radius)
116    }
117
118    /// Clamps radii into range.
119    #[must_use]
120    pub fn clamped(mut self) -> Self {
121        if let Some(t) = &mut self.temporal {
122            t.radius = t.radius.clamp(1, Temporal::MAX_RADIUS);
123            t.protect = t.protect.max(0.0);
124        }
125        if let Some(s) = &mut self.spatial {
126            s.radius = s.radius.clamp(1, Spatial::MAX_RADIUS);
127            s.protect = s.protect.max(0.0);
128        }
129        self
130    }
131}
132
133/// Parameters tuned for a kind of footage (`docs/denoise.md#presets`). Individual values override
134/// it: [`Overrides::resolve`], and the timeline's `preset` field.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum Preset {
138    /// Flat dark sky: a strong temporal pass with stars and point lights protected.
139    Night,
140    /// Bright, low noise, fine texture: gentle.
141    Snow,
142}
143
144impl Preset {
145    pub fn denoise(self) -> Denoise {
146        match self {
147            Self::Night => Denoise {
148                temporal: Some(Temporal {
149                    radius: 4,
150                    motion: Motion::Nvof,
151                    strength: 2.0,
152                    chroma: Some(3.0),
153                    protect: 2.0,
154                }),
155                spatial: Some(Spatial {
156                    radius: 2,
157                    luma: 1.0,
158                    chroma: 2.5,
159                    protect: 2.0,
160                }),
161            },
162            Self::Snow => Denoise {
163                temporal: Some(Temporal {
164                    radius: 2,
165                    motion: Motion::Nvof,
166                    strength: 1.5,
167                    chroma: None,
168                    protect: 0.0,
169                }),
170                spatial: None,
171            },
172        }
173    }
174}
175
176impl std::str::FromStr for Preset {
177    type Err = String;
178    fn from_str(s: &str) -> Result<Self, String> {
179        match s {
180            "night" => Ok(Self::Night),
181            "snow" => Ok(Self::Snow),
182            _ => Err(format!("unknown denoise preset {s:?} (night, snow)")),
183        }
184    }
185}
186
187/// Values set one by one (`ffcine grade` flags); what isn't set comes from the preset, or without
188/// one from the defaults, where both passes are off until asked for.
189#[derive(Debug, Clone, Copy, Default, PartialEq)]
190pub struct Overrides {
191    /// 0 turns the temporal pass off.
192    pub temporal_radius: Option<u32>,
193    pub temporal_motion: Option<Motion>,
194    pub temporal_strength: Option<f32>,
195    pub temporal_chroma: Option<f32>,
196    /// Spatial luma strength; 0 turns the spatial pass off unless `chroma` is set.
197    pub luma: Option<f32>,
198    pub chroma: Option<f32>,
199    pub spatial_radius: Option<u32>,
200    /// Both passes' [`Temporal::protect`].
201    pub protect: Option<f32>,
202}
203
204impl Overrides {
205    /// The stage for `preset` with these values on top, clamped.
206    #[must_use]
207    pub fn resolve(&self, preset: Option<Preset>) -> Denoise {
208        let base = preset.map_or_else(Denoise::default, Preset::denoise);
209        let t0 = base.temporal.unwrap_or_default();
210        let radius = self
211            .temporal_radius
212            .unwrap_or(base.temporal.map_or(0, |t| t.radius));
213        let temporal = (radius > 0).then_some(Temporal {
214            radius,
215            motion: self.temporal_motion.unwrap_or(t0.motion),
216            strength: self.temporal_strength.unwrap_or(t0.strength),
217            chroma: self.temporal_chroma.or(t0.chroma),
218            protect: self.protect.unwrap_or(t0.protect),
219        });
220        Denoise {
221            temporal,
222            spatial: spatial_over(
223                base.spatial,
224                self.luma,
225                self.chroma,
226                self.spatial_radius,
227                self.protect,
228            ),
229        }
230        .clamped()
231    }
232}
233
234/// `base` with spatial values on top. Without a base the luma strength decides and chroma defaults
235/// to 1.5x it; with one, luma 0 alone still means "spatial off".
236pub(crate) fn spatial_over(
237    base: Option<Spatial>,
238    luma: Option<f32>,
239    chroma: Option<f32>,
240    radius: Option<u32>,
241    protect: Option<f32>,
242) -> Option<Spatial> {
243    let d = Spatial::default();
244    let l = luma.or(base.map(|b| b.luma)).unwrap_or(0.0);
245    let c = chroma.unwrap_or(match (base, luma) {
246        (Some(_), Some(v)) if v <= 0.0 => 0.0,
247        (Some(b), _) => b.chroma,
248        (None, _) => 1.5 * l,
249    });
250    (l > 0.0 || c > 0.0).then_some(Spatial {
251        radius: radius.or(base.map(|b| b.radius)).unwrap_or(d.radius),
252        luma: l,
253        chroma: c,
254        protect: protect.or(base.map(|b| b.protect)).unwrap_or(d.protect),
255    })
256}
257
258/// Workgroup size of every kernel here.
259pub const GROUP: u32 = 16;
260/// Luma pixels per motion vector, each way.
261pub const BLOCK: u32 = 16;
262/// Quarter-resolution search range around the predictor, in quarter pixels (+-24 luma pixels).
263pub const SEARCH: u32 = 6;
264
265/// A generated compute kernel: `header` (functions, shared memory) and `body` (inside `main`),
266/// reading and writing the descriptors named in the doc of the function that made it.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct Compute {
269    pub header: String,
270    pub body: String,
271    /// Shared memory in bytes.
272    pub shmem: usize,
273}
274
275fn swizzle(comps: u32) -> (&'static str, &'static str) {
276    if comps == 1 {
277        ("float", "r")
278    } else {
279        ("vec2", "rg")
280    }
281}
282
283fn store(comps: u32, v: &str) -> String {
284    if comps == 1 {
285        format!("vec4({v}, 0.0, 0.0, 1.0)")
286    } else {
287        format!("vec4({v}, 0.0, 1.0)")
288    }
289}
290
291/// Copies `src` (sampled) into `dst` (storage), same size.
292pub fn copy_kernel() -> Compute {
293    Compute {
294        header: String::new(),
295        body: r"
296    ivec2 p = ivec2(gl_GlobalInvocationID.xy);
297    if (all(lessThan(p, imageSize(dst))))
298        imageStore(dst, p, texelFetch(src, p, 0));
299"
300        .into(),
301        shmem: 0,
302    }
303}
304
305/// Box-averages 4x4 luma of `src` into quarter-size `dst` (`r16f`), normalised to 0..1.
306pub fn quarter_kernel(norm: f32) -> Compute {
307    Compute {
308        header: String::new(),
309        body: format!(
310            r"
311    ivec2 q = ivec2(gl_GlobalInvocationID.xy);
312    if (all(lessThan(q, imageSize(dst)))) {{
313        ivec2 hi = textureSize(src, 0) - 1;
314        float s = 0.0;
315        for (int j = 0; j < 4; j++)
316            for (int i = 0; i < 4; i++)
317                s += texelFetch(src, min(q * 4 + ivec2(i, j), hi), 0).r;
318        imageStore(dst, q, vec4(s * ({norm:.8} / 16.0), 0.0, 0.0, 1.0));
319    }}
320"
321        ),
322        shmem: 0,
323    }
324}
325
326/// Block matching from the centre frame to one neighbour.
327///
328/// Descriptors: `qc`, `qn` (quarter luma of centre and neighbour), `lc`, `ln` (full luma),
329/// `pred` (motion to the nearer neighbour, only if `pred_scale` is set: its vectors times
330/// `pred_scale` seed the search), `dst` (`rgba16f`, one texel per [`BLOCK`]).
331pub fn motion_kernel(norm: f32, pred_scale: Option<f32>) -> Compute {
332    let pred = pred_scale.map_or_else(
333        || "vec2 vp = vec2(0.0);".to_owned(),
334        |s| format!("vec2 vp = texelFetch(pred, b, 0).xy * {s:.6};"),
335    );
336    Compute {
337        header: String::new(),
338        body: format!(
339            r"
340    ivec2 b = ivec2(gl_GlobalInvocationID.xy);
341    if (all(lessThan(b, imageSize(dst)))) {{
342        const float K = {norm:.8} * 255.0;
343        ivec2 qhi = textureSize(qn, 0) - 1;
344        ivec2 q0 = b * 4;
345        float cq[16];
346        for (int i = 0; i < 16; i++)
347            cq[i] = texelFetch(qc, min(q0 + ivec2(i & 3, i >> 2), qhi), 0).r;
348        {pred}
349        ivec2 seed = ivec2(round(vp * 0.25));
350        ivec2 best = ivec2(0);
351        float bc = 1e9;
352        for (int pass = 0; pass < 2; pass++) {{
353            ivec2 c0 = pass == 0 ? ivec2(0) : seed;
354            int r = pass == 0 ? 1 : {SEARCH};
355            for (int dy = -r; dy <= r; dy++)
356                for (int dx = -r; dx <= r; dx++) {{
357                    ivec2 v = c0 + ivec2(dx, dy);
358                    float c = 0.0;
359                    for (int i = 0; i < 16; i++)
360                        c += abs(cq[i] - texelFetch(qn, clamp(q0 + ivec2(i & 3, i >> 2) + v, ivec2(0), qhi), 0).r);
361                    // Prefer short vectors on flat or ambiguous blocks.
362                    c += 0.002 * float(abs(v.x) + abs(v.y));
363                    if (c < bc) {{ bc = c; best = v; }}
364                }}
365        }}
366        // Full-resolution refine around 4x the quarter vector: steps of 2 px, then 1 px, on the
367        // 16x16 block subsampled by 2 (centre samples cached).
368        ivec2 lhi = textureSize(ln, 0) - 1;
369        ivec2 l0 = b * {BLOCK};
370        float cl[64];
371        for (int i = 0; i < 64; i++)
372            cl[i] = texelFetch(lc, min(l0 + 2 * ivec2(i & 7, i >> 3), lhi), 0).r;
373        ivec2 fbest = best * 4;
374        float fc = 1e9;
375        for (int step = 2; step >= 1; step--) {{
376            ivec2 c1 = fbest;
377            for (int dy = -1; dy <= 1; dy++)
378                for (int dx = -1; dx <= 1; dx++) {{
379                    if (step == 1 && dx == 0 && dy == 0) continue;
380                    ivec2 v = c1 + step * ivec2(dx, dy);
381                    float c = 0.0;
382                    for (int i = 0; i < 64; i++)
383                        c += abs(cl[i] - texelFetch(ln, clamp(l0 + 2 * ivec2(i & 7, i >> 3) + v, ivec2(0), lhi), 0).r);
384                    if (c < fc) {{ fc = c; fbest = v; }}
385                }}
386        }}
387        float mad = fc * K / float(({BLOCK} / 2) * ({BLOCK} / 2));
388        imageStore(dst, b, vec4(vec2(fbest), mad, 1.0));
389    }}
390"
391        ),
392        shmem: 0,
393    }
394}
395
396/// Where the motion vectors come from.
397#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum Motion {
400    /// NVIDIA optical flow (NVOFA) on half-resolution 8-bit luma, 4x4 grid (8x8 luma pixels),
401    /// via CUDA, rescored in Vulkan; falls back to `blocks` where NVOF isn't available.
402    #[default]
403    Nvof,
404    /// Vulkan compute block matching, 16x16 blocks.
405    Blocks,
406}
407
408impl std::str::FromStr for Motion {
409    type Err = String;
410    fn from_str(s: &str) -> Result<Self, String> {
411        match s {
412            "nvof" => Ok(Self::Nvof),
413            "blocks" => Ok(Self::Blocks),
414            _ => Err(format!("unknown motion source {s:?} (nvof, blocks)")),
415        }
416    }
417}
418
419/// Luma pixels per NVOF vector: half-resolution input, 4x4 output grid.
420pub const NVOF_BLOCK: u32 = 8;
421
422/// Half-resolution 8-bit luma for NVOF: 2x2 box average of `src` into `dst` (`r8`).
423pub fn luma8_kernel(norm: f32) -> Compute {
424    Compute {
425        header: String::new(),
426        body: format!(
427            r"
428    ivec2 q = ivec2(gl_GlobalInvocationID.xy);
429    if (all(lessThan(q, imageSize(dst)))) {{
430        ivec2 hi = textureSize(src, 0) - 1;
431        float s = 0.0;
432        for (int i = 0; i < 4; i++)
433            s += texelFetch(src, min(q * 2 + ivec2(i & 1, i >> 1), hi), 0).r;
434        imageStore(dst, q, vec4((clamp(s * ({norm:.8} / 4.0), 0.0, 1.0)), 0.0, 0.0, 1.0));
435    }}
436"
437        ),
438        shmem: 0,
439    }
440}
441
442/// Scores NVOF's vectors like [`motion_kernel`] does its own: `flow` (`rg32f`, luma pixels, one
443/// per [`NVOF_BLOCK`]) in, `(dx, dy, mad)` out to `dst` (`rgba16f`), the MAD of the block at its
444/// vector between full-resolution `lc` and `ln`.
445pub fn confidence_kernel(norm: f32) -> Compute {
446    Compute {
447        header: String::new(),
448        body: format!(
449            r"
450    ivec2 b = ivec2(gl_GlobalInvocationID.xy);
451    if (all(lessThan(b, imageSize(dst)))) {{
452        // NVOF's vector, its four neighbours' and zero: keep the best match. NVOF regularises
453        // flat dark areas into arbitrary vectors; static ones are usually right there.
454        ivec2 fhi = imageSize(dst) - 1;
455        vec2 cand[6] = vec2[6](
456            texelFetch(flow, b, 0).xy, vec2(0.0),
457            texelFetch(flow, clamp(b + ivec2(1, 0), ivec2(0), fhi), 0).xy,
458            texelFetch(flow, clamp(b - ivec2(1, 0), ivec2(0), fhi), 0).xy,
459            texelFetch(flow, clamp(b + ivec2(0, 1), ivec2(0), fhi), 0).xy,
460            texelFetch(flow, clamp(b - ivec2(0, 1), ivec2(0), fhi), 0).xy);
461        ivec2 lhi = textureSize(ln, 0) - 1;
462        ivec2 l0 = b * {NVOF_BLOCK};
463        float cl[{NVOF_BLOCK} * {NVOF_BLOCK}];
464        for (int i = 0; i < {NVOF_BLOCK} * {NVOF_BLOCK}; i++)
465            cl[i] = texelFetch(lc, min(l0 + ivec2(i % {NVOF_BLOCK}, i / {NVOF_BLOCK}), lhi), 0).r;
466        vec2 v = cand[0];
467        float c = 1e9;
468        for (int k = 0; k < 6; k++) {{
469            ivec2 iv = ivec2(round(cand[k]));
470            float ck = 0.0;
471            for (int i = 0; i < {NVOF_BLOCK} * {NVOF_BLOCK}; i++) {{
472                ivec2 p = min(l0 + ivec2(i % {NVOF_BLOCK}, i / {NVOF_BLOCK}), lhi);
473                ck += abs(cl[i] - texelFetch(ln, clamp(p + iv, ivec2(0), lhi), 0).r);
474            }}
475            if (ck < c) {{ c = ck; v = cand[k]; }}
476        }}
477        float mad = c * {norm:.8} * 255.0 / float({NVOF_BLOCK} * {NVOF_BLOCK});
478        imageStore(dst, b, vec4(v, mad, 1.0));
479    }}
480"
481        ),
482        shmem: 0,
483    }
484}
485
486/// Shape of a plane relative to luma.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub struct Plane {
489    /// 1 (luma or a single chroma plane) or 2 (interleaved chroma).
490    pub comps: u32,
491    /// Subsampling relative to luma (1 or 2 each way).
492    pub sub: (u32, u32),
493}
494
495/// Point protection (`docs/denoise.md#point-protection`), GLSL: `pw`, the weight of a sample from
496/// how far it falls below the centre pixel `cen`. The 3x3 patch distance divides one pixel's
497/// difference by 9, so a point a few sigmas above a flat sky matches every empty patch; this looks
498/// at the pixel alone, and only down: bright points stay, dark outliers still average away.
499fn protect_glsl(h: f32, protect: f32, cen: &str, sample: &str) -> String {
500    let t = protect * h;
501    format!(
502        "float pw = exp(-max(pow(max({cen} - {sample}, 0.0), 2.0) - {t2:.6}, 0.0) / {h2:.6});",
503        t2 = t * t,
504        h2 = h * h,
505    )
506}
507
508/// Motion-compensated merge of `k` neighbours into the centre.
509///
510/// Descriptors: `cur` (centre plane, nearest), `n0..n{k-1}` (neighbour planes, linear),
511/// `m0..m{k-1}` (their motion, linear), `dst` (storage, plane size).
512pub fn merge_kernel(
513    k: usize,
514    plane: Plane,
515    norm: f32,
516    strength: f32,
517    protect: f32,
518    block: u32,
519) -> Compute {
520    let (ty, sw) = swizzle(plane.comps);
521    let (sx, sy) = plane.sub;
522    let h = strength.max(0.1);
523    let protected = protect > 0.0 && plane.comps == 1;
524    // Block MAD between two frames of pure noise sigma h is ~1.13 h (luma, at the vector);
525    // beyond that the match itself is doubtful.
526    let mth = 1.5 * h + 1.0;
527    let header = format!(
528        r"
529const float FFC_K = {norm:.8} * 255.0;
530const vec2 FFC_SUB = vec2({sx}.0, {sy}.0);
531const float FFC_H2 = {h2:.6};
532const float FFC_MTH = {mth:.6};
533const float FFC_MS = {ms:.6};
534
535void ffc_merge(sampler2D n, sampler2D m, ivec2 p, vec2 sz, {ty} cp[9], inout {ty} acc, inout float wsum) {{
536    vec2 msz = vec2(textureSize(m, 0)) * {block}.0;
537    vec3 mv = textureLod(m, (vec2(p) + 0.5) * FFC_SUB / msz, 0.0).xyz;
538    vec2 q = vec2(p) + 0.5 + mv.xy / FFC_SUB;
539    if (any(lessThan(q, vec2(0.5))) || any(greaterThan(q, sz - 0.5)))
540        return;
541    float d2 = 0.0;
542    float vmax = 0.0;
543    {ty} c = {ty}(0.0);
544    for (int i = 0; i < 9; i++) {{
545        {ty} v = textureLod(n, (q + vec2(i % 3 - 1, i / 3 - 1)) / sz, 0.0).{sw};
546        if (i == 4) c = v * FFC_K;
547        {ty} d = v * FFC_K - cp[i];
548        d2 += dot(d, d);
549{track}
550    }}
551    d2 /= {n9}.0;
552    float w = exp(-max(d2 - 2.0 * FFC_H2, 0.0) / FFC_H2)
553            * exp(-max(mv.z - FFC_MTH, 0.0) / FFC_MS);
554{prot}
555    acc += w * c;
556    wsum += w;
557}}
558",
559        h2 = h * h,
560        ms = 0.5 * h + 0.5,
561        n9 = 9 * plane.comps,
562        track = if protected {
563            "        vmax = max(vmax, v * FFC_K);"
564        } else {
565            ""
566        },
567        prot = if protected {
568            // A point in this frame that the neighbour also has, within a pixel, and that the
569            // vector just misses: keep it out. A lone bright pixel in the centre (noise) has no
570            // such evidence in the neighbour and averages away as usual.
571            format!(
572                "    {}\n    w *= mix(1.0, pw, clamp((vmax - cp[4] + {t:.6}) / {h:.6} + 1.0, 0.0, 1.0));",
573                protect_glsl(h, protect, "cp[4]", "c"),
574                t = protect * h,
575            )
576        } else {
577            String::new()
578        },
579    );
580    let mut body = format!(
581        r"
582    ivec2 p = ivec2(gl_GlobalInvocationID.xy);
583    ivec2 isz = textureSize(cur, 0);
584    if (all(lessThan(p, isz))) {{
585        vec2 sz = vec2(isz);
586        {ty} cp[9];
587        for (int i = 0; i < 9; i++)
588            cp[i] = texelFetch(cur, clamp(p + ivec2(i % 3 - 1, i / 3 - 1), ivec2(0), isz - 1), 0).{sw} * FFC_K;
589        {ty} acc = cp[4];
590        float wsum = 1.0;
591"
592    );
593    for i in 0..k {
594        let _ = writeln!(body, "        ffc_merge(n{i}, m{i}, p, sz, cp, acc, wsum);");
595    }
596    let _ = writeln!(
597        body,
598        "        imageStore(dst, p, {});\n    }}",
599        store(plane.comps, "acc / (wsum * FFC_K)")
600    );
601    Compute {
602        header,
603        body,
604        shmem: 0,
605    }
606}
607
608/// NLM (3x3 patch, `(2 radius + 1)^2` search) from `src` (sampled) to `dst` (storage).
609pub fn spatial_kernel(radius: u32, comps: u32, norm: f32, strength: f32, protect: f32) -> Compute {
610    let (ty, sw) = swizzle(comps);
611    let halo = radius + 1;
612    let t = GROUP + 2 * halo;
613    let h = strength.max(0.1);
614    let header = format!(
615        r"
616const int FFC_T = {t};
617const int FFC_R = {radius};
618const float FFC_K = {norm:.8} * 255.0;
619const float FFC_H2 = {h2:.6};
620shared {ty} ffc_tile[FFC_T * FFC_T];
621{ty} ffc_at(ivec2 l) {{ return ffc_tile[l.y * FFC_T + l.x]; }}
622",
623        h2 = h * h,
624    );
625    let body = format!(
626        r"
627    ivec2 isz = textureSize(src, 0);
628    ivec2 g0 = ivec2(gl_WorkGroupID.xy) * {GROUP} - {halo};
629    for (uint i = gl_LocalInvocationIndex; i < uint(FFC_T * FFC_T); i += {GROUP}u * {GROUP}u) {{
630        ivec2 o = ivec2(int(i) % FFC_T, int(i) / FFC_T);
631        ffc_tile[i] = texelFetch(src, clamp(g0 + o, ivec2(0), isz - 1), 0).{sw} * FFC_K;
632    }}
633    barrier();
634    ivec2 p = ivec2(gl_GlobalInvocationID.xy);
635    if (all(lessThan(p, isz))) {{
636        ivec2 l = ivec2(gl_LocalInvocationID.xy) + {halo};
637        {ty} acc = {ty}(0.0);
638        float wsum = 0.0;
639        for (int sy = -FFC_R; sy <= FFC_R; sy++)
640            for (int sx = -FFC_R; sx <= FFC_R; sx++) {{
641                ivec2 s = l + ivec2(sx, sy);
642                float d2 = 0.0;
643                for (int j = -1; j <= 1; j++)
644                    for (int i = -1; i <= 1; i++) {{
645                        {ty} d = ffc_at(s + ivec2(i, j)) - ffc_at(l + ivec2(i, j));
646                        d2 += dot(d, d);
647                    }}
648                d2 /= {n9}.0;
649                float w = exp(-max(d2 - 2.0 * FFC_H2, 0.0) / FFC_H2);
650                {prot}
651                acc += w * ffc_at(s);
652                wsum += w;
653            }}
654        imageStore(dst, p, {st});
655    }}
656",
657        n9 = 9 * comps,
658        prot = if protect > 0.0 && comps == 1 {
659            format!(
660                "{} w *= pw;",
661                protect_glsl(h, protect, "ffc_at(l)", "ffc_at(s)")
662            )
663        } else {
664            String::new()
665        },
666        st = store(comps, "acc / (wsum * FFC_K)"),
667    );
668    let shmem = (t * t) as usize * 4 * comps as usize;
669    Compute {
670        header,
671        body,
672        shmem,
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn denoise_params_parse_and_clamp() {
682        let d: Denoise =
683            serde_json::from_str(r#"{"temporal": {"radius": 9, "strength": 6}}"#).unwrap();
684        let d = d.clamped();
685        assert_eq!(d.radius(), 5);
686        assert!(d.spatial.is_none());
687        assert!((d.temporal.unwrap().chroma_strength() - 6.0).abs() < f32::EPSILON);
688        assert!(Denoise::default().is_off());
689    }
690
691    #[test]
692    fn merge_calls_every_neighbour() {
693        let c = merge_kernel(
694            4,
695            Plane {
696                comps: 2,
697                sub: (2, 2),
698            },
699            1.0,
700            4.0,
701            0.0,
702            BLOCK,
703        );
704        assert_eq!(c.body.matches("ffc_merge(").count(), 4);
705        assert!(c.body.contains("n3, m3"));
706        assert!(c.header.contains("vec2 cp[9]"));
707    }
708
709    #[test]
710    fn protect_only_when_asked() {
711        let plane = Plane {
712            comps: 1,
713            sub: (1, 1),
714        };
715        let off = merge_kernel(2, plane, 1.0, 3.0, 0.0, BLOCK);
716        assert!(!off.header.contains("pw"), "{}", off.header);
717        assert!(!spatial_kernel(2, 1, 1.0, 2.0, 0.0).body.contains("pw"));
718        // 3 strengths of 2: the threshold is (3 * 2)^2 = 36.
719        let want = "- 36.000000, 0.0) / 4.000000";
720        let c = merge_kernel(2, plane, 1.0, 2.0, 3.0, BLOCK);
721        assert!(c.header.contains(want), "{}", c.header);
722        let c = spatial_kernel(2, 1, 1.0, 2.0, 3.0);
723        assert!(c.body.contains(want), "{}", c.body);
724        // Chroma never: a point is a luma thing.
725        let chroma = Plane {
726            comps: 2,
727            sub: (2, 2),
728        };
729        assert!(
730            !merge_kernel(2, chroma, 1.0, 2.0, 3.0, BLOCK)
731                .header
732                .contains("pw")
733        );
734        assert!(!spatial_kernel(2, 2, 1.0, 2.0, 3.0).body.contains("pw"));
735    }
736
737    #[test]
738    fn protect_off_stays_out_of_plan_keys() {
739        // Render segments are keyed on the serialized params: no protection, same key as before.
740        let v = serde_json::to_value(Temporal::default()).unwrap();
741        assert!(v.get("protect").is_none());
742        let d: Temporal = serde_json::from_value(v).unwrap();
743        assert_eq!(d, Temporal::default());
744        let night = serde_json::to_value(Preset::Night.denoise()).unwrap();
745        assert!(night["temporal"].get("protect").is_some());
746    }
747
748    #[test]
749    fn presets_parse_and_stay_in_range() {
750        for (name, p) in [("night", Preset::Night), ("snow", Preset::Snow)] {
751            assert_eq!(name.parse::<Preset>().unwrap(), p);
752            let d = p.denoise();
753            assert_eq!(d.clamped(), d);
754            assert_eq!(Overrides::default().resolve(Some(p)), d);
755        }
756        assert!("aurora".parse::<Preset>().is_err());
757    }
758
759    #[test]
760    fn overrides_without_a_preset_keep_the_old_flags() {
761        assert!(Overrides::default().resolve(None).is_off());
762        let d = Overrides {
763            temporal_radius: Some(2),
764            luma: Some(2.0),
765            ..Overrides::default()
766        }
767        .resolve(None);
768        assert_eq!(d.temporal, Some(Temporal::default()));
769        let s = d.spatial.unwrap();
770        assert_eq!((s.radius, s.luma, s.chroma), (2, 2.0, 3.0));
771        // Only strengths given, no radius: still off, as before.
772        let d = Overrides {
773            temporal_strength: Some(9.0),
774            ..Overrides::default()
775        }
776        .resolve(None);
777        assert!(d.is_off());
778    }
779
780    #[test]
781    fn overrides_replace_preset_values_one_by_one() {
782        let night = Preset::Night.denoise();
783        let d = Overrides {
784            temporal_strength: Some(9.0),
785            protect: Some(-1.0),
786            ..Overrides::default()
787        }
788        .resolve(Some(Preset::Night));
789        let t = d.temporal.unwrap();
790        assert_eq!(t.radius, night.temporal.unwrap().radius);
791        assert_eq!((t.strength, t.protect), (9.0, 0.0));
792        assert_eq!(d.spatial.unwrap().luma, night.spatial.unwrap().luma);
793        // Luma 0 alone turns the spatial pass off; with a chroma strength it stays for chroma.
794        let off = Overrides {
795            luma: Some(0.0),
796            ..Overrides::default()
797        };
798        assert!(off.resolve(Some(Preset::Night)).spatial.is_none());
799        let chroma = Overrides {
800            chroma: Some(2.0),
801            ..off
802        };
803        let s = chroma.resolve(Some(Preset::Night)).spatial.unwrap();
804        assert_eq!((s.luma, s.chroma), (0.0, 2.0));
805    }
806
807    /// CPU references of the merge and the NLM, the weights of the generated GLSL: known zero
808    /// motion, no block confidence, one plane in 8-bit code values. Enough to see what the weights
809    /// do to a point.
810    #[allow(
811        clippy::cast_possible_truncation,
812        clippy::cast_possible_wrap,
813        clippy::cast_precision_loss,
814        clippy::cast_sign_loss
815    )]
816    mod reference {
817        use super::*;
818
819        pub const N: usize = 96;
820        pub type Frame = Vec<f32>;
821
822        /// xorshift + Box-Muller, so the tests need no dependency and never change.
823        pub struct Noise(pub u64);
824
825        impl Noise {
826            fn uniform(&mut self) -> f32 {
827                self.0 ^= self.0 << 13;
828                self.0 ^= self.0 >> 7;
829                self.0 ^= self.0 << 17;
830                ((self.0 >> 40) as f32 + 1.0) / ((1u64 << 24) as f32 + 2.0)
831            }
832
833            pub fn gauss(&mut self) -> f32 {
834                let (a, b) = (self.uniform(), self.uniform());
835                (-2.0 * a.ln()).sqrt() * (std::f32::consts::TAU * b).cos()
836            }
837        }
838
839        /// `exp(-max(d2 - 2h^2, 0) / h^2)`.
840        fn patch_weight(d2: f32, h: f32) -> f32 {
841            (-(d2 - 2.0 * h * h).max(0.0) / (h * h)).exp()
842        }
843
844        /// `pw` of [`protect_glsl`].
845        fn protect_weight(centre: f32, sample: f32, h: f32, protect: f32) -> f32 {
846            let (t, d) = (protect * h, (centre - sample).max(0.0));
847            (-(d * d - t * t).max(0.0) / (h * h)).exp()
848        }
849
850        fn at(f: &[f32], x: i32, y: i32) -> f32 {
851            let c = |v: i32| v.clamp(0, N as i32 - 1) as usize;
852            f[c(y) * N + c(x)]
853        }
854
855        fn d2(a: &[f32], ax: i32, ay: i32, b: &[f32], bx: i32, by: i32) -> f32 {
856            let mut s = 0.0;
857            for j in -1..=1 {
858                for i in -1..=1 {
859                    let d = at(a, ax + i, ay + j) - at(b, bx + i, by + j);
860                    s += d * d;
861                }
862            }
863            s / 9.0
864        }
865
866        pub fn merge(frames: &[Frame], centre: usize, h: f32, protect: f32) -> Frame {
867            let cur = &frames[centre];
868            let mut out = vec![0.0; N * N];
869            for y in 0..N as i32 {
870                for x in 0..N as i32 {
871                    let c = at(cur, x, y);
872                    let (mut acc, mut wsum) = (c, 1.0);
873                    for (k, f) in frames.iter().enumerate() {
874                        if k != centre {
875                            let v = at(f, x, y);
876                            let mut w = patch_weight(d2(f, x, y, cur, x, y), h);
877                            if protect > 0.0 {
878                                let mut vmax = 0.0_f32;
879                                for j in -1..=1 {
880                                    for i in -1..=1 {
881                                        vmax = vmax.max(at(f, x + i, y + j));
882                                    }
883                                }
884                                let seen = ((vmax - c + protect * h) / h + 1.0).clamp(0.0, 1.0);
885                                let pw = protect_weight(c, v, h, protect);
886                                w *= 1.0 + (pw - 1.0) * seen;
887                            }
888                            acc += w * v;
889                            wsum += w;
890                        }
891                    }
892                    out[y as usize * N + x as usize] = acc / wsum;
893                }
894            }
895            out
896        }
897
898        pub fn nlm(f: &[f32], radius: i32, h: f32, protect: f32) -> Frame {
899            let mut out = vec![0.0; N * N];
900            for y in 0..N as i32 {
901                for x in 0..N as i32 {
902                    let (mut acc, mut wsum) = (0.0, 0.0);
903                    for sy in -radius..=radius {
904                        for sx in -radius..=radius {
905                            let v = at(f, x + sx, y + sy);
906                            let mut w = patch_weight(d2(f, x + sx, y + sy, f, x, y), h);
907                            if protect > 0.0 {
908                                w *= protect_weight(at(f, x, y), v, h, protect);
909                            }
910                            acc += w * v;
911                            wsum += w;
912                        }
913                    }
914                    out[y as usize * N + x as usize] = acc / wsum;
915                }
916            }
917            out
918        }
919
920        pub fn run(stage: Denoise, frames: &[Frame]) -> Frame {
921            let centre = frames.len() / 2;
922            let temporal = stage.temporal.unwrap();
923            let r = temporal.radius as usize;
924            let window = &frames[centre - r..=centre + r];
925            let merged = merge(window, r, temporal.strength, temporal.protect);
926            match stage.spatial {
927                Some(s) => nlm(&merged, s.radius as i32, s.luma, s.protect),
928                None => merged,
929            }
930        }
931
932        pub fn stars(amp: f32, sigma: f32) -> (Vec<Frame>, Vec<(i32, i32)>) {
933            let mut n = Noise(0x9E37_79B9_7F4A_7C15);
934            let points: Vec<_> = (0..25)
935                .map(|i| (9 + (i % 5) * 18, 9 + (i / 5) * 18))
936                .collect();
937            // The centre frame's points sit on `points`, other frames' a pixel off: the vectors
938            // of a flat sky never quite line them up.
939            let frames = (0..11)
940                .map(|k: i32| {
941                    let mut f: Frame = (0..N * N).map(|_| 42.0 + sigma * n.gauss()).collect();
942                    for &(x, y) in &points {
943                        f[y as usize * N + (x + (k + 1) % 2) as usize] += amp;
944                    }
945                    f
946                })
947                .collect();
948            (frames, points)
949        }
950
951        /// Mean height of the points above the sky around them.
952        pub fn peak(f: &[f32], points: &[(i32, i32)]) -> f32 {
953            let sum: f32 = points
954                .iter()
955                .map(|&(x, y)| at(f, x, y) - at(f, x + 7, y + 7))
956                .sum();
957            sum / points.len() as f32
958        }
959
960        /// Temporal-free sigma of the sky between the points.
961        pub fn sky_sigma(f: &[f32]) -> f32 {
962            let v: Vec<f32> = (0..N * N)
963                .filter(|i| !(6..13).contains(&(i % N % 18)) || !(6..13).contains(&(i / N % 18)))
964                .map(|i| f[i])
965                .collect();
966            let m = v.iter().sum::<f32>() / v.len() as f32;
967            (v.iter().map(|a| (a - m) * (a - m)).sum::<f32>() / v.len() as f32).sqrt()
968        }
969    }
970
971    #[test]
972    fn night_keeps_faint_stars_and_flattens_the_sky() {
973        use reference::*;
974        let night = Preset::Night.denoise();
975        let unprotected = Denoise {
976            temporal: night.temporal.map(|t| Temporal { protect: 0.0, ..t }),
977            spatial: night.spatial.map(|s| Spatial { protect: 0.0, ..s }),
978        };
979        let (amp, sigma) = (6.0, 0.7);
980        let (frames, points) = stars(amp, sigma);
981        let source = &frames[frames.len() / 2];
982        let (a, b) = (run(night, &frames), run(unprotected, &frames));
983        let (kept, lost) = (peak(&a, &points) / amp, peak(&b, &points) / amp);
984        assert!(kept > 0.9, "protected peak {kept}");
985        assert!(lost < 0.2, "unprotected peak {lost}");
986        // The sky still flattens, at least 3x.
987        let (before, after) = (sky_sigma(source), sky_sigma(&a));
988        assert!(after < before / 3.0, "sky {before} -> {after}");
989    }
990
991    #[test]
992    #[allow(clippy::cast_precision_loss)]
993    fn snow_keeps_fine_texture() {
994        use reference::*;
995        // Fine low-contrast texture, the same in every frame, under little noise.
996        let mut n = Noise(0x1234_5678_9ABC_DEF1);
997        let tex: Frame = (0..N * N).map(|_| 215.0 + 2.5 * n.gauss()).collect();
998        let frames: Vec<Frame> = (0..11)
999            .map(|_| tex.iter().map(|v| v + 0.8 * n.gauss()).collect())
1000            .collect();
1001        let hp = |f: &[f32]| {
1002            let m = f.iter().sum::<f32>() / f.len() as f32;
1003            (f.iter().map(|a| (a - m) * (a - m)).sum::<f32>() / f.len() as f32).sqrt()
1004        };
1005        let out = run(Preset::Snow.denoise(), &frames);
1006        let kept = hp(&out) / hp(&tex);
1007        assert!((0.9..1.1).contains(&kept), "texture energy {kept}");
1008        let err = |f: &[f32]| {
1009            let s: f32 = f.iter().zip(&tex).map(|(a, b)| (a - b) * (a - b)).sum();
1010            (s / f.len() as f32).sqrt()
1011        };
1012        assert!(
1013            err(&out) < 0.7 * err(&frames[5]),
1014            "{} vs {}",
1015            err(&out),
1016            err(&frames[5])
1017        );
1018    }
1019
1020    #[test]
1021    fn spatial_shmem_fits() {
1022        let c = spatial_kernel(Spatial::MAX_RADIUS, 2, 1.0, 2.0, 0.0);
1023        assert!(c.shmem <= 16 * 1024, "{}", c.shmem);
1024    }
1025}