Effects: Turbulent Displace displaces the whole layer, not one quadrant (#227)

Edge pinning (Turbulent Displace's default Pin All, Wave Warp's
Pinning, Bulge's Pin All Edges) holds the layer bounds still: a
comp-sized rectangle for shape and text layers. That rectangle started
at the layer's origin, but a shape layer's content surrounds its origin
(anchor 0,0), so everything left of or above the origin counted as
outside the layer and was pinned: only the bottom-right quadrant of a
centred shape moved.

The renderer now passes the bounds' top-left in layer coordinates
(EffectEnv::bounds_origin): (-w/2, -h/2) for layers without a source
rectangle, (0, 0) otherwise. util::pin_rect places the bounds there and
the three effects use it on the CPU and the GPU. Effect point
parameters keep their current layer coordinates.

Test: edge_pinning_displaces_all_of_a_centred_shape_layer,
edge_pinning_on_a_shape_layer (GPU vs CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 12:20:10 +07:00
co-authored by Claude Opus 5.5
parent 172b7cfcc0
commit 754e80bdf9
9 changed files with 96 additions and 21 deletions
+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:?}");
}
}