diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index a3e2a3a59..65dc52a6e 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -126,6 +126,10 @@ const LIGHT_BYTES: usize = 6 * 16; /// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the /// caller draws it on the CPU). pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option { + // FXC (DirectX 12) may fail to build the kernel (#613): the caller renders the run on the CPU. + if !e.g.has_kernel("classic3d") { + return None; + } let (w, h) = (canvas.width, canvas.height); let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect(); if boxes.is_empty() { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 90f148585..bcd929304 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -30,7 +30,8 @@ const ENTRIES: &[&str] = &[ 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`]). +/// [`Enc::dispatch_ext`]). Like the effect kernels, one that fails to build (FXC on DirectX 12 +/// can't unroll `classic3d`'s loops, #613) leaves its work to the CPU. const EXT_ENTRIES: &[&str] = &["classic3d"]; /// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom; @@ -622,15 +623,21 @@ impl GpuContext { }) }; // 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>>()?; + // left out, and its effects render on the CPU (see `Enc::take_missing`). So is a + // missing Classic 3D kernel: its 3D runs render on the CPU. + let mut pipelines = ENTRIES.iter().chain(crate::adv3d::SKY_KERNELS).map(|e| Ok((*e, build(e, &layout)?))).collect::, String>>()?; let mut unavailable = vec![]; + for e in EXT_ENTRIES { + match build(e, &layout_ext) { + Ok(p) => { + pipelines.insert(*e, p); + } + Err(why) => { + log::warn!("gpu {name}: kernel {e} unavailable, its 3D layers render on the CPU: {why}"); + unavailable.push((*e, why)); + } + } + } for e in EFFECT_ENTRIES .iter() .chain(crate::fx_color::KERNELS) @@ -927,6 +934,11 @@ impl GpuContext { &self.unavailable } + /// Whether kernel `entry` was built (an effect or Classic 3D kernel can be missing, #613). + pub(crate) fn has_kernel(&self, entry: &str) -> bool { + self.pipelines.contains_key(entry) + } + /// 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) { @@ -934,6 +946,49 @@ impl GpuContext { self.unavailable.push((entry, "dropped by a test".into())); } + /// A copy of this context sharing the same [`wgpu::Device`] and [`wgpu::Queue`] with + /// kernel `entry` left out, as if it had failed to build (tests of the CPU fallback + /// without opening a second device on `llvmpipe`). + #[cfg(test)] + pub(crate) fn without_kernel(&self, entry: &'static str) -> GpuContext { + let mut pipelines = self.pipelines.clone(); + pipelines.remove(entry); + let mut unavailable = self.unavailable.clone(); + unavailable.push((entry, "dropped by a test".into())); + GpuContext { + device: self.device.clone(), + queue: self.queue.clone(), + name: self.name.clone(), + max_dim: self.max_dim, + median_kernel: self.median_kernel, + bgl: self.bgl.clone(), + bgl_ext: self.bgl_ext.clone(), + pipelines, + unavailable, + display_bgl: self.display_bgl.clone(), + display: self.display.clone(), + display_mip: self.display_mip.clone(), + dummy_tex: self.dummy_tex.clone(), + dummy: self.dummy.clone(), + dummy_buf: self.dummy_buf.clone(), + uploads: Mutex::new(Uploads::default()), + upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), + upload_full_warned: Default::default(), + pool: self.pool.clone(), + staging: Default::default(), + transfers: Default::default(), + zeros: Default::default(), + adv3d: std::sync::OnceLock::new(), + adv3d_raster: self.adv3d_raster, + particles: Default::default(), + particle_states: Default::default(), + deferred: self.deferred.clone(), + readbacks: self.readbacks.clone(), + software: self.software, + yuv: std::sync::OnceLock::new(), + } + } + /// Bytes of readback staging buffers kept for reuse (tests). #[cfg(test)] pub(crate) fn staging_bytes(&self) -> u64 { diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl index d2cad8324..1c963385a 100644 --- a/crates/gpu/src/shaders/classic3d.wgsl +++ b/crates/gpu/src/shaders/classic3d.wgsl @@ -255,16 +255,14 @@ fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { } let tr = pl.mat1.y; let dark = li.shadow.x; - var o = vec3(1.0); - for (var k = 0; k < 3; k++) { - var st = 0.0; - if (px.w > 0.0) { - st = clamp(px[k] / px.w, 0.0, 1.0); - } - let through = (1.0 - alpha) + alpha * tr * st; - o[k] = 1.0 - dark * (1.0 - through); + // Per channel, as whole vectors: FXC (DirectX 12) can't write a vector component through a + // loop index without unrolling every loop around the call, and fails (#613). + var st = vec3(0.0); + if (px.w > 0.0) { + st = clamp(px.xyz / px.w, vec3(0.0), vec3(1.0)); } - return o; + let through = (1.0 - alpha) + alpha * tr * st; + return 1.0 - dark * (1.0 - through); } fn shadow_of(it: u32, l: u32, p: vec3) -> vec3 { diff --git a/crates/gpu/src/tests/init.rs b/crates/gpu/src/tests/init.rs index 2292544cd..4578a8489 100644 --- a/crates/gpu/src/tests/init.rs +++ b/crates/gpu/src/tests/init.rs @@ -148,23 +148,28 @@ fn opengl_adapters_get_cpu_compositing_at_once() { assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed()); } +/// #613: FXC (DirectX 12's default shader compiler) builds Classic 3D, so the compositor starts +/// on DirectX 12 with its 3D kernel, and initialization leaves the caller's error scope and the +/// host's error handler untouched. Ignored by default: FXC compiles every kernel, which takes +/// minutes. Skips where there is no DirectX 12 adapter. #[test] #[cfg(target_os = "windows")] -#[ignore = "requires the reproduced Windows DX12/FXC pointwise compiler failure; no adapter skips"] -fn reproduced_fxc_pointwise_failure_returns_error_without_escaping_or_notifying_host() { +#[ignore = "slow: FXC compiles every kernel"] +fn fxc_builds_classic3d_and_initialization_errors_stay_scoped() { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); desc.backends = wgpu::Backends::DX12; desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc; - let (adapter, device, queue) = native_device(desc, true).expect("required DX12 device"); + let Some((adapter, device, queue)) = native_device(desc, false) else { return }; assert_eq!(adapter.get_info().backend, wgpu::Backend::Dx12); let host = count_host_errors(&device); let outer = device.push_error_scope(wgpu::ErrorFilter::Validation); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| GpuContext::new(&adapter, device.clone(), queue))); - let error = match result.expect("FXC compiler failure must not escape the constructor") { - Err(error) => error, - Ok(_) => panic!("the platform-specific FXC failure no longer reproduces; reassess this diagnostic"), + let ctx = match result.expect("an FXC compiler failure must not escape the constructor") { + Ok(ctx) => ctx, + Err(error) => panic!("the compositor starts on DirectX 12 with FXC: {}", brief(error)), }; - assert!(error.contains("GPU initialization (pointwise)") && error.contains("FXC D3DCompile"), "{}", brief(error)); + let missing: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect(); + assert!(ctx.has_kernel("classic3d"), "FXC builds classic3d (unavailable: {missing:?})"); assert!(pollster::block_on(outer.pop()).is_none(), "constructor must drain its own scopes and leave the caller's scope clean"); assert_eq!(host.load(Ordering::SeqCst), 0); valid_pipeline(&device); diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs index bd9455b9e..32f11a341 100644 --- a/crates/gpu/src/tests/three_d.rs +++ b/crates/gpu/src/tests/three_d.rs @@ -11,7 +11,7 @@ use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKin use effectcraft_render::{Backend, RenderOpts, Renderer}; use effectcraft_time::{FrameRate, Tick}; -use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set}; +use crate::tests::{Pattern, Scene, check, compare_at, diff, gpu, n, opts, set, tolerance}; const W: u32 = 160; const H: u32 = 100; @@ -209,6 +209,10 @@ fn the_gpu_draws_classic_3d_runs_itself() { assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3); let mut e = crate::context::Enc::new(g.context()); let canvas = e.image(W, H); + if !g.context().has_kernel("classic3d") { + // FXC (DirectX 12) can't build the kernel (#613); the CPU fallback is tested below. + return; + } let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run"); let img = e.download(&out).unwrap(); let mut cpu = effectcraft_raster::Image::new(W, H); @@ -253,7 +257,9 @@ fn bokeh_depth_of_field_runs_on_the_gpu() { assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU"); let mut e = crate::context::Enc::new(g.context()); let canvas = e.image(W, H); - assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU"); + if g.context().has_kernel("classic3d") { + assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU"); + } } } } @@ -288,3 +294,27 @@ fn two_d_layer_with_a_three_d_track_matte() { } } } + +/// #613: FXC on DirectX 12 fails to build `classic3d` (it can't unroll its loops). The +/// compositor still starts without it, lists it, and 3D runs render on the CPU, matching the +/// CPU render. +#[test] +fn classic_3d_runs_render_on_the_cpu_without_the_kernel() { + let Some(base) = gpu() else { + return; + }; + let ctx = base.context().without_kernel("classic3d"); + assert!(!ctx.has_kernel("classic3d")); + let g = crate::Gpu::from_context(ctx); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [30.0, 20.0, -260.0], None); + 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!("classic 3D without its kernel {depth:?}"), Some(d), 0.0); + } +}