mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge pull request #1279 from vbasky/fix/1205-layer-cover
Render: remove the blanket layer cover; per-effect padding already covers what needs it
This commit is contained in:
@@ -95,6 +95,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);
|
||||
|
||||
@@ -312,6 +312,19 @@ impl Buf {
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `left` and `top` transparent pixels on those sides only.
|
||||
pub fn grow_left_top(&mut self, left: u32, top: u32) {
|
||||
self.pad_sides([left, top, 0, 0]);
|
||||
}
|
||||
/// 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, within [`pad_within_budget`] and as
|
||||
/// clamped by [`Image::effective_pad`].
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
@@ -377,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
|
||||
Some((dx as i64, dy as i64))
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -555,6 +595,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 }
|
||||
}
|
||||
@@ -1583,4 +1632,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
let (sx, sy) = (s * kx, s * ky);
|
||||
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
|
||||
@@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if !repeat && !ctx.adjustment {
|
||||
let reach = if frac > 0.0 { r + 1 } else { r };
|
||||
b.pad((reach * 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);
|
||||
}
|
||||
let edge = repeat || ctx.adjustment;
|
||||
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
|
||||
|
||||
@@ -22,6 +22,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])
|
||||
}
|
||||
@@ -167,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
||||
/// 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::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 }
|
||||
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,
|
||||
};
|
||||
@@ -292,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
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)
|
||||
@@ -312,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
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)
|
||||
|
||||
@@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
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);
|
||||
|
||||
@@ -131,6 +131,14 @@ pub trait FxTarget {
|
||||
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).
|
||||
|
||||
@@ -566,14 +566,26 @@ 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. 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 } => 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(|s| s.skips_cover()) {
|
||||
target.align(layer_size);
|
||||
} else {
|
||||
target.cover(layer_size);
|
||||
}
|
||||
}
|
||||
let mut mask_shapes = masks::shapes(ctx, layer);
|
||||
if origin != [0.0; 2] {
|
||||
|
||||
Reference in New Issue
Block a user