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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
This commit is contained in:
radialmonster
2026-10-28 13:58:46 -04:00
co-authored by Claude Opus 5.5
parent e2d18b5fb9
commit 1bfa617a5a
2 changed files with 31 additions and 1 deletions
+3 -1
View File
@@ -106,7 +106,9 @@ fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
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;
+28
View File
@@ -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() {