From e2a47495eb8ad4c5601ceba3280a1b8c3b2b267f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:17:18 +0000 Subject: [PATCH] GPU: an effect kernel the shader compiler rejects no longer turns the GPU compositor off (#613) GpuContext::new built every compute pipeline up front and failed as a whole when one didn't build. On DirectX 12, FXC (wgpu's default D3D shader compiler) aborts on the `pointwise` effect kernel, so the whole app fell back to CPU compositing. The compositor's own kernels are still required, but the effect kernels (`pointwise`, the blur/glow/shadow passes and every fx_* kernel) are now optional: one that fails to build is logged and listed (GpuContext::unavailable_kernels), and a GPU effect chain that dispatches a missing kernel reports it (Enc::take_missing) so those effects render on the CPU while everything else stays on the GPU. This does not make FXC accept `pointwise` (that needs a Windows/FXC box to bisect); it limits the damage to the effects using it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/gpu/src/context.rs | 95 +++++++++++++++++++++++++++------------ crates/gpu/src/effects.rs | 9 ++++ crates/gpu/src/tests.rs | 32 +++++++++++++ 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e994dd1b..c5065eae 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -18,14 +18,6 @@ const ENTRIES: &[&str] = &[ "quantize", "convert", "half", - "box_h", - "box_v", - "directional", - "glow_bright", - "glow_combine", - "shadow_make", - "shadow_combine", - "pointwise", "adjust_mix", "bokeh_boost", "bokeh_prefix", @@ -33,6 +25,11 @@ const ENTRIES: &[&str] = &[ "fill", ]; +/// Effect entry points in `kernels.wgsl`. Like the `fx_*` kernels, one that fails to build +/// (a driver's shader compiler rejecting it, #613) leaves its effects to the CPU instead of +/// turning the whole compositor off. +const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright", "glow_combine", "shadow_make", "shadow_combine", "pointwise"]; + /// Entry points that also bind group 1 (four read-only storage buffers; see /// [`Enc::dispatch_ext`]). const EXT_ENTRIES: &[&str] = &["classic3d"]; @@ -244,6 +241,8 @@ pub struct GpuContext { bgl: wgpu::BindGroupLayout, bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, + /// Effect kernels that failed to build, and why (their effects render on the CPU). + unavailable: Vec<(&'static str, String)>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, /// The display texture's next mip level from the one above (`display.wgsl` `mip`). @@ -450,7 +449,29 @@ impl GpuContext { immediate_size: 0, }) })?; - let pipelines = ENTRIES + let build = |e: &'static str, layout: &wgpu::PipelineLayout| { + init_resource(&device, e, || { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(e), + layout: Some(layout), + module: &module, + entry_point: Some(e), + compilation_options: Default::default(), + cache: None, + }) + }) + }; + // The compositor's own kernels are required; an effect kernel that doesn't build is + // left out, and its effects render on the CPU (see `Enc::take_missing`). + let mut pipelines = ENTRIES + .iter() + .chain(crate::adv3d::SKY_KERNELS) + .map(|e| (e, &layout)) + .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) + .map(|(e, layout)| Ok((*e, build(e, layout)?))) + .collect::, String>>()?; + let mut unavailable = vec![]; + for e in EFFECT_ENTRIES .iter() .chain(crate::fx_color::KERNELS) .chain(crate::fx_distort::KERNELS) @@ -472,23 +493,17 @@ impl GpuContext { .chain(crate::fx_time::KERNELS) .chain(crate::fx_pixel2::KERNELS) .chain(crate::fx_gen2::KERNELS) - .chain(crate::adv3d::SKY_KERNELS) - .map(|e| (e, &layout)) - .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) - .map(|(e, layout)| { - let p = init_resource(&device, e, || { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(e), - layout: Some(layout), - module: &module, - entry_point: Some(e), - compilation_options: Default::default(), - cache: None, - }) - })?; - Ok((*e, p)) - }) - .collect::, String>>()?; + { + match build(e, &layout) { + Ok(p) => { + pipelines.insert(*e, p); + } + Err(why) => { + log::warn!("gpu {name}: kernel {e} unavailable, its effects render on the CPU: {why}"); + unavailable.push((*e, why)); + } + } + } let dmodule = init_resource(&device, "display shader module", || { device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft display"), @@ -547,6 +562,7 @@ impl GpuContext { bgl, bgl_ext, pipelines, + unavailable, display_bgl, display, display_mip, @@ -721,6 +737,18 @@ impl GpuContext { /// Readbacks (GPU → CPU) are possible: waited for natively, or deferred (a browser /// worker, [`crate::deferred`]). Impossible on the browser's main thread. + /// Effect kernels the device couldn't build, with why: their effects render on the CPU. + pub fn unavailable_kernels(&self) -> &[(&'static str, String)] { + &self.unavailable + } + + /// Leave kernel `entry` out, as if it had failed to build (tests of the CPU fallback). + #[cfg(test)] + pub(crate) fn drop_kernel(&mut self, entry: &'static str) { + self.pipelines.remove(entry); + self.unavailable.push((entry, "dropped by a test".into())); + } + pub fn can_readback(&self) -> bool { self.readbacks.check().is_ok() && (self.can_wait() || self.deferred.is_some()) } @@ -859,6 +887,8 @@ pub(crate) struct Enc<'g> { /// The open command encoder and its id in the texture pool. enc: Option<(wgpu::CommandEncoder, u64)>, pending: usize, + /// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]). + missing: bool, } impl Drop for Enc<'_> { @@ -874,7 +904,13 @@ impl Drop for Enc<'_> { impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { - Enc { g, enc: None, pending: 0 } + Enc { g, enc: None, pending: 0, missing: false } + } + + /// Whether a dispatch since the last call named a kernel the device doesn't have (one + /// that failed to build): its output is wrong, and the effect must render on the CPU. + pub(crate) fn take_missing(&mut self) -> bool { + std::mem::take(&mut self.missing) } pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder { @@ -954,7 +990,10 @@ impl<'g> Enc<'g> { ) { let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { - log::error!("gpu: no kernel {entry}"); + if !g.unavailable.iter().any(|(e, _)| *e == entry) { + log::error!("gpu: no kernel {entry}"); + } + self.missing = true; return; }; let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM }); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 00cc4fd9..426931d7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -66,8 +66,12 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option } let misses = deferred.as_ref().map(|d| d.misses()); let mut b = GBuf { img: e.g.upload_image(&buf.img)?, offset: buf.offset, scale: buf.scale }; + e.take_missing(); for step in chain { b = apply(e, step.spec.id, &step.ctx, b)?; + if e.take_missing() { + return None; + } if let Some(l) = levels { b.img = ops::quantize(e, &b.img, l); } @@ -119,8 +123,13 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { fn gpu(&mut self, steps: &[FxStep]) -> bool { let Some(b) = &self.b else { return false }; let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale }; + self.e.take_missing(); for step in steps { let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + // A kernel the device couldn't build: the effects render on the CPU (#613). + if self.e.take_missing() { + return false; + } cur = next; if let Some(l) = self.levels { cur.img = ops::quantize(self.e, &cur.img, l); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 39be29a9..3629d8ea 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -745,3 +745,35 @@ fn within_memory_returns_the_work_when_there_is_room() { let img = g.within_memory(|| g.ctx.image(64, 32)).expect("room for a 64×32 texture"); assert_eq!((img.width, img.height), (64, 32)); } + +/// #613: an effect kernel the device's shader compiler rejects (FXC on DirectX 12 aborting on +/// `pointwise`) no longer turns the whole GPU compositor off: the context is built without it, +/// lists it, and the effects that need it render on the CPU, matching the CPU render. +#[test] +fn effects_whose_kernels_failed_render_on_the_cpu() { + if gpu().is_none() { + return; + } + let Ok(mut ctx) = pollster::block_on(crate::context::GpuContext::request()) else { return }; + ctx.drop_kernel("pointwise"); + ctx.drop_kernel("box_h"); + let names: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect(); + assert_eq!(names, ["pointwise", "box_h"]); + let g = Gpu::from_context(ctx); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(80, 50); + s.effect(&mut l, "ec.color.exposure", &[("master/exposure", n(0.7))]); + s.effect(&mut l, "ec.color.tint", &[("black", c(0.1, 0.0, 0.3)), ("amount", n(80.0))]); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]); + s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]); + s.push(l); + let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(&g); + let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame"); + let mut d = diff(&cpu, &img, tolerance(depth)); + d.quantized = depth != BitDepth::Bpc32; + check(&format!("missing kernels {depth:?}"), Some(d), 0.0); + } +}