From 1bfa617a5a84083d79e948f8a4d46425a02eeb03 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 16:38:20 -0400 Subject: [PATCH] GPU: black pixels take the colour tools and a light vignette as on the CPU The GPU colour tools took the hue of every pixel with atan2(a, b). For a black pixel both are 0, and WGSL leaves atan2(0, 0) undefined: on an RTX 3090 (Vulkan) it is NaN, so with any colour tool on (saturation, vibrance, the mixer) every exactly black pixel became NaN. NaN is written as 0, so it stayed black and nothing showed, until a later step lightened it: with a positive vignette the CPU lifts the black corners and the GPU left them black (up to 127 levels apart in 8-bit). A neutral pixel now gets hue 0, as Rust's atan2 gives on the CPU. Regression test: black bars under a light vignette with saturation, vibrance or the mixer (fails on main: mean 12.8, max 127 levels). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/gpu/src/wgsl/finish.wgsl | 4 +++- crates/gpu/tests/it/equivalence.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 04115b945..767ff44c9 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -106,7 +106,9 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { let lab0 = oklab(rgb); var l = lab0.x; var c = sqrt(lab0.y * lab0.y + lab0.z * lab0.z); - var h = atan2(lab0.z, lab0.y); + // a neutral (black or grey) pixel has no hue: atan2(0, 0) is undefined in WGSL (NaN on some + // GPUs, which a later lightening such as the vignette then shows); the CPU's atan2 gives 0 + var h = select(atan2(lab0.z, lab0.y), 0.0, c == 0.0); if (pu(F_MIXER) != 0u) { let w = band_weights(h); var dh = 0.0; diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index 6a8169b30..d3c369454 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -494,6 +494,34 @@ fn clipped_highlights_stay_neutral_on_both() { } } +/// Black pixels have no hue. The GPU's `atan2(0, 0)` is undefined (NaN on some GPUs), which made +/// every exactly black pixel NaN once a colour tool ran, and a lightening vignette then showed it: +/// the CPU lifted the black corners, the GPU left them black. +#[test] +fn black_pixels_match_under_colour_tools_and_a_light_vignette() { + if !gpu() { + return; + } + let base = scene(0, 320, 240); + // black bars across the frame, reaching into the corners the vignette lightens + let src = Arc::new(Rgb32f::from_fn(320, 240, |x, y| if y % 40 < 8 || x % 64 < 6 { [0.0; 3] } else { base.get(x, y) })); + let info = SourceInfo { raw: true, ..Default::default() }; + let req = RenderRequest::fit(320, 240); + for (name, f) in [ + ("black, light vignette + saturation", (|s: &mut DevelopSettings| s.color.saturation = 40.0) as fn(&mut DevelopSettings)), + ("black, light vignette + vibrance", |s| s.color.vibrance = 30.0), + ("black, light vignette + mixer", |s| s.mixer.yellow.hue = 30.0), + ] { + let mut s = DevelopSettings::default(); + s.vignette.amount = 25.0; + s.vignette.style = VignetteStyle::HighlightPriority; + f(&mut s); + check(name, &src, &info, &s, &req); + let gpu = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu render").image; + assert!(gpu.get(0, 0)[1] > 0, "{name}: the black corner is not lightened on the GPU"); + } +} + #[test] fn rendered_sources_and_other_scenes() { if !gpu() {