Files
effectcraft/crates/gpu/src/tests_deferred.rs
T
Brandon ThomasandClaude Opus 5.5 6eedf8e2c5 GPU on wgpu's OpenGL backend: renderable depth target, portable shaders, serialised tests
FreeBSD's CI VM (Mesa llvmpipe over GL) failed 22 effectcraft-gpu tests:

- Advanced 3D's camera-depth target was R32Float, which GLES can't render to without
  EXT_color_buffer_float: create_render_pipeline failed validation (release builds only log
  that and draw nothing). The target is now R32Uint holding the f32 bits (renderable on every
  backend, lossless), and GpuContext checks the adapter's format features (render attachment,
  blendable colour) and independent blending up front; when they're missing the render
  pipelines are never built and Advanced 3D renders on the CPU.
- Signed `%` in the shaders: naga emits GLSL `%`, undefined for negative operands, which broke
  the VR effects' horizontal wrap and Cell Pattern's tiling. A shared `imod` never takes `%` of
  a negative number.
- asin/acos: Mesa's are coarse approximations (~1e-4 rad), visible in every lens/sphere/cylinder
  mapping. `asin_p`/`acos_p` (Cephes' asinf polynomial) are f32-accurate on every backend.
- "Could not lock adapter context": the GL backend has one context lock per adapter with a
  timeout, so tests driving devices concurrently panic. Every gpu test helper that hands out a
  device holds one process-wide lock for the calling test; ui_web_job_gpu's two device tests
  share a lock too.
- On GL, up to 1 % of pixels may flip across a decision boundary (division through a
  reciprocal, unfused fma); the GPU-vs-CPU comparisons allow that there only.

Also adds the FreeBSD workflow (from the FreeBSD CI PR) so this branch is checked there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:08:21 -04:00

143 lines
7.3 KiB
Rust

//! Deferred readbacks (the browser worker's WebGPU path) vs the CPU: a frame renders in passes
//! until no readback is missing, and the result matches the CPU render. Natively the same
//! device works with readbacks deferred; a device poll delivers them, as the browser's event
//! loop does.
use std::sync::OnceLock;
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource};
use effectcraft_render::{Accelerator, Backend, LayerCache, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::Gpu;
use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
/// The device, held exclusively: deferred state is per device and a frame renders at a time
/// (a frame worker renders one frame at a time too).
pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
static G: OnceLock<Option<Gpu>> = OnceLock::new();
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
// Also no other GPU test while it runs (see `hold_gpu_lock`).
crate::tests::hold_gpu_lock();
let g = G.get_or_init(|| pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok()).as_ref()?;
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
}
/// Footage with GPU effects around a CPU-only one, a solid with Glow, and a precomp whose
/// layer is blurred inside and outside (a chain whose input is another chain's result).
pub(crate) fn scene() -> Scene {
let cpu_only = "ec.generate.fractal";
assert!(!effectcraft_effects::GPU_EFFECTS.contains(&cpu_only));
let mut s = Scene::new(BitDepth::Bpc32);
let mut bg = s.footage(97, 61);
s.effect(&mut bg, "ec.blur.gaussian", &[("blurriness", n(5.0))]);
s.effect(&mut bg, cpu_only, &[]);
s.effect(&mut bg, "ec.color.levels", &[("gamma", n(1.3))]);
s.push(bg);
let mut solid = s.solid([0.9, 0.4, 0.1], 30, 20);
s.effect(&mut solid, "ec.stylize.glow", &[("threshold", n(20.0)), ("radius", n(6.0))]);
set(&mut solid, "transform/position", v3(30.0, 20.0));
s.push(solid);
// Precomp: a footage layer blurred inside; the precomp layer blurred again outside.
let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner_id = s.p.add_item("Inner", effectcraft_color::Label::Blue, None, ItemKind::Comp(inner.clone().into()));
{
let mut ins = Scene { p: std::mem::take(&mut s.p), cid: inner_id, comp: inner };
let mut l = ins.footage(50, 30);
ins.effect(&mut l, "ec.blur.fastbox", &[("radius", n(3.0))]);
ins.push(l);
s.p = ins.p;
}
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
s.effect(&mut pre, "ec.blur.directional", &[("length", n(6.0)), ("direction", Value::Scalar(30.0))]);
set(&mut pre, "transform/position", v3(60.0, 35.0));
s.push(pre);
s
}
/// Render in passes with deferred readbacks; returns the frame and the number of passes.
pub(crate) fn render_passes(g: &Gpu, s: &Scene, opts: RenderOpts, cache: &LayerCache, t: Tick) -> (effectcraft_raster::Image, usize) {
cache.set_gate(g.miss_gate());
let mut r = Renderer::new(&s.p, &Pattern, opts);
r.accel = Some(g);
r.cache = Some(cache);
g.frame_begin();
for pass in 1..=12 {
g.pass_begin();
let img = r.comp_frame(s.cid, t);
if !g.pass_missed() {
assert_eq!(g.deferred().unwrap().in_flight(), 0);
return (img, pass);
}
// The browser awaits `Gpu::settled`; natively a blocking poll delivers the readbacks.
g.wait();
pollster::block_on(g.settled());
}
panic!("the frame never completed");
}
#[test]
fn deferred_passes_match_the_cpu() {
let Some((g, _lock)) = deferred_gpu() else { return };
assert!(g.context().can_readback() && !g.context().can_wait());
let s = scene();
let t = Tick::from_seconds_f64(0.5);
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..Default::default() }).comp_frame_cpu(s.cid, t);
let tol = tolerance(BitDepth::Bpc32) * 2.0;
// The whole frame on the GPU (GPU compositor + chains + the frame's readback).
let cache = LayerCache::default();
let (img, passes) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, ..Default::default() }, &cache, t);
let d = diff(&cpu, &img, tol);
assert!(d.over == 0, "whole-frame GPU: {d:?}");
assert!(passes >= 3, "chains, the nested chain, then the frame readback: {passes} passes");
// Again (a new frame: results are per frame; the layer cache holds the genuine buffers of
// the cacheable layers): the same pixels.
let (img2, passes2) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, ..Default::default() }, &cache, t);
assert_eq!(img2.data, img.data);
assert!(passes2 <= passes, "{passes2} <= {passes}");
// CPU compositing with GPU chains (a region of interest keeps the frame off the GPU
// compositor): every placeholder is gone by the last pass.
let roi = Some([0.0, 0.0, 97.0, 61.0]);
let cpu_roi = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi, ..Default::default() }).comp_frame_cpu(s.cid, t);
let (img3, passes3) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, roi, ..Default::default() }, &LayerCache::default(), t);
let d = diff(&cpu_roi, &img3, tol);
assert!(d.over == 0, "CPU composite, GPU chains: {d:?}");
assert!(passes3 >= 2);
let (p, rb, inflight) = g.deferred_stats().unwrap();
assert!(p >= (passes + passes2 + passes3) as u64 && rb > 0 && inflight == 0);
}
#[test]
fn placeholders_never_reach_the_layer_cache() {
let Some((g, _lock)) = deferred_gpu() else { return };
let s = scene();
let t = Tick::from_seconds_f64(1.0);
let cache = LayerCache::default();
cache.set_gate(g.miss_gate());
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() });
r.accel = Some(g);
r.cache = Some(&cache);
g.frame_begin();
g.pass_begin();
let _ = r.comp_frame(s.cid, t);
assert!(g.pass_missed());
// Only buffers computed before the first miss were kept (the first layer's input up to its
// first chain: nothing with an effect result).
let first_pass_entries = cache.stats().entries;
g.wait();
pollster::block_on(g.settled());
let (img, _) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() }, &cache, t);
assert!(cache.stats().entries > first_pass_entries, "{first_pass_entries} → {:?}", cache.stats());
// A fresh CPU render through the same cache (served from it) agrees with the CPU.
let mut cr = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() });
cr.cache = Some(&cache);
let cached = cr.comp_frame_cpu(s.cid, t);
let fresh =
Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() }).comp_frame_cpu(s.cid, t);
let tol = tolerance(BitDepth::Bpc32) * 2.0;
assert_eq!(diff(&fresh, &cached, tol).over, 0);
assert_eq!(diff(&fresh, &img, tol).over, 0);
}