diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7e968297..4e0af6f5 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d7..42d2bab7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a..58f6c362 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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()) { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7e065391..eee105fc 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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).