M12.7: GPU distort/stylize effects (Turbulent Displace, Displacement Map, Wave Warp, Ripple, Twirl, Bulge, CC Lens, Mesh Warp, Mosaic, Find Edges, Emboss)

Compute kernels in shaders/fx_distort.wgsl mirroring the CPU effects; per-row/column
structure (map coordinates, wave phase, tile bounds, mesh triangle buckets) is computed
in f64 on the CPU and uploaded. Displacement Map fetches its map layer through the
effect host as the CPU does and samples it on the GPU. CPU oracle tests in
tests_fx_distort.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:42:43 -04:00
parent 9a6389db86
commit fcc09cf095
6 changed files with 1087 additions and 6 deletions

View File

@ -414,7 +414,8 @@ fn grid_warp(src: &Image, w: u32, h: u32, nx: usize, ny: usize, dest: &[Pt], src
out
}
fn parse_mesh(s: &str) -> Vec<Pt> {
/// Mesh Warp's `mesh` parameter: the vertex offsets (layer pixels), row-major.
pub fn parse_mesh(s: &str) -> Vec<(f64, f64)> {
s.split(|c: char| c.is_whitespace() || c == ';')
.filter(|t| !t.is_empty())
.filter_map(|t| {

View File

@ -62,6 +62,7 @@ use std::sync::OnceLock;
pub use color_fx::{exposure_settings, levels_clip};
pub use color2::Curve;
pub use distort2::parse_mesh;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
@ -641,6 +642,17 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.color.exposure",
"ec.channel.invert",
"ec.distort.transform",
"ec.distort.turbulentdisplace",
"ec.distort.displacementmap",
"ec.distort.wavewarp",
"ec.distort.ripple",
"ec.distort.twirl",
"ec.distort.bulge",
"ec.distort.cclens",
"ec.distort.meshwarp",
"ec.stylize.mosaic",
"ec.stylize.findedges",
"ec.stylize.emboss",
];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated

View File

@ -1,18 +1,413 @@
//! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact
//! steps with the pixel loops as compute kernels.
//!
//! Parameter conversions, padding and anything with a per-row or per-column structure (map
//! coordinates, tile bounds, Mesh Warp's triangle buckets) are computed here in f64 exactly as
//! the CPU does and uploaded; the kernels do the per-pixel work.
use std::f64::consts::TAU;
use effectcraft_effects::EffectCtx;
use effectcraft_effects::util::{Src, src_at};
use crate::context::Enc;
use crate::context::{Enc, Params};
use crate::effects::GBuf;
/// Compute entry points in `fx_distort.wgsl`.
pub(crate) const KERNELS: &[&str] = &[];
pub(crate) const KERNELS: &[&str] = &[
"dst_twirl",
"dst_bulge",
"dst_wavewarp",
"dst_ripple",
"dst_cclens",
"dst_turbulent",
"dst_displace",
"dst_mesh",
"dst_mosaic_rows",
"dst_mosaic_tiles",
"dst_mosaic_out",
"dst_mosaic_sharp",
"dst_findedges",
"dst_emboss",
];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[];
pub(crate) const IDS: &[&str] = &[
"ec.distort.turbulentdisplace",
"ec.distort.displacementmap",
"ec.distort.wavewarp",
"ec.distort.ripple",
"ec.distort.twirl",
"ec.distort.bulge",
"ec.distort.cclens",
"ec.distort.meshwarp",
"ec.stylize.mosaic",
"ec.stylize.findedges",
"ec.stylize.emboss",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option<GBuf> {
None
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.distort.turbulentdisplace" => turbulent(e, ctx, b),
"ec.distort.displacementmap" => displacement_map(e, ctx, b),
"ec.distort.wavewarp" => wave_warp(e, ctx, b),
"ec.distort.ripple" => ripple(e, ctx, b),
"ec.distort.twirl" => twirl(e, ctx, b),
"ec.distort.bulge" => bulge(e, ctx, b),
"ec.distort.cclens" => cc_lens(e, ctx, b),
"ec.distort.meshwarp" => mesh_warp(e, ctx, b),
"ec.stylize.mosaic" => mosaic(e, ctx, b),
"ec.stylize.findedges" => find_edges(e, ctx, b),
"ec.stylize.emboss" => emboss(e, ctx, b),
_ => None,
}
}
/// Run a same-size per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option<GBuf> {
let out = e.image(b.img.width, b.img.height);
e.pixels(entry, p, &b.img, None, &out, data);
b.img = out;
Some(b)
}
/// Buf::layer_w / layer_h (distort.rs).
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> {
let ang = ctx.params.f("angle").to_radians();
let (lw, lh) = layer_wh(ctx);
let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32];
run(e, "dst_twirl", &p, b, None)
}
fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let rx = (ctx.params.f("hradius") * b.scale).max(1.0);
let ry = (ctx.params.f("vradius") * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
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 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];
run(e, "dst_bulge", &p, b, None)
}
fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let h = ctx.params.f("height") * b.scale;
let w = (ctx.params.f("width") * b.scale).max(1.0);
let dir = ctx.params.f("direction").to_radians();
let speed = ctx.params.f("speed");
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU;
let (dx, dy) = (dir.sin(), -dir.cos());
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];
// 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).
let (bw, bh) = (b.img.width as usize, b.img.height as usize);
let mut data = vec![0.0f32; 2 * (bw + bh)];
let split = |v: f64| (v - v.floor(), v.floor());
for x in 0..bw {
let (f, i) = split((x as f64 + 0.5) * dx / w);
data[x] = f as f32;
data[bw + x] = i as f32;
}
for y in 0..bh {
let (f, i) = split((y as f64 + 0.5) * dy / w + phase / TAU);
data[2 * bw + y] = f as f32;
data[2 * bw + bh + y] = i as f32;
}
let mut p = Params::default();
p.u[0] = [ctx.params.e("waveType"), ctx.params.e("pinning"), ctx.seed ^ 0x3a7e, bw as u32];
p.f[0] = [dx as f32, dy as f32, h as f32, 0.0];
p.f[2] = rect;
let buf = e.data(&data);
run(e, "dst_wavewarp", &p, b, Some(&buf))
}
fn ripple(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let (lw, lh) = layer_wh(ctx);
let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * 0.5 * b.scale).max(1.0);
let asymmetric = ctx.params.e("conversion") == 0;
let c = b.to_px(ctx.params.v2("center"));
let w = (ctx.params.f("waveWidth") * b.scale).max(1.0);
let h = ctx.params.f("waveHeight") * b.scale;
let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * TAU;
let mut p = Params::default();
p.u[0][0] = asymmetric as u32;
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, w as f32];
p.f[1] = [h as f32, phase.rem_euclid(TAU) as f32, 0.0, 0.0];
run(e, "dst_ripple", &p, b, None)
}
// ---------------------------------------------------------------- distort2.rs
fn cc_lens(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let c = b.to_px(ctx.params.v2("center"));
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
let r = (ctx.params.f("size") / 100.0 * lw.max(lh) * 0.5).max(0.5);
let k = 2f64.powf(ctx.params.f("convergence") / 50.0);
let mut p = Params::default();
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, k as f32];
run(e, "dst_cclens", &p, b, None)
}
fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let amount = ctx.params.f("amount") * b.scale;
if amount.abs() < 1e-9 {
return Some(b);
}
let kind = ctx.params.e("displacement");
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)?;
}
let off = b.to_px(ctx.params.v2("offset"));
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
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 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) => {
let z = evo.rem_euclid(c);
(z as f32, (z - c) as f32, (z / c) as f32)
}
None => (evo as f32, 0.0, 0.0),
};
let mut p = Params::default();
p.u[0] = [kind, ctx.params.e("pinning"), seed, oct.ceil().max(1.0) as u32];
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[3] = [edge as f32, falloff, 0.0, 0.0];
run(e, "dst_turbulent", &p, b, None)
}
fn displacement_map(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let hs = src_at(ctx.params.e("useForHorizontal"));
let vs = src_at(ctx.params.e("useForVertical"));
let mh = ctx.params.f("maxHorizontal") * b.scale;
let mv = ctx.params.f("maxVertical") * b.scale;
let wrap = ctx.params.b("wrapPixelsAround");
if ctx.params.b("expandOutput") && !wrap && !ctx.adjustment {
b.pad(e, mh.abs().max(mv.abs()).ceil() as u32)?;
}
let layer = ctx.layer_param("displacementMapLayer", false);
let behavior = ctx.params.e("displacementMapBehavior");
let (w, h) = (b.img.width as usize, b.img.height as usize);
let inv = 1.0 / b.scale.max(1e-9);
let ls = ctx.layer_size;
// Per column / row: the map layer's buffer coordinate (fit_layer / Tile Map) and whether
// the pixel lies inside the centred map (Center Map).
let mut data = vec![0.0f32; 2 * (w + h)];
let mut mode = 0u32;
let mut center = false;
let map = match &layer {
Some(o) => {
if o.buf.img.is_empty() {
return None;
}
let os = o.size;
let axis = |i: usize, k: usize| -> (f64, bool) {
let q = (i as f64 + 0.5 - b.offset[k]) * inv;
let map_q = match behavior {
2 => (q + (os[k] - ls[k]) * 0.5).rem_euclid(os[k].max(1.0)),
1 if ls[0] > 0.0 && ls[1] > 0.0 => q * (os[k] / ls[k]),
1 => q,
_ => q + (os[k] - ls[k]) * 0.5,
};
let c = q + (os[k] - ls[k]) * 0.5;
(map_q * o.buf.scale + o.buf.offset[k], (0.0..os[k]).contains(&c))
};
for x in 0..w {
let (m, inside) = axis(x, 0);
data[x] = m as f32;
data[w + h + x] = inside as u32 as f32;
}
for y in 0..h {
let (m, inside) = axis(y, 1);
data[w + y] = m as f32;
data[2 * w + h + y] = inside as u32 as f32;
}
mode = if behavior == 2 { 2 } else { 1 };
center = behavior == 0;
Some(e.g.upload_image(&o.buf.img)?)
}
None => None,
};
let idx = |s: Src| effectcraft_effects::util::SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32;
let mut p = Params::default();
p.u[0] = [idx(hs), idx(vs), mode, wrap as u32 | (center as u32) << 1];
p.u[1] = [w as u32, h as u32, 0, 0];
p.f[0] = [mh as f32, mv as f32, 0.0, 0.0];
let buf = e.data(&data);
let out = e.image(b.img.width, b.img.height);
e.pixels("dst_displace", &p, &b.img, map.as_ref(), &out, Some(&buf));
b.img = out;
Some(b)
}
fn mesh_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let rows = ctx.params.f("rows").round().clamp(1.0, 31.0) as usize;
let cols = ctx.params.f("columns").round().clamp(1.0, 31.0) as usize;
let q = ctx.params.f("quality").round().clamp(1.0, 10.0) as usize;
let offs = effectcraft_effects::parse_mesh(ctx.params.s("mesh"));
if offs.len() != (rows + 1) * (cols + 1) || offs.iter().all(|o| o.0 == 0.0 && o.1 == 0.0) {
return Some(b);
}
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
let vert = |i: usize, j: usize| {
let o = offs[j * (cols + 1) + i];
b.to_px([i as f64 / cols as f64 * lw + o.0, j as f64 / rows as f64 * lh + o.1])
};
let (nx, ny) = (cols * q, rows * q);
let mut dest = Vec::with_capacity((nx + 1) * (ny + 1));
let mut srcp = Vec::with_capacity((nx + 1) * (ny + 1));
for gj in 0..=ny {
for gi in 0..=nx {
let (ci, cj) = ((gi / q).min(cols - 1), (gj / q).min(rows - 1));
let (u, v) = ((gi - ci * q) as f64 / q as f64, (gj - cj * q) as f64 / q as f64);
let (p00, p10, p01, p11) = (vert(ci, cj), vert(ci + 1, cj), vert(ci, cj + 1), vert(ci + 1, cj + 1));
let x = (p00.0 * (1.0 - u) + p10.0 * u) * (1.0 - v) + (p01.0 * (1.0 - u) + p11.0 * u) * v;
let y = (p00.1 * (1.0 - u) + p10.1 * u) * (1.0 - v) + (p01.1 * (1.0 - u) + p11.1 * u) * v;
dest.push((x, y));
srcp.push(b.to_px([gi as f64 / nx as f64 * lw, gj as f64 / ny as f64 * lh]));
}
}
// distort2::grid_warp: triangles in order, bucketed per output row.
let idx = |i: usize, j: usize| j * (nx + 1) + i;
let hh = b.img.height as usize;
let mut tri_data: Vec<f32> = Vec::with_capacity(nx * ny * 24);
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); hh];
let mut t = 0u32;
for j in 0..ny {
for i in 0..nx {
let (a, b2, c, d) = (idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1));
for tri in [[a, b2, c], [a, c, d]] {
let [ia, ib, ic] = tri;
let (pa, pb, pc) = (dest[ia], dest[ib], dest[ic]);
let den: f64 = (pb.1 - pc.1) * (pa.0 - pc.0) + (pc.0 - pb.0) * (pa.1 - pc.1);
// Degenerate triangles are skipped per pixel on the CPU; leaving them out of
// the buckets is the same.
if den.abs() < 1e-12 {
continue;
}
let ys = [pa.1, pb.1, pc.1];
let y0 = (ys[0].min(ys[1]).min(ys[2]) - 0.5).floor().max(0.0) as usize;
let y1 = (ys[0].max(ys[1]).max(ys[2]) + 0.5).ceil().max(0.0) as usize;
for bucket in buckets.iter_mut().take(y1.min(hh)).skip(y0) {
bucket.push(t);
}
let (sa, sb, sc) = (srcp[ia], srcp[ib], srcp[ic]);
tri_data.extend(
[(pb.1 - pc.1) / den, (pc.0 - pb.0) / den, (pc.1 - pa.1) / den, (pa.0 - pc.0) / den, pc.0, pc.1, sa.0, sa.1, sb.0, sb.1, sc.0, sc.1]
.map(|v| v as f32),
);
t += 1;
}
}
}
let row_starts = tri_data.len();
let list_start = row_starts + hh + 1;
let total: usize = buckets.iter().map(Vec::len).sum();
if list_start + total >= 1 << 24 {
// Indices past 2^24 are not exact in the f32 data buffer.
return None;
}
let mut data = tri_data;
data.reserve(hh + 1 + total);
let mut n = 0usize;
for bucket in &buckets {
data.push(n as f32);
n += bucket.len();
}
data.push(n as f32);
for bucket in &buckets {
data.extend(bucket.iter().map(|&t| t as f32));
}
let mut p = Params::default();
p.u[0] = [row_starts as u32, list_start as u32, 0, 0];
let buf = e.data(&data);
run(e, "dst_mesh", &p, b, Some(&buf))
}
// ---------------------------------------------------------------- misc.rs (Stylize)
fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let hb = ctx.params.f("horizontal").round().max(1.0) as usize;
let vb = ctx.params.f("vertical").round().max(1.0) as usize;
let (w, h) = (b.img.width as usize, b.img.height as usize);
if w == 0 || h == 0 {
return Some(b);
}
let cw = (w as f64 / hb as f64).max(1.0);
let ch = (h as f64 / vb as f64).max(1.0);
let mut p = Params::default();
if ctx.params.b("sharpColors") {
let centre = |i: usize, c: f64| (((i as f64 / c).floor() + 0.5) * c) as i64 as f32;
let data: Vec<f32> = (0..w).map(|x| centre(x, cw)).chain((0..h).map(|y| centre(y, ch))).collect();
p.u[0][0] = w as u32;
let buf = e.data(&data);
return run(e, "dst_mosaic_sharp", &p, b, Some(&buf));
}
let (nx, ny) = ((w as f64 / cw).ceil() as usize, (h as f64 / ch).ceil() as usize);
let tx: Vec<usize> = (0..w).map(|x| ((x as f64 / cw) as usize).min(nx - 1)).collect();
let ty: Vec<usize> = (0..h).map(|y| ((y as f64 / ch) as usize).min(ny - 1)).collect();
// Tile bounds: tile i covers the columns with tx == i (tx is non-decreasing).
let bounds = |t: &[usize], n: usize| -> Vec<f32> { (0..=n).map(|i| t.partition_point(|&v| v < i) as f32).collect() };
let mut data: Vec<f32> = tx.iter().chain(&ty).map(|&v| v as f32).collect();
data.extend(bounds(&tx, nx));
data.extend(bounds(&ty, ny));
p.u[0] = [w as u32, h as u32, nx as u32, ny as u32];
let buf = e.data(&data);
let rows = e.image(nx as u32, h as u32);
e.pixels("dst_mosaic_rows", &p, &b.img, None, &rows, Some(&buf));
let tiles = e.image(nx as u32, ny as u32);
e.pixels("dst_mosaic_tiles", &p, &rows, None, &tiles, Some(&buf));
let out = e.image(b.img.width, b.img.height);
e.pixels("dst_mosaic_out", &p, &tiles, None, &out, Some(&buf));
b.img = out;
Some(b)
}
fn find_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = Params::default();
p.u[0][0] = ctx.params.b("invert") as u32;
p.f[0][0] = ctx.params.f("blend") as f32 / 100.0;
run(e, "dst_findedges", &p, b, None)
}
fn emboss(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let ang = ctx.params.f("direction").to_radians();
let relief = ctx.params.f("relief") * b.scale;
let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief);
let mut p = Params::default();
p.f[0] = [dx as f32, dy as f32, ctx.params.f("contrast") as f32 / 100.0, ctx.params.f("blend") as f32 / 100.0];
run(e, "dst_emboss", &p, b, None)
}

View File

@ -149,3 +149,5 @@ mod tests_3d;
mod tests_adjust;
#[cfg(test)]
mod tests_adv3d;
#[cfg(test)]
mod tests_fx_distort;

View File

@ -1,2 +1,554 @@
// GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in
// effectcraft-effects operation for operation; parameter layouts are documented per entry.
// Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer pixels, as in util::remap.
const DST_TAU: f32 = 6.2831855;
const DST_HALF_PI: f32 = 1.5707964;
fn dst_centre(p: vec2<i32>) -> vec2<f32> {
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5);
}
fn dst_inside(p: vec2<i32>) -> bool {
let d = out_dims();
return p.x < d.x && p.y < d.y;
}
// ---------------------------------------------------------------- Twirl (distort::twirl)
// f[0] = (cx, cy, radius, angle in radians)
@compute @workgroup_size(16, 16)
fn dst_twirl(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let r = P.f[0].z;
let dx = q.x - c.x;
let dy = q.y - c.y;
let d = sqrt(dx * dx + dy * dy);
var s = q;
if (d < r) {
let t = 1.0 - d / r;
let a = -P.f[0].w * t * t;
let sn = sin(a);
let co = cos(a);
s = vec2<f32>(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- Bulge (distort::bulge)
// f[0] = (cx, cy, rx, ry); f[1] = (height, taper, pin, edge); f[2] = layer rect (x, y, w, h)
@compute @workgroup_size(16, 16)
fn dst_bulge(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let rx = P.f[0].z;
let ry = P.f[0].w;
let dx = (q.x - c.x) / rx;
let dy = (q.y - c.y) / ry;
let d2 = dx * dx + dy * dy;
var s = q;
if (d2 < 1.0) {
var amt = P.f[1].x * pow(1.0 - d2, P.f[1].y) * 0.5;
if (P.f[1].z > 0.5) {
let l = P.f[2];
let edge = P.f[1].w;
let ex = clamp(min(q.x - l.x - 0.5, l.x + l.z - 0.5 - q.x) / edge, 0.0, 1.0);
let ey = clamp(min(q.y - l.y - 0.5, l.y + l.w - 0.5 - q.y) / edge, 0.0, 1.0);
amt *= min(ex, ey);
}
let k = 1.0 - amt;
s = vec2<f32>(c.x + dx * rx * k, c.y + dy * ry * k);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- Wave Warp (distort::wave_warp)
fn dst_half_circle(u: f32) -> f32 {
let v = 2.0 * u - 1.0;
return sqrt(max(1.0 - v * v, 0.0));
}
fn dst_wave_noise(i: f32, seed: u32) -> f32 {
return hash_noise(bitcast<u32>(i32(i)), 0x5eedu, seed) * 2.0 - 1.0;
}
// distort::wave_shape at the phase `cf + ci` in cycles (t / 2π), split into a fraction cf in
// [0, 2) and whole cycles ci so the fraction keeps full precision.
fn dst_wave_shape(kind: u32, cf: f32, ci: f32, seed: u32) -> f32 {
let ph = cf - floor(cf);
switch kind {
case 1u: {
return select(-1.0, 1.0, ph < 0.5);
}
case 2u: {
return 1.0 - 4.0 * abs(ph - 0.5);
}
case 3u: {
return 2.0 * ph - 1.0;
}
case 4u: {
if (ph < 0.5) {
return dst_half_circle(ph * 2.0);
}
return -dst_half_circle(ph * 2.0 - 1.0);
}
case 5u: {
return 2.0 * dst_half_circle(fract(ph * 2.0)) - 1.0;
}
case 6u: {
return 1.0 - 2.0 * dst_half_circle(fract(ph * 2.0));
}
case 7u: {
return dst_wave_noise(floor(cf * 2.0) + 2.0 * ci, seed);
}
case 8u: {
let u = cf * 2.0;
let i = floor(u);
let f = u - i;
let s = f * f * (3.0 - 2.0 * f);
let n = i + 2.0 * ci;
return dst_wave_noise(n, seed) * (1.0 - s) + dst_wave_noise(n + 1.0, seed) * s;
}
default: {
return sin(ph * DST_TAU);
}
}
}
// distort::wave_pin.
fn dst_wave_pin(pin: u32, x: f32, y: f32, r: vec4<f32>) -> f32 {
let u = clamp((x - r.x - 0.5) / max(r.z - 1.0, 1.0), 0.0, 1.0);
let v = clamp((y - r.y - 0.5) / max(r.w - 1.0, 1.0), 0.0, 1.0);
let horiz = clamp(min(v, 1.0 - v) * 2.0, 0.0, 1.0);
let vert = clamp(min(u, 1.0 - u) * 2.0, 0.0, 1.0);
switch pin {
case 1u: { return min(horiz, vert); }
case 2u: { return min(sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)) * 2.0, 1.0); }
case 3u: { return u; }
case 4u: { return v; }
case 5u: { return 1.0 - u; }
case 6u: { return 1.0 - v; }
case 7u: { return horiz; }
case 8u: { return vert; }
default: { return 1.0; }
}
}
// u[0] = (wave type, pinning, seed, buffer width); f[0] = (dx, dy, height); f[2] = layer rect
// (x, y, w, h). data: the phase in cycles (along / width + phase / 2π) as per-column and per-row
// (fraction, whole cycles) pairs: fx (w), ix (w), fy (h), iy (h).
@compute @workgroup_size(16, 16)
fn dst_wavewarp(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let dx = P.f[0].x;
let dy = P.f[0].y;
let w = P.u[0].w;
let x = u32(p.x);
let y = u32(p.y);
let cf = data[x] + data[2u * w + y];
let ci = data[w + x] + data[2u * w + u32(out_dims().y) + y];
let disp = dst_wave_shape(P.u[0].x, cf, ci, P.u[0].z) * P.f[0].z * dst_wave_pin(P.u[0].y, q.x, q.y, P.f[2]);
textureStore(out, p, sample_bilinear(src, q.x + dy * disp, q.y - dx * disp));
}
// ---------------------------------------------------------------- Ripple (distort::ripple)
// u[0].x = asymmetric; f[0] = (cx, cy, radius, wave width); f[1] = (wave height, phase mod 2π)
@compute @workgroup_size(16, 16)
fn dst_ripple(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let r = P.f[0].z;
let dx = q.x - c.x;
let dy = q.y - c.y;
let d = sqrt(dx * dx + dy * dy);
var s = q;
if (d < r && d != 0.0) {
let h = P.f[1].x;
let fall = 1.0 - d / r;
let arg = d / P.f[0].w * DST_TAU + P.f[1].y;
let o = sin(arg) * h * fall;
var t = 0.0;
if (P.u[0].x != 0u) {
t = cos(arg) * h * fall * 0.5;
}
let ux = dx / d;
let uy = dy / d;
s = vec2<f32>(q.x + ux * o - uy * t, q.y + uy * o + ux * t);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- CC Lens (distort2::cc_lens)
// f[0] = (cx, cy, radius, convergence exponent)
@compute @workgroup_size(16, 16)
fn dst_cclens(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let r = P.f[0].z;
let dx = q.x - c.x;
let dy = q.y - c.y;
let d = sqrt(dx * dx + dy * dy);
if (d >= r) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
var s = q;
if (d >= 1e-9) {
let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w);
let k = r * g / d;
s = vec2<f32>(c.x + dx * k, c.y + dy * k);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- Turbulent Displace (distort2)
// distort2::fbm. u[0].w = octave count; f[1].w = fraction of the last octave; f[3].y = falloff.
fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 {
let n = P.u[0].w;
let frac = P.f[1].w;
let falloff = P.f[3].y;
var sum = 0.0;
var norm = 0.0;
var amp = 1.0;
var f = 1.0;
for (var o = 0u; o < n; o++) {
var w = 1.0;
if (o + 1u == n && frac > 0.0) {
w = frac;
}
sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w;
norm += amp * w;
amp *= falloff;
f *= 2.0;
}
return sum / max(norm, 1e-6);
}
// The centred noise `n(u, v, s)` (cross-faded over the evolution cycle when u[1].x is set).
// f[1] = (z, z - cycle, cycle position t).
fn dst_turb_n(u: f32, v: f32, s: u32) -> f32 {
let seed = P.u[0].z + s;
if (P.u[1].x != 0u) {
let t = P.f[1].z;
return dst_fbm(u, v, P.f[1].x, seed) * (1.0 - t) + dst_fbm(u, v, P.f[1].y, seed) * t - 0.5;
}
return dst_fbm(u, v, P.f[1].x, seed) - 0.5;
}
// u[0] = (displacement kind, pinning, seed, octaves); u[1].x = cycle evolution;
// f[0] = (offset x, offset y, size, amount); f[1] = (z0, z1, t, last-octave fraction);
// f[2] = layer rect (x, y, w, h); f[3] = (edge, falloff)
@compute @workgroup_size(16, 16)
fn dst_turbulent(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let kind = P.u[0].x;
let amount = P.f[0].w;
let u = (q.x - P.f[0].x) / P.f[0].z;
let v = (q.y - P.f[0].y) / P.f[0].z;
var d = vec2<f32>(0.0);
switch kind {
case 1u, 2u, 4u, 5u: {
let e = 0.05;
let gx = (dst_turb_n(u + e, v, 0u) - dst_turb_n(u - e, v, 0u)) / (2.0 * e);
let gy = (dst_turb_n(u, v + e, 0u) - dst_turb_n(u, v - e, 0u)) / (2.0 * e);
let k = amount * 0.25;
if (kind == 1u || kind == 4u) {
d = vec2<f32>(gx * k, gy * k);
} else {
d = vec2<f32>(-gy * k, gx * k);
}
}
case 6u: {
d = vec2<f32>(0.0, dst_turb_n(u, v, 17u) * amount);
}
case 7u: {
d = vec2<f32>(dst_turb_n(u, v, 0u) * amount, 0.0);
}
case 8u: {
let a = dst_turb_n(u, v, 0u) * amount;
d = vec2<f32>(a, a);
}
default: {
d = vec2<f32>(dst_turb_n(u, v, 0u) * amount, dst_turb_n(u, v, 17u) * amount);
}
}
let r = P.f[2];
let edge = P.f[3].x;
let l = clamp((q.x - r.x - 0.5) / edge, 0.0, 1.0);
let rr = clamp((r.x + r.z - 0.5 - q.x) / edge, 0.0, 1.0);
let t = clamp((q.y - r.y - 0.5) / edge, 0.0, 1.0);
let bt = clamp((r.y + r.w - 0.5 - q.y) / edge, 0.0, 1.0);
var k = 1.0;
switch P.u[0].y {
case 1u: { k = min(min(l, rr), min(t, bt)); }
case 2u: { k = min(t, bt); }
case 3u: { k = min(l, rr); }
case 4u: { k = l; }
case 5u: { k = t; }
case 6u: { k = rr; }
case 7u: { k = bt; }
default: {}
}
textureStore(out, p, sample_bilinear(src, q.x + d.x * k, q.y + d.y * k));
}
// ---------------------------------------------------------------- Displacement Map (distort2)
// util::pick for a straight colour (source index in util::SRC_ORDER).
fn dst_pick(s: u32, c: vec3<f32>, a: f32) -> f32 {
switch s {
case 0u: { return c.x; }
case 1u: { return c.y; }
case 2u: { return c.z; }
case 3u: { return a; }
case 4u: { return luminance(c); }
case 5u: { return rgb_to_hsl(c).x; }
case 6u: { return rgb_to_hsl(c).z; }
case 7u: { return rgb_to_hsl(c).y; }
case 8u: { return 1.0; }
case 9u: { return 0.5; }
default: { return 0.0; }
}
}
// u[0] = (horizontal source, vertical source, map mode: 0 self, 1 fitted layer (transparent
// outside), 2 tiled layer (edges repeat), flags: 1 wrap, 2 Center Map bounds);
// u[1] = (buffer width, buffer height); f[0] = (max horizontal, max vertical).
// data: per-column map x, per-row map y (the map layer's buffer pixels), per-column and
// per-row "inside the centred map" flags. aux = the map layer.
@compute @workgroup_size(16, 16)
fn dst_displace(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let w = P.u[1].x;
let h = P.u[1].y;
let x = u32(p.x);
let y = u32(p.y);
let px = textureLoad(src, p, 0);
let flags = P.u[0].w;
if ((flags & 2u) != 0u && (data[w + h + x] < 0.5 || data[2u * w + h + y] < 0.5)) {
textureStore(out, p, px);
return;
}
var m = px;
let mode = P.u[0].z;
if (mode == 1u) {
m = sample_bilinear(aux, data[x], data[w + y]);
} else if (mode == 2u) {
m = sample_bilinear_clamped(aux, data[x], data[w + y]);
}
var c = vec3<f32>(0.0);
let a = m.w;
if (a > 1e-6) {
c = m.xyz / a;
}
let al = max(a, 0.0);
var dx = 0.0;
var dy = 0.0;
if (P.u[0].x != 10u) {
dx = (dst_pick(P.u[0].x, c, al) - 0.5) * 2.0 * P.f[0].x;
}
if (P.u[0].y != 10u) {
dy = (dst_pick(P.u[0].y, c, al) - 0.5) * 2.0 * P.f[0].y;
}
if (dx == 0.0 && dy == 0.0) {
textureStore(out, p, px);
return;
}
var sx = f32(p.x) + 0.5 + dx;
var sy = f32(p.y) + 0.5 + dy;
if ((flags & 1u) != 0u) {
let fw = f32(w);
let fh = f32(h);
sx = sx - floor(sx / fw) * fw;
sy = sy - floor(sy / fh) * fh;
textureStore(out, p, sample_bilinear_clamped(src, sx, sy));
} else {
textureStore(out, p, sample_bilinear(src, sx, sy));
}
}
// ---------------------------------------------------------------- Mesh Warp (distort2::grid_warp)
// u[0] = (row starts offset, list offset). data: 12 floats per triangle (the barycentric
// coefficients k11 k12 k21 k22 relative to vertex c, c, then the source points a, b, c), then
// per output row the start of its triangle list (height + 1 entries), then the lists (triangle
// indices in the CPU's bucket order).
@compute @workgroup_size(16, 16)
fn dst_mesh(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let rs = P.u[0].x;
let lo = P.u[0].y;
let i0 = u32(data[rs + u32(p.y)]);
let i1 = u32(data[rs + u32(p.y) + 1u]);
var cur = vec4<f32>(0.0);
for (var i = i0; i < i1; i++) {
if (cur.w > 0.0) {
break;
}
let b = u32(data[lo + i]) * 12u;
let ex = q.x - data[b + 4u];
let ey = q.y - data[b + 5u];
let l1 = data[b] * ex + data[b + 1u] * ey;
let l2 = data[b + 2u] * ex + data[b + 3u] * ey;
let l3 = 1.0 - l1 - l2;
// The CPU tests against -1e-9 in f64; f32 rounding needs a wider margin so pixels on
// shared edges are not dropped by both triangles.
let e = -1e-5;
if (l1 < e || l2 < e || l3 < e) {
continue;
}
let sx = data[b + 6u] * l1 + data[b + 8u] * l2 + data[b + 10u] * l3;
let sy = data[b + 7u] * l1 + data[b + 9u] * l2 + data[b + 11u] * l3;
cur = sample_bilinear(src, sx, sy);
}
textureStore(out, p, cur);
}
// ---------------------------------------------------------------- Mosaic (misc::mosaic)
// data: column tile index (w), row tile index (h), tile column bounds (nx + 1), tile row bounds
// (ny + 1); u[0] = (w, h, nx, ny).
// Row sums of each tile's columns: out is nx × h.
@compute @workgroup_size(16, 16)
fn dst_mosaic_rows(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let w = P.u[0].x;
let h = P.u[0].y;
let xb = w + h + u32(p.x);
var s = vec4<f32>(0.0);
for (var x = u32(data[xb]); x < u32(data[xb + 1u]); x++) {
s += textureLoad(src, vec2<i32>(i32(x), p.y), 0);
}
textureStore(out, p, s);
}
// Tile averages: out is nx × ny (src = the row sums).
@compute @workgroup_size(16, 16)
fn dst_mosaic_tiles(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let w = P.u[0].x;
let h = P.u[0].y;
let xb = w + h + u32(p.x);
let yb = w + h + P.u[0].z + 1u + u32(p.y);
let y0 = u32(data[yb]);
let y1 = u32(data[yb + 1u]);
var s = vec4<f32>(0.0);
for (var y = y0; y < y1; y++) {
s += textureLoad(src, vec2<i32>(p.x, i32(y)), 0);
}
let n = f32((u32(data[xb + 1u]) - u32(data[xb])) * (y1 - y0));
textureStore(out, p, s / max(n, 1.0));
}
// Each pixel takes its tile's average (src = the tile averages).
@compute @workgroup_size(16, 16)
fn dst_mosaic_out(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let tx = i32(data[u32(p.x)]);
let ty = i32(data[P.u[0].x + u32(p.y)]);
textureStore(out, p, textureLoad(src, vec2<i32>(tx, ty), 0));
}
// Sharp Colors: data = per-column / per-row source pixel; u[0].x = w.
@compute @workgroup_size(16, 16)
fn dst_mosaic_sharp(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let sx = i32(data[u32(p.x)]);
let sy = i32(data[P.u[0].x + u32(p.y)]);
textureStore(out, p, tex_get_clamped(src, sx, sy));
}
// ---------------------------------------------------------------- Find Edges / Emboss (misc)
fn dst_blend_with(orig: vec4<f32>, fx: vec4<f32>, k: f32) -> vec4<f32> {
return fx + (orig - fx) * k;
}
fn dst_lum_at(x: i32, y: i32) -> f32 {
return luminance(tex_get_clamped(src, x, y).xyz);
}
// u[0].x = invert; f[0].x = blend with original.
@compute @workgroup_size(16, 16)
fn dst_findedges(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let x = p.x;
let y = p.y;
let gx = dst_lum_at(x + 1, y - 1) + 2.0 * dst_lum_at(x + 1, y) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x - 1, y)
- dst_lum_at(x - 1, y + 1);
let gy = dst_lum_at(x - 1, y + 1) + 2.0 * dst_lum_at(x, y + 1) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x, y - 1)
- dst_lum_at(x + 1, y - 1);
let e = min(sqrt(gx * gx + gy * gy), 1.0);
var v = 1.0 - e;
if (P.u[0].x != 0u) {
v = e;
}
let px = textureLoad(src, p, 0);
let a = px.w;
textureStore(out, p, dst_blend_with(px, vec4<f32>(v * a, v * a, v * a, a), P.f[0].x));
}
// f[0] = (dx, dy, contrast, blend with original).
@compute @workgroup_size(16, 16)
fn dst_emboss(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let d = P.f[0].xy;
let a = sample_bilinear_clamped(src, q.x + d.x, q.y + d.y);
let c = sample_bilinear_clamped(src, q.x - d.x, q.y - d.y);
let l = luminance(a.xyz) - luminance(c.xyz);
let v = clamp(0.5 + l * P.f[0].z, 0.0, 1.0);
let px = textureLoad(src, p, 0);
let al = px.w;
textureStore(out, p, dst_blend_with(px, vec4<f32>(v * al, v * al, v * al, al), P.f[0].w));
}

View File

@ -0,0 +1,119 @@
//! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn twirl_bulge_ripple_wave_warp() {
effect_case("ec.distort.twirl", &[("angle", n(120.0)), ("radius", n(40.0))]);
effect_case("ec.distort.twirl", &[("angle", n(-400.0)), ("radius", n(70.0)), ("center", Value::Vec2([25.0, 18.0]))]);
effect_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]);
effect_case("ec.distort.bulge", &[("height", n(-2.5)), ("taperRadius", n(60.0)), ("pinAllEdges", on()), ("center", Value::Vec2([30.0, 25.0]))]);
effect_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]);
effect_case("ec.distort.ripple", &[("conversion", e(1)), ("phase", n(70.0)), ("speed", n(3.0)), ("center", Value::Vec2([20.0, 30.0]))]);
effect_case("ec.distort.wavewarp", &[("height", n(6.0)), ("width", n(25.0)), ("direction", n(30.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]);
}
#[test]
fn turbulent_displace_and_cc_lens() {
effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
effect_case(
"ec.distort.turbulentdisplace",
&[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())],
);
effect_case(
"ec.distort.turbulentdisplace",
&[
("displacement", e(5)),
("evolutionOptions/cycleEvolution", on()),
("evolutionOptions/cycle", n(2.0)),
("evolution", n(500.0)),
("evolutionOptions/randomSeed", n(7.0)),
("pinning", e(0)),
("amount", n(-30.0)),
],
);
effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]);
effect_case("ec.distort.cclens", &[("size", n(70.0))]);
effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);
effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]);
}
#[test]
fn mesh_warp() {
// 2 rows × 3 columns: 12 vertices.
let mesh = "0,0 0,0 0,0 0,0 0,0 6,-4 -5,3 0,0 0,0 0,0 0,0 0,0";
effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(3.0)), ("quality", n(3.0)), ("mesh", Value::Str(mesh.into()))]);
let mesh = "-4,-3 2,0 8,5 0,-2 9,9 1,0 -3,4 2,2 4,-6";
effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(2.0)), ("quality", n(5.0)), ("mesh", Value::Str(mesh.into()))]);
}
#[test]
fn mosaic_find_edges_emboss() {
effect_case("ec.stylize.mosaic", &[]);
effect_case("ec.stylize.mosaic", &[("horizontal", n(7.0)), ("vertical", n(3.0))]);
effect_case("ec.stylize.mosaic", &[("horizontal", n(13.0)), ("vertical", n(9.0)), ("sharpColors", on())]);
effect_case("ec.stylize.findedges", &[]);
effect_case("ec.stylize.findedges", &[("invert", on()), ("blend", n(30.0))]);
effect_case("ec.stylize.emboss", &[]);
effect_case("ec.stylize.emboss", &[("direction", n(160.0)), ("relief", n(4.0)), ("contrast", n(250.0)), ("blend", n(20.0))]);
}
#[test]
fn displacement_map_self() {
effect_case("ec.distort.displacementmap", &[("maxHorizontal", n(8.0)), ("maxVertical", n(-5.0))]);
effect_case("ec.distort.displacementmap", &[("useForHorizontal", e(4)), ("useForVertical", e(3)), ("wrapPixelsAround", on())]);
effect_case(
"ec.distort.displacementmap",
&[("useForHorizontal", e(10)), ("useForVertical", e(6)), ("maxVertical", n(12.0)), ("expandOutput", Value::Bool(false))],
);
}
/// Displacement Map reading another layer (Center / Stretch / Tile), composited.
#[test]
fn displacement_map_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (behavior, wrap) in [(0, false), (1, false), (2, true), (2, false)] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut map = s.footage(50, 36);
map.switches.video = false;
let mid = s.push(map);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.distort.displacementmap",
&[
("displacementMapLayer", Value::Layer(Some(mid.0))),
("useForHorizontal", e(0)),
("useForVertical", e(4)),
("maxHorizontal", n(9.0)),
("maxVertical", n(6.0)),
("displacementMapBehavior", e(behavior)),
("wrapPixelsAround", Value::Bool(wrap)),
],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("displacement map {behavior} {wrap} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}