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>
This commit is contained in:
Brandon Thomas
2026-10-04 02:57:17 -04:00
co-authored by Claude Opus 5.5
parent d39c0e818c
commit f3715aac32
46 changed files with 3304 additions and 387 deletions
+144 -5
View File
@@ -324,14 +324,44 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
/// Rasterise a scene and read it back ([`effectcraft_render::Accelerator::raster_3d`]). `None`
/// when the device can't (no readback, size or buffer limits).
///
/// With deferred readbacks (a browser worker) both targets are read back under the scene's key
/// ([`crate::deferred`]): the first pass starts them and gets an empty target (the pass misses).
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
if !g.can_wait() {
if !g.can_readback() {
return None;
}
let mut e = Enc::new(g);
let (color, zout) = raster_into(&mut e, s)?;
let cb = e.read_texture(&color, s.width, s.height, 8)?;
let zb = e.read_texture(&zout, s.width, s.height, 4)?;
let (cb, zb) = match g.deferred.clone() {
Some(d) => {
use crate::deferred::Lookup::*;
let mut k = crate::deferred::Key::new(4);
key_scene(&mut k, s);
let kc = k.finish();
let kz = kc ^ 1;
match (d.lookup(kc), d.lookup(kz)) {
(Ready(c), Ready(z)) => (c?.bytes, z?.bytes),
(Absent, Absent) => {
let (color, zout) = raster_into(&mut e, s)?;
e.read_texture_async(&color, s.width, s.height, 8, d.start(kc, s.width, s.height, [0.0; 2], 1.0));
e.read_texture_async(&zout, s.width, s.height, 4, d.start(kz, s.width, s.height, [0.0; 2], 1.0));
d.miss();
return Some(empty_target(s.width, s.height));
}
_ => {
d.miss();
return Some(empty_target(s.width, s.height));
}
}
}
None => {
let (color, zout) = raster_into(&mut e, s)?;
(e.read_texture(&color, s.width, s.height, 8)?, e.read_texture(&zout, s.width, s.height, 4)?)
}
};
if cb.len() != (s.width * s.height * 8) as usize || zb.len() != (s.width * s.height * 4) as usize {
return None;
}
let color = cb.chunks_exact(8).map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
let depth = zb
.chunks_exact(4)
@@ -343,6 +373,34 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
Some(Target { width: s.width, height: s.height, color, depth })
}
/// A placeholder target (a pass whose readbacks are in flight).
fn empty_target(w: u32, h: u32) -> Target {
let n = (w * h) as usize;
Target { width: w, height: h, color: vec![[0.0; 4]; n], depth: vec![f32::INFINITY; n] }
}
/// Everything that determines a scene's pixels, for deferred readback keys: the packed
/// buffers, the geometry, the target size.
fn key_scene(k: &mut crate::deferred::Key, s: &Scene) {
k.u64(s.width as u64);
k.u64(s.height as u64);
k.u64(s.ssaa as u64);
k.u64(s.opaque_count as u64);
for b in pack(s) {
k.u64(b.len() as u64);
k.bytes(&b);
}
let mut v = Vec::with_capacity(s.vertices.len() * VERTEX_SIZE as usize);
for x in &s.vertices {
for f in x.pos.iter().chain(&x.normal).chain(&x.uv).chain(&x.tangent) {
v.extend_from_slice(&f.to_le_bytes());
}
v.extend_from_slice(&x.material.to_le_bytes());
}
k.bytes(&v);
k.bytes(&s.indices.iter().flat_map(|i| i.to_le_bytes()).collect::<Vec<u8>>());
}
// ---------------------------------------------------------------- after the rasteriser
/// Compute kernels of `adv3d.wgsl` (resolve, motion-blur accumulation, depth of field,
@@ -673,9 +731,12 @@ pub(crate) fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&Gp
/// [`effectcraft_render::Accelerator::render_3d`]: the whole prepared run on the GPU, read
/// back.
pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendered> {
if !g.can_wait() {
if !g.can_readback() {
return None;
}
if let Some(d) = g.deferred.clone() {
return render_prepared_deferred(g, &d, prep);
}
let mut e = Enc::new(g);
let Some(r) = resolve_run(&mut e, prep)? else { return Some(None) };
let img = finish(&mut e, &r, !prep.linear, None);
@@ -692,6 +753,84 @@ pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendere
Some(Some((img, depth)))
}
/// [`render_prepared`] with deferred readbacks: the image and the depth are read back under the
/// run's key (every sub-sample's scene, the output size and encoding). A depth of field needs a
/// readback in the middle of the run: such runs go to the CPU path (whose scenes still
/// rasterise here, [`render`]).
fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::Deferred>, prep: &Prepared) -> Option<Rendered> {
use crate::deferred::Lookup::*;
if prep.dof.is_some() {
return None;
}
let (w, h) = prep.out;
let mut k = crate::deferred::Key::new(5);
k.u64(w as u64);
k.u64(h as u64);
k.u64(prep.samples as u64);
k.f64(prep.scale);
k.u64(prep.linear as u64);
let mut any = false;
for i in 0..prep.samples.max(1) {
let s = prep.scene(i);
any |= !s.indices.is_empty();
key_scene(&mut k, &s);
}
if !any {
return Some(None);
}
let ki = k.finish();
let kz = ki ^ 1;
match (d.lookup(ki), d.lookup(kz)) {
(Ready(a), Ready(b)) => {
let (a, b) = (a?, b?);
let mut img = effectcraft_render::Image::new(a.width, a.height);
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut img.data);
if dst.len() != a.bytes.len() || b.bytes.len() != dst.len() / 4 {
return None;
}
dst.copy_from_slice(&a.bytes);
let depth = b
.bytes
.chunks_exact(4)
.map(|c| {
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
if z >= BIG * 0.5 { f32::INFINITY } else { z }
})
.collect();
return Some(Some((img, depth)));
}
(Absent, Absent) => {}
_ => {
d.miss();
return Some(None);
}
}
let mut e = Enc::new(g);
let Some(r) = resolve_run(&mut e, prep)? else { return Some(None) };
let img = finish(&mut e, &r, !prep.linear, None);
let _ = e.read_keyed(&img, ki, [0.0; 2], 1.0);
let size = w as u64 * h as u64 * 4;
let rb = g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("adv3d depth readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
e.encoder().copy_buffer_to_buffer(&r.depth, 0, &rb, 0, size);
e.submit();
let done = d.start(kz, w, h, [0.0; 2], 1.0);
let b = rb.clone();
rb.map_async(wgpu::MapMode::Read, .., move |res| {
let out = res.ok().and_then(|_| b.get_mapped_range(..).ok().map(|v| v.to_vec()));
b.unmap();
done(out);
});
#[cfg(not(target_arch = "wasm32"))]
let _ = g.device.poll(wgpu::PollType::Poll);
d.miss();
Some(None)
}
/// Draw a prepared run over the GPU canvas (the compositor's walk; no readback unless the
/// depth of field needs its radius range). `None` = render it on the CPU.
pub(crate) fn draw_run(e: &mut Enc, prep: &Prepared, canvas: &GpuImage) -> Option<GpuImage> {
+13 -1
View File
@@ -269,7 +269,7 @@ impl Accelerator for Gpu {
}
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
self.ctx.can_wait().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
}
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
@@ -295,6 +295,16 @@ impl Accelerator for Gpu {
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
self.ctx.deferred().map(|d| d.gate())
}
fn settle(&self) -> Option<std::pin::Pin<Box<dyn std::future::Future<Output = ()> + '_>>> {
let d = self.ctx.deferred()?.clone();
// Natively the device delivers the readbacks when polled: wait for them here.
#[cfg(not(target_arch = "wasm32"))]
if d.in_flight() > 0 {
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
}
Some(Box::pin(async move { d.settled().await }))
}
}
impl effectcraft_effects::psim::ParticleSim for Gpu {
@@ -314,6 +324,8 @@ mod tests_adv3d;
#[cfg(test)]
mod tests_deferred;
#[cfg(test)]
mod tests_deferred_jobs;
#[cfg(test)]
mod tests_fx_color;
#[cfg(test)]
mod tests_fx_depth;
+56 -7
View File
@@ -108,14 +108,30 @@ fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec<u8> {
}
/// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]).
///
/// With deferred readbacks (a browser worker) the state's readback is keyed by the request
/// ([`crate::deferred`]): the first pass starts it and gets no particles (the pass misses and is
/// rendered again), later passes read the bytes.
pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimParticle>> {
if !g.can_wait() || req.steps > u32::MAX as u64 {
if !g.can_readback() || req.steps > u32::MAX as u64 {
return None;
}
let n = req.births.len() as u32;
if n == 0 {
return Some(vec![]);
}
let deferred = g.deferred.clone();
let key = deferred.as_ref().map(|_| sim_key(req));
if let (Some(d), Some(k)) = (&deferred, key) {
match d.lookup(k) {
crate::deferred::Lookup::Ready(r) => return r.map(|r| parse(&r.bytes)),
crate::deferred::Lookup::InFlight => {
d.miss();
return Some(vec![]);
}
crate::deferred::Lookup::Absent => {}
}
}
let size = n as u64 * STATE_BYTES;
if size > g.device.limits().max_storage_buffer_binding_size {
return None;
@@ -176,6 +192,20 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimPartic
}
enc.copy_buffer_to_buffer(&next, 0, &read, 0, size);
g.queue.submit([enc.finish()]);
if let (Some(d), Some(k)) = (&deferred, key) {
let done = d.start(k, n, 1, [0.0; 2], 1.0);
let b = read.clone();
read.map_async(wgpu::MapMode::Read, .., move |r| {
let out = r.ok().and_then(|_| b.get_mapped_range(..).ok().map(|v| v.to_vec()));
b.unmap();
done(out);
});
#[cfg(not(target_arch = "wasm32"))]
let _ = g.device.poll(wgpu::PollType::Poll);
keep_checkpoint(g, req, n, next);
d.miss();
return Some(vec![]);
}
let (tx, rx) = std::sync::mpsc::channel();
read.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = tx.send(r);
@@ -183,16 +213,36 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimPartic
g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
rx.recv().ok()?.ok()?;
let view = read.get_mapped_range(..).ok()?;
let f: Vec<f32> = view.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
let parts = parse(&view);
drop(view);
read.unmap();
let parts = f
.chunks_exact(12)
keep_checkpoint(g, req, n, next);
Some(parts)
}
/// The deferred readback key of a request: everything that determines the state.
fn sim_key(req: &SimRequest) -> u128 {
let mut k = crate::deferred::Key::new(3);
k.u64(req.key);
k.u64(req.steps);
k.u64(req.births.len() as u64);
k.bytes(&req.births.iter().flat_map(|b| b.to_le_bytes()).collect::<Vec<u8>>());
k.debug(&req.system);
k.finish()
}
/// Live particles of a state buffer's bytes.
fn parse(bytes: &[u8]) -> Vec<SimParticle> {
let f: Vec<f32> = bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
f.chunks_exact(12)
.enumerate()
.filter(|(_, s)| s[9] != 0.0)
.map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 })
.collect();
// Keep the new state as a checkpoint.
.collect()
}
/// Keep a simulated state on the GPU as a checkpoint (the oldest go beyond the limit).
fn keep_checkpoint(g: &GpuContext, req: &SimRequest, n: u32, next: wgpu::Buffer) {
if let Ok(mut c) = g.particle_states.lock() {
let now = c.clock;
c.list.retain(|k| !(k.key == req.key && k.steps == req.steps));
@@ -202,7 +252,6 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimPartic
c.list.remove(i);
}
}
Some(parts)
}
pub(crate) type PipesCell = OnceLock<Pipes>;
+5 -5
View File
@@ -35,7 +35,7 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
/// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light,
/// an environment light, a semi-transparent textured card.
fn scene_project() -> (Project, ItemId) {
pub(crate) fn scene_project() -> (Project, ItemId) {
let mut p = Project::default();
let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
@@ -90,7 +90,7 @@ fn scene_project() -> (Project, ItemId) {
(p, cid)
}
fn scene(p: &Project, cid: ItemId) -> Scene {
pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
@@ -177,7 +177,7 @@ fn gpu_raster_perf_smoke() {
/// The lit scene seen through a depth-of-field camera (hexagonal iris, diffraction fringe,
/// highlight boost) with the sphere moving under motion blur.
fn dof_mb_project() -> (Project, ItemId) {
pub(crate) fn dof_mb_project() -> (Project, ItemId) {
let (mut p, cid) = scene_project();
add(&mut p, cid, LayerSource::Camera, |l| {
let zoom = match l.props.prop_mut("cameraOptions/zoom").unwrap().value {
@@ -208,7 +208,7 @@ fn dof_mb_project() -> (Project, ItemId) {
/// Share of pixels (0–1) differing by more than 1/255 on any channel, and the largest
/// difference.
fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
let mut off = 0;
let mut max = 0.0f32;
for (p, q) in a.iter().zip(b) {
@@ -222,7 +222,7 @@ fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
(off as f64 / a.len().max(1) as f64, max)
}
fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
(ctx, run)
+3 -3
View File
@@ -16,7 +16,7 @@ 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, ()>)> {
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(());
let g = G.get_or_init(|| pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok()).as_ref()?;
@@ -25,7 +25,7 @@ fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)>
/// 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 {
pub(crate) fn scene() -> Scene {
let cpu_only = "ec.blur.ccradial";
assert!(!effectcraft_effects::GPU_EFFECTS.contains(&cpu_only));
let mut s = Scene::new(BitDepth::Bpc32);
@@ -56,7 +56,7 @@ fn scene() -> Scene {
}
/// 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) {
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);
+109
View File
@@ -0,0 +1,109 @@
//! 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));
}
+3 -3
View File
@@ -10,7 +10,7 @@ use effectcraft_keyframe::Value;
use crate::Gpu;
use crate::tests::{gpu, n};
struct Host(&'static Gpu);
pub(crate) struct Host(pub(crate) &'static Gpu);
impl EffectHost for Host {
fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option<LayerPixels> {
@@ -24,7 +24,7 @@ impl EffectHost for Host {
}
}
fn params(id: &str, vals: &[(&str, Value)]) -> Params {
pub(crate) fn params(id: &str, vals: &[(&str, Value)]) -> Params {
let spec = effectcraft_effects::find(id).unwrap();
let size = [320.0, 240.0];
let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
@@ -47,7 +47,7 @@ fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec<SimParticle>, Vec<S
/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol`
/// (world units) for 99 % of them and on average within `tol / 10`.
fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) {
pub(crate) fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) {
assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len());
let gi: std::collections::HashMap<u32, &SimParticle> = gpu.iter().map(|q| (q.id, q)).collect();
let mut missing = 0;