diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 173389de..31b697de 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -6,6 +6,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Image; use rayon::prelude::*; +use crate::util::pin_rect; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -67,8 +68,7 @@ fn bulge(ctx: &EffectCtx, mut b: Buf) -> Buf { // Taper Radius: higher values make the sides of the bulge shallower (a more peaked profile). let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0; let pin = ctx.params.b("pinAllEdges"); - let (lx, ly) = (b.offset[0], b.offset[1]); - let (lw, lh) = (b.layer_w(ctx) * b.scale, b.layer_h(ctx) * b.scale); + let (lx, ly, lw, lh) = pin_rect(ctx, b.offset, b.scale); let edge = (lw.min(lh) * 0.1).max(1.0); b.img = remap(&b.img, false, |x, y| { let (dx, dy) = ((x - c.0) / rx, (y - c.1) / ry); @@ -168,7 +168,8 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { if !ctx.adjustment { b.pad(h.abs().ceil() as u32 + 1); } - let rect = (b.offset[0], b.offset[1], (b.layer_w(ctx) * b.scale).max(1.0), (b.layer_h(ctx) * b.scale).max(1.0)); + let (x0, y0, lw, lh) = pin_rect(ctx, b.offset, b.scale); + let rect = (x0, y0, lw.max(1.0), lh.max(1.0)); b.img = remap(&b.img, false, |x, y| { // Waves travel along `dir`; displacement is perpendicular to it. let along = x * dx + y * dy; diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index f672d149..fcfa8acd 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; +use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, pin_rect, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -103,7 +103,7 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let locked = pin_raw >= 8; let pin = if locked { pin_raw - 7 } else { pin_raw }; let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 }; - let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let (lx, ly, lw, lh) = pin_rect(ctx, b.offset, b.scale); let edge = (lw.min(lh) * if locked { 0.25 } else { 0.1 }).max(1.0); let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9)); let n = |u: f64, v: f64, s: u32| { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 5f832c20..00596938 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -347,6 +347,11 @@ pub struct EffectEnv<'a> { /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). pub shutter: Option<(f64, f64, u32)>, + /// Top-left corner of the layer bounds ([`EffectCtx::layer_size`]) in layer coordinates: + /// (0, 0) for layers with a source rectangle; shape and text layers have comp-sized bounds + /// centred on their origin (−w/2, −h/2). Edge pinning measures from these bounds + /// ([`util::pin_rect`]). + pub bounds_origin: [f64; 2], } /// What an effect gets to render with. diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 06580b71..4d285f78 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -427,6 +427,15 @@ pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64 (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale) } +/// The layer bounds edge pinning holds in place, in the pixels of a buffer with `offset` and +/// `scale` (see [`crate::Buf`]): (x0, y0, width, height). Unlike [`layer_rect`] it starts at +/// [`crate::EffectEnv::bounds_origin`], so a shape layer's content around its origin lies +/// inside it. +pub fn pin_rect(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f64, f64, f64, f64) { + let o = ctx.env.bounds_origin; + (offset[0] + o[0] * scale, offset[1] + o[1] * scale, ctx.layer_size[0] * scale, ctx.layer_size[1] * scale) +} + /// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other /// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { diff --git a/crates/gpu/src/deferred.rs b/crates/gpu/src/deferred.rs index 3ac2c67e..0174b84e 100644 --- a/crates/gpu/src/deferred.rs +++ b/crates/gpu/src/deferred.rs @@ -253,6 +253,8 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option) -> u12 k.debug(&e.working_space); k.u64(e.working_linear as u64); k.debug(&e.shutter); + k.f64(e.bounds_origin[0]); + k.f64(e.bounds_origin[1]); } k.finish() } diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index 15d13bdc..59933c58 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -8,7 +8,7 @@ use std::f64::consts::TAU; use effectcraft_effects::EffectCtx; -use effectcraft_effects::util::{Src, src_at}; +use effectcraft_effects::util::{self, Src, src_at}; use crate::context::{Enc, Params}; use crate::effects::GBuf; @@ -77,11 +77,6 @@ fn layer_wh(ctx: &EffectCtx) -> (f64, f64) { (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)) } -/// util::layer_rect. -fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> [f32; 4] { - [b.offset[0] as f32, b.offset[1] as f32, (ctx.layer_size[0] * b.scale) as f32, (ctx.layer_size[1] * b.scale) as f32] -} - // ---------------------------------------------------------------- distort.rs fn twirl(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { @@ -101,13 +96,12 @@ fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let h = ctx.params.f("height"); let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0; let pin = ctx.params.b("pinAllEdges"); - let (lw, lh) = layer_wh(ctx); - let (lw, lh) = (lw * b.scale, lh * b.scale); + let (x0, y0, lw, lh) = util::pin_rect(ctx, b.offset, b.scale); let edge = (lw.min(lh) * 0.1).max(1.0); let mut p = Params::default(); p.f[0] = [c.0 as f32, c.1 as f32, rx as f32, ry as f32]; p.f[1] = [h as f32, taper as f32, if pin { 1.0 } else { 0.0 }, edge as f32]; - p.f[2] = [b.offset[0] as f32, b.offset[1] as f32, lw as f32, lh as f32]; + p.f[2] = [x0 as f32, y0 as f32, lw as f32, lh as f32]; run(e, "dst_bulge", &p, b, None) } @@ -121,8 +115,8 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { if !ctx.adjustment { b.pad(e, h.abs().ceil() as u32 + 1)?; } - let (lw, lh) = layer_wh(ctx); - let rect = [b.offset[0] as f32, b.offset[1] as f32, (lw * b.scale).max(1.0) as f32, (lh * b.scale).max(1.0) as f32]; + let (x0, y0, lw, lh) = util::pin_rect(ctx, b.offset, b.scale); + let rect = [x0 as f32, y0 as f32, lw.max(1.0) as f32, lh.max(1.0) as f32]; // The wave phase in cycles, (x·dx + y·dy) / width + phase / 2π, is separable: per column // and per row (fraction, whole cycles) in f64, so the kernel's sum keeps the fraction exact // to f32 (the circle shapes have vertical tangents where a phase error is amplified). @@ -189,8 +183,8 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let evo = ctx.params.f("evolution") / 360.0; let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0)); let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 }; - let rect = layer_rect(ctx, &b); - let edge = ((ctx.layer_size[0] * b.scale).min(ctx.layer_size[1] * b.scale) * 0.1).max(1.0); + let (x0, y0, lw, lh) = util::pin_rect(ctx, b.offset, b.scale); + let edge = (lw.min(lh) * 0.1).max(1.0); let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9)); let (z0, z1, t) = match cycle { Some(c) => { @@ -204,7 +198,7 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { p.u[1][0] = cycle.is_some() as u32; p.f[0] = [off.0 as f32, off.1 as f32, size as f32, amount as f32]; p.f[1] = [z0, z1, t, (oct - oct.floor()) as f32]; - p.f[2] = rect; + p.f[2] = [x0 as f32, y0 as f32, lw as f32, lh as f32]; p.f[3] = [edge as f32, falloff, 0.0, 0.0]; run(e, "dst_turbulent", &p, b, None) } diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs index 9732f99c..414ccb54 100644 --- a/crates/gpu/src/tests_fx_distort.rs +++ b/crates/gpu/src/tests_fx_distort.rs @@ -1,7 +1,8 @@ //! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`. use effectcraft_keyframe::Value; -use effectcraft_project::BitDepth; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, LayerSource}; use effectcraft_render::RenderOpts; use effectcraft_time::Tick; @@ -54,6 +55,30 @@ fn turbulent_displace_and_cc_lens() { effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]); } +/// Edge pinning on a shape layer, whose comp-sized bounds are centred on its origin (#227). +#[test] +fn edge_pinning_on_a_shape_layer() { + let cases: [(&str, &[(&str, Value)]); 3] = [ + ("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]), + ("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]), + ("ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([-20.0, -10.0]))]), + ]; + for (id, vals) in cases { + let mut s = Scene::new(BitDepth::Bpc32); + 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, [90.0, 54.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [0.9, 0.3, 0.1, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + s.p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + s.effect(&mut l, id, vals); + s.push(l); + check(&format!("{id} on a shape layer"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } +} + #[test] fn mesh_warp() { // 2 rows × 3 columns: 12 vertices. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5c1a7e74..ac120165 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -895,7 +895,14 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let size = source_size(self.project, layer); - let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + // Layers without a source rectangle (shape, text) have comp-sized bounds centred on + // their origin, which their content surrounds. + let (layer_size, bounds_origin) = if size.0 == 0 { + let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64); + ([w, h], [-w / 2.0, -h / 2.0]) + } else { + ([size.0 as f64, size.1 as f64], [0.0; 2]) + }; let mask_shapes = masks::shapes(ctx, layer); let host = FxHost { r: self, ctx, layer, index: Default::default() }; let env = EffectEnv { @@ -907,6 +914,7 @@ impl<'a> Renderer<'a> { working_space: self.pipe.space, working_linear: self.pipe.linear, shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), + bounds_origin, }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 28006b8a..01f04131 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -425,3 +425,34 @@ fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { p.settings.gpu_acceleration = false; assert!(!asked(&p, crate::Backend::Auto)); } + +/// #227: a shape layer's content surrounds its origin, so edge pinning (Turbulent Displace's +/// default Pin All, Wave Warp's All Edges) must measure from comp-sized bounds centred on it; +/// bounds starting at the origin pinned all but the bottom-right quadrant. +#[test] +fn edge_pinning_displaces_all_of_a_centred_shape_layer() { + for (id, vals) in [("ec.distort.turbulentdisplace", vec![]), ("ec.distort.wavewarp", vec![("pinning", Value::Enum(1))])] { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [180.0, 80.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let plain = render_frame(&p, cid, Tick::ZERO, 1.0); + add_effect_200(&mut p, &mut l, id, &vals); + p.comp_mut(cid).unwrap().layers = vec![l]; + let warped = render_frame(&p, cid, Tick::ZERO, 1.0); + // Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each. + let mut q = [0.0f32; 4]; + for y in 0..100 { + for x in 0..200 { + q[(x >= 100) as usize + 2 * (y >= 50) as usize] += (plain.get(x, y)[3] - warped.get(x, y)[3]).abs(); + } + } + assert!(q.iter().all(|&d| d > 20.0), "{id}: per-quadrant change {q:?}"); + } +}