From 71272d5cd71c8bb293428db317fa4d7a442dbe1f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:14:38 -0400 Subject: [PATCH] GPU: fs_adjust compiles under FXC (D3D12); a pipeline build failure falls back to the CPU On D3D12 without DXC, wgpu compiles HLSL with FXC, which aborted on fs_adjust ("internal error: compilation aborted unexpectedly"). wgpu turns that into a panic, so every GPU test failed on the Windows runner, and the app would crash where the same path is taken. A bisect on the Windows runner pinned it to two cases of adjust()'s switch, Exposure and Selective Color: both looped over dynamically indexed vector components. They are now written per channel / per range with the same math (GPU-vs-CPU parity is unchanged and passes on Metal and on the Windows runner). Compositor::try_new_with_format catches validation and internal errors while building the pipelines and returns Err; the canvas then uses the CPU compositor (once, remembered) instead of crashing. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/compose.wgsl | 57 +++++++++++++++----------------- crates/gpu/src/lib.rs | 19 +++++++++++ crates/gpu/tests/parity.rs | 10 ++++++ crates/ui-egui/src/gpu_canvas.rs | 22 +++++++++--- 4 files changed, 74 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 92252fc48c..653d3466c9 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -402,35 +402,39 @@ fn color_lookup(c: vec3, n: i32, tetra: bool) -> vec3 { return mix(mix(c00, c10, f.y), mix(c01, c11, f.y), f.z); } -// compose::adjust::selective_color (ranges × CMYK percentages in LUT row 0) +// One Selective Color range: its CMYK percentages (LUT row 0, 4 per range) weighted by `w`. +fn selective_row(r: i32, w: f32, c: vec3, relative: bool) -> vec3 { + let k = lut_at(r * 4 + 3) / 100.0; + var d = vec3(lut_at(r * 4), lut_at(r * 4 + 1), lut_at(r * 4 + 2)) / 100.0 + k; + if (relative) { d = d * (1.0 - c); } + return select(vec3(0.0), d * w, w > 0.0); +} + +// compose::adjust::selective_color (ranges × CMYK percentages in LUT row 0). Written without +// loops: FXC (D3D12) aborts compiling loops with dynamic indexing inside `adjust`'s switch. fn selective_color(c: vec3, relative: bool) -> vec3 { let mx = max(max(c.r, c.g), c.b); let mn = min(min(c.r, c.g), c.b); let md = c.r + c.g + c.b - mx - mn; - var w: array; - w[0] = select(0.0, mx - md, c.r >= mx); - w[1] = select(0.0, md - mn, c.b <= mn); - w[2] = select(0.0, mx - md, c.g >= mx); - w[3] = select(0.0, md - mn, c.r <= mn); - w[4] = select(0.0, mx - md, c.b >= mx); - w[5] = select(0.0, md - mn, c.g <= mn); - w[6] = max((mn - 0.5) * 2.0, 0.0); - w[7] = clamp(1.0 - abs(mx - 0.5) - abs(mn - 0.5), 0.0, 1.0); - w[8] = max((0.5 - mx) * 2.0, 0.0); - var delta = vec3(0.0); - for (var r = 0; r < 9; r++) { - if (w[r] <= 0.0) { continue; } - let k = lut_at(r * 4 + 3) / 100.0; - for (var i = 0; i < 3; i++) { - let a = lut_at(r * 4 + i) / 100.0; - var d = a + k; - if (relative) { d = d * (1.0 - c[i]); } - delta[i] += d * w[r]; - } - } + var delta = selective_row(0, select(0.0, mx - md, c.r >= mx), c, relative); + delta += selective_row(1, select(0.0, md - mn, c.b <= mn), c, relative); + delta += selective_row(2, select(0.0, mx - md, c.g >= mx), c, relative); + delta += selective_row(3, select(0.0, md - mn, c.r <= mn), c, relative); + delta += selective_row(4, select(0.0, mx - md, c.b >= mx), c, relative); + delta += selective_row(5, select(0.0, md - mn, c.g <= mn), c, relative); + delta += selective_row(6, max((mn - 0.5) * 2.0, 0.0), c, relative); + delta += selective_row(7, clamp(1.0 - abs(mx - 0.5) - abs(mn - 0.5), 0.0, 1.0), c, relative); + delta += selective_row(8, max((0.5 - mx) * 2.0, 0.0), c, relative); return clamp(c - delta, vec3(0.0), vec3(1.0)); } +// Exposure on one channel (p = exposure scale, offset, gamma, transfer gamma). Per channel, not a +// loop over `c[i]`: FXC aborts on that inside `adjust`'s switch. +fn exposure(v: f32, p: vec4) -> f32 { + let lin = pow(max(t_decode(v, p.w) * p.x + p.y, 0.0), 1.0 / p.z); + return clamp(t_encode(lin, p.w), 0.0, 1.0); +} + // Modern Brightness curve: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white anchor. // Mirrors compose::adjust::modern_brightness. fn mbright(v: f32, b: f32) -> f32 { @@ -472,14 +476,7 @@ fn adjust(c: vec3) -> vec3 { let b = p0.x; let ct = p0.y; return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct)); } - case 6: { // Exposure - var o: vec3; - for (var i = 0; i < 3; i++) { - let lin = pow(max(t_decode(c[i], p0.w) * p0.x + p0.y, 0.0), 1.0 / p0.z); - o[i] = clamp(t_encode(lin, p0.w), 0.0, 1.0); - } - return o; - } + case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure case 7: { return vec3(lut(0, c.r), lut(1, c.g), lut(2, c.b)); } // Levels / Curves case 8: { // Hue/Saturation let hsl = rgb_to_hsl(c); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 5b6c25491c..839c5cacbd 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -394,6 +394,25 @@ impl Compositor { if feats.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) { wgpu::TextureFormat::Rgba32Float } else { wgpu::TextureFormat::Rgba16Float } } + /// [`Compositor::new_with_format`], but a shader or pipeline the driver can't build (e.g. a + /// D3D12 shader-compiler failure) is an `Err` instead of a panic, so the caller can fall back + /// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed + /// to have worked. + pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result { + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); + let comp = Self::new_with_format(device, acc_format); + // Pop in reverse order; native wgpu reports errors synchronously, so the futures are ready. + let errors = [internal.pop(), validation.pop()]; + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + for f in errors { + if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(f).poll(&mut cx) { + return Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))); + } + } + Ok(comp) + } + /// Create a compositor whose accumulation/render-target format is `acc_format`. Pass /// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers), /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 0f688a5565..f79b725f29 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1120,3 +1120,13 @@ fn rgba16f_fallback_path_renders() { let f = Compositor::preferred_acc_format(&adapter); assert!(matches!(f, wgpu::TextureFormat::Rgba32Float | wgpu::TextureFormat::Rgba16Float)); } + +#[test] +fn unbuildable_pipelines_are_an_error_not_a_panic() { + // The app falls back to the CPU compositor on Err; a panic here would crash it (as FXC once + // did on D3D12). A depth format can't be a colour target, so its pipelines fail to build. + let Some(g) = gpu() else { return }; + let e = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Depth32Float).err().expect("depth target must fail"); + assert!(e.0.contains("pipelines"), "{e}"); + assert!(Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba16Float).is_ok()); +} diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index ba0f1b4a18..a330725f8d 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -160,10 +160,21 @@ impl GpuCanvas { } let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return Err(photocraft_gpu::Unsupported("no GPU canvas".into())) }; - let mut comp = res - .compositor - .take() - .unwrap_or_else(|| photocraft_gpu::Compositor::new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter))); + // A driver that couldn't build the pipelines once won't later: stay on the CPU compositor. + if let Some(e) = &res.compositor_failed { + return Err(e.clone()); + } + let mut comp = match res.compositor.take() { + Some(c) => c, + None => match photocraft_gpu::Compositor::try_new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter)) { + Ok(c) => c, + Err(e) => { + log::warn!("{e}; using the CPU compositor"); + res.compositor_failed = Some(e.clone()); + return Err(e); + } + }, + }; let key = doc.id.0; let fresh = res.docs.get(&key).is_none_or(|d| d.size != size); let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) }; @@ -382,6 +393,8 @@ struct Resources { out_linear: bool, /// The wgpu layer compositor (created on first use). compositor: Option, + /// Why the wgpu compositor couldn't be created (then the CPU compositor is used). + compositor_failed: Option, encode_bgl: wgpu::BindGroupLayout, encode_pipeline: wgpu::RenderPipeline, lut_bgl: wgpu::BindGroupLayout, @@ -632,6 +645,7 @@ impl Resources { views: HashMap::new(), out_linear: target.is_srgb(), compositor: None, + compositor_failed: None, encode_bgl, encode_pipeline, }