From fb715d9015503d22b1da8ac511857e6fd485d35f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:32:56 +1100 Subject: [PATCH 1/3] Render: drop the blanket layer cover from effect stacks; effects pay only what they need (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #659 every text or shape layer with an enabled video effect grew its effect buffer to the comp-sized layer rectangle before the stack ran, so glow and blur — which only need their own margins — allocated a 1920x1080 RGBA32F texture per layer per frame in flight (~5 GB peak VRAM in parallel export, measured on an RTX 3090). Effects that actually place pixels outside the painted content already grow the buffer themselves by exactly what they need (CC Tiler, CC Smear, CC Mosaic sharp via layer_cover_pad / Buf::pad), on CPU and GPU. The blanket target.cover(layer_size) in Renderer::run_effects_on, the FxTarget::cover trait method with its CPU default, and the GPU GpuTarget::cover texture allocation are removed; Buf::cover/cover_plan stay as the effects-crate helpers they were designed as. The #659 regression tests (tiling across the layer region, centred-shape edge pinning) and the render/effects/gpu suites pass unchanged. --- crates/gpu/src/effects.rs | 15 --------------- crates/render/src/lib.rs | 24 +++++------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..426931d78 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -145,21 +145,6 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } - - /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no - /// readback. A region the device can't allocate leaves the buffer as it is. - fn cover(&mut self, size: [f64; 2]) { - let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } - } - _ => b, - }; - self.b = Some(out); - } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..d9c87af9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,16 +490,6 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); - /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own - /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. - /// Authored buffers (text, shapes) only span their painted content; this gives the stack - /// the room a sized source's buffer already has (#659). - fn cover(&mut self, size: [f64; 2]) { - self.cpu(&mut |mut buf| { - buf.cover(size); - buf - }); - } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1132,15 +1122,11 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); - } + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From 9a202b133ae15a232090e2468143a351e8c1e7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:53:10 +0200 Subject: [PATCH 2/3] Render: effect stacks keep the cover's pixel-grid phase, only known-margin stacks skip the cover, and displacement and Repeat Edge Pixels get the room the cover gave them (#1205) --- crates/effects/src/distort2.rs | 3 ++ crates/effects/src/lib.rs | 53 ++++++++++++++++++++++++++++++++++ crates/effects/src/misc.rs | 6 ++++ crates/gpu/src/effects.rs | 46 +++++++++++++++++++++++++++++ crates/gpu/src/fx_distort.rs | 3 ++ crates/render/src/lib.rs | 38 +++++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..4f09b95e2 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -93,6 +93,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..a968f1aad 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -286,6 +286,32 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + if left == 0 && top == 0 { + return; + } + let src = std::mem::replace(&mut self.img, Image::new(0, 0)); + let (w, h) = (src.width + left, src.height + top); + let mut out = Image::new(w, h); + let sw = src.width as usize; + for y in 0..src.height as usize { + let d = (y + top as usize) * w as usize + left as usize; + out.data[d..d + sw].copy_from_slice(&src.data[y * sw..(y + 1) * sw]); + } + self.img = out; + self.offset[0] += left as f64; + self.offset[1] += top as f64; + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { @@ -301,6 +327,33 @@ impl Buf { /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer /// already covers that region unchanged (footage, solids, the comp below an adjustment). +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() { return None; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..fed4ce45c 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); b @@ -49,6 +52,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((r * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d78..4bf503940 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -23,6 +23,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -145,6 +159,32 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } + + /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no + /// readback. A region the device can't allocate leaves the buffer as it is. + fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { + let Some(b) = self.b.take() else { return }; + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } + } + _ => b, + }; + self.b = Some(out); + } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { @@ -249,6 +289,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -264,6 +307,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index a62ad6831..e4e17c64c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -177,6 +177,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9c87af9f..041052b8e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,6 +490,24 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1126,7 +1144,25 @@ impl<'a> Renderer<'a> { // place pixels outside it grow the buffer themselves, by exactly what they need // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, - // per frame in flight, for glow and blur that never use the extra room. + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { + Some(effect.as_str()) + } + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + target.align(layer_size); + } else { + target.cover(layer_size); + } + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From b51c91a126e9a3a100057bd51132f7060d6a0c63 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 12:14:27 +1100 Subject: [PATCH 3/3] Render: ask the effect spec whether a stack can skip the layer cover Matej's follow-up kept the known-margin set as SKIPS_COVER and the renderer looked the ids up. Ask EffectSpec::skips_cover instead so a new margin effect opts in on the spec rather than in run_effect_stack. Tests pin align_plan to cover_shift mod 64 and that Twirl still takes the full cover. --- crates/effects/src/lib.rs | 29 +++++++++++++++++++++++++++++ crates/render/src/lib.rs | 6 ++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a968f1aad..cf59a56c8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -514,6 +514,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1504,4 +1513,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 041052b8e..313cb73ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1150,14 +1150,12 @@ impl<'a> Renderer<'a> { // get the cover: they move content past the painted bounds without growing the buffer. let video = || { fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { - GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { - Some(effect.as_str()) - } + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), _ => None, }) }; if !adjustment && video().next().is_some() { - if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + if video().all(|s| s.skips_cover()) { target.align(layer_size); } else { target.cover(layer_size);