Files
effectcraft/crates/ui-egui/tests/ui_web_job_gpu.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

130 lines
7.0 KiB
Rust

//! Headless checks for M13.30 (GPU in the browser's job workers), run natively: a job worker
//! session whose accelerator is a device with deferred readbacks (the worker's own WebGPU
//! device) runs the Render Queue and an analysis with their frames rendered on the device in
//! passes (`offload::run_request_async`, `render::passes`), and gets what a CPU worker gets.
use std::sync::{Arc, Mutex};
use effectcraft_engine::Session;
use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerReply, WorkerRequest};
use effectcraft_gpu::Gpu;
use serde_json::json;
/// One device at a time in this test binary: wgpu's OpenGL backend (Mesa llvmpipe on FreeBSD,
/// Linux without Vulkan) panics or crashes when tests create and drive devices concurrently.
static GPU_LOCK: Mutex<()> = Mutex::new(());
/// Holds offloaded jobs until the test runs them.
#[derive(Default)]
struct Manual(Mutex<Vec<(WorkerRequest, Arc<Inbox>)>>);
impl Offload for Manual {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
self.0.lock().unwrap().push((req, inbox));
Ok(())
}
fn cancel(&self, _: u64) {}
}
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
fn exporter(files: Files) -> Arc<effectcraft_host::FileExporter> {
let sink: Arc<dyn Fn(&str, Vec<u8>) + Send + Sync> = Arc::new(move |p: &str, d: Vec<u8>| files.lock().unwrap().push((p.to_string(), d)));
Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })
}
/// Run `req` on a worker session (`gpu`: its accelerator); its replies and the files it wrote.
fn run(req: WorkerRequest, gpu: Option<&Gpu>) -> (Vec<WorkerReply>, Vec<(String, Vec<u8>)>) {
let files: Files = Arc::default();
let mut w = Session { exporter: Some(exporter(files.clone())), ..Default::default() };
w.accel = gpu.map(|g| Arc::new(g.clone()) as Arc<dyn effectcraft_engine::render::Accelerator>);
let replies = Arc::new(Mutex::new(vec![]));
let r2 = replies.clone();
let post: Post = std::rc::Rc::new(move |r| r2.lock().unwrap().push(r));
effectcraft_engine::offload::run_request(&mut w, req, &post);
let r = replies.lock().unwrap().clone();
let f = files.lock().unwrap().clone();
(r, f)
}
fn page() -> (Session, Arc<Manual>) {
let off = Arc::new(Manual::default());
let mut s = Session { offload: Some(off.clone()), exporter: Some(exporter(Arc::default())), ..Default::default() };
s.execute("comp.new", json!({"name": "Job", "width": 96, "height": 64, "frameRate": 10, "duration": 0.6})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#3070c0"})).unwrap()["layer"].as_u64().unwrap();
// Moving texture (Fractal Noise, offset animated), then a blur: GPU effects.
s.execute("effect.apply", json!({"layers": [l], "effect": "ec.noise.fractal"})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "effects/#1/transform/offset", "time": 0.0, "value": [0.5, 0.5]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "effects/#1/transform/offset", "time": 0.5, "value": [0.6, 0.56]})).unwrap();
s.execute("effect.apply", json!({"layers": [l], "effect": "ec.blur.gaussian"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#2/blurriness", "value": 3.0})).unwrap();
(s, off)
}
#[test]
fn render_queue_jobs_render_on_the_worker_gpu() {
let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(g) = Gpu::headless_deferred() else {
eprintln!("no GPU adapter: skipped");
return;
};
let (mut s, off) = page();
s.execute("renderQueue.add", json!({"format": "png", "output": "/job/f_[#####].png"})).unwrap();
s.execute("renderQueue.render", json!({"wait": false})).unwrap();
let (req, _) = off.0.lock().unwrap().pop().expect("the render went to the worker");
let (cpu_replies, mut cpu_files) = run(req.clone(), None);
let before = g.deferred_stats().unwrap();
let (gpu_replies, mut gpu_files) = run(req, Some(&g));
// (the CPU worker writes a batch's files in parallel)
cpu_files.sort();
gpu_files.sort();
let after = g.deferred_stats().unwrap();
let done = |r: &[WorkerReply]| r.iter().any(|r| matches!(r, WorkerReply::Item { update } if format!("{update:?}").contains("Done")));
assert!(done(&cpu_replies) && done(&gpu_replies), "{gpu_replies:?}");
assert!(matches!(gpu_replies.last(), Some(WorkerReply::Done)));
// Backend Auto (what renders use) warms up both sides, GPU first: the GPU frames rendered
// in passes on the device (their chains, then the frame read back).
let frames = gpu_files.len() as u64;
assert_eq!(frames, 6);
assert!(after.0 - before.0 >= 6, "passes {before:?} → {after:?}");
assert!(after.1 - before.1 >= 6, "readbacks {before:?} → {after:?}");
assert_eq!(after.2, 0, "nothing left in flight");
// The same frames as the CPU worker's (within GPU rounding).
assert_eq!(cpu_files.iter().map(|f| &f.0).collect::<Vec<_>>(), gpu_files.iter().map(|f| &f.0).collect::<Vec<_>>());
for ((name, a), (_, b)) in cpu_files.iter().zip(&gpu_files) {
let a = image::load_from_memory(a).unwrap().to_rgba8();
let b = image::load_from_memory(b).unwrap().to_rgba8();
assert_eq!(a.dimensions(), b.dimensions());
let worst = a.as_raw().iter().zip(b.as_raw()).map(|(x, y)| x.abs_diff(*y)).max().unwrap();
assert!(worst <= 3, "{name}: differs by {worst} levels");
}
}
#[test]
fn analyses_render_their_input_on_the_worker_gpu() {
let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(g) = Gpu::headless_deferred() else {
eprintln!("no GPU adapter: skipped");
return;
};
let (mut s, off) = page();
let l = s.active_comp().unwrap().layers[0].id;
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
s.execute("effect.apply", json!({"layers": [l.0], "effect": "Warp Stabilizer"})).unwrap();
s.execute("warp.analyze", json!({"layer": l.0, "wait": false})).unwrap();
let (req, _) = off.0.lock().unwrap().pop().expect("the analysis went to the worker");
let (cpu, _) = run(req.clone(), None);
let before = g.deferred_stats().unwrap();
let (gpu, _) = run(req, Some(&g));
let after = g.deferred_stats().unwrap();
let group = |r: &[WorkerReply]| r.iter().find_map(|r| if let WorkerReply::Group { group, .. } = r { Some(group.clone()) } else { None });
let failed = |r: &[WorkerReply]| r.iter().find_map(|r| if let WorkerReply::Failed { error } = r { Some(error.clone()) } else { None });
assert_eq!(group(&cpu).is_some(), group(&gpu).is_some(), "cpu {:?} / gpu {:?}", failed(&cpu), failed(&gpu));
assert!(group(&gpu).is_some(), "{:?}", failed(&gpu));
// The input frames (the effects above Warp Stabilizer) rendered on the device in passes.
assert!(after.1 - before.1 >= 6, "readbacks {before:?} → {after:?}");
assert!(after.0 - before.0 >= 12, "passes {before:?} → {after:?}");
let progress = gpu.iter().filter(|r| matches!(r, WorkerReply::Progress { .. })).count();
assert!(progress >= 1);
}