mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 16:33:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d39c0e818c
commit
f3715aac32
+144
-5
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user