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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-04 11:33:15 -04:00
co-authored by Claude Opus 5.5
parent 1c09936154
commit 71272d5cd7
4 changed files with 74 additions and 34 deletions
+27 -30
View File
@@ -402,35 +402,39 @@ fn color_lookup(c: vec3<f32>, n: i32, tetra: bool) -> vec3<f32> {
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<f32>, relative: bool) -> vec3<f32> {
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<f32>, relative: bool) -> vec3<f32> {
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<f32, 9>;
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>) -> 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<f32>) -> vec3<f32> {
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<f32>;
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);
+19
View File
@@ -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<Self, Unsupported> {
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`.
+10
View File
@@ -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());
}
+18 -4
View File
@@ -160,10 +160,21 @@ impl GpuCanvas {
}
let mut renderer = self.rs.renderer.write();
let Some(res) = renderer.callback_resources.get_mut::<Resources>() 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<photocraft_gpu::Compositor>,
/// Why the wgpu compositor couldn't be created (then the CPU compositor is used).
compositor_failed: Option<photocraft_gpu::Unsupported>,
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,
}