Files
effectcraft/crates/gpu/src/tests_deferred.rs
T
Brandon ThomasandClaude Opus 5.5 1b726349ab M13.24: web app depth (WEB-1) — GPU effects in frame workers, OPFS disk cache, storage manager
- GPU in the browser: each frame worker opens its own WebGPU device (Gpu::request_deferred) and
  renders the GPU compositor and GPU effects with deferred readbacks (effectcraft-gpu deferred):
  readbacks keyed by content start with map_async and miss, the pass returns placeholders, the
  layer cache's gate drops inserts, and FrameServer::resume renders again once the device
  delivered. Backend Auto times whole frames per comp in the workers. Frames with effects go to
  GPU workers instead of the page. CPU fallback without WebGPU in workers (?nogpuworkers).
- Fix: a WGSL constant (3.40282347e38) that Chrome rejects invalidated every GPU kernel in Chrome
  (the page's compositor too); a test checks every float literal fits f32.
- Disk cache in the Origin Private File System: workers write sealed frame entries with sync
  access handles (temp file + move), the page keeps the DiskIndex (LRU under the settings' limit,
  at most half the quota) and reads hits instead of rendering; survives reloads; blue cache bar.
- Storage manager: Settings ▸ Disk ▸ Browser Storage (usage/quota, persistent storage, Clear
  buttons, automation ids) and storage.info / storage.persist / storage.clear (StorageHost).
- Docs: web.md (GPU workers, deferred passes, disk cache, storage manager, threading design:
  one engine instance per worker), architecture, preferences, parity (WEB-1), roadmap.
- Tests: deferred passes vs CPU on a real device, placeholder gating, frame server passes and
  Auto, GPU worker routing + disk cache across a reload, storage commands and widgets, store
  usage by kind; smoke test: GPU effect frames in workers with pixel parity, disk hits after a
  reload, storage manager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:35:03 -04:00

141 lines
7.1 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).
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(());
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).
fn scene() -> Scene {
let cpu_only = "ec.blur.ccradial";
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.
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);
}