Effects: text and shape layers run their stack over the whole layer region (#659).

An authored layer's effect buffer spanned only its painted content — the glyph
bounding box for text, the drawn bounds for shapes — while the size the stack
measures from (effect_bounds) is the whole comp-sized layer rectangle. Tiling
and displacement effects computed correct output for pixels outside the painted
rectangle and then wrote them into the buffer that was too small, so CC Tiler
repeated only within the text box and CC Smear lost its liquid trail at the
glyph edge (#658). Buf::cover now grows the stack's input to the layer's
rectangle with transparent margins — what a sized source's buffer already is —
applied by run_effect_stack whenever an enabled video effect will run, and
natively on the GPU by reallocating and copying the texture. The placement and
the sized-source paths are unchanged; two regression tests (CC Tiler spans the
region, CC Smear trails past the text bounds) fail without the growth.
This commit is contained in:
Vikram Bhaskaran 2026-10-11 12:25:29 +11:00
parent f953cd2855
commit fa733bfb04
No known key found for this signature in database
GPG Key ID: 5CFB5F821425464B
4 changed files with 121 additions and 0 deletions

View File

@ -264,6 +264,21 @@ impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
pub fn cover(&mut self, size: [f64; 2]) {
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
let src = std::mem::replace(&mut self.img, Image::new(w, h));
let (sw, sh) = (src.width as usize, src.height as usize);
for y in 0..sh {
let s = y * sw;
let d = (y + dy as usize) * w as usize + dx as usize;
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
}
self.offset[0] += dx as f64;
self.offset[1] += dy as f64;
}
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
pub fn rebase(&mut self, origin: [f64; 2]) {
@ -281,6 +296,27 @@ impl Buf {
}
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `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).
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;
}
// Region and content, both as pixel ranges in the buffer's own coordinates.
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
return None;
}
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {

View File

@ -145,6 +145,21 @@ 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<GBuf> {

View File

@ -377,6 +377,16 @@ 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).
@ -991,6 +1001,13 @@ 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.
if 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);
}
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()) {

View File

@ -597,6 +597,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
}
}
/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs
/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect
/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the
/// probe is the x-span of the painted pixels, not their count.
#[test]
fn effects_on_text_reach_past_the_text_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
let painted = plain.data.iter().filter(|px| px[3] > 0.5).count();
add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]);
p.comp_mut(cid).unwrap().layers = vec![l];
let tiled = render_frame(&p, cid, Tick::ZERO, 1.0);
let (mut lo, mut hi) = (u32::MAX, 0u32);
for y in 0..100 {
for x in 0..200 {
if tiled.get(x as i64, y as i64)[3] > 0.5 {
lo = lo.min(x);
hi = hi.max(x);
}
}
}
assert!(
painted > 50 && hi.saturating_sub(lo) > 100,
"CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
);
}
/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// the painted rectangle used to fall off its edge, so the liquid trail never appeared.
#[test]
fn cc_smear_trails_text_past_its_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0);
let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
add_effect_200(
&mut p,
&mut l,
"ec.distort.ccsmear",
&[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l];
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
/// effect's own masks are in effect space too: Set Matte with a centred shape layer as the
/// matte keeps the centre of a full-comp solid (#227).