Motion Tile: mirror folds sample the edge texel, not the transparent outside

A tile boundary that lands exactly on an output pixel centre puts the tile
fraction at exactly 0 or 1, and the sample position at the source's geometric
edge — where sample_bilinear mixes in half a texel of the transparent outside.
The fold column comes out half-faded: a visible seam line along the mirrored
boundary, which sweeps the tile as Tile Center animates across pixel centres
(#2820). Both the CPU path and the GPU kernel switch to the clamped sampler,
which repeats the edge texel there; interior samples are untouched, and the
default single-tile identity mapping already sampled texel centres.

The regression test puts the folds exactly on pixel centres (tileWidth 50%,
centre on the layer, mirror on) and asserts the fold columns stay opaque and
equal the edge texels they mirror — it fails on the unfixed code at alpha 0.5.

Fixes #2820

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
This commit is contained in:
rahul05ranjan committed 2026-11-02 12:30:58 +00:00
1 parent ad7abfa507
commit 540ee9fbc5
2 files changed
+39 -2

No files matched your search

+36 -1
View File
@@ -457,7 +457,11 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf {
fy = 1.0 - fy;
}
}
*px = src.sample_bilinear(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]);
// Clamped: a tile edge lands exactly on a pixel centre whenever the boundary does
// (#2820's animated Tile Center walks it across the grid), and the plain sampler
// would mix in the transparent outside at the source's geometric edge, leaving a
// half-faded seam pixel along the fold.
*px = src.sample_bilinear_clamped(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]);
}
});
Buf { img: out, offset: noff, scale: s }
@@ -808,6 +812,37 @@ mod tests {
}
}
#[test]
fn motion_tile_mirror_folds_stay_opaque() {
// A fold that lands exactly on a pixel centre samples the source's geometric edge, where
// the plain bilinear tap reaches half a texel into the transparent outside — a faint
// seam line on the mirrored boundary that sweeps the tile while Tile Center animates
// (#2820). The fold must repeat the edge texel instead: opaque, and exactly the pixel
// it mirrors.
let img = ramp(10, 8);
let out = run_at(
"ec.stylize.motiontile",
&[("tileCenter", Value::Vec2([5.0, 4.0])), ("tileWidth", num(50.0)), ("mirrorEdges", Value::Bool(true))],
img.clone(),
0.0,
);
assert_eq!((out.img.width, out.img.height), (10, 8));
for (x, mirrored) in [(2u32, 0u32), (7, 9)] {
for y in 0..out.img.height {
let a = out.img.get(x, y);
assert!((a[3] - 1.0).abs() < 1e-5, "fold x={x},y={y} faded to alpha {}", a[3]);
let b = img.get(mirrored, y);
for c in 0..4 {
assert!((a[c] - b[c]).abs() < 1e-5, "fold x={x},y={y} channel {c}");
}
}
}
// And no pixel anywhere in the tile faded either.
for px in out.img.data.iter() {
assert!((px[3] - 1.0).abs() < 1e-5, "tile pixel faded to alpha {}", px[3]);
}
}
#[test]
fn strobe_by_time() {
assert!(strobe_on(0.02, 0.05, 1.0, 0.0, 1));
+3 -1
View File
@@ -611,7 +611,9 @@ fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3<u32>) {
fy = 1.0 - fy;
}
}
textureStore(out, p, sample_bilinear(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y));
// Clamped for the same reason as the CPU path: a fold on a pixel centre samples the
// source's geometric edge, where the plain sampler mixes in the transparent outside.
textureStore(out, p, sample_bilinear_clamped(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y));
}
// CC RepeTile (stylize2::repetile) into a new output size. data: per output column (tile index,