Merge pull request #235 from storytold/fix/227-turbulent-displace

Effects: Turbulent Displace displaces the whole layer, not one quadrant (#227)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 12:36:29 +07:00
commit 543bff29dc
9 files changed
+96 -21

No files matched your search

+4 -3
View File
@@ -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<crate::ParamSpec>, 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;
+2 -2
View File
@@ -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<crate::ParamSpec>, 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| {
+5
View File
@@ -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.
+9
View File
@@ -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 {
+2
View File
@@ -253,6 +253,8 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> 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()
}
+8 -14
View File
@@ -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<GBuf> {
@@ -101,13 +96,12 @@ fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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)
}
+26 -1
View File
@@ -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.
+9 -1
View File
@@ -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
+31
View File
@@ -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:?}");
}
}