From eec81bbe0e424c2480e2b32060f4478e39a77dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:20 +0200 Subject: [PATCH 1/4] Render: an adjustment layer's footprint key ignores its effects, so a time-dependent effect (Noise) no longer re-keys it and the GPU re-uploads it every frame (#687) --- crates/gpu/src/tests.rs | 41 +++++++++++++++++++++++++ crates/render/src/cache.rs | 13 +++++--- crates/render/src/cache/dependencies.rs | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 784a393d..f7111cd4 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -857,3 +857,44 @@ fn readback_staging_stays_bounded_across_sizes() { let held = g.ctx.staging_bytes(); assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes"); } + +/// A static adjustment layer with a time-dependent effect (Noise) keeps its footprint key +/// across frames (the footprint is the source through the masks, not the effects), so the GPU +/// uploads nothing after the first frame; matches the CPU. +#[test] +fn adjustment_footprint_uploads_once_under_time_dependent_effects() { + let mut s = Scene::new(BitDepth::Bpc8); + let bg = s.solid([0.3, 0.5, 0.7], 96, 64); + s.push(bg); + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.noise.noise", &[("amount", n(30.0))]); + s.push(adj); + // The key itself (no GPU needed): stable across time. + let comp = s.p.comp(s.cid).expect("the scene's comp"); + let adj = comp.layers.last().expect("the adjustment layer"); + let key = |k: f64| { + effectcraft_render::cache::footprint_key(&effectcraft_render::EvalCtx::new(&s.p, s.cid, comp, Tick::from_seconds_f64(k)), adj, 1.0, false, false) + }; + assert!(key(0.0).is_some()); + assert_eq!(key(0.0), key(0.5), "the footprint key changes with time"); + let Some(g) = gpu() else { return }; + let cache = effectcraft_render::cache::LayerCache::new(64 << 20); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(&cache); + let t = |k: u64| Tick::from_seconds_f64(k as f64 / 30.0); + let mut prev = None; + for k in 0..4 { + let before = g.ctx.transfer_stats().upload_bytes; + let img = g.render(&r, s.cid, t(k)).expect("the GPU renders the frame"); + let up = g.ctx.transfer_stats().upload_bytes - before; + if k >= 2 { + assert_eq!(up, 0, "frame {k} uploaded {up} bytes: the footprint was re-keyed"); + } + // Noise changes every frame: the frames differ. + assert!(prev.as_ref() != Some(&img.data), "frame {k} repeats the previous one"); + prev = Some(img.data); + check(&format!("noise adjustment frame {k}"), compare_at(&s, opts(), t(k)), 0.0); + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 88fa5c7c..bae12d1e 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -423,8 +423,10 @@ fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) { /// 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 +/// `effects: false` ignores the effects (a key of the source and masks only). +fn time_dependent(layer: &Layer, effects: bool) -> bool { + if effects + && layer.switches.effects && let Some(fx) = layer.effects() { for g in fx.groups() { @@ -522,7 +524,10 @@ fn has_expression(g: &PropGroup) -> bool { /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { - Some(derive(key_any(ctx, layer, scale, draft, blur, false, None)?, 0xf007_9417)) + // The footprint is the source through the masks, without the effects: a time-dependent + // effect (Noise on a finishing adjustment layer) must not re-key it every frame (on the GPU, + // a full-frame re-upload per frame). + Some(derive(key_any(ctx, layer, scale, draft, blur, false, Some(0))?, 0xf007_9417)) } fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { @@ -620,7 +625,7 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } - if time_dependent(layer) { + if time_dependent(layer, effects != Some(0)) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. layer.start_time.0.hash(&mut h); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 8ddee7a9..eaccd058 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -78,7 +78,7 @@ impl Dependencies<'_> { hash_debug(self.h, layer); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); - if super::time_dependent(layer) { + if super::time_dependent(layer, true) { layer.layer_time(ctx.time).0.hash(self.h); } match layer.source { From 9c5c1872928f3f9ec0ce6d1123bbe4e1ec78b63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:01:00 +0200 Subject: [PATCH 2/4] GPU: an Enc reuses the working textures it released at once instead of allocating new ones for every intermediate of a frame (#687) --- crates/gpu/src/context.rs | 69 ++++++++++++++++++++++++++++++++------- crates/gpu/src/tests.rs | 44 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 11f28c70..793c4d9e 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -71,6 +71,8 @@ impl GpuImage { struct Lease { texture: wgpu::Texture, pool: Weak>, + /// The [`Enc`] that took it (see [`Pool::owned`]). + owner: u64, } impl std::fmt::Debug for Lease { @@ -84,7 +86,7 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { - p.release(self.texture.clone()); + p.release_owned(self.texture.clone(), self.owner); } } } @@ -108,8 +110,18 @@ pub(crate) struct Pool { /// Working textures reused / newly allocated (diagnostics, tests). hits: u64, misses: u64, + /// Textures released while the [`Enc`] that took them is alive, kept for that `Enc` only. + /// Its later commands run after everything it recorded or submitted before (one queue, + /// recording order), so it can overwrite them at once instead of allocating a new texture + /// for every intermediate of a frame (a 4K frame holds dozens). Other encoders never see a + /// working texture of another `Enc` before it is gone. + owned: HashMap>>, + next_owner: u64, } +/// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. +pub(crate) static TEXTURE_REUSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + /// Counts of uploads (CPU → GPU) and readbacks (GPU → CPU). #[derive(Default)] pub(crate) struct Transfers { @@ -178,6 +190,37 @@ impl Pool { } } + fn register(&mut self) -> u64 { + self.next_owner += 1; + if TEXTURE_REUSE.load(std::sync::atomic::Ordering::Relaxed) { + self.owned.insert(self.next_owner, HashMap::new()); + } + self.next_owner + } + + /// `Enc` `owner` is gone: its kept textures go through the shared pool. + fn unregister(&mut self, owner: u64) { + if let Some(m) = self.owned.remove(&owner) { + for t in m.into_values().flatten() { + self.release(t); + } + } + } + + fn release_owned(&mut self, t: wgpu::Texture, owner: u64) { + if let Some(m) = self.owned.get_mut(&owner) { + m.entry((t.width(), t.height())).or_default().push(t); + return; + } + self.release(t); + } + + fn take_owned(&mut self, owner: u64, w: u32, h: u32) -> Option { + let t = self.owned.get_mut(&owner)?.get_mut(&(w, h))?.pop()?; + self.hits += 1; + Some(t) + } + fn take(&mut self, w: u32, h: u32) -> Option { let t = self.free.get_mut(&(w, h)).and_then(Vec::pop); match &t { @@ -817,12 +860,12 @@ impl GpuContext { /// A pooled working texture: (image, reused). A reused texture holds old pixels; a new /// one is zeroed. - fn pooled(&self, w: u32, h: u32) -> (GpuImage, bool) { + fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) { let (w, h) = (w.max(1), h.max(1)); - let reused = self.pool.lock().ok().and_then(|mut p| p.take(w, h)); + let reused = self.pool.lock().ok().and_then(|mut p| p.take_owned(owner, w, h).or_else(|| p.take(w, h))); let was_reused = reused.is_some(); let texture = reused.unwrap_or_else(|| self.texture(w, h)); - let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool) }); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner }); (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } @@ -923,22 +966,26 @@ pub(crate) struct Enc<'g> { pending: usize, /// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]). missing: bool, + /// This `Enc`'s id in the texture pool (textures it may reuse at once). + owner: u64, } impl Drop for Enc<'_> { fn drop(&mut self) { // Unsubmitted work is discarded, so nothing recorded will read pooled textures. - if let Some((_, id)) = self.enc.take() - && let Ok(mut p) = self.g.pool.lock() - { - p.close(id); + if let Ok(mut p) = self.g.pool.lock() { + if let Some((_, id)) = self.enc.take() { + p.close(id); + } + p.unregister(self.owner); } } } impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { - Enc { g, enc: None, pending: 0, missing: false } + let owner = g.pool.lock().map(|mut p| p.register()).unwrap_or(0); + Enc { g, enc: None, pending: 0, missing: false, owner } } /// Whether a dispatch since the last call named a kernel the device doesn't have (one @@ -960,7 +1007,7 @@ impl<'g> Enc<'g> { /// A new zeroed image (pooled; a reused texture is cleared by a kernel). pub fn image(&mut self, w: u32, h: u32) -> GpuImage { - let (img, reused) = self.g.pooled(w, h); + let (img, reused) = self.g.pooled(w, h, self.owner); if reused { self.fill(&img, 0.0); } @@ -970,7 +1017,7 @@ impl<'g> Enc<'g> { /// A new image for a kernel that writes every pixel of it (pooled; contents undefined). /// Under test it starts out as NaNs, so a kernel that skips pixels fails the CPU oracle. pub fn scratch(&mut self, w: u32, h: u32) -> GpuImage { - let (img, _) = self.g.pooled(w, h); + let (img, _) = self.g.pooled(w, h, self.owner); if cfg!(test) { self.fill(&img, f32::NAN); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index f7111cd4..fcebcd44 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -898,3 +898,47 @@ fn adjustment_footprint_uploads_once_under_time_dependent_effects() { check(&format!("noise adjustment frame {k}"), compare_at(&s, opts(), t(k)), 0.0); } } + +/// An `Enc` reuses a working texture it released at once (`Pool::owned`); another `Enc` does +/// not get it while the first is alive. +#[test] +fn enc_reuses_its_own_released_textures() { + let Some(g) = gpu() else { return }; + let mut e = crate::context::Enc::new(&g.ctx); + let a = e.image(37, 23); + let ta = a.texture.clone(); + drop(a); + let mut other = crate::context::Enc::new(&g.ctx); + let o = other.image(37, 23); + assert!(o.texture != ta, "another Enc got a texture the first may still be using"); + let b = e.image(37, 23); + assert!(b.texture == ta, "the Enc allocated a new texture instead of reusing its own"); +} + +/// In-`Enc` texture reuse changes no pixel: a busy frame (layers, effects, adjustment layer, +/// nested comp) renders bit-identically with reuse on and off, and matches the CPU. +#[test] +fn texture_reuse_does_not_change_output() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc32); + for i in 0..4 { + let mut l = s.footage(90 - i * 7, 60 - i * 5); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(2.0 + i as f64))]); + set(&mut l, "transform/rotation", Value::Scalar(10.0 * i as f64)); + l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Multiply }; + s.push(l); + } + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.color.levels", &[("gamma", n(1.2))]); + s.push(adj); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let t = Tick::from_seconds_f64(0.3); + let on = g.render(&r, s.cid, t).expect("the GPU renders the frame"); + crate::context::TEXTURE_REUSE.store(false, std::sync::atomic::Ordering::Relaxed); + let off = g.render(&r, s.cid, t); + crate::context::TEXTURE_REUSE.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(off.is_some_and(|off| off.data == on.data), "texture reuse changed the frame"); + check("texture reuse vs CPU", compare_at(&s, opts(), t), 0.0); +} From d8ff1189dcd1177f30601d506f297e92daac983f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:38 +0200 Subject: [PATCH 3/4] GPU: the upload cache budget can be set per context and warns once when full, instead of evicting layer textures silently (#687) --- crates/gpu/src/context.rs | 29 ++++++++++++++++++++++++++--- crates/gpu/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 793c4d9e..51221abc 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -37,7 +37,7 @@ const EXT_ENTRIES: &[&str] = &["classic3d"]; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; -/// Uploaded layer buffers kept on the GPU (bytes). +/// Uploaded layer buffers kept on the GPU by default (bytes; [`GpuContext::set_upload_budget`]). const UPLOAD_BUDGET: usize = 1536 << 20; /// Working textures kept for reuse once their last image is dropped (bytes). @@ -296,6 +296,8 @@ pub struct GpuContext { dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + upload_budget: std::sync::atomic::AtomicUsize, + upload_full_warned: std::sync::atomic::AtomicBool, pool: Arc>, /// CPU ↔ GPU traffic (diagnostics: `bench --gpu`). pub(crate) transfers: Transfers, @@ -641,6 +643,8 @@ impl GpuContext { dummy, dummy_buf, uploads: Mutex::new(Uploads::default()), + upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), + upload_full_warned: Default::default(), pool: Default::default(), staging: Default::default(), transfers: Default::default(), @@ -869,6 +873,17 @@ impl GpuContext { (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } + /// Bytes of uploaded layer textures the context keeps (default 1536 MB). An application that + /// knows the device's memory can raise it (static layers of a big frame upload once) or lower + /// it (a small GPU); the least recently used textures go first. + pub fn set_upload_budget(&self, bytes: usize) { + self.upload_budget.store(bytes.max(1 << 20), std::sync::atomic::Ordering::Relaxed); + } + + pub fn upload_budget(&self) -> usize { + self.upload_budget.load(std::sync::atomic::Ordering::Relaxed) + } + /// Uploads and readbacks so far (images; uploads of cached layers count once). pub fn transfer_stats(&self) -> TransferStats { use std::sync::atomic::Ordering::Relaxed; @@ -925,7 +940,8 @@ impl GpuContext { } u.bytes += bytes; // Drop textures of freed buffers, then the least recently used over budget. - if u.bytes > UPLOAD_BUDGET { + let budget = self.upload_budget(); + if u.bytes > budget { let dead: Vec = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect(); for k in dead { if let Some(e) = u.map.remove(&k) { @@ -933,7 +949,14 @@ impl GpuContext { } } } - while u.bytes > UPLOAD_BUDGET { + if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!( + target: "effectcraft_gpu", + "GPU upload cache full ({} MB): evicting layer textures, which upload again when next used; a frame whose static layers exceed it re-uploads them every frame", + budget >> 20 + ); + } + while u.bytes > budget { let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; if let Some(e) = u.map.remove(&k) { u.bytes -= e.bytes; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index fcebcd44..426899c7 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -942,3 +942,40 @@ fn texture_reuse_does_not_change_output() { assert!(off.is_some_and(|off| off.data == on.data), "texture reuse changed the frame"); check("texture reuse vs CPU", compare_at(&s, opts(), t), 0.0); } + +/// Static layer buffers (here adjustment-layer footprints) upload once while they fit the upload budget, and upload again every frame +/// when they do not (least recently used first); the frame is the same either way. +#[test] +fn upload_budget_bounds_the_layer_textures_kept() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc8); + for (i, c) in [[0.8, 0.2, 0.1], [0.1, 0.3, 0.9]].into_iter().enumerate() { + let mut l = s.solid(c, 200, 200); + set(&mut l, "transform/position", v3(60.0 + 30.0 * i as f64, 40.0)); + // Adjustment layers: their footprints are cached layer buffers the GPU uploads. + l.switches.adjustment = true; + s.effect(&mut l, "ec.color.levels", &[("gamma", n(1.1 + 0.2 * i as f64))]); + s.push(l); + } + let frame = |cache: &effectcraft_render::cache::LayerCache, k: u64| { + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(cache); + let before = g.ctx.transfer_stats().upload_bytes; + let img = g.render(&r, s.cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame"); + (img.data, g.ctx.transfer_stats().upload_bytes - before) + }; + let fits = effectcraft_render::cache::LayerCache::new(64 << 20); + let (a, _) = frame(&fits, 0); + let (b, up) = frame(&fits, 1); + assert_eq!(up, 0, "static layers re-uploaded within the default budget"); + // A budget smaller than the two 640 KB footprints: each upload evicts the other. + let default = g.ctx.upload_budget(); + g.ctx.set_upload_budget(1 << 20); + let tight = effectcraft_render::cache::LayerCache::new(64 << 20); + let _ = frame(&tight, 0); + let (c, up) = frame(&tight, 1); + g.ctx.set_upload_budget(default); + assert!(up > 0, "over budget, the footprints must upload again"); + assert!(a == b && b == c, "the upload budget changed the frame"); +} From bc6a145203ae3ccac504748808232765cde1c229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:09:22 +0200 Subject: [PATCH 4/4] GPU: a layer's effect stack runs resident on the GPU and is placed directly, instead of a readback and re-upload around every GPU effect run (#687) --- crates/gpu/src/tests.rs | 65 ++++++++++++++++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 41 +++++++++++++++++++++++++ crates/render/src/lib.rs | 13 ++++++++ 3 files changed, 119 insertions(+) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 426899c7..696402d6 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -979,3 +979,68 @@ fn upload_budget_bounds_the_layer_textures_kept() { assert!(up > 0, "over budget, the footprints must upload again"); assert!(a == b && b == c, "the upload budget changed the frame"); } + +/// Layers with effect stacks run them resident on the GPU (`walk::draw_resident_fx`): GPU +/// effects, a CPU-only effect in the middle (read back, run, upload), opacity and a blend mode, +/// a shape layer whose effects run in effect space from its bounds' corner; 8 and 32 bpc, full +/// and half resolution; all match the CPU. +#[test] +fn effect_stacks_render_resident() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut a = s.footage(70, 44); + s.effect(&mut a, "ec.blur.gaussian", &[("blurriness", n(4.0))]); + s.effect(&mut a, "ec.generate.fractal", &[]); + s.effect(&mut a, "ec.color.levels", &[("gamma", n(1.4))]); + set(&mut a, "transform/rotation", Value::Scalar(20.0)); + set(&mut a, "transform/opacity", Value::Scalar(70.0)); + a.blend_mode = BlendMode::Overlay; + s.push(a); + let mut l = build::layer(&mut s.p, &s.comp, "Shape", LayerSource::Shape, (97, 61), None); + let mut next = s.p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [50.0, 30.0], [5.0, -4.0], 0.0); + let fill = build::shape_fill(&mut ids, [0.2, 0.8, 0.3, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + s.p.next_id = next; + l.props.sub_mut("contents").expect("a shape layer has contents").children.push(g.into()); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + s.effect(&mut l, "ec.stylize.mosaic", &[]); + s.push(l); + let t = Tick::from_seconds_f64(0.2); + check(&format!("effect stacks {depth:?}"), compare_at(&s, opts(), t), 0.0); + check(&format!("effect stacks half {depth:?}"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } +} + +/// A layer whose effects give the same result as last frame draws from the layer cache (its +/// processed buffer uploads once) instead of re-running the stack on the GPU every frame. +#[test] +fn static_effect_layers_draw_from_the_cache() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc8); + let mut l = s.solid([0.6, 0.4, 0.2], 80, 50); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + s.push(l); + let cache = effectcraft_render::cache::LayerCache::new(64 << 20); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(&cache); + let comp = s.p.comp(s.cid).expect("the scene's comp"); + let layer = comp.layers.last().expect("the layer"); + let t = |k: u64| Tick::from_seconds_f64(k as f64 / 30.0); + assert!(r.layer_is_static(&effectcraft_render::EvalCtx::new(&s.p, s.cid, comp, t(2)), layer)); + let mut frames = Vec::new(); + for k in 0..3 { + let before = g.ctx.transfer_stats().upload_bytes; + frames.push(g.render(&r, s.cid, t(k)).expect("the GPU renders the frame").data); + let up = g.ctx.transfer_stats().upload_bytes - before; + if k == 2 { + assert_eq!(up, 0, "the cached layer uploaded again"); + } + } + assert!(frames[1] == frames[2]); + check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 83cb8550..d0c01732 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -217,6 +217,9 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & if let Some(done) = draw_precomp(e, r, ctx, layer, canvas, opacity) { return done; } + if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) { + return done; + } let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) }; if st.plain { return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity); @@ -246,6 +249,44 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// A plain layer with effects, its effect stack run on the GPU: the source (+ masks, from the +/// layer cache) uploads once while unchanged, the effects stay resident and the result is +/// placed directly; the CPU path instead runs each GPU-capable effect run through an +/// upload → kernels → readback, then uploads the result again to place it. `None`: not this +/// kind of layer (draw it the usual way); `Some(None)`: the GPU failed. +fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option> { + if !layer.switches.effects + || layer.effects().is_none_or(|f| f.children.is_empty()) + || styles::active(ctx, layer) + || layer.preserve_transparency + || r.track_matte(ctx, layer).is_some() + // Unchanged since the last frame: the cached result uploads once (the usual path). + || r.layer_is_static(ctx, layer) + { + return None; + } + let input = r.layer_input(ctx, layer, 0)?; + if input.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_buf(&input) else { return Some(None) }; + let pipe = r.pipe(); + // `layer_input` is in effect space: run the stack there, then back to layer space. + let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels); + let origin = r.run_layer_effects_on(ctx, layer, &mut fx); + let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) }; + offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale]; + if img.width == 0 || img.height == 0 { + return Some(Some(())); + } + if let Some(c) = pipe.to_blend() { + img = ops::convert(e, &img, &c); + } + // The placement only reads the buffer's size, offset and scale. + let shape = Buf { img: Image { width: img.width, height: img.height, data: Vec::new() }, offset, scale }; + Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c)) +} + /// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level /// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source` /// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a77b7c04..b15fc019 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -761,6 +761,19 @@ impl<'a> Renderer<'a> { /// always continuously rasterised, and Continuously Rasterize shape layers and vector footage — /// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so /// vector content is drawn at its final size and stays sharp when scaled up). + /// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as + /// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats + /// re-running its effects every frame. + pub fn layer_is_static(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + if self.cache.is_none() { + return false; + } + let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); + let prev = ctx.time - ctx.comp.frame_rate.frame_duration(); + let now = cache::layer_key(ctx, layer, scale, draft, blur); + now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur) + } + pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; let continuous = match layer.source {