mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M9.13: Radio Waves Image Contours / Mask waves, Parameters Are Set At, reflection, profile
- Wave Type: Polygon, Image Contours (source layer channel threshold, pre-blur, tolerance, contour index, source centre) and Mask, rippling outward from the outline (exact signed distance transform, util::signed_distance). - Parameters Are Set At Birth via the new EffectHost::params_at; Render Quality; Reflection off the layer edges; Stroke Profile. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3103e7e80f
commit
3c586e2dba
6 files changed
+463
-45
No files matched your search
@@ -20,7 +20,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("ec.distort.reshape", "no correspondence points"),
|
||||
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
|
||||
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
|
||||
("ec.generate.radiowaves", "no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile"),
|
||||
("ec.generate.scribble", "no caps, joins or Start/End Apply To"),
|
||||
("ec.generate.cellpattern", "HQ variants render like the standard ones"),
|
||||
("ec.key.innerouter", "one additional mask per side; no cleanup strokes"),
|
||||
|
||||
+374
-41
@@ -349,60 +349,275 @@ fn outline_dist(pts: &[(f64, f64)], dx: f64, dy: f64) -> f64 {
|
||||
best
|
||||
}
|
||||
|
||||
/// Radio Waves' Wave Type options.
|
||||
const RW_TYPES: [&str; 3] = ["Polygon", "Image Contours", "Mask"];
|
||||
/// Radio Waves' Stroke Profile options.
|
||||
const RW_PROFILES: [&str; 7] = ["Square", "Taper Front", "Taper Back", "Taper Both", "Sine", "Sawtooth Out", "Sawtooth In"];
|
||||
/// Image Contour's Value Channel options.
|
||||
const RW_CHANNELS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation"];
|
||||
|
||||
/// Stroke Profile weight across the stroke: `u` from −1 (inner edge) to 1 (outer edge).
|
||||
fn rw_profile(kind: u32, u: f64) -> f64 {
|
||||
let u = u.clamp(-1.0, 1.0);
|
||||
match kind {
|
||||
1 => (1.0 - u) * 0.5,
|
||||
2 => (1.0 + u) * 0.5,
|
||||
3 => 1.0 - u.abs(),
|
||||
4 => (u * PI * 0.5).cos(),
|
||||
5 if u < 0.0 => 1.0 + u,
|
||||
6 if u > 0.0 => 1.0 - u,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a coordinate into `[lo, hi]` by bouncing off the ends (Reflection).
|
||||
fn reflect(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
let span = hi - lo;
|
||||
if span <= 0.0 {
|
||||
return lo;
|
||||
}
|
||||
let t = (v - lo).rem_euclid(2.0 * span);
|
||||
lo + if t > span { 2.0 * span - t } else { t }
|
||||
}
|
||||
|
||||
/// The source shape of a contour wave: signed distance (buffer px, negative inside) on the
|
||||
/// buffer grid, and the point of the shape that sits on the Producer Point.
|
||||
struct Contour {
|
||||
sdf: Plane,
|
||||
anchor: (f64, f64),
|
||||
}
|
||||
|
||||
/// Image Contours: the region of the source layer whose Value Channel passes Value Threshold.
|
||||
fn image_contour(ctx: &EffectCtx, b: &Buf) -> Option<Contour> {
|
||||
let pr = ctx.params;
|
||||
let lp = ctx.layer_param("imageContour/sourceLayer", true)?;
|
||||
let img = crate::util::fit_layer(ctx, b, &lp, false);
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let ch = pr.e("imageContour/valueChannel");
|
||||
let mut v = Plane::from_image(&img, |px| {
|
||||
let (c, a) = unpremul(px);
|
||||
let (hh, s, l) = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]);
|
||||
match ch {
|
||||
0 => c[0] * a,
|
||||
1 => c[1] * a,
|
||||
2 => c[2] * a,
|
||||
3 => a,
|
||||
5 => hh,
|
||||
6 => l * a,
|
||||
7 => s,
|
||||
_ => luminance(c[0], c[1], c[2]) * a,
|
||||
}
|
||||
});
|
||||
let blur = pr.f("imageContour/preBlur").max(0.0) * b.scale;
|
||||
if blur > 0.0 {
|
||||
v = gauss_plane(&v, blur * 0.5, blur * 0.5);
|
||||
}
|
||||
let thr = (pr.f("imageContour/valueThreshold") / 255.0) as f32;
|
||||
let invert = pr.b("imageContour/invertInput");
|
||||
let mut inside: Vec<bool> = v.data.iter().map(|&x| (x >= thr) != invert).collect();
|
||||
// Contour: 0 = every region; n = the n-th largest connected region.
|
||||
let pick = pr.f("imageContour/contour").round() as usize;
|
||||
if pick > 0 {
|
||||
let mut label = vec![0u32; w * h];
|
||||
let mut sizes = vec![0usize];
|
||||
for start in 0..w * h {
|
||||
if !inside[start] || label[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
let id = sizes.len() as u32;
|
||||
let mut stack = vec![start];
|
||||
label[start] = id;
|
||||
let mut n = 0;
|
||||
while let Some(i) = stack.pop() {
|
||||
n += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut nb = |j: usize| {
|
||||
if inside[j] && label[j] == 0 {
|
||||
label[j] = id;
|
||||
stack.push(j);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
nb(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
nb(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
nb(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
nb(i + w);
|
||||
}
|
||||
}
|
||||
sizes.push(n);
|
||||
}
|
||||
let mut order: Vec<usize> = (1..sizes.len()).collect();
|
||||
order.sort_by(|a, c| sizes[*c].cmp(&sizes[*a]).then(a.cmp(c)));
|
||||
let keep = order.get(pick - 1).copied().unwrap_or(usize::MAX) as u32;
|
||||
inside.iter_mut().zip(&label).for_each(|(v, l)| *v = *v && *l == keep);
|
||||
}
|
||||
if !inside.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
let mut sdf = crate::util::signed_distance(&inside, w, h);
|
||||
// Tolerance smooths the traced outline.
|
||||
let tol = pr.f("imageContour/tolerance").max(0.0) * b.scale;
|
||||
if tol > 0.0 {
|
||||
sdf = gauss_plane(&sdf, tol * 0.5, tol * 0.5);
|
||||
}
|
||||
Some(Contour { sdf, anchor: b.to_px(pr.v2("imageContour/sourceCenter")) })
|
||||
}
|
||||
|
||||
/// Mask: the chosen mask of this layer.
|
||||
fn mask_contour(ctx: &EffectCtx, b: &Buf) -> Option<Contour> {
|
||||
let idx = ctx.params.f("waveMask/mask").round() as usize;
|
||||
let (pts, _, inverted) = crate::generate3::mask_px(ctx, b, idx)?;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let inside: Vec<bool> = (0..w * h).map(|i| crate::util::point_in_poly(&pts, (i % w) as f64 + 0.5, (i / w) as f64 + 0.5) != inverted).collect();
|
||||
if !inside.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
// Mask waves start where the mask is (anchored at the Producer Point: no shift).
|
||||
let anchor = b.to_px(ctx.params.v2("producerPoint"));
|
||||
Some(Contour { sdf: crate::util::signed_distance(&inside, w, h), anchor })
|
||||
}
|
||||
|
||||
/// One live wave.
|
||||
struct Wave {
|
||||
centre: (f64, f64),
|
||||
radius: f64,
|
||||
half_width: f64,
|
||||
rotation: f64,
|
||||
fade: f32,
|
||||
color: [f32; 4],
|
||||
opacity: f32,
|
||||
/// Polygon outline (unit shape rotated and scaled), or None for a circle / contour.
|
||||
outline: Option<Vec<(f64, f64)>>,
|
||||
}
|
||||
|
||||
/// Radio Waves: waves are born at Frequency per second at the Producer Point and grow at
|
||||
/// Expansion px/s for Lifespan seconds, moving at Velocity towards Direction (bouncing off the
|
||||
/// layer edges with Reflection) and spinning. Polygon waves are regular polygons or stars;
|
||||
/// Image Contours waves ripple outward from the outline of a source layer's thresholded
|
||||
/// channel, Mask waves from a mask. With Parameters Are Set At Birth each wave keeps the values
|
||||
/// it was born with; Each Frame applies the current values to every wave. Stroke draws each wave
|
||||
/// with its Profile across the width.
|
||||
fn radio_waves(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("producerPoint"));
|
||||
let sides = ctx.params.f("polygon/sides").round().clamp(3.0, 64.0);
|
||||
let star = ctx.params.b("polygon/star");
|
||||
let depth = ctx.params.f("polygon/starDepth").clamp(-1.0, 1.0);
|
||||
let freq = ctx.params.f("waveMotion/frequency").max(0.01);
|
||||
let expansion = ctx.params.f("waveMotion/expansion") * b.scale;
|
||||
let orient = ctx.params.f("waveMotion/orientation").to_radians();
|
||||
// Direction: 0° = up, clockwise.
|
||||
let dirn = ctx.params.f("waveMotion/direction").to_radians();
|
||||
let vel = ctx.params.f("waveMotion/velocity") * b.scale;
|
||||
let spin = ctx.params.f("waveMotion/spin").to_radians();
|
||||
let life = ctx.params.f("waveMotion/lifespan").max(0.01);
|
||||
let color = ctx.params.color("waveStroke/color");
|
||||
let op = (ctx.params.f("waveStroke/opacity") / 100.0) as f32;
|
||||
let fin = ctx.params.f("waveStroke/fadeInTime").max(0.0);
|
||||
let fout = ctx.params.f("waveStroke/fadeOutTime").max(0.0);
|
||||
let sw = ctx.params.f("waveStroke/startWidth") * b.scale;
|
||||
let ew = ctx.params.f("waveStroke/endWidth") * b.scale;
|
||||
let cur = ctx.params;
|
||||
let t = ctx.time.max(0.0);
|
||||
let freq = cur.f("waveMotion/frequency").max(0.01);
|
||||
let life_now = cur.f("waveMotion/lifespan").max(0.01);
|
||||
let birth = cur.e("parametersAreSetAt") == 0;
|
||||
let wave_type = cur.e("waveType");
|
||||
let quality = cur.get("renderQuality").map(Value::as_f64).unwrap_or(4.0).clamp(1.0, 10.0);
|
||||
let aa = 0.5 + 1.5 / quality;
|
||||
let profile = cur.e("waveStroke/profile");
|
||||
let reflection = cur.b("waveMotion/reflection");
|
||||
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
|
||||
let contour = match wave_type {
|
||||
1 => image_contour(ctx, &b),
|
||||
2 => mask_contour(ctx, &b),
|
||||
_ => None,
|
||||
};
|
||||
if wave_type != 0 && contour.is_none() {
|
||||
return b;
|
||||
}
|
||||
let k_hi = (t * freq).floor() as i64;
|
||||
let k_lo = (((t - life) * freq).floor() as i64).max(0).max(k_hi - 256);
|
||||
// (centre, radius, half width, rotation, fade) per live wave.
|
||||
let waves: Vec<((f64, f64), f64, f64, f64, f32)> = (k_lo..=k_hi)
|
||||
let k_lo = (((t - life_now) * freq).floor() as i64 - 1).max(0).max(k_hi - 256);
|
||||
let mut cache: std::collections::HashMap<i64, crate::Params> = std::collections::HashMap::new();
|
||||
let waves: Vec<Wave> = (k_lo..=k_hi)
|
||||
.filter_map(|k| {
|
||||
let age = t - k as f64 / freq;
|
||||
let born = k as f64 / freq;
|
||||
let age = t - born;
|
||||
// Birth: the values the wave was born with (when the host can tell).
|
||||
let pr: &crate::Params = if birth {
|
||||
if let Some(h) = ctx.env.host
|
||||
&& let std::collections::hash_map::Entry::Vacant(e) = cache.entry(k)
|
||||
&& let Some(p) = h.params_at(born)
|
||||
{
|
||||
e.insert(p);
|
||||
}
|
||||
cache.get(&k).unwrap_or(cur)
|
||||
} else {
|
||||
cur
|
||||
};
|
||||
let life = pr.f("waveMotion/lifespan").max(0.01);
|
||||
if !(0.0..life).contains(&age) {
|
||||
return None;
|
||||
}
|
||||
let c = b.to_px(pr.v2("producerPoint"));
|
||||
let fin = pr.f("waveStroke/fadeInTime").max(0.0);
|
||||
let fout = pr.f("waveStroke/fadeOutTime").max(0.0);
|
||||
let fi = if fin > 0.0 { (age / fin).min(1.0) } else { 1.0 };
|
||||
let fo = if fout > 0.0 { ((life - age) / fout).min(1.0) } else { 1.0 };
|
||||
let centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age);
|
||||
Some((centre, expansion * age, ((sw + (ew - sw) * age / life) * 0.5).max(0.25), orient + spin * age, (fi * fo) as f32))
|
||||
let dirn = pr.f("waveMotion/direction").to_radians();
|
||||
let vel = pr.f("waveMotion/velocity") * b.scale;
|
||||
let mut centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age);
|
||||
if reflection {
|
||||
centre = (reflect(centre.0, lx, lx + lw), reflect(centre.1, ly, ly + lh));
|
||||
}
|
||||
let sw = pr.f("waveStroke/startWidth") * b.scale;
|
||||
let ew = pr.f("waveStroke/endWidth") * b.scale;
|
||||
let radius = pr.f("waveMotion/expansion") * b.scale * age;
|
||||
let rotation = pr.f("waveMotion/orientation").to_radians() + pr.f("waveMotion/spin").to_radians() * age;
|
||||
let outline = (wave_type == 0).then(|| {
|
||||
let sides = pr.f("polygon/sides").round().clamp(3.0, 64.0);
|
||||
let star = pr.b("polygon/star");
|
||||
let depth = pr.f("polygon/starDepth").clamp(-1.0, 1.0);
|
||||
if sides >= 64.0 && !star {
|
||||
return None;
|
||||
}
|
||||
let n = sides as usize;
|
||||
let unit: Vec<(f64, f64)> = if star {
|
||||
(0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect()
|
||||
} else {
|
||||
(0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect()
|
||||
};
|
||||
Some(unit.iter().map(|&(a, k)| ((a + rotation).cos() * radius * k, (a + rotation).sin() * radius * k)).collect())
|
||||
});
|
||||
Some(Wave {
|
||||
centre,
|
||||
radius,
|
||||
half_width: ((sw + (ew - sw) * age / life) * 0.5).max(0.25),
|
||||
rotation,
|
||||
fade: (fi * fo) as f32,
|
||||
color: pr.color("waveStroke/color"),
|
||||
opacity: (pr.f("waveStroke/opacity") / 100.0) as f32,
|
||||
outline: outline.flatten(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let circle = sides >= 64.0 && !star;
|
||||
// Unit outline (radius 1) of the polygon or star, rotated per wave below.
|
||||
let n = sides as usize;
|
||||
let unit: Vec<(f64, f64)> = if star {
|
||||
(0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect()
|
||||
} else {
|
||||
(0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect()
|
||||
};
|
||||
let outlines: Vec<Vec<(f64, f64)>> =
|
||||
waves.iter().map(|&(_, rad, _, rot, _)| unit.iter().map(|&(a, k)| ((a + rot).cos() * rad * k, (a + rot).sin() * rad * k)).collect()).collect();
|
||||
map_xy(&mut b.img, |x, y, px| {
|
||||
let mut a = 0.0f32;
|
||||
for (wi, &(cc, rad, hw, _, fade)) in waves.iter().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - cc.0, y as f64 + 0.5 - cc.1);
|
||||
let d = if circle { ((dx * dx + dy * dy).sqrt() - rad).abs() } else { outline_dist(&outlines[wi], dx, dy) };
|
||||
a = a.max((hw - d + 0.5).clamp(0.0, 1.0) as f32 * fade);
|
||||
let mut out = px;
|
||||
// Older waves first, so newer ones sit on top.
|
||||
for wv in &waves {
|
||||
let (dx, dy) = (x as f64 + 0.5 - wv.centre.0, y as f64 + 0.5 - wv.centre.1);
|
||||
// Signed distance from the wave line (negative inside the wave).
|
||||
let d = match (&contour, &wv.outline) {
|
||||
(Some(ct), _) => {
|
||||
// The contour placed with its anchor on the wave centre, rotated.
|
||||
let (s, c) = (-(wv.rotation)).sin_cos();
|
||||
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
|
||||
ct.sdf.sample(ct.anchor.0 + rx, ct.anchor.1 + ry) as f64 - wv.radius
|
||||
}
|
||||
(None, Some(o)) => {
|
||||
let ud = outline_dist(o, dx, dy);
|
||||
let poly: Vec<[f64; 2]> = o.iter().map(|p| [p.0, p.1]).collect();
|
||||
if crate::util::point_in_poly(&poly, dx, dy) { -ud } else { ud }
|
||||
}
|
||||
(None, None) => (dx * dx + dy * dy).sqrt() - wv.radius,
|
||||
};
|
||||
let ad = d.abs();
|
||||
let cov = ((wv.half_width - ad) / aa + 0.5).clamp(0.0, 1.0);
|
||||
if cov <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let u = (d / wv.half_width).clamp(-1.0, 1.0);
|
||||
let a = (cov * rw_profile(profile, u)) as f32 * wv.fade * wv.opacity * wv.color[3];
|
||||
out = over(out, [wv.color[0] * a, wv.color[1] * a, wv.color[2] * a, a]);
|
||||
}
|
||||
let a = a * op * color[3];
|
||||
over(px, [color[0] * a, color[1] * a, color[2] * a, a])
|
||||
out
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -776,9 +991,21 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
"Radio Waves",
|
||||
vec![
|
||||
p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("parametersAreSetAt", "Parameters Are Set At", Value::Enum(1), popup(&["Birth", "Each Frame"])),
|
||||
p("renderQuality", "Render Quality", num(4.0), slider(1.0, 10.0, 1.0, 10.0, 0)),
|
||||
p("waveType", "Wave Type", Value::Enum(0), popup(&RW_TYPES)),
|
||||
p("polygon/sides", "Sides", num(64.0), slider(3.0, 64.0, 3.0, 64.0, 0)),
|
||||
p("polygon/star", "Star", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("polygon/starDepth", "Star Depth", num(-0.3), slider(-1.0, 1.0, -1.0, 1.0, 2)),
|
||||
p("imageContour/sourceLayer", "Source Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("imageContour/sourceCenter", "Source Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("imageContour/valueChannel", "Value Channel", Value::Enum(4), popup(&RW_CHANNELS)),
|
||||
p("imageContour/invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("imageContour/valueThreshold", "Value Threshold", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
p("imageContour/preBlur", "Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("imageContour/tolerance", "Tolerance", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("imageContour/contour", "Contour", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 0)),
|
||||
p("waveMask/mask", "Mask", num(0.0), ParamUi::Mask),
|
||||
p("waveMotion/frequency", "Frequency", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)),
|
||||
p("waveMotion/expansion", "Expansion", num(100.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)),
|
||||
p("waveMotion/orientation", "Orientation", num(0.0), ang()),
|
||||
@@ -786,6 +1013,8 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("waveMotion/velocity", "Velocity", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)),
|
||||
p("waveMotion/spin", "Spin", num(0.0), slider(-3600.0, 3600.0, -360.0, 360.0, 1)),
|
||||
p("waveMotion/lifespan", "Lifespan (sec)", num(2.0), slider(0.01, 100.0, 0.01, 10.0, 2)),
|
||||
p("waveMotion/reflection", "Reflection", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("waveStroke/profile", "Profile", Value::Enum(0), popup(&RW_PROFILES)),
|
||||
p("waveStroke/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("waveStroke/opacity", "Opacity", num(100.0), pct()),
|
||||
p("waveStroke/fadeInTime", "Fade-in Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
|
||||
@@ -1014,4 +1243,108 @@ mod tests {
|
||||
assert!(c > o.img.get(60, 4)[0]);
|
||||
assert!(c > 0.5);
|
||||
}
|
||||
|
||||
fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
|
||||
let mut v: Vec<(&str, Value)> =
|
||||
vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
|
||||
v.extend_from_slice(set);
|
||||
crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_distance_of_a_square() {
|
||||
let (w, h) = (20, 20);
|
||||
let inside: Vec<bool> = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
|
||||
let d = crate::util::signed_distance(&inside, w, h);
|
||||
assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
|
||||
assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
|
||||
assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_waves_mask_contours_profile_reflection_and_birth() {
|
||||
// Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
|
||||
let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
|
||||
let masks = [sq];
|
||||
let env = crate::EffectEnv { masks: &masks, ..Default::default() };
|
||||
let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
|
||||
assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
|
||||
assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
|
||||
// Rounded corner: diagonal distance 10 from (37, 37).
|
||||
assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
|
||||
// No mask chosen: nothing drawn.
|
||||
assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
|
||||
// Image Contours: the bright square of a source layer.
|
||||
struct Src;
|
||||
impl crate::EffectHost for Src {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
let mut img = Image::new(64, 64);
|
||||
for y in 27..37 {
|
||||
for x in 27..37 {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn params_at(&self, t: f64) -> Option<Params> {
|
||||
// Colour animates from red (born at 0) to green (born later).
|
||||
let s = crate::find("ec.generate.radiowaves").unwrap();
|
||||
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
|
||||
p.values.insert("waveMotion/frequency".into(), num(1.0));
|
||||
p.values.insert("waveMotion/expansion".into(), num(20.0));
|
||||
p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
|
||||
let ic = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
|
||||
// Source Center off the square shifts the contour onto the producer point accordingly.
|
||||
let sh = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
|
||||
// Profile: Taper Both thins the stroke edges; Square is solid across.
|
||||
let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
|
||||
let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
|
||||
let mut tap = wide.to_vec();
|
||||
tap.push(("waveStroke/profile", Value::Enum(3)));
|
||||
let tap = rw(&tap, 0.2, crate::EffectEnv::default());
|
||||
assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
|
||||
assert!(tap.get(52, 32)[3] > 0.8);
|
||||
// Reflection: a fast wave bounces back off the right edge.
|
||||
let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
|
||||
let gone = rw(&fast, 0.3, crate::EffectEnv::default());
|
||||
let mut rf = fast.to_vec();
|
||||
rf.push(("waveMotion/reflection", Value::Bool(true)));
|
||||
let back = rw(&rf, 0.3, crate::EffectEnv::default());
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
|
||||
// Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
|
||||
let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
|
||||
let (old, new) = (both.get(55, 32), both.get(35, 32));
|
||||
assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
|
||||
assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,11 @@ pub trait EffectHost: Sync {
|
||||
fn comp_scene(&self) -> Option<CompScene> {
|
||||
None
|
||||
}
|
||||
/// The running effect's own parameters evaluated at another **layer time** (Radio Waves'
|
||||
/// Parameters Are Set At: Birth). `None` when unavailable.
|
||||
fn params_at(&self, _layer_time: f64) -> Option<Params> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
|
||||
@@ -615,6 +620,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
||||
("polygon", "Polygon"),
|
||||
("waveMotion", "Wave Motion"),
|
||||
("waveStroke", "Stroke"),
|
||||
("imageContour", "Image Contour"),
|
||||
("waveMask", "Mask"),
|
||||
("coreSettings", "Core Settings"),
|
||||
("glowSettings", "Glow Settings"),
|
||||
("expertSettings", "Expert Settings"),
|
||||
|
||||
@@ -563,6 +563,78 @@ impl<S: Clone> SimCache<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1D squared-distance transform of sampled function `f` (Felzenszwalb & Huttenlocher 2012;
|
||||
/// `f` is 0 at sites and +∞ elsewhere).
|
||||
fn sq_dt_1d(f: &[f64], out: &mut [f64]) {
|
||||
let n = f.len();
|
||||
let sites: Vec<usize> = (0..n).filter(|&i| f[i].is_finite()).collect();
|
||||
if sites.is_empty() {
|
||||
out.iter_mut().for_each(|o| *o = f64::INFINITY);
|
||||
return;
|
||||
}
|
||||
let mut v = vec![0usize; sites.len()];
|
||||
let mut z = vec![0f64; sites.len() + 1];
|
||||
let mut k = 0usize;
|
||||
v[0] = sites[0];
|
||||
z[0] = f64::NEG_INFINITY;
|
||||
z[1] = f64::INFINITY;
|
||||
let s_of = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64);
|
||||
for &q in &sites[1..] {
|
||||
let mut s = s_of(q, v[k]);
|
||||
while s <= z[k] {
|
||||
k -= 1;
|
||||
s = s_of(q, v[k]);
|
||||
}
|
||||
k += 1;
|
||||
v[k] = q;
|
||||
z[k] = s;
|
||||
z[k + 1] = f64::INFINITY;
|
||||
}
|
||||
k = 0;
|
||||
for (q, o) in out.iter_mut().enumerate() {
|
||||
while z[k + 1] < q as f64 {
|
||||
k += 1;
|
||||
}
|
||||
let d = q as f64 - v[k] as f64;
|
||||
*o = d * d + f[v[k]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Euclidean distance from every pixel to the nearest `true` pixel of `mask` (`w`×`h`),
|
||||
/// infinite when there is none.
|
||||
pub fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec<f32> {
|
||||
let mut g: Vec<f64> = mask.iter().map(|&m| if m { 0.0 } else { f64::INFINITY }).collect();
|
||||
// Columns, then rows.
|
||||
let cols: Vec<Vec<f64>> = (0..w)
|
||||
.into_par_iter()
|
||||
.map(|x| {
|
||||
let f: Vec<f64> = (0..h).map(|y| g[y * w + x]).collect();
|
||||
let mut o = vec![0.0; h];
|
||||
sq_dt_1d(&f, &mut o);
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
for (x, col) in cols.iter().enumerate() {
|
||||
for y in 0..h {
|
||||
g[y * w + x] = col[y];
|
||||
}
|
||||
}
|
||||
g.par_chunks_mut(w.max(1)).for_each(|row| {
|
||||
let f = row.to_vec();
|
||||
sq_dt_1d(&f, row);
|
||||
});
|
||||
g.iter().map(|&d| d.sqrt() as f32).collect()
|
||||
}
|
||||
|
||||
/// Signed distance to the boundary of the `inside` region: negative inside, positive outside
|
||||
/// (pixel centres; ±0.5 at the boundary).
|
||||
pub fn signed_distance(inside: &[bool], w: usize, h: usize) -> Plane {
|
||||
let outside: Vec<bool> = inside.iter().map(|v| !v).collect();
|
||||
let to_in = distance_transform(inside, w, h);
|
||||
let to_out = distance_transform(&outside, w, h);
|
||||
Plane { w, h, data: to_in.iter().zip(&to_out).map(|(a, b)| if *a > 0.0 { a - 0.5 } else { -(b - 0.5) }).collect() }
|
||||
}
|
||||
|
||||
/// Point-in-polygon (even-odd) for a closed polyline.
|
||||
pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool {
|
||||
let n = pts.len();
|
||||
|
||||
@@ -151,6 +151,13 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn params_at(&self, layer_time: f64) -> Option<Params> {
|
||||
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let g = self.layer.effects()?.groups().nth(i)?;
|
||||
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
|
||||
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
|
||||
}
|
||||
|
||||
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
|
||||
// The comp camera's view of the layer (as if 3D at its transform), brought back into the
|
||||
// layer's own pixel grid through the inverse of how the layer itself composites.
|
||||
|
||||
+3
-3
@@ -3,11 +3,11 @@
|
||||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 303 effects with 2716 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 303 effects with 2730 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 24 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 273 implemented in full, 30 partial (what is missing is listed).
|
||||
- **Status**: 274 implemented in full, 29 partial (what is missing is listed).
|
||||
|
||||
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
|
||||
|
||||
@@ -208,7 +208,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Grid | `ec.generate.grid` | 12 | | 32 | Implemented |
|
||||
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
|
||||
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
|
||||
| Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile |
|
||||
| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented |
|
||||
| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To |
|
||||
| Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented |
|
||||
| Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented |
|
||||
|
||||
Reference in new issue
Block a user