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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 21:17:18 +00:00
1 parent 1bcfe6912d
commit e2a47495eb
3 files changed
+108 -28

No files matched your search

+67 -28
View File
@@ -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::<Result<HashMap<_, _>, 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::<Result<HashMap<_, _>, 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 });
+9
View File
@@ -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);
+32
View File
@@ -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);
}
}