From a2ff724ac2e7311585c6749400ed61d674f342b4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:34:33 -0400 Subject: [PATCH] M12.1: render cache + parallel compositing fast paths - render: LayerCache (content-keyed processed-layer cache: source -> masks -> effects, keyed by a hash of the layer's non-transform props evaluated at the frame time, solid item, switches, scale; time folded in for clock-driven effects / wiggle paths). Static and transform-only layers render once; edits only re-render the edited layer. Session + UI frame jobs share it. - effects: TIME_DEPENDENT list + test that every clock-reading effect is listed. - raster: affine composite fast path (per-row span clipping, unchecked interior bilinear, integer-translation copies, inlined Normal blend), checked against composite_warp_reference; box/Gaussian blur without transposes, two ping-pong buffers; accumulate_warp so motion blur sums into one buffer instead of one full-frame image per sub-sample. - ui: viewer frame is urgent and jumps the prefetch queue; stale-revision jobs are dropped; paused prefetch waits for the viewer frame; "Frame Render Time" reports viewer frames only; parallel f32 -> Color32 conversion. - cli: `frame --bench N` (per-layer/per-effect timings, wall + CPU time) and `frame --bench-play N` (consecutive frames with/without the layer cache). - tests: cache transparency (scrub, edits of values/keys/effects/masks/ solids/text/timing), hit/miss accounting, time-dependent effects, demo cached == uncached, fast warp/blur/accumulate == reference. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 33 ++++ apps/effectcraft-cli/Cargo.toml | 3 + apps/effectcraft-cli/src/main.rs | 117 ++++++++++++- crates/effects/src/lib.rs | 50 ++++++ crates/engine/src/lib.rs | 6 +- crates/engine/src/tests.rs | 29 ++++ crates/raster/src/blur.rs | 183 +++++++++++++++----- crates/raster/src/lib.rs | 2 +- crates/raster/src/warp.rs | 250 +++++++++++++++++++++++++++- crates/render/src/cache.rs | 245 +++++++++++++++++++++++++++ crates/render/src/lib.rs | 104 +++++++++--- crates/render/src/tests.rs | 171 +++++++++++++++++++ crates/ui-egui/src/frames.rs | 102 ++++++++++-- crates/ui-egui/src/lib.rs | 34 +++- crates/ui-egui/src/panels/viewer.rs | 6 +- 15 files changed, 1247 insertions(+), 88 deletions(-) create mode 100644 crates/render/src/cache.rs diff --git a/Cargo.lock b/Cargo.lock index b29b9181..6673f6af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpu-time" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1057,6 +1067,7 @@ dependencies = [ name = "effectcraft-cli" version = "0.1.0" dependencies = [ + "cpu-time", "effectcraft-engine", "effectcraft-render", "effectcraft-time", @@ -5040,6 +5051,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5049,6 +5076,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index c5bc28d0..a62a4b81 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -18,3 +18,6 @@ effectcraft-render = { workspace = true } effectcraft-time = { workspace = true } serde_json = { workspace = true } image = { workspace = true } + + +cpu-time = "1" diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58..eb930fcb 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1,13 +1,18 @@ //! EffectCraft headless CLI. //! //! ```text -//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png +//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] +//! [--bench N] [--bench-play N] --out out.png //! effectcraft-cli run [project.ecproj | --demo] '' '' … (prints results) //! effectcraft-cli commands (lists commands) //! ``` +//! +//! `--bench N` renders the frame N times without the layer cache and prints per-layer and +//! per-effect timings; `--bench-play N` renders N consecutive frames with and without the cache. use effectcraft_engine::Session; -use effectcraft_render::RenderOpts; +use effectcraft_engine::project::ItemId; +use effectcraft_render::{LayerCache, LayerTiming, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -22,7 +27,8 @@ fn open(s: &mut Session, args: &[String]) { eprintln!("effectcraft: {e}"); std::process::exit(1); } - } else if args.iter().any(|a| a == "--demo") || true { + } else { + // `--demo` or no project: the demo project. let _ = s.execute("file.openDemoProject", json!({})); } } @@ -31,6 +37,105 @@ fn opt<'a>(args: &'a [String], k: &str) -> Option<&'a str> { args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).map(String::as_str) } +fn median(v: &mut [f64]) -> f64 { + v.sort_by(f64::total_cmp); + if v.is_empty() { 0.0 } else { v[v.len() / 2] } +} + +fn min(v: &[f64]) -> f64 { + v.iter().copied().fold(f64::INFINITY, f64::min) +} + +/// CPU time of the whole process (all threads) in ms: a load-independent measure of work. +fn cpu_ms() -> f64 { + cpu_time::ProcessTime::try_now().map(|t| t.as_duration().as_secs_f64() * 1e3).unwrap_or(0.0) +} + +/// One frame: (wall ms, CPU ms, per-layer timings). +fn render_timed(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, cache: Option<&LayerCache>) -> (f64, f64, Vec) { + let prof = std::sync::Mutex::new(Vec::::new()); + let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + r.cache = cache; + r.profile = Some(&prof); + let c0 = cpu_ms(); + let t0 = std::time::Instant::now(); + let img = r.comp_frame(cid, t); + let ms = t0.elapsed().as_secs_f64() * 1e3; + let cpu = cpu_ms() - c0; + std::hint::black_box(&img); + (ms, cpu, prof.into_inner().unwrap_or_default()) +} + +type LayerRow = (usize, String, Vec, Vec, Vec<(String, Vec)>); + +/// `frame --bench N`: render the same frame N times without the layer cache (the full cost of a +/// frame) and print min/median frame time plus min per-layer and per-effect times. +fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) { + let mut totals = Vec::with_capacity(n); + let mut cpus = Vec::with_capacity(n); + let mut per_layer: Vec = Vec::new(); + for _ in 0..n { + let (ms, cpu, prof) = render_timed(s, cid, t, opts, None); + totals.push(ms); + cpus.push(cpu); + for (i, lt) in prof.into_iter().enumerate() { + if per_layer.len() <= i { + per_layer.push((lt.depth, lt.layer.clone(), vec![], vec![], vec![])); + } + let e = &mut per_layer[i]; + e.2.push(lt.process_ms); + e.3.push(lt.composite_ms); + for (j, (id, ms)) in lt.effects.into_iter().enumerate() { + if e.4.len() <= j { + e.4.push((id, vec![])); + } + e.4[j].1.push(ms); + } + } + } + eprintln!( + "bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms", + opts.scale, + min(&totals), + median(&mut totals), + min(&cpus) + ); + for (d, name, p, c, fx) in per_layer { + eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2); + for (id, v) in fx { + eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2); + } + } +} + +/// `frame --bench-play N`: render N consecutive frames from `t` as playback does, without and +/// with the layer cache, and print per-frame times. +fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) { + let Some(comp) = s.project.comp(cid) else { return }; + let f0 = comp.frame_rate.frame_at(t); + let cache = LayerCache::default(); + for (label, c) in [("no cache", None), ("layer cache", Some(&cache))] { + let runs: Vec<(f64, f64)> = (0..n as i64) + .map(|i| { + let (ms, cpu, _) = render_timed(s, cid, comp.frame_rate.tick_of(f0 + i), opts, c); + (ms, cpu) + }) + .collect(); + let mut v: Vec = runs.iter().map(|r| r.0).collect(); + let mean = v.iter().sum::() / n as f64; + let cpu = runs.iter().map(|r| r.1).sum::() / n as f64; + eprintln!( + "bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms", + opts.scale, + median(&mut v), + min(&v) + ); + } + let st = cache.stats(); + eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6); +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let Some(cmd) = args.first().cloned() else { usage() }; @@ -54,6 +159,12 @@ fn main() { }; let scale = opt(rest, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0); let out = opt(rest, "--out").unwrap_or("frame.png"); + if let Some(n) = opt(rest, "--bench").and_then(|v| v.parse::().ok()) { + bench(&s, cid, t, RenderOpts { scale, ..Default::default() }, n.max(1)); + } + if let Some(n) = opt(rest, "--bench-play").and_then(|v| v.parse::().ok()) { + bench_play(&s, cid, t, RenderOpts { scale, ..Default::default() }, n.max(1)); + } let t0 = std::time::Instant::now(); let img = s.render(cid, t, RenderOpts { scale, ..Default::default() }); let el = t0.elapsed(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7dc27fa2..efe5ae3e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -222,6 +222,26 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_ g } +/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated +/// parameters). The renderer's layer cache folds the layer time into the key for these. +/// The `time_dependence_is_declared` test checks the list against every registered effect. +pub const TIME_DEPENDENT: &[&str] = &[ + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.generate.radiowaves", + "ec.stylize.scatter", + "ec.stylize.strobe", + "ec.noise.noise", + "ec.noise.addgrain", + "ec.noise.noisealpha", + "ec.noise.noisehlsauto", +]; + +/// See [`TIME_DEPENDENT`]. +pub fn is_time_dependent(id: &str) -> bool { + TIME_DEPENDENT.contains(&id) +} + /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) @@ -265,4 +285,34 @@ mod tests { assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id); } } + + /// Effects that read the clock must be listed in `TIME_DEPENDENT`, or the layer cache would + /// serve stale pixels. Render every effect at two times with default parameters (plus + /// "random…" switches turned on) and require identical output from unlisted ones. + #[test] + fn time_dependence_is_declared() { + let mut img = Image::new(40, 30); + for y in 0..30 { + for x in 0..40 { + let a = if (6..34).contains(&x) && (5..25).contains(&y) { 1.0 } else { 0.3 }; + img.set(x, y, [((x * 7 + y * 3) % 11) as f32 / 11.0 * a, y as f32 / 30.0 * a, 0.4 * a, a]); + } + } + for s in registry() { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in params.values.iter_mut() { + if k.to_lowercase().contains("random") && matches!(v, Value::Bool(_)) { + *v = Value::Bool(true); + } + } + let run = |t: f64| { + let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false }; + apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let (a, b) = (run(0.0), run(1.37)); + if a != b { + assert!(is_time_dependent(s.id), "{} depends on time but is not in TIME_DEPENDENT", s.id); + } + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18..02d39164 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use effectcraft_project::{Comp, ItemId, Layer, LayerId, Project, Uid}; use effectcraft_raster::Image; -use effectcraft_render::{ExprHost, FootageSource, NoFootage, RenderOpts, Renderer}; +use effectcraft_render::{ExprHost, FootageSource, LayerCache, NoFootage, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -135,6 +135,8 @@ pub struct Session { pub events: Vec, /// Commands executed: (id, params). pub journal: Vec<(String, Value)>, + /// Processed-layer pixels reused across frames and edits (content-keyed, never stale). + pub layer_cache: Arc, } impl Default for Session { @@ -152,6 +154,7 @@ impl Default for Session { importer: None, events: vec![], journal: vec![], + layer_cache: Arc::new(LayerCache::default()), } } } @@ -303,6 +306,7 @@ impl Session { pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); + r.cache = Some(&self.layer_cache); r.comp_frame(comp, t) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52..2a47d87a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18,6 +18,35 @@ fn demo_opens_and_renders() { assert!(lit > 100_000, "{lit}"); } +/// The session's layer cache never changes pixels: scrubbing the demo (including back and +/// forth, and across an edit made through a command) matches cache-less renders exactly. +#[test] +fn demo_layer_cache_is_transparent() { + let mut s = demo(); + let cid = s.active_comp_id().unwrap(); + let opts = effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() }; + let uncached = |s: &Session, t: f64| { + let mut r = effectcraft_render::Renderer::new(&s.project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + r.comp_frame(cid, effectcraft_time::Tick::from_seconds_f64(t)) + }; + let check = |s: &Session, t: f64| { + let a = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), opts); + let b = uncached(s, t); + assert!(a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|c| (p[c] - q[c]).abs() < 1e-6)), "t={t}"); + }; + for t in [0.5, 1.0, 3.0, 3.0334, 6.0, 3.0, 0.5] { + check(&s, t); + } + assert!(s.layer_cache.stats().hits > 0); + // Edit the title's glow through the command layer, then scrub again. + let title = s.project.comp(cid).unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap(); + s.execute("prop.set", json!({"layer": title, "path": "effects/#1/radius", "value": 5.0})).unwrap(); + for t in [3.0, 0.5] { + check(&s, t); + } +} + #[test] fn every_command_has_unique_id_and_runs_or_reports() { let mut ids = std::collections::HashSet::new(); diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 5a8412f3..1c065d77 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -1,20 +1,23 @@ //! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional //! and radial (spin/zoom) blurs. +//! +//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over +//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two +//! buffers so a Gaussian allocates two images regardless of the number of passes. use rayon::prelude::*; use crate::{Image, Px}; -/// One horizontal box pass of radius `r` (window 2r+1) over each row; `repeat` = edge pixels -/// extend, otherwise transparent. -fn box_h(img: &Image, r: usize, repeat: bool) -> Image { +/// One horizontal box pass of radius `r` (window 2r+1) over each row of `src` (width `w`) into +/// `out`; `repeat` = edge pixels extend, otherwise transparent. +fn box_h_into(src: &[Px], out: &mut [Px], w: usize, r: usize, repeat: bool) { if r == 0 { - return img.clone(); + out.copy_from_slice(src); + return; } - let w = img.width as usize; - let mut out = Image::new(img.width, img.height); let norm = 1.0 / (2 * r + 1) as f32; - out.data.par_chunks_mut(w).zip(img.data.par_chunks(w)).for_each(|(o, row)| { + out.par_chunks_mut(w).zip(src.par_chunks(w)).for_each(|(o, row)| { let get = |i: isize| -> Px { if i < 0 { if repeat { row[0] } else { [0.0; 4] } @@ -40,18 +43,89 @@ fn box_h(img: &Image, r: usize, repeat: bool) -> Image { } } }); - out } -fn transpose(img: &Image) -> Image { - let (w, h) = (img.width as usize, img.height as usize); - let mut out = Image::new(img.height, img.width); - out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { - for y in 0..h { - col[y] = img.data[y * w + x]; +/// One vertical box pass (same semantics as [`box_h_into`] along columns). +fn box_v_into(src: &[Px], out: &mut [Px], w: usize, h: usize, r: usize, repeat: bool) { + if r == 0 { + out.copy_from_slice(src); + return; + } + let norm = 1.0 / (2 * r + 1) as f32; + let block = (4 * r).clamp(32, 256); + let row = |i: isize| -> Option<&[Px]> { + if i < 0 { + repeat.then(|| &src[0..w]) + } else if i as usize >= h { + repeat.then(|| &src[(h - 1) * w..h * w]) + } else { + Some(&src[i as usize * w..(i as usize + 1) * w]) + } + }; + out.par_chunks_mut(w * block).enumerate().for_each(|(bi, chunk)| { + let ya = (bi * block) as isize; + let mut acc = vec![[0.0f32; 4]; w]; + for i in ya - r as isize..=ya + r as isize { + if let Some(rw) = row(i) { + for (a, p) in acc.iter_mut().zip(rw) { + for c in 0..4 { + a[c] += p[c]; + } + } + } + } + for (yy, o) in chunk.chunks_mut(w).enumerate() { + let y = ya + yy as isize; + for (o, a) in o.iter_mut().zip(&acc) { + *o = a.map(|v| v * norm); + } + match (row(y + r as isize + 1), row(y - r as isize)) { + (Some(add), Some(sub)) => { + for ((a, p), q) in acc.iter_mut().zip(add).zip(sub) { + for c in 0..4 { + a[c] += p[c] - q[c]; + } + } + } + (Some(add), None) => { + for (a, p) in acc.iter_mut().zip(add) { + for c in 0..4 { + a[c] += p[c]; + } + } + } + (None, Some(sub)) => { + for (a, q) in acc.iter_mut().zip(sub) { + for c in 0..4 { + a[c] -= q[c]; + } + } + } + (None, None) => {} + } } }); - out +} + +/// Run horizontal passes with radii `rx` then vertical passes with radii `ry`. +fn box_passes(img: &Image, rx: &[usize], ry: &[usize], repeat: bool) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + let passes: Vec<(bool, usize)> = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0).collect(); + if passes.is_empty() || w == 0 || h == 0 { + return img.clone(); + } + let mut a = Image::new(img.width, img.height); + let mut b = Image::new(img.width, img.height); + for (i, &(vertical, r)) in passes.iter().enumerate() { + let src: &[Px] = if i == 0 { &img.data } else { &a.data }; + if vertical { + box_v_into(src, &mut b.data, w, h, r, repeat); + } else { + box_h_into(src, &mut b.data, w, r, repeat); + } + std::mem::swap(&mut a, &mut b); + } + a } /// Box radii for `n` passes approximating a Gaussian of `sigma` (Kovesi / "boxes for Gauss"). @@ -72,36 +146,15 @@ fn box_radii(sigma: f64, n: usize) -> Vec { /// Separable box blur with given radii, `iterations` passes. pub fn box_blur(img: &Image, rx: usize, ry: usize, iterations: usize, repeat: bool) -> Image { - let mut cur = img.clone(); - for _ in 0..iterations.max(1) { - cur = box_h(&cur, rx, repeat); - } - if ry > 0 { - let mut t = transpose(&cur); - for _ in 0..iterations.max(1) { - t = box_h(&t, ry, repeat); - } - cur = transpose(&t); - } - cur + let n = iterations.max(1); + box_passes(img, &vec![rx; n], &vec![ry; n], repeat) } /// Gaussian blur with standard deviations in pixels (3 box passes per axis). pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image { - let mut cur = img.clone(); - if sigma_x > 0.05 { - for r in box_radii(sigma_x, 3) { - cur = box_h(&cur, r, repeat); - } - } - if sigma_y > 0.05 { - let mut t = transpose(&cur); - for r in box_radii(sigma_y, 3) { - t = box_h(&t, r, repeat); - } - cur = transpose(&t); - } - cur + let rx = if sigma_x > 0.05 { box_radii(sigma_x, 3) } else { vec![] }; + let ry = if sigma_y > 0.05 { box_radii(sigma_y, 3) } else { vec![] }; + box_passes(img, &rx, &ry, repeat) } /// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from @@ -182,6 +235,54 @@ mod tests { assert!(b.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); } + /// Direct (windowed sum) box filter along x or y: the definition the passes must match. + fn box_ref(img: &Image, r: usize, vertical: bool, repeat: bool) -> Image { + let mut out = Image::new(img.width, img.height); + let (w, h) = (img.width as i64, img.height as i64); + for y in 0..h { + for x in 0..w { + let mut acc = [0.0f64; 4]; + for k in -(r as i64)..=(r as i64) { + let (sx, sy) = if vertical { (x, y + k) } else { (x + k, y) }; + let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) }; + for c in 0..4 { + acc[c] += p[c] as f64; + } + } + out.set(x as u32, y as u32, acc.map(|v| (v / (2 * r + 1) as f64) as f32)); + } + } + out + } + + #[test] + fn passes_match_direct_box_filter() { + let mut img = Image::new(53, 301); + for y in 0..301 { + for x in 0..53 { + let v = crate::hash_noise(x, y, 3); + img.set(x, y, [v * 0.5, v * 0.25, v, v]); + } + } + for repeat in [false, true] { + for (rx, ry) in [(0, 3), (2, 0), (5, 9), (40, 70), (1, 200)] { + let fast = box_passes(&img, &[rx, rx + 1], &[ry, ry / 2], repeat); + let mut slow = img.clone(); + for r in [rx, rx + 1] { + slow = box_ref(&slow, r, false, repeat); + } + for r in [ry, ry / 2] { + slow = box_ref(&slow, r, true, repeat); + } + for (i, (p, q)) in fast.data.iter().zip(&slow.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "r {rx},{ry} repeat {repeat} px {i}: {p:?} vs {q:?}"); + } + } + } + } + } + #[test] fn radii_sane() { let r = box_radii(10.0, 3); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index ed9dd898..643de9e7 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -10,7 +10,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use rayon::prelude::*; pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur}; -pub use warp::{Sampling, WarpOpts, composite_warp, resample}; +pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; pub type Px = [f32; 4]; diff --git a/crates/raster/src/warp.rs b/crates/raster/src/warp.rs index 15753954..8b45761e 100644 --- a/crates/raster/src/warp.rs +++ b/crates/raster/src/warp.rs @@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_geom::{Mat3, Rect, vec2}; use rayon::prelude::*; -use crate::{Image, hash_noise}; +use crate::{Image, Px, hash_noise}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sampling { @@ -52,7 +52,163 @@ fn half(src: &Image) -> Image { } /// Draw `src` transformed by `m` (src pixel space → dst pixel space) onto `dst` with blending. +/// +/// Affine transforms with non-stencil modes take a fast path (per-row span clipping, interior +/// bilinear without bounds checks, integer-translation copies, inlined Normal blending); it +/// matches [`composite_warp_reference`] to float rounding. pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { + composite_warp_impl(dst, src, m, opts, true, None); +} + +/// `dst += warp(src) · weight` (plain sum, no blending): motion-blur sub-samples accumulate +/// straight into one buffer instead of one full-size image per sample. +pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Sampling, weight: f32) { + let opts = WarpOpts { sampling, ..Default::default() }; + if m.is_affine() && m.inverse().is_some_and(|i| i.is_affine()) { + composite_warp_impl(dst, src, m, &opts, true, Some(weight)); + return; + } + let mut tmp = Image::new(dst.width, dst.height); + composite_warp(&mut tmp, src, m, &opts); + dst.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(d, s)| { + for c in 0..4 { + d[c] += s[c] * weight; + } + }); +} + +/// The straightforward per-pixel implementation (reference for the fast path's tests). +pub fn composite_warp_reference(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { + composite_warp_impl(dst, src, m, opts, false, None); +} + +/// Bilinear blend of four taps (same operation order as [`Image::sample_bilinear`]). +#[inline(always)] +fn bilerp(a: Px, b: Px, c: Px, d: Px, tx: f32, ty: f32) -> Px { + let mut o = [0.0; 4]; + for i in 0..4 { + let top = a[i] + (b[i] - a[i]) * tx; + let bot = c[i] + (d[i] - c[i]) * tx; + o[i] = top + (bot - top) * ty; + } + o +} + +/// Bilinear sample with an unchecked interior fast path. +#[inline(always)] +fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let (xi, yi) = (x0 as i64, y0 as i64); + let (w, h) = (img.width as i64, img.height as i64); + if xi >= 0 && yi >= 0 && xi + 1 < w && yi + 1 < h { + let tx = (fx - x0) as f32; + let ty = (fy - y0) as f32; + let i = yi as usize * w as usize + xi as usize; + let row0 = &img.data[i..i + 2]; + let row1 = &img.data[i + w as usize..i + w as usize + 2]; + return bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty); + } + img.sample_bilinear(x, y) +} + +/// Index range `[lo, hi)` of `i` in `0..n` for which `p0 + i·s` may lie in `[min, max]` +/// (conservative by one step on each side; callers still check per pixel). +fn span(p0: f64, s: f64, min: f64, max: f64, n: usize) -> (usize, usize) { + if s.abs() < 1e-12 { + return if p0 >= min - 1e-9 && p0 <= max + 1e-9 { (0, n) } else { (0, 0) }; + } + let (a, b) = ((min - p0) / s, (max - p0) / s); + let (a, b) = if a < b { (a, b) } else { (b, a) }; + let lo = (a.floor() - 1.0).max(0.0); + let hi = (b.ceil() + 2.0).min(n as f64); + if hi <= lo { (0, 0) } else { (lo as usize, hi as usize) } +} + +/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. +#[allow(clippy::too_many_arguments)] +fn affine_rows( + dst: &mut Image, + img: &Image, + inv: &Mat3, + sampling: Sampling, + op: f32, + x0: usize, + x1: usize, + y0: usize, + y1: usize, + blend: impl Fn(Px, Px, u32, u32) -> Px + Sync, +) { + let w = dst.width as usize; + let m = inv.0; + let (iw, ih) = (img.width as f64, img.height as f64); + let step = vec2(m[0][0], m[1][0]); + // Pure integer translation: every sample lands on a pixel centre, so all filters return the + // source pixel itself (bicubic still clamps negatives, as in the reference). + let int_tx = m[0][0] == 1.0 && m[1][1] == 1.0 && m[0][1] == 0.0 && m[1][0] == 0.0 && m[0][2].fract() == 0.0 && m[1][2].fract() == 0.0; + dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { + let fy = y as f64 + 0.5; + let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]); + let n = x1 - x0; + let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n); + let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n); + let (lo, hi) = (ax.max(ay), bx.min(by)); + if lo >= hi { + return; + } + if int_tx { + let sy = y as i64 + m[1][2] as i64; + if sy < 0 || sy >= img.height as i64 { + return; + } + let dx = m[0][2] as i64; + let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize]; + let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize; + let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize; + for x in xa..xb.max(xa) { + let mut s = srow[(x as i64 + dx) as usize]; + if sampling == Sampling::Bicubic { + s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)]; + } + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + } + return; + } + for i in lo..hi { + let x = x0 + i; + let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64); + if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 { + continue; + } + let mut s = match sampling { + Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64), + Sampling::Bilinear => sample_bilinear_fast(img, p.x, p.y), + Sampling::Bicubic => img.sample_bicubic(p.x, p.y), + }; + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + } + }); +} + +fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, allow_fast: bool, accumulate: Option) { if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 { return; } @@ -91,6 +247,26 @@ pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { let affine = inv.is_affine(); let mode = opts.mode; let op = opts.opacity; + if let Some(k) = accumulate + && affine + { + affine_rows(dst, img, &inv, opts.sampling, 1.0, x0, x1, y0, y1, |d, s, _, _| [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]); + return; + } + if allow_fast && affine && !mode.is_stencil() { + match mode { + BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| { + let k = 1.0 - s[3]; + [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] + }), + BlendMode::Dissolve | BlendMode::DancingDissolve => { + let seed = opts.seed; + affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) + } + _ => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), + } + return; + } dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { let fy = y as f64 + 0.5; let mut sp = inv.apply(vec2(x0 as f64 + 0.5, fy)); @@ -172,6 +348,78 @@ mod tests { assert_eq!(dst.get(3, 2), [1.0; 4]); } + fn test_image(w: u32, h: u32, seed: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let a = if (x + y) % 7 == 0 { 0.0 } else { hash_noise(x, y, seed) }; + img.set(x, y, [hash_noise(x, y, seed + 1) * a, hash_noise(x, y, seed + 2) * a, hash_noise(x, y, seed + 3) * a, a]); + } + } + img + } + + /// The fast affine path equals the reference per-pixel path (modes, filters, transforms). + #[test] + fn fast_path_matches_reference() { + let src = test_image(37, 23, 5); + let mats = [ + Mat3::IDENTITY, + Mat3::translate(vec2(5.0, -3.0)), + Mat3::translate(vec2(-40.0, 2.0)), + Mat3::translate(vec2(3.25, 7.5)), + Mat3::translate(vec2(20.0, 10.0)) * Mat3::rotate_deg(23.0) * Mat3::scale(vec2(1.3, 0.8)), + Mat3::translate(vec2(30.0, 5.0)) * Mat3::rotate_deg(-120.0) * Mat3::scale(vec2(0.7, 0.7)), + Mat3::translate(vec2(10.0, 10.0)) * Mat3::scale(vec2(-1.0, 1.0)), + Mat3::scale(vec2(0.3, 0.3)), + ]; + let modes = [BlendMode::Normal, BlendMode::Add, BlendMode::Multiply, BlendMode::Screen, BlendMode::Dissolve, BlendMode::Overlay]; + for (mi, m) in mats.iter().enumerate() { + for mode in modes { + for sampling in [Sampling::Nearest, Sampling::Bilinear, Sampling::Bicubic] { + for opacity in [1.0, 0.6] { + let opts = WarpOpts { sampling, opacity, mode, seed: 9, clip: None }; + let mut a = test_image(48, 40, 77); + let mut b = a.clone(); + composite_warp(&mut a, &src, m, &opts); + composite_warp_reference(&mut b, &src, m, &opts); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "mat {mi} {mode:?} {sampling:?} op {opacity} px {i}: {p:?} vs {q:?}"); + } + } + } + } + } + } + } + + /// Accumulating sub-samples equals warping each into its own image and summing. + #[test] + fn accumulate_matches_per_sample_images() { + let src = test_image(30, 20, 11); + let persp = Mat3::square_to_quad([vec2(5.0, 4.0), vec2(40.0, 2.0), vec2(44.0, 30.0), vec2(2.0, 35.0)]) * Mat3::scale(vec2(1.0 / 30.0, 1.0 / 20.0)); + let mats = [Mat3::translate(vec2(3.0, 4.0)), Mat3::translate(vec2(5.5, 4.25)) * Mat3::rotate_deg(12.0), persp]; + let k = 1.0 / mats.len() as f32; + let mut fast = Image::new(48, 40); + let mut slow = Image::new(48, 40); + for m in &mats { + accumulate_warp(&mut fast, &src, m, Sampling::Bilinear, k); + let mut img = Image::new(48, 40); + composite_warp_reference(&mut img, &src, m, &WarpOpts::default()); + for (a, b) in slow.data.iter_mut().zip(&img.data) { + for c in 0..4 { + a[c] += b[c] * k; + } + } + } + for (p, q) in fast.data.iter().zip(&slow.data) { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "{p:?} vs {q:?}"); + } + } + } + #[test] fn downscale_preserves_average() { let src = Image::filled(64, 64, [0.25, 0.5, 0.75, 1.0]); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs new file mode 100644 index 00000000..78ff39b5 --- /dev/null +++ b/crates/render/src/cache.rs @@ -0,0 +1,245 @@ +//! Layer render cache. +//! +//! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the +//! layer's own non-transform properties, its source item and the render options — not on its +//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed +//! by a content hash of exactly those inputs, *evaluated at the frame time*, so: +//! +//! * a static layer (or one whose only animation is in Transform) renders once and is reused on +//! every frame while scrubbing or playing; +//! * editing one layer re-renders only that layer — every other layer's cached pixels stay valid +//! because their keys do not change; +//! * any change to a property value, keyframe, expression result, effect, mask, switch or source +//! item changes the key, so stale pixels are never returned. +//! +//! Layers whose pixels depend on time in ways the property values do not capture (time-based +//! effects such as Noise or Wave Warp, Wiggle Paths, footage, precomps) either fold the layer +//! time into the key or are not cached. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use effectcraft_effects::Buf; +use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; + +use crate::eval::EvalCtx; + +/// Thread-safe, memory-budgeted cache of processed layer buffers. +pub struct LayerCache { + inner: Mutex, +} + +struct Entry { + buf: Arc, + bytes: usize, + last_use: u64, +} + +struct Inner { + map: HashMap, + bytes: usize, + budget: usize, + clock: u64, + hits: u64, + misses: u64, +} + +/// Hit/miss counters and memory use (for perf readouts and tests). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CacheStats { + pub entries: usize, + pub bytes: usize, + pub hits: u64, + pub misses: u64, +} + +impl Default for LayerCache { + fn default() -> Self { + LayerCache::new(1 << 30) + } +} + +impl LayerCache { + /// A cache holding at most `budget` bytes of pixels. + pub fn new(budget: usize) -> LayerCache { + LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0 }) } + } + + pub fn get(&self, key: u64) -> Option> { + let mut g = self.inner.lock().ok()?; + g.clock += 1; + let now = g.clock; + match g.map.get_mut(&key) { + Some(e) => { + e.last_use = now; + let b = e.buf.clone(); + g.hits += 1; + Some(b) + } + None => { + g.misses += 1; + None + } + } + } + + pub fn insert(&self, key: u64, buf: Arc) { + let bytes = buf.img.data.len() * std::mem::size_of::(); + let Ok(mut g) = self.inner.lock() else { return }; + if bytes > g.budget / 4 { + return; + } + g.clock += 1; + let now = g.clock; + if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { + g.bytes -= old.bytes; + } + g.bytes += bytes; + if g.bytes > g.budget { + // Evict least-recently used entries down to 3/4 of the budget. + let target = g.budget / 4 * 3; + let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); + by_age.sort_unstable(); + for (_, k) in by_age { + if g.bytes <= target { + break; + } + if let Some(e) = g.map.remove(&k) { + g.bytes -= e.bytes; + } + } + } + } + + pub fn clear(&self) { + if let Ok(mut g) = self.inner.lock() { + g.map.clear(); + g.bytes = 0; + } + } + + pub fn stats(&self) -> CacheStats { + self.inner.lock().map(|g| CacheStats { entries: g.map.len(), bytes: g.bytes, hits: g.hits, misses: g.misses }).unwrap_or_default() + } +} + +/// FNV-1a style 64-bit hasher fed by `Debug` output (exact float formatting) and raw values. +struct KeyHasher(u64); + +impl Hasher for KeyHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 ^= b as u64; + self.0 = self.0.wrapping_mul(0x0100_0000_01b3); + } + } +} + +impl std::fmt::Write for KeyHasher { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + Hasher::write(self, s.as_bytes()); + Ok(()) + } +} + +fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) { + use std::fmt::Write; + let _ = write!(h, "{v:?}"); + Hasher::write_u8(h, 0xff); +} + +/// Time-based layer content: does any part of the source/effects read the clock directly +/// (rather than through property values)? +fn time_dependent(layer: &Layer) -> bool { + if layer.switches.effects + && let Some(fx) = layer.effects() + { + for g in fx.groups() { + if let effectcraft_project::GroupKind::Effect { effect } = &g.kind + && g.enabled + && effectcraft_effects::is_time_dependent(effect) + { + return true; + } + } + } + if matches!(layer.source, LayerSource::Shape) + && let Some(c) = layer.props.sub("contents") + { + return group_has(c, "wiggle"); + } + false +} + +fn group_has(g: &PropGroup, match_id: &str) -> bool { + g.children.iter().any(|c| match c { + Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id), + Node::Prop(_) => false, + }) +} + +/// Feed every property of `g` that can change over time (keyframes or expression) as its value +/// at the context time. The static structure is hashed separately. +fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { + for c in &g.children { + match c { + Node::Prop(p) => { + if !p.keys.is_empty() || p.has_expression() { + p.uid.hash(h); + hash_debug(h, &ctx.value(layer, p)); + } + } + Node::Group(sub) => hash_values(h, ctx, layer, sub), + } + } +} + +/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context +/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment +/// layers…). +pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { + if layer.switches.adjustment { + return None; + } + let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); + match &layer.source { + LayerSource::Solid { item } => { + let it = ctx.project.item(*item)?; + let ItemKind::Solid(s) = &it.kind else { return None }; + hash_debug(&mut h, s); + } + LayerSource::Text | LayerSource::Shape => {} + _ => return None, + } + ctx.comp_id.hash(&mut h); + ctx.comp.width.hash(&mut h); + ctx.comp.height.hash(&mut h); + scale.to_bits().hash(&mut h); + draft.hash(&mut h); + layer.id.hash(&mut h); + hash_debug(&mut h, &layer.source); + hash_debug(&mut h, &layer.switches); + for c in &layer.props.children { + if let Node::Group(g) = c + && g.match_id == "transform" + { + continue; + } + // Static structure: values, keyframes, expressions, enabled flags, effect ids, modes. + hash_debug(&mut h, c); + if let Node::Group(g) = c { + hash_values(&mut h, ctx, layer, g); + } + } + if time_dependent(layer) { + layer.layer_time(ctx.time).0.hash(&mut h); + // Effects also see the layer's time mapping. + layer.start_time.0.hash(&mut h); + layer.stretch.to_bits().hash(&mut h); + } + Some(h.finish()) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd335..ef221734 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,7 @@ //! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity. //! Adjustment layers run their effects on everything below, limited by their own bounds/masks. +pub mod cache; pub mod eval; pub mod masks; pub mod shapes; @@ -13,6 +14,7 @@ pub mod text; use std::sync::Arc; +pub use cache::{CacheStats, LayerCache}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, Params}; use effectcraft_geom::{Mat3, vec2}; @@ -59,13 +61,32 @@ pub struct Renderer<'a> { pub footage: &'a dyn FootageSource, pub expr: Option<&'a dyn ExprHost>, pub opts: RenderOpts, + /// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything. + pub cache: Option<&'a LayerCache>, + /// Optional per-layer timing sink (`frame --bench` / perf readouts). + pub profile: Option<&'a std::sync::Mutex>>, /// Current nesting depth (precomp recursion guard). depth: usize, } +/// Time spent on one layer of one frame (milliseconds). +#[derive(Clone, Debug, Default)] +pub struct LayerTiming { + pub depth: usize, + pub layer: String, + /// Source + masks + effects (cache lookup only, on a hit). + pub process_ms: f64, + /// Per effect: (effect id, ms). Empty on a cache hit. + pub effects: Vec<(String, f64)>, + /// Transform + matte + blend. + pub composite_ms: f64, + /// The processed buffer came from the layer cache. + pub cached: bool, +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { - Renderer { project, footage, expr: None, opts, depth: 0 } + Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 } } fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { @@ -126,7 +147,11 @@ impl<'a> Renderer<'a> { p } - fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool) -> Buf { + fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf { + self.apply_effects_timed(ctx, layer, buf, adjustment, None) + } + + fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, mut timing: Option<&mut Vec<(String, f64)>>) -> Buf { if !layer.switches.effects { return buf; } @@ -142,7 +167,11 @@ impl<'a> Renderer<'a> { let Some(spec) = effectcraft_effects::find(effect) else { continue }; let params = self.effect_params(ctx, layer, g); let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment }; + let t0 = std::time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); + if let Some(v) = timing.as_deref_mut() { + v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); + } } buf } @@ -165,7 +194,7 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 }; + let sub = Renderer { depth: self.depth + 1, ..*self }; let lt = layer.layer_time(ctx.time); Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } @@ -193,10 +222,28 @@ impl<'a> Renderer<'a> { } /// Fully processed layer buffer (source → masks → effects). - pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// Served from the layer cache when the layer's content key matches. + pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + self.layer_buf_timed(ctx, layer, None) + } + + fn layer_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option> { + let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft)); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + if let Some(t) = timing.as_deref_mut() { + t.cached = true; + } + return Some(b); + } let mut buf = self.source(ctx, layer)?; masks::apply(ctx, layer, &mut buf); - Some(self.apply_effects(ctx, layer, buf, false)) + let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, timing.map(|t| &mut t.effects))); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, buf.clone()); + } + Some(buf) } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { @@ -232,25 +279,14 @@ impl<'a> Renderer<'a> { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; let phase = ctx.comp.shutter_phase / 360.0; + // Sub-samples sum straight into one buffer (each warp is row-parallel). let mut acc = Image::new(target.width, target.height); - let accum: Vec = (0..samples) - .into_par_iter() - .map(|i| { - let f = phase + angle * i as f64 / (samples - 1) as f64; - let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); - let m = self.buf_matrix(&sub, layer, buf); - let mut img = Image::new(target.width, target.height); - composite_warp(&mut img, &buf.img, &m, &WarpOpts { mode: BlendMode::Normal, opacity: 1.0, ..opts }); - img - }) - .collect(); let k = 1.0 / samples as f32; - for img in &accum { - acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, b)| { - for c in 0..4 { - a[c] += b[c] * k; - } - }); + for i in 0..samples { + let f = phase + angle * i as f64 / (samples - 1) as f64; + let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); + let m = self.buf_matrix(&sub, layer, buf); + effectcraft_raster::accumulate_warp(&mut acc, &buf.img, &m, opts.sampling, k); } target.blend_from(&acc, mode, opacity, layer.id.0 as u32); } @@ -264,16 +300,34 @@ impl<'a> Renderer<'a> { self.draw_adjustment(ctx, layer, canvas, opacity); return; } - let Some(buf) = self.layer_buf(ctx, layer) else { return }; + let Some(prof) = self.profile else { + if let Some(buf) = self.layer_buf(ctx, layer) { + self.composite_layer(ctx, layer, &buf, canvas, opacity); + } + return; + }; + let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }; + let t0 = std::time::Instant::now(); + let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return }; + let t1 = std::time::Instant::now(); + self.composite_layer(ctx, layer, &buf, canvas, opacity); + timing.process_ms = (t1 - t0).as_secs_f64() * 1e3; + timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3; + if let Ok(mut v) = prof.lock() { + v.push(timing); + } + } + + fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) { let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind))); let preserve = layer.preserve_transparency; if matte.is_none() && !preserve { - self.place(ctx, layer, &buf, canvas, layer.blend_mode, opacity); + self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity); return; } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); - self.place(ctx, layer, &buf, &mut iso, BlendMode::Normal, 1.0); + self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0); if let Some((m, kind)) = matte { let mut mimg = Image::new(canvas.width, canvas.height); if m.is_active_at(ctx.time) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4607486..877e9329 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -160,3 +160,174 @@ fn effects_run_in_pipeline() { let c = img.get(100, 50); assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } + +// ------------------------------------------------------------------------------ layer cache + +fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, params: &[(&str, Value)]) { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]); + p.next_id = next; + for (k, v) in params { + g.prop_mut(k).unwrap().value = v.clone(); + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); +} + +/// A comp with a static effected solid, an animated-trim shape, a moving text layer with a blur. +fn cache_scene() -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); + add_effect(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]); + let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None); + let mut next = p.next_id; + { + let mut ids = Ids(&mut next); + let e = build::shape_ellipse(&mut ids, [60.0, 60.0], [0.0, 0.0]); + let tr = build::shape_trim(&mut ids, 0.0, 0.0, 0.0); + let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0); + let g = build::shape_group(&mut ids, "Ring", vec![e, tr, s]); + shape.props.sub_mut("contents").unwrap().children.push(g.into()); + } + p.next_id = next; + shape.props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(10.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))]; + let mut text = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() })); + text.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; + add_effect(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); + let c = p.comp_mut(cid).unwrap(); + c.layers = vec![text, shape, bg]; + (p, cid) +} + +fn assert_same(a: &crate::Image, b: &crate::Image, what: &str) { + assert_eq!((a.width, a.height), (b.width, b.height), "{what}"); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-6, "{what}: pixel {i}: {p:?} vs {q:?}"); + } + } +} + +#[test] +fn cached_frames_match_uncached() { + let (p, cid) = cache_scene(); + let cache = crate::LayerCache::default(); + for f in [0.0, 0.25, 0.5, 0.5, 1.2, 1.5, 0.25] { + let t = Tick::from_seconds_f64(f); + assert_same(&render_cached(&p, cid, t, Some(&cache)), &render_cached(&p, cid, t, None), &format!("t={f}")); + } + // bg is static, text only moves (transform), the shape is static after 1 s: lots of reuse. + let st = cache.stats(); + assert!(st.hits >= 10, "{st:?}"); +} + +#[test] +fn static_and_transform_only_layers_are_reused() { + let (p, cid) = cache_scene(); + let cache = crate::LayerCache::default(); + render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache)); + let before = cache.stats(); + render_cached(&p, cid, Tick::from_seconds_f64(0.4), Some(&cache)); + let after = cache.stats(); + // bg (static) + text (only its position animates) hit; the shape's trim animates: miss. + assert_eq!(after.hits - before.hits, 2, "{before:?} {after:?}"); + assert_eq!(after.misses - before.misses, 1, "{before:?} {after:?}"); +} + +type Edit = Box; + +/// Every kind of edit re-renders the affected layer: the cached render of the edited project +/// equals a fresh uncached render, and differs from the unedited frame. +#[test] +fn edits_invalidate_cached_layers() { + let (p, cid) = cache_scene(); + let t = Tick::from_seconds_f64(0.5); + let edits: Vec<(&str, Edit)> = vec![ + ( + "effect param value", + Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.prop_mut("effects/#1/white").unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0])), + ), + ( + "effect param keyframes", + Box::new(|p, cid| { + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/blurriness").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(30.0))] + }), + ), + ("effect disabled", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false)), + ("effects switch off", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[0].switches.effects = false)), + ( + "effect added", + Box::new(|p, cid| { + let mut l = p.comp_mut(cid).unwrap().layers[2].clone(); + add_effect(p, &mut l, "ec.channel.invert", &[]); + p.comp_mut(cid).unwrap().layers[2] = l; + }), + ), + ( + "shape keyframe moved", + Box::new(|p, cid| { + p.comp_mut(cid).unwrap().layers[1].props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys[1].time = Tick::from_seconds_f64(2.0) + }), + ), + ( + "text changed", + Box::new(|p, cid| { + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("text/sourceText").unwrap().value = + Value::Text(Box::new(TextDoc { text: "XYZ".into(), size: 40.0, ..Default::default() })) + }), + ), + ( + "solid colour", + Box::new(|p, cid| { + let LayerSource::Solid { item } = p.comp(cid).unwrap().layers[2].source else { unreachable!() }; + if let ItemKind::Solid(s) = &mut p.item_mut(item).unwrap().kind { + s.color = [0.9, 0.1, 0.1]; + } + p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false; + }), + ), + ( + "mask added", + Box::new(|p, cid| { + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[2].props.sub_mut("masks").unwrap().children.push(m.into()); + }), + ), + ("layer time shifted", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[1].start_time = Tick::from_seconds_f64(0.3))), + ]; + for (what, edit) in edits { + let cache = crate::LayerCache::default(); + let orig = render_cached(&p, cid, t, Some(&cache)); + let mut q = p.clone(); + edit(&mut q, cid); + let cached = render_cached(&q, cid, t, Some(&cache)); + let fresh = render_cached(&q, cid, t, None); + assert_same(&cached, &fresh, what); + assert!(orig.data.iter().zip(&fresh.data).any(|(a, b)| (a[0] - b[0]).abs() + (a[3] - b[3]).abs() > 1e-3), "{what}: edit had no visible effect"); + } +} + +#[test] +fn time_dependent_effects_rerender_every_frame() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100); + add_effect(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let cache = crate::LayerCache::default(); + let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache)); + let b = render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache)); + assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2"); + assert!(a != b, "noise must animate"); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 3f20b2ec..aa90e22c 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{ItemId, Project}; -use effectcraft_engine::render::{ExprHost, FootageSource, RenderOpts, Renderer}; +use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; +use rayon::prelude::*; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct FrameKey { @@ -49,14 +50,18 @@ pub struct RenderSource { pub project: Arc, pub footage: Arc, pub expr: Option>, + pub layer_cache: Arc, } pub struct Frames { cache: Arc>, + /// Keys queued or rendering. inflight: Arc>>, + /// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first). + queue: Arc>, pool: rayon::ThreadPool, ctx: Option, - /// Last render time per frame (ms), for the Info panel / perf readout. + /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, } @@ -66,6 +71,7 @@ impl Default for Frames { Frames { cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, budget: 3 << 30 })), inflight: Arc::new(Mutex::new(HashSet::new())), + queue: Arc::new(Mutex::new(Queue::default())), pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"), ctx: None, last_ms: Arc::new(Mutex::new(0.0)), @@ -78,7 +84,7 @@ impl Default for Frames { pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorImage { let px: Vec = img .data - .iter() + .par_iter() .map(|p| { let a = p[3].clamp(0.0, 1.0); let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8; @@ -123,36 +129,92 @@ impl Frames { } } - /// Queue a frame (no-op if cached or already rendering). + /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, false); + } + + /// Queue the frame the viewer is showing: it jumps ahead of every prefetch job. + pub fn request_urgent(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, true); + } + + /// An urgent (viewer) frame is queued or rendering. + pub fn urgent_pending(&self) -> bool { + self.queue.lock().map(|q| q.urgent_running > 0 || q.jobs.iter().any(|j| j.urgent)).unwrap_or(false) + } + + fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { if self.is_cached(&key) { return; } + let Ok(mut q) = self.queue.lock() else { return }; + // Jobs for an older project revision can never be shown: drop them. + let before = q.jobs.len(); + q.jobs.retain(|j| j.key.revision >= key.revision); + let dropped = before - q.jobs.len(); + if dropped > 0 + && let Ok(mut inf) = self.inflight.lock() + { + inf.retain(|k| k.revision >= key.revision || q.running.contains(k)); + } + if urgent { + // Only the newest viewer frame is urgent; earlier ones become prefetch. + for j in q.jobs.iter_mut() { + j.urgent = j.key == key; + } + } { let Ok(mut inf) = self.inflight.lock() else { return }; if !inf.insert(key) { return; } } - let src = src.clone(); + q.seq += 1; + let seq = q.seq; + q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq }); + drop(q); + let queue = self.queue.clone(); let cache = self.cache.clone(); let inflight = self.inflight.clone(); let ctx = self.ctx.clone(); let last = self.last_ms.clone(); + // One spawn per job; each spawn renders whichever queued job matters most right now. self.pool.spawn(move || { + let job = { + let Ok(mut q) = queue.lock() else { return }; + // Urgent first (newest), then prefetch in request order. + let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i); + let Some(i) = best else { return }; + let job = q.jobs.swap_remove(i); + q.running.insert(job.key); + if job.urgent { + q.urgent_running += 1; + } + job + }; let t0 = std::time::Instant::now(); - let mut r = Renderer::new(&src.project, src.footage.as_ref(), opts); - r.expr = src.expr.as_deref(); - let img = r.comp_frame(comp, t); + let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + r.expr = job.src.expr.as_deref(); + r.cache = Some(&job.src.layer_cache); + let img = r.comp_frame(job.comp, job.t); let ci = Arc::new(to_color_image(&img)); - if let Ok(mut l) = last.lock() { + if job.urgent + && let Ok(mut l) = last.lock() + { *l = t0.elapsed().as_secs_f64() * 1000.0; } if let Ok(mut c) = cache.lock() { - c.insert(key, ci); + c.insert(job.key, ci); } if let Ok(mut inf) = inflight.lock() { - inf.remove(&key); + inf.remove(&job.key); + } + if let Ok(mut q) = queue.lock() { + q.running.remove(&job.key); + if job.urgent { + q.urgent_running -= 1; + } } if let Some(ctx) = ctx { ctx.request_repaint(); @@ -160,3 +222,21 @@ impl Frames { }); } } + +struct Job { + key: FrameKey, + src: RenderSource, + comp: ItemId, + t: Tick, + opts: RenderOpts, + urgent: bool, + seq: u64, +} + +#[derive(Default)] +struct Queue { + jobs: Vec, + running: HashSet, + urgent_running: usize, + seq: u64, +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d2..45e2239e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,7 +162,12 @@ impl EffectcraftApp { } pub fn render_source(&self) -> RenderSource { - RenderSource { project: self.session.project.clone(), footage: self.session.footage.clone(), expr: self.session.expr.clone() } + RenderSource { + project: self.session.project.clone(), + footage: self.session.footage.clone(), + expr: self.session.expr.clone(), + layer_cache: self.session.layer_cache.clone(), + } } /// The render scale used by the viewer right now. @@ -178,13 +183,26 @@ impl EffectcraftApp { FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32 } } - /// Request a frame render (no-op if cached/in flight). + /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, false); + } + + /// Request the frame on screen: rendered before any queued prefetch. + pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, true); + } + + fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview }; - self.frames.request(&self.render_source(), key, comp, t, opts); + if urgent { + self.frames.request_urgent(&self.render_source(), key, comp, t, opts); + } else { + self.frames.request(&self.render_source(), key, comp, t, opts); + } } // ---------------------------------------------------------------- playback @@ -226,7 +244,15 @@ impl EffectcraftApp { let (wa, wb) = (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); - let ahead = (self.frames_parallelism() * 2).max(4) as i64; + // While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few + // frames, and only once it is done, so prefetch never delays what is on screen. + let ahead = if self.playback.playing { + (self.frames_parallelism() * 2).max(4) as i64 + } else if self.frames.urgent_pending() { + 0 + } else { + (self.frames_parallelism() / 2).max(2) as i64 + }; let mut queued = self.frames.inflight(); for k in 0..ahead * 3 { if queued >= ahead as usize { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b1ae92af..b5915867 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -184,11 +184,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Frame. let ppp = ctx.pixels_per_point(); let scale = app.viewer_scale(zoom, ppp); + if !app.playback.playing { + // Queue the frame on screen before any prefetch. + app.request_frame_urgent(cid, comp.frame_rate.frame_at(app.session.time()), scale); + } crate::tick_playback(app, &ctx, scale); let time = app.session.time(); let frame = comp.frame_rate.frame_at(time); let key = app.frame_key(cid, frame, scale); - app.request_frame(cid, frame, scale); + app.request_frame_urgent(cid, frame, scale); if let Some(img) = app.frames.get(&key) { let stale = app.viewer_tex.as_ref().is_none_or(|(_, k)| *k != key); if stale {