Files
effectcraft/crates/gpu/src/tests_deferred_jobs.rs
T
Brandon ThomasandClaude Opus 5.5 f3715aac32 M13.30: web depth and polish
- GPU in browser job workers: each job worker opens its own WebGPU device
  (deferred readbacks); the Render Queue export and the analyses' frame loops
  are futures (render::passes, offload::run_request_async) whose frames
  render in passes, awaiting the device between them. GPU particles and
  Advanced 3D read back under keys too. workers.jobs reports passes and
  readbacks; the smoke test measures them.
- Layer buffers in the browser's OPFS disk cache: frame workers back their
  layer caches with a PrefetchStore; the page plans what to fetch before a
  request from recent misses (LayerPrefetch over the shared DiskIndex).
- Templates embed footage (default on, size-capped with a warning) and
  creating from a template extracts it next to the new project / into
  browser storage.
- ICC LUT profiles (lut8/lut16/lutAtoB/lutBtoA, from the public ICC spec) in
  Simulate Output > My Custom RGB, baked into a 3D LUT for the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:57:17 -04:00

110 lines
5.2 KiB
Rust

//! Deferred readbacks for what job workers and frame workers render besides effect chains:
//! GPU particles and Advanced 3D read back under keys too, so they render on the device in
//! passes; and the job loops' pass driver (`effectcraft_render::passes`) settles natively.
use effectcraft_effects::EffectCtx;
use effectcraft_project::BitDepth;
use effectcraft_render::three_d::adv;
use effectcraft_render::{Accelerator, Backend, LayerCache, NoFootage, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::{Pattern, diff, n, tolerance};
use crate::tests_adv3d::{off_share, run_of, scene, scene_project};
use crate::tests_deferred::{deferred_gpu, render_passes};
use crate::tests_particles::{Host, agree, params};
/// The device's readbacks have landed (the browser's event loop; natively a poll).
fn settle(g: &crate::Gpu) {
g.wait();
pollster::block_on(g.settled());
}
#[test]
fn particles_resolve_in_passes() {
let Some((g, _lock)) = deferred_gpu() else { return };
let host = Host(g);
for (id, vals) in [
("ec.sim.ccparticleworld", vec![("birthRate", n(2.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(7.0))]),
("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(90.0))]),
] {
let p = params(id, &vals);
let ctx = |env| EffectCtx { params: &p, time: 1.5, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
let run = |c: &EffectCtx| {
if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) }
};
let cpu = run(&ctx(Default::default())).unwrap();
let env = || effectcraft_effects::EffectEnv { host: Some(&host), ..Default::default() };
g.frame_begin();
g.pass_begin();
// First pass: the simulation runs, its readback starts; no particles yet, the pass missed.
let first = run(&ctx(env())).unwrap();
assert!(first.is_empty() && g.pass_missed(), "{id}");
settle(g);
g.pass_begin();
let gpu = run(&ctx(env())).unwrap();
assert!(!g.pass_missed(), "{id}: served from the readback");
agree(&format!("deferred {id}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1);
}
}
#[test]
fn advanced_3d_resolves_in_passes() {
let Some((g, _lock)) = deferred_gpu() else { return };
let (p, cid) = scene_project();
// One scene rasterised (the CPU compositor's per-scene path).
let s = scene(&p, cid);
let cpu = adv::raster::render(&s);
g.frame_begin();
g.pass_begin();
let t = g.raster_3d(&s).expect("handled");
assert!(g.pass_missed() && t.color.iter().all(|c| c[3] == 0.0), "a placeholder first");
settle(g);
g.pass_begin();
let t = g.raster_3d(&s).expect("handled");
assert!(!g.pass_missed());
let (share, _) = off_share(&cpu.color, &t.color);
assert!(share < 0.015, "raster: {:.3} % of pixels differ", share * 100.0);
// A whole prepared run (resolve, encoding) read back under its key.
let (ctx, run) = run_of(&p, cid);
let out = (ctx.comp.width, ctx.comp.height);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let prep = adv::Prepared::new(&r, &ctx, &run, out);
let (cimg, _) = adv::render_prepared(&prep).expect("cpu drew");
g.frame_begin();
g.pass_begin();
assert!(matches!(g.render_3d(&prep), Some(None)) && g.pass_missed(), "nothing drawn yet: a placeholder");
settle(g);
g.pass_begin();
let (gimg, depth) = g.render_3d(&prep).expect("handled").expect("drawn");
assert!(!g.pass_missed());
assert_eq!(depth.len(), (out.0 * out.1) as usize);
let (share, _) = off_share(&cimg.data, &gimg.data);
assert!(share <= 0.01, "run: {:.3} % of pixels differ", share * 100.0);
}
/// The job loops' driver: `passes::comp_frame` awaits the device between passes (natively
/// `Accelerator::settle` polls it) and gives the frame `render_passes` gives.
#[test]
fn job_frames_render_in_passes() {
let Some((g, _lock)) = deferred_gpu() else { return };
let s = crate::tests_deferred::scene();
let t = Tick::from_seconds_f64(0.5);
let cache = LayerCache::default();
cache.set_gate(g.miss_gate());
let opts = RenderOpts { backend: Backend::Gpu, ..Default::default() };
let (want, _) = render_passes(g, &s, opts, &LayerCache::default(), t);
let mut r = Renderer::new(&s.p, &Pattern, opts);
r.accel = Some(g);
r.cache = Some(&cache);
let before = g.deferred_stats().unwrap();
let img = effectcraft_render::passes::block_on(effectcraft_render::passes::comp_frame(&r, s.cid, t));
let after = g.deferred_stats().unwrap();
assert!(after.0 - before.0 >= 2 && after.1 > before.1, "rendered in passes with readbacks: {before:?} → {after:?}");
assert_eq!(img.data, want.data);
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..Default::default() }).comp_frame_cpu(s.cid, t);
assert_eq!(diff(&cpu, &img, tolerance(BitDepth::Bpc32) * 2.0).over, 0);
// `in_passes` without a deferred accelerator: one pass, at once.
let one = effectcraft_render::passes::block_on(effectcraft_render::passes::in_passes(None, |_| 7));
assert_eq!((one.value, one.passes, one.accelerated), (7, 1, false));
}