1use std::fmt::Write as _;
24
25use serde::{Deserialize, Serialize};
26
27#[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero(v: &f32) -> bool {
29 *v == 0.0
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34#[serde(default, deny_unknown_fields)]
35pub struct Temporal {
36 pub radius: u32,
38 pub motion: Motion,
40 pub strength: f32,
42 pub chroma: Option<f32>,
44 #[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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
72#[serde(default, deny_unknown_fields)]
73pub struct Spatial {
74 pub radius: u32,
76 pub luma: f32,
78 pub chroma: f32,
80 #[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#[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 pub fn radius(&self) -> u32 {
115 self.temporal.map_or(0, |t| t.radius)
116 }
117
118 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum Preset {
138 Night,
140 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
190pub struct Overrides {
191 pub temporal_radius: Option<u32>,
193 pub temporal_motion: Option<Motion>,
194 pub temporal_strength: Option<f32>,
195 pub temporal_chroma: Option<f32>,
196 pub luma: Option<f32>,
198 pub chroma: Option<f32>,
199 pub spatial_radius: Option<u32>,
200 pub protect: Option<f32>,
202}
203
204impl Overrides {
205 #[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
234pub(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
258pub const GROUP: u32 = 16;
260pub const BLOCK: u32 = 16;
262pub const SEARCH: u32 = 6;
264
265#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct Compute {
269 pub header: String,
270 pub body: String,
271 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
291pub 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
305pub 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
326pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum Motion {
400 #[default]
403 Nvof,
404 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
419pub const NVOF_BLOCK: u32 = 8;
421
422pub 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
442pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub struct Plane {
489 pub comps: u32,
491 pub sub: (u32, u32),
493}
494
495fn 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
508pub 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 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 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
608pub 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 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 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 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 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 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 #[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 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 fn patch_weight(d2: f32, h: f32) -> f32 {
841 (-(d2 - 2.0 * h * h).max(0.0) / (h * h)).exp()
842 }
843
844 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 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 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 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 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 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}