From ce6db35d14fd0d78267a033968f7ca64e075630f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:20:57 -0400 Subject: [PATCH 01/17] M9.13: Lumetri (HSL Secondary, hue/sat curves, RGB curves, HDR, looks) + Hue/Sat ranges + Levels/Curves channels + Timewarp optical flow - Lumetri: HSL Secondary key/refine/correction with Show Mask, Hue vs Sat/Hue/Luma, Luma vs Sat, Sat vs Sat and per-channel RGB curves, High Dynamic Range (HDR White / Specular), Input LUT and Look (custom .cube + built-in looks). - Hue/Saturation: Channel Control with Reds..Magentas ranges (range + fall-offs). - Levels: Channel popup and Red/Green/Blue/Alpha controls; Curves: Channel parameter. - catalog::param_shown: Effect Controls shows only what a popup selects. - Timewarp Pixel Motion: block-matching flow (raster::flow::block_flow_window) with Vector Detail, Smoothing, Error Threshold, Block Size, Weighting, Correct Luminance, Filtering, Build From One Image; Matte Layer/Channel, Warp Layer, Show, Source Crops. - GPU falls back to CPU when channel/range controls are used. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 44 +- crates/effects/src/color2.rs | 10 +- crates/effects/src/color3.rs | 511 ++++++++++++++++++- crates/effects/src/color_fx.rs | 282 +++++++++- crates/effects/src/lib.rs | 11 +- crates/effects/src/time_fx.rs | 475 ++++++++++++++++- crates/gpu/src/effects.rs | 8 + crates/raster/src/flow.rs | 31 +- crates/ui-egui/src/panels/effect_controls.rs | 68 ++- docs/effects.md | 14 +- 10 files changed, 1366 insertions(+), 88 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 4ec20906..449b1a4d 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -12,11 +12,7 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.channel.minimax", "no Don't Shrink Edges option"), ("ec.channel.combiner", "no Saturation Multiplied target"), ("ec.3d.channelextract", "no anti-alias option"), - ("ec.color.huesaturation", "no per-range Channel Control"), - ("ec.color.levels", "master channel only (no Channel popup)"), - ("ec.color.curves", "no Channel popup"), ("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"), - ("ec.color.lumetri", "no HSL Secondary, hue/saturation curves, HDR mode or look files"), ("ec.color.selectivecolor", "simplified Colors / Details layout"), ("ec.distort.turbulentdisplace", "no locked pinning variants"), ("ec.distort.opticscompensation", "no Optimal Pixels"), @@ -39,7 +35,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.noise.removegrain", "no preview region, sampling or temporal filtering"), ("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only"), - ("ec.time.timewarp", "Pixel Motion blends frames (no optical flow); no matte, warp or crop controls"), ("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"), ("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"), ("ec.sim.caustics", "no Sky group or light type"), @@ -52,6 +47,45 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.transition.blockdissolve", "no Soft Edges option"), ]; +/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path, +/// `group/param`) of an instance of effect `effect`, given the instance's current values +/// (`value(spec id)`). After Effects shows only the controls a popup selects: Levels' channel, +/// Hue/Saturation's Channel Control, the camera system of the card effects, and so on. Every +/// parameter stays animatable and addressable; this is presentation only. +pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option) -> bool { + let e = |id: &str| value(id).map(|v| v.as_enum()).unwrap_or(0); + let b = |id: &str| value(id).is_some_and(|v| v.as_bool()); + match effect { + "ec.color.levels" => { + let ch = e("channel") as usize; + for (i, pre) in crate::color_fx::LEVELS_CHANNEL_PREFIX.iter().enumerate() { + if param.starts_with(pre) { + return ch == i + 1; + } + } + let master = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite", "clipToOutputBlack", "clipToOutputWhite"]; + !master.contains(¶m) || ch == 0 + } + "ec.color.huesaturation" => { + let ch = e("channelControl") as usize; + for (i, (pre, _, _)) in crate::color_fx::HUESAT_RANGES.iter().enumerate() { + if param.starts_with(pre) { + return ch == i + 1; + } + } + match param { + "hue" | "saturation" | "lightness" => ch == 0, + "colorizeSaturation" | "colorizeLightness" => b("colorize"), + _ => true, + } + } + _ => { + let _ = b(""); + true + } + } +} + /// Implementation status of effect `id` (`Implemented` or `Partial: …`). pub fn status(id: &str) -> String { match PARTIAL.iter().find(|(e, _)| *e == id) { diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index f1c918bc..6e7475ea 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -911,7 +911,15 @@ pub fn specs() -> Vec { spec( "ec.color.curves", "Curves", - vec![curve("rgb", "RGB"), curve("red", "Red"), curve("green", "Green"), curve("blue", "Blue"), curve("alpha", "Alpha")], + vec![ + // The channel the graph edits (the Effect Controls graph's Channel popup). + p("channel", "Channel", Value::Enum(0), ParamUi::Hidden), + curve("rgb", "RGB"), + curve("red", "Red"), + curve("green", "Green"), + curve("blue", "Blue"), + curve("alpha", "Alpha"), + ], curves, ), spec("ec.color.levelsic", "Levels (Individual Controls)", lv_params, levels_ic), diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 5b9716ef..7d626d19 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -249,8 +249,193 @@ fn tint_offset(c: [f32; 4]) -> [f32; 3] { [c[0] - l, c[1] - l, c[2] - l] } +/// Control points `x,y x,y …` (0..1, whitespace or `;` separated), sorted by x. +fn parse_points(s: &str) -> Vec<(f32, f32)> { + let mut pts: Vec<(f32, f32)> = s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|t| { + let (x, y) = t.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + (x.is_finite() && y.is_finite()).then_some((x, y)) + }) + .collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + pts +} + +/// A Lumetri hue / saturation curve: control points `x,y` in 0..1 where y = 0.5 is neutral, +/// interpolated with a cardinal (Catmull-Rom) spline through the points. Hue curves wrap +/// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points. +/// Tabulated as offsets `y − 0.5`. +#[derive(Clone, Debug)] +pub(crate) struct OffsetCurve { + lut: Vec, +} + +impl OffsetCurve { + const N: usize = 512; + + /// `None` for an empty or flat-neutral curve. + pub(crate) fn parse(s: &str, periodic: bool) -> Option { + let pts = parse_points(s); + if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) { + return None; + } + let mut ext: Vec<(f32, f32)> = vec![]; + if periodic { + let wrapped: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x.rem_euclid(1.0), y)).collect(); + let mut w = wrapped; + w.sort_by(|a, b| a.0.total_cmp(&b.0)); + for k in -1..=1 { + ext.extend(w.iter().map(|&(x, y)| (x + k as f32, y))); + } + } else { + let (x0, y0) = pts[0]; + let (x1, y1) = pts[pts.len() - 1]; + ext.push((x0.min(0.0) - 2.0, y0)); + ext.push((x0.min(0.0) - 1.0, y0)); + ext.extend(pts.iter().copied()); + ext.push((x1.max(1.0) + 1.0, y1)); + ext.push((x1.max(1.0) + 2.0, y1)); + } + let n = ext.len(); + // Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat + // segments so the curve never overshoots its points. + let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6); + let m: Vec = (0..n) + .map(|k| { + if k == 0 || k == n - 1 { + return 0.0; + } + let (a, b) = (slope(k - 1), slope(k)); + if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 } + }) + .collect(); + let eval = |x: f32| -> f32 { + let k = ext.partition_point(|p| p.0 <= x).saturating_sub(1).min(n - 2); + let (xa, ya) = ext[k]; + let (xb, yb) = ext[k + 1]; + let h = (xb - xa).max(1e-6); + let t = ((x - xa) / h).clamp(0.0, 1.0); + let (t2, t3) = (t * t, t * t * t); + (2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * m[k] + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * m[k + 1] + }; + let single = pts.len() == 1; + let lut = (0..=Self::N).map(|i| if single { pts[0].1 - 0.5 } else { eval(i as f32 / Self::N as f32) - 0.5 }).collect(); + Some(OffsetCurve { lut }) + } + + /// Offset from neutral at `x` (clamped to 0..1). + pub(crate) fn at(&self, x: f32) -> f32 { + let f = x.clamp(0.0, 1.0) * Self::N as f32; + let i = (f as usize).min(Self::N - 1); + let t = f - i as f32; + self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t + } +} + +/// Lumetri's built-in creative looks (our own formulas; Custom loads a `.cube` file). +pub const LUMETRI_LOOKS: [&str; 8] = + ["None", "Custom", "EC Warm Film", "EC Cool Night", "EC Bleach Bypass", "EC Teal & Orange", "EC Faded Matte", "EC Monochrome"]; + +fn builtin_look(look: u32, c: [f32; 3]) -> [f32; 3] { + let l = luminance(c[0], c[1], c[2]); + match look { + 2 => [c[0] * 1.06 + 0.015, c[1] * 1.01 + 0.01, c[2] * 0.88], + 3 => [c[0] * 0.82, c[1] * 0.92, c[2] * 1.08 + 0.02].map(|v| v * 0.92), + 4 => { + // Silver retained: half the saturation, a luminance overlay for contrast. + let d = c.map(|v| l + (v - l) * 0.45); + let o = |v: f32| if l < 0.5 { 2.0 * v * l } else { 1.0 - 2.0 * (1.0 - v) * (1.0 - l) }; + d.map(|v| v * 0.5 + o(v) * 0.5) + } + 5 => { + let t = (l - 0.5).clamp(-0.5, 0.5); + [c[0] + 0.18 * t, c[1] + 0.03 * t, c[2] - 0.16 * t] + } + 6 => c.map(|v| 0.08 + v * 0.84).map(|v| l + (v - l) * 0.8), + 7 => [l; 3], + _ => c, + } +} + +/// HSL Secondary's Show Mask styles. +const HSL_MASK_STYLES: [&str; 3] = ["Color/Gray", "Color/Black", "White/Black"]; + +/// HSL Secondary key: membership (0..1) of an HSL colour (h, s, l in 0..1). +struct HslKey { + hue: Option<(f32, f32, f32)>, + sat: Option<(f32, f32, f32)>, + luma: Option<(f32, f32, f32)>, +} + +impl HslKey { + fn from(ctx: &EffectCtx) -> HslKey { + let pr = ctx.params; + let k = |id: &str| pr.f(&format!("hslSecondary/key/{id}")) as f32; + let on = |id: &str| pr.get(&format!("hslSecondary/key/{id}")).is_none_or(|v| v.as_bool()); + let band = |lo: &str, hi: &str, soft: &str| (k(lo) / 100.0, k(hi) / 100.0, k(soft) / 100.0); + HslKey { + hue: on("hueEnabled").then(|| (k("hueCenter"), k("hueRange"), k("hueSoftness"))), + sat: on("saturationEnabled").then(|| band("saturationLow", "saturationHigh", "saturationSoftness")), + luma: on("lightnessEnabled").then(|| band("lightnessLow", "lightnessHigh", "lightnessSoftness")), + } + } + fn band(v: f32, (lo, hi, soft): (f32, f32, f32)) -> f32 { + let (lo, hi) = (lo.min(hi), lo.max(hi)); + let d = if v < lo { + lo - v + } else if v > hi { + v - hi + } else { + 0.0 + }; + if d <= 0.0 { + 1.0 + } else if soft > 0.0 { + (1.0 - d / soft).max(0.0) + } else { + 0.0 + } + } + fn weight(&self, h: f32, s: f32, l: f32) -> f32 { + let mut w = 1.0; + if let Some((centre, range, soft)) = self.hue + && range < 180.0 + { + let d = ((h * 360.0 - centre).rem_euclid(360.0)).min((centre - h * 360.0).rem_euclid(360.0)); + let hw = if d <= range { + 1.0 + } else if soft > 0.0 { + (1.0 - (d - range) / soft).max(0.0) + } else { + 0.0 + }; + // Greys have no hue: they belong to a hue key only as far as they are saturated. + w *= hw * (s * 20.0).min(1.0); + } + if let Some(b) = self.sat { + w *= Self::band(s, b); + } + if let Some(b) = self.luma { + w *= Self::band(l, b); + } + w + } +} + +/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation), +/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves), +/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order. fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; + let hdr = pr.b("highDynamicRange"); + let hdr_white = if hdr { (pr.get("basicCorrection/tone/hdrWhite").map(Value::as_f64).unwrap_or(100.0) / 100.0).max(0.01) as f32 } else { 1.0 }; + let hdr_spec = if hdr { (pr.f("basicCorrection/tone/hdrSpecular") / 100.0) as f32 } else { 0.0 }; + let input_lut = if pr.e("basicCorrection/inputLut") == 1 { crate::utility::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None }; + let look = pr.e("creative/look"); + let look_lut = if look == 1 { crate::utility::load_lut(pr.s("creative/lookFile")) } else { None }; let temp = (pr.f("basicCorrection/whiteBalance/temperature") / 100.0) as f32; let tint = (pr.f("basicCorrection/whiteBalance/tint") / 100.0) as f32; let expo = 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32); @@ -277,6 +462,12 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { ] }; let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue")); + let rgb_curves = + ["curves/rgbCurves/master", "curves/rgbCurves/red", "curves/rgbCurves/green", "curves/rgbCurves/blue"].map(|id| crate::color2::Curve::parse(pr.s(id))); + let hs = |id: &str, periodic: bool| OffsetCurve::parse(pr.s(&format!("curves/hueSaturationCurves/{id}")), periodic); + let (hue_sat, hue_hue, hue_luma, luma_sat, sat_sat) = + (hs("hueVsSat", true), hs("hueVsHue", true), hs("hueVsLuma", true), hs("lumaVsSat", false), hs("satVsSat", false)); + let any_hs = hue_sat.is_some() || hue_hue.is_some() || hue_luma.is_some() || luma_sat.is_some() || sat_sat.is_some(); let ws = tint_offset(pr.color("colorWheels/shadowsWheel")); let wm = tint_offset(pr.color("colorWheels/midtonesWheel")); let wh_ = tint_offset(pr.color("colorWheels/highlightsWheel")); @@ -285,12 +476,29 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { let vround = (pr.f("vignette/vignetteRoundness") / 100.0) as f32; let vfeather = (pr.f("vignette/vignetteFeather") / 100.0) as f32; let sharpen = (pr.f("creative/adjustments/sharpen") / 100.0) as f32; + // HSL Secondary. + let sec = |id: &str, d: f64| pr.get(&format!("hslSecondary/{id}")).map(Value::as_f64).unwrap_or(d) as f32; + let s_temp = sec("correction/temperature", 0.0) / 100.0; + let s_tint = sec("correction/tint", 0.0) / 100.0; + let s_contrast = sec("correction/contrast", 0.0) / 100.0; + let s_sharpen = sec("correction/sharpen", 0.0) / 100.0; + let s_sat = sec("correction/saturation", 100.0) / 100.0; + let s_wheel = pr.get("hslSecondary/correction/correctionWheel").map(|v| tint_offset(v.as_color())).unwrap_or([0.0; 3]); + let show_mask = pr.b("hslSecondary/key/showMask"); + let sec_active = show_mask || s_temp != 0.0 || s_tint != 0.0 || s_contrast != 0.0 || s_sharpen != 0.0 || s_sat != 1.0 || s_wheel != [0.0; 3]; let (lx, ly, lw, lh) = layer_rect(ctx, &b); let w = b.img.width as usize; let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5); - map_colour(&mut b, |i, c| { + map_colour(&mut b, |_, c| { let mut c = c; - // Basic Correction: white balance, exposure, contrast, tone, saturation. + if let Some(l) = &input_lut { + c = l.apply(c); + } + // Basic Correction: white balance, exposure, contrast, tone, saturation. With High + // Dynamic Range the tone controls work on a range whose white is HDR White. + if hdr_white != 1.0 { + c = c.map(|v| v / hdr_white); + } if temp != 0.0 || tint != 0.0 { c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)]; } @@ -306,13 +514,24 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2); c = c.map(|v| v + d); } + if hdr_spec != 0.0 { + // HDR Specular scales what lies above white. + c = c.map(|v| if v > 1.0 { 1.0 + (v - 1.0) * (1.0 + hdr_spec).max(0.0) } else { v }); + } + if hdr_white != 1.0 { + c = c.map(|v| v * hdr_white); + } let l = luminance(c[0], c[1], c[2]); if sat != 1.0 { c = c.map(|v| l + (v - l) * sat); } - // Creative. - if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) { - let mut d = c; + // Creative: the look, then the adjustments, all at Intensity. + let has_look = look >= 2 || look_lut.is_some(); + if intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) { + let mut d = match &look_lut { + Some(lut) => lut.apply(c), + None => builtin_look(look, c), + }; if faded != 0.0 { d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded); } @@ -336,9 +555,40 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { } c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)]; } - // Curves. + // Curves: tone sliders, RGB curves (master, then each channel), hue/saturation curves. c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2])); c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])]; + if let Some(m) = &rgb_curves[0] { + c = c.map(|v| m.eval(v)); + } + for k in 0..3 { + if let Some(cv) = &rgb_curves[k + 1] { + c[k] = cv.eval(c[k]); + } + } + if any_hs { + let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)); + let chroma = (s * 20.0).min(1.0); + let off = |cv: &Option, x: f32| cv.as_ref().map(|cv| cv.at(x)).unwrap_or(0.0); + // y = 1 doubles saturation, y = 0 removes it. + let mut ms = (1.0 + 2.0 * off(&hue_sat, h) * chroma) * (1.0 + 2.0 * off(&luma_sat, l)) * (1.0 + 2.0 * off(&sat_sat, s)); + ms = ms.max(0.0); + let dh = off(&hue_hue, h) * chroma; + let dl = off(&hue_luma, h) * chroma; + let luma = luminance(c[0], c[1], c[2]); + if dh != 0.0 { + let (r, g, b2) = hsl_to_rgb((h + dh).rem_euclid(1.0), s, l); + // Keep values outside 0..1 (HDR) by moving only the in-range part. + let base = [c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)]; + c = [c[0] + r - base[0], c[1] + g - base[1], c[2] + b2 - base[2]]; + } + if ms != 1.0 { + c = c.map(|v| luma + (v - luma) * ms); + } + if dl != 0.0 { + c = c.map(|v| v + dl); + } + } // Color wheels. if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] { let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); @@ -349,6 +599,70 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z); } } + c + }); + // HSL Secondary: key the graded colour, refine the matte, apply the correction through it. + let mask = sec_active.then(|| { + let key = HslKey::from(ctx); + let mut m = Plane::from_image(&b.img, |px| { + let (c, a) = unpremul(px); + if a <= 0.0 { + return 0.0; + } + let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)); + key.weight(h, s, l) + }); + let denoise = sec("refine/denoise", 0.0) / 100.0; + if denoise > 0.0 { + // Smooth speckle, then restore the matte's edge contrast. + let s = (denoise * 3.0) as f64 * b.scale; + m = gauss_plane(&m, s, s); + let k = 1.0 + denoise * 2.0; + m.data.iter_mut().for_each(|v| *v = ((*v - 0.5) * k + 0.5).clamp(0.0, 1.0)); + } + let blur = sec("refine/blur", 0.0) / 100.0; + if blur > 0.0 { + let s = (blur * 10.0) as f64 * b.scale; + m = gauss_plane(&m, s, s); + } + if pr.b("hslSecondary/key/invertMask") { + m.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + m + }); + let style = pr.e("hslSecondary/key/maskStyle"); + map_colour(&mut b, |i, c| { + let mut c = c; + if let Some(m) = &mask { + let k = m.data[i]; + if show_mask { + return match style { + 0 => { + let g = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * 0.2 + 0.4; + [lerp(g, c[0], k), lerp(g, c[1], k), lerp(g, c[2], k)] + } + 1 => c.map(|v| v * k), + _ => [k; 3], + }; + } + if k > 0.0 { + let mut d = c; + if s_temp != 0.0 || s_tint != 0.0 { + d = [d[0] * (1.0 + 0.25 * s_temp), d[1] * (1.0 - 0.25 * s_tint), d[2] * (1.0 - 0.25 * s_temp)]; + } + if s_contrast != 0.0 { + d = d.map(|v| 0.5 + (v - 0.5) * (1.0 + s_contrast)); + } + let l = luminance(d[0], d[1], d[2]); + if s_sat != 1.0 { + d = d.map(|v| l + (v - l) * s_sat); + } + for j in 0..3 { + d[j] += 0.5 * s_wheel[j]; + } + c = [lerp(c[0], d[0], k), lerp(c[1], d[1], k), lerp(c[2], d[2], k)]; + } + } // Vignette. if vig != 0.0 { let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); @@ -366,7 +680,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { } c.map(|v| v.max(0.0)) }); - if sharpen != 0.0 { + let sec_sharpen = if show_mask { 0.0 } else { s_sharpen }; + if sharpen != 0.0 || (sec_sharpen != 0.0 && mask.is_some()) { let luma = Plane::luma(&b.img); let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale); b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { @@ -374,7 +689,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { if a <= 0.0 { return; } - let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0; + let amount = sharpen + sec_sharpen * mask.as_ref().map(|m| m.data[i]).unwrap_or(0.0); + let d = (luma.data[i] - blur.data[i]) * amount * 2.0; *px = premul(c.map(|v| (v + d).max(0.0)), a); }); } @@ -384,7 +700,10 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { let grey = || col(0.5, 0.5, 0.5); let mut lum = vec![ - p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None"])), + p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox), + p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])), + // A .cube file path (or the file's text) for Input LUT = Custom. + p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text), p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()), p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()), p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), @@ -393,8 +712,11 @@ pub fn specs() -> Vec { p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()), p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()), p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()), + p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)), + p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()), p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), - p("creative/look", "Look", Value::Enum(0), popup(&["None"])), + p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)), + p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text), p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()), p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()), @@ -421,10 +743,50 @@ pub fn specs() -> Vec { for (id, name) in CURVES { lum.push(p(id, name, num(0.0), bipolar())); } + // Curve graphs: control points "x,y x,y …" in 0..1 (like Curves). RGB curves default to + // the identity diagonal; hue/saturation curves are flat at y = 0.5 (no change) and empty + // means flat. + let pts = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Hidden); + lum.extend([ + pts("curves/rgbCurves/master", "RGB Curve", "0,0 1,1"), + pts("curves/rgbCurves/red", "Red Curve", "0,0 1,1"), + pts("curves/rgbCurves/green", "Green Curve", "0,0 1,1"), + pts("curves/rgbCurves/blue", "Blue Curve", "0,0 1,1"), + pts("curves/hueSaturationCurves/hueVsSat", "Hue vs Sat", ""), + pts("curves/hueSaturationCurves/hueVsHue", "Hue vs Hue", ""), + pts("curves/hueSaturationCurves/hueVsLuma", "Hue vs Luma", ""), + pts("curves/hueSaturationCurves/lumaVsSat", "Luma vs Sat", ""), + pts("curves/hueSaturationCurves/satVsSat", "Sat vs Sat", ""), + ]); lum.extend([ p("colorWheels/shadowsWheel", "Shadows", grey(), ParamUi::Color), p("colorWheels/midtonesWheel", "Midtones", grey(), ParamUi::Color), p("colorWheels/highlightsWheel", "Highlights", grey(), ParamUi::Color), + // HSL Secondary: key (hue centre / range / softness in degrees, saturation and + // lightness bands in %), refine, correction. + p("hslSecondary/key/hueEnabled", "H", Value::Bool(true), ParamUi::Checkbox), + p("hslSecondary/key/hueCenter", "Hue Center", num(0.0), ParamUi::Angle), + p("hslSecondary/key/hueRange", "Hue Range", num(180.0), slider(0.0, 180.0, 0.0, 180.0, 1)), + p("hslSecondary/key/hueSoftness", "Hue Softness", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)), + p("hslSecondary/key/saturationEnabled", "S", Value::Bool(true), ParamUi::Checkbox), + p("hslSecondary/key/saturationLow", "Saturation Low", num(0.0), pct()), + p("hslSecondary/key/saturationHigh", "Saturation High", num(100.0), pct()), + p("hslSecondary/key/saturationSoftness", "Saturation Softness", num(0.0), pct()), + p("hslSecondary/key/lightnessEnabled", "L", Value::Bool(true), ParamUi::Checkbox), + p("hslSecondary/key/lightnessLow", "Lightness Low", num(0.0), pct()), + p("hslSecondary/key/lightnessHigh", "Lightness High", num(100.0), pct()), + p("hslSecondary/key/lightnessSoftness", "Lightness Softness", num(0.0), pct()), + p("hslSecondary/key/showMask", "Show Mask", Value::Bool(false), ParamUi::Checkbox), + p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)), + p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox), + p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()), + p("hslSecondary/refine/blur", "Blur", num(0.0), pct()), + p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color), + p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()), + p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()), + p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()), + p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()), + p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()), p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()), @@ -580,4 +942,133 @@ mod tests { assert!(c.get(0, 0)[0] > 0.4); assert_eq!(c.data, run("ec.color.lumetri", &[("curves/curveMasterMidtones", num(50.0))], g).data); } + + fn s(v: &str) -> Value { + Value::Str(v.into()) + } + + /// Red, green and grey swatches side by side. + fn swatches() -> Image { + let mut img = Image::new(3, 1); + img.set(0, 0, [0.8, 0.1, 0.1, 1.0]); + img.set(1, 0, [0.1, 0.8, 0.1, 1.0]); + img.set(2, 0, [0.5, 0.5, 0.5, 1.0]); + img + } + + #[test] + fn lumetri_rgb_curves_per_channel() { + let g = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]); + let m = run("ec.color.lumetri", &[("curves/rgbCurves/master", s("0,0 0.5,0.75 1,1"))], g.clone()).get(0, 0); + assert!((m[0] - 0.75).abs() < 1e-3 && (m[2] - 0.75).abs() < 1e-3); + let r = run("ec.color.lumetri", &[("curves/rgbCurves/red", s("0,0 0.5,0.25 1,1"))], g).get(0, 0); + assert!((r[0] - 0.25).abs() < 1e-3 && (r[1] - 0.5).abs() < 1e-4); + } + + #[test] + fn lumetri_hue_saturation_curves_target_hues() { + let img = swatches(); + // Hue vs Sat: desaturate reds only (a dip to 0 at hue 0, neutral from 0.2 to 0.8). + let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsSat", s("0,0 0.2,0.5 0.8,0.5"))], img.clone()); + let red = out.get(0, 0); + assert!((red[0] - red[1]).abs() < 0.02, "red desaturated: {red:?}"); + assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-3), "green untouched"); + assert!(close_px(out.get(2, 0), img.get(2, 0), 1e-4), "grey untouched"); + // Hue vs Hue: shift reds by +1/6 turn (y = 0.5 + 60/360) towards yellow. + let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsHue", s("0,0.6667 0.2,0.5 0.8,0.5"))], img.clone()); + let p = out.get(0, 0); + assert!(p[1] > 0.6 && p[0] > 0.6 && p[2] < 0.2, "red became yellow: {p:?}"); + // Hue vs Luma brightens greens only. + let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsLuma", s("0.333,0.7 0.1,0.5 0.6,0.5"))], img.clone()); + assert!(out.get(1, 0)[1] > 0.85 && close_px(out.get(0, 0), img.get(0, 0), 1e-3)); + // Luma vs Sat / Sat vs Sat: y = 0 everywhere removes all saturation. + for id in ["lumaVsSat", "satVsSat"] { + let out = run("ec.color.lumetri", &[(&format!("curves/hueSaturationCurves/{id}"), s("0,0 1,0"))], img.clone()); + let p = out.get(0, 0); + assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: {p:?}"); + } + } + + fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps) + } + + #[test] + fn lumetri_hsl_secondary_keys_and_corrects() { + let img = swatches(); + let key_red = [("hslSecondary/key/hueCenter", num(0.0)), ("hslSecondary/key/hueRange", num(20.0)), ("hslSecondary/key/hueSoftness", num(10.0))]; + let mut vals: Vec<(&str, Value)> = key_red.to_vec(); + vals.push(("hslSecondary/correction/saturation", num(0.0))); + let out = run("ec.color.lumetri", &vals, img.clone()); + let r = out.get(0, 0); + assert!((r[0] - r[1]).abs() < 1e-3, "red desaturated through the key: {r:?}"); + assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-5) && close_px(out.get(2, 0), img.get(2, 0), 1e-5)); + // Show Mask (White/Black) shows the key; Invert Mask flips it. + let mut vals: Vec<(&str, Value)> = key_red.to_vec(); + vals.extend([("hslSecondary/key/showMask", Value::Bool(true)), ("hslSecondary/key/maskStyle", Value::Enum(2))]); + let m = run("ec.color.lumetri", &vals, img.clone()); + assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [1.0, 0.0, 0.0]); + vals.push(("hslSecondary/key/invertMask", Value::Bool(true))); + let m = run("ec.color.lumetri", &vals, img.clone()); + assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [0.0, 1.0, 1.0]); + // Lightness band keys only the bright grey; Blur softens the matte across pixels. + let vals = [ + ("hslSecondary/key/lightnessLow", num(40.0)), + ("hslSecondary/key/lightnessHigh", num(60.0)), + ("hslSecondary/key/showMask", Value::Bool(true)), + ("hslSecondary/key/maskStyle", Value::Enum(2)), + ]; + let m = run("ec.color.lumetri", &vals, img.clone()); + assert_eq!(m.get(2, 0)[0], 1.0); + let mut wide = Image::new(32, 1); + for x in 0..32 { + wide.set(x, 0, if x < 16 { [0.8, 0.1, 0.1, 1.0] } else { [0.1, 0.8, 0.1, 1.0] }); + } + let mut vals: Vec<(&str, Value)> = key_red.to_vec(); + vals.extend([ + ("hslSecondary/key/showMask", Value::Bool(true)), + ("hslSecondary/key/maskStyle", Value::Enum(2)), + ("hslSecondary/refine/blur", num(50.0)), + ]); + let m = run("ec.color.lumetri", &vals, wide); + let v = m.get(16, 0)[0]; + assert!(v > 0.05 && v < 0.95, "blurred matte edge {v}"); + } + + #[test] + fn lumetri_hdr_range_and_looks() { + // HDR White 200 nits: the tone controls see 1.6 as 0.8, so Highlights act on it. + let bright = Image::filled(2, 2, [1.6, 1.6, 1.6, 1.0]); + let sdr = run("ec.color.lumetri", &[("basicCorrection/tone/highlights", num(-100.0))], bright.clone()).get(0, 0)[0]; + let hdr = run( + "ec.color.lumetri", + &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrWhite", num(200.0)), ("basicCorrection/tone/highlights", num(-100.0))], + bright.clone(), + ) + .get(0, 0)[0]; + assert!(hdr < 1.6 - 0.05 && sdr < 1.6, "{sdr} {hdr}"); + let spec = run("ec.color.lumetri", &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrSpecular", num(100.0))], bright).get(0, 0)[0]; + assert!((spec - 2.2).abs() < 1e-4, "{spec}"); + // Built-in looks change the image at Intensity; 0 % leaves it. + let img = ramp(); + let mono = run("ec.color.lumetri", &[("creative/look", Value::Enum(7))], img.clone()); + let p = mono.get(9, 3); + assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5); + let none = run("ec.color.lumetri", &[("creative/look", Value::Enum(7)), ("creative/lookIntensity", num(0.0))], img.clone()); + assert!(close(&none, &img, 1e-5)); + // A Custom look from inline .cube text (swap red and blue). + let mut cube = String::from("LUT_3D_SIZE 2\n"); + for bb in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube += &format!("{bb} {g} {r}\n"); + } + } + } + let g = Image::filled(1, 1, [0.9, 0.2, 0.1, 1.0]); + let o = run("ec.color.lumetri", &[("creative/look", Value::Enum(1)), ("creative/lookFile", Value::Str(cube.clone()))], g.clone()).get(0, 0); + assert!((o[0] - 0.1).abs() < 1e-3 && (o[2] - 0.9).abs() < 1e-3, "{o:?}"); + let o = run("ec.color.lumetri", &[("basicCorrection/inputLut", Value::Enum(1)), ("basicCorrection/inputLutFile", Value::Str(cube))], g).get(0, 0); + assert!((o[0] - 0.1).abs() < 1e-3, "{o:?}"); + } } diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 71707daa..240dee1f 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -70,6 +70,85 @@ fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Hue/Saturation's colour ranges (Channel Control entries after Master): id prefix, display +/// name and centre hue in degrees. +pub const HUESAT_RANGES: [(&str, &str, f64); 6] = [ + ("reds", "Reds", 0.0), + ("yellows", "Yellows", 60.0), + ("greens", "Greens", 120.0), + ("cyans", "Cyans", 180.0), + ("blues", "Blues", 240.0), + ("magentas", "Magentas", 300.0), +]; + +/// Hue/Saturation's Channel Control popup. +pub const HUESAT_CHANNELS: [&str; 7] = ["Master", "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"]; + +/// One colour range of Hue/Saturation: the hues it covers fully (`start..end`, degrees, may +/// wrap), the fall-off widths either side, and its hue / saturation / lightness adjustments. +#[derive(Clone, Copy, Debug)] +struct HueRange { + start: f32, + end: f32, + fall_start: f32, + fall_end: f32, + hue: f32, + sat: f32, + light: f32, +} + +impl HueRange { + /// Membership of hue `h` (degrees): 1 inside the range, ramping to 0 across the fall-offs. + fn weight(&self, h: f32) -> f32 { + let width = (self.end - self.start).rem_euclid(360.0); + let d = (h - self.start).rem_euclid(360.0); + if d <= width { + return 1.0; + } + let after = d - width; + let before = 360.0 - d; + let wa = if self.fall_end > 0.0 { 1.0 - after / self.fall_end } else { 0.0 }; + let wb = if self.fall_start > 0.0 { 1.0 - before / self.fall_start } else { 0.0 }; + wa.max(wb).clamp(0.0, 1.0) + } +} + +/// The colour ranges with non-zero adjustments. +fn huesat_ranges(ctx: &EffectCtx) -> Vec { + let f = |id: String, d: f64| ctx.params.get(&id).map(Value::as_f64).unwrap_or(d) as f32; + HUESAT_RANGES + .iter() + .map(|(r, _, c)| HueRange { + start: f(format!("{r}RangeStart"), c - 15.0), + end: f(format!("{r}RangeEnd"), c + 15.0), + fall_start: f(format!("{r}StartFalloff"), 30.0).max(0.0), + fall_end: f(format!("{r}EndFalloff"), 30.0).max(0.0), + hue: f(format!("{r}Hue"), 0.0) / 360.0, + sat: f(format!("{r}Saturation"), 0.0) / 100.0, + light: f(format!("{r}Lightness"), 0.0) / 100.0, + }) + .filter(|r| r.hue != 0.0 || r.sat != 0.0 || r.light != 0.0) + .collect() +} + +/// Whether no colour range of Hue/Saturation adjusts anything (the GPU kernel does Master and +/// Colorize only). +pub fn huesat_ranges_identity(ctx: &EffectCtx) -> bool { + huesat_ranges(ctx).is_empty() +} + +fn adjust_sat(s: f32, sat: f32) -> f32 { + if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) } +} + +fn adjust_light(l: f32, light: f32) -> f32 { + if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) } +} + +/// Hue/Saturation. Master adjusts every pixel; each colour range (Reds … Magentas, chosen in +/// Channel Control) adjusts pixels whose original hue lies in its range, fading across the +/// fall-offs (greys, having no hue, belong to no range). Colorize replaces hue and +/// saturation. fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { let hue = ctx.params.f("hue") as f32 / 360.0; let sat = ctx.params.f("saturation") as f32 / 100.0; @@ -78,14 +157,24 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { let ch = ctx.params.f("colorizeHue") as f32 / 360.0; let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0; let cl = ctx.params.f("colorizeLightness") as f32 / 100.0; + let ranges = huesat_ranges(ctx); b.img.map_straight(|c| { let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); let (h, s, l) = if colorize { (ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0)) } else { - let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) }; - let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) }; - ((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2) + let mut s2 = adjust_sat(s, sat); + let mut l2 = adjust_light(l, light); + let mut h2 = h + hue; + for r in &ranges { + let w = r.weight(h * 360.0) * (s * 20.0).min(1.0); + if w > 0.0 { + h2 += r.hue * w; + s2 = adjust_sat(s2.clamp(0.0, 1.0), r.sat * w); + l2 = adjust_light(l2, r.light * w); + } + } + (h2.rem_euclid(1.0), s2.clamp(0.0, 1.0), l2) }; let (r, g, bl) = hsl_to_rgb(h, s, l); [r, g, bl] @@ -107,29 +196,87 @@ pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// Levels' Channel popup. +pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; + +/// Spec-id prefixes of Levels' per-channel controls (`redInBlack`, …; the RGB controls have +/// plain ids). +pub const LEVELS_CHANNEL_PREFIX: [&str; 4] = ["red", "green", "blue", "alpha"]; + +/// Levels' five controls (input black, input white, gamma, output black, output white) for +/// Channel popup index `ch` (0 = RGB). +pub fn levels_channel_ids(ch: usize) -> [String; 5] { + if ch == 0 || ch > 4 { + return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from); + } + let pre = LEVELS_CHANNEL_PREFIX[ch - 1]; + ["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}")) +} + +fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) -> f32 { + let mut t = (v - ib) / (iw - ib).max(1e-6); + if clip_b { + t = t.max(0.0); + } + if clip_w { + t = t.min(1.0); + } + let t = t.max(0.0).powf(1.0 / g); + ob + (ow - ob) * t +} + +const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; + +/// Levels: the RGB controls map every colour channel, then the Red / Green / Blue controls +/// their own channel and the Alpha controls alpha. All five sets are kept; the Channel popup +/// only picks the set Effect Controls shows. fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf { let ib = ctx.params.f("inBlack") as f32; let iw = ctx.params.f("inWhite") as f32; let g = ctx.params.f("gamma").max(0.01) as f32; let ob = ctx.params.f("outBlack") as f32; let ow = ctx.params.f("outWhite") as f32; - let (clip_b, clip_w) = levels_clip(ctx); - b.img.map_straight(|c| { - c.map(|v| { - let mut t = (v - ib) / (iw - ib).max(1e-6); - if clip_b { - t = t.max(0.0); + let clip = levels_clip(ctx); + b.img.map_straight(|c| c.map(|v| level(v, [ib, iw, g, ob, ow], clip))); + let chans = levels_channel_settings(ctx); + let ident = |l: &([f32; 5], (bool, bool))| l.0 == LEVELS_IDENTITY; + if chans.iter().all(ident) { + return b; + } + use rayon::prelude::*; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = crate::util::unpremul(*px); + let mut o = c; + for i in 0..3 { + if !ident(&chans[i]) { + o[i] = level(c[i], chans[i].0, chans[i].1); } - if clip_w { - t = t.min(1.0); - } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t - }) + } + let na = if ident(&chans[3]) { a } else { level(a, chans[3].0, chans[3].1).clamp(0.0, 1.0) }; + *px = crate::util::premul(o, na); }); b } +/// Levels' Red, Green, Blue and Alpha settings and clip switches (identity for projects saved +/// before the Channel popup). +pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] { + std::array::from_fn(|i| { + let ids = levels_channel_ids(i + 1); + let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); + let pre = LEVELS_CHANNEL_PREFIX[i]; + let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; + ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) + }) +} + +/// Whether Levels' per-channel controls are all at their defaults (the GPU kernel handles +/// only the RGB controls). +pub fn levels_channels_identity(ctx: &EffectCtx) -> bool { + levels_channel_settings(ctx).iter().all(|l| l.0 == LEVELS_IDENTITY) +} + /// Exposure's per-channel (gain, offset, gamma) for R, G, B: the Master group's values, or the /// Red / Green / Blue groups' with Channels = Individual Channels. pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { @@ -457,6 +604,24 @@ fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Levels' Red / Green / Blue / Alpha controls ("Red Input Black", …), in that order. +fn levels_channel_params() -> Vec { + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + let names = ["Input Black", "Input White", "Gamma", "Output Black", "Output White"]; + let mut v = vec![]; + for (i, pre) in LEVELS_CHANNEL_PREFIX.iter().enumerate() { + let label = LEVELS_CHANNELS[i + 1]; + let ids = levels_channel_ids(i + 1); + for k in 0..5 { + let ui = if k == 2 { slider(0.1, 10.0, 0.1, 3.0, 2) } else { slider(-1.0, 2.0, 0.0, 1.0, 3) }; + v.push(p(leak(ids[k].clone()), leak(format!("{label} {}", names[k])), num(LEVELS_IDENTITY[k] as f64), ui)); + } + v.push(p(leak(format!("{pre}ClipToOutputBlack")), leak(format!("{label} Clip To Output Black")), Value::Enum(2), popup(&LEVELS_CLIP))); + v.push(p(leak(format!("{pre}ClipToOutputWhite")), leak(format!("{label} Clip To Output White")), Value::Enum(2), popup(&LEVELS_CLIP))); + } + v +} + pub fn specs() -> Vec { let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1); let mut gpg = vec![p("blackStretch", "Black Stretch", num(0.0), slider(0.0, 4.0, 0.0, 4.0, 2))]; @@ -501,21 +666,39 @@ pub fn specs() -> Vec { spec( "ec.color.huesaturation", "Hue/Saturation", - vec![ - p("hue", "Master Hue", num(0.0), ParamUi::Angle), - p("saturation", "Master Saturation", num(0.0), pct()), - p("lightness", "Master Lightness", num(0.0), pct()), - p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox), - p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle), - p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)), - p("colorizeLightness", "Colorize Lightness", num(0.0), pct()), - ], + { + let mut v = vec![ + p("channelControl", "Channel Control", Value::Enum(0), popup(&HUESAT_CHANNELS)), + p("hue", "Master Hue", num(0.0), ParamUi::Angle), + p("saturation", "Master Saturation", num(0.0), pct()), + p("lightness", "Master Lightness", num(0.0), pct()), + ]; + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + for (r, name, c) in HUESAT_RANGES { + let single = name.trim_end_matches('s'); + v.push(p(leak(format!("{r}Hue")), leak(format!("{single} Hue")), num(0.0), ParamUi::Angle)); + v.push(p(leak(format!("{r}Saturation")), leak(format!("{single} Saturation")), num(0.0), pct())); + v.push(p(leak(format!("{r}Lightness")), leak(format!("{single} Lightness")), num(0.0), pct())); + v.push(p(leak(format!("{r}RangeStart")), leak(format!("{single} Range Start")), num(c - 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0))); + v.push(p(leak(format!("{r}RangeEnd")), leak(format!("{single} Range End")), num(c + 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0))); + v.push(p(leak(format!("{r}StartFalloff")), leak(format!("{single} Start Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0))); + v.push(p(leak(format!("{r}EndFalloff")), leak(format!("{single} End Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0))); + } + v.extend([ + p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox), + p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle), + p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("colorizeLightness", "Colorize Lightness", num(0.0), pct()), + ]); + v + }, hue_saturation, ), spec( "ec.color.levels", "Levels", vec![ + p("channel", "Channel", Value::Enum(0), popup(&LEVELS_CHANNELS)), p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)), p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)), p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)), @@ -525,7 +708,10 @@ pub fn specs() -> Vec { p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&LEVELS_CLIP)), // Projects saved before the two popups: "Don't Clip". p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Hidden), - ], + ] + .into_iter() + .chain(levels_channel_params()) + .collect(), levels, ), spec( @@ -837,4 +1023,50 @@ mod tests { assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15); assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6)); } + + #[test] + fn levels_channel_controls_act_on_their_channel() { + let g = [0.5, 0.5, 0.5, 1.0]; + assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing"); + let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g); + assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}"); + let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]); + assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}"); + let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]); + assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}"); + // Per-channel clipping follows its own popups. + let hot = [0.9, 0.9, 0.9, 1.0]; + let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1]; + let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1]; + assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}"); + } + + #[test] + fn hue_saturation_ranges_adjust_their_hues_only() { + let red = [0.8, 0.1, 0.1, 1.0]; + let blue = [0.1, 0.1, 0.8, 1.0]; + let grey = [0.5, 0.5, 0.5, 1.0]; + let v = [("redsSaturation", num(-100.0))]; + let r = px("ec.color.huesaturation", &v, red); + assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}"); + assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5)); + assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5)); + // Blues hue +120°: blue becomes red. + let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue); + assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}"); + // Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half. + let orange_ish = { + let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5); + [r, g, b, 1.0] + }; + let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish); + let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]); + assert!((l - 0.75).abs() < 0.01, "half weight: {l}"); + // Moving the range changes which hues it takes. + let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish); + assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99); + let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() }; + assert!(!huesat_ranges_identity(&ctx)); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..72ef00e1 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -60,7 +60,7 @@ pub mod warp_stab; use std::collections::HashMap; use std::sync::OnceLock; -pub use color_fx::{exposure_settings, levels_clip}; +pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip}; pub use color2::Curve; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -504,6 +504,12 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("curves", "Curves"), ("colorWheels", "Color Wheels"), ("vignette", "Vignette"), + ("rgbCurves", "RGB Curves"), + ("hueSaturationCurves", "Hue Saturation Curves"), + ("hslSecondary", "HSL Secondary"), + ("key", "Key"), + ("refine", "Refine"), + ("correction", "Correction"), // Camera Lens Blur. ("irisProperties", "Iris Properties"), ("highlight", "Highlight"), @@ -530,6 +536,9 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ // Time: Timewarp. ("tuning", "Tuning"), ("motionBlur", "Motion Blur"), + ("smoothing", "Smoothing"), + ("weighting", "Weighting"), + ("sourceCrops", "Source Crops"), // Simulation. ("extras", "Extras"), ("wiggle", "Wiggle"), diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index a6058132..e0c1aad7 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -6,8 +6,8 @@ //! masks and ignore effects applied before them (precompose to include those). Without a host //! (tests, thumbnails) they pass their input through. //! -//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by -//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet. +//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames +//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples. //! //! [`EffectHost::self_at`]: crate::EffectHost::self_at @@ -16,6 +16,8 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +use effectcraft_raster::flow::Flow; + use crate::util::{fit_layer, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; @@ -90,11 +92,29 @@ pub fn specs() -> Vec { p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)), p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)), p("tuning/vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("tuning/smoothing/globalSmoothness", "Global Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/smoothing/localSmoothness", "Local Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/smoothing/smoothingIterations", "Smoothing Iterations", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 0)), p("tuning/buildFromOneImage", "Build From One Image", Value::Bool(false), ParamUi::Checkbox), + p("tuning/correctLuminanceChanges", "Correct Luminance Changes", Value::Bool(false), ParamUi::Checkbox), + p("tuning/filtering", "Filtering", Value::Enum(0), popup(&["Normal", "Extreme"])), + p("tuning/errorThreshold", "Error Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("tuning/blockSize", "Block Size", num(8.0), slider(4.0, 64.0, 4.0, 32.0, 0)), + p("tuning/weighting/redWeighting", "Red Weighting", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/weighting/greenWeighting", "Green Weighting", num(0.59), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/weighting/blueWeighting", "Blue Weighting", num(0.11), slider(0.0, 1.0, 0.0, 1.0, 2)), p("motionBlur/enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox), p("motionBlur/shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])), p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)), p("motionBlur/shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + p("matteLayer", "Matte Layer", Value::Layer(None), ParamUi::Layer), + p("matteChannel", "Matte Channel", Value::Enum(0), popup(&TW_MATTE_CHANNELS)), + p("warpLayer", "Warp Layer", Value::Layer(None), ParamUi::Layer), + p("show", "Show", Value::Enum(0), popup(&TW_SHOW)), + p("sourceCrops/leftCrop", "Left Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), + p("sourceCrops/rightCrop", "Right Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), + p("sourceCrops/bottomCrop", "Bottom Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), + p("sourceCrops/topCrop", "Top Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), ], timewarp, ), @@ -428,6 +448,238 @@ pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f6 if by_frame { source_frame / fps } else { t * speed / 100.0 } } +/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops). +#[derive(Clone, Copy, Debug)] +pub struct PixelMotion { + /// Vector spacing in pixels (from Vector Detail). + pub tile: u32, + /// Matching block size in pixels. + pub window: u32, + pub global_smoothness: f32, + pub local_smoothness: f32, + pub iterations: u32, + pub build_from_one: bool, + pub correct_luminance: bool, + pub extreme: bool, + /// Error Threshold (0..1): pixels whose two warped frames disagree by more fall back to a + /// frame mix. + pub error_threshold: f32, + pub weights: [f32; 3], + /// Left, right, top, bottom crops in pixels. + pub crops: [u32; 4], +} + +impl PixelMotion { + fn from(ctx: &EffectCtx, scale: f64) -> PixelMotion { + let pr = ctx.params; + let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); + let detail = g("tuning/vectorDetail", 20.0).clamp(0.0, 100.0) / 100.0; + // Vector Detail 0 → one vector per 32 px, 100 → one per 2 px (exponential). + let tile = (32.0 * (1.0f64 / 16.0).powf(detail) * scale).round().max(2.0) as u32; + let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32; + PixelMotion { + tile, + window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32, + global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32, + local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32, + iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32, + build_from_one: pr.b("tuning/buildFromOneImage"), + correct_luminance: pr.b("tuning/correctLuminanceChanges"), + extreme: pr.e("tuning/filtering") == 1, + error_threshold: (g("tuning/errorThreshold", 10.0) / 100.0).clamp(0.0, 1.0) as f32, + weights: [g("tuning/weighting/redWeighting", 0.3), g("tuning/weighting/greenWeighting", 0.59), g("tuning/weighting/blueWeighting", 0.11)] + .map(|v| v.max(0.0) as f32), + crops: [px("sourceCrops/leftCrop"), px("sourceCrops/rightCrop"), px("sourceCrops/topCrop"), px("sourceCrops/bottomCrop")], + } + } +} + +/// Replace the cropped edges of `img` with the nearest kept pixel (Source Crops: edge garbage +/// must not drive or smear the motion). +fn crop_edges(img: &Image, [l, r, t, b]: [u32; 4]) -> Image { + if l == 0 && r == 0 && t == 0 && b == 0 { + return img.clone(); + } + let (w, h) = (img.width as i64, img.height as i64); + let (x0, x1) = ((l as i64).min(w - 1), (w - 1 - r as i64).max(0)); + let (y0, y1) = ((t as i64).min(h - 1), (h - 1 - b as i64).max(0)); + let mut out = img.clone(); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = img.get((x as i64).clamp(x0, x1.max(x0)), (y as i64).clamp(y0, y1.max(y0))); + } + }); + out +} + +/// Weighted grey (R·wr + G·wg + B·wb in every colour channel) that the motion is estimated on. +fn motion_grey(img: &Image, wt: [f32; 3], gain: f32) -> Image { + let sum = (wt[0] + wt[1] + wt[2]).max(1e-6); + let mut out = img.clone(); + out.data.par_iter_mut().for_each(|p| { + let g = (p[0] * wt[0] + p[1] * wt[1] + p[2] * wt[2]) / sum * gain; + *p = [g, g, g, p[3]]; + }); + out +} + +fn mean_grey(img: &Image) -> f32 { + let n = img.data.len().max(1) as f32; + img.data.par_iter().map(|p| p[0]).sum::() / n +} + +/// Smooth a motion field: `iterations` passes pulling each vector towards its 3×3 mean by +/// Local Smoothness, then a blend towards a wide average by Global Smoothness. +fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) { + let (c, r) = (flow.cols, flow.rows); + if c * r <= 1 { + return; + } + let mean = |v: &[[f32; 2]], rad: i64| -> Vec<[f32; 2]> { + (0..c * r) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % c) as i64, (i / c) as i64); + let mut s = [0.0f32; 2]; + let mut n = 0.0; + for dy in -rad..=rad { + for dx in -rad..=rad { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && ny >= 0 && (nx as usize) < c && (ny as usize) < r { + let q = v[ny as usize * c + nx as usize]; + s[0] += q[0]; + s[1] += q[1]; + n += 1.0; + } + } + } + [s[0] / n, s[1] / n] + }) + .collect() + }; + if pm.local_smoothness > 0.0 { + for _ in 0..pm.iterations { + let m = mean(&flow.v, 1); + for (v, q) in flow.v.iter_mut().zip(m) { + v[0] += (q[0] - v[0]) * pm.local_smoothness * 0.5; + v[1] += (q[1] - v[1]) * pm.local_smoothness * 0.5; + } + } + } + if pm.global_smoothness > 0.0 { + let m = mean(&flow.v, 3); + for (v, q) in flow.v.iter_mut().zip(m) { + v[0] += (q[0] - v[0]) * pm.global_smoothness * 0.5; + v[1] += (q[1] - v[1]) * pm.global_smoothness * 0.5; + } + } +} + +/// The motion field from frame `a` to frame `b` (or from the warp layer's frames). +pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow { + let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0); + let gain = if pm.correct_luminance { + let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0))); + if mb > 1e-4 { ma / mb } else { 1.0 } + } else { + 1.0 + }; + let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain); + let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4); + smooth_flow(&mut flow, pm); + flow +} + +/// Pixel Motion: the frame at fraction `w` between `a` and `b` built by warping along `flow` +/// (Build From One Image: from the nearer frame only), falling back to a mix where the two +/// warped frames disagree by more than the Error Threshold. +pub fn timewarp_interpolate(a: &Image, b: &Image, w: f32, flow: &Flow, pm: &PixelMotion) -> Image { + let (a, b) = (crop_edges(a, pm.crops), crop_edges(b, pm.crops)); + let mut out = Image::new(a.width, a.height); + let wd = w as f64; + let sample = |img: &Image, x: f64, y: f64| if pm.extreme { img.sample_bicubic(x, y) } else { img.sample_bilinear_clamped(x, y) }; + out.rows_mut().for_each(|(y, row)| { + let cy = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let cx = x as f64 + 0.5; + let f = flow.at(cx, cy); + let (fx, fy) = (f[0] as f64, f[1] as f64); + let pa = sample(&a, cx - wd * fx, cy - wd * fy); + let pb = sample(&b, cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy); + if pm.build_from_one { + *px = if w < 0.5 { pa } else { pb }; + continue; + } + let err = (0..4).map(|c| (pa[c] - pb[c]).abs()).fold(0.0f32, f32::max); + let (pa, pb) = if pm.error_threshold > 0.0 && err > pm.error_threshold && pm.error_threshold < 1.0 { + (a.get(x as i64, y as i64), b.get(x as i64, y as i64)) + } else { + (pa, pb) + }; + for c in 0..4 { + px[c] = pa[c] + (pb[c] - pa[c]) * w; + } + } + }); + out +} + +/// Timewarp's Matte Channel options. +const TW_MATTE_CHANNELS: [&str; 4] = ["Alpha", "Inverted Alpha", "Luminance", "Inverted Luminance"]; +/// Timewarp's Show options. +const TW_SHOW: [&str; 4] = ["Normal", "Foreground Only", "Background Only", "Matte"]; + +/// A matte plane (foreground = 1) from a matte layer frame. +fn matte_of(img: &Image, channel: u32) -> Vec { + img.data + .par_iter() + .map(|p| { + let (c, a) = unpremul(*p); + let l = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * a; + match channel { + 1 => 1.0 - a, + 2 => l, + 3 => 1.0 - l, + _ => a, + } + .clamp(0.0, 1.0) + }) + .collect() +} + +fn times_mask(img: &Image, m: &[f32], invert: bool) -> Image { + let mut out = img.clone(); + out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| { + let k = if invert { 1.0 - k } else { k }; + p.iter_mut().for_each(|v| *v *= k); + }); + out +} + +/// Another layer (layer parameter `id`) at the comp times matching source layer times `times`, +/// fitted onto `grid`. `None` when no layer is chosen. +fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option> { + let lid = ctx.params.get(id)?.as_layer()?; + let host = ctx.env.host?; + let me = ctx.params.get(&crate::layer_source_id(id)).is_none_or(|v| v.as_enum() != 0); + let shift = ctx.env.comp_time - ctx.time; + Some( + times + .iter() + .map(|t| match host.layer_at(lid, t + shift, me) { + Some(lp) => fit_layer(ctx, grid, &lp, false), + None => Image::new(grid.img.width, grid.img.height), + }) + .collect(), + ) +} + +/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two +/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion +/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning +/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a +/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter +/// Samples instants across the shutter. fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { if ctx.env.host.is_none() { return b; @@ -437,36 +689,126 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { let speed = ctx.params.f("speed"); let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); let method = ctx.params.e("method"); - // Build From One Image: interpolated frames come from the nearest source frame alone. let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); - // Sample times (with weights) for one instant of source time. - let instant = |s: f64| -> Vec<(f64, f32)> { - let f = s * fps; - let f0 = (f + 1e-6).floor(); - let frac = (f - f0) as f32; - if one { - return vec![((f + 1e-6).round() / fps, 1.0)]; - } - if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] } - }; - let mut samples: Vec<(f64, f32)> = Vec::new(); + // Instants of source time (with weights) across the shutter. + let mut instants: Vec<(f64, f32)> = vec![]; if ctx.params.b("motionBlur/enableMotionBlur") { let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 }; let n = ctx.params.f("motionBlur/shutterSamples").round().clamp(1.0, 64.0) as usize; let rate = if by_frame { 1.0 } else { speed / 100.0 }; let span = angle / 360.0 * rate / fps; for i in 0..n { - let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }; - samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32))); + instants.push((src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }, 1.0 / n as f32)); } } else { - samples = instant(src); + instants.push((src, 1.0)); + } + // Each instant needs the frame at or before it and the next one. + let split = |s: f64| { + let f = s * fps; + let f0 = (f + 1e-6).floor(); + (f0, ((f - f0) as f32).max(0.0)) + }; + let mut times: Vec = vec![]; + for &(s, _) in &instants { + let (f0, _) = split(s); + times.push(f0 / fps); + times.push((f0 + 1.0) / fps); } - let times: Vec = samples.iter().map(|s| s.0).collect(); - let weights: Vec = samples.iter().map(|s| s.1).collect(); let Some(fr) = fetch(ctx, ×) else { return b }; - let img = average(&fr.imgs, &weights); - with_img(fr.grid, img) + let grid = fr.grid.clone(); + let frame = |t: f64| -> &Image { + let k = times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + &fr.imgs[k] + }; + let pm = PixelMotion::from(ctx, grid.scale); + let mattes = fetch_layer(ctx, "matteLayer", ×, &grid); + let warps = fetch_layer(ctx, "warpLayer", ×, &grid); + let matte_ch = ctx.params.e("matteChannel"); + let show = ctx.params.e("show"); + let mut flows: Vec<((i64, bool), Flow)> = vec![]; + let mut out_frames = vec![]; + for &(s, wgt) in &instants { + let (f0, frac) = split(s); + let (t0, t1) = (f0 / fps, (f0 + 1.0) / fps); + let (a, bb) = (frame(t0), frame(t1)); + let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + let ma = mattes.as_ref().map(|m| matte_of(&m[idx(t0)], matte_ch)); + let mb = mattes.as_ref().map(|m| matte_of(&m[idx(t1)], matte_ch)); + let img = if method == 0 || frac < 1e-4 || (one && method == 1) { + // Whole frames (Build From One Image with Frame Mix: the nearest frame). + let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() }; + let base = if pick < 0.5 { a } else { bb }; + match (show, &ma, &mb) { + (1, Some(m), _) if pick < 0.5 => times_mask(base, m, false), + (2, Some(m), _) if pick < 0.5 => times_mask(base, m, true), + (1, _, Some(m)) => times_mask(base, m, false), + (2, _, Some(m)) => times_mask(base, m, true), + (3, Some(m), _) => matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base), + _ => base.clone(), + } + } else if method == 1 { + match (&ma, &mb, show) { + (Some(m0), Some(m1), 3) => effectcraft_raster::flow::mix(&matte_image(m0, a), &matte_image(m1, bb), frac), + (Some(m0), Some(m1), 1 | 2) => effectcraft_raster::flow::mix(×_mask(a, m0, show == 2), ×_mask(bb, m1, show == 2), frac), + _ => effectcraft_raster::flow::mix(a, bb, frac), + } + } else { + // Pixel Motion: vectors from the warp layer if any, else from the layer. + let key = ((f0 as i64), false); + let flow_ab = |fa: &Image, fb: &Image| -> Flow { timewarp_flow(fa, fb, &pm) }; + let flow = match flows.iter().find(|(k, _)| *k == key) { + Some((_, f)) => f.clone(), + None => { + let f = match &warps { + Some(w) => flow_ab(&w[idx(t0)], &w[idx(t1)]), + None => flow_ab(a, bb), + }; + flows.push((key, f.clone())); + f + } + }; + match (&ma, &mb) { + (Some(m0), Some(m1)) => { + // Foreground and background each move on their own vectors. + let (fa, fb) = (times_mask(a, m0, false), times_mask(bb, m1, false)); + let (ga, gb) = (times_mask(a, m0, true), times_mask(bb, m1, true)); + let fg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&fa, &fb) }; + let bg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&ga, &gb) }; + let fg = timewarp_interpolate(&fa, &fb, frac, &fg_flow, &pm); + let bg = timewarp_interpolate(&ga, &gb, frac, &bg_flow, &pm); + match show { + 1 => fg, + 2 => bg, + 3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::>(), &fg), + _ => { + let mut o = bg; + o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| { + let k = 1.0 - q[3]; + for c in 0..4 { + p[c] = q[c] + p[c] * k; + } + }); + o + } + } + } + _ => timewarp_interpolate(a, bb, frac, &flow, &pm), + } + }; + out_frames.push((img, wgt)); + } + let imgs: Vec = out_frames.iter().map(|(i, _)| i.clone()).collect(); + let weights: Vec = out_frames.iter().map(|(_, w)| *w).collect(); + let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) }; + with_img(grid, img) +} + +/// A matte shown as opaque grey. +fn matte_image(m: &[f32], like: &Image) -> Image { + let mut out = Image::new(like.width, like.height); + out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| *p = [k, k, k, 1.0]); + out } // ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur @@ -563,6 +905,97 @@ mod tests { assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]); } + /// Texture `x − dx` (smooth, non-repeating). + fn texture(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f64 - dx, y as f64); + let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.031 + v * 0.017).sin()) as f32; + img.set(x, y, [s, s * 0.8, 1.0 - s, 1.0]); + } + } + img + } + + /// A textured layer moving right 3 px per frame (10 fps); layer 7 (a "warp" / matte layer) + /// is the same texture standing still, layer 8 a matte that is white on the left half. + struct Moving; + impl EffectHost for Moving { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: texture(96, 64, (t * 10.0).round() * 3.0), offset: [0.0; 2], scale: 1.0 }) + } + fn layer_at(&self, id: u64, _: f64, _: bool) -> Option { + let img = match id { + 7 => texture(96, 64, 0.0), + _ => { + let mut m = Image::new(96, 64); + for y in 0..64 { + for x in 0..48 { + m.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + m + } + }; + Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [96.0, 64.0] }) + } + } + + fn mae(a: &Image, b: &Image, margin: i64) -> f32 { + let (w, h) = (a.width as i64, a.height as i64); + let mut s = 0.0; + let mut n = 0.0; + for y in margin..h - margin { + for x in margin..w - margin { + let (p, q) = (a.get(x, y), b.get(x, y)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3.0; + } + } + s / n + } + + fn run_moving(vals: &[(&str, Value)], t: f64) -> Image { + let env = EffectEnv { host: Some(&Moving), frame_rate: 10.0, comp_time: t, ..Default::default() }; + run_fx("ec.time.timewarp", vals, Image::new(96, 64), t, env).img + } + + #[test] + fn timewarp_pixel_motion_follows_the_motion() { + // Speed 50 % at layer time 1.1 s: source 0.55 s, halfway between frames 5 and 6 (15 and + // 18 px), so the true in-between frame is the texture at 16.5 px. + let want = texture(96, 64, 16.5); + let pm = run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1); + let mix = run_moving(&[("method", Value::Enum(1))], 1.1); + let (e_pm, e_mix) = (mae(&pm, &want, 8), mae(&mix, &want, 8)); + assert!(e_pm < e_mix * 0.5, "pixel motion {e_pm} vs frame mix {e_mix}"); + // Deterministic and seek-consistent (same time → same pixels). + assert_eq!(pm.data, run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1).data); + // Build From One Image warps the nearer frame alone: still close to the truth. + let one = run_moving(&[("method", Value::Enum(2)), ("tuning/buildFromOneImage", Value::Bool(true)), ("tuning/vectorDetail", num(60.0))], 1.1); + assert!(mae(&one, &want, 8) < e_mix * 0.5); + // A Warp Layer standing still supplies zero motion: the result is a plain mix. + let warped = run_moving(&[("method", Value::Enum(2)), ("warpLayer", Value::Layer(Some(7))), ("tuning/errorThreshold", num(100.0))], 1.1); + assert!(mae(&warped, &mix, 0) < 1e-3, "{}", mae(&warped, &mix, 0)); + // Show Matte displays the matte layer; Foreground Only keeps the matte's left half. + let m = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(3))], 1.1); + assert!(m.get(10, 10)[0] > 0.99 && m.get(80, 10)[0] < 0.01); + let fg = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(1))], 1.1); + assert!(fg.get(10, 30)[3] > 0.99 && fg.get(85, 30)[3] < 0.01); + let normal = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("tuning/vectorDetail", num(60.0))], 1.1); + assert!(mae(&normal, &want, 12) < e_mix, "{} {e_mix}", mae(&normal, &want, 12)); + // Source Crops: a crop replicates the kept edge into the cropped band. + let c = run_moving(&[("method", Value::Enum(2)), ("sourceCrops/leftCrop", num(10.0))], 1.1); + assert_eq!(c.get(0, 20), c.get(5, 20)); + } + #[test] fn timewarp_and_wide_time() { let h = Clock { static_layer: false }; diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b3..f84d4bb1 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -307,11 +307,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [br, k, ct, 0.0]; } "ec.color.huesaturation" => { + // Colour ranges (Channel Control) render on the CPU. + if !effectcraft_effects::huesat_ranges_identity(ctx) { + return None; + } p.u[0] = [3, ctx.params.b("colorize") as u32, 0, 0]; p.f[0] = [f("hue") as f32 / 360.0, f("saturation") as f32 / 100.0, f("lightness") as f32 / 100.0, 0.0]; p.f[1] = [f("colorizeHue") as f32 / 360.0, f("colorizeSaturation") as f32 / 100.0, f("colorizeLightness") as f32 / 100.0, 0.0]; } "ec.color.levels" => { + // Red / Green / Blue / Alpha controls render on the CPU. + if !effectcraft_effects::levels_channels_identity(ctx) { + return None; + } let (clip_b, clip_w) = effectcraft_effects::levels_clip(ctx); p.u[0] = [4, clip_b as u32, clip_w as u32, 0]; p.f[0] = [f("inBlack") as f32, f("inWhite") as f32, f("gamma").max(0.01) as f32, f("outBlack") as f32]; diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs index c398eadd..feb033d6 100644 --- a/crates/raster/src/flow.rs +++ b/crates/raster/src/flow.rs @@ -95,18 +95,28 @@ fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32 /// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution; /// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`). pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { - let bs = block.max(4) as usize; + block_flow_window(a, b, block, block, radius) +} + +/// [`block_flow`] with separate vector spacing and matching window: one vector per +/// `tile × tile` tile, each matched with the `window × window` block centred on its tile +/// (Timewarp's Vector Detail and Block Size). +pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow { + let bs = tile.max(2) as usize; + let win = window.max(4) as i64; + // Window origin relative to the tile origin. + let wo = bs as i64 / 2 - win / 2; let (w, h) = (a.width as usize, a.height as usize); let cols = w.div_ceil(bs).max(1); let rows = h.div_ceil(bs).max(1); let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; - if a.width != b.width || a.height != b.height || w < bs || h < bs { + if a.width != b.width || a.height != b.height || w < bs || h < bs || (w as i64) < win || (h as i64) < win { return flow; } // Pyramids (level 0 = full resolution); stop while blocks still fit. let mut pa = vec![Plane::luma(a)]; let mut pb = vec![Plane::luma(b)]; - while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) { + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (bs as i64).max(win) as usize * 2 && p.h / 2 >= (bs as i64).max(win) as usize * 2) { let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); pa.extend(na); pb.extend(nb); @@ -118,15 +128,15 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { let (la, lb) = (&pa[level], &pb[level]); let k = 1i64 << level; // The block covers the tile's footprint at this level (at least 4 pixels). - let lbs = ((bs as i64) / k).max(4); + let lbs = (win / k).max(4); let r = if level == top { radius as i64 } else { 1 }; let prev = cur.clone(); cur = (0..cols * rows) .into_par_iter() .map(|i| { let (c, rr) = ((i % cols) as i64, (i / cols) as i64); - let bx = c * bs as i64 / k; - let by = rr * bs as i64 / k; + let bx = (c * bs as i64 + wo) / k; + let by = (rr * bs as i64 + wo) / k; // Candidates: this tile's and its neighbours' vectors from the coarser level. let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; if level != top { @@ -160,18 +170,19 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. let (la, lb) = (&pa[0], &pb[0]); let bsi = bs as i64; + let wsi = win; let fine: Vec<[f32; 2]> = (0..cols * rows) .into_par_iter() .map(|i| { - let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi); + let (bx, by) = ((i % cols) as i64 * bsi + wo, (i / cols) as i64 * bsi + wo); let d = cur[i]; - let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]); + let e0 = sad(la, lb, bx, by, wsi, d[0], d[1]); let fit = |em: f32, ep: f32| { let den = em - 2.0 * e0 + ep; if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 } }; - let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1])); - let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1)); + let sx = fit(sad(la, lb, bx, by, wsi, d[0] - 1, d[1]), sad(la, lb, bx, by, wsi, d[0] + 1, d[1])); + let sy = fit(sad(la, lb, bx, by, wsi, d[0], d[1] - 1), sad(la, lb, bx, by, wsi, d[0], d[1] + 1)); [d[0] as f32 + sx, d[1] as f32 + sy] }) .collect(); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index ff6d07a8..02dcb1a9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -441,6 +441,37 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P } } +/// The effect instance a row belongs to, for conditional visibility +/// ([`effectcraft_engine::effects::catalog::param_shown`]): effect id, the instance's root +/// group and the spec-id path of the group being drawn (`""` or `cameraPosition/`). +struct FxScope<'a> { + effect: &'a str, + root: &'a PropGroup, + path: String, +} + +impl<'a> FxScope<'a> { + fn sub(&self, m: &str) -> FxScope<'a> { + FxScope { effect: self.effect, root: self.root, path: format!("{}{m}/", self.path) } + } + /// Whether child `m` of the current group is shown. + fn shown(&self, layer: &Layer, ectx: &EvalCtx, m: &str) -> bool { + let root = self.root; + let value = |id: &str| -> Option { + let mut g = root; + let mut parts = id.split('/').peekable(); + while let Some(part) = parts.next() { + if parts.peek().is_none() { + return g.get(part).map(|p| ectx.value(layer, p)); + } + g = g.sub(part)?; + } + None + }; + effectcraft_engine::effects::catalog::param_shown(self.effect, &format!("{}{m}", self.path), &value) + } +} + #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -452,6 +483,7 @@ fn group_rows( rect: Rect, y: &mut f32, depth: usize, + fx: &FxScope, actions: &mut Actions, ) { let t = app.tokens; @@ -459,7 +491,7 @@ fn group_rows( let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW)); match c { Node::Prop(pr) => { - if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() { + if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() || !fx.shown(layer, ectx, &pr.match_id) { continue; } *y += ROW; @@ -486,7 +518,7 @@ fn group_rows( } Node::Group(sg) => { // Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent). - if effectcraft_engine::effects::paint::is_paint(g) { + if effectcraft_engine::effects::paint::is_paint(g) || !fx.shown(layer, ectx, &sg.match_id) { continue; } *y += ROW; @@ -503,7 +535,8 @@ fn group_rows( p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text); } if open { - group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, actions); + let sub = fx.sub(&sg.match_id); + group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions); } } } @@ -642,12 +675,24 @@ fn channel_popup( fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { let t = app.tokens; let euid = g.uid; - let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(4); + // The channel lives in the effect's hidden Channel parameter (UI state for instances + // saved before it existed). + let chan_prop = g.get("channel"); + let ch = match chan_prop { + Some(pr) => ectx.value(layer, pr).as_enum() as usize, + None => app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0), + } + .min(4); let x0 = r.min.x + 30.0; let cy = r.min.y + 17.0; let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect(); if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) { - app.ui.fx_curve_channel.insert(euid, n); + match chan_prop { + Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))), + None => { + app.ui.fx_curve_channel.insert(euid, n); + } + } } let pencil = app.ui.fx_curve_pencil.contains(&euid); let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0)); @@ -696,7 +741,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, /// Levels parameter ids for a channel: input black, input white, gamma, output black, output white. fn levels_ids(effect: &str, ch: usize) -> [String; 5] { if effect == "ec.color.levels" { - return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from); + return effectcraft_engine::effects::levels_channel_ids(ch); } // Levels (Individual Controls) nests each channel's controls in its twirl-down group. let pre = fw::LEVELS_CHANNELS[ch.min(4)].0; @@ -721,7 +766,13 @@ fn levels_editor( let euid = g.uid; let x0 = r.min.x + 30.0; let cy = r.min.y + 17.0; - let ch = if effect == "ec.color.levelsic" { app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) } else { 0 }; + // Levels keeps its channel in the Channel parameter; Levels (Individual Controls) shows + // every channel and the popup only picks the histogram. + let ch = if effect == "ec.color.levelsic" { + app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) + } else { + g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4) + }; if effect == "ec.color.levelsic" { let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect(); if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel")) @@ -959,7 +1010,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions); + let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() }; + group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions); } y += 4.0; } diff --git a/docs/effects.md b/docs/effects.md index 75d32478..15db5435 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2434 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 2562 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**: 261 implemented in full, 42 partial (what is missing is listed). +- **Status**: 266 implemented in full, 37 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -103,15 +103,15 @@ Effects are addressed by id (`ec..`) or display name in commands | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | | Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | -| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup | +| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented | | Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | | Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented | -| Hue/Saturation | `ec.color.huesaturation` | 7 | GPU | 32 | Partial: no per-range Channel Control | +| Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented | | Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | -| Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) | +| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | -| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | +| Lumetri Color | `ec.color.lumetri` | 66 | | 32 | Implemented | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented | @@ -383,7 +383,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented | | Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented | | Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented | -| Timewarp | `ec.time.timewarp` | 10 | | 32 | Partial: Pixel Motion blends frames (no optical flow); no matte, warp or crop controls | +| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented | ## Transition From 0ed5c87b942b9909e0e0f6fb07156a267dce46fe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:59:10 -0400 Subject: [PATCH 02/17] M9.13: Card Dance / Shatter / Card Wipe camera systems, lighting and material - card3d: shared Camera System (Camera Position with rotations, X/Y position and Transform Order; Corner Pins with focal length; Comp Camera via EffectHost::comp_scene), Lighting (Distant / Point / First Comp Light, colour, position, depth, ambient) and Material (diffuse, specular, highlight sharpness). - Shatter: Custom Shatter Map (+ White Tiles Fixed), Gradient group, Textures group (front/side/back modes and layers, colour, opacity) and extrusion side rendering. - Card Wipe: true 3D cards, Back Layer as a layer parameter (old None/Self popup migrated), Flip Direction, Position / Rotation Jitter. - Renderer supplies the comp camera and first light to effects. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/card3d.rs | 413 ++++++++++++++++++++++++++ crates/effects/src/catalog.rs | 8 +- crates/effects/src/lib.rs | 17 +- crates/effects/src/migrate.rs | 34 +++ crates/effects/src/noise3.rs | 2 +- crates/effects/src/sim2.rs | 481 ++++++++++++++++++++++++++----- crates/effects/src/transition.rs | 260 ++++++++++++----- crates/render/src/lib.rs | 25 ++ crates/render/src/tests.rs | 13 + docs/effects.md | 10 +- 10 files changed, 1118 insertions(+), 145 deletions(-) create mode 100644 crates/effects/src/card3d.rs diff --git a/crates/effects/src/card3d.rs b/crates/effects/src/card3d.rs new file mode 100644 index 00000000..5bb29d10 --- /dev/null +++ b/crates/effects/src/card3d.rs @@ -0,0 +1,413 @@ +//! The 3D system shared by Card Dance, Shatter and Card Wipe: Camera System (Camera Position, +//! Corner Pins, Comp Camera), Lighting (Distant Source, Point Source, First Comp Light) and +//! Material (diffuse, specular, highlight sharpness). +//! +//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the +//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels +//! (homogeneous), so every camera system reduces to one 3×4 matrix. + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; + +use crate::sim::pt; +use crate::{Buf, EffectCtx, ParamSpec, col, num, p, popup, slider}; + +pub(crate) type M3 = [[f64; 3]; 3]; + +/// Camera System options. +pub const CAMERA_SYSTEMS: [&str; 3] = ["Camera Position", "Corner Pins", "Comp Camera"]; +/// Transform Order options (rotation order, and whether the camera position applies before or +/// after the rotation). +pub const TRANSFORM_ORDERS: [&str; 12] = [ + "Rotate XYZ, Position", + "Rotate XZY, Position", + "Rotate YXZ, Position", + "Rotate YZX, Position", + "Rotate ZXY, Position", + "Rotate ZYX, Position", + "Position, Rotate XYZ", + "Position, Rotate XZY", + "Position, Rotate YXZ", + "Position, Rotate YZX", + "Position, Rotate ZXY", + "Position, Rotate ZYX", +]; +/// Light Type options. +pub const LIGHT_TYPES: [&str; 3] = ["Distant Source", "Point Source", "First Comp Light"]; + +/// Camera System, Camera Position and Corner Pins parameters. `z` is the default camera +/// distance (Z Position, in layer sizes at the 70 mm-equivalent Focal Length). +pub(crate) fn camera_params(z: f64) -> Vec { + vec![ + p("cameraSystem", "Camera System", Value::Enum(0), popup(&CAMERA_SYSTEMS)), + p("cameraPosition/xRotation", "X Rotation", num(0.0), ParamUi::Angle), + p("cameraPosition/yRotation", "Y Rotation", num(0.0), ParamUi::Angle), + p("cameraPosition/zRotation", "Z Rotation", num(0.0), ParamUi::Angle), + p("cameraPosition/xyPosition", "X, Y Position", pt(0.5, 0.5), ParamUi::Point), + p("cameraPosition/cameraZ", "Z Position", num(z), slider(0.05, 10.0, 0.5, 5.0, 2)), + p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), + p("cameraPosition/transformOrder", "Transform Order", Value::Enum(0), popup(&TRANSFORM_ORDERS)), + p("cornerPins/upperLeftCorner", "Upper Left Corner", pt(0.0, 0.0), ParamUi::Point), + p("cornerPins/upperRightCorner", "Upper Right Corner", pt(1.0, 0.0), ParamUi::Point), + p("cornerPins/lowerLeftCorner", "Lower Left Corner", pt(0.0, 1.0), ParamUi::Point), + p("cornerPins/lowerRightCorner", "Lower Right Corner", pt(1.0, 1.0), ParamUi::Point), + p("cornerPins/autoFocalLength", "Auto Focal Length", Value::Bool(true), ParamUi::Checkbox), + p("cornerPins/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), + ] +} + +/// Lighting parameters. +pub(crate) fn lighting_params(intensity: f64, ambient: f64) -> Vec { + vec![ + p("lighting/lightType", "Light Type", Value::Enum(0), popup(&LIGHT_TYPES)), + p("lighting/lightIntensity", "Light Intensity", num(intensity), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lighting/lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point), + p("lighting/lightDepth", "Light Depth", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 3)), + p("lighting/ambientLight", "Ambient Light", num(ambient), slider(0.0, 2.0, 0.0, 1.0, 2)), + ] +} + +/// Material parameters. Specular defaults to 0 so pieces at rest reproduce the layer. +pub(crate) fn material_params(diffuse: f64) -> Vec { + vec![ + p("material/diffuse", "Diffuse Reflection", num(diffuse), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/specular", "Specular Reflection", num(0.0), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), + ] +} + +/// Whether the camera / lighting parameter `param` is shown for the current choices. +pub(crate) fn shown(param: &str, e: &dyn Fn(&str) -> u32) -> Option { + if param == "cameraPosition" || param.starts_with("cameraPosition/") { + return Some(e("cameraSystem") == 0); + } + if param == "cornerPins" || param.starts_with("cornerPins/") { + return Some(e("cameraSystem") == 1); + } + if param == "lighting/lightPosition" || param == "lighting/lightDepth" { + return Some(e("lighting/lightType") != 2); + } + None +} + +pub(crate) fn mat_mul(a: &M3, b: &M3) -> M3 { + std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) +} + +fn rx(a: f64) -> M3 { + let (s, c) = a.sin_cos(); + [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]] +} +fn ry(a: f64) -> M3 { + let (s, c) = a.sin_cos(); + [[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]] +} +fn rz(a: f64) -> M3 { + let (s, c) = a.sin_cos(); + [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]] +} + +/// Rotation applying the axes in `order` (0 XYZ, 1 XZY, 2 YXZ, 3 YZX, 4 ZXY, 5 ZYX; the first +/// letter is applied first). Angles in radians. +pub(crate) fn rot_ordered(x: f64, y: f64, z: f64, order: u32) -> M3 { + let (mx, my, mz) = (rx(x), ry(y), rz(z)); + let seq: [&M3; 3] = match order % 6 { + 1 => [&mx, &mz, &my], + 2 => [&my, &mx, &mz], + 3 => [&my, &mz, &mx], + 4 => [&mz, &mx, &my], + 5 => [&mz, &my, &mx], + _ => [&mx, &my, &mz], + }; + mat_mul(seq[2], &mat_mul(seq[1], seq[0])) +} + +/// A camera: buffer-world → buffer pixels (homogeneous rows X, Y, W; W is the depth in front +/// of the camera, in the units `near` uses). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Proj { + pub m: [[f64; 4]; 3], + /// Points with W below this are behind / too close to the camera. + pub near: f64, +} + +impl Proj { + /// Looking down +z at the layer plane from `d` pixels in front of buffer point `c`. + pub fn simple(c: [f64; 2], d: f64) -> Proj { + Proj { m: [[d, 0.0, c[0], 0.0], [0.0, d, c[1], 0.0], [0.0, 0.0, 1.0, d]], near: 0.05 * d } + } + + /// `self` after the affine scene transform `p ↦ a·p + t`. + fn after(&self, a: &M3, t: [f64; 3]) -> Proj { + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..3 { + m[i][j] = (0..3).map(|k| self.m[i][k] * a[k][j]).sum(); + } + m[i][3] = (0..3).map(|k| self.m[i][k] * t[k]).sum::() + self.m[i][3]; + } + Proj { m, near: self.near } + } + + /// A screen-space homography applied after `self`. + fn then(&self, h: &M3) -> Proj { + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..4 { + m[i][j] = (0..3).map(|k| h[i][k] * self.m[k][j]).sum(); + } + } + // Keep W's scale (and so `near`) comparable when H rescales it. + Proj { m, near: self.near * h[2][2].abs().max(1e-9) } + } + + /// Project a buffer-world point (None behind the camera). + #[cfg(test)] + pub fn project(&self, p: [f64; 3]) -> Option<[f64; 2]> { + let r = |i: usize| self.m[i][0] * p[0] + self.m[i][1] * p[1] + self.m[i][2] * p[2] + self.m[i][3]; + let w = r(2); + (w > self.near).then(|| [r(0) / w, r(1) / w]) + } + + /// The camera's centre in buffer-world space (where the projection collapses), or a point + /// far in front of the layer when it has none (orthographic). + pub fn eye(&self) -> [f64; 3] { + let a: M3 = std::array::from_fn(|i| [self.m[i][0], self.m[i][1], self.m[i][2]]); + match inv3(&a) { + Some(ai) => { + let t = [-self.m[0][3], -self.m[1][3], -self.m[2][3]]; + std::array::from_fn(|i| (0..3).map(|k| ai[i][k] * t[k]).sum()) + } + None => [0.0, 0.0, -1e9], + } + } +} + +pub(crate) fn inv3(m: &M3) -> Option { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + if det.abs() < 1e-12 { + return None; + } + let d = 1.0 / det; + Some([ + [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d], + [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d], + [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d], + ]) +} + +/// Homography taking the unit square's corners (0,0) (1,0) (1,1) (0,1) to `q`. +fn square_to_quad(q: [[f64; 2]; 4]) -> M3 { + let v = |p: [f64; 2]| effectcraft_geom::Vec2 { x: p[0], y: p[1] }; + effectcraft_geom::Mat3::square_to_quad([v(q[0]), v(q[1]), v(q[2]), v(q[3])]).0 +} + +/// The camera an effect renders through, from its Camera System parameters. +pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj { + let pr = ctx.params; + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let c = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s]; + let size = lw.max(lh) * s; + let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); + match pr.e("cameraSystem") { + 1 => { + // Corner Pins: the layer's corners land on the four pins; the focal length sets how + // strongly depth (pieces leaving the plane) is foreshortened. + let focal = if pr.get("cornerPins/autoFocalLength").is_none_or(|v| v.as_bool()) { 70.0 } else { f("cornerPins/focalLength", 70.0).max(1.0) }; + let base = Proj::simple(c, size * 2.0 * focal / 70.0); + let pin = |id: &str| { + let (x, y) = b.to_px(pr.v2(id)); + [x, y] + }; + let quad = + [pin("cornerPins/upperLeftCorner"), pin("cornerPins/upperRightCorner"), pin("cornerPins/lowerRightCorner"), pin("cornerPins/lowerLeftCorner")]; + let rect = [ + [b.offset[0], b.offset[1]], + [b.offset[0] + lw * s, b.offset[1]], + [b.offset[0] + lw * s, b.offset[1] + lh * s], + [b.offset[0], b.offset[1] + lh * s], + ]; + match inv3(&square_to_quad(rect)) { + Some(ri) => base.then(&mat_mul(&square_to_quad(quad), &ri)), + None => base, + } + } + 2 => { + // Comp Camera: the comp's camera looking at this layer (layer px → layer px). + let base = Proj::simple(c, size * 2.0); + let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base }; + // buffer world → layer: (p − offset) / s; layer → buffer: · s + offset. + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..3 { + m[i][j] = cam[i][j] / s; + } + m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s; + } + let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]]; + let p = Proj { m, near: 1e-6 }.then(&back); + // W of the layer centre sets the scale of `near`. + let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3]; + Proj { near: (wc.abs() * 0.02).max(1e-9), ..p } + } + _ => { + let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05); + let focal = f("cameraPosition/focalLength", 70.0).max(1.0); + let base = Proj::simple(c, size * zpos * focal / 140.0); + let order = pr.e("cameraPosition/transformOrder"); + let r = rot_ordered( + f("cameraPosition/xRotation", 0.0).to_radians(), + f("cameraPosition/yRotation", 0.0).to_radians(), + f("cameraPosition/zRotation", 0.0).to_radians(), + order, + ); + let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]); + let (px, py) = b.to_px(xy); + let shift = [px - c[0], py - c[1], 0.0]; + // Rotate about the layer centre, then move by the X, Y Position offset (or the + // other way round). + let c3 = [c[0], c[1], 0.0]; + let rc: [f64; 3] = std::array::from_fn(|i| c3[i] - (0..3).map(|k| r[i][k] * c3[k]).sum::()); + let t = if order >= 6 { + // p ↦ R(p + shift − c) + c + std::array::from_fn(|i| rc[i] + (0..3).map(|k| r[i][k] * shift[k]).sum::()) + } else { + std::array::from_fn(|i| rc[i] + shift[i]) + }; + base.after(&r, t) + } + } +} + +/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`]. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CompLight { + /// Position in layer pixels (z away from the viewer). + pub pos: [f64; 3], + /// Direction the light travels (parallel / spot lights), unit, layer space. + pub dir: [f64; 3], + /// Colour × intensity (1 = 100 %). + pub color: [f32; 3], + /// 0 parallel, 1 point / spot, 2 ambient. + pub kind: u8, +} + +/// The comp's camera and lights relative to the effect's layer. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CompScene { + /// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view + /// of the layer brought back into the layer's own pixel grid (identity on the layer plane + /// for the default camera). + pub camera: Option<[[f64; 4]; 3]>, + pub light: Option, +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn norm(a: [f64; 3]) -> [f64; 3] { + let l = dot(a, a).sqrt(); + if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] } +} + +/// Lighting and Material, ready to shade pieces. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Lighting { + /// 0 distant, 1 point, 2 ambient only. + kind: u8, + color: [f32; 3], + pos: [f64; 3], + /// Distant light: direction towards the light. + to_light: [f64; 3], + ambient: f32, + kd: f32, + ks: f32, + sharp: f32, +} + +impl Lighting { + pub fn from(ctx: &EffectCtx, b: &Buf) -> Lighting { + let pr = ctx.params; + let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let centre = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s, 0.0]; + let intensity = f("lighting/lightIntensity", 1.0) as f32; + let lc = pr.get("lighting/lightColor").map(|v| v.as_color()).unwrap_or([1.0; 4]); + let mut color = [lc[0] * intensity, lc[1] * intensity, lc[2] * intensity]; + let lp = pr.get("lighting/lightPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]); + let (x, y) = b.to_px(lp); + let mut pos = [x, y, -f("lighting/lightDepth", 1.0) * lw.max(lh) * s]; + let mut kind = if pr.e("lighting/lightType") == 1 { 1 } else { 0 }; + let mut to_light = norm(sub(pos, centre)); + if pr.e("lighting/lightType") == 2 { + match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) { + Some(l) => { + color = [l.color[0] * intensity, l.color[1] * intensity, l.color[2] * intensity]; + pos = [l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1], l.pos[2] * s]; + kind = l.kind; + to_light = norm([-l.dir[0], -l.dir[1], -l.dir[2]]); + } + // No light in the comp: ambient only. + None => kind = 2, + } + } + Lighting { + kind, + color, + pos, + to_light, + ambient: f("lighting/ambientLight", 0.25) as f32, + kd: f("material/diffuse", 0.75) as f32, + ks: f("material/specular", 0.0) as f32, + sharp: f("material/highlightSharpness", 15.0).max(1.0) as f32, + } + } + + /// Multiplier and additive highlight for a piece with unit normal `n` (rotated front normal) + /// at `at`, seen from `eye`. Pieces are lit on whichever side faces the viewer. + pub fn shade(&self, n: [f64; 3], at: [f64; 3], eye: [f64; 3]) -> ([f32; 3], [f32; 3]) { + let v = norm(sub(eye, at)); + let n = if dot(n, v) < 0.0 { [-n[0], -n[1], -n[2]] } else { n }; + if self.kind == 2 { + let a = self.ambient; + return ([a + self.kd * self.color[0], a + self.kd * self.color[1], a + self.kd * self.color[2]], [0.0; 3]); + } + let l = if self.kind == 1 { norm(sub(self.pos, at)) } else { self.to_light }; + let ndl = dot(n, l).max(0.0) as f32; + let h = norm([l[0] + v[0], l[1] + v[1], l[2] + v[2]]); + let spec = if self.ks > 0.0 && ndl > 0.0 { self.ks * (dot(n, h).max(0.0) as f32).powf(self.sharp) } else { 0.0 }; + (std::array::from_fn(|k| self.ambient + self.kd * ndl * self.color[k]), std::array::from_fn(|k| spec * self.color[k])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_camera_keeps_the_layer_plane() { + let p = Proj::simple([10.0, 20.0], 100.0); + assert_eq!(p.project([3.0, 4.0, 0.0]), Some([3.0, 4.0])); + let e = p.eye(); + assert!((e[0] - 10.0).abs() < 1e-9 && (e[1] - 20.0).abs() < 1e-9 && (e[2] + 100.0).abs() < 1e-9); + // Further away looks smaller (towards the centre). + let q = p.project([30.0, 20.0, 100.0]).unwrap(); + assert!((q[0] - 20.0).abs() < 1e-9); + } + + #[test] + fn rotation_orders_differ() { + let a = rot_ordered(0.5, 0.3, 0.2, 0); + let b = rot_ordered(0.5, 0.3, 0.2, 5); + assert!((0..3).any(|i| (0..3).any(|j| (a[i][j] - b[i][j]).abs() > 1e-3))); + let z = rot_ordered(0.0, 0.0, 0.0, 3); + assert_eq!(z, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + } +} diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 449b1a4d..a1314f59 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -35,15 +35,12 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.noise.removegrain", "no preview region, sampling or temporal filtering"), ("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only"), - ("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"), - ("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"), ("ec.sim.caustics", "no Sky group or light type"), ("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"), ("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"), ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), ("ec.stylize.cartoon", "no Edge Enhancement"), - ("ec.transition.cardwipe", "2D flip only; no back layer picker, camera, lighting or jitter"), ("ec.transition.blockdissolve", "no Soft Edges option"), ]; @@ -79,6 +76,11 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option true, } } + "ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true), + "ec.sim.shatter" => match param { + "shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5, + _ => crate::card3d::shown(param, &e).unwrap_or(true), + }, _ => { let _ = b(""); true diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 72ef00e1..91a4cdda 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -10,6 +10,7 @@ pub mod audio_fx; mod blur2; mod blur3; pub mod camera_tracker; +mod card3d; pub mod catalog; mod channel; mod channel2; @@ -60,6 +61,7 @@ pub mod warp_stab; use std::collections::HashMap; use std::sync::OnceLock; +pub use card3d::{CompLight, CompScene}; pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip}; pub use color2::Curve; use effectcraft_keyframe::Value; @@ -260,6 +262,11 @@ pub trait EffectHost: Sync { fn aux(&self) -> Option> { None } + /// The composition's camera and first light relative to the effect's layer (Card + /// Dance / Shatter / Card Wipe's Comp Camera and First Comp Light). `None` when unknown. + fn comp_scene(&self) -> Option { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). @@ -573,6 +580,11 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("xScale", "X Scale"), ("yScale", "Y Scale"), ("cameraPosition", "Camera Position"), + ("cornerPins", "Corner Pins"), + ("positionJitter", "Position Jitter"), + ("rotationJitter", "Rotation Jitter"), + ("textures", "Textures"), + ("gradient", "Gradient"), ("cannon", "Cannon"), ("grid", "Grid"), ("layerExploder", "Layer Exploder"), @@ -677,6 +689,8 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", + // Card Wipe's position / rotation jitter moves with time. + "ec.transition.cardwipe", // Temporal Smoothing reads neighbouring frames. "ec.color.autolevels", "ec.color.autocontrast", @@ -711,7 +725,8 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { #[cfg(test)] pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { let s = find(id).unwrap_or_else(|| panic!("no effect {id}")); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let size = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() }; for (k, v) in vals { assert!(params.values.contains_key(*k), "{id}: unknown param {k}"); params.values.insert(k.to_string(), v.clone()); diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 5b3747e9..727b827f 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -242,6 +242,18 @@ fn is_numeric_ui(u: &ParamUi) -> bool { /// point defaults of added parameters. Returns whether anything changed. pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool { let before = g.clone(); + // Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self" + // kept in the hidden Back Layer Is Self switch. + let mut back_self = None; + if spec.id == "ec.transition.cardwipe" + && let Some(pr) = g.get_mut("backLayer") + && matches!(pr.ui, ParamUi::Popup { .. }) + { + back_self = Some(pr.value.as_enum() == 1); + pr.value = Value::Layer(None); + pr.keys.clear(); + pr.ui = ParamUi::Layer; + } for ps in &spec.params { // Locate the stored property: at its spec path, under an old id, or at top level // before it moved into a twirl-down group. @@ -296,6 +308,11 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay } } } + if let Some(on) = back_self + && let Some(pr) = g.get_mut("backSelf") + { + pr.value = Value::Bool(on); + } // Refresh twirl-down group names. fn names(g: &mut PropGroup) { for c in &mut g.children { @@ -368,4 +385,21 @@ mod tests { assert!(g.get("smoothness").is_none()); assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + + #[test] + fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { + let spec = find("ec.transition.cardwipe").unwrap(); + let mut next = 1; + for (old, self_) in [(1, true), (0, false)] { + let mut g = instantiate(spec, &mut Ids(&mut next), "Card Wipe", [100.0, 50.0]); + take_prop(&mut g, "backSelf"); + let pr = g.get_mut("backLayer").unwrap(); + pr.ui = ParamUi::Popup { options: vec!["None".into(), "Self".into()] }; + pr.value = Value::Enum(old); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(g.get("backLayer").unwrap().value, Value::Layer(None)); + assert_eq!(g.get("backLayer").unwrap().ui, ParamUi::Layer); + assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_)); + } + } } diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 7bce1e7a..6a952be1 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -431,7 +431,7 @@ mod tests { fn noise_types_differ_and_block_is_piecewise_constant() { let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]); // Block noise: neighbours inside one 10 px cell share a value. - assert_eq!(a.get(1, 1), a.get(2, 2)); + assert_eq!(a.get(4, 4), a.get(5, 5)); let others: Vec = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect(); assert_ne!(others[0], others[1]); assert_ne!(others[1], others[2]); diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 69fd25a6..5d545e52 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -10,6 +10,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +use crate::card3d::{Lighting, Proj}; use crate::generate::value_noise; use crate::sim::{Acc, Shape, Sprite, h, hs, pct, pt, raster, spec, splat}; use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul}; @@ -17,7 +18,7 @@ use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; // ------------------------------------------------------------------ 3D pieces -type M3 = [[f64; 3]; 3]; +pub(crate) type M3 = [[f64; 3]; 3]; fn mat_mul(a: &M3, b: &M3) -> M3 { std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) @@ -35,7 +36,7 @@ fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { } /// Rotation by `angle` about unit `axis` (Rodrigues). -fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { +pub(crate) fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt(); if l < 1e-12 || angle == 0.0 { return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; @@ -72,9 +73,15 @@ struct Cam { d: f64, } +impl Cam { + fn proj(self) -> Proj { + Proj::simple(self.c, self.d) + } +} + /// A planar textured piece ready to draw. #[derive(Clone, Copy)] -struct Piece { +pub(crate) struct Piece { /// Screen (buffer px, homogeneous) → piece-local (a, b, w). inv: M3, /// Rest centre of the piece in texture pixels. @@ -84,34 +91,43 @@ struct Piece { n: u8, bbox: [f32; 4], shade: f32, - alpha: f32, + /// Lighting: colour multiplier and additive specular highlight. + tint: [f32; 3], + spec: [f32; 3], + pub(crate) alpha: f32, /// Flat straight colour instead of the texture. - flat: Option<[f32; 3]>, + pub(crate) flat: Option<[f32; 3]>, /// Facing away from the camera (draw the back texture). - back: bool, + pub(crate) back: bool, + /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically + /// (flipped about the X axis), 2 horizontally (about the Y axis). + pub(crate) back_mirror: u8, depth: f64, } impl Piece { /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its - /// centre at 3D `pos` (buffer px; z away from the camera). - fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: Cam) -> Option { + /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. + pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into) -> Option { + let cam: Proj = cam.into(); let n = poly.len().min(6); if n < 3 { return None; } - let (cx, cy, d) = (cam.c[0], cam.c[1], cam.d); - let zc = pos[2]; - let hm: M3 = [ - [cx * r[2][0] + d * r[0][0], cx * r[2][1] + d * r[0][1], cx * (d + zc) + d * (pos[0] - cx)], - [cy * r[2][0] + d * r[1][0], cy * r[2][1] + d * r[1][1], cy * (d + zc) + d * (pos[1] - cy)], - [r[2][0], r[2][1], d + zc], - ]; + let m = &cam.m; + let row = |i: usize| -> [f64; 3] { + [ + m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0], + m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1], + m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3], + ] + }; + let hm: M3 = [row(0), row(1), row(2)]; let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; for v in &poly[..n] { let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64); let w = hm[2][0] * a + hm[2][1] * b + hm[2][2]; - if w < d * 0.05 { + if w < cam.near { return None; } let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; @@ -126,8 +142,12 @@ impl Piece { if area < 0.0 { pp[..n].reverse(); } - // Front normal (0, 0, -1) rotated; facing the camera when its z is negative. - let nz = -r[2][2]; + // The front faces -z at rest; it faces the camera when its normal points at the eye. + let eye = cam.eye(); + let nrm = [-r[0][2], -r[1][2], -r[2][2]]; + let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]]; + let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2]; + let depth = hm[2][2]; Some(Piece { inv, c0, @@ -135,10 +155,13 @@ impl Piece { n: n as u8, bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], shade: 1.0, + tint: [1.0; 3], + spec: [0.0; 3], alpha: 1.0, flat: None, - back: nz > 0.0, - depth: zc, + back: facing < 0.0, + back_mirror: 0, + depth, }) } @@ -146,10 +169,23 @@ impl Piece { fn facing(r: &M3) -> f32 { r[2][2].abs() as f32 } + + /// Light the piece (rotation `r`, centre `pos`) with Lighting / Material. + pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) { + let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye); + self.tint = t; + self.spec = s; + } +} + +impl From for Proj { + fn from(c: Cam) -> Proj { + c.proj() + } } /// Draw pieces (already sorted far → near) over `out`. -fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { +pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { raster( out, pieces, @@ -181,6 +217,11 @@ fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&I Some(c) => (c, 1.0), None => { let tex = if q.back { back.unwrap_or(front) } else { front }; + let (u, v) = match (q.back, q.back_mirror) { + (true, 1) => (u, 2.0 * q.c0[1] - v), + (true, 2) => (2.0 * q.c0[0] - u, v), + _ => (u, v), + }; unpremul(tex.sample_bilinear(u as f64, v as f64)) } }; @@ -189,13 +230,14 @@ fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&I return None; } let k = q.shade; - Some([c[0] * k * a, c[1] * k * a, c[2] * k * a, a]) + let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]); + Some([o[0] * a, o[1] * a, o[2] * a, a]) }, Acc::Over, ); } -fn sort_far_first(pieces: &mut [Piece]) { +pub(crate) fn sort_far_first(pieces: &mut [Piece]) { pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); } @@ -430,12 +472,9 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { let [lw, lh] = ctx.layer_size; let s = b.scale; let (cw, ch) = (lw / cols as f64, lh / rows as f64); - let zpos = pr.f("cameraPosition/cameraZ").max(0.05); - let focal = pr.f("cameraPosition/focalLength").max(1.0); - let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: lw.max(lh) * s * zpos * focal / 140.0 }; - let ambient = pr.f("lighting/ambientLight") as f32; - let diffuse = pr.f("material/diffuse") as f32; - let intensity = pr.f("lighting/lightIntensity") as f32; + let cam = crate::card3d::projection(ctx, &b); + let eye = cam.eye(); + let light = Lighting::from(ctx, &b); let props: Vec<(u32, f64, f64)> = CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); let src = b.img.clone(); @@ -458,7 +497,7 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { let (tw, th) = ((cw * s) as f32, (ch * s) as f32); let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]]; let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?; - pc.shade = ambient + diffuse * intensity * Piece::facing(&r); + pc.light(&light, &r, pos, eye); Some(pc) }) .collect(); @@ -471,7 +510,8 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ Shatter -const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles"]; +/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map. +const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"]; /// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`. fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec> { @@ -570,12 +610,40 @@ fn drag_factors(k: f64, t: f64) -> (f64, f64) { ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) } } +/// Shatter's Front / Side / Back Mode options. +const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"]; + +/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour. +struct Face { + mode: u32, + img: Option, +} + +impl Face { + fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face { + let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode); + // A face layer of None is the effect's own layer. + let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true)); + Face { mode, img } + } +} + +/// Group key of a Custom Shatter Map colour (pieces of one colour break as one). +fn map_key(c: [f32; 4]) -> u32 { + let (c, a) = unpremul(c); + if a < 0.5 { + return u32::MAX; + } + let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15; + q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) +} fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let view = pr.e("view"); let render = pr.e("render"); let kind = pr.e("shape/pattern"); + let custom = kind as usize == SHATTER_PATTERNS.len() - 1; let reps = pr.f("shape/repetitions").clamp(1.0, 500.0); let dir = pr.f("shape/direction").to_radians(); let origin = pr.v2("shape/origin"); @@ -591,15 +659,29 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let gravity = pr.f("physics/gravity"); let gdir = pr.f("physics/gravityDirection").to_radians(); let ginc = pr.f("physics/gravityInclination").to_radians(); - let ambient = pr.f("lighting/ambientLight") as f32; - let intensity = pr.f("lighting/lightIntensity") as f32; let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed; let [lw, lh] = ctx.layer_size; let s = b.scale; let t = ctx.time.max(0.0); let unit = lw * 0.1; let cell = lw / reps; - let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; + let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; + let cam = crate::card3d::projection(ctx, &b); + let eye = cam.eye(); + let light = Lighting::from(ctx, &b); + // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold + // (Invert Gradient: as dark) break. + let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; + let invert_gradient = pr.b("gradient/invertGradient"); + // Textures. + let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); + let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let front = Face::from(ctx, &b, "textures/frontMode", "textures/frontLayer", 1); + let side = Face::from(ctx, &b, "textures/sideMode", "textures/sideLayer", 0); + let backf = Face::from(ctx, &b, "textures/backMode", "textures/backLayer", 1); + let custom_map = custom.then(|| layer_or_self(ctx, &b, "shape/customShatterMap", true, true)); + let white_fixed = pr.b("shape/whiteTilesFixed"); let (sd, cd) = dir.sin_cos(); let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; // Pattern-space bounds of the layer rectangle. @@ -609,21 +691,45 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { }); let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max)); let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max)); - let cells = pattern_cells(kind, cell, px0, py0, px1, py1, seed); + // Custom Shatter Map: square cells grouped by the map's colour. + let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed); + let polys: Vec> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect(); + let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + let keys: Vec = match &custom_map { + Some(m) => polys + .iter() + .map(|lp| { + let c = centre(lp); + map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1])) + }) + .collect(), + None => (0..polys.len() as u32).collect(), + }; + // Group centres (custom map) — each group moves as one rigid piece. + let mut groups: std::collections::HashMap = std::collections::HashMap::new(); + for (lp, &key) in polys.iter().zip(&keys) { + let c = centre(lp); + let e = groups.entry(key).or_insert(([0.0; 2], 0.0)); + e.0[0] += c[0]; + e.0[1] += c[1]; + e.1 += 1.0; + } let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; let src = b.img.clone(); - let mut pieces: Vec = cells + let mut pieces: Vec = polys .par_iter() - .enumerate() - .filter_map(|(i, poly)| { - let i = i as u32; - let lp: Vec<[f64; 2]> = poly.iter().map(|&q| to_layer(q)).collect(); + .zip(keys.par_iter()) + .flat_map_iter(|(lp, &key)| { + let mut out: Vec = vec![]; let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max)); let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max)); - if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh { - return None; + if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX { + return out.into_iter(); } - let c = [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + let pc_c = centre(lp); + let (gsum, gn) = groups[&key]; + let gc = [gsum[0] / gn, gsum[1] / gn]; + let i = key; // Broken when inside a force sphere (centred `depth` in front of the layer). let mut v = [0.0f64; 3]; let mut broken = false; @@ -632,7 +738,7 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { if rad <= 0.0 { continue; } - let dv = [c[0] - fp[0], c[1] - fp[1], fd * lw]; + let dv = [gc[0] - fp[0], gc[1] - fp[1], fd * lw]; let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt(); if dist < rad { broken = true; @@ -642,19 +748,31 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { } } } - if (broken && render == 1) || (!broken && render == 2) { - return None; + if broken && let Some(g) = &gradient { + let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1])); + let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64; + if invert_gradient { + l = 1.0 - l; + } + broken = threshold > 0.0 && l >= 1.0 - threshold; } - let mut pos = [c[0], c[1], 0.0]; + if broken && custom && white_fixed && key == 0xfff { + broken = false; + } + if (broken && render == 1) || (!broken && render == 2) { + return out.into_iter(); + } + let mut gpos = [gc[0], gc[1], 0.0]; let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); - if broken && t > 0.0 { + let moving = broken && t > 0.0; + if moving { let mass = 1.0 + mass_var * hs(i, 1, seed) as f64; for q in 0..3 { v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0; } let (f1, f2) = drag_factors(k, t); for q in 0..3 { - pos[q] += v[q] * f1 + g3[q] * f2; + gpos[q] += v[q] * f1 + g3[q] * f2; } let axis = match tumble { 1 => [0.0, 0.0, 0.0], @@ -670,18 +788,66 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0); r = rot_axis(axis, omega * f1); } + // The piece's centre: its offset from the group centre, rotated with the group. + let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0]; + let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::()); let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); - let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; + let c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_c[1] * s + b.offset[1]) as f32]; let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; - let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; - pc.shade = ambient + (1.0 - ambient) * intensity * Piece::facing(&r); - Some(pc) + let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() }; + pc.light(&light, &r, posb, eye); + pc.alpha = tex_opacity; + let face = if pc.back { &backf } else { &front }; + if face.mode == 0 { + pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]); + } else if face.mode == 2 { + pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]); + } + // Extrusion: the sides of a moving piece, from its front outline back by the depth. + if moving && extrude > 0.0 { + let nb = [r[0][2], r[1][2], r[2][2]]; + let n = tex.len(); + for e in 0..n { + let (a, bv) = (tex[e], tex[(e + 1) % n]); + let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64]; + let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt(); + if len < 1e-6 { + continue; + } + let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len); + let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]]; + let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]); + // Side centre in world: edge midpoint (world) plus half the depth backwards. + let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64]; + let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5); + let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32); + let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5]; + let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]]; + if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) { + sp.light(&light, &rs, mw, eye); + sp.alpha = tex_opacity; + let colour = match (&side.img, side.mode) { + (Some(img), m) if m != 0 => { + let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64)); + if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c } + } + _ => [tex_color[0], tex_color[1], tex_color[2]], + }; + sp.flat = Some(colour); + out.push(sp); + } + } + } + out.push(pc); + out.into_iter() }) .collect(); sort_far_first(&mut pieces); let mut out = Image::new(src.width, src.height); + let front_img = front.img.as_ref().unwrap_or(&src); + let back_img = backf.img.as_ref().unwrap_or(front_img); if view == 0 { - draw_pieces(&mut out, &pieces, &src, None); + draw_pieces(&mut out, &pieces, front_img, Some(back_img)); } else { // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. let animated = view == 2 || view == 4; @@ -1277,13 +1443,9 @@ fn card_dance_params() -> Vec { v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); } - v.extend([ - p("cameraPosition/cameraZ", "Z Position", num(2.0), slider(0.05, 10.0, 0.5, 5.0, 2)), - p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), - p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), - p("lighting/ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), - ]); + v.extend(crate::card3d::camera_params(2.0)); + v.extend(crate::card3d::lighting_params(1.0, 0.25)); + v.extend(crate::card3d::material_params(0.75)); v } @@ -1399,6 +1561,8 @@ pub fn specs() -> Vec { p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), p("shape/direction", "Direction", num(0.0), ParamUi::Angle), p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point), + p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer), + p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox), p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point), p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), @@ -1416,10 +1580,24 @@ pub fn specs() -> Vec { p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), - p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), - p("lighting/ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], + p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox), + p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), + p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)), + p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), + p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer), + p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer), + p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + ] + .into_iter() + .chain(crate::card3d::camera_params(4.0)) + .chain(crate::card3d::lighting_params(1.0, 0.25)) + .chain(crate::card3d::material_params(0.75)) + .chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))]) + .collect(), shatter, ), spec( @@ -1651,4 +1829,177 @@ mod tests { }; assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0)); } + + /// A host with a comp camera (identity, or tilted) and a first comp light. + struct Scene { + camera: Option<[[f64; 4]; 3]>, + light: Option, + layer: Option, + } + impl crate::EffectHost for Scene { + fn layer(&self, _: u64, _: bool) -> Option { + let img = self.layer.clone()?; + let size = [img.width as f64, img.height as f64]; + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn comp_scene(&self) -> Option { + Some(crate::CompScene { camera: self.camera, light: self.light }) + } + } + + fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image { + run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img + } + + #[test] + fn card_dance_camera_systems() { + let src = ramp(40, 30); + // Camera Position: rotating the camera changes the view; zero rotation is the layer. + let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0); + assert!(sum_diff(&r, &src) > 10.0); + let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0); + let order2 = run( + "ec.sim.carddance", + &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))], + src.clone(), + 0.0, + ); + assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters"); + // X, Y Position moves the view: the layer shifts by the offset. + let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0); + assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10)); + // Corner Pins: the layer's corners land on the pins. + let pins = [ + ("cameraSystem", Value::Enum(1)), + ("cornerPins/upperLeftCorner", pt(10.0, 5.0)), + ("cornerPins/upperRightCorner", pt(30.0, 5.0)), + ("cornerPins/lowerLeftCorner", pt(10.0, 25.0)), + ("cornerPins/lowerRightCorner", pt(30.0, 25.0)), + ]; + let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0); + assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad"); + assert!(cp.get(20, 15)[3] > 0.99); + assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre"); + // Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it. + let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None }; + let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident); + assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src)); + let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident }; + let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half); + assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty"); + } + + #[test] + fn card_dance_lighting_and_material() { + let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]); + let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))]; + let lit = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = tilt.to_vec(); + v.extend_from_slice(extra); + run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15) + }; + let base = lit(&[]); + // Tilted cards facing away from a frontal light get darker than flat ones. + assert!(base[0] < 0.45, "{base:?}"); + // Light colour tints; intensity brightens; ambient alone lights evenly. + let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]); + assert!(red[0] > red[1] + 0.05); + assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]); + let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]); + assert!((amb[0] - 0.5).abs() < 1e-3); + // A light placed to the side (Point Source) changes the shading of the tilted card. + let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]); + assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}"); + // Specular adds a highlight on flat, frontally lit cards. + let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15); + assert!(flat[0] > 0.9, "{flat:?}"); + // First Comp Light: no light in the comp → ambient only; a light from the host is used. + let none = Scene { camera: None, light: None, layer: None }; + let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15); + assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}"); + let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none }; + let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15); + assert!(a[2] > a[0] + 0.2, "{a:?}"); + } + + #[test] + fn shatter_custom_map_gradient_textures_and_extrusion() { + let src = ramp(32, 32); + let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))]; + let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + match host { + Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h), + None => run("ec.sim.shatter", &v, src.clone(), t), + } + }; + // Custom Shatter Map: a map white on the left and black on the right; with White Tiles + // Fixed the left half stays put while the right half breaks away. + let mut map = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let host = Scene { camera: None, light: None, layer: Some(map) }; + let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))]; + let at_rest = with(&custom, 0.0, Some(&host)); + assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src)); + let broke = with(&custom, 1.0, Some(&host)); + assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed"); + assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved"); + // Gradient: a black gradient layer with threshold 50 % keeps everything together. + let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) }; + let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black)); + assert!(max_diff(&g, &src) < 0.05); + let gi = with( + &[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))], + 1.0, + Some(&black), + ); + assert!(sum_diff(&gi, &src) > 10.0); + // Textures: Front Mode Color paints the pieces flat; Opacity fades them. + let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None); + let p = flat.get(16, 16); + assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); + let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None); + assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4)); + // Extrusion: thicker pieces cover more once they tumble. + let cover = |d: f64| { + with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None) + .data + .iter() + .map(|p| p[3]) + .sum::() + }; + assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0)); + // Deterministic and seek-consistent. + assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host))); + } + + fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { + let mut m = 0.0f32; + for y in 0..a.height { + for x in xs.clone() { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max); + } + } + m + } + + fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { + let mut s = 0.0; + for y in 0..a.height { + for x in xs.clone() { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::(); + } + } + s + } } diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index c41c6449..54236edf 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -1,5 +1,5 @@ //! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card -//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe. +//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe. //! //! Every transition is the identity at 0 % completion. Angles are clockwise from "up". @@ -9,6 +9,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Px; use rayon::prelude::*; +use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first}; use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider}; @@ -163,21 +164,47 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Card Wipe: the layer is cut into Rows × Columns cards that flip over in Flip Order, each +/// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera +/// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards +/// vanish as they turn away). Position / Rotation Jitter wobble the cards over time. fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { let done = completion(ctx); - if done <= 0.0 { - return b; + let pr = ctx.params; + let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0); + let jitter_on = [ + "positionJitter/xJitterAmount", + "positionJitter/yJitterAmount", + "positionJitter/zJitterAmount", + "rotationJitter/xRotJitterAmount", + "rotationJitter/yRotJitterAmount", + "rotationJitter/zRotJitterAmount", + ] + .iter() + .any(|id| f(id) != 0.0); + if done <= 0.0 + && !jitter_on + && pr.e("cameraSystem") == 0 + && f("cameraPosition/xRotation") == 0.0 + && f("cameraPosition/yRotation") == 0.0 + && f("cameraPosition/zRotation") == 0.0 + { + // Nothing has moved: the layer as is (exactly). + let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()); + if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) { + return b; + } } - let tw = (ctx.params.f("transitionWidth") / 100.0).clamp(0.01, 1.0); - let rows = ctx.params.f("rows").round().clamp(1.0, 1000.0) as i64; + let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0); + let rows = pr.f("rows").round().clamp(1.0, 1000.0) as i64; // Rows & Columns: Independent, or Columns Follows Rows. - let cols = if ctx.params.e("rowsAndColumns") == 1 { rows } else { ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64 }; - let card_scale = ctx.params.f("cardScale").max(0.01); - let axis = ctx.params.e("flipAxis"); - let order = ctx.params.e("flipOrder"); - let back_self = ctx.params.e("backLayer") == 1; - let jitter = ctx.params.f("timingRandomness").clamp(0.0, 1.0); - let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d; + let cols = if pr.e("rowsAndColumns") == 1 { rows } else { pr.f("columns").round().clamp(1.0, 1000.0) as i64 }; + let card_scale = pr.f("cardScale").max(0.01); + let axis = pr.e("flipAxis"); + let direction = pr.e("flipDirection"); + let order = pr.e("flipOrder"); + let jitter = pr.f("timingRandomness").clamp(0.0, 1.0); + let seed = pr.f("randomSeed") as u32 ^ 0xca7d; let (x0, y0, w, h) = layer_rect(ctx, &b); let (cw, ch) = (w / cols as f64, h / rows as f64); let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 }; @@ -187,64 +214,91 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { Some(o) => place_layer(ctx, &b, &o, 2), None => b.img.clone(), }); - let src = b.img.clone(); - b.img = remap(&src, false, |x, y| { - let (lx, ly) = (x - x0, y - y0); - if lx < 0.0 || ly < 0.0 || lx >= w || ly >= h { - return Some((x, y)); - } - let i = ((lx / cw).floor() as i64).min(cols - 1); - let j = ((ly / ch).floor() as i64).min(rows - 1); - let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch); - let mut o = match order { - 1 => 1.0 - nx(i), - 2 => ny(j), - 3 => 1.0 - ny(j), - 4 => (nx(i) + ny(j)) * 0.5, - 5 => (1.0 - nx(i) + ny(j)) * 0.5, - 6 => (nx(i) + 1.0 - ny(j)) * 0.5, - 7 => 1.0 - (nx(i) + ny(j)) * 0.5, - 8 => { - let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]); - let (c, _) = unpremul(g); - luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64 + // Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the + // old "Self" choice; none hides the cards' backs. + let back = match ctx.layer_param("backLayer", true) { + Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)), + None if pr.b("backSelf") => Some(b.img.clone()), + None => None, + }; + let cam = crate::card3d::projection(ctx, &b); + let eye = cam.eye(); + let light = crate::card3d::Lighting::from(ctx, &b); + let t = ctx.time; + let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 { + let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU; + let rate = 0.5 + hash1(i as u32, j as u32, seed ^ (k + 101)) as f64; + (t * speed * rate * std::f64::consts::TAU + ph).sin() + }; + let (jx, jy, jz, js) = + (f("positionJitter/xJitterAmount"), f("positionJitter/yJitterAmount"), f("positionJitter/zJitterAmount"), f("positionJitter/jitterSpeed")); + let (rjx, rjy, rjz, rjs) = + (f("rotationJitter/xRotJitterAmount"), f("rotationJitter/yRotJitterAmount"), f("rotationJitter/zRotJitterAmount"), f("rotationJitter/rotJitterSpeed")); + let mut pieces: Vec = (0..rows * cols) + .into_par_iter() + .filter_map(|k| { + let (i, j) = (k % cols, k / cols); + let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch); + let mut o = match order { + 1 => 1.0 - nx(i), + 2 => ny(j), + 3 => 1.0 - ny(j), + 4 => (nx(i) + ny(j)) * 0.5, + 5 => (1.0 - nx(i) + ny(j)) * 0.5, + 6 => (nx(i) + 1.0 - ny(j)) * 0.5, + 7 => 1.0 - (nx(i) + ny(j)) * 0.5, + 8 => { + let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]); + let (c, _) = unpremul(g); + luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64 + } + _ => nx(i), + }; + if jitter > 0.0 { + o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter; } - _ => nx(i), - }; - if jitter > 0.0 { - o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter; - } - let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0); - // Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1). - let (mut dx, mut dy) = ((x - ccx) / card_scale, (y - ccy) / card_scale); - if dx.abs() > cw * 0.5 + 1e-9 || dy.abs() > ch * 0.5 + 1e-9 { - return None; - } - if p <= 0.0 { - return Some((ccx + dx, ccy + dy)); - } - let cos = (p * std::f64::consts::PI).cos(); - if cos.abs() < 1e-4 || (cos < 0.0 && !back_self) { - return None; - } - let around_x = match axis { - 1 => false, - 2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5, - _ => true, - }; - if around_x { - dy /= cos; - if dy.abs() > ch * 0.5 { + let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0); + let around_x = match axis { + 1 => false, + 2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5, + _ => true, + }; + let sign = match direction { + 1 => -1.0, + 2 if hash1(i as u32, j as u32, seed ^ 0x99) < 0.5 => -1.0, + _ => 1.0, + }; + let a = p * std::f64::consts::PI * sign; + let flip = if around_x { rot_axis([1.0, 0.0, 0.0], a) } else { rot_axis([0.0, 1.0, 0.0], a) }; + let jr = crate::card3d::rot_ordered( + (rjx * jit(i, j, 11, rjs)).to_radians(), + (rjy * jit(i, j, 12, rjs)).to_radians(), + (rjz * jit(i, j, 13, rjs)).to_radians(), + 0, + ); + let r = crate::card3d::mat_mul(&jr, &flip); + let pos = [ccx + jx * jit(i, j, 21, js) * cw, ccy + jy * jit(i, j, 22, js) * ch, jz * jit(i, j, 23, js) * cw.max(ch)]; + // Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1). + let (sw, sh) = ((cw * 0.5 * card_scale.min(1.0)) as f32, (ch * 0.5 * card_scale.min(1.0)) as f32); + let (cx, cy) = (ccx as f32, ccy as f32); + let poly = [[cx - sw, cy - sh], [cx + sw, cy - sh], [cx + sw, cy + sh], [cx - sw, cy + sh]]; + // Above 1 the card's picture is magnified (cropped to the card). + let k = card_scale.max(1.0); + let rs: crate::card3d::M3 = std::array::from_fn(|q| [r[q][0] * k, r[q][1] * k, r[q][2]]); + let poly = poly.map(|v| [cx + (v[0] - cx) / k as f32, cy + (v[1] - cy) / k as f32]); + let mut pc = Piece::new(&poly, [cx, cy], &rs, pos, cam)?; + if pc.back && back.is_none() { return None; } - } else { - dx /= cos; - if dx.abs() > cw * 0.5 { - return None; - } - } - Some((ccx + dx, ccy + dy)) - }); + pc.back_mirror = if around_x { 1 } else { 2 }; + pc.light(&light, &r, pos, eye); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height); + draw_pieces(&mut out, &pieces, &b.img, back.as_ref()); + b.img = out; b } @@ -447,12 +501,15 @@ pub fn specs() -> Vec { vec![ done(), p("transitionWidth", "Transition Width", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)), - p("backLayer", "Back Layer", Value::Enum(0), popup(&["None", "Self"])), + p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + // Projects saved when Back Layer was a None / Self popup: Self. + p("backSelf", "Back Layer Is Self", Value::Bool(false), ParamUi::Hidden), p("rowsAndColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])), p("rows", "Rows", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), p("columns", "Columns", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), p("cardScale", "Card Scale", num(1.0), slider(0.01, 10.0, 0.01, 2.0, 2)), p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])), + p("flipDirection", "Flip Direction", Value::Enum(0), popup(&["Positive", "Negative", "Random"])), p( "flipOrder", "Flip Order", @@ -472,7 +529,22 @@ pub fn specs() -> Vec { p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), p("timingRandomness", "Timing Randomness", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), - ], + ] + .into_iter() + .chain(crate::card3d::camera_params(2.0)) + .chain(crate::card3d::lighting_params(1.0, 0.25)) + .chain(crate::card3d::material_params(0.75)) + .chain([ + p("positionJitter/xJitterAmount", "X Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("positionJitter/jitterSpeed", "Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("positionJitter/yJitterAmount", "Y Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("positionJitter/zJitterAmount", "Z Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("rotationJitter/xRotJitterAmount", "X Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)), + p("rotationJitter/rotJitterSpeed", "Rot Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("rotationJitter/yRotJitterAmount", "Y Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)), + p("rotationJitter/zRotJitterAmount", "Z Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)), + ]) + .collect(), card_wipe, ), spec( @@ -635,7 +707,7 @@ mod tests { let back = run( "ec.transition.cardwipe", &img, - &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))], + &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backSelf", Value::Bool(true))], ); assert_eq!(back.get(2, 20)[3], 1.0); } @@ -746,4 +818,52 @@ mod tests { assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first"); assert_eq!(out.get(1, 1)[3], 1.0, "corners last"); } + + fn run_t(img: &Image, set: &[(&str, Value)], time: f64, host: Option<&dyn crate::EffectHost>) -> Image { + let s = find("ec.transition.cardwipe").unwrap(); + let size = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, size))).collect() }; + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let env = crate::EffectEnv { host, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time, layer_size: size, seed: 7, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + } + + #[test] + fn card_wipe_back_layer_direction_jitter_and_camera() { + struct Green; + impl crate::EffectHost for Green { + fn layer(&self, _: u64, _: bool) -> Option { + Some(crate::LayerPixels { buf: Buf { img: Image::filled(80, 40, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [80.0, 40.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]); + let v = [("completion", num(100.0)), ("rows", num(1.0)), ("columns", num(8.0))]; + // Fully flipped cards show the Back Layer. + let mut b: Vec<(&str, Value)> = v.to_vec(); + b.push(("backLayer", Value::Layer(Some(3)))); + let out = run_t(&img, &b, 0.0, Some(&Green)); + let p = out.get(45, 20); + assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); + // Half way, Flip Direction decides which way the cards turn (the views differ). + let half = [("completion", num(30.0)), ("rows", num(1.0)), ("columns", num(1.0)), ("transitionWidth", num(100.0)), ("flipAxis", Value::Enum(1))]; + let pos = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0))]].concat(), 0.0, None); + let neg = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0)), ("flipDirection", Value::Enum(1))]].concat(), 0.0, None); + assert_ne!(pos, neg); + // Position Jitter moves the cards over time (and only then). + let j = [("completion", num(0.0)), ("positionJitter/xJitterAmount", num(0.3))]; + let a = run_t(&img, &j, 0.0, None); + let c = run_t(&img, &j, 0.37, None); + assert_ne!(a, c); + assert_eq!(c, run_t(&img, &j, 0.37, None), "deterministic"); + assert_eq!(run_t(&img, &[("completion", num(0.0))], 0.37, None), img, "no jitter, no change"); + // Camera Position rotation tilts the whole wipe in perspective. + let tilt = run_t(&img, &[("completion", num(0.0)), ("cameraPosition/yRotation", num(40.0))], 0.0, None); + assert_eq!(tilt.get(0, 0)[3], 0.0); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 58a04193..4db96f0e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -151,6 +151,31 @@ impl EffectHost for FxHost<'_, '_, '_> { } } + fn comp_scene(&self) -> Option { + // 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. + let world = self.ctx.world_matrix(self.layer); + let (cam, _, _) = self.ctx.camera(); + let p = (cam * world).0; + let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0; + let rows = [p[0], p[1], p[3]]; + let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum())); + let inv = world.inverse(); + let light = three_d::light::lights_at(self.ctx).first().and_then(|l| { + let inv = inv.as_ref()?; + let pos = inv.apply(l.pos); + let dir = inv.apply_vec(l.dir); + let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12); + let kind = match l.kind { + effectcraft_project::LightKind::Parallel => 0, + effectcraft_project::LightKind::Ambient => 2, + _ => 1, + }; + Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind }) + }); + Some(effectcraft_effects::CompScene { camera: Some(camera), light }) + } + fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 02c7f496..662c031a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -163,6 +163,19 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. +#[test] +fn card_dance_comp_camera_matches_the_default_view() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50); + add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let c = img.get(100, 50); + assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}"); + assert!(img.get(10, 50)[3] < 0.01); +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { diff --git a/docs/effects.md b/docs/effects.md index 15db5435..18c23b28 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2562 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 2647 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**: 266 implemented in full, 37 partial (what is missing is listed). +- **Status**: 269 implemented in full, 34 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -328,11 +328,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented | | CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented | | CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | -| Card Dance | `ec.sim.carddance` | 35 | | 32 | Partial: no camera system, corner pins or lighting/material controls | +| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | | Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | | Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map | | Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles | -| Shatter | `ec.sim.shatter` | 26 | | 32 | Partial: no custom shatter map, gradient, textures or extrusion rendering | +| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | | Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group | ## Stylize @@ -400,7 +400,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented | | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | -| Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter | +| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | | Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented | From ba5e2061fec9935ebee65b736bd9d17df19591fa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:13:01 -0400 Subject: [PATCH 03/17] M9.13: Particle Playground Particle Exploder, Ephemeral Property Mapper, Affects, text and layer particles - Particle Exploder, Ephemeral Property Mapper (operators), Affects groups on every producer / force / mapper (source, selection map, characters, age + feather). - Full mapper target list (angle, torque, scale, gradient velocity/force, lifespan, character, font size, time offset, scale speed...); old saves remap by label. - Cannon / Grid text particles (stroke font), Auto-Orient Rotation, Layer Map draws the map layer per particle with Time Offset Type / Time Offset. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 1 - crates/effects/src/lib.rs | 4 + crates/effects/src/migrate.rs | 17 + crates/effects/src/sim3.rs | 887 +++++++++++++++++++++++++++++----- crates/effects/src/textfx.rs | 5 + docs/effects.md | 6 +- 6 files changed, 782 insertions(+), 138 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a1314f59..77f7f49f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -37,7 +37,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), ("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"), - ("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"), ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), ("ec.stylize.cartoon", "no Edge Enhancement"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 91a4cdda..49d98236 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -593,6 +593,10 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("repel", "Repel"), ("wall", "Wall"), ("persistentPropertyMapper", "Persistent Property Mapper"), + ("ephemeralPropertyMapper", "Ephemeral Property Mapper"), + ("particleExploder", "Particle Exploder"), + ("affects", "Affects"), + ("options", "Options"), // Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`). ("ultraSettings", "Ultra Settings"), ("fillAndStroke", "Fill and Stroke"), diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 727b827f..b2f48573 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -402,4 +402,21 @@ mod tests { assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_)); } } + + #[test] + fn particle_playground_mapper_targets_remap_by_label() { + let spec = find("ec.sim.particleplayground").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Particle Playground", [100.0, 50.0]); + let old: Vec = + ["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"] + .iter() + .map(|s| s.to_string()) + .collect(); + let pr = prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap(); + pr.ui = ParamUi::Popup { options: old }; + pr.value = Value::Enum(8); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed"); + } } diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 5560917a..a8763d85 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -8,10 +8,14 @@ //! random spread, direction), **Repel** (particles push each other apart within a radius), //! **Wall** (a mask of the layer the particles bounce off) and the **Persistent Property //! Mapper** (a map layer's red / green / blue drive particle properties at the particle's -//! position each step). **Layer Map** draws particles with the colours of another layer -//! sampled at each particle's birth position. Map layers are read at the frame being rendered -//! (their pixels are part of the cache key). Particles replace the layer's own pixels, as in -//! After Effects. +//! position each step) and the **Ephemeral Property Mapper** (the same with an operator per +//! channel, holding for one step only). **Particle Exploder** bursts particles into smaller +//! ones. **Layer Map** draws each particle as a copy of another layer, at a time set by its Time +//! Offset Type. Every producer / force / mapper has an **Affects** group (particles from which +//! producer, a selection map, characters, age with feather). The Options' cannon / grid text +//! turns particles into characters (the built-in stroke font). Map layers are read at the frame +//! being rendered (their pixels are part of the cache key). Particles replace the layer's own +//! pixels, as in After Effects. //! //! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a //! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall @@ -33,20 +37,72 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren // ---------------------------------------------------------------- Particle Playground +/// Which producer made a particle (Affects ▸ Particles From). +pub const SRC_CANNON: u8 = 0; +pub const SRC_GRID: u8 = 1; +pub const SRC_LAYER_EXPLODER: u8 = 2; +pub const SRC_PARTICLE_EXPLODER: u8 = 3; + #[derive(Clone, Copy, Debug, PartialEq)] pub struct PgParticle { pub p: [f32; 2], pub v: [f32; 2], - /// Layer position where the particle was born (Layer Map lookups). + /// Layer position where the particle was born. pub origin: [f32; 2], pub c: [f32; 4], + /// Radius, or font size for text particles. pub r: f32, pub mass: f32, + /// Kinetic friction (velocity damping) and static friction (force needed to start moving). pub friction: f32, + pub static_friction: f32, pub force: [f32; 2], + /// Rotation (degrees), angular velocity (°/s) and torque (°/s²). + pub angle: f32, + pub ang_vel: f32, + pub torque: f32, + /// X / Y scale (1 = 100 %) and its growth per second. + pub scale: [f32; 2], + pub scale_speed: f32, + /// Age and lifespan in seconds. + pub age: f32, + pub lifespan: f32, + /// Character code of a text particle (0 = a dot). + pub ch: u32, + /// Layer Map time offset (seconds). + pub toff: f32, + /// Producer (`SRC_*`). + pub src: u8, pub id: u32, } +impl PgParticle { + pub fn new(p: [f32; 2], v: [f32; 2], c: [f32; 4], r: f32, id: u32, src: u8) -> PgParticle { + PgParticle { + p, + v, + origin: p, + c, + r, + mass: 1.0, + friction: 0.0, + static_friction: 0.0, + force: [0.0; 2], + angle: 0.0, + ang_vel: 0.0, + torque: 0.0, + scale: [1.0; 2], + scale_speed: 0.0, + age: 0.0, + lifespan: f32::INFINITY, + ch: 0, + toff: 0.0, + src, + id, + } + } +} + #[derive(Clone, Debug, Default)] pub struct PgState { pub parts: Vec, @@ -76,10 +132,216 @@ impl MapImg { } } -/// Particle properties a Property Mapper channel can drive (After Effects' order; the -/// properties we don't model yet are left out). -pub const MAP_TARGETS: [&str; 14] = - ["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"]; +/// Particle properties a Property Mapper channel can drive, in After Effects' order. +pub const MAP_TARGETS: [&str; 27] = [ + "None", + "Red", + "Green", + "Blue", + "Kinetic Friction", + "Static Friction", + "Angle", + "Angular Velocity", + "Torque", + "Scale", + "X Scale", + "Y Scale", + "X", + "Y", + "Gradient Velocity", + "X Speed", + "Y Speed", + "Gradient Force", + "X Force", + "Y Force", + "Opacity", + "Mass", + "Lifespan", + "Character", + "Font Size", + "Time Offset", + "Scale Speed", +]; + +/// Ephemeral Property Mapper operators. +pub const MAP_OPERATORS: [&str; 7] = ["Set", "Add", "Difference", "Subtract", "Multiply", "Min", "Max"]; + +/// Affects ▸ Particles From options. +pub const PARTICLES_FROM: [&str; 8] = + ["All", "Cannon", "Grid", "Layer Exploder", "Particle Exploder", "Grid & Layer Exploder", "Cannon & Grid", "Cannon & Particle Exploder"]; + +/// Layer Map ▸ Time Offset Type options. +pub const TIME_OFFSET_TYPES: [&str; 4] = ["Relative", "Absolute", "Relative Random", "Absolute Random"]; + +/// A property's current value (`target` in [`MAP_TARGETS`]). +fn prop_get(q: &PgParticle, target: u32) -> f32 { + match target { + 1 => q.c[0], + 2 => q.c[1], + 3 => q.c[2], + 4 => q.friction, + 5 => q.static_friction, + 6 => q.angle, + 7 => q.ang_vel, + 8 => q.torque, + 9 => (q.scale[0] + q.scale[1]) * 0.5, + 10 => q.scale[0], + 11 => q.scale[1], + 12 => q.p[0], + 13 => q.p[1], + 15 => q.v[0], + 16 => q.v[1], + 18 => q.force[0], + 19 => q.force[1], + 20 => q.c[3], + 21 => q.mass, + 22 => q.lifespan, + 23 => q.ch as f32, + 24 => q.r, + 25 => q.toff, + 26 => q.scale_speed, + _ => 0.0, + } +} + +fn prop_set(q: &mut PgParticle, target: u32, v: f32) { + match target { + 1 => q.c[0] = v, + 2 => q.c[1] = v, + 3 => q.c[2] = v, + 4 => q.friction = v.clamp(0.0, 10.0), + 5 => q.static_friction = v.max(0.0), + 6 => q.angle = v, + 7 => q.ang_vel = v, + 8 => q.torque = v, + 9 => q.scale = [v.max(0.0); 2], + 10 => q.scale[0] = v.max(0.0), + 11 => q.scale[1] = v.max(0.0), + 12 => q.p[0] = v, + 13 => q.p[1] = v, + 15 => q.v[0] = v, + 16 => q.v[1] = v, + 18 => q.force[0] = v, + 19 => q.force[1] = v, + 20 => q.c[3] = v.clamp(0.0, 1.0), + 21 => q.mass = v.max(0.01), + 22 => q.lifespan = v.max(0.0), + 23 => q.ch = v.round().clamp(0.0, 0x10ffff as f32) as u32, + 24 => q.r = v.max(0.0), + 25 => q.toff = v, + 26 => q.scale_speed = v, + _ => {} + } +} + +/// An Affects group: which particles a producer, force or mapper acts on, as a weight 0..1. +#[derive(Default)] +struct Affects { + from: u32, + selection: Option, + chars: Vec, + /// Positive: older than; negative: younger than (seconds); 0 = any age. + age: f32, + feather: f32, +} + +impl Affects { + fn from(ctx: &EffectCtx, group: &str) -> Affects { + let pr = ctx.params; + let g = |id: &str| format!("{group}/affects/{id}"); + Affects { + from: pr.e(&g("particlesFrom")), + selection: ctx.layer_param(&g("selectionMap"), true).map(|lp| MapImg::from(ctx, lp)), + chars: pr.s(&g("characters")).chars().collect(), + age: pr.f(&g("olderYoungerThan")) as f32, + feather: pr.f(&g("ageFeather")).max(0.0) as f32, + } + } + fn weight(&self, q: &PgParticle) -> f32 { + let src_ok = match self.from { + 1 => q.src == SRC_CANNON, + 2 => q.src == SRC_GRID, + 3 => q.src == SRC_LAYER_EXPLODER, + 4 => q.src == SRC_PARTICLE_EXPLODER, + 5 => q.src == SRC_GRID || q.src == SRC_LAYER_EXPLODER, + 6 => q.src == SRC_CANNON || q.src == SRC_GRID, + 7 => q.src == SRC_CANNON || q.src == SRC_PARTICLE_EXPLODER, + _ => true, + }; + if !src_ok { + return 0.0; + } + if !self.chars.is_empty() && !char::from_u32(q.ch).is_some_and(|c| self.chars.contains(&c)) { + return 0.0; + } + let mut w = 1.0; + if self.age != 0.0 { + // Signed distance past the age threshold, feathered across Age Feather. + let d = if self.age > 0.0 { q.age - self.age } else { -self.age - q.age }; + w *= if self.feather > 0.0 { + (d / self.feather + 0.5).clamp(0.0, 1.0) + } else if d >= 0.0 { + 1.0 + } else { + 0.0 + }; + } + if let Some(m) = &self.selection { + let c = m.at(q.p[0], q.p[1]); + w *= effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * c[3]; + } + w + } +} + +/// A Persistent or Ephemeral Property Mapper. +struct Mapper { + map: MapImg, + /// Per channel (R, G, B): target, operator, min, max. + chans: [(u32, u32, f32, f32); 3], + affects: Affects, +} + +impl Mapper { + fn apply(&self, q: &mut PgParticle) { + let w = self.affects.weight(q); + if w <= 0.0 { + return; + } + let px = self.map.at(q.p[0], q.p[1]); + for (ch, &(target, op, lo, hi)) in self.chans.iter().enumerate() { + if target == 0 { + continue; + } + let v = lo + (hi - lo) * px[ch]; + if target == 14 || target == 17 { + // Gradient Velocity / Force: along the map channel's gradient. + let gx = self.map.at(q.p[0] + 1.0, q.p[1])[ch] - self.map.at(q.p[0] - 1.0, q.p[1])[ch]; + let gy = self.map.at(q.p[0], q.p[1] + 1.0)[ch] - self.map.at(q.p[0], q.p[1] - 1.0)[ch]; + let (gx, gy) = (gx * 0.5 * (hi - lo) * w, gy * 0.5 * (hi - lo) * w); + if target == 14 { + q.v = [q.v[0] + gx, q.v[1] + gy]; + } else { + q.force = [q.force[0] + gx, q.force[1] + gy]; + } + continue; + } + let cur = prop_get(q, target); + let new = match op { + 1 => cur + v, + 2 => (cur - v).abs(), + 3 => cur - v, + 4 => cur * v, + 5 => cur.min(v), + 6 => cur.max(v), + _ => v, + }; + // (An unlimited lifespan has no in-between.) + let v = if w >= 1.0 || !cur.is_finite() { new } else { cur + (new - cur) * w }; + prop_set(q, target, v); + } + } +} struct Pg { // Cannon @@ -93,17 +355,26 @@ struct Pg { vel_spread: f32, color: [f32; 4], radius: f32, + /// Edit Cannon Text: characters fired in order (empty = dots). + cannon_text: Vec, + // Particle Exploder + pex_radius: f32, + pex_dispersion: f32, + pex_affects: Affects, // Gravity gravity: [f32; 2], grav_spread: f32, + grav_affects: Affects, // Repel repel: f32, repel_radius: f32, + repel_affects: Affects, // Wall wall: Option>, - // Property mapper - mapper: Option, - map_to: [(u32, f32, f32); 3], + wall_affects: Affects, + // Property mappers + persistent: Option, + ephemeral: Option, seed: u32, bounds: [f32; 4], } @@ -125,48 +396,46 @@ impl Pg { [a.cos() * rr, a.sin() * rr] }; let p = [self.pos[0] + off[0], self.pos[1] + off[1]]; - PgParticle { p, v: [d[0] * speed, d[1] * speed], origin: p, c: self.color, r: self.radius, mass: 1.0, friction: 0.0, force: [0.0; 2], id } + let mut q = PgParticle::new(p, [d[0] * speed, d[1] * speed], self.color, self.radius, id, SRC_CANNON); + if !self.cannon_text.is_empty() { + q.ch = self.cannon_text[id as usize % self.cannon_text.len()] as u32; + } + q + } + + /// The particle as the ephemeral mapper sees it this step (unchanged without one). + fn ephemeral(&self, q: &PgParticle) -> PgParticle { + let mut e = *q; + if let Some(m) = &self.ephemeral { + m.apply(&mut e); + } + e } fn step(&self, st: &mut PgState) { let dt = (1.0 / SPS) as f32; let s = self.seed; - // Property mapper (persistent): drive properties from the map at the particle position. - if let Some(m) = &self.mapper { - st.parts.par_iter_mut().for_each(|q| { - let px = m.at(q.p[0], q.p[1]); - for (ch, &(target, lo, hi)) in self.map_to.iter().enumerate() { - let v = lo + (hi - lo) * px[ch]; - match target { - 1 => q.c[0] = v, - 2 => q.c[1] = v, - 3 => q.c[2] = v, - 4 => q.friction = v.clamp(0.0, 1.0), - 5 => q.r = self.radius * v.max(0.0), - 6 => q.p[0] = v, - 7 => q.p[1] = v, - 8 => q.v[0] = v, - 9 => q.v[1] = v, - 10 => q.force[0] = v, - 11 => q.force[1] = v, - 12 => q.c[3] = v.clamp(0.0, 1.0), - 13 => q.mass = v.max(0.01), - _ => {} - } - } - }); + // Persistent Property Mapper: the mapped values stay with the particle. + if let Some(m) = &self.persistent { + st.parts.par_iter_mut().for_each(|q| m.apply(q)); } + // Ephemeral Property Mapper: the mapped values hold for this step only. + let eph: Vec = st.parts.par_iter().map(|q| self.ephemeral(q)).collect(); // Repel: spatial hash, symmetric pairwise push within the radius. let mut push = vec![[0.0f32; 2]; st.parts.len()]; - if self.repel != 0.0 && self.repel_radius > 0.0 && st.parts.len() > 1 { + if self.repel != 0.0 && self.repel_radius > 0.0 && eph.len() > 1 { let cell = self.repel_radius; let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); - for (i, q) in st.parts.iter().enumerate() { + for (i, q) in eph.iter().enumerate() { grid.entry(((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32)).or_default().push(i); } - let parts = &st.parts; + let parts = &eph; push.par_iter_mut().enumerate().for_each(|(i, f)| { let q = parts[i]; + let w = self.repel_affects.weight(&q); + if w <= 0.0 { + return; + } let (cx, cy) = ((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32); for gy in cy - 1..=cy + 1 { for gx in cx - 1..=cx + 1 { @@ -179,7 +448,7 @@ impl Pg { let (dx, dy) = (q.p[0] - o.p[0], q.p[1] - o.p[1]); let d = (dx * dx + dy * dy).sqrt(); if d < self.repel_radius && d > 1e-4 { - let k = self.repel * (1.0 - d / self.repel_radius) / d; + let k = w * self.repel * (1.0 - d / self.repel_radius) / d; f[0] += dx * k; f[1] += dy * k; } @@ -189,14 +458,26 @@ impl Pg { }); } let wall = self.wall.as_deref(); - st.parts.par_iter_mut().zip(push.par_iter()).for_each(|(q, f)| { + st.parts.par_iter_mut().zip(eph.par_iter()).zip(push.par_iter()).for_each(|((q, e), f)| { + let gw = self.grav_affects.weight(e); let g = if self.grav_spread > 0.0 { 1.0 + (hash1(q.id, 7, s) * 2.0 - 1.0) * self.grav_spread } else { 1.0 }; - let ax = self.gravity[0] * g + (q.force[0] + f[0]) / q.mass; - let ay = self.gravity[1] * g + (q.force[1] + f[1]) / q.mass; - let damp = 1.0 - q.friction * 0.1; - q.v = [(q.v[0] + ax * dt) * damp, (q.v[1] + ay * dt) * damp]; - let np = [q.p[0] + q.v[0] * dt, q.p[1] + q.v[1] * dt]; - if let Some(w) = wall { + let ax = self.gravity[0] * g * gw + (e.force[0] + f[0]) / e.mass; + let ay = self.gravity[1] * g * gw + (e.force[1] + f[1]) / e.mass; + // Static friction: a resting particle stays until the push beats it. + let resting = e.v[0].abs() < 1e-3 && e.v[1].abs() < 1e-3; + let (ax, ay) = if resting && (ax * ax + ay * ay).sqrt() <= e.static_friction { (0.0, 0.0) } else { (ax, ay) }; + let damp = (1.0 - e.friction * 0.1).max(0.0); + let nv = [(e.v[0] + ax * dt) * damp, (e.v[1] + ay * dt) * damp]; + // Keep only the persistent part of the velocity. + q.v = [nv[0] - (e.v[0] - q.v[0]), nv[1] - (e.v[1] - q.v[1])]; + q.ang_vel += e.torque * dt; + q.angle += e.ang_vel * dt; + q.scale = [(q.scale[0] + e.scale_speed * dt).max(0.0), (q.scale[1] + e.scale_speed * dt).max(0.0)]; + q.age += dt; + let np = [q.p[0] + nv[0] * dt, q.p[1] + nv[1] * dt]; + if let Some(w) = wall + && self.wall_affects.weight(e) > 0.5 + { let was = point_in_poly(w, q.p[0] as f64, q.p[1] as f64); let now = point_in_poly(w, np[0] as f64, np[1] as f64); if was != now { @@ -208,7 +489,41 @@ impl Pg { q.p = np; }); let b = self.bounds; - st.parts.retain(|q| q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3]); + let eph_life: Vec = st.parts.iter().map(|q| self.ephemeral(q).lifespan).collect(); + let mut k = 0; + st.parts.retain(|q| { + let life = eph_life[k]; + k += 1; + q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3] && q.age <= life + }); + // Particle Exploder: affected particles larger than the new radius burst into smaller + // ones that fly apart (each new particle bursts no further). + if self.pex_radius > 0.0 { + let mut out = Vec::with_capacity(st.parts.len()); + for q in std::mem::take(&mut st.parts) { + if q.src == SRC_PARTICLE_EXPLODER || q.ch != 0 || q.r <= self.pex_radius * 1.01 || self.pex_affects.weight(&q) <= 0.5 || out.len() > 60_000 { + out.push(q); + continue; + } + let n = ((q.r / self.pex_radius).powi(2).round() as u32).clamp(2, 16); + for k in 0..n { + let id = st.next_id; + st.next_id = st.next_id.wrapping_add(1); + let a = (k as f32 + hash1(id, 31, s)) / n as f32 * std::f32::consts::TAU; + let (sa, ca) = a.sin_cos(); + let rr = q.r - self.pex_radius; + let sp = self.pex_dispersion * (0.5 + hash1(id, 32, s)); + let mut c = q; + c.p = [q.p[0] + ca * rr, q.p[1] + sa * rr]; + c.v = [q.v[0] + ca * sp, q.v[1] + sa * sp]; + c.r = self.pex_radius; + c.src = SRC_PARTICLE_EXPLODER; + c.id = id; + out.push(c); + } + } + st.parts = out; + } if self.cannon { st.carry += self.rate / SPS; let n = st.carry.floor() as u32; @@ -244,13 +559,58 @@ fn image_key(img: &Image) -> u64 { h.finish() } +fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option { + let pr = ctx.params; + let map = ctx.layer_param(&format!("{group}/useLayerAsMap"), true).map(|lp| MapImg::from(ctx, lp))?; + let ch = |c: &str| { + ( + pr.e(&format!("{group}/map{c}To")), + if ephemeral { pr.e(&format!("{group}/{}Operator", c.to_lowercase())) } else { 0 }, + pr.f(&format!("{group}/{}Min", c.to_lowercase())) as f32, + pr.f(&format!("{group}/{}Max", c.to_lowercase())) as f32, + ) + }; + Some(Mapper { map, chans: [ch("Red"), ch("Green"), ch("Blue")], affects: Affects::from(ctx, group) }) +} + +/// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees +/// and scaled, over `out`. +fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { + let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale); + let r = (w * w + h * h).sqrt() * 0.5 + 1.0; + let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64); + let (y0, y1) = ((y - r).floor().max(0.0) as i64, (y + r).ceil().min(out.height as f64) as i64); + if x0 >= x1 || y0 >= y1 || scale[0] == 0.0 || scale[1] == 0.0 { + return; + } + let (sa, ca) = angle.to_radians().sin_cos(); + for py in y0..y1 { + for px in x0..x1 { + let (dx, dy) = ((px as f64 + 0.5 - x) / buf_scale, (py as f64 + 0.5 - y) / buf_scale); + // Undo rotation, then scale; into map layer pixels around its centre. + let (u, v) = ((dx * ca + dy * sa) / scale[0] + lp.size[0] * 0.5, (-dx * sa + dy * ca) / scale[1] + lp.size[1] * 0.5); + if u < 0.0 || v < 0.0 || u >= lp.size[0] || v >= lp.size[1] { + continue; + } + let s = lp.buf.img.sample_bilinear(u * lp.buf.scale + lp.buf.offset[0], v * lp.buf.scale + lp.buf.offset[1]); + if s[3] <= 0.0 { + continue; + } + let d = out.get(px, py); + let k = 1.0 - s[3] * alpha; + out.set(px as u32, py as u32, [s[0] * alpha + d[0] * k, s[1] * alpha + d[1] * k, s[2] * alpha + d[2] * k, s[3] * alpha + d[3] * k]); + } + } +} + fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); let gf = pr.f("gravity/gravityForce") as f32; let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); - let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; + let persistent = if pr.b("mapperEnabled") { mapper_from(ctx, "persistentPropertyMapper", false) } else { None }; + let ephemeral = mapper_from(ctx, "ephemeralPropertyMapper", true); let pg = Pg { cannon: pr.b("cannonEnabled"), pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, @@ -262,17 +622,20 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { vel_spread: pr.f("cannon/velocityRandomSpread") as f32, color: pr.color("cannon/cannonColor"), radius: pr.f("cannon/cannonParticleRadius").max(0.0) as f32, + cannon_text: pr.s("options/cannonText").chars().collect(), + pex_radius: pr.f("particleExploder/radiusOfNewParticles").max(0.0) as f32, + pex_dispersion: pr.f("particleExploder/velocityDispersion") as f32, + pex_affects: Affects::from(ctx, "particleExploder"), gravity: [grav_dir.sin() * gf, -grav_dir.cos() * gf], grav_spread: (pr.f("gravity/gravityForceRandomSpread") / 100.0).max(0.0) as f32, + grav_affects: Affects::from(ctx, "gravity"), repel: pr.f("repel/repelForce") as f32 * 100.0, repel_radius: pr.f("repel/repelForceRadius").max(0.0) as f32, + repel_affects: Affects::from(ctx, "repel"), wall, - mapper, - map_to: [ - (pr.e("persistentPropertyMapper/mapRedTo"), pr.f("persistentPropertyMapper/redMin") as f32, pr.f("persistentPropertyMapper/redMax") as f32), - (pr.e("persistentPropertyMapper/mapGreenTo"), pr.f("persistentPropertyMapper/greenMin") as f32, pr.f("persistentPropertyMapper/greenMax") as f32), - (pr.e("persistentPropertyMapper/mapBlueTo"), pr.f("persistentPropertyMapper/blueMin") as f32, pr.f("persistentPropertyMapper/blueMax") as f32), - ], + wall_affects: Affects::from(ctx, "wall"), + persistent, + ephemeral, seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], }; @@ -284,6 +647,7 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let (gw, gh) = (pr.f("grid/gridWidth"), pr.f("grid/gridHeight")); let gcol = pr.color("grid/gridColor"); let grad = pr.f("grid/gridParticleRadius").max(0.0) as f32; + let grid_text: Vec = pr.s("options/gridText").chars().collect(); let exploder = if pr.b("exploderEnabled") { let host = ctx.env.host; let lid = pr.get("layerExploder/explodeLayer").and_then(Value::as_layer); @@ -296,11 +660,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { }; let ex_r = pr.f("layerExploder/radiusOfNewParticles").max(0.5) as f32; let ex_disp = pr.f("layerExploder/velocityDispersion") as f32; - let layer_map = pr.b("layerMapEnabled").then(|| ctx.layer_param("layerMap/layerMapLayer", true).map(|lp| MapImg::from(ctx, lp))).flatten(); let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); - if let Some(m) = &pg.mapper { - key ^= image_key(&m.img).rotate_left(7); + for m in [&pg.persistent, &pg.ephemeral].into_iter().flatten() { + key ^= image_key(&m.map.img).rotate_left(7); + } + for a in [&pg.pex_affects, &pg.grav_affects, &pg.repel_affects, &pg.wall_affects] { + if let Some(s) = &a.selection { + key ^= image_key(&s.img).rotate_left(11); + } } if let Some(e) = &exploder { key ^= image_key(&e.buf.img).rotate_left(13); @@ -312,14 +680,29 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let init = || { let mut st = PgState::default(); if grid_on && across > 0 && down > 0 { + let mut k = 0usize; for j in 0..down { for i in 0..across { + // Edit Grid Text: one character per grid point in reading order; spaces + // leave the point empty. + let ch = if grid_text.is_empty() { + 0 + } else { + let c = grid_text[k % grid_text.len()]; + k += 1; + if c == ' ' { + continue; + } + c as u32 + }; let fx = if across > 1 { i as f64 / (across - 1) as f64 - 0.5 } else { 0.0 }; let fy = if down > 1 { j as f64 / (down - 1) as f64 - 0.5 } else { 0.0 }; let p = [(gpos[0] + fx * gw) as f32, (gpos[1] + fy * gh) as f32]; let id = st.next_id; st.next_id += 1; - st.parts.push(PgParticle { p, v: [0.0; 2], origin: p, c: gcol, r: grad, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + let mut q = PgParticle::new(p, [0.0; 2], gcol, grad, id, SRC_GRID); + q.ch = ch; + st.parts.push(q); } } } @@ -338,7 +721,7 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let (dx, dy) = (x - lw * 0.5, y - lh * 0.5); let l = (dx * dx + dy * dy).sqrt().max(1.0); let v = [dx / l * ex_disp + h(1) * ex_disp, dy / l * ex_disp + h(2) * ex_disp]; - st.parts.push(PgParticle { p: [x, y], v, origin: [x, y], c, r: ex_r, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + st.parts.push(PgParticle::new([x, y], v, c, ex_r, id, SRC_LAYER_EXPLODER)); } x += step; } @@ -348,23 +731,81 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { st }; let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)); + // Render: what the ephemeral mapper changes shows for this frame only. + let parts: Vec = st.parts.iter().map(|q| pg.ephemeral(q)).collect(); + let auto_orient = pr.b("options/autoOrientRotation"); let s = b.scale as f32; - let sprites: Vec = st - .parts - .iter() - .map(|q| { - let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); - let c = match &layer_map { - Some(m) => { - let mc = m.at(q.origin[0], q.origin[1]); - [mc[0], mc[1], mc[2], mc[3] * q.c[3]] + // Layer Map: affected particles show the map layer (at a time set by the offset type). + let map_id = if pr.b("layerMapEnabled") { pr.get("layerMap/layerMapLayer").and_then(Value::as_layer) } else { None }; + let map_affects = Affects::from(ctx, "layerMap"); + let offset_type = pr.e("layerMap/timeOffsetType"); + let offset = pr.f("layerMap/timeOffset"); + let fps = ctx.fps(); + let mut frames: std::collections::HashMap> = std::collections::HashMap::new(); + let mut sprites: Vec = vec![]; + let mut blits: Vec<(PgParticle, i64)> = vec![]; + for q in &parts { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + let angle = if auto_orient && (q.v[0] != 0.0 || q.v[1] != 0.0) { q.v[1].atan2(q.v[0]).to_degrees() + 90.0 } else { q.angle }; + if let (Some(_), true) = (map_id, map_affects.weight(q) > 0.5) { + let rnd = hash1(q.id, 41, seed) as f64; + let t = match offset_type { + 1 => offset, + 2 => ctx.env.comp_time + offset * rnd, + 3 => offset * rnd, + _ => ctx.env.comp_time + offset, + } + q.toff as f64; + let frame = (t * fps).round() as i64; + blits.push((PgParticle { angle, ..*q }, frame)); + continue; + } + let sc = (q.scale[0] + q.scale[1]) * 0.5; + match char::from_u32(q.ch).filter(|c| q.ch != 0 && *c != ' ') { + Some(c) => { + // A text particle: the character's strokes, Font Size tall, rotated. + let size = (q.r * sc * s) as f64; + let u = size * 0.7 / 6.0; + let wgt = (size * 0.045).max(0.35) as f32; + let (sa, ca) = (angle as f64).to_radians().sin_cos(); + let tr = |g: [f64; 2]| { + let (gx, gy) = ((g[0] - 2.0) * u * q.scale[0] as f64 / sc.max(1e-6) as f64, (g[1] - 3.0) * u * q.scale[1] as f64 / sc.max(1e-6) as f64); + [x + gx * ca - gy * sa, y + gx * sa + gy * ca] + }; + for stroke in crate::textfx::glyph_strokes(c) { + if stroke.len() == 1 { + let p0 = tr(stroke[0]); + sprites.push(Sprite::new(p0[0] as f32, p0[1] as f32, wgt, q.c, Shape::Disc)); + } + for w2 in stroke.windows(2) { + let (a0, a1) = (tr(w2[0]), tr(w2[1])); + sprites.push(Sprite::new(a0[0] as f32, a0[1] as f32, wgt, q.c, Shape::Line { dx: (a1[0] - a0[0]) as f32, dy: (a1[1] - a0[1]) as f32 })); + } } - None => q.c, - }; - Sprite::new(x as f32, y as f32, q.r * s, c, Shape::Disc) - }) - .collect(); - let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + } + None => sprites.push(Sprite::new(x as f32, y as f32, q.r * sc * s, q.c, Shape::Disc)), + } + } + let mut fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + if let (Some(id), Some(host)) = (map_id, ctx.env.host) { + let me = pr.get(&crate::layer_source_id("layerMap/layerMapLayer")).is_none_or(|v| v.as_enum() != 0); + for (q, frame) in &blits { + if !frames.contains_key(frame) && frames.len() >= 32 { + // Too many distinct times: reuse the nearest fetched frame. + if let Some(k) = frames.keys().min_by_key(|k| (*k - frame).abs()).copied() + && let Some(Some(lp)) = frames.get(&k) + { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]); + } + continue; + } + let lp = frames.entry(*frame).or_insert_with(|| host.layer_at(id, *frame as f64 / fps, me)); + if let Some(lp) = lp { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]); + } + } + } // Particles replace the layer's own pixels. b.img = combine(&b.img, &fx, 5); b @@ -473,7 +914,23 @@ pub fn specs() -> Vec { let pt = |x: f64, y: f64| Value::Vec2([x, y]); let px = |d: f64, max: f64| (num(d), slider(0.0, max * 10.0, 0.0, max, 1)); let sp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1); + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + // An Affects twirl-down inside `group`. + let affects = |group: &str| -> Vec { + let id = |s: &str| leak(format!("{group}/affects/{s}")); + vec![ + p(id("particlesFrom"), "Particles From", Value::Enum(0), popup(&PARTICLES_FROM)), + p(id("selectionMap"), "Selection Map", Value::Layer(None), ParamUi::Layer), + p(id("characters"), "Characters", Value::Str(String::new()), ParamUi::Text), + p(id("olderYoungerThan"), "Older/Younger than", num(0.0), slider(-1000.0, 1000.0, -10.0, 10.0, 2)), + p(id("ageFeather"), "Age Feather", num(0.0), slider(0.0, 1000.0, 0.0, 10.0, 2)), + ] + }; let mut pg = vec![ + // Options (the Options dialog): text fired by the cannon / placed on the grid. + p("options/cannonText", "Cannon Text", Value::Str(String::new()), ParamUi::Text), + p("options/gridText", "Grid Text", Value::Str(String::new()), ParamUi::Text), + p("options/autoOrientRotation", "Auto-Orient Rotation", Value::Bool(false), ParamUi::Checkbox), // Generator switches (kept for saved projects; After Effects turns a generator off with // its rate / counts instead). p("cannonEnabled", "Cannon Enabled", Value::Bool(true), ParamUi::Hidden), @@ -503,20 +960,40 @@ pub fn specs() -> Vec { p("layerExploder/explodeLayer", "Explode Layer", Value::Layer(None), ParamUi::Layer), sp("layerExploder/radiusOfNewParticles", "Radius of New Particles", px(2.0, 50.0)), sp("layerExploder/velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), - // Layer Map + ]; + pg.extend([ + sp("particleExploder/radiusOfNewParticles", "Radius of New Particles", px(0.0, 50.0)), + sp("particleExploder/velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), + ]); + pg.extend(affects("particleExploder")); + pg.extend([ p("layerMap/layerMapLayer", "Use Layer", Value::Layer(None), ParamUi::Layer), + p("layerMap/timeOffsetType", "Time Offset Type", Value::Enum(0), popup(&TIME_OFFSET_TYPES)), + p("layerMap/timeOffset", "Time Offset", num(0.0), slider(-1000.0, 1000.0, -10.0, 10.0, 2)), + ]); + pg.extend(affects("layerMap")); + pg.extend([ // Gravity sp("gravity/gravityForce", "Force", px(108.0, 1000.0)), sp("gravity/gravityForceRandomSpread", "Force Random Spread", px(0.0, 100.0)), p("gravity/gravityDirection", "Direction", num(180.0), ParamUi::Angle), + ]); + pg.extend(affects("gravity")); + pg.extend([ // Repel p("repel/repelForce", "Force", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 2)), sp("repel/repelForceRadius", "Force Radius", px(0.0, 100.0)), + ]); + pg.extend(affects("repel")); + pg.extend([ // Wall p("wall/wallBoundary", "Boundary", num(0.0), ParamUi::Mask), + ]); + pg.extend(affects("wall")); + pg.extend([ // Persistent Property Mapper p("persistentPropertyMapper/useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer), - ]; + ]); for (to, mn, mx, n_to) in [ ("persistentPropertyMapper/mapRedTo", "persistentPropertyMapper/redMin", "persistentPropertyMapper/redMax", "Map Red To"), ("persistentPropertyMapper/mapGreenTo", "persistentPropertyMapper/greenMin", "persistentPropertyMapper/greenMax", "Map Green To"), @@ -526,6 +1003,18 @@ pub fn specs() -> Vec { pg.push(p(mn, "Min", num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); pg.push(p(mx, "Max", num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); } + pg.extend(affects("persistentPropertyMapper")); + // Ephemeral Property Mapper: like the persistent one, with an operator per channel; the + // values hold for one frame. + pg.push(p("ephemeralPropertyMapper/useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer)); + for c in ["Red", "Green", "Blue"] { + let l = c.to_lowercase(); + pg.push(p(leak(format!("ephemeralPropertyMapper/map{c}To")), leak(format!("Map {c} To")), Value::Enum(0), popup(&MAP_TARGETS))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Operator")), "Operator", Value::Enum(0), popup(&MAP_OPERATORS))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Min")), "Min", num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Max")), "Max", num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + } + pg.extend(affects("ephemeralPropertyMapper")); pg.push(p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0))); let pc = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1)); vec![ @@ -691,7 +1180,19 @@ mod tests { 0.7, ); assert_eq!(a.data, b.data); - let st = Pg { + let st = + Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), [(15, 0, 0.0, 120.0), (0, 0, 0.0, 1.0), (9, 0, 0.0, 2.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + // Red 1 → X speed 120 px/s; blue 0.5 → scale 1. + assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); + assert!((s.parts[0].scale[0] - 1.0).abs() < 1e-3); + } + + /// A Pg with nothing switched on. + fn bare(seed: u32) -> Pg { + Pg { cannon: false, pos: [0.0; 2], barrel_angle: 0.0, @@ -702,47 +1203,32 @@ mod tests { vel_spread: 0.0, color: [1.0; 4], radius: 2.0, + cannon_text: vec![], + pex_radius: 0.0, + pex_dispersion: 0.0, + pex_affects: Affects::default(), gravity: [0.0; 2], grav_spread: 0.0, + grav_affects: Affects::default(), repel: 0.0, repel_radius: 0.0, + repel_affects: Affects::default(), wall: None, - mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), k: [1.0; 2], o: [0.0; 2] }), - map_to: [(8, 0.0, 120.0), (0, 0.0, 1.0), (5, 0.0, 2.0)], - seed: 1, + wall_affects: Affects::default(), + persistent: None, + ephemeral: None, + seed, bounds: [-1e6, -1e6, 1e6, 1e6], - }; - let mut s = PgState::default(); - s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); - st.step(&mut s); - // Red 1 → X velocity 120 px/s; blue 0.5 → scale 1 (radius 2). - assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); - assert!((s.parts[0].r - 2.0).abs() < 1e-3); + } + } + + fn mapper(img: Image, chans: [(u32, u32, f32, f32); 3]) -> Mapper { + Mapper { map: MapImg { img, k: [1.0; 2], o: [0.0; 2] }, chans, affects: Affects::default() } } #[test] fn barrel_radius_square_or_disc() { - let pg = |r: f32| Pg { - cannon: true, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: r, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - gravity: [0.0; 2], - grav_spread: 0.0, - repel: 0.0, - repel_radius: 0.0, - wall: None, - mapper: None, - map_to: [(0, 0.0, 1.0); 3], - seed: 3, - bounds: [-1e6, -1e6, 1e6, 1e6], - }; + let pg = |r: f32| Pg { cannon: true, barrel_radius: r, ..bare(3) }; let (sq, disc) = (pg(10.0), pg(-10.0)); let mut corner = false; for id in 0..400 { @@ -760,37 +1246,170 @@ mod tests { #[test] fn mapper_drives_position_and_opacity_in_ae_order() { - let st = Pg { - cannon: false, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: 0.0, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - gravity: [0.0; 2], - grav_spread: 0.0, - repel: 0.0, - repel_radius: 0.0, - wall: None, - mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), k: [1.0; 2], o: [0.0; 2] }), - map_to: [(6, 0.0, 30.0), (12, 0.0, 1.0), (4, 0.0, 1.0)], - seed: 1, - bounds: [-1e6, -1e6, 1e6, 1e6], - }; - assert_eq!(MAP_TARGETS[6], "X"); - assert_eq!(MAP_TARGETS[12], "Opacity"); + let st = + Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), [(12, 0, 0.0, 30.0), (20, 0, 0.0, 1.0), (4, 0, 0.0, 1.0)])), ..bare(1) }; + assert_eq!(MAP_TARGETS[12], "X"); + assert_eq!(MAP_TARGETS[20], "Opacity"); + assert_eq!(MAP_TARGETS[26], "Scale Speed"); let mut s = PgState::default(); - s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); st.step(&mut s); assert!((s.parts[0].p[0] - 30.0).abs() < 1e-3, "{:?}", s.parts[0].p); assert!((s.parts[0].c[3] - 0.5).abs() < 1e-3); assert!((s.parts[0].friction - 0.25).abs() < 1e-3); } + #[test] + fn ephemeral_mapper_holds_for_one_step_with_operators() { + // Ephemeral X Speed = 60 px/s (Set): the particle moves, but keeps no velocity. + let st = Pg { ephemeral: Some(mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(15, 0, 0.0, 60.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + assert!((s.parts[0].p[0] - 2.0).abs() < 1e-4, "{:?}", s.parts[0].p); + assert!(s.parts[0].v[0].abs() < 1e-4); + // Operators: Add / Multiply / Max on the particle's own value. + let op = |o: u32, cur: f32| { + let m = mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(21, o, 0.0, 3.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)]); + let mut q = PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID); + q.mass = cur; + m.apply(&mut q); + q.mass + }; + assert_eq!(MAP_OPERATORS[1], "Add"); + assert!((op(1, 2.0) - 5.0).abs() < 1e-5); + assert!((op(4, 2.0) - 6.0).abs() < 1e-5); + assert!((op(6, 5.0) - 5.0).abs() < 1e-5); + assert!((op(2, 5.0) - 2.0).abs() < 1e-5, "Difference"); + } + + #[test] + fn affects_select_by_source_age_and_characters() { + let mut q = PgParticle::new([0.0; 2], [0.0; 2], [1.0; 4], 2.0, 0, SRC_CANNON); + let a = Affects { from: 2, ..Default::default() }; + assert_eq!(a.weight(&q), 0.0, "grid only"); + q.src = SRC_GRID; + assert_eq!(a.weight(&q), 1.0); + let older = Affects { age: 1.0, ..Default::default() }; + q.age = 0.5; + assert_eq!(older.weight(&q), 0.0); + q.age = 1.5; + assert_eq!(older.weight(&q), 1.0); + let younger = Affects { age: -1.0, feather: 1.0, ..Default::default() }; + q.age = 1.0; + assert!((younger.weight(&q) - 0.5).abs() < 1e-5, "feathered"); + let chars = Affects { chars: vec!['A', 'B'], ..Default::default() }; + q.ch = 'B' as u32; + assert_eq!(chars.weight(&q), 1.0); + q.ch = 'C' as u32; + assert_eq!(chars.weight(&q), 0.0); + // Gravity restricted to the cannon leaves grid particles in place. + let st = Pg { gravity: [0.0, 100.0], grav_affects: Affects { from: 1, ..Default::default() }, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 1, SRC_CANNON)); + for _ in 0..10 { + st.step(&mut s); + } + assert_eq!(s.parts[0].p, [5.0, 5.0]); + assert!(s.parts[1].p[1] > 5.0); + } + + #[test] + fn particle_exploder_bursts_big_particles() { + let st = Pg { pex_radius: 2.0, pex_dispersion: 30.0, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([50.0, 50.0], [0.0; 2], [1.0; 4], 6.0, 0, SRC_GRID)); + s.next_id = 1; + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "(6/2)² new particles"); + assert!(s.parts.iter().all(|q| q.r == 2.0 && q.src == SRC_PARTICLE_EXPLODER)); + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "new particles do not burst again"); + // Lifespan from a mapper removes particles once they are older. + let life = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0; 4]), [(22, 0, 0.0, 0.05), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + for _ in 0..2 { + life.step(&mut s); + } + assert_eq!(s.parts.len(), 1); + for _ in 0..3 { + life.step(&mut s); + } + assert!(s.parts.is_empty()); + } + + #[test] + fn text_particles_draw_characters() { + let grid = |text: &str| { + run( + &[ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/gridWidth", num(40.0)), + ("grid/gridHeight", num(0.0)), + ("grid/particlesAcross", num(3.0)), + ("grid/particlesDown", num(1.0)), + ("grid/gridParticleRadius", num(16.0)), + ("gravity/gravityForce", num(0.0)), + ("options/gridText", Value::Str(text.into())), + ], + 0.1, + ) + }; + let dots = grid(""); + let text = grid("I-O"); + assert_ne!(dots.data, text.data); + // "I" is a vertical bar: ink above and below the grid point, not a round dot. + assert!(text.get(20, 26)[3] > 0.3 && text.get(20, 34)[3] > 0.3, "{:?}", text.get(20, 26)); + // A space leaves its grid point empty. + let gap = grid("I O"); + assert!(gap.get(40, 30)[3] < 0.05); + // Cannon text: particles carry the characters in order. + let pg = Pg { cannon_text: vec!['A', 'B'], ..bare(1) }; + assert_eq!(pg.spawn(0).ch, 'A' as u32); + assert_eq!(pg.spawn(3).ch, 'B' as u32); + } + + #[test] + fn layer_map_shows_the_map_layer_at_its_time_offset() { + /// A 6×6 map layer whose colour encodes the comp time it was fetched at. + struct Clock; + impl crate::EffectHost for Clock { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn layer_at(&self, _: u64, t: f64, _: bool) -> Option { + let v = (t as f32 / 4.0).clamp(0.0, 1.0); + Some(crate::LayerPixels { buf: Buf { img: Image::filled(6, 6, [v, 0.0, 1.0 - v, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [6.0, 6.0] }) + } + } + let render = |kind: u32, offset: f64| { + let vals = [ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/particlesAcross", num(1.0)), + ("grid/particlesDown", num(1.0)), + ("gravity/gravityForce", num(0.0)), + ("layerMap/layerMapLayer", Value::Layer(Some(9))), + ("layerMap/timeOffsetType", Value::Enum(kind)), + ("layerMap/timeOffset", num(offset)), + ]; + let env = EffectEnv { host: Some(&Clock), comp_time: 1.0, frame_rate: 10.0, ..Default::default() }; + run_fx("ec.sim.particleplayground", &vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), 1.0, env).img + }; + // The particle is a 6×6 copy of the map layer. + let rel = render(0, 1.0); + assert!(rel.get(40, 30)[3] > 0.99 && rel.get(46, 30)[3] < 0.01); + assert!((rel.get(40, 30)[0] - 0.5).abs() < 1e-3, "relative: now + 1 s = 2 s"); + let abs = render(1, 3.0); + assert!((abs.get(40, 30)[0] - 0.75).abs() < 1e-3, "absolute: 3 s"); + } + #[test] fn cc_hair_grows_from_opaque_pixels() { let mut img = Image::new(60, 60); diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index ade9b1ce..4d938faf 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -94,6 +94,11 @@ fn glyph_src(c: char) -> &'static str { } } +/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles). +pub(crate) fn glyph_strokes(c: char) -> Vec> { + parse_glyph(c) +} + fn parse_glyph(c: char) -> Vec> { glyph_src(c) .split(';') diff --git a/docs/effects.md b/docs/effects.md index 18c23b28..840b8e5b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2647 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 2702 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**: 269 implemented in full, 34 partial (what is missing is listed). +- **Status**: 270 implemented in full, 33 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -331,7 +331,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | | Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | | Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map | -| Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles | +| Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | | Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | | Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group | From 3103e7e80f116edf7b3be5047cc443ad6d2a5519 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:30:33 -0400 Subject: [PATCH 04/17] M9.13: Fill masks/feather, Refine mattes Reduce Chatter and motion blur - Fill: Fill Mask, All Masks, Horizontal / Vertical Feather, Invert outside the mask (GPU falls back to the CPU when masks are used). - Refine Soft / Hard Matte: Reduce Chatter (temporal steadying of oscillating edges) and Use Motion Blur (samples, shutter angle, higher quality) from neighbouring frames. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 3 - crates/effects/src/color_fx.rs | 89 ++++++++++++++++ crates/effects/src/lib.rs | 7 +- crates/effects/src/matte.rs | 188 +++++++++++++++++++++++++++------ crates/gpu/src/effects.rs | 4 + docs/effects.md | 10 +- 6 files changed, 260 insertions(+), 41 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 77f7f49f..b72242dd 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -19,7 +19,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.distort.transform", "no shutter-angle motion blur"), ("ec.distort.reshape", "no correspondence points"), ("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"), - ("ec.generate.fill", "no Fill Mask / All Masks / feather"), ("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"), @@ -27,8 +26,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.key.innerouter", "one additional mask per side; no cleanup strokes"), ("ec.key.advancedspill", "no Tolerance, Desaturate or Spill Color Correction"), ("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"), - ("ec.matte.refinesoft", "no Reduce Chatter or motion blur"), - ("ec.matte.refinehard", "no Reduce Chatter or motion blur"), ("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 240dee1f..7427e9d2 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -340,10 +340,66 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Whether Fill uses masks (Fill Mask or All Masks): the GPU kernel fills alpha only. +pub fn fill_uses_masks(ctx: &EffectCtx) -> bool { + !ctx.env.masks.is_empty() && (ctx.params.b("allMasks") || ctx.params.f("fillMask") >= 1.0) +} + +/// Coverage (0..1, 2×2 supersampled) of the closed polygons `polys` (buffer px) on a `w`×`h` +/// grid: the union of the shapes, each inverted when its mask is. +pub(crate) fn polys_coverage(w: usize, h: usize, polys: &[(Vec<[f64; 2]>, bool)]) -> crate::util::Plane { + use rayon::prelude::*; + let mut out = crate::util::Plane::new(w, h); + out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let mut hit = 0.0; + for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] { + let (px, py) = (x as f64 + sx, y as f64 + sy); + if polys.iter().any(|(p, inv)| crate::util::point_in_poly(p, px, py) != *inv) { + hit += 0.25; + } + } + *v = hit; + } + }); + out +} + +/// Fill: paints the layer's pixels with Color. With Fill Mask (or All Masks) only the pixels +/// inside that mask (or any mask) are painted, with the mask edge feathered horizontally / +/// vertically; Invert paints outside instead. Without a mask, Invert fills the transparent +/// areas. fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = ctx.params.color("color"); let invert = ctx.params.b("invert"); let opacity = ctx.params.f("opacity") as f32 / 100.0; + if fill_uses_masks(ctx) { + let all = ctx.params.b("allMasks"); + let idx = ctx.params.f("fillMask").round() as usize; + let polys: Vec<(Vec<[f64; 2]>, bool)> = ctx + .env + .masks + .iter() + .enumerate() + .filter(|(i, m)| (all || i + 1 == idx) && m.points.len() >= 3) + .map(|(_, m)| (m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.inverted)) + .collect(); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut cov = polys_coverage(w, h, &polys); + let (fh, fv) = (ctx.params.f("horizontalFeather").max(0.0) * b.scale, ctx.params.f("verticalFeather").max(0.0) * b.scale); + if fh > 0.0 || fv > 0.0 { + cov = crate::util::gauss_plane(&cov, fh * 0.5, fv * 0.5); + } + use rayon::prelude::*; + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &m)| { + let m = if invert { 1.0 - m } else { m } * opacity; + let a = px[3]; + for i in 0..3 { + px[i] += (c[i] * a - px[i]) * m; + } + }); + return b; + } b.img.data.iter_mut().for_each(|px| { let a = if invert { 1.0 - px[3] } else { px[3] }; let f = [c[0] * a, c[1] * a, c[2] * a]; @@ -755,8 +811,12 @@ pub fn specs() -> Vec { name: "Fill", category: "Generate", params: vec![ + p("fillMask", "Fill Mask", num(0.0), ParamUi::Mask), + p("allMasks", "All Masks", Value::Bool(false), ParamUi::Checkbox), p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("horizontalFeather", "Horizontal Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)), + p("verticalFeather", "Vertical Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)), p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), ], render: fill, @@ -1069,4 +1129,33 @@ mod tests { let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() }; assert!(!huesat_ranges_identity(&ctx)); } + + #[test] + fn fill_mask_all_masks_feather_and_invert() { + let sq = |x0: f64, x1: f64| crate::MaskShape { + name: String::new(), + points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]], + closed: true, + inverted: false, + }; + let masks = [sq(0.0, 5.0), sq(15.0, 20.0)]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]); + let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img; + let one = run(&[("fillMask", num(1.0))]); + assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]); + assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]); + let all = run(&[("allMasks", Value::Bool(true))]); + assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]); + let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]); + assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]); + assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]); + let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]); + let v = soft.get(6, 5)[1]; + assert!(v > 0.05 && v < 0.5, "feathered edge {v}"); + // Without masks, Fill fills the layer as before. + let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img; + assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 49d98236..d465059d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -62,7 +62,9 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip}; +pub use color_fx::{ + HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, +}; pub use color2::Curve; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -693,6 +695,9 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", + // Refine mattes' Reduce Chatter / motion blur read neighbouring frames. + "ec.matte.refinesoft", + "ec.matte.refinehard", // Card Wipe's position / rotation jitter moves with time. "ec.transition.cardwipe", // Temporal Smoothing reads neighbouring frames. diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index d7701158..4c42c9c3 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -93,38 +93,91 @@ fn refine(ctx: &EffectCtx, mut b: Buf, hard: bool) -> Buf { let hi = morph_plane(&a, r, r, true); let lo = morph_plane(&a, r, r, false); let band = |i: usize| hi.data[i] - lo.data[i] > 1e-3; - let mut na = Plane::new(a.w, a.h); - if edge_details { - let guide = Plane::from_image(&b.img, |px| effectcraft_color::luminance(px[0], px[1], px[2])); - let eps = 1e-4 + smooth * smooth * 0.05; - let filtered = guided_filter(&guide, &a, r, eps); - na.data.par_iter_mut().enumerate().for_each(|(i, v)| { - *v = if band(i) { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] }; - }); - } else { - na = a.clone(); - if smooth > 0.0 { - let s = smooth as f64 * r as f64 * 0.5; - na = gauss_plane(&na, s, s); - } - } - if feather > 0.0 { - na = gauss_plane(&na, feather, feather); - } - na = na.map(|v| { - let mut v = if choke > 0.0 { - (v - choke) / (1.0 - choke) - } else if choke < 0.0 { - v / (1.0 + choke) + // The refined matte of a frame. + let refine_alpha = |img: &effectcraft_raster::Image| -> Plane { + let a = Plane::alpha(img); + let hi = morph_plane(&a, r, r, true); + let lo = morph_plane(&a, r, r, false); + let mut na = Plane::new(a.w, a.h); + if edge_details { + let guide = Plane::from_image(img, |px| effectcraft_color::luminance(px[0], px[1], px[2])); + let eps = 1e-4 + smooth * smooth * 0.05; + let filtered = guided_filter(&guide, &a, r, eps); + na.data.par_iter_mut().enumerate().for_each(|(i, v)| { + *v = if hi.data[i] - lo.data[i] > 1e-3 { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] }; + }); } else { - v - }; - if (contrast - 1.0).abs() > 1e-6 { - v = (v - 0.5) * contrast + 0.5; + na = a.clone(); + if smooth > 0.0 { + let s = smooth as f64 * r as f64 * 0.5; + na = gauss_plane(&na, s, s); + } } - let v = v.clamp(0.0, 1.0); - if invert { 1.0 - v } else { v } - }); + if feather > 0.0 { + na = gauss_plane(&na, feather, feather); + } + na.map(|v| { + let mut v = if choke > 0.0 { + (v - choke) / (1.0 - choke) + } else if choke < 0.0 { + v / (1.0 + choke) + } else { + v + }; + if (contrast - 1.0).abs() > 1e-6 { + v = (v - 0.5) * contrast + 0.5; + } + let v = v.clamp(0.0, 1.0); + if invert { 1.0 - v } else { v } + }) + }; + let mut na = refine_alpha(&b.img); + // Neighbouring frames of this effect's input (same pixel grid), refined the same way. + let fps = ctx.fps(); + let frame_at = |t: f64| -> Option { + let o = ctx.env.host?.self_at(t, ctx.env.effect_index)?; + (o.img.width == b.img.width && o.img.height == b.img.height && (o.offset[0] - b.offset[0]).abs() < 1e-6 && (o.offset[1] - b.offset[1]).abs() < 1e-6) + .then(|| refine_alpha(&o.img)) + }; + // Reduce Chatter: where the previous and next frames agree but this one differs, the matte + // is chattering; average the three there. Where they disagree (motion) it is left alone. + let chatter = (pr.f("reduceChatter") / 100.0).clamp(0.0, 1.0) as f32; + if chatter > 0.0 { + let prev = frame_at(ctx.time - 1.0 / fps); + let next = frame_at(ctx.time + 1.0 / fps); + if let (Some(p), Some(n)) = (&prev, &next) { + na.data.par_iter_mut().enumerate().for_each(|(i, v)| { + let (c, a, z) = (*v, p.data[i], n.data[i]); + let steady = (1.0 - (a - z).abs() / 0.5).clamp(0.0, 1.0); + *v = c + ((a + c + z) / 3.0 - c) * chatter * steady; + }); + } + } + // Motion blur: the matte averaged over the shutter (centred on the frame). + if pr.b("useMotionBlur") { + let g = |id: &str| pr.get(&format!("motionBlur/{id}")).map(Value::as_f64); + let samples = g("motionBlurSamples").unwrap_or(16.0).clamp(1.0, 64.0) as usize; + let samples = if pr.b("motionBlur/higherQuality") { samples * 2 } else { samples }.min(64); + let span = g("shutterAngle").unwrap_or(180.0).clamp(0.0, 720.0) / 360.0 / fps; + if samples > 1 && span > 0.0 { + let mut acc = na.clone(); + let mut n = 1.0f32; + for k in 0..samples { + let t = ctx.time - span * 0.5 + span * k as f64 / (samples - 1) as f64; + if (t - ctx.time).abs() < 1e-9 { + continue; + } + if let Some(p) = frame_at(t) { + acc.data.iter_mut().zip(&p.data).for_each(|(a, q)| *a += q); + n += 1.0; + } + } + if n > 1.0 { + acc.data.iter_mut().for_each(|a| *a /= n); + na = acc; + } + } + } if view_edges { // The refined matte in grey with the analysed edge band tinted. b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { @@ -190,6 +243,16 @@ pub fn specs() -> Vec { check(pre[3], "View Decontamination Map", false), ] }; + // Reduce Chatter and the motion-blur settings. + let temporal = || { + vec![ + p("reduceChatter", "Reduce Chatter", num(0.0), pct0()), + check("useMotionBlur", "Use Motion Blur", false), + p("motionBlur/motionBlurSamples", "Motion Blur Samples", num(16.0), slider(1.0, 64.0, 1.0, 64.0, 0)), + p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)), + check("motionBlur/higherQuality", "Higher Quality", false), + ] + }; let radius = |d: f64| p("radius", "Additional Edge Radius", num(d), slider(1.0, 200.0, 1.0, 50.0, 1)); let mut soft_params = vec![ check("calculateEdgeDetails", "Calculate Edge Details", true), @@ -199,8 +262,9 @@ pub fn specs() -> Vec { p("feather", "Feather", num(10.0), pct0()), p("contrast", "Contrast", num(0.0), pct0()), p("shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), - check("decontaminate", "Decontaminate Edge Colors", true), ]; + soft_params.extend(temporal()); + soft_params.push(check("decontaminate", "Decontaminate Edge Colors", true)); soft_params.extend(decon(&[ "decontamination/decontaminationAmount", "decontamination/extendWhereSmoothed", @@ -214,8 +278,9 @@ pub fn specs() -> Vec { p("smooth", "Smooth", num(20.0), pct0()), p("feather", "Feather", num(0.0), pct0()), p("choke", "Choke", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), - check("decontaminate", "Decontaminate Edge Colors", true), ]; + hard_params.extend(temporal()); + hard_params.push(check("decontaminate", "Decontaminate Edge Colors", true)); hard_params.extend(decon(&["decontaminationAmount", "extendWhereSmoothed", "increaseDecontaminationRadius", "viewDecontaminationMap"])); // Our extras: the analysed edge band's radius, alpha contrast and inversion. hard_params.push(radius(3.0)); @@ -345,4 +410,63 @@ mod tests { assert!(out.get(0, 0)[3] < 0.01); assert!(out.data.iter().all(|p| (0.0..=1.0).contains(&p[3]))); } + + /// A white square whose left edge sits at x = 10 + `jitter` × (frame parity) + `speed` × frame. + struct Edge { + jitter: i64, + speed: i64, + } + fn edge(x0: i64) -> Image { + let mut img = Image::new(40, 20); + for y in 4..16 { + for x in x0.max(0)..(x0 + 16).min(40) { + img.set(x as u32, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + impl crate::EffectHost for Edge { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + let f = (t * 10.0).round() as i64; + Some(Buf { img: edge(10 + self.jitter * (f & 1) + self.speed * f), offset: [0.0; 2], scale: 1.0 }) + } + } + + fn run_t(id: &str, over: &[(&str, Value)], t: f64, host: &Edge) -> Image { + let f = (t * 10.0).round() as i64; + let img = edge(10 + host.jitter * (f & 1) + host.speed * f); + let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() }; + crate::run_fx(id, over, img, t, env).img + } + + #[test] + fn refine_mattes_reduce_chatter_and_motion_blur() { + for id in ["ec.matte.refinesoft", "ec.matte.refinehard"] { + let jitter = Edge { jitter: 1, speed: 0 }; + let base = [("feather", num(0.0)), ("smooth", num(0.0)), ("decontaminate", Value::Bool(false))]; + let with = |extra: &[(&str, Value)], t: f64, h: &Edge| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + run_t(id, &v, t, h) + }; + let diff = |a: &Image, b: &Image| a.data.iter().zip(&b.data).map(|(p, q)| (p[3] - q[3]).abs()).sum::(); + let (a0, a1) = (with(&[], 1.0, &jitter), with(&[], 1.1, &jitter)); + let (c0, c1) = (with(&[("reduceChatter", num(100.0))], 1.0, &jitter), with(&[("reduceChatter", num(100.0))], 1.1, &jitter)); + assert!(diff(&c0, &c1) < diff(&a0, &a1) * 0.7, "{id}: chatter {} vs {}", diff(&c0, &c1), diff(&a0, &a1)); + // Motion blur smears a moving matte edge along the motion. + let moving = Edge { jitter: 0, speed: 4 }; + let mb = with(&[("useMotionBlur", Value::Bool(true)), ("motionBlur/shutterAngle", num(360.0))], 0.3, &moving); + let sharp = with(&[], 0.3, &moving); + let partial = |img: &Image| img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count(); + assert!(partial(&mb) > partial(&sharp) + 10, "{id}: {} vs {}", partial(&mb), partial(&sharp)); + // Without a host both are inert. + assert_eq!(run(id, &[("reduceChatter", num(100.0)), ("useMotionBlur", Value::Bool(true))], edge(10)), run(id, &[], edge(10))); + } + } } diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index f84d4bb1..e14ff230 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -345,6 +345,10 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0][0] = 1.0 - f("blend") as f32 / 100.0; } "ec.generate.fill" => { + // Fill Mask / All Masks render on the CPU. + if effectcraft_effects::fill_uses_masks(ctx) { + return None; + } p.u[0] = [7, ctx.params.b("invert") as u32, 0, 0]; p.f[0] = ctx.params.color("color"); p.f[1][0] = f("opacity") as f32 / 100.0; diff --git a/docs/effects.md b/docs/effects.md index 840b8e5b..d615b817 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2702 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 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. - **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**: 270 implemented in full, 33 partial (what is missing is listed). +- **Status**: 273 implemented in full, 30 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -202,7 +202,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | -| Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather | +| Fill | `ec.generate.fill` | 7 | GPU | 32 | Implemented | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | | Grid | `ec.generate.grid` | 12 | | 32 | Implemented | @@ -254,8 +254,8 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Matte Choker | `ec.matte.mattechoker` | 7 | | 32 | Implemented | | Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented | -| Refine Hard Matte | `ec.matte.refinehard` | 11 | | 32 | Partial: no Reduce Chatter or motion blur | -| Refine Soft Matte | `ec.matte.refinesoft` | 13 | | 32 | Partial: no Reduce Chatter or motion blur | +| Refine Hard Matte | `ec.matte.refinehard` | 16 | | 32 | Implemented | +| Refine Soft Matte | `ec.matte.refinesoft` | 18 | | 32 | Implemented | | Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented | | Simple Choker | `ec.matte.simplechoker` | 2 | | 32 | Implemented | From 3c586e2dba8c1f40cced4d835e03f51594bd32b1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:42:37 -0400 Subject: [PATCH 05/17] 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 --- crates/effects/src/catalog.rs | 1 - crates/effects/src/generate2.rs | 415 ++++++++++++++++++++++++++++---- crates/effects/src/lib.rs | 7 + crates/effects/src/util.rs | 72 ++++++ crates/render/src/lib.rs | 7 + docs/effects.md | 6 +- 6 files changed, 463 insertions(+), 45 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index b72242dd..9e2f1250 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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"), diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 19a9b6a3..9dc26333 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -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 { + 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 = 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 = (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 { + 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 = (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>, +} + +/// 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 = std::collections::HashMap::new(); + let waves: Vec = (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> = - 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 { "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 { 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 = (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 { + 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> { + None + } + fn params_at(&self, t: f64) -> Option { + // 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::(); + 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:?}"); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d465059d..022e61fd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -269,6 +269,11 @@ pub trait EffectHost: Sync { fn comp_scene(&self) -> Option { 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 { + 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"), diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9249ecf9..06580b71 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -563,6 +563,78 @@ impl SimCache { } } +/// 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 = (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 { + let mut g: Vec = mask.iter().map(|&m| if m { 0.0 } else { f64::INFINITY }).collect(); + // Columns, then rows. + let cols: Vec> = (0..w) + .into_par_iter() + .map(|x| { + let f: Vec = (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 = 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(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4db96f0e..1f736668 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -151,6 +151,13 @@ impl EffectHost for FxHost<'_, '_, '_> { } } + fn params_at(&self, layer_time: f64) -> Option { + 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 { // 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. diff --git a/docs/effects.md b/docs/effects.md index d615b817..68dc9e86 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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..`) 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..`) 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 | From bb384fbc9cb4ef258c019415586bb82898175eac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:50:57 -0400 Subject: [PATCH 06/17] M9.13: Foam flow map, wobble, pop velocity, bubble texture layer, orientation, environment map Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 1 - crates/effects/src/lib.rs | 1 + crates/effects/src/sim2.rs | 361 ++++++++++++++++++++++++++++------ docs/effects.md | 6 +- 4 files changed, 310 insertions(+), 59 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 9e2f1250..ad47275a 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -32,7 +32,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), - ("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"), ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), ("ec.stylize.cartoon", "no Edge Enhancement"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 022e61fd..2bb5e914 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -574,6 +574,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("force2", "Force 2"), ("bubbles", "Bubbles"), ("rendering", "Rendering"), + ("flowMap", "Flow Map"), ("heightMapControls", "Height Map Controls"), ("simulation", "Simulation"), ("producer1", "Producer 1"), diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 5d545e52..5994316e 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -1213,6 +1213,8 @@ struct FoamBubble { v: [f32; 2], age: f32, size: f32, + /// Spin (radians) for Physical Orientation. + spin: f32, id: u32, } @@ -1228,12 +1230,20 @@ static FOAM_CACHE: SimCache = SimCache::new(4); /// Foam steps once per frame at 30 frames per second. const FOAM_FPS: f64 = 30.0; +/// Foam's Bubble Texture options (the last uses Bubble Texture Layer). +const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"]; + /// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for /// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early). fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5)) } +/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind, +/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each +/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their +/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble +/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); @@ -1254,14 +1264,50 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { let wind = pr.f("physics/windSpeed") as f32; let wind_dir = (pr.f("physics/windDirection") as f32).to_radians(); let turb = pr.f("physics/turbulence") as f32; + let wobble = pr.f("physics/wobbleAmount").max(0.0) as f32; let repulsion = pr.f("physics/repulsion") as f32; + let pop_velocity = pr.f("physics/popVelocity") as f32; let viscosity = pr.f("physics/viscosity").clamp(0.0, 1.0) as f32; let sticky = pr.f("physics/stickiness").clamp(0.0, 1.0) as f32; let universe = pr.f("universeSize").max(0.01) as f32; let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed; let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit]; - let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + // Flow Map: the map layer's first frame, its luminance a height field (Fits the layer or + // the whole universe). + let flow = pr.get("flowMap/flowMap").and_then(Value::as_layer).and_then(|id| { + let host = ctx.env.host?; + let me = pr.get(&crate::layer_source_id("flowMap/flowMap")).is_none_or(|v| v.as_enum() != 0); + host.layer_at(id, ctx.env.comp_time - ctx.time, me) + }); + let steep = pr.f("flowMap/flowMapSteepness") as f32; + let fits_universe = pr.e("flowMap/flowMapFits") == 1; + let substeps = match pr.e("flowMap/simulationQuality") { + 1 => 2, + 2 => 4, + _ => 1, + }; + let height = flow.as_ref().map(|lp| { + let img = &lp.buf.img; + let pl = Plane::from_image(img, |px| { + let (c, a) = unpremul(px); + effectcraft_color::luminance(c[0], c[1], c[2]) * a + }); + (pl, lp.buf.scale, lp.buf.offset, lp.size) + }); + let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); + let (ux1, uy1) = (lw - ux0, lh - uy0); + // Height at a layer point (0..1). + let h_at = |x: f32, y: f32| -> f32 { + let Some((pl, s, off, size)) = &height else { return 0.0 }; + let (fx, fy) = if fits_universe { ((x - ux0) / (ux1 - ux0), (y - uy0) / (uy1 - uy0)) } else { (x / lw, y / lh) }; + pl.sample(fx as f64 * size[0] * s + off[0], fy as f64 * size[1] * s + off[1]) + }; + let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + if let Some((pl, ..)) = &height { + key ^= pl.data.iter().step_by(5).fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); + } let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; + let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { let t = step as f32 / FOAM_FPS as f32; // Births. @@ -1279,73 +1325,104 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co]; let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit]; let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01); - st.b.push(FoamBubble { p, v, age: 0.0, size: sz, id }); + st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, id }); } - // Forces: drift towards the wind, turbulence, viscosity. - for q in st.b.iter_mut() { - let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; - let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; - for k in 0..2 { - q.v[k] += (wvec[k] - q.v[k]) * 0.1; - q.v[k] *= 1.0 - viscosity * 0.5; + for _ in 0..substeps { + let k = 1.0 / substeps as f32; + // Forces: drift towards the wind, turbulence, viscosity, the flow map's slope. + for q in st.b.iter_mut() { + let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + for c in 0..2 { + q.v[c] += (wvec[c] - q.v[c]) * 0.1 * k; + q.v[c] *= 1.0 - viscosity * 0.5 * k; + } + q.v[0] += n1 * turb * unit * k; + q.v[1] += n2 * turb * unit * k; + if height.is_some() && steep != 0.0 { + let e = unit.max(1.0); + let gx = (h_at(q.p[0] + e, q.p[1]) - h_at(q.p[0] - e, q.p[1])) / (2.0 * e); + let gy = (h_at(q.p[0], q.p[1] + e) - h_at(q.p[0], q.p[1] - e)) / (2.0 * e); + // Downhill: from light (high) to dark (low). + q.v[0] -= gx * steep * lw * 0.5 * k; + q.v[1] -= gy * steep * lw * 0.5 * k; + } + // Physical Orientation: bubbles roll with their sideways motion. + q.spin += q.v[0] / (rad(q).max(0.5)) * k; } - q.v[0] += n1 * turb * unit; - q.v[1] += n2 * turb * unit; - } - // Repulsion between overlapping bubbles (grid hashed, deterministic order). - let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); - if repulsion > 0.0 && st.b.len() > 1 { - let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); - let cs = maxr * 2.0; - let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); - for (i, q) in st.b.iter().enumerate() { - grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); - } - let mut push = vec![[0.0f32; 2]; st.b.len()]; - for (i, q) in st.b.iter().enumerate() { - let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); - for dy in -1..=1 { - for dx in -1..=1 { - let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; - for &j in list { - if j <= i { - continue; - } - let o = &st.b[j]; - let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); - let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); - let overlap = rad(q) + rad(o) - d; - if overlap > 0.0 { - let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d; - push[i][0] -= ddx * f; - push[i][1] -= ddy * f; - push[j][0] += ddx * f; - push[j][1] += ddy * f; + // Repulsion between overlapping bubbles (grid hashed, deterministic order). + if repulsion > 0.0 && st.b.len() > 1 { + let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); + let cs = maxr * 2.0; + let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); + for (i, q) in st.b.iter().enumerate() { + grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); + } + let mut push = vec![[0.0f32; 2]; st.b.len()]; + for (i, q) in st.b.iter().enumerate() { + let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); + for dy in -1..=1 { + for dx in -1..=1 { + let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; + for &j in list { + if j <= i { + continue; + } + let o = &st.b[j]; + let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); + let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); + let overlap = rad(q) + rad(o) - d; + if overlap > 0.0 { + let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d * k; + push[i][0] -= ddx * f; + push[i][1] -= ddy * f; + push[j][0] += ddx * f; + push[j][1] += ddy * f; + } } } } } + for (q, d) in st.b.iter_mut().zip(&push) { + q.p[0] += d[0].clamp(-maxr, maxr); + q.p[1] += d[1].clamp(-maxr, maxr); + } } - for (q, d) in st.b.iter_mut().zip(&push) { - q.p[0] += d[0].clamp(-maxr, maxr); - q.p[1] += d[1].clamp(-maxr, maxr); + for q in st.b.iter_mut() { + q.p[0] += q.v[0] * k; + q.p[1] += q.v[1] * k; } } - let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); - let (ux1, uy1) = (lw - ux0, lh - uy0); for q in st.b.iter_mut() { - q.p[0] += q.v[0]; - q.p[1] += q.v[1]; q.age += 1.0; } + // Pops: bubbles past their pop age burst, pushing their neighbours away. + let popped: Vec<([f32; 2], f32)> = st.b.iter().filter(|q| q.age >= foam_pop_age(q.id, lifespan, frailty, seed)).map(|q| (q.p, rad(q))).collect(); + if pop_velocity != 0.0 { + for (pp, pr_) in &popped { + let reach = (pr_ * 3.0).max(unit); + for q in st.b.iter_mut() { + let (dx, dy) = (q.p[0] - pp[0], q.p[1] - pp[1]); + let d = (dx * dx + dy * dy).sqrt(); + if d > 1e-4 && d < reach { + let f = pop_velocity * unit * (1.0 - d / reach) / d; + q.v[0] += dx * f; + q.v[1] += dy * f; + } + } + } + } st.b.retain(|q| q.age < foam_pop_age(q.id, lifespan, frailty, seed) && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1); }); let view = pr.e("view"); let zoom = pr.f("zoom").max(0.01) as f32; + let zoom_producer = pr.b("producer/zoomProducerPoint"); let texture = pr.e("rendering/bubbleTexture"); + let orient = pr.e("rendering/bubbleOrientation"); let blend = pr.e("rendering/blendMode"); let s = b.scale as f32; - let (cx, cy) = (lw * 0.5, lh * 0.5); + // Zoom centres on the layer, or on the producer with Zoom Producer Point. + let (cx, cy) = if zoom_producer { (prod[0] as f32, prod[1] as f32) } else { (lw * 0.5, lh * 0.5) }; let mut order: Vec<&FoamBubble> = st.b.iter().collect(); if blend == 2 { // Solid New on Top: oldest first. @@ -1353,14 +1430,27 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { } else if blend == 1 { order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); } + // Where a bubble is drawn: buffer centre, radius, rotation (radians). + let place = |q: &FoamBubble| { + let wob = if wobble > 0.0 { 1.0 + wobble * 0.15 * ((q.age * 0.35) + hs(q.id, 13, seed) * 6.0).sin() } else { 1.0 }; + let r = rad(q) * wob * zoom * s; + let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); + let rot = match orient { + 1 => q.spin, + 2 => q.v[1].atan2(q.v[0]), + _ => 0.0, + }; + (bx, by, r, rot) + }; + let user_tex = (view == 2 && texture == 5).then(|| ctx.layer_param("rendering/bubbleTextureLayer", true)).flatten(); let sprites: Vec = order .iter() + .filter(|_| user_tex.is_none()) .map(|q| { - let r = q.size * unit * 3.0 * (q.age * growth).min(1.0) * zoom * s; - let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); + let (bx, by, r, rot) = place(q); let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); // Draft and Draft + Flow Map show the wireframe-style bubbles. - if view != 2 { + let mut sp = if view != 2 { Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) } else { let (c, shape) = match texture { @@ -1371,13 +1461,91 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { _ => ([1.0, 1.0, 1.0], Shape::Bubble), }; Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape) - } + }; + sp.rot = rot; + sp }) .collect(); - b.img = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + let mut out = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + // User Defined: the texture layer fills each bubble's disc. + if let Some(tex) = &user_tex { + for q in &order { + let (bx, by, r, rot) = place(q); + let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); + foam_disc(&mut out, bx, by, r as f64, |u, v| { + // u, v in −1..1 across the bubble, rotated into the texture. + let (sr, cr) = rot.sin_cos(); + let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr); + let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]); + let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]); + px.map(|c| c * fade) + }); + } + } + // Environment Map: each bubble reflects the map along its sphere normal (Reflection + // Convergence pulls the reflection towards the bubble's own spot of the map). + let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32; + if view == 2 + && strength > 0.0 + && let Some(env) = ctx.layer_param("rendering/environmentMap", true) + { + let env_img = crate::util::fit_layer(ctx, &b, &env, true); + let conv = pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0); + let (w, h) = (b.img.width as f64, b.img.height as f64); + for q in &order { + let (bx, by, r, _) = place(q); + foam_disc(&mut out, bx, by, r as f64, |u, v| { + // Sphere normal; the reflection looks across the whole map, or (convergence 1) + // just behind the bubble. + let (ex, ey) = + if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) }; + let e = env_img.sample_bilinear(ex, ey); + let rim = ((u * u + v * v) as f32).min(1.0); + let k = strength * (0.3 + 0.7 * rim); + [e[0] * k, e[1] * k, e[2] * k, 0.0] + }); + } + } + // Draft + Flow Map shows the flow map under the bubbles. + if view == 1 + && let Some(lp) = &flow + { + let fm = crate::util::fit_layer(ctx, &b, lp, true); + out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { + let k = 1.0 - o[3]; + for c in 0..4 { + o[c] += f[c] * 0.5 * k; + } + }); + } + b.img = out; b } +/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at +/// (`x`, `y`) with anti-aliased edges ("over" for opaque texels, additive for alpha 0). +fn foam_disc(out: &mut Image, x: f64, y: f64, r: f64, f: impl Fn(f64, f64) -> Px) { + if r <= 0.0 { + return; + } + let (x0, x1) = ((x - r - 1.0).floor().max(0.0) as i64, (x + r + 1.0).ceil().min(out.width as f64) as i64); + let (y0, y1) = ((y - r - 1.0).floor().max(0.0) as i64, (y + r + 1.0).ceil().min(out.height as f64) as i64); + for py in y0..y1 { + for px in x0..x1 { + let (u, v) = ((px as f64 + 0.5 - x) / r, (py as f64 + 0.5 - y) / r); + let d = (u * u + v * v).sqrt(); + let cov = ((1.0 - d) * r + 0.5).clamp(0.0, 1.0) as f32; + if cov <= 0.0 { + continue; + } + let s = f(u.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)).map(|c| c * cov); + let o = out.get(px, py); + let k = 1.0 - s[3]; + out.set(px as u32, py as u32, [s[0] + o[0] * k, s[1] + o[1] * k, s[2] + o[2] * k, (s[3] + o[3] * k).min(1.0)]); + } + } +} + // ------------------------------------------------------------------ specs fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { @@ -1609,6 +1777,7 @@ pub fn specs() -> Vec { p("producer/producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), p("producer/producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), p("producer/producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), + p("producer/zoomProducerPoint", "Zoom Producer Point", Value::Bool(false), ParamUi::Checkbox), p("producer/productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), p("bubbles/size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), p("bubbles/sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), @@ -1620,13 +1789,24 @@ pub fn specs() -> Vec { p("physics/windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), p("physics/windDirection", "Wind Direction", num(90.0), ParamUi::Angle), p("physics/turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/wobbleAmount", "Wobble Amount", num(0.05), slider(0.0, 10.0, 0.0, 2.0, 3)), p("physics/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/popVelocity", "Pop Velocity", num(1.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), p("physics/stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)), p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)), p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)), p("rendering/blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), - p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky"])), + p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&FOAM_TEXTURES)), + p("rendering/bubbleTextureLayer", "Bubble Texture Layer", Value::Layer(None), ParamUi::Layer), + p("rendering/bubbleOrientation", "Bubble Orientation", Value::Enum(0), popup(&["Fixed", "Physical Orientation", "Bubble Velocity"])), + p("rendering/environmentMap", "Environment Map", Value::Layer(None), ParamUi::Layer), + p("rendering/reflectionStrength", "Reflection Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("rendering/reflectionConvergence", "Reflection Convergence", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("flowMap/flowMap", "Flow Map", Value::Layer(None), ParamUi::Layer), + p("flowMap/flowMapSteepness", "Flow Map Steepness", num(0.05), slider(-100.0, 100.0, -1.0, 1.0, 3)), + p("flowMap/flowMapFits", "Flow Map Fits", Value::Enum(0), popup(&["Background", "Universe"])), + p("flowMap/simulationQuality", "Simulation Quality", Value::Enum(0), popup(&["Normal", "High", "Intense"])), p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), ], foam, @@ -2002,4 +2182,75 @@ mod tests { } s } + + /// Layer 1 = a left-dark, right-light ramp; layer 2 = solid green. + struct FoamHost; + impl crate::EffectHost for FoamHost { + fn layer(&self, id: u64, _: bool) -> Option { + let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) }; + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] }) + } + fn layer_at(&self, id: u64, _: f64, me: bool) -> Option { + self.layer(id, me) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + #[test] + fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() { + let base = vec![ + ("producer/producerPoint", pt(16.0, 16.0)), + ("producer/productionRate", num(2.0)), + ("physics/windSpeed", num(0.0)), + ("physics/turbulence", num(0.0)), + ("view", Value::Enum(2)), + ]; + let with = |extra: &[(&str, Value)], t: f64| { + let mut v = base.clone(); + v.extend_from_slice(extra); + run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img + }; + let cx = |img: &Image| { + let (mut s, mut n) = (0.0, 0.0); + for y in 0..32 { + for x in 0..32 { + let a = img.get(x, y)[3]; + s += a * x as f32; + n += a; + } + } + s / n.max(1e-6) + }; + // Flow map: bubbles run downhill (towards the dark left side). + let still = with(&[], 1.0); + let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0); + assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still)); + let hq = + with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0); + assert_eq!( + hq, + with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0) + ); + // Pop velocity scatters the survivors when short-lived bubbles pop. + let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))]; + let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0); + let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0); + assert_ne!(a, b2); + // Wobble changes the drawn bubbles. + assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0)); + // User Defined texture: bubbles show the texture layer (green). + let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0); + assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1)); + // Environment map: reflections tint the bubbles. + let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0); + let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::(); + assert!(g(&refl) > g(&still) + 1.0); + // Zoom Producer Point zooms about the producer instead of the layer centre. + let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))]; + let z1 = with(&off, 1.0); + let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0); + assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1)); + } } diff --git a/docs/effects.md b/docs/effects.md index 68dc9e86..f728aa9b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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. +EffectCraft ships 303 effects with 2742 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**: 274 implemented in full, 29 partial (what is missing is listed). +- **Status**: 275 implemented in full, 28 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -330,7 +330,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | | Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | | Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | -| Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map | +| Foam | `ec.sim.foam` | 36 | | 32 | Implemented | | Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | | Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | | Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group | From 69202c7b3e12a9a38123e65424c19431c652e168 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:04:46 -0400 Subject: [PATCH 07/17] M9.13: Minimax Don't Shrink Edges, Channel Combiner Saturation Multiplied, 3D Channel Extract anti-alias, Block Dissolve Soft Edges, locked pinning, Optimal Pixels Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 6 --- crates/effects/src/channel.rs | 64 ++++++++++++++++++++++--- crates/effects/src/channel3d.rs | 65 ++++++++++++++++++++------ crates/effects/src/distort2.rs | 80 +++++++++++++++++++++++++++++--- crates/effects/src/migrate.rs | 2 + crates/effects/src/transition.rs | 27 +++++++++-- docs/effects.md | 16 +++---- 7 files changed, 215 insertions(+), 45 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index ad47275a..402cc2c5 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -9,13 +9,8 @@ use crate::{CATEGORIES, registry}; /// as the public behaviour documentation describes it. pub const PARTIAL: &[(&str, &str)] = &[ ("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"), - ("ec.channel.minimax", "no Don't Shrink Edges option"), - ("ec.channel.combiner", "no Saturation Multiplied target"), - ("ec.3d.channelextract", "no anti-alias option"), ("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"), ("ec.color.selectivecolor", "simplified Colors / Details layout"), - ("ec.distort.turbulentdisplace", "no locked pinning variants"), - ("ec.distort.opticscompensation", "no Optimal Pixels"), ("ec.distort.transform", "no shutter-angle motion blur"), ("ec.distort.reshape", "no correspondence points"), ("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"), @@ -35,7 +30,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), ("ec.stylize.cartoon", "no Edge Enhancement"), - ("ec.transition.blockdissolve", "no Soft Edges option"), ]; /// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path, diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs index 17b23521..b763ac21 100644 --- a/crates/effects/src/channel.rs +++ b/crates/effects/src/channel.rs @@ -90,7 +90,8 @@ const COMBINER_FROM: [&str; 14] = [ "Min RGB", ]; /// Channel Combiner's To options (a single value goes into one channel). -const COMBINER_TO: [&str; 8] = ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale"]; +const COMBINER_TO: [&str; 9] = + ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale", "Saturation Multiplied"]; fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { let from = ctx.params.e("from"); @@ -160,6 +161,13 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { let (r, g, bb) = hsl_to_rgb(0.0, v.clamp(0.0, 1.0), 0.5); [r, g, bb] } + // Saturation Multiplied: the pixel keeps its hue and lightness; its saturation + // is scaled by the value. + 8 => { + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let (r, g, bb) = hsl_to_rgb(h, (s * v).clamp(0.0, 1.0), l); + [r, g, bb] + } _ => [v, v, v], } } @@ -175,6 +183,25 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { /// Minimax's Direction options. pub(crate) const MINIMAX_DIRECTIONS: [&str; 3] = ["Horizontal & Vertical", "Just Horizontal", "Just Vertical"]; +/// `p` with `rx` / `ry` zero pixels added on every side. +fn pad_plane(p: &Plane, rx: usize, ry: usize) -> Plane { + let (w, h) = (p.w + 2 * rx, p.h + 2 * ry); + let mut out = Plane::new(w, h); + for y in 0..p.h { + out.data[(y + ry) * w + rx..(y + ry) * w + rx + p.w].copy_from_slice(&p.data[y * p.w..(y + 1) * p.w]); + } + out +} + +/// The `w`×`h` interior of a plane padded by [`pad_plane`]. +fn crop_plane(p: &Plane, rx: usize, ry: usize, w: usize, h: usize) -> Plane { + let mut out = Plane::new(w, h); + for y in 0..h { + out.data[y * w..(y + 1) * w].copy_from_slice(&p.data[(y + ry) * p.w + rx..(y + ry) * p.w + rx + w]); + } + out +} + fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; if r == 0 { @@ -182,6 +209,7 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { } let op = ctx.params.e("operation"); let ch = ctx.params.e("channel"); + let dont_shrink = ctx.params.b("dontShrinkEdges"); let (rx, ry) = match ctx.params.e("direction") { 1 => (r, 0), 2 => (0, r), @@ -204,12 +232,16 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { if !sel[k] { continue; } - *pl = match op { - 0 => morph_plane(pl, rx, ry, false), - 1 => morph_plane(pl, rx, ry, true), - 2 => morph_plane(&morph_plane(pl, rx, ry, false), rx, ry, true), - _ => morph_plane(&morph_plane(pl, rx, ry, true), rx, ry, false), + // Beyond the layer edge is empty (0), so Minimum eats into the edges unless Don't + // Shrink Edges extends the edge pixels instead. + let padded = if dont_shrink { pl.clone() } else { pad_plane(pl, rx, ry) }; + let out = match op { + 0 => morph_plane(&padded, rx, ry, false), + 1 => morph_plane(&padded, rx, ry, true), + 2 => morph_plane(&morph_plane(&padded, rx, ry, false), rx, ry, true), + _ => morph_plane(&morph_plane(&padded, rx, ry, true), rx, ry, false), }; + *pl = if dont_shrink { out } else { crop_plane(&out, rx, ry, pl.w, pl.h) }; } let mut img = join(&planes); img.data.par_iter_mut().for_each(|px| { @@ -408,6 +440,7 @@ pub fn specs() -> Vec { p("radius", "Radius", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0)), p("channel", "Channel", Value::Enum(0), popup(&["Color", "Alpha", "Alpha and Color", "Red", "Green", "Blue"])), p("direction", "Direction", Value::Enum(0), popup(&MINIMAX_DIRECTIONS)), + p("dontShrinkEdges", "Don't Shrink Edges", Value::Bool(false), ParamUi::Checkbox), ], minimax, ), @@ -658,4 +691,23 @@ mod tests { assert!(close(out.get(0, 0), [0.0, 0.0, 1.0, 1.0])); assert!(close(out.get(1, 0), [1.0, 0.0, 0.0, 1.0])); } + + #[test] + fn minimax_shrinks_layer_edges_unless_told_not_to() { + let img = Image::filled(9, 9, [1.0, 1.0, 1.0, 1.0]); + let shrunk = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0))], img.clone()); + assert_eq!(shrunk.get(0, 4)[0], 0.0); + assert_eq!(shrunk.get(4, 4)[0], 1.0); + let kept = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0)), ("dontShrinkEdges", Value::Bool(true))], img); + assert_eq!(kept.get(0, 4)[0], 1.0); + } + + #[test] + fn channel_combiner_saturation_multiplied() { + // From Lightness (0.5) into Saturation Multiplied halves the saturation, keeping hue. + let red = Image::filled(1, 1, [0.75, 0.25, 0.25, 1.0]); + let out = run("ec.channel.combiner", &[("from", Value::Enum(8)), ("to", Value::Enum(8))], red).get(0, 0); + let (h, s, l) = rgb_to_hsl(out[0], out[1], out[2]); + assert!((h.abs() < 1e-4 || (h - 1.0).abs() < 1e-4) && (s - 0.25).abs() < 1e-3 && (l - 0.5).abs() < 1e-3, "{h} {s} {l}"); + } } diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 8da44388..3515204e 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -31,12 +31,17 @@ fn aux(ctx: &EffectCtx) -> Option> { /// Aux index for each buffer pixel (None outside the aux image). fn index_map(b: &Buf, a: &AuxChannels) -> Vec> { + index_map_at(b, a, [0.5, 0.5]) +} + +/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1). +fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec> { let (w, h) = (b.img.width as usize, b.img.height as usize); let inv = 1.0 / b.scale.max(1e-9); (0..w * h) .into_par_iter() .map(|i| { - let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]); a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) }) .collect() @@ -60,7 +65,28 @@ pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", " fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(a) = aux(ctx) else { return b }; + if ctx.params.b("antialias") { + // Anti-alias: average four sub-pixel samples of the channel. + let subs = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]; + let imgs: Vec = subs.iter().map(|s| channel_extract_at(ctx, &b, &a, &index_map_at(&b, &a, *s))).collect::>>().unwrap_or_default(); + if imgs.len() == 4 { + let mut out = imgs[0].clone(); + out.data.par_iter_mut().enumerate().for_each(|(i, p)| { + *p = std::array::from_fn(|c| imgs.iter().map(|m| m.data[i][c]).sum::() * 0.25); + }); + b.img = out; + } + return b; + } let idx = index_map(&b, &a); + if let Some(i) = channel_extract_at(ctx, &b, &a, &idx) { + b.img = i; + } + b +} + +/// 3D Channel Extract through one index map (None when the channel is missing). +fn channel_extract_at(ctx: &EffectCtx, b: &Buf, a: &AuxChannels, idx: &[Option]) -> Option { let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32); // Clamp Output limits the mapped values to 0..1; Invert Depth Map flips them. let (clamp, invert) = (ctx.params.b("clampOutput"), ctx.params.b("invertDepthMap")); @@ -69,24 +95,20 @@ fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf { let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - cl((v - wp) / (bp - wp).max(1e-9)) }; let lv = |v: f32| if invert { 1.0 - lv(v) } else { lv(v) }; let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) }; - let img: Option = match ctx.params.e("channel") { - 0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))), - 1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), - 7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), - 4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), + match ctx.params.e("channel") { + 0 => a.depth().map(|z| map_grey(b, idx, |i| lv(z[i]))), + 1 => a.object_id().map(|c| map_grey(b, idx, |i| lv(c[i]))), + 7 => a.material_id().map(|c| map_grey(b, idx, |i| lv(c[i]))), + 4 => a.coverage().map(|c| map_grey(b, idx, |i| lv(c[i]))), 2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]]) .or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?])) - .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])), + .map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])), 3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]]) - .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])), + .map(|c| map_rgb(b, idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])), 5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]]) - .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])), - _ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])), - }; - if let Some(i) = img { - b.img = i; + .map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])), + _ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(b, idx, |i| [c[0][i], c[1][i], c[2][i]])), } - b } fn map_grey(b: &Buf, idx: &[Option], f: impl Fn(usize) -> f32 + Sync) -> Image { @@ -443,6 +465,7 @@ pub fn specs() -> Vec { p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)), p("clampOutput", "Clamp Output", Value::Bool(true), ParamUi::Checkbox), p("invertDepthMap", "Invert Depth Map", Value::Bool(false), ParamUi::Checkbox), + p("antialias", "Anti-alias", Value::Bool(false), ParamUi::Checkbox), ], channel_extract, ), @@ -689,4 +712,18 @@ mod tests { let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default()); assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); } + + #[test] + fn channel_extract_antialias_averages_sub_pixels() { + // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. + let mut a = AuxChannels::new(40, 20, 2.0); + a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())]; + let host = AuxHost(Arc::new(a)); + let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))]; + let hard = run("ec.3d.channelextract", &vals, &host); + let v = hard.get(5, 5)[0]; + assert!(v == 0.0 || v == 1.0, "{v}"); + let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); + assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); + } } diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 66f038e0..a1e58944 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -62,7 +62,26 @@ const TURBULENT_KINDS: [&str; 9] = [ "Horizontal Displacement", "Cross Displacement", ]; -const TURBULENT_PINNING: [&str; 8] = ["None", "Pin All", "Pin Horizontal", "Pin Vertical", "Pin Left", "Pin Top", "Pin Right", "Pin Bottom"]; +/// Turbulent Displace's Pinning options. The Locked variants also keep every displaced +/// sample inside the layer (no edge pixels pulled in from outside) and ease the pinning over a +/// wider band, so the pinned edges and their neighbourhood stay put. +const TURBULENT_PINNING: [&str; 15] = [ + "None", + "Pin All", + "Pin Horizontal", + "Pin Vertical", + "Pin Left", + "Pin Top", + "Pin Right", + "Pin Bottom", + "Pin All Locked", + "Pin Horizontal Locked", + "Pin Vertical Locked", + "Pin Left Locked", + "Pin Top Locked", + "Pin Right Locked", + "Pin Bottom Locked", +]; fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let amount = ctx.params.f("amount") * b.scale; @@ -80,10 +99,12 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let evo = ctx.params.f("evolution") / 360.0; // Evolution Options: Cycle Evolution loops the noise every `cycle` revolutions. let cycle = (ctx.params.b("evolutionOptions/cycleEvolution")).then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0)); - let pin = ctx.params.e("pinning"); + let pin_raw = ctx.params.e("pinning"); + 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 edge = (lw.min(lh) * 0.1).max(1.0); + 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| { let s = seed.wrapping_add(s); @@ -128,7 +149,11 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { 7 => bt, _ => 1.0, }; - Some((x + dx * k, y + dy * k)) + let (sx, sy) = (x + dx * k, y + dy * k); + if locked { + return Some((sx.clamp(lx + 0.5, lx + lw - 0.5), sy.clamp(ly + 0.5, ly + lh - 0.5))); + } + Some((sx, sy)) }); b } @@ -223,7 +248,10 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { let f = r_ref / half.tan(); // Resize (with Reverse Lens Distortion): grow the layer so the stretched corners stay // visible, up to 2× / 4× the layer size (Unlimited is capped at 8×). - let resize = ctx.params.e("resize"); + // Optimal Pixels (invalidates Resize): scale the result so the whole corrected picture + // fits the layer (Reverse) or fills it with no empty corners (forward). + let optimal = ctx.params.b("optimalPixels"); + let resize = if optimal { 0 } else { ctx.params.e("resize") }; if reverse && resize > 0 && !ctx.adjustment { let c = b.to_px(ctx.params.v2("viewCenter")); let rc = @@ -234,9 +262,21 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { b.pad(grow.min(cap).min(4096.0).ceil() as u32); } let c = b.to_px(ctx.params.v2("viewCenter")); + let fit = if optimal { + let rc = + [(lx, ly), (lx + lw, ly), (lx, ly + lh), (lx + lw, ly + lh)].iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(1.0, f64::max); + if reverse { + let th = (rc / r_ref * half).min(FRAC_PI_2 - 1e-3); + (f * th.tan() / rc).max(1.0) + } else { + ((rc / f).atan() / half * r_ref / rc).min(1.0) + } + } else { + 1.0 + }; b.img = remap(&b.img, false, |x, y| { let (dx, dy) = (x - c.0, y - c.1); - let r = (dx * dx + dy * dy).sqrt(); + let r = (dx * dx + dy * dy).sqrt() * fit; if r < 1e-9 { return Some((x, y)); } @@ -249,7 +289,7 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { } f * th.tan() }; - let k = rs / r; + let k = rs / r * fit; Some((c.0 + dx * k, c.1 + dy * k)) }); b @@ -717,6 +757,7 @@ pub fn specs() -> Vec { p("reverseLensDistortion", "Reverse Lens Distortion", Value::Bool(false), ParamUi::Checkbox), p("fovOrientation", "FOV Orientation", Value::Enum(0), popup(&["Horizontal", "Vertical", "Diagonal"])), p("viewCenter", "View Center", pt(0.5, 0.5), ParamUi::Point), + p("optimalPixels", "Optimal Pixels (Invalidates Resize)", Value::Bool(false), ParamUi::Checkbox), p("resize", "Resize", Value::Enum(0), popup(&["Off", "Max 2X", "Max 4X", "Unlimited"])), ], optics_compensation, @@ -1051,6 +1092,15 @@ mod tests { // Random Seed changes the pattern. let seeded = run("ec.distort.turbulentdisplace", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], img); assert!(max_diff(&seeded.img, &pinned.img) > 1e-3); + // Locked pinning: no sample comes from outside the layer (no transparent pixels pulled + // in) and the border still holds. + let opaque = Image::filled(40, 40, [0.5, 0.5, 0.5, 1.0]); + let strong = [("size", num(10.0)), ("amount", num(80.0))]; + let left = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(4))]].concat(), opaque.clone()); + let locked = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(11))]].concat(), opaque.clone()); + assert!(left.img.data.iter().any(|p| p[3] < 0.99), "plain pin lets edges pull in"); + assert!(locked.img.data.iter().all(|p| p[3] > 0.999), "locked keeps everything inside"); + assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked"); } #[test] @@ -1077,4 +1127,20 @@ mod tests { let max4 = run("ec.distort.opticscompensation", &set(2), img); assert!(max4.img.width >= max2.img.width); } + + #[test] + fn optics_optimal_pixels_fills_or_fits_the_layer() { + let img = Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]); + // Forward: without Optimal Pixels the corners sample outside the layer. + let plain = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0))], img.clone()); + assert!(plain.img.get(0, 0)[3] < 0.5); + let opt = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0)), ("optimalPixels", Value::Bool(true))], img.clone()); + assert!(opt.img.get(0, 0)[3] > 0.9 && opt.img.get(31, 23)[3] > 0.9); + // Reverse: everything fits; Resize is ignored. + let rev = [("fieldOfView", num(120.0)), ("reverseLensDistortion", Value::Bool(true)), ("optimalPixels", Value::Bool(true)), ("resize", Value::Enum(2))]; + let r = run("ec.distort.opticscompensation", &rev, img.clone()); + assert_eq!(r.img.width, 32); + // The edges of the source now sit inside the layer: empty space around them. + assert!(r.img.get(0, 0)[3] < 0.5 && r.img.get(16, 12)[3] > 0.9); + } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index b2f48573..1e2c25e0 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -153,6 +153,8 @@ pub fn legacy_default(effect: &str, param: &str) -> Option { ("ec.noise.removegrain", "noiseReductionSettings/mode") => Value::Enum(1), // Audio: the Compressor's release was always manual. ("ec.audio.compressor", "autoRelease") => Value::Bool(false), + // Transition: Block Dissolve's blocks had hard edges. + ("ec.transition.blockdissolve", "softEdges") => Value::Bool(false), _ => return None, }) } diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index 54236edf..2b21da59 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -62,14 +62,21 @@ fn block_dissolve(ctx: &EffectCtx, mut b: Buf) -> Buf { let feather = ctx.params.f("feather") * b.scale; let (ox, oy) = (b.offset[0], b.offset[1]); let seed = ctx.seed ^ 0xb10c; + // Soft Edges (Best Quality): block edges falling between pixels are anti-aliased (4×4 + // sub-pixel samples); off, each pixel takes its centre's block. + let soft = ctx.params.b("softEdges"); + let subs: Vec = if soft { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }; + let kept = |x: f64, y: f64| { + let (bx, by) = (((x - ox) / bw).floor() as i64, ((y - oy) / bh).floor() as i64); + hash1(bx as u32, by as u32, seed) >= done + }; let mut mask = Plane::new(b.img.width as usize, b.img.height as usize); let w = mask.w; + let n = (subs.len() * subs.len()) as f32; mask.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { - let by = ((y as f64 + 0.5 - oy) / bh).floor() as i64; for (x, m) in row.iter_mut().enumerate() { - let bx = ((x as f64 + 0.5 - ox) / bw).floor() as i64; - let r = hash1(bx as u32, by as u32, seed); - *m = if r >= done { 1.0 } else { 0.0 }; + let hits = subs.iter().flat_map(|sy| subs.iter().map(move |sx| (*sx, *sy))).filter(|(sx, sy)| kept(x as f64 + sx, y as f64 + sy)).count(); + *m = hits as f32 / n; } }); if feather > 0.0 { @@ -466,6 +473,7 @@ pub fn specs() -> Vec { p("blockWidth", "Block Width", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)), p("blockHeight", "Block Height", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)), p("feather", "Feather", num(0.0), slider(0.0, 400.0, 0.0, 50.0, 1)), + p("softEdges", "Soft Edges (Best Quality)", Value::Bool(true), ParamUi::Checkbox), ], block_dissolve, ), @@ -866,4 +874,15 @@ mod tests { let tilt = run_t(&img, &[("completion", num(0.0)), ("cameraPosition/yRotation", num(40.0))], 0.0, None); assert_eq!(tilt.get(0, 0)[3], 0.0); } + + #[test] + fn block_dissolve_soft_edges_antialias_blocks() { + let img = Image::filled(40, 4, [1.0, 1.0, 1.0, 1.0]); + let v = [("completion", num(50.0)), ("blockWidth", num(2.5)), ("blockHeight", num(4.0))]; + let frac = |o: &Image| o.data.iter().filter(|p| p[3] > 0.01 && p[3] < 0.99).count(); + let hard = run("ec.transition.blockdissolve", &img, &[v.as_slice(), &[("softEdges", Value::Bool(false))]].concat()); + let soft = run("ec.transition.blockdissolve", &img, &v); + assert_eq!(frac(&hard), 0); + assert!(frac(&soft) > 0); + } } diff --git a/docs/effects.md b/docs/effects.md index f728aa9b..9b791cbb 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2742 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 2746 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**: 275 implemented in full, 28 partial (what is missing is listed). +- **Status**: 281 implemented in full, 22 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -15,7 +15,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| 3D Channel Extract | `ec.3d.channelextract` | 5 | | 32 | Partial: no anti-alias option | +| 3D Channel Extract | `ec.3d.channelextract` | 6 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | | 32 | Implemented | @@ -71,10 +71,10 @@ Effects are addressed by id (`ec..`) or display name in commands | Blend | `ec.channel.blend` | 4 | | 32 | Implemented | | CC Composite | `ec.channel.cccomposite` | 3 | | 32 | Implemented | | Calculations | `ec.channel.calculations` | 9 | | 32 | Implemented | -| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Partial: no Saturation Multiplied target | +| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented | | Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented | | Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented | -| Minimax | `ec.channel.minimax` | 4 | | 32 | Partial: no Don't Shrink Edges option | +| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented | | Remove Color Matting | `ec.channel.removecolormatting` | 2 | | 32 | Implemented | | Set Channels | `ec.channel.setchannels` | 9 | | 32 | Implemented | | Set Matte | `ec.channel.setmatte` | 6 | | 32 | Implemented | @@ -154,7 +154,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Mesh Warp | `ec.distort.meshwarp` | 3 | | 32 | Implemented | | Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented | | Offset | `ec.distort.offset` | 2 | | 32 | Implemented | -| Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels | +| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented | | Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | | Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points | @@ -163,7 +163,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | | Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur | -| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Partial: no locked pinning variants | +| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | @@ -389,7 +389,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Block Dissolve | `ec.transition.blockdissolve` | 4 | | 32 | Partial: no Soft Edges option | +| Block Dissolve | `ec.transition.blockdissolve` | 5 | | 32 | Implemented | | CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | | 32 | Implemented | | CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | | 32 | Implemented | | CC Image Wipe | `ec.transition.ccimagewipe` | 7 | | 32 | Implemented | From 3a7c9c5f618427ba8cc0907bb1ce3ebb0afa6243 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:23:15 -0400 Subject: [PATCH 08/17] M9.13: Transform shutter-angle motion blur, Glow Operation / Arbitrary Map, Cartoon Edge Enhancement - Transform: Use Composition's Shutter Angle / Shutter Angle, sampling the transform across the shutter via EffectHost::params_at (EffectEnv::shutter carries the comp shutter when motion blur is on); the GPU path defers to the CPU when blurring. - Glow: Glow Operation (blend modes) and Glow Colors > Arbitrary Map. - Cartoon: Advanced > Edge Enhancement (shock-filter sharpening / softening). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 3 -- crates/effects/src/distort.rs | 96 +++++++++++++++++++++++++++++++++ crates/effects/src/lib.rs | 10 +++- crates/effects/src/misc.rs | 98 +++++++++++++++++++++++++++++++++- crates/effects/src/stylize2.rs | 62 +++++++++++++++++++++ crates/gpu/src/effects.rs | 8 +++ crates/render/src/lib.rs | 5 ++ docs/effects.md | 10 ++-- 8 files changed, 281 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 402cc2c5..31e786c4 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -11,7 +11,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"), ("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"), ("ec.color.selectivecolor", "simplified Colors / Details layout"), - ("ec.distort.transform", "no shutter-angle motion blur"), ("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"), @@ -28,8 +27,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), - ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), - ("ec.stylize.cartoon", "no Edge Enhancement"), ]; /// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path, diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index e5472b1a..173389de 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -257,6 +257,32 @@ fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Transform's shutter: (angle, phase) in degrees and the number of samples, or None when it +/// renders without motion blur. Use Composition's Shutter Angle takes the comp's shutter +/// (only when the comp and layer motion-blur switches are on); otherwise Shutter Angle, centred +/// on the frame. +pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> { + let (angle, phase, n) = if ctx.params.b("useCompositionShutterAngle") { + ctx.env.shutter? + } else { + let a = ctx.params.f("shutterAngle").clamp(0.0, 720.0); + (a, -a * 0.5, 16) + }; + (angle > 0.0).then_some((angle, phase, n.clamp(2, 64))) +} + +fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { + let anchor = b.to_px(pr.v2("anchor")); + let pos = b.to_px(pr.v2("position")); + let sh = pr.f("scaleHeight"); + let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + Mat3::translate(vec2(pos.0, pos.1)) + * Mat3::rotate_deg(pr.f("rotation")) + * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) + * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) + * Mat3::translate(vec2(-anchor.0, -anchor.1)) +} + fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { let anchor = b.to_px(ctx.params.v2("anchor")); let pos = b.to_px(ctx.params.v2("position")); @@ -268,6 +294,34 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { let rot = ctx.params.f("rotation"); let opacity = ctx.params.f("opacity") as f32 / 100.0; let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear }; + // Motion blur: the transform at Samples instants across the shutter, averaged (the + // transform at other times comes from the host; without one the frame renders sharp). + if let Some((angle, phase, n)) = transform_shutter(ctx) + && let Some(host) = ctx.env.host + { + let fd = 1.0 / ctx.fps(); + let mut acc = Image::new(b.img.width, b.img.height); + let mut count = 0.0f32; + for k in 0..n { + let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; + let Some(pr) = host.params_at(t) else { continue }; + let mut one = Image::new(b.img.width, b.img.height); + let op = pr.f("opacity") as f32 / 100.0; + effectcraft_raster::composite_warp( + &mut one, + &b.img, + &transform_matrix(&pr, &b), + &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, + ); + acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); + count += 1.0; + } + if count > 0.0 { + acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count)); + b.img = acc; + return b; + } + } let m = Mat3::translate(vec2(pos.0, pos.1)) * Mat3::rotate_deg(rot) * Mat3::skew_deg(skew, skew_axis) @@ -397,6 +451,8 @@ pub fn specs() -> Vec { p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle), p("rotation", "Rotation", num(0.0), ParamUi::Angle), p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("useCompositionShutterAngle", "Use Composition's Shutter Angle", Value::Bool(true), ParamUi::Checkbox), + p("shutterAngle", "Shutter Angle", num(0.0), slider(0.0, 720.0, 0.0, 360.0, 1)), p("sampling", "Sampling", Value::Enum(0), popup(&["Bilinear", "Bicubic"])), ], transform, @@ -510,4 +566,44 @@ mod tests { let b = run("ec.distort.transform", &[("rotation", num(17.0)), ("sampling", Value::Enum(1))], im); assert!(a.img.data.iter().zip(&b.img.data).any(|(p, q)| differs(*p, *q))); } + + #[test] + fn transform_shutter_angle_motion_blur() { + /// Position moves right 200 px per second. + struct Moving; + impl crate::EffectHost for Moving { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.distort.transform").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [40.0, 20.0]))).collect() }; + p.values.insert("position".into(), Value::Vec2([20.0 + 200.0 * t, 10.0])); + Some(p) + } + } + let mut sq = Image::new(40, 20); + for y in 5..15 { + for x in 15..25 { + sq.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + let render = |vals: &[(&str, Value)], shutter: Option<(f64, f64, u32)>| { + let env = crate::EffectEnv { host: Some(&Moving), frame_rate: 10.0, shutter, ..Default::default() }; + crate::run_fx("ec.distort.transform", vals, sq.clone(), 0.0, env).img + }; + let partial = |i: &Image| i.data.iter().filter(|p| p[3] > 0.02 && p[3] < 0.98).count(); + // Use Composition Shutter Angle with the comp / layer motion blur off: sharp. + assert_eq!(partial(&render(&[], None)), 0); + // With the comp shutter (180°): smeared along x over 10 px. + let comp = render(&[], Some((180.0, -90.0, 16))); + assert!(partial(&comp) > 50, "{}", partial(&comp)); + // Own Shutter Angle. + let own = render(&[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", num(360.0))], None); + assert!(partial(&own) > partial(&comp)); + assert_eq!(partial(&render(&[("useCompositionShutterAngle", Value::Bool(false))], None)), 0, "0 deg: sharp"); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2bb5e914..28db9c15 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -66,11 +66,12 @@ pub use color_fx::{ HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, }; pub use color2::Curve; +pub use distort::transform_shutter; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation}; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -292,6 +293,10 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The composition's shutter (angle and phase in degrees, samples per frame) when both the + /// comp's and the layer's motion blur switches are on (Transform's Use Composition's + /// Shutter Angle). + pub shutter: Option<(f64, f64, u32)>, } /// What an effect gets to render with. @@ -708,6 +713,9 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.matte.refinehard", // Card Wipe's position / rotation jitter moves with time. "ec.transition.cardwipe", + // Transform's motion blur and Radio Waves' birth parameters read the parameters at + // other times. + "ec.distort.transform", // Temporal Smoothing reads neighbouring frames. "ec.color.autolevels", "ec.color.autocontrast", diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index ed3678e8..89b496bb 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -120,12 +120,48 @@ pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) t.max(0.0).powf(0.5f32.ln() / m.ln()) } +/// Glow Operation options (blend modes the glow is applied with). +pub const GLOW_OPERATIONS: [&str; 17] = [ + "Normal", + "Add", + "Multiply", + "Screen", + "Overlay", + "Soft Light", + "Hard Light", + "Color Dodge", + "Color Burn", + "Darken", + "Lighten", + "Difference", + "Exclusion", + "Hue", + "Saturation", + "Color", + "Luminosity", +]; + +/// Glow's Glow Operation as a blend mode (Add for projects saved before it existed). +pub fn glow_operation(ctx: &EffectCtx) -> effectcraft_color::BlendMode { + let name = GLOW_OPERATIONS.get(ctx.params.get("glowOperation").map(|v| v.as_enum()).unwrap_or(1) as usize).copied().unwrap_or("Add"); + effectcraft_color::BlendMode::ALL.iter().copied().find(|m| m.label() == name).unwrap_or(effectcraft_color::BlendMode::Add) +} + +/// Glow's Arbitrary Map: red, green and blue curves of the glow brightness, as Curves point +/// lists `x,y …` separated by `|` (an empty or missing curve is the identity). +fn glow_map(s: &str) -> Vec> { + let mut parts = s.split('|'); + (0..3).map(|_| parts.next().and_then(crate::Curve::parse)).collect() +} + fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { let thr = ctx.params.f("threshold") as f32 / 100.0; let radius = ctx.params.f("radius") * b.scale; let intensity = ctx.params.f("intensity") as f32; let alpha_based = ctx.params.e("based") == 0; let use_colors = ctx.params.e("colors") == 1; + let arbitrary = (ctx.params.e("colors") == 2).then(|| glow_map(ctx.params.s("arbitraryMap"))); + let glow_op = glow_operation(ctx); let looping = ctx.params.e("colorLooping"); let loops = ctx.params.f("colorLoops") as f32; let phase = (ctx.params.f("colorPhase") / 360.0) as f32; @@ -147,7 +183,11 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { // Glow Based On: brightness from the colours, or from the alpha channel. let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) }; let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0); - if use_colors { + if let Some(map) = &arbitrary { + let t = glow_ab_t(l, looping, loops, phase, mid); + let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::>(); + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + } else if use_colors { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; @@ -170,6 +210,8 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { } // None: the glow alone. 2 => *o = [g[0], g[1], g[2], g[3]], + // On Top with another Glow Operation: that blend mode. + _ if glow_op != effectcraft_color::BlendMode::Add => *o = effectcraft_color::blend_pixel(glow_op, *o, g, 0.0), // On Top: add (screen-like add on premultiplied colour; alpha grows by the glow's alpha). _ => { for c in 0..3 { @@ -571,7 +613,10 @@ pub fn specs() -> Vec { p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)), p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])), - p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])), + p("glowOperation", "Glow Operation", Value::Enum(1), popup(&GLOW_OPERATIONS)), + p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors", "Arbitrary Map"])), + // Arbitrary Map: "red points | green points | blue points" (Curves format). + p("arbitraryMap", "Arbitrary Map", Value::Str(String::new()), ParamUi::Hidden), p("colorLooping", "Color Looping", Value::Enum(2), popup(&["Sawtooth A>B", "Sawtooth B>A", "Triangle A>B>A", "Triangle B>A>B"])), p("colorLoops", "Color Loops", num(1.0), slider(1.0, 127.0, 1.0, 10.0, 1)), p("colorPhase", "Color Phase", num(0.0), ParamUi::Angle), @@ -687,3 +732,52 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + /// A bright square on mid grey. + fn scene() -> effectcraft_raster::Image { + let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]); + for y in 8..16 { + for x in 8..16 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + #[test] + fn glow_operation_and_arbitrary_map() { + // Pixels addressed in layer space (Glow pads its buffer). + struct Out(Buf); + impl Out { + fn get(&self, x: i64, y: i64) -> [f32; 4] { + self.0.img.get(x + self.0.offset[0] as i64, y + self.0.offset[1] as i64) + } + } + let run = |vals: &[(&str, Value)]| Out(run_fx("ec.stylize.glow", vals, scene(), 0.0, EffectEnv::default())); + let add = run(&[]); + assert_eq!(GLOW_OPERATIONS[1], "Add"); + // Multiply darkens around the square where Add brightens. + let mul = run(&[("glowOperation", Value::Enum(2))]); + assert!(add.get(6, 12)[0] > 0.31 && mul.get(6, 12)[0] <= 0.3 + 1e-4, "{:?} {:?}", add.get(6, 12), mul.get(6, 12)); + assert_eq!( + glow_operation(&EffectCtx { + params: &crate::Params::default(), + time: 0.0, + layer_size: [1.0; 2], + seed: 0, + adjustment: false, + env: Default::default() + }), + effectcraft_color::BlendMode::Add + ); + // Arbitrary Map: the glow takes the mapped colour (here pure red for every brightness). + let red = run(&[("colors", Value::Enum(2)), ("arbitraryMap", Value::Str("0,1 1,1|0,0 1,0|0,0 1,0".into())), ("operation", Value::Enum(2))]); + let p = red.get(6, 12); + assert!(p[0] > 0.05 && p[1] < 1e-4 && p[2] < 1e-4, "{p:?}"); + } +} diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 56bbedac..06c09d2e 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -61,6 +61,41 @@ fn cartoon(ctx: &EffectCtx, mut b: Buf) -> Buf { } else { b.img.clone() }; + // Advanced ▸ Edge Enhancement: one shock-filter step. Each pixel samples from the side of + // the nearest edge it belongs to (along the luma gradient, by the sign of the Laplacian): + // positive values sharpen the transitions, negative ones soften them. + let enh = ctx.params.f("advanced/edgeEnhancement") / 100.0; + let smoothed = if enh < 0.0 { + // Softening: blend towards a blur of the picture. + let s = 1.5 * b.scale; + let blur = effectcraft_raster::gaussian_blur(&smoothed, s, s, true); + let k = (-enh) as f32; + let mut out = smoothed; + out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| (0..4).for_each(|c| p[c] += (q[c] - p[c]) * k)); + out + } else if enh > 0.0 { + let l = Plane::luma(&smoothed); + let (w, h) = (l.w as i64, l.h as i64); + let s = enh * 2.0 * b.scale; + let src = smoothed.clone(); + let mut out = smoothed; + out.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (x, y) = ((i as i64) % w, (i as i64) / w); + let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy); + let (gx, gy) = ((g(1, 0) - g(-1, 0)) * 0.5, (g(0, 1) - g(0, -1)) * 0.5); + let lap = g(1, 0) + g(-1, 0) + g(0, 1) + g(0, -1) - 4.0 * g(0, 0); + let m = (gx * gx + gy * gy).sqrt(); + if m < 1e-4 || lap.abs() < 1e-5 || y >= h { + return; + } + let k = -lap.signum() as f64 * s / m as f64; + let (sx, sy) = (x as f64 + 0.5 + gx as f64 * k, y as f64 + 0.5 + gy as f64 * k); + *px = src.sample_bilinear_clamped(sx, sy); + }); + out + } else { + smoothed + }; let luma = Plane::luma(&smoothed); let edges = if render >= 1 { let mag = sobel(&luma); @@ -543,6 +578,7 @@ pub fn specs() -> Vec { p("edge/edgeWidth", "Width", num(1.5), slider(0.0, 10.0, 0.0, 5.0, 2)), p("edge/edgeSoftness", "Softness", num(60.0), pct()), p("edge/edgeOpacity", "Opacity", num(100.0), pct()), + p("advanced/edgeEnhancement", "Edge Enhancement", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), p("advanced/edgeBlackLevel", "Edge Black Level", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("advanced/edgeContrast", "Edge Contrast", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), ], @@ -993,4 +1029,30 @@ mod tests { assert_eq!(cyc(0.0), cyc(360.0)); assert_ne!(cyc(0.0), cyc(180.0)); } + + #[test] + fn cartoon_edge_enhancement_sharpens_or_softens() { + // A soft 8-pixel ramp from black to white. + let mut img = Image::new(32, 4); + for y in 0..4 { + for x in 0..32 { + let v = 0.5 + 0.5 * ((x as f32 - 15.5) / 4.0).tanh(); + img.set(x, y, [v, v, v, 1.0]); + } + } + let fill = |e: f64| { + let v = [ + ("render", Value::Enum(0)), + ("detailRadius", num(0.0)), + ("fill/shadingSteps", num(64.0)), + ("fill/shadingSmoothness", num(100.0)), + ("advanced/edgeEnhancement", num(e)), + ]; + run_at("ec.stylize.cartoon", &v, img.clone(), 0.0).img + }; + let steep = |o: &Image| (0..31).map(|x| (o.get(x + 1, 2)[0] - o.get(x, 2)[0]).abs()).fold(0.0f32, f32::max); + let (base, sharp, soft) = (fill(0.0), fill(100.0), fill(-100.0)); + assert!(steep(&sharp) > steep(&base) + 0.02, "{} vs {}", steep(&sharp), steep(&base)); + assert!(steep(&soft) < steep(&base) + 1e-4, "{} vs {}", steep(&soft), steep(&base)); + } } diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index e14ff230..d0a60dce 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -173,6 +173,10 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Other Glow Operations and the Arbitrary Map render on the CPU. + if ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != effectcraft_color::BlendMode::Add { + return None; + } let thr = ctx.params.f("threshold") as f32 / 100.0; let radius = ctx.params.f("radius") * b.scale; let intensity = ctx.params.f("intensity") as f32; @@ -231,6 +235,10 @@ fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Motion blur (shutter angle) renders on the CPU. + if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() { + return None; + } let anchor = b.to_px(ctx.params.v2("anchor")); let pos = b.to_px(ctx.params.v2("position")); let sh = ctx.params.f("scaleHeight"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1f736668..d84315e2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -653,6 +653,11 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some(( + ctx.comp.shutter_angle, + ctx.comp.shutter_phase, + ctx.comp.motion_blur_samples, + )), }; // 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 diff --git a/docs/effects.md b/docs/effects.md index 9b791cbb..f54f62f6 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2746 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 2751 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**: 281 implemented in full, 22 partial (what is missing is listed). +- **Status**: 284 implemented in full, 19 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -162,7 +162,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | -| Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur | +| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | | Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | @@ -351,11 +351,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented | | CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented | | CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented | -| Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement | +| Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented | | Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | | Emboss | `ec.stylize.emboss` | 4 | | 32 | Implemented | | Find Edges | `ec.stylize.findedges` | 2 | | 32 | Implemented | -| Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map | +| Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented | | Mosaic | `ec.stylize.mosaic` | 3 | | 32 | Implemented | | Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented | | Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented | From 413e3ea97d0fe1ee7706259d697d438373b711d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:43:41 -0400 Subject: [PATCH 09/17] M9.13: Camera Lens Blur fringe / blur map, Colorama full controls, Selective Color Details - Camera Lens Blur: Diffraction Fringe, Blur Map (layer, channel, placement, focal distance, invert) with per-pixel radius. - Colorama: Add Phase (layer, from, mode), preset / custom Output Cycle palettes, Modify, Modify Alpha, Change Empty Pixels, Pixel Selection, Masking, Composite Over Layer. - Selective Color: Colors popup and Details twirl-downs (older saves migrate). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 228 ++++++++++++++++++++------- crates/effects/src/catalog.rs | 38 ++++- crates/effects/src/color2.rs | 24 ++- crates/effects/src/color_fx.rs | 271 ++++++++++++++++++++++++++++++--- crates/effects/src/lib.rs | 14 ++ crates/effects/src/migrate.rs | 14 ++ docs/effects.md | 10 +- 7 files changed, 516 insertions(+), 83 deletions(-) diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee..639f57b6 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -284,15 +284,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { } let shape = ctx.params.e("irisProperties/irisShape"); let sides = if shape >= 8 { 0 } else { shape + 3 }; - let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0); + let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0; // Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1). let aspect = ctx.params.f("irisProperties/irisAspectRatio"); - if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 { - for s in spans.iter_mut() { - s.1 = (s.1 as f64 * aspect).round() as i64; - s.2 = (s.2 as f64 * aspect).round() as i64; - } - } let reach = r * aspect.max(1.0); let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; if !repeat { @@ -342,58 +336,148 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { v }) .collect(); - let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); - let norm = 1.0 / count as f64; - let mut out = Image::new(src.width, src.height); - out.rows_mut().for_each(|(y, row)| { - for (x, o) in row.iter_mut().enumerate() { - let mut acc = [0.0f64; 4]; - for &(dy, l, rr) in &spans { - let mut yy = y as i64 + dy; - if yy < 0 || yy >= h as i64 { - if !repeat { - continue; - } - yy = yy.clamp(0, h as i64 - 1); + // Sum of the source over the iris spans centred on (x, y). + let span_sum = |spans: &[(i64, i64, i64)], x: usize, y: usize| -> [f64; 4] { + let mut acc = [0.0f64; 4]; + for &(dy, l, rr) in spans { + let mut yy = y as i64 + dy; + if yy < 0 || yy >= h as i64 { + if !repeat { + continue; } - let pre = &prefix[yy as usize]; - let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr); - if repeat { - if a < 0 { - let n = (-a).min(bnd - a + 1) as f64; - let first = src.data[yy as usize * w]; - for c in 0..4 { - acc[c] += first[c] as f64 * n; - } - a = 0; - } - if bnd >= w as i64 { - let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64; - let last = src.data[yy as usize * w + w - 1]; - for c in 0..4 { - acc[c] += last[c] as f64 * n; - } - bnd = w as i64 - 1; - } - } else { - a = a.max(0); - bnd = bnd.min(w as i64 - 1); - } - if a <= bnd { + yy = yy.clamp(0, h as i64 - 1); + } + let pre = &prefix[yy as usize]; + let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr); + if repeat { + if a < 0 { + let n = (-a).min(bnd - a + 1) as f64; + let first = src.data[yy as usize * w]; for c in 0..4 { - acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c]; + acc[c] += first[c] as f64 * n; } + a = 0; + } + if bnd >= w as i64 { + let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64; + let last = src.data[yy as usize * w + w - 1]; + for c in 0..4 { + acc[c] += last[c] as f64 * n; + } + bnd = w as i64 - 1; + } + } else { + a = a.max(0); + bnd = bnd.min(w as i64 - 1); + } + if a <= bnd { + for c in 0..4 { + acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c]; } } - let mut px = acc.map(|v| (v * norm) as f32); - px[3] = px[3].clamp(0.0, 1.0); - if linear { - let (c, a) = unpremul(px); - px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a); - } - *o = px; } + acc + }; + // Diffraction Fringe: the iris edge (outer 20 % of the radius) gets more of the energy: + // 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring. + let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0); + let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe); + let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) { + let stretch = |mut s: Vec<(i64, i64, i64)>| { + if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 { + for v in s.iter_mut() { + v.1 = (v.1 as f64 * aspect).round() as i64; + v.2 = (v.2 as f64 * aspect).round() as i64; + } + } + s + }; + let outer = stretch(iris_spans(radius, sides, ctx.params.f("irisProperties/irisRotation"), roundness)); + let inner = if fringe > 0.0 && radius * 0.8 >= 1.0 { + stretch(iris_spans(radius * 0.8, sides, ctx.params.f("irisProperties/irisRotation"), roundness)) + } else { + vec![] + }; + (outer, inner) + }; + let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::(); + // One blurred pixel with the given iris. + let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] { + let (outer, inner) = sp; + let acc = if inner.is_empty() { + let n = count(outer).max(1.0); + span_sum(outer, x, y).map(|v| v / n) + } else { + let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y)); + let (no, ni) = (count(outer), count(inner)); + let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9); + std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm) + }; + let mut px = acc.map(|v| v as f32); + px[3] = px[3].clamp(0.0, 1.0); + if linear { + let (c, a) = unpremul(px); + px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a); + } + px + }; + // Blur Map: per-pixel radius from a map layer (no blur at Blur Focal Distance, full radius + // at the farthest value). + let map = ctx.layer_param("blurMap/blurMapLayer", true).map(|o| { + let stretch = ctx.params.e("blurMap/placement") == 1; + let img = crate::util::fit_layer(ctx, &b, &o, stretch); + let ch = ctx.params.e("blurMap/channel"); + let invert = ctx.params.b("blurMap/invertBlurMap"); + let focal = (ctx.params.f("blurMap/blurFocalDistance") / 255.0).clamp(0.0, 1.0) as f32; + let span = focal.max(1.0 - focal).max(1e-3); + img.data + .iter() + .map(|p| { + let (c, a) = unpremul(*p); + let mut v = match ch { + 1 => a, + 2 => c[0].max(c[1]).max(c[2]) * a, + _ => luminance(c[0], c[1], c[2]) * a, + }; + if invert { + v = 1.0 - v; + } + ((v - focal).abs() / span).clamp(0.0, 1.0) + }) + .collect::>() }); + let mut out = Image::new(src.width, src.height); + match &map { + None => { + let sp = iris(r); + out.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + *o = blur_px(&sp, x, y); + } + }); + } + Some(m) => { + // Radii quantised to levels; each pixel blends the two nearest. + let levels = (r.ceil() as usize).clamp(1, 8); + let irises: Vec<_> = (0..=levels).map(|k| iris(r * k as f64 / levels as f64)).collect(); + let srcd = &b.img.data; + out.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let f = m[y * w + x] * levels as f32; + let k0 = (f.floor() as usize).min(levels); + let t = f - k0 as f32; + let at = |k: usize| if k == 0 || r * k as f64 / (levels as f64) < 0.5 { srcd[y * w + x] } else { blur_px(&irises[k], x, y) }; + let a = at(k0); + *o = if t > 1e-4 && k0 < levels { + let bq = at(k0 + 1); + std::array::from_fn(|c| a[c] + (bq[c] - a[c]) * t) + } else { + a + }; + } + }); + } + } b.img = out; b } @@ -598,6 +682,12 @@ pub fn specs() -> Vec { p("irisProperties/irisRoundness", "Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("irisProperties/irisAspectRatio", "Aspect Ratio", num(1.0), slider(0.01, 100.0, 0.25, 4.0, 2)), p("irisProperties/irisRotation", "Rotation", num(0.0), ParamUi::Angle), + p("irisProperties/diffractionFringe", "Diffraction Fringe", num(0.0), slider(0.0, 500.0, 0.0, 500.0, 1)), + p("blurMap/blurMapLayer", "Layer", Value::Layer(None), ParamUi::Layer), + p("blurMap/channel", "Channel", Value::Enum(0), popup(&["Luminance", "Alpha", "Color"])), + p("blurMap/placement", "Placement", Value::Enum(1), popup(&["Center", "Stretch"])), + p("blurMap/blurFocalDistance", "Blur Focal Distance", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("blurMap/invertBlurMap", "Invert Blur Map", Value::Bool(false), ParamUi::Checkbox), p("highlight/specularBrightness", "Gain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("highlight/specularThreshold", "Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)), p("highlight/highlightSaturation", "Saturation", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), @@ -816,4 +906,38 @@ mod tests { let pad = (out.img.width - 16) / 2; assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005); } + + #[test] + fn camera_lens_blur_diffraction_fringe_and_blur_map() { + use crate::{EffectEnv, EffectHost, LayerPixels}; + // A single bright point spreads into a disc; with Diffraction Fringe its rim is brighter. + let mut dot = Image::filled(41, 41, [0.0, 0.0, 0.0, 1.0]); + dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]); + let v = [("blurRadius", num(10.0)), ("irisProperties/irisShape", Value::Enum(8)), ("repeatEdge", Value::Bool(true))]; + let plain = run("ec.blur.cameralens", &dot, &v).img; + let ring = run("ec.blur.cameralens", &dot, &[v.as_slice(), &[("irisProperties/diffractionFringe", num(300.0))]].concat()).img; + assert!((plain.get(20, 20)[0] - plain.get(29, 20)[0]).abs() < 1e-5, "even disc"); + assert!(ring.get(29, 20)[0] > ring.get(20, 20)[0] * 2.0, "{:?} {:?}", ring.get(29, 20), ring.get(20, 20)); + // Blur Map: black (focal distance 0) stays sharp, white gets the full radius. + struct Half; + impl EffectHost for Half { + fn layer(&self, _: u64, _: bool) -> Option { + let mut m = Image::filled(40, 20, [0.0, 0.0, 0.0, 1.0]); + for y in 0..20 { + for x in 20..40 { + m.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [40.0, 20.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let stripes = crate::util::gen_image(40, 20, |x, _| if x % 4 < 2 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }); + let vals = [("blurRadius", num(6.0)), ("repeatEdge", Value::Bool(true)), ("blurMap/blurMapLayer", Value::Layer(Some(1)))]; + let out = crate::run_fx("ec.blur.cameralens", &vals, stripes.clone(), 0.0, EffectEnv { host: Some(&Half), ..Default::default() }).img; + assert_eq!(out.get(4, 10), stripes.get(4, 10), "sharp where the map is at the focal distance"); + assert!((out.get(30, 10)[0] - 0.5).abs() < 0.2, "{:?}", out.get(30, 10)); + } } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 31e786c4..9966bfa9 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -8,9 +8,6 @@ use crate::{CATEGORIES, registry}; /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"), - ("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"), - ("ec.color.selectivecolor", "simplified Colors / Details layout"), ("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"), @@ -61,6 +58,13 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option true, } } + "ec.color.selectivecolor" => match param.strip_prefix("details/") { + Some(rest) => { + let g = rest.split('/').next().unwrap_or(""); + crate::color2::SC_GROUPS.iter().position(|x| *x == g).is_none_or(|i| i == e("colors") as usize) + } + None => true, + }, "ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true), "ec.sim.shatter" => match param { "shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5, @@ -143,6 +147,34 @@ mod tests { assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } + #[test] + fn popups_choose_the_shown_controls() { + use effectcraft_keyframe::Value; + let with = |pairs: &'static [(&'static str, u32)]| move |id: &str| pairs.iter().find(|(k, _)| *k == id).map(|(_, v)| Value::Enum(*v)); + let shown = |e: &str, p: &str, pairs: &'static [(&'static str, u32)]| super::param_shown(e, p, &with(pairs)); + // Levels: RGB shows the master controls only; Red shows the red ones. + assert!(shown("ec.color.levels", "inBlack", &[])); + assert!(!shown("ec.color.levels", "redInBlack", &[])); + assert!(shown("ec.color.levels", "redInBlack", &[("channel", 1)])); + assert!(!shown("ec.color.levels", "inBlack", &[("channel", 1)])); + assert!(shown("ec.color.levels", "channel", &[("channel", 1)])); + // Hue/Saturation: Master or one colour range. + assert!(shown("ec.color.huesaturation", "hue", &[])); + assert!(shown("ec.color.huesaturation", "bluesHue", &[("channelControl", 5)])); + assert!(!shown("ec.color.huesaturation", "redsHue", &[("channelControl", 5)])); + // Card effects: the camera system's group. + assert!(shown("ec.sim.carddance", "cameraPosition", &[])); + assert!(!shown("ec.sim.carddance", "cornerPins", &[])); + assert!(shown("ec.transition.cardwipe", "cornerPins/upperLeftCorner", &[("cameraSystem", 1)])); + assert!(!shown("ec.sim.shatter", "shape/customShatterMap", &[])); + // Selective Color: the chosen range's Details group. + assert!(shown("ec.color.selectivecolor", "details/reds", &[])); + assert!(!shown("ec.color.selectivecolor", "details/blues/bluesCyan", &[])); + assert!(shown("ec.color.selectivecolor", "details", &[])); + // Everything else is always shown. + assert!(shown("ec.blur.gaussian", "blurriness", &[])); + } + #[test] fn partial_notes_name_registered_effects() { for (id, note) in super::PARTIAL { diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 6e7475ea..67063d31 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -504,6 +504,16 @@ const SC_IDS: [[&str; 4]; 9] = [ ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], ]; +/// Selective Color's Colors popup (the range whose controls Effect Controls shows) and the +/// Details twirl-down group of each range. +pub const SC_COLORS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"]; +pub const SC_GROUPS: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"]; + +/// Spec id of range `r`'s control `leaf` (`details/reds/redsCyan`). +fn sc_param_id(r: usize, leaf: &str) -> String { + format!("details/{}/{leaf}", SC_GROUPS[r]) +} + const SC_NAMES: [[&str; 4]; 9] = [ ["Reds Cyan", "Reds Magenta", "Reds Yellow", "Reds Black"], ["Yellows Cyan", "Yellows Magenta", "Yellows Yellow", "Yellows Black"], @@ -534,7 +544,7 @@ fn sc_weights(c: [f32; 3]) -> [f32; 9] { } fn selective_color(ctx: &EffectCtx, mut b: Buf) -> Buf { - let adj = SC_IDS.map(|ids| ids.map(|id| ctx.params.f(id) as f32 / 100.0)); + let adj: [[f32; 4]; 9] = std::array::from_fn(|r| SC_IDS[r].map(|id| ctx.params.f(&sc_param_id(r, id)) as f32 / 100.0)); if adj.iter().all(|r| r.iter().all(|v| *v == 0.0)) { return b; } @@ -882,10 +892,12 @@ pub fn specs() -> Vec { } lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); - let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"]))]; - for (ids, names) in SC_IDS.iter().zip(SC_NAMES.iter()) { + let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"])), p("colors", "Colors", Value::Enum(0), popup(&SC_COLORS))]; + // Details: every range's Cyan / Magenta / Yellow / Black in its own twirl-down. + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + for (r, (ids, names)) in SC_IDS.iter().zip(SC_NAMES.iter()).enumerate() { for k in 0..4 { - sc_params.push(p(ids[k], names[k], num(0.0), pct())); + sc_params.push(p(leak(sc_param_id(r, ids[k])), names[k], num(0.0), pct())); } } let clip_params = |extra: bool| { @@ -1146,10 +1158,10 @@ mod tests { let img = ramp(); assert_eq!(run("ec.color.selectivecolor", &img, &[]), img); let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]); - let out = run("ec.color.selectivecolor", &red, &[("redsCyan", num(100.0)), ("method", Value::Enum(1))]); + let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]); assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]); let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]); - assert_eq!(run("ec.color.selectivecolor", &blue, &[("redsCyan", num(100.0))]), blue); + assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue); } #[test] diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 7427e9d2..0a6663a5 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -634,28 +634,204 @@ fn colorama_phase(c: [f32; 3], a: f32, mode: u32) -> f32 { v.clamp(0.0, 1.0) } -/// Colorama with the built-in hue-cycle output palette: the chosen input attribute, shifted by -/// Phase Shift and repeated Cycle Repetitions times, picks a colour on the cycle (Interpolate -/// Palette off posterizes to the palette's six colours). +/// Colorama's Output Cycle presets (our own palettes; Custom uses the Palette parameter). +pub const COLORAMA_PRESETS: [&str; 9] = ["Hue Cycle", "Ramp Grayscale", "Fire", "Ice", "Ramp Red", "Ramp Green", "Ramp Blue", "Pastel Cycle", "Custom"]; +/// Colorama's Modify options: which channels of the pixel take the palette colour. +const COLORAMA_MODIFY: [&str; 12] = + ["All", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Red & Green", "Green & Blue", "Blue & Red", "None"]; + +/// An output cycle: colour stops around the wheel (position 0..1, straight RGBA), cyclic. +#[derive(Clone, Debug, PartialEq)] +pub struct ColoramaPalette { + pub stops: Vec<(f32, [f32; 4])>, +} + +impl ColoramaPalette { + /// Built-in palette `i` of [`COLORAMA_PRESETS`]. + pub fn preset(i: u32) -> ColoramaPalette { + let s = |v: &[(f32, [f32; 3])]| ColoramaPalette { stops: v.iter().map(|(p, c)| (*p, [c[0], c[1], c[2], 1.0])).collect() }; + match i { + 1 => s(&[(0.0, [0.0; 3]), (0.999, [1.0; 3])]), + 2 => s(&[(0.0, [0.0, 0.0, 0.0]), (0.3, [0.7, 0.05, 0.0]), (0.6, [1.0, 0.55, 0.0]), (0.85, [1.0, 0.95, 0.4]), (0.999, [1.0, 1.0, 1.0])]), + 3 => s(&[(0.0, [0.0, 0.0, 0.1]), (0.4, [0.1, 0.3, 0.8]), (0.75, [0.5, 0.85, 1.0]), (0.999, [1.0, 1.0, 1.0])]), + 4 => s(&[(0.0, [0.0; 3]), (0.999, [1.0, 0.0, 0.0])]), + 5 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 1.0, 0.0])]), + 6 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 0.0, 1.0])]), + 7 => s(&[(0.0, [1.0, 0.7, 0.7]), (1.0 / 3.0, [0.7, 1.0, 0.7]), (2.0 / 3.0, [0.7, 0.7, 1.0])]), + _ => s(&[ + (0.0, [1.0, 0.0, 0.0]), + (1.0 / 6.0, [1.0, 1.0, 0.0]), + (2.0 / 6.0, [0.0, 1.0, 0.0]), + (3.0 / 6.0, [0.0, 1.0, 1.0]), + (4.0 / 6.0, [0.0, 0.0, 1.0]), + (5.0 / 6.0, [1.0, 0.0, 1.0]), + ]), + } + } + + /// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. + pub fn parse(s: &str) -> Option { + let mut stops: Vec<(f32, [f32; 4])> = s + .split_whitespace() + .filter_map(|t| { + let (pos, c) = t.split_once(':')?; + let v: Vec = c.split(',').filter_map(|x| x.trim().parse().ok()).collect(); + (v.len() >= 3).then(|| (pos.trim().parse::().ok().map(|p| p.rem_euclid(1.0)), [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)])) + }) + .filter_map(|(p, c)| p.map(|p| (p, c))) + .collect(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + (!stops.is_empty()).then_some(ColoramaPalette { stops }) + } + + /// The colour at phase `ph` (0..1). Interpolate Palette blends between neighbouring stops + /// (around the wheel); off, each phase takes the stop at or before it. + pub fn eval(&self, ph: f32, interpolate: bool) -> [f32; 4] { + let n = self.stops.len(); + let ph = ph.rem_euclid(1.0); + let k = self.stops.iter().rposition(|s| s.0 <= ph).unwrap_or(n - 1); + let (p0, c0) = self.stops[k]; + if !interpolate || n == 1 { + return c0; + } + let (p1, c1) = self.stops[(k + 1) % n]; + let span = (p1 - p0).rem_euclid(1.0).max(1e-6); + let t = ((ph - p0).rem_euclid(1.0) / span).clamp(0.0, 1.0); + std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * t) + } +} + +/// How well colour `c` matches `key` (1 = matches) for Pixel Selection's Matching Mode +/// (1 RGB, 2 Hue, 3 Chroma), with Matching Tolerance / Softness (0..1). +fn colorama_match(c: [f32; 3], key: [f32; 3], mode: u32, tol: f32, soft: f32) -> f32 { + let d = match mode { + 2 => { + let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]); + let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]); + let dh = (h1 - h2).abs(); + dh.min(1.0 - dh) * 2.0 + (1.0 - s1.min(1.0)) * 0.5 + } + 3 => { + // Chroma: the colour without its brightness. + let n = |c: [f32; 3]| { + let s = c[0] + c[1] + c[2]; + if s > 1e-5 { [c[0] / s, c[1] / s, c[2] / s] } else { [1.0 / 3.0; 3] } + }; + let (a, b) = (n(c), n(key)); + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() * 1.5 + } + _ => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(), + }; + if d <= tol { + 1.0 + } else if soft > 0.0 { + (1.0 - (d - tol) / soft).clamp(0.0, 1.0) + } else { + 0.0 + } +} + +/// Colorama: a phase taken from the pixel (Get Phase From, optionally combined with an Add +/// Phase layer), shifted and repeated around the Output Cycle, picks a palette colour, which +/// replaces the channels Modify names. Pixel Selection limits it to colours near Matching +/// Color; Masking to where a mask layer allows; Blend With Original mixes the result back. fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf { - let shift = ctx.params.f("inputPhase/phaseShift") as f32 / 360.0; - let mode = ctx.params.e("inputPhase/mode"); - let cycles = ctx.params.f("outputCycle/cycles").max(0.0) as f32; - let smooth = ctx.params.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); - let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0; - b.img.data.iter_mut().for_each(|px| { + let pr = ctx.params; + let shift = pr.f("inputPhase/phaseShift") as f32 / 360.0; + let mode = pr.e("inputPhase/mode"); + let cycles = pr.f("outputCycle/cycles").max(0.0) as f32; + let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); + let preset = pr.e("outputCycle/usePresetPalette"); + let palette = if preset as usize == COLORAMA_PRESETS.len() - 1 { ColoramaPalette::parse(pr.s("outputCycle/palette")) } else { None } + .unwrap_or_else(|| ColoramaPalette::preset(preset)); + let blend = 1.0 - pr.f("blend") as f32 / 100.0; + // Add Phase. + let add = ctx.layer_param("inputPhase/addPhase", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let add_from = pr.e("inputPhase/addPhaseFrom"); + let add_mode = pr.e("inputPhase/addMode"); + // Modify. + let modify = pr.e("modify/modify"); + let modify_alpha = pr.b("modify/modifyAlpha"); + let change_empty = pr.b("modify/changeEmptyPixels"); + // Pixel Selection. + let match_mode = pr.e("pixelSelection/matchingMode"); + let key = pr.color("pixelSelection/matchingColor"); + let tol = (pr.f("pixelSelection/matchingTolerance") / 100.0) as f32; + let soft = (pr.f("pixelSelection/matchingSoftness") / 100.0) as f32; + // Masking. + let mask_mode = pr.e("masking/maskingMode"); + let mask = (mask_mode != 0).then(|| ctx.layer_param("masking/maskLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true))).flatten(); + let composite = pr.get("masking/compositeOverLayer").is_none_or(|v| v.as_bool()); + use rayon::prelude::*; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let a = px[3]; - if a <= 0.0 { + if a <= 0.0 && !change_empty { return; } - let c = [px[0] / a, px[1] / a, px[2] / a]; - let mut ph = (colorama_phase(c, a, mode) * cycles + shift).rem_euclid(1.0); - if !smooth { - ph = (ph * 6.0).floor() / 6.0; + let c = if a > 0.0 { [px[0] / a, px[1] / a, px[2] / a] } else { [0.0; 3] }; + let mut p = colorama_phase(c, a, mode); + if let Some(img) = &add { + let (ac, aa) = crate::util::unpremul(img.data[i]); + let q = colorama_phase(ac, aa, add_from); + p = match add_mode { + 1 => (p + q).min(1.0), + 2 => (p + q) * 0.5, + 3 => p + q - p * q, + _ => (p + q).rem_euclid(1.0), + }; } - let (r, g, bl) = effectcraft_color::hsv_to_rgb(ph, 1.0, 1.0); - let o = mix(c, [r, g, bl], blend); - *px = [o[0] * a, o[1] * a, o[2] * a, a]; + let ph = (p * cycles + shift).rem_euclid(1.0); + let pc = palette.eval(ph, smooth); + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let (ph_, ps, pl) = rgb_to_hsl(pc[0], pc[1], pc[2]); + let hl = |h: f32, s: f32, l: f32| { + let (r, g, bb) = hsl_to_rgb(h, s, l); + [r, g, bb] + }; + let new: [f32; 3] = match modify { + 1 => [pc[0], c[1], c[2]], + 2 => [c[0], pc[1], c[2]], + 3 => [c[0], c[1], pc[2]], + 4 => hl(ph_, s, l), + 5 => hl(h, s, pl), + 6 => hl(h, ps, l), + 7 => { + // Value: scale the colour to the palette colour's maximum. + let (m, pm) = (c[0].max(c[1]).max(c[2]), pc[0].max(pc[1]).max(pc[2])); + if m > 1e-6 { c.map(|v| v * pm / m) } else { [pm; 3] } + } + 8 => [pc[0], pc[1], c[2]], + 9 => [c[0], pc[1], pc[2]], + 10 => [pc[0], c[1], pc[2]], + 11 => c, + _ => [pc[0], pc[1], pc[2]], + }; + let new_a = if modify_alpha { + pc[3] + } else if a > 0.0 { + a + } else { + 1.0 + }; + // Where the effect applies: Pixel Selection × Masking. + let mut w = if match_mode == 0 { 1.0 } else { colorama_match(c, [key[0], key[1], key[2]], match_mode, tol, soft) }; + if let Some(m) = &mask { + let (mc, ma) = crate::util::unpremul(m.data[i]); + let lum = luminance(mc[0], mc[1], mc[2]) * ma; + w *= match mask_mode { + 1 => lum, + 2 => 1.0 - lum, + 3 => ma, + _ => 1.0 - ma, + } + .clamp(0.0, 1.0); + } + let k = w * blend; + let o = mix(c, new, k); + let oa = a + (new_a - a) * k; + // Composite Over Layer off: only the affected pixels remain. + let oa = if composite { oa } else { oa * w }; + *px = [o[0] * oa, o[1] * oa, o[2] * oa, oa.clamp(0.0, 1.0)]; }); b } @@ -919,9 +1095,30 @@ pub fn specs() -> Vec { "Colorama", vec![ p("inputPhase/mode", "Get Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)), + p("inputPhase/addPhase", "Add Phase", Value::Layer(None), ParamUi::Layer), + p("inputPhase/addPhaseFrom", "Add Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)), + p("inputPhase/addMode", "Add Mode", Value::Enum(0), popup(&["Wrap", "Clamp", "Average", "Screen"])), p("inputPhase/phaseShift", "Phase Shift", num(0.0), ParamUi::Angle), + p("outputCycle/usePresetPalette", "Use Preset Palette", Value::Enum(0), popup(&COLORAMA_PRESETS)), + // Use Preset Palette = Custom: "position:r,g,b[,a] …" around the wheel. + p( + "outputCycle/palette", + "Output Cycle", + Value::Str("0:1,0,0 0.1667:1,1,0 0.3333:0,1,0 0.5:0,1,1 0.6667:0,0,1 0.8333:1,0,1".into()), + ParamUi::Text, + ), p("outputCycle/cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)), p("outputCycle/interpolatePalette", "Interpolate Palette", Value::Bool(true), ParamUi::Checkbox), + p("modify/modify", "Modify", Value::Enum(0), popup(&COLORAMA_MODIFY)), + p("modify/modifyAlpha", "Modify Alpha", Value::Bool(false), ParamUi::Checkbox), + p("modify/changeEmptyPixels", "Change Empty Pixels", Value::Bool(false), ParamUi::Checkbox), + p("pixelSelection/matchingColor", "Matching Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pixelSelection/matchingTolerance", "Matching Tolerance", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("pixelSelection/matchingSoftness", "Matching Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("pixelSelection/matchingMode", "Matching Mode", Value::Enum(0), popup(&["Off", "RGB", "Hue", "Chroma"])), + p("masking/maskLayer", "Mask Layer", Value::Layer(None), ParamUi::Layer), + p("masking/maskingMode", "Masking Mode", Value::Enum(0), popup(&["Off", "Luminance", "Inverted Luminance", "Alpha", "Inverted Alpha"])), + p("masking/compositeOverLayer", "Composite Over Layer", Value::Bool(true), ParamUi::Checkbox), p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), ], colorama, @@ -1158,4 +1355,44 @@ mod tests { let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img; assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); } + + #[test] + fn colorama_palettes_modify_selection_masking_and_add_phase() { + let grey = [0.5, 0.5, 0.5, 1.0]; + let red = [1.0, 0.0, 0.0, 1.0]; + // Ramp Grayscale: intensity 0.5 maps to mid grey. + let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey); + assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}"); + // Custom palette: two stops, black at 0 and blue at 0.5. + let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))]; + let c = px("ec.color.colorama", &custom, grey); + assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}"); + // Modify Lightness keeps the hue of the pixel (red stays red). + let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red); + assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}"); + // Pixel Selection: only colours near Matching Color change. + let sel = [ + ("pixelSelection/matchingMode", Value::Enum(1)), + ("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)), + ("pixelSelection/matchingTolerance", num(10.0)), + ]; + assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6)); + assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3)); + // Composite Over Layer off: unselected pixels vanish. + let mut off = sel.to_vec(); + off.push(("masking/compositeOverLayer", Value::Bool(false))); + assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0); + // Modify Alpha takes the palette alpha. + let alpha = [ + ("outputCycle/usePresetPalette", Value::Enum(8)), + ("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())), + ("modify/modifyAlpha", Value::Bool(true)), + ]; + assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5); + // Palette parsing and evaluation. + let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap(); + assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]); + assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around"); + assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 28db9c15..90a85a6d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -514,6 +514,19 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("toleranceGroup", "Tolerance"), ("inputPhase", "Input Phase"), ("outputCycle", "Output Cycle"), + ("modify", "Modify"), + ("pixelSelection", "Pixel Selection"), + ("masking", "Masking"), + ("details", "Details"), + ("reds", "Reds"), + ("yellows", "Yellows"), + ("greens", "Greens"), + ("cyans", "Cyans"), + ("blues", "Blues"), + ("magentas", "Magentas"), + ("whites", "Whites"), + ("neutrals", "Neutrals"), + ("blacks", "Blacks"), ("moreOptions", "More Options"), ("basicCorrection", "Basic Correction"), ("whiteBalance", "White Balance"), @@ -532,6 +545,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ // Camera Lens Blur. ("irisProperties", "Iris Properties"), ("highlight", "Highlight"), + ("blurMap", "Blur Map"), // Numbers. ("format", "Format"), ("fillAndStroke", "Fill and Stroke"), diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 1e2c25e0..cba69cbe 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -421,4 +421,18 @@ mod tests { assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed"); } + + #[test] + fn selective_color_ranges_move_into_details() { + let spec = find("ec.color.selectivecolor").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Selective Color", [100.0, 50.0]); + let mut pr = take_at(&mut g, "details/reds/redsCyan").unwrap(); + pr.value = Value::Scalar(40.0); + g.children.push(pr.into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert!(g.get("redsCyan").is_none()); + assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0)); + assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds"); + } } diff --git a/docs/effects.md b/docs/effects.md index f54f62f6..8bb8d9e4 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2751 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 2773 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**: 284 implemented in full, 19 partial (what is missing is listed). +- **Status**: 287 implemented in full, 16 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -51,7 +51,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | | CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | -| Camera Lens Blur | `ec.blur.cameralens` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer | +| Camera Lens Blur | `ec.blur.cameralens` | 16 | | 32 | Implemented | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | | Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | | Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | @@ -102,7 +102,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | -| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | +| Colorama | `ec.color.colorama` | 20 | | 32 | Implemented | | Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented | | Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | @@ -119,7 +119,7 @@ Effects are addressed by id (`ec..`) or display name in commands | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented | -| Selective Color | `ec.color.selectivecolor` | 37 | | 32 | Partial: simplified Colors / Details layout | +| Selective Color | `ec.color.selectivecolor` | 38 | | 32 | Implemented | | Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | | Tritone | `ec.color.tritone` | 4 | | 32 | Implemented | From 6d5e86601f1d3f1eddb67b58a62b015479678729 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:57:05 -0400 Subject: [PATCH 10/17] M9.13: Cell Pattern HQ variants, Advanced Spill Suppressor Ultra controls, Inner/Outer Key masks and cleanup - Cell Pattern: HQ variants search a wider cell neighbourhood and anti-alias. - Advanced Spill Suppressor: Tolerance, Desaturate, Spill Color Correction. - Inner/Outer Key: Additional Foreground / Background 1-10 and Cleanup Foreground / Background 1-10 brush strokes (older single masks migrate to slot 1). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 3 - crates/effects/src/generate2.rs | 50 ++++++++++++--- crates/effects/src/keying.rs | 53 ++++++++++++++- crates/effects/src/keying2.rs | 110 ++++++++++++++++++++++++++++---- crates/effects/src/lib.rs | 24 +++++++ crates/effects/src/migrate.rs | 20 ++++++ docs/effects.md | 10 +-- 7 files changed, 236 insertions(+), 34 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 9966bfa9..8c501a3c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -12,9 +12,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"), ("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"), ("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"), - ("ec.key.advancedspill", "no Tolerance, Desaturate or Spill Color Correction"), ("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"), ("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 9dc26333..2234fc6a 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -43,8 +43,9 @@ fn add_light(px: Px, l: [f32; 3], la: f32) -> Px { // ---------------------------------------------------------------- Cell Pattern -/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts -/// (we always render at full precision). +/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts but +/// search a wider neighbourhood of cells (no artefacts at high Disperse) and anti-alias the +/// cell edges (4×4 samples per pixel). const CELL_PATTERNS: [&str; 12] = [ "Bubbles", "Crystals", @@ -75,13 +76,13 @@ fn pattern_kind(i: u32) -> u32 { /// Worley distances for point (u, v) in cell units: (F1, F2, hash of the nearest cell). /// `tile` repeats the cell layout every (columns, rows) cells. -fn worley(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>) -> (f64, f64, f32) { +fn worley_r(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>, reach: i64) -> (f64, f64, f32) { let (cx, cy) = (u.floor() as i64, v.floor() as i64); let mut f1 = f64::INFINITY; let mut f2 = f64::INFINITY; let mut id = 0.0f32; - for j in -1..=1 { - for i in -1..=1 { + for j in -reach..=reach { + for i in -reach..=reach { let (gx, gy) = (cx + i, cy + j); let (tx, ty) = match tile { Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)), @@ -135,6 +136,7 @@ fn overflow(v: f32, mode: u32) -> f32 { fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf { let pattern = pattern_kind(ctx.params.e("cellPattern")); + let hq = (6..=10).contains(&ctx.params.e("cellPattern")); let invert = ctx.params.b("invert"); let contrast = ctx.params.f("contrast") as f32 / 100.0; let ov = ctx.params.e("overflow"); @@ -152,11 +154,19 @@ fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf { .params .b("tilingOptions/enableTiling") .then(|| (ctx.params.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64, ctx.params.f("tilingOptions/cellsVertical").round().max(1.0) as i64)); + let subs: Vec = if hq { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }; + let reach = if hq { 2 } else { 1 }; map_xy(&mut b.img, |x, y, _| { - let u = (x as f64 + 0.5 - off.0) / size; - let v = (y as f64 + 0.5 - off.1) / size; - let (f1, f2, id) = worley(u, v, disperse, evo, seed, tile); - let mut val = cell_value(pattern, f1, f2, id); + let mut val = 0.0; + for sy in &subs { + for sx in &subs { + let u = (x as f64 + sx - off.0) / size; + let v = (y as f64 + sy - off.1) / size; + let (f1, f2, id) = worley_r(u, v, disperse, evo, seed, tile, reach); + val += cell_value(pattern, f1, f2, id); + } + } + let mut val = val / (subs.len() * subs.len()) as f32; val = overflow((val - 0.5) * contrast + 0.5, ov); if invert { val = 1.0 - val; @@ -1347,4 +1357,26 @@ mod tests { assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); } + + #[test] + fn cell_pattern_hq_variants_antialias() { + let s = crate::find("ec.generate.cellpattern").unwrap(); + let r = |pat: u32| { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("cellPattern".into(), Value::Enum(pat)); + params.values.insert("size".into(), num(12.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img + }; + let distinct = |img: &Image| { + let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); + v.sort_unstable(); + v.dedup(); + v.len() + }; + let (std, hq) = (r(4), r(9)); + assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); + assert_ne!(std, hq); + assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); + } } diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 6ec88641..9d61ff8c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -496,14 +496,36 @@ fn advanced_spill(ctx: &EffectCtx, mut b: Buf) -> Buf { let amt = ctx.params.f("suppression") as f32 / 100.0; let range = ctx.params.f("ultraSettings/spillRange") as f32 / 100.0; let luma = ctx.params.f("ultraSettings/lumaCorrection") as f32 / 100.0; + // Ultra: Tolerance limits suppression to hues near the key colour; Desaturate takes the + // colour out of suppressed pixels; Spill Color Correction puts the removed spill back as + // neutral light (so suppressed areas keep their brightness without the cast). + let key = ctx.params.color("ultraSettings/keyColor"); + let key_hue = effectcraft_color::rgb_to_hsl(key[0], key[1], key[2]).0; + let tol = if ultra { ctx.params.get("ultraSettings/tolerance").map(Value::as_f64).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 }; + let desat = if ultra { ctx.params.f("ultraSettings/desaturate") as f32 / 100.0 } else { 0.0 }; + let neutral = if ultra { ctx.params.f("ultraSettings/spillColorCorrection") as f32 / 100.0 } else { 0.0 }; b.img.map_straight(|mut c| { + let w = if tol < 1.0 { + let h = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]).0; + let d = (h - key_hue).abs(); + let d = d.min(1.0 - d); + (1.0 - d / (tol * 0.5).max(1e-4)).clamp(0.0, 1.0) + } else { + 1.0 + }; let mx = c[o1].max(c[o2]); let avg = 0.5 * (c[o1] + c[o2]); let limit = mx + (avg - mx) * range; - let spill = (c[pi] - limit).max(0.0) * amt; + let spill = (c[pi] - limit).max(0.0) * amt * w; c[pi] -= spill; - let back = spill * LUMA_W[pi] * luma; - c.map(|v| v + back) + let back = spill * LUMA_W[pi] * luma + spill * neutral / 3.0; + let mut c = c.map(|v| v + back); + if desat > 0.0 && spill > 0.0 { + let l = effectcraft_color::luminance(c[0], c[1], c[2]); + let k = (desat * (spill * 4.0).min(1.0)).min(1.0); + c = c.map(|v| v + (l - v) * k); + } + c }); b } @@ -675,7 +697,10 @@ pub fn specs() -> Vec { p("method", "Method", Value::Enum(0), popup(&["Standard", "Ultra"])), p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)), p("ultraSettings/keyColor", "Key Color", col(0.0, 1.0, 0.0), ParamUi::Color), + p("ultraSettings/tolerance", "Tolerance", num(50.0), pct()), + p("ultraSettings/desaturate", "Desaturate", num(0.0), pct()), p("ultraSettings/spillRange", "Spill Range", num(50.0), pct()), + p("ultraSettings/spillColorCorrection", "Spill Color Correction", num(0.0), pct()), p("ultraSettings/lumaCorrection", "Luma Correction", num(0.0), pct()), ], advanced_spill, @@ -846,4 +871,26 @@ mod tests { let out = run("ec.key.keycleaner", &[], img.clone()); assert_eq!(out.get(12, 2), img.get(12, 2)); } + + #[test] + fn advanced_spill_ultra_tolerance_desaturate_and_color_correction() { + let ultra = |extra: &[(&str, Value)], c: [f32; 4]| { + let mut v = vec![("method", Value::Enum(1)), ("ultraSettings/keyColor", Value::Color([0.0, 1.0, 0.0, 1.0]))]; + v.extend_from_slice(extra); + run("ec.key.advancedspill", &v, Image::filled(1, 1, c)).get(0, 0) + }; + // A yellow-green pixel (hue away from the key) is left alone with a narrow tolerance. + let yg = [0.6, 0.8, 0.1, 1.0]; + assert!((ultra(&[("ultraSettings/tolerance", num(5.0))], yg)[1] - 0.8).abs() < 1e-5); + assert!(ultra(&[("ultraSettings/tolerance", num(100.0))], yg)[1] < 0.79); + // Desaturate greys the suppressed pixel. + let g = [0.3, 0.8, 0.3, 1.0]; + let d = ultra(&[("ultraSettings/desaturate", num(100.0))], g); + assert!((d[0] - d[1]).abs() < 0.05, "{d:?}"); + // Spill Color Correction keeps the brightness the suppression removed. + let plain = ultra(&[], g); + let cc = ultra(&[("ultraSettings/spillColorCorrection", num(100.0))], g); + let sum = |p: [f32; 4]| p[0] + p[1] + p[2]; + assert!(sum(cc) > sum(plain) + 0.1 && (sum(cc) - sum(g)).abs() < 1e-4, "{plain:?} {cc:?}"); + } } diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 41ed1fc0..4467318b 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -102,8 +102,11 @@ fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { } fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf { - let fg_idx = [ctx.params.e("foreground"), ctx.params.e("additionalForeground")]; - let bg_idx = [ctx.params.e("background"), ctx.params.e("additionalBackground")]; + // Foreground (Inside) plus Additional Foreground 1–10; likewise for the background. + let fg_idx: Vec = + std::iter::once(ctx.params.e("foreground")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalForeground/foreground{k}")))).collect(); + let bg_idx: Vec = + std::iter::once(ctx.params.e("background")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalBackground/background{k}")))).collect(); let radius = (ctx.params.f("singleMaskHighlightRadius") * b.scale) as f32; let thin = ctx.params.f("edgeThin") * b.scale; let feather = ctx.params.f("edgeFeather") * b.scale; @@ -187,6 +190,26 @@ fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf { .collect(), ); } + // Cleanup Foreground / Background: brush strokes along mask paths that paint the matte + // towards opaque / transparent (Brush Radius, Brush Pressure). + for (side, target) in [("Foreground", 1.0f32), ("Background", 0.0)] { + for k in 1..=10 { + let g = format!("cleanup{side}/cleanup{side}{k}"); + let idx = ctx.params.e(&format!("{g}/path")); + let Some(m) = (idx > 0).then(|| ctx.env.masks.get(idx as usize - 1)).flatten() else { continue }; + let rad = (ctx.params.f(&format!("{g}/brushRadius")) * b.scale).max(0.5); + let pressure = (ctx.params.f(&format!("{g}/brushPressure")) / 100.0).clamp(0.0, 1.0) as f32; + let pts: Vec<[f64; 2]> = m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(); + alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let d = dist_to_poly(&pts, m.closed, x, y); + if d < rad + 1.0 { + let cov = ((rad - d) / rad.max(1.0) * 2.0).clamp(0.0, 1.0) as f32 * pressure; + *a += (target - *a) * cov; + } + }); + } + } if thin.abs() > 0.01 { alpha = morph_frac(&alpha, thin.abs(), thin < 0.0); } @@ -332,18 +355,34 @@ pub fn specs() -> Vec { spec( "ec.key.innerouter", "Inner/Outer Key", - vec![ - p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options()), - p("additionalForeground", "Additional Foreground", Value::Enum(0), mask_options()), - p("background", "Background (Outside)", Value::Enum(0), mask_options()), - p("additionalBackground", "Additional Background", Value::Enum(0), mask_options()), - p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)), - p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)), - p("edgeThreshold", "Edge Threshold", num(0.0), pct()), - p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox), - p("blendWithOriginal", "Blend with Original", num(0.0), pct()), - ], + { + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + let mut v = vec![p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options())]; + for k in 1..=10 { + v.push(p(leak(format!("additionalForeground/foreground{k}")), leak(format!("Foreground {k}")), Value::Enum(0), mask_options())); + } + v.push(p("background", "Background (Outside)", Value::Enum(0), mask_options())); + for k in 1..=10 { + v.push(p(leak(format!("additionalBackground/background{k}")), leak(format!("Background {k}")), Value::Enum(0), mask_options())); + } + for side in ["Foreground", "Background"] { + for k in 1..=10 { + let g = format!("cleanup{side}/cleanup{side}{k}"); + v.push(p(leak(format!("{g}/path")), "Path", Value::Enum(0), mask_options())); + v.push(p(leak(format!("{g}/brushRadius")), "Brush Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1))); + v.push(p(leak(format!("{g}/brushPressure")), "Brush Pressure", num(80.0), pct())); + } + } + v.extend([ + p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)), + p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("edgeThreshold", "Edge Threshold", num(0.0), pct()), + p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox), + p("blendWithOriginal", "Blend with Original", num(0.0), pct()), + ]); + v + }, inner_outer_key, ), spec( @@ -478,4 +517,47 @@ mod tests { assert_eq!(out.img.get(2, 10), img.get(2, 10)); } } + + #[test] + fn inner_outer_key_more_masks_and_cleanup_strokes() { + let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false }; + // Masks 1..4: foreground squares; mask 5: a horizontal cleanup stroke across the middle. + let masks = vec![ + sq(1.0, 5.0), + sq(7.0, 11.0), + sq(13.0, 17.0), + sq(19.0, 23.0), + MaskShape { name: String::new(), points: vec![[0.0, 5.0], [30.0, 5.0]], closed: false, inverted: false }, + ]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::filled(30, 10, [0.5, 0.5, 0.5, 1.0]); + let vals = [ + ("foreground", Value::Enum(1)), + ("additionalForeground/foreground1", Value::Enum(2)), + ("additionalForeground/foreground2", Value::Enum(3)), + ("additionalForeground/foreground5", Value::Enum(4)), + ]; + let out = run_fx("ec.key.innerouter", &vals, img.clone(), 0.0, env).img; + for x in [3, 9, 15, 21] { + assert_eq!(out.get(x, 3)[3], 1.0, "foreground square at {x}"); + } + assert_eq!(out.get(27, 3)[3], 0.0); + // Cleanup Background along mask 5 erases a band; Cleanup Foreground restores outside. + let mut bg = vals.to_vec(); + bg.extend([ + ("cleanupBackground/cleanupBackground1/path", Value::Enum(5)), + ("cleanupBackground/cleanupBackground1/brushRadius", num(1.5)), + ("cleanupBackground/cleanupBackground1/brushPressure", num(100.0)), + ]); + let erased = run_fx("ec.key.innerouter", &bg, img.clone(), 0.0, env).img; + assert!(erased.get(3, 5)[3] < 0.1 && erased.get(3, 2)[3] > 0.9); + let mut fg = vals.to_vec(); + fg.extend([ + ("cleanupForeground/cleanupForeground3/path", Value::Enum(5)), + ("cleanupForeground/cleanupForeground3/brushRadius", num(1.5)), + ("cleanupForeground/cleanupForeground3/brushPressure", num(100.0)), + ]); + let painted = run_fx("ec.key.innerouter", &fg, img, 0.0, env).img; + assert!(painted.get(27, 5)[3] > 0.9 && painted.get(27, 2)[3] < 0.1); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 90a85a6d..ba0b9064 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -626,6 +626,30 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("options", "Options"), // Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`). ("ultraSettings", "Ultra Settings"), + ("additionalForeground", "Additional Foreground"), + ("additionalBackground", "Additional Background"), + ("cleanupForeground", "Cleanup Foreground"), + ("cleanupBackground", "Cleanup Background"), + ("cleanupForeground1", "Cleanup Foreground 1"), + ("cleanupForeground2", "Cleanup Foreground 2"), + ("cleanupForeground3", "Cleanup Foreground 3"), + ("cleanupForeground4", "Cleanup Foreground 4"), + ("cleanupForeground5", "Cleanup Foreground 5"), + ("cleanupForeground6", "Cleanup Foreground 6"), + ("cleanupForeground7", "Cleanup Foreground 7"), + ("cleanupForeground8", "Cleanup Foreground 8"), + ("cleanupForeground9", "Cleanup Foreground 9"), + ("cleanupForeground10", "Cleanup Foreground 10"), + ("cleanupBackground1", "Cleanup Background 1"), + ("cleanupBackground2", "Cleanup Background 2"), + ("cleanupBackground3", "Cleanup Background 3"), + ("cleanupBackground4", "Cleanup Background 4"), + ("cleanupBackground5", "Cleanup Background 5"), + ("cleanupBackground6", "Cleanup Background 6"), + ("cleanupBackground7", "Cleanup Background 7"), + ("cleanupBackground8", "Cleanup Background 8"), + ("cleanupBackground9", "Cleanup Background 9"), + ("cleanupBackground10", "Cleanup Background 10"), ("fillAndStroke", "Fill and Stroke"), ("pathOptions", "Path Options"), ("controlPoints", "Control Points"), diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index cba69cbe..02b51dfb 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -38,6 +38,10 @@ pub const PARAM_ID_ALIASES: &[(&str, &str, &str)] = &[ ("ec.matte.rotobrush", "refineEdgeMatte/extendWhereSmoothed", "refineEdgeMatte/decontamination/extendWhereSmoothed"), ("ec.matte.rotobrush", "refineEdgeMatte/increaseDecontaminationRadius", "refineEdgeMatte/decontamination/increaseDecontaminationRadius"), ("ec.matte.rotobrush", "refineEdgeMatte/viewDecontaminationMap", "refineEdgeMatte/decontamination/viewDecontaminationMap"), + // Keying: Inner/Outer Key's single additional masks became Additional Foreground / + // Background 1 (of 10). + ("ec.key.innerouter", "additionalForeground", "additionalForeground/foreground1"), + ("ec.key.innerouter", "additionalBackground", "additionalBackground/background1"), // Generate: Radio Waves' growth speed was called Velocity; Velocity now moves the wave. ("ec.generate.radiowaves", "velocity", "waveMotion/expansion"), ]; @@ -153,6 +157,8 @@ pub fn legacy_default(effect: &str, param: &str) -> Option { ("ec.noise.removegrain", "noiseReductionSettings/mode") => Value::Enum(1), // Audio: the Compressor's release was always manual. ("ec.audio.compressor", "autoRelease") => Value::Bool(false), + // Keying: Advanced Spill Suppressor's Ultra mode had no hue tolerance. + ("ec.key.advancedspill", "ultraSettings/tolerance") => Value::Scalar(100.0), // Transition: Block Dissolve's blocks had hard edges. ("ec.transition.blockdissolve", "softEdges") => Value::Bool(false), _ => return None, @@ -435,4 +441,18 @@ mod tests { assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0)); assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds"); } + + #[test] + fn inner_outer_key_additional_masks_move_into_their_groups() { + let spec = find("ec.key.innerouter").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Inner/Outer Key", [100.0, 50.0]); + let mut pr = take_at(&mut g, "additionalForeground/foreground1").unwrap(); + pr.match_id = "additionalForeground".into(); + pr.value = Value::Enum(3); + g.children.push(pr.into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert!(g.get("additionalForeground").is_none()); + assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3)); + } } diff --git a/docs/effects.md b/docs/effects.md index 8bb8d9e4..8b57bc17 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2773 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 2854 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**: 287 implemented in full, 16 partial (what is missing is listed). +- **Status**: 290 implemented in full, 13 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -197,7 +197,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | | CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | -| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Partial: HQ variants render like the standard ones | +| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Implemented | | Checkerboard | `ec.generate.checkerboard` | 10 | | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | @@ -235,13 +235,13 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Advanced Spill Suppressor | `ec.key.advancedspill` | 5 | | 32 | Partial: no Tolerance, Desaturate or Spill Color Correction | +| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | | 32 | Implemented | | CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | | 32 | Implemented | | Color Difference Key | `ec.key.colordifference` | 16 | | 32 | Implemented | | Color Range | `ec.key.colorrange` | 8 | | 32 | Implemented | | Difference Matte | `ec.key.differencematte` | 6 | | 32 | Implemented | | Extract | `ec.key.extract` | 6 | | 32 | Implemented | -| Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes | +| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented | | Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | | Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | | Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented | From e44af1f7e8aa8bcfac147b842c9a542efb1fa4b2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:15:53 -0400 Subject: [PATCH 11/17] M9.13: Keylight colour suppression / balancing and source crop methods, Scribble caps / joins / Start-End Apply To Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 2 - crates/effects/src/generate3.rs | 206 +++++++++++++++++++++++++++++++- crates/effects/src/keylight.rs | 178 ++++++++++++++++++++++++++- docs/effects.md | 8 +- 4 files changed, 380 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 8c501a3c..b6e6b40c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -11,8 +11,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.scribble", "no caps, joins or Start/End Apply To"), - ("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"), ("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 658ce04c..fe0b14a2 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -466,6 +466,141 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; *out = merged; } +/// End Cap and Join options (Scribble's Edge Options). +const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; +const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; + +/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: +/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the +/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). +pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { + let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { + (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() + }; + let mut out = Vec::new(); + for l in lines { + let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { + if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { + v.push(p); + } + v + }); + if pts.len() < 2 { + if let (Some(p), 0) = (pts.first(), cap) { + out.push(disc(*p)); + } + continue; + } + let dir = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + [dx / l, dy / l] + }; + let n = pts.len(); + for k in 0..n - 1 { + let (mut a, mut b) = (pts[k], pts[k + 1]); + let d = dir(a, b); + // Projecting caps extend the end segments by the half width. + if cap == 2 && k == 0 { + a = [a[0] - d[0] * r, a[1] - d[1] * r]; + } + if cap == 2 && k == n - 2 { + b = [b[0] + d[0] * r, b[1] + d[1] * r]; + } + let nn = [-d[1] * r, d[0] * r]; + out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); + } + // Joins. + for k in 1..n - 1 { + let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); + let cross = d0[0] * d1[1] - d0[1] * d1[0]; + if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { + continue; + } + if join == 0 { + out.push(disc(p)); + continue; + } + // The outer side of the turn. + let s = if cross > 0.0 { -1.0 } else { 1.0 }; + let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; + let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; + let bevel = vec![p, o0, o1]; + if join == 1 { + // Miter point: where the two offset edges meet. + let bis = [d0[0] - d1[0], d0[1] - d1[1]]; + let bl = bis[0].hypot(bis[1]); + let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); + let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; + if bl > 1e-9 && len <= miter_limit * r { + let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; + let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); + let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; + out.push(vec![p, o0, tip, o1]); + continue; + } + } + out.push(bevel); + } + if cap == 0 { + out.push(disc(pts[0])); + out.push(disc(pts[n - 1])); + } + } + out +} + +/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. +pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { + let mut out = Plane::new(w, h); + let inside = |poly: &[[f64; 2]], x: f64, y: f64| { + let n = poly.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); + if c.abs() < 1e-12 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true + }; + for poly in polys { + if poly.len() < 3 { + continue; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in poly { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); + let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); + for y in ya..yb { + for x in xa..xb { + let mut hit = 0; + for sy in 0..4 { + for sx in 0..4 { + if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { + hit += 1; + } + } + } + let v = &mut out.data[y * w + x]; + *v = v.max(hit as f32 / 16.0); + } + } + } + out +} + fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let which = pr.e("scribble"); @@ -585,19 +720,33 @@ fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); } let r = width * 0.5; - let mut segs = Vec::new(); + // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. + let each = pr.e("startEndApplyTo") == 1; + let mut parts: Vec> = Vec::new(); for group in &lines { let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); let total: f64 = lens.iter().sum(); let (g0, g1) = (total * start, total * end); let mut acc = 0.0; for (l, len) in group.iter().zip(lens) { - let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)); + let part = + if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) }; acc += len; - poly_segs(&part, false, r, 1.0, &mut segs); + parts.push(part); } } - let cov = raster_segs(w, h, &segs, 0.9); + let cap = pr.e("edgeOptions/endCap"); + let join = pr.e("edgeOptions/join"); + let cov = if cap == 0 && join == 0 { + let mut segs = Vec::new(); + for part in &parts { + poly_segs(part, false, r, 1.0, &mut segs); + } + raster_segs(w, h, &segs, 0.9) + } else { + let polys = stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0)); + raster_convex(w, h, &polys) + }; let color = pr.color("color"); paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style); b @@ -1467,6 +1616,9 @@ pub fn specs() -> Vec { mask_idx("mask", "Mask", 1.0), p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), + p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), + p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)), p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), p("opacity", "Opacity", num(100.0), pct()), p("angle", "Angle", num(45.0), ang()), @@ -1479,6 +1631,7 @@ pub fn specs() -> Vec { p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), p("start", "Start", num(0.0), pct()), p("end", "End", num(100.0), pct()), + p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), check("fillPathsSequentially", "Fill Paths Sequentially", true), p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), @@ -1936,4 +2089,49 @@ mod tests { let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; assert_eq!(peak, 8); } + + #[test] + fn stroke_outline_caps_and_joins() { + let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]]; + let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0)); + // Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square. + let (round, butt, proj) = (cov(0), cov(1), cov(2)); + assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9); + assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner"); + // A right-angle turn: Miter fills the outer corner, Bevel cuts it. + let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]]; + let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0)); + let (miter, bevel) = (j(1), j(2)); + assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32)); + // A tight miter limit falls back to a bevel. + let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0)); + assert!(limited.get(32, 32) < 0.1); + } + + #[test] + fn scribble_caps_and_start_end_apply_to() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))]; + let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env); + let butt = run_env( + "ec.generate.scribble", + &[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(), + img.clone(), + 0.0, + env, + ); + assert_ne!(round.img.data, butt.img.data); + // Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over. + let two = vec![ + crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false }, + crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false }, + ]; + let env2 = EffectEnv { masks: &two, ..Default::default() }; + let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))]; + let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2); + let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2); + assert_ne!(all.img.data, each.img.data); + } } diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs index 96a4483a..92a7d74f 100644 --- a/crates/effects/src/keylight.rs +++ b/crates/effects/src/keylight.rs @@ -126,6 +126,104 @@ fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] { c.map(|v| ((v - 0.5) * contrast + 0.5) * bright) } +/// Colour Suppression choices. +const SUPPRESS: [&str; 7] = ["None", "Red", "Green", "Blue", "Cyan", "Magenta", "Yellow"]; +/// Source Crops' X / Y Method choices. +const CROP_METHODS: [&str; 4] = ["Colour", "Repeat", "Reflect", "Wrap"]; + +/// (colour, balance 0..1, amount 0..1) of a Colour Suppression setting. +fn suppression(ctx: &EffectCtx, kind: &str, bal: &str, amt: &str) -> (u32, f32, f32) { + (ctx.params.e(kind), (ctx.params.get(bal).map(Value::as_f64).unwrap_or(50.0) / 100.0) as f32, (ctx.params.f(amt) / 100.0) as f32) +} + +/// Colour Suppression: remove the excess of a primary (over a Balance-weighted mix of the other +/// two) or of a secondary (the smaller of its two primaries over the third). +fn suppress(mut c: [f32; 3], (kind, bal, amt): (u32, f32, f32)) -> [f32; 3] { + if kind == 0 || amt <= 0.0 { + return c; + } + match kind { + 1..=3 => { + let k = kind as usize - 1; + let (a, b) = ((k + 1) % 3, (k + 2) % 3); + let limit = c[a] * bal + c[b] * (1.0 - bal); + c[k] -= (c[k] - limit).max(0.0) * amt; + } + _ => { + // Cyan = G + B over R, Magenta = R + B over G, Yellow = R + G over B. + let third = match kind { + 4 => 0, + 5 => 1, + _ => 2, + }; + let (a, b) = ((third + 1) % 3, (third + 2) % 3); + let excess = (c[a].min(c[b]) - c[third]).max(0.0) * amt; + c[a] -= excess; + c[b] -= excess; + } + } + c +} + +/// (tint offset) of a Colour Balancing wheel: hue (degrees) and saturation (0..1). +fn balance(ctx: &EffectCtx, hue: &str, sat: &str) -> [f32; 3] { + let s = (ctx.params.f(sat) / 100.0) as f32; + if s <= 0.0 { + return [0.0; 3]; + } + let (r, g, b) = effectcraft_color::hsl_to_rgb((ctx.params.f(hue) / 360.0).rem_euclid(1.0) as f32, 1.0, 0.5); + let l = luminance(r, g, b); + [(r - l) * s * 0.5, (g - l) * s * 0.5, (b - l) * s * 0.5] +} + +/// Colour Balancing: shift the colour towards the wheel's hue, keeping its luminance. +fn colour_balance(c: [f32; 3], tint: [f32; 3]) -> [f32; 3] { + if tint == [0.0; 3] { + return c; + } + [c[0] + tint[0], c[1] + tint[1], c[2] + tint[2]].map(|v| v.max(0.0)) +} + +/// The layer with Source Crops applied: inside the crop the picture, outside it the Edge Colour +/// (X / Y Method Colour) or the kept picture repeated, reflected or wrapped. +fn crop_source(ctx: &EffectCtx, b: &Buf) -> Image { + let pr = ctx.params; + let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0); + if crops.iter().all(|c| *c <= 0.0) { + return b.img.clone(); + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let (x0, x1) = (b.offset[0] + crops[0] * lw, b.offset[0] + (1.0 - crops[1]) * lw); + let (y0, y1) = (b.offset[1] + crops[2] * lh, b.offset[1] + (1.0 - crops[3]) * lh); + let methods = [pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")]; + let ec = pr.color("sourceCrops/edgeColour"); + let ea = (pr.f("sourceCrops/edgeColourAlpha") / 100.0) as f32; + let edge = premul([ec[0], ec[1], ec[2]], ea); + // Fold a coordinate into [lo, hi) by a method; None = outside with Colour. + let fold = |v: f64, lo: f64, hi: f64, m: u32| -> Option { + let span = (hi - lo).max(1.0); + if v >= lo && v < hi { + return Some(v); + } + match m { + 1 => Some(v.clamp(lo, hi - 0.5)), + 2 => { + let t = (v - lo).rem_euclid(2.0 * span); + Some(lo + if t >= span { 2.0 * span - t - 0.5 } else { t }) + } + 3 => Some(lo + (v - lo).rem_euclid(span)), + _ => None, + } + }; + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + match (fold(fx, x0, x1, methods[0]), fold(fy, y0, y1, methods[1])) { + (Some(sx), Some(sy)) => b.img.get_clamped(sx.floor() as i64, sy.floor() as i64), + _ => edge, + } + }) +} + fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let view = pr.e("view"); @@ -151,7 +249,9 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale; let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale; - let src = b.img.clone(); + // Source Crops: the cropped borders take the Edge Colour, or repeat / reflect / wrap the + // kept picture (X Method / Y Method), before keying. + let src = crop_source(ctx, &b); let blurred; let key_src: &Image = if preblur > 0.0 { blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true); @@ -247,6 +347,22 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { pr.f("edgeColourCorrection/edgeBrightness") as f32 / 100.0 + 1.0, ); let unpremultiply = pr.b("unpremultiplyResult"); + let (x_method, y_method) = (pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")); + let edge_alpha = pr.f("sourceCrops/edgeColourAlpha") as f32 / 100.0; + let fg_sup = suppression( + ctx, + "foregroundColourCorrection/colourSuppression", + "foregroundColourCorrection/suppressionBalance", + "foregroundColourCorrection/suppressionAmount", + ); + let fg_bal = balance(ctx, "foregroundColourCorrection/colourBalanceHue", "foregroundColourCorrection/colourBalanceSaturation"); + let edge_sup = suppression( + ctx, + "edgeColourCorrection/edgeColourSuppression", + "edgeColourCorrection/edgeSuppressionBalance", + "edgeColourCorrection/edgeSuppressionAmount", + ); + let edge_bal = balance(ctx, "edgeColourCorrection/edgeColourBalanceHue", "edgeColourCorrection/edgeColourBalanceSaturation"); let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0); let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); let w = b.img.width as usize; @@ -258,7 +374,7 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { // Source crops (layer fractions from each edge). let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9)); - let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]; + let cropped = (lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]) && edge_alpha <= 0.0 && x_method == 0 && y_method == 0; // Screen removal on the raw (unclipped) transparency, then despill. let ar = raw.data[i].max(1e-4); let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0)); @@ -287,11 +403,11 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { fg = apply_replace(fg, replace, rc, raised); fg = apply_replace(fg, in_replace, in_rc, raised_in); if fg_cc { - fg = correct(fg, fs, fc, fbr); + fg = colour_balance(suppress(correct(fg, fs, fc, fbr), fg_sup), fg_bal); } let edge = edges.as_ref().map_or(0.0, |e| e[i]); if edge > 0.0 { - let ce = correct(fg, es, ec, ebr); + let ce = colour_balance(suppress(correct(fg, es, ec, ebr), edge_sup), edge_bal); fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge); } let grey = |v: f32| [v, v, v, 1.0]; @@ -380,6 +496,11 @@ pub fn specs() -> Vec { p("foregroundColourCorrection/saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), cc("foregroundColourCorrection/contrast", "Contrast"), cc("foregroundColourCorrection/brightness", "Brightness"), + p("foregroundColourCorrection/colourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)), + pp("foregroundColourCorrection/suppressionBalance", "Suppression Balance", pct(50.0)), + pp("foregroundColourCorrection/suppressionAmount", "Suppression Amount", pct(0.0)), + p("foregroundColourCorrection/colourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle), + pp("foregroundColourCorrection/colourBalanceSaturation", "Colour Balance Saturation", pct(0.0)), // Edge Colour Correction p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox), pp("edgeColourCorrection/edgeHardness", "Edge Hardness", pct(0.0)), @@ -388,7 +509,16 @@ pub fn specs() -> Vec { p("edgeColourCorrection/edgeSaturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), cc("edgeColourCorrection/edgeContrast", "Contrast"), cc("edgeColourCorrection/edgeBrightness", "Brightness"), + p("edgeColourCorrection/edgeColourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)), + pp("edgeColourCorrection/edgeSuppressionBalance", "Suppression Balance", pct(50.0)), + pp("edgeColourCorrection/edgeSuppressionAmount", "Suppression Amount", pct(0.0)), + p("edgeColourCorrection/edgeColourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle), + pp("edgeColourCorrection/edgeColourBalanceSaturation", "Colour Balance Saturation", pct(0.0)), // Source Crops + p("sourceCrops/xMethod", "X Method", Value::Enum(0), popup(&CROP_METHODS)), + p("sourceCrops/yMethod", "Y Method", Value::Enum(0), popup(&CROP_METHODS)), + p("sourceCrops/edgeColour", "Edge Colour", col(0.0, 0.0, 0.0), ParamUi::Color), + pp("sourceCrops/edgeColourAlpha", "Edge Colour Alpha", pct(0.0)), pp("sourceCrops/cropLeft", "Left", pct(0.0)), pp("sourceCrops/cropRight", "Right", pct(0.0)), pp("sourceCrops/cropTop", "Top", pct(0.0)), @@ -500,4 +630,44 @@ mod tests { assert_eq!(g.sub("insideMask").and_then(|s| s.get("insideMask")).map(|p| p.name.as_str()), Some("Inside Mask")); assert!(g.get("clipWhite").is_none()); } + + #[test] + fn colour_suppression_balancing_and_crop_methods() { + // Suppress Red pulls red down to the balance of green and blue; Cyan the shared G+B. + assert!((suppress([0.9, 0.3, 0.1], (1, 0.5, 1.0))[0] - 0.2).abs() < 1e-6); + let c = suppress([0.1, 0.8, 0.6], (4, 0.5, 1.0)); + assert!((c[1] - 0.3).abs() < 1e-6 && (c[2] - 0.1).abs() < 1e-6); + assert_eq!(suppress([0.9, 0.3, 0.1], (1, 0.5, 0.0)), [0.9, 0.3, 0.1]); + // Foreground colour correction with suppression / balancing changes the keyed colour. + let base = [("screenColour", green()), ("foregroundColourCorrection/enableColourCorrection", Value::Bool(true))]; + let run = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + run_fx("ec.keying.keylight", &v, plate(), 0.0, EffectEnv::default()).img.get(15, 15) + }; + let plain = run(&[]); + let sup = run(&[("foregroundColourCorrection/colourSuppression", Value::Enum(1)), ("foregroundColourCorrection/suppressionAmount", num(100.0))]); + assert!(sup[0] < plain[0] - 0.1, "{sup:?} vs {plain:?}"); + let bal = run(&[("foregroundColourCorrection/colourBalanceHue", num(240.0)), ("foregroundColourCorrection/colourBalanceSaturation", num(100.0))]); + assert!(bal[2] > plain[2] + 0.05); + // Source Crops: Colour fills the cropped border; Repeat copies the kept edge. + let crop = [("screenColour", green()), ("sourceCrops/cropLeft", num(10.0)), ("view", Value::Enum(1))]; + let filled = + run_fx("ec.keying.keylight", &[crop.as_slice(), &[("sourceCrops/edgeColourAlpha", num(100.0))]].concat(), plate(), 0.0, EffectEnv::default()).img; + assert_eq!(filled.get(1, 15)[0], 1.0, "edge colour alpha shows in the source alpha view"); + let src = crop_source( + &crate::EffectCtx { + params: &crate::Params { + values: [("sourceCrops/cropLeft".to_string(), num(25.0)), ("sourceCrops/xMethod".to_string(), Value::Enum(1))].into_iter().collect(), + }, + time: 0.0, + layer_size: [40.0, 30.0], + seed: 0, + adjustment: false, + env: Default::default(), + }, + &Buf { img: plate(), offset: [0.0; 2], scale: 1.0 }, + ); + assert_eq!(src.get(2, 15), plate().get(10, 15), "repeat"); + } } diff --git a/docs/effects.md b/docs/effects.md index 8b57bc17..7f5e0aa7 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2854 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 2872 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**: 290 implemented in full, 13 partial (what is missing is listed). +- **Status**: 292 implemented in full, 11 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -209,7 +209,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | | Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented | -| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To | +| Scribble | `ec.generate.scribble` | 25 | | 32 | Implemented | | Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented | | Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented | | Write-on | `ec.generate.writeon` | 10 | | 32 | Implemented | @@ -243,7 +243,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Extract | `ec.key.extract` | 6 | | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented | | Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | -| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | +| Key Light | `ec.keying.keylight` | 56 | | 32 | Implemented | | Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented | | Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | | Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | From 05e3861b8b707972346f8279c907c6da64059289 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:20:40 -0400 Subject: [PATCH 12/17] M9.13: Rolling Shutter Repair Pixel Motion Detail, Reshape correspondence points Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 2 - crates/effects/src/distort3.rs | 74 +++++++++++++++++++++++++++++++++- crates/effects/src/distort4.rs | 33 +++++++++++++-- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index b6e6b40c..521d2e7c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -8,8 +8,6 @@ use crate::{CATEGORIES, registry}; /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. 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.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 46ca1a66..e1f0a898 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -428,6 +428,42 @@ fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- Reshape +/// Reshape's correspondence points: "s,d s,d …" (outline fractions, sorted by destination). +fn parse_correspondence(s: &str) -> Vec<(f64, f64)> { + let mut v: Vec<(f64, f64)> = s + .split_whitespace() + .filter_map(|t| { + let (a, b) = t.split_once(',')?; + Some((a.trim().parse::().ok()?.rem_euclid(1.0), b.trim().parse::().ok()?.rem_euclid(1.0))) + }) + .collect(); + v.sort_by(|a, b| a.1.total_cmp(&b.1)); + v +} + +/// The source outline fraction matching destination fraction `u` (piecewise linear between +/// correspondence pairs; around the loop for closed shapes). +fn map_fraction(pairs: &[(f64, f64)], u: f64, closed: bool) -> f64 { + let n = pairs.len(); + if n == 1 { + return (pairs[0].0 + (u - pairs[0].1)).rem_euclid(1.0); + } + if !closed { + // Open paths: clamp beyond the first / last pair (ends map to ends). + let ext: Vec<(f64, f64)> = std::iter::once((0.0, 0.0)).chain(pairs.iter().copied()).chain(std::iter::once((1.0, 1.0))).collect(); + let k = ext.iter().rposition(|p| p.1 <= u).unwrap_or(0).min(ext.len() - 2); + let (a, b) = (ext[k], ext[k + 1]); + let t = if b.1 > a.1 { (u - a.1) / (b.1 - a.1) } else { 0.0 }; + return (a.0 + (b.0 - a.0) * t).clamp(0.0, 1.0); + } + let k = pairs.iter().rposition(|p| p.1 <= u).unwrap_or(n - 1); + let (a, b) = (pairs[k], pairs[(k + 1) % n]); + let du = (b.1 - a.1).rem_euclid(1.0).max(1e-9); + let ds = (b.0 - a.0).rem_euclid(1.0); + let t = (u - a.1).rem_euclid(1.0) / du; + (a.0 + ds * t).rem_euclid(1.0) +} + fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b }; let pct = ctx.params.f("percent") / 100.0; @@ -450,7 +486,25 @@ fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { } else { 0 }; - let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect(); + // Correspondence points ("source,destination" pairs of outline fractions 0..1) pin which + // point of the source outline goes to which point of the destination outline; in between + // the outlines are matched proportionally by length. Without them the starting point is + // chosen automatically (above). + let pairs = parse_correspondence(ctx.params.s("correspondencePoints")); + let src_at = |i: usize| -> [f64; 2] { + if pairs.is_empty() { + return s[(i + rot) % N]; + } + let u = map_fraction(&pairs, i as f64 / N as f64, sc && dc); + let len = crate::util::poly_length(&sm, sc); + crate::util::poly_point_at(&sm, sc, u * len).0 + }; + let disp: Vec<[f64; 2]> = (0..N) + .map(|i| { + let q = src_at(i); + [q[0] - d[i][0], q[1] - d[i][1]] + }) + .collect(); let smooth = ctx.params.e("interpolation") == 2; b.img = remap(&b.img, false, |x, y| { if let Some(bd) = &bound @@ -839,6 +893,8 @@ pub fn specs() -> Vec { p("percent", "Percent", num(100.0), pct()), p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)), p("interpolation", "Interpolation Method", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + // Correspondence points: "source,destination" outline fractions (0..1) per point. + p("correspondencePoints", "Correspondence Points", Value::Str(String::new()), ParamUi::Hidden), ], reshape, ), @@ -1029,6 +1085,22 @@ mod tests { assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); } + #[test] + fn reshape_correspondence_points_steer_the_match() { + // Matching outlines with a zero pair change nothing; a quarter-turn offset twists. + let im = img(40, 40); + let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]; + let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change"); + let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&twisted.img, &im) > 0.1); + // Fraction mapping. + assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9); + assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9); + assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9); + } + #[test] fn upscale_grows_about_centre() { let im = img(20, 10); diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index 5a218e66..c576fead 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -435,12 +435,15 @@ fn median(v: &mut [f64]) -> f64 { /// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same /// buffer grid). `per_row`: Warp method (one velocity per scanline band). -fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option { +/// `detail` (Pixel Motion Detail, 0..1; 0.2 = the standard grid) scales the number of motion +/// vectors the Pixel Motion method measures: more vectors follow finer local motion. +fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool, detail: f64) -> Option { use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track}; let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 }); let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4); let (w, h) = (pc.width(), pc.height()); - let n = if detailed { 24 } else { 12 }; + let base = if detailed { 24.0 } else { 12.0 }; + let n = if per_row { base as usize } else { (base * (0.5 + detail.clamp(0.0, 1.0) * 2.5)).round().clamp(4.0, 96.0) as usize }; let cell_w = w as f64 / n as f64; let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize; let cell_h = h as f64 / rows as f64; @@ -520,7 +523,17 @@ fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let per_row = ctx.params.e("advanced/method") == 0; - let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("advanced/detailedAnalysis"), per_row) else { return b }; + let Some(flow) = measure( + &b.img, + prev.as_ref(), + next.as_ref(), + 1.0, + ctx.params.b("advanced/detailedAnalysis"), + per_row, + ctx.params.f("advanced/pixelMotionDetail") / 100.0, + ) else { + return b; + }; let dir = ctx.params.e("scanDirection"); let src = b.img.clone(); let (w, h) = (b.img.width as f64, b.img.height as f64); @@ -740,6 +753,20 @@ mod tests { ); assert_eq!(out.img.data, again.img.data); } + // Pixel Motion Detail changes how many vectors Pixel Motion measures (and still repairs). + let detail = |d: f64| { + run_fx( + "ec.distort.rollingshutterrepair", + &[("rollingShutterRate", num(100.0)), ("advanced/method", Value::Enum(1)), ("advanced/pixelMotionDetail", num(d))], + skewed.clone(), + t, + env, + ) + .img + }; + let (fine, coarse) = (detail(100.0), detail(0.0)); + assert_ne!(fine.data, coarse.data); + assert!(err(&fine) < err(&skewed) * 0.5 && err(&coarse) < err(&skewed) * 0.5); let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default()); assert_eq!(none.img.data, skewed.data); } From af78c34b25e63431752d9163a2cfc7d6026d859b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:24:01 -0400 Subject: [PATCH 13/17] M9.13: Advanced Lightning Alpha Obstacle and Expert Settings Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 1 - crates/effects/src/generate2.rs | 164 ++++++++++++++++++++++++++++---- 2 files changed, 147 insertions(+), 18 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 521d2e7c..69d533d5 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -8,7 +8,6 @@ use crate::{CATEGORIES, registry}; /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"), ("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 2234fc6a..9617be04 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -639,7 +639,40 @@ type Seg = ((f64, f64), (f64, f64), f32); /// Advanced Lightning's Lightning Type options. const LIGHTNING_TYPES: [&str; 8] = ["Direction", "Strike", "Breaking", "Bouncy", "Omni", "Anywhere", "Vertical", "Two-Way Strike"]; -fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f64, decay: f32, rng: &mut Rng, out: &mut Vec) { +/// Advanced Lightning's Fractal Type options. +const FRACTAL_TYPES: [&str; 3] = ["Linear", "Semi-Linear", "Spline"]; + +/// How bolts grow: the general and Expert Settings. +struct BoltCfg<'a> { + turb: f64, + fork: f64, + decay: f32, + /// The layer's alpha (buffer grid) and Alpha Obstacle (−10..10; positive avoids opaque + /// areas, negative is drawn to them). + alpha: Option<&'a Plane>, + obstacle: f64, + main_only: bool, + /// Min Fork Distance (px): shorter stretches do not fork. + min_fork: f64, + /// Termination Threshold: branches fainter than this stop. + term: f32, + /// Zig-zag scale from Fractal Type (Linear 1, Semi-Linear 0.7, Spline 0.45 with an extra + /// subdivision level). + shape: f64, + fork_strength: f32, + fork_var: f32, +} + +impl BoltCfg<'_> { + fn alpha_at(&self, p: (f64, f64)) -> f32 { + self.alpha.map_or(0.0, |a| a.sample(p.0, p.1)) + } +} + +/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random. +/// Segments go to `out` with their intensity; `mains` marks the main core's segments. +#[allow(clippy::too_many_arguments)] +fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec, mains: &mut Vec) { if out.len() > 20_000 { return; } @@ -647,19 +680,38 @@ fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f let len = (dx * dx + dy * dy).sqrt(); if depth == 0 || len < 1.0 { out.push((a, z, inten)); + mains.push(main); return; } let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); - let off = rng.s() * len * 0.22 * turb; + let mut off = rng.s() * len * 0.22 * cfg.turb * cfg.shape; + // Alpha Obstacle: of the two mirror positions for the midpoint, prefer the one with less + // (positive) or more (negative) alpha, the more strongly the larger the setting. + if cfg.obstacle != 0.0 && cfg.alpha.is_some() && (main || !cfg.main_only) { + let mid = ((a.0 + z.0) * 0.5, (a.1 + z.1) * 0.5); + let p1 = (mid.0 + nx * off, mid.1 + ny * off); + let p2 = (mid.0 - nx * off, mid.1 - ny * off); + let (a1, a2) = (cfg.alpha_at(p1), cfg.alpha_at(p2)); + let worse = if cfg.obstacle > 0.0 { a1 > a2 } else { a1 < a2 }; + if worse && rng.f() < cfg.obstacle.abs() / 10.0 { + off = -off; + } + } let m = ((a.0 + z.0) * 0.5 + nx * off, (a.1 + z.1) * 0.5 + ny * off); - bolt(a, m, depth - 1, inten, turb, fork, decay, rng, out); - bolt(m, z, depth - 1, inten, turb, fork, decay, rng, out); - if rng.f() < fork && inten > 0.05 { + // Positive obstacles stop branches that run into opaque areas. + if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 { + return; + } + bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains); + bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains); + if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork { let ang = rng.s() * 0.7; let (s, c) = ang.sin_cos(); let (fx, fy) = ((m.0 - a.0) * c - (m.1 - a.1) * s, (m.0 - a.0) * s + (m.1 - a.1) * c); let k = 1.2; - bolt(m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), inten * (1.0 - decay), turb, fork, decay, rng, out); + // Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it. + let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 }; + bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains); } } @@ -671,26 +723,49 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { let turb = ctx.params.f("turbulence").max(0.0); let fork = (ctx.params.f("forking") / 100.0).clamp(0.0, 1.0); let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32; - let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32; + let fractal = ctx.params.e("expertSettings/fractalType"); + let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32 + u32::from(fractal == 2); let state = ctx.params.f("conductivityState").floor() as i64 as u64; let mut rng = Rng::new(state ^ ((ctx.seed as u64) << 32)); + let obstacle = ctx.params.f("alphaObstacle").clamp(-10.0, 10.0); + let alpha_plane = (obstacle != 0.0).then(|| Plane::alpha(&b.img)); + let g = |id: &str, dflt: f64| ctx.params.get(id).map(Value::as_f64).unwrap_or(dflt); + let cfg = BoltCfg { + turb, + fork, + decay, + alpha: alpha_plane.as_ref(), + obstacle, + main_only: ctx.params.b("expertSettings/mainCoreCollisionOnly"), + min_fork: g("expertSettings/minForkDistance", 0.0).max(0.0) * b.scale, + term: (g("expertSettings/terminationThreshold", 5.0) / 100.0).clamp(0.0, 1.0) as f32, + shape: match fractal { + 1 => 0.7, + 2 => 0.45, + _ => 1.0, + }, + fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32, + fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32, + }; let mut segs = Vec::new(); + let mut mains = Vec::new(); let (h, w) = (b.img.height as f64, b.img.width as f64); + let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains); // LIGHTNING_TYPES order. match kind { // Direction: travels toward the Direction point and beyond. 0 => { let z = (o.0 + (d.0 - o.0) * 3.0, o.1 + (d.1 - o.1) * 3.0); - bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs); + go(o, z, depth, &cfg, &mut rng); } // Breaking: more branching as the points move apart. 2 => { let dist = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt(); let f = (fork * (0.5 + dist / w.max(h).max(1.0))).min(1.0); - bolt(o, d, depth, 1.0, turb, f, decay, &mut rng, &mut segs); + go(o, d, depth, &BoltCfg { fork: f, ..cfg }, &mut rng); } // Bouncy: a strike with a stronger zig-zag. - 3 => bolt(o, d, depth, 1.0, turb * 1.6, fork, decay, &mut rng, &mut segs), + 3 => go(o, d, depth, &BoltCfg { turb: turb * 1.6, ..cfg }, &mut rng), // Omni / Anywhere: bolts in all directions out to the Outer Radius. 4 | 5 => { let len = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt().max(w.min(h) * 0.25); @@ -698,27 +773,37 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { let a = rng.f() * 2.0 * PI; let l = if kind == 5 { len * (0.3 + 0.7 * rng.f()) } else { len }; let z = (o.0 + a.cos() * l, o.1 + a.sin() * l); - bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs); + go(o, z, depth, &cfg, &mut rng); } } - 6 => bolt(o, (o.0, h), depth, 1.0, turb, fork, decay, &mut rng, &mut segs), + 6 => go(o, (o.0, h), depth, &cfg, &mut rng), // Two-Way Strike: from both ends toward the middle. 7 => { let m = ((o.0 + d.0) * 0.5, (o.1 + d.1) * 0.5); - bolt(o, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs); - bolt(d, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs); + go(o, m, depth.saturating_sub(1).max(1), &cfg, &mut rng); + go(d, m, depth.saturating_sub(1).max(1), &cfg, &mut rng); } // Strike. - _ => bolt(o, d, depth, 1.0, turb, fork, decay, &mut rng, &mut segs), + _ => go(o, d, depth, &cfg, &mut rng), } + let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); + let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max); // Decay Main Core: the whole bolt fades with distance from the origin. if decay_main { - let far = segs.iter().map(|(_, z, _)| ((z.0 - o.0).powi(2) + (z.1 - o.1).powi(2)).sqrt()).fold(1e-9, f64::max); for (a, _, i) in &mut segs { - let t = (((a.0 - o.0).powi(2) + (a.1 - o.1).powi(2)).sqrt() / far) as f32; + let t = (dist(*a) / far) as f32; *i *= (1.0 - decay * t).max(0.0); } } + // Core Drain: the main core loses intensity along its length. + let drain = (g("expertSettings/coreDrain", 0.0) / 100.0).clamp(0.0, 1.0) as f32; + if drain > 0.0 { + for ((a, _, i), m) in segs.iter_mut().zip(&mains) { + if *m { + *i *= (1.0 - drain * (dist(*a) / far) as f32).max(0.0); + } + } + } segs } @@ -1052,8 +1137,16 @@ pub fn specs() -> Vec { p("forking", "Forking", num(25.0), pct()), p("decay", "Decay", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)), p("decayMainCore", "Decay Main Core", Value::Bool(false), ParamUi::Checkbox), + p("alphaObstacle", "Alpha Obstacle", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 2)), p("compositeOnOriginal", "Composite On Original", Value::Bool(true), ParamUi::Checkbox), p("expertSettings/complexity", "Complexity", num(6.0), slider(1.0, 12.0, 1.0, 12.0, 0)), + p("expertSettings/minForkDistance", "Min Fork Distance", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("expertSettings/terminationThreshold", "Termination Threshold", num(5.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("expertSettings/mainCoreCollisionOnly", "Main Core Collision Only", Value::Bool(false), ParamUi::Checkbox), + p("expertSettings/fractalType", "Fractal Type", Value::Enum(0), popup(&FRACTAL_TYPES)), + p("expertSettings/coreDrain", "Core Drain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("expertSettings/forkStrength", "Fork Strength", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("expertSettings/forkVariation", "Fork Variation", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), ], advanced_lightning, ), @@ -1211,6 +1304,43 @@ mod tests { assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); } + #[test] + fn lightning_alpha_obstacle_and_expert_settings() { + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); + // A wall of opaque pixels across the middle of the bolt's path. + let mut wall = Image::new(64, 48); + for y in 0..48 { + for x in 0..64 { + if (18..30).contains(&y) && x < 50 { + wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + } + let opts = |o: f64| { + vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))] + }; + let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); + let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); + assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); + // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. + let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + for (id, v) in [ + ("expertSettings/fractalType", Value::Enum(2)), + ("expertSettings/coreDrain", num(100.0)), + ("expertSettings/forkStrength", num(100.0)), + ("expertSettings/forkVariation", num(100.0)), + ] { + assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); + } + let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); + assert!(sum(&drained.img) < sum(&base.img)); + let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); + let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); + assert_eq!(no_forks.img, term.img, "no forks either way"); + assert!(sum(&no_forks.img) < sum(&base.img)); + } + #[test] fn lightning_types_and_decay_main_core() { let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); From 4b97b44cac0a849a805dca468c2c647d45c01411 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:29:59 -0400 Subject: [PATCH 14/17] M9.13: Path Text kerning, shear / scale, line spacing, fade time and jitter Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 1 - crates/effects/src/lib.rs | 3 + crates/effects/src/textfx.rs | 174 +++++++++++++++++++++++++++------- docs/effects.md | 12 +-- 4 files changed, 150 insertions(+), 40 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 69d533d5..c243559a 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -8,7 +8,6 @@ use crate::{CATEGORIES, registry}; /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), ("ec.noise.removegrain", "no preview region, sampling or temporal filtering"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ba0b9064..bd6e035a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -657,6 +657,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("orientation", "Orientation"), ("paragraph", "Paragraph"), ("preview", "Preview"), + ("jitterSettings", "Jitter Settings"), // Generate. ("positionsColors", "Positions & Colors"), ("feather", "Feather"), @@ -737,6 +738,8 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.obsolete.lightning", "ec.text.timecode", "ec.text.numbers", + // Path Text's jitter changes every frame. + "ec.obsolete.pathtext", // Time effects read neighbouring frames of the layer. "ec.time.echo", "ec.time.posterizetime", diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index 4d938faf..87364f6b 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -508,49 +508,112 @@ fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf { closed = false; } let len = poly_length(&path, closed); - let text: String = pr.s("text").chars().take(g("advanced", "visibleCharacters").as_f64().max(0.0) as usize).collect(); + let visible = g("advanced", "visibleCharacters").as_f64().max(0.0); + let all: Vec = pr.s("text").chars().collect(); + let n_all = all.len(); let size = (g("character", "size").as_f64() * b.scale).max(0.5); let u = unit(size); let tracking = g("character", "tracking").as_f64() * b.scale; let lm = g("paragraph", "leftMargin").as_f64() * b.scale; let rm = g("paragraph", "rightMargin").as_f64() * b.scale; let shift = g("paragraph", "baselineShift").as_f64() * b.scale; + let line_spacing = pr.get("paragraph/lineSpacing").map(Value::as_f64).unwrap_or(size / b.scale * 1.2) * b.scale; let rot = g("character/orientation", "characterRotation").as_f64().to_radians(); - let n = text.chars().count(); - let advs: Vec = text.chars().map(|c| advance(c, true) * u).collect(); - let natural: f64 = advs.iter().sum::() + tracking * (n.max(1) as f64 - 1.0); - let avail = (len - lm - rm).max(0.0); - let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() { - 1 => (len - rm - natural, 0.0), - 2 => (lm + (avail - natural) * 0.5, 0.0), - 3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }), - _ => (lm, 0.0), + let shear = pr.f("character/orientation/characterShear").to_radians().tan(); + let hshear = pr.f("character/horizontalShear").to_radians().tan(); + let sx = pr.get("character/horizontalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0; + let sy = pr.get("character/verticalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0; + // Kerning: "index:value …" — the gap after character `index` (0-based) in 1/1000 em. + let kerning: Vec<(usize, f64)> = pr + .s("character/kerning") + .split_whitespace() + .filter_map(|t| { + let (i, v) = t.split_once(':')?; + Some((i.trim().parse().ok()?, v.trim().parse().ok()?)) + }) + .collect(); + let kern = |i: usize| kerning.iter().filter(|(k, _)| *k == i).map(|(_, v)| v / 1000.0 * size).sum::(); + // Jitter: per character, re-randomised every frame. + let jit = |id: &str| pr.f(&format!("advanced/jitterSettings/{id}")); + let (j_base, j_kern, j_rot, j_scale) = + (jit("baselineJitterMax") * b.scale, jit("kerningJitterMax") * b.scale, jit("rotationJitterMax").to_radians(), jit("scaleJitterMax") / 100.0); + let frame = (ctx.time * ctx.fps()).floor() as i64 as u32; + let rnd = |i: usize, k: u32| hash1(i as u32, k.wrapping_add(frame.wrapping_mul(7919)), 0x7e17) as f64 * 2.0 - 1.0; + // Fade Time: characters fade in over this share of the visible characters. + let fade = pr.f("advanced/fadeTime").clamp(0.0, 100.0) / 100.0 * n_all.max(1) as f64; + let char_alpha = |i: usize| -> f32 { + if fade <= 0.0 { if (i as f64) < visible.floor() { 1.0 } else { 0.0 } } else { ((visible - i as f64) / fade).clamp(0.0, 1.0) as f32 } }; - let mut polys = Vec::new(); - for (c, adv) in text.chars().zip(advs) { - let mid = cursor + adv * 0.5; - cursor += adv + tracking + extra; - if !closed && (mid < 0.0 || mid > len) { - continue; - } - let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid }); - let ang = d[1].atan2(d[0]) + rot; - let (s, co) = ang.sin_cos(); - let left = ink_left(c, true); - for st in parse_glyph(c) { - polys.push( - st.iter() - .map(|g| { - let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u; - let ly = (g[1] - 6.0) * u - shift; - [q[0] + lx * co - ly * s, q[1] + lx * s + ly * co] - }) - .collect(), - ); + // Lines (separated by line breaks) stack along the path's normal by Line Spacing. + let mut polys_by_alpha: Vec<(f32, Vec>)> = Vec::new(); + let mut gi = 0usize; + for (li, line) in all.split(|c| *c == '\n' || *c == '\r').enumerate() { + let idx0 = gi; + gi += line.len() + 1; + let n = line.len(); + let advs: Vec = line.iter().map(|&c| advance(c, true) * u * sx).collect(); + let kerns: Vec = (0..n).map(|k| kern(idx0 + k) + if j_kern != 0.0 { rnd(idx0 + k, 2) * j_kern } else { 0.0 }).collect(); + let natural: f64 = advs.iter().sum::() + tracking * (n.max(1) as f64 - 1.0) + kerns.iter().take(n.saturating_sub(1)).sum::(); + let avail = (len - lm - rm).max(0.0); + let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() { + 1 => (len - rm - natural, 0.0), + 2 => (lm + (avail - natural) * 0.5, 0.0), + 3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }), + _ => (lm, 0.0), + }; + let line_off = li as f64 * line_spacing; + for (k, (&c, adv)) in line.iter().zip(advs).enumerate() { + let i = idx0 + k; + let mid = cursor + adv * 0.5; + cursor += adv + tracking + extra + kerns[k]; + let alpha = char_alpha(i); + if alpha <= 0.0 || (!closed && (mid < 0.0 || mid > len)) { + continue; + } + let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid }); + let ang = d[1].atan2(d[0]) + rot + if j_rot != 0.0 { rnd(i, 3) * j_rot } else { 0.0 }; + let (s, co) = ang.sin_cos(); + let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 }; + let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 }; + let left = ink_left(c, true); + let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) { + Some((_, v)) => v, + None => { + polys_by_alpha.push((alpha, Vec::new())); + &mut polys_by_alpha.last_mut().expect("pushed").1 + } + }; + for st in parse_glyph(c) { + set.push( + st.iter() + .map(|gl| { + // Glyph space (baseline at 0, y down), then scale, shear and lift. + let gx = ((gl[0] - left - 0.8) * u + 0.8 * u) * sx * js - adv * 0.5; + let gy = (gl[1] - 6.0) * u * sy * js; + let gx = gx - gy * (shear + hshear); + let gy = gy - shift - jb + line_off; + [q[0] + gx * co - gy * s, q[1] + gx * s + gy * co] + }) + .collect(), + ); + } } } - let look = look_from(ctx, &b, "compositeOnOriginal"); - draw_text(&mut b, &polys, weight(size), &look); + let mut look = look_from(ctx, &b, "compositeOnOriginal"); + let base_op = look.opacity; + let mut first = true; + for (alpha, polys) in &polys_by_alpha { + look.opacity = base_op * alpha; + draw_text(&mut b, polys, weight(size), &look); + if first { + // Later passes go over the first one. + look.on_original = true; + first = false; + } + } + if polys_by_alpha.is_empty() { + draw_text(&mut b, &[], weight(size), &look); + } b } @@ -811,12 +874,24 @@ pub fn specs() -> Vec { p("fillAndStroke/strokeWidth", "Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), p("character/size", "Size", num(36.0), size()), p("character/tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + // Kerning: "index:value" pairs, the gap after character `index` in 1/1000 em. + p("character/kerning", "Kerning", Value::Str(String::new()), ParamUi::Text), p("character/orientation/characterRotation", "Character Rotation", num(0.0), ang()), + p("character/orientation/characterShear", "Character Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)), + p("character/horizontalShear", "Horizontal Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)), + p("character/horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)), + p("character/verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)), p("paragraph/alignment", "Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])), p("paragraph/leftMargin", "Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), p("paragraph/rightMargin", "Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("paragraph/lineSpacing", "Line Spacing", num(43.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)), p("paragraph/baselineShift", "Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), p("advanced/visibleCharacters", "Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)), + p("advanced/fadeTime", "Fade Time", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("advanced/jitterSettings/baselineJitterMax", "Baseline Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("advanced/jitterSettings/kerningJitterMax", "Kerning Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("advanced/jitterSettings/rotationJitterMax", "Rotation Jitter Max", num(0.0), slider(0.0, 360.0, 0.0, 360.0, 1)), + p("advanced/jitterSettings/scaleJitterMax", "Scale Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), check("compositeOnOriginal", "Composite On Original", true), ], path_text, @@ -1000,6 +1075,39 @@ mod tests { assert!(ink(&circle.img) > 10.0); } + #[test] + fn path_text_kerning_scale_lines_fade_and_jitter() { + let img = Image::new(160, 100); + let line = [ + ("pathOptions/shapeType", Value::Enum(3)), + ("pathOptions/controlPoints/vertex1", Value::Vec2([5.0, 40.0])), + ("pathOptions/controlPoints/vertex2", Value::Vec2([155.0, 40.0])), + ("character/size", num(20.0)), + ("text", Value::Str("AB".into())), + ]; + let with = |extra: &[(&str, Value)], t: f64| run("ec.obsolete.pathtext", &[line.as_slice(), extra].concat(), img.clone(), t).img; + let right_edge = |i: &Image| (0..160).rev().find(|&x| (0..100).any(|y| i.get(x, y)[3] > 0.3)).unwrap_or(0); + let base = with(&[], 0.0); + // Kerning after the first character pushes the second one along. + assert!(right_edge(&with(&[("character/kerning", Value::Str("0:500".into()))], 0.0)) >= right_edge(&base) + 8); + // Horizontal Scale widens, shear changes the shapes. + assert!(right_edge(&with(&[("character/horizontalScale", num(200.0))], 0.0)) > right_edge(&base) + 5); + assert_ne!(with(&[("character/horizontalShear", num(30.0))], 0.0), base); + // A second line sits Line Spacing below the first. + let two = with(&[("text", Value::Str("AB\rCD".into())), ("paragraph/lineSpacing", num(30.0))], 0.0); + let ink_rows = |i: &Image, y0: i64, y1: i64| (y0..y1).flat_map(|y| (0..160).map(move |x| (x, y))).map(|(x, y)| i.get(x, y)[3]).sum::(); + assert!(ink_rows(&two, 52, 72) > 5.0 && ink_rows(&base, 52, 72) < 0.5); + // Fade Time: the character being revealed is partly transparent. + let fading = with(&[("advanced/visibleCharacters", num(1.5)), ("advanced/fadeTime", num(50.0))], 0.0); + let total = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let (one, both) = (with(&[("advanced/visibleCharacters", num(1.0))], 0.0), with(&[("advanced/visibleCharacters", num(2.0))], 0.0)); + assert!(total(&fading) > total(&one) + 1.0 && total(&fading) < total(&both) - 1.0, "{} {} {}", total(&one), total(&fading), total(&both)); + // Jitter differs between frames but is repeatable. + let j = [("advanced/jitterSettings/baselineJitterMax", num(10.0))]; + assert_ne!(with(&j, 0.0), with(&j, 0.5)); + assert_eq!(with(&j, 0.5), with(&j, 0.5)); + } + #[test] fn lightning_deterministic_and_animates() { let img = Image::new(96, 64); diff --git a/docs/effects.md b/docs/effects.md index 7f5e0aa7..eb0ab1ed 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2872 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 2892 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**: 292 implemented in full, 11 partial (what is missing is listed). +- **Status**: 296 implemented in full, 7 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -157,9 +157,9 @@ Effects are addressed by id (`ec..`) or display name in commands | Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented | | Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | -| Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points | +| Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented | | Ripple | `ec.distort.ripple` | 7 | | 32 | Implemented | -| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect | +| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | | Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | @@ -188,7 +188,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented | -| Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing | +| Advanced Lightning | `ec.generate.advancedlightning` | 24 | | 32 | Implemented | | Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | | Beam | `ec.generate.beam` | 11 | | 32 | Implemented | @@ -287,7 +287,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented | | Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented | | Luma Key | `ec.key.luma` | 5 | | 32 | Implemented | -| Path Text | `ec.obsolete.pathtext` | 21 | | 32 | Partial: no kerning, shear/scale, line spacing, fade time or jitter | +| Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented | | Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented | | Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented | From aae00dfaca8a3b65c26669fdc64157bfa15b4ce7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:38:40 -0400 Subject: [PATCH 15/17] M9.13: Add / Match / Remove Grain preview region, presets, channel balance, sampling and temporal filtering Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 3 - crates/effects/src/lib.rs | 5 + crates/effects/src/migrate.rs | 12 ++ crates/effects/src/noise.rs | 320 ++++++++++++++++++++++++++++++++-- crates/effects/src/noise2.rs | 60 +++++-- docs/effects.md | 10 +- 6 files changed, 375 insertions(+), 35 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index c243559a..2828080c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -8,9 +8,6 @@ use crate::{CATEGORIES, registry}; /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), - ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), - ("ec.noise.removegrain", "no preview region, sampling or temporal filtering"), ("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bd6e035a..d4ea8b81 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -566,6 +566,9 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("fineTuning", "Fine Tuning"), ("unsharpMask", "Unsharp Mask"), ("noiseOptions", "Noise Options (Animation)"), + ("previewRegion", "Preview Region"), + ("sampling", "Sampling"), + ("temporalFiltering", "Temporal Filtering"), // Time: Timewarp. ("tuning", "Tuning"), ("motionBlur", "Motion Blur"), @@ -733,6 +736,8 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.stylize.strobe", "ec.noise.noise", "ec.noise.addgrain", + // Remove Grain's temporal filtering reads neighbouring frames. + "ec.noise.removegrain", "ec.noise.noisealpha", "ec.noise.noisehlsauto", "ec.obsolete.lightning", diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 02b51dfb..e5dc30ba 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -455,4 +455,16 @@ mod tests { assert!(g.get("additionalForeground").is_none()); assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3)); } + + #[test] + fn match_grain_viewing_modes_remap_by_label() { + let spec = find("ec.noise.matchgrain").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]); + let pr = g.get_mut("viewingMode").unwrap(); + pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] }; + pr.value = Value::Enum(0); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output"); + } } diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4555d16a..a543bf4e 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -59,6 +59,8 @@ pub(crate) struct GrainLook { /// Grain time (frames of grain at the animation speed) and whether it interpolates. pub frame: f32, pub seed: u32, + /// Color ▸ Red / Green / Blue Balance. + pub balance: [f32; 3], } impl GrainLook { @@ -84,7 +86,28 @@ impl GrainLook { midpoint: pr.get("application/midpoint").map(Value::as_f64).unwrap_or(0.5).clamp(0.01, 0.99) as f32, frame: if smooth && ft.fract() != 0.0 { ft as f32 } else { ft.floor() as f32 }, seed: (pr.f("animation/randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37), + balance: ch("color/", "redBalance", "greenBalance", "blueBalance"), } + .with_preset(pr.e("preset")) + } + + /// Apply Add Grain's Preset (a film-stock look on top of the controls). + fn with_preset(mut self, preset: u32) -> GrainLook { + // (intensity ×, size ×, softness +, saturation ×, monochrome) + let (ki, ks, soft, ksat, mono) = match preset { + 1 => (0.6, 0.7, 0.2, 0.6, false), + 2 => (1.3, 1.8, 0.4, 0.8, false), + 3 => (1.8, 2.5, 0.8, 0.5, false), + 4 => (1.0, 0.6, 0.0, 1.0, false), + 5 => (1.6, 1.4, 0.3, 1.0, true), + _ => return self, + }; + self.intensity *= ki; + self.size *= ks; + self.softness += soft; + self.saturation *= ksat; + self.mono |= mono; + self } /// Zero-mean noise planes (one, or one per channel) of grain `size` pixels (times each @@ -129,7 +152,7 @@ impl GrainLook { let l = luminance(self.tint[0], self.tint[1], self.tint[2]).max(1e-3); g = [0, 1, 2].map(|k| g[k] * ((1.0 - self.tint_amount) + self.tint_amount * self.tint[k] / l)); } - g + [g[0] * self.balance[0], g[1] * self.balance[1], g[2] * self.balance[2]] } /// Combine source channel `c` with signed grain `n` by the Blending Mode. @@ -171,6 +194,9 @@ pub(crate) fn grain_params() -> (Vec, Vec, V p("color/saturation", "Saturation", num(1.0), mult()), p("color/tintAmount", "Tint Amount", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), p("color/tintColor", "Tint Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color), + p("color/redBalance", "Red Balance", num(1.0), mult()), + p("color/greenBalance", "Green Balance", num(1.0), mult()), + p("color/blueBalance", "Blue Balance", num(1.0), mult()), ]; let application = vec![ p("application/blendingMode", "Blending Mode", Value::Enum(0), popup(&GRAIN_BLEND_MODES)), @@ -187,27 +213,124 @@ pub(crate) fn grain_params() -> (Vec, Vec, V (tweaking, color, application, animation) } +/// Add Grain's Viewing Mode options. +pub(crate) const ADD_GRAIN_VIEWS: [&str; 3] = ["Preview", "Blending Matte", "Final Output"]; +/// Add Grain's Preset options (our own film-stock looks). +pub(crate) const GRAIN_PRESETS: [&str; 6] = ["None", "Fine 35mm", "Coarse 16mm", "Vintage 8mm", "Video Noise", "Monochrome High Speed"]; + +/// The Preview Region controls of the grain effects (centre, size, box display). +pub(crate) fn preview_params() -> Vec { + vec![ + p("previewRegion/center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("previewRegion/width", "Width", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)), + p("previewRegion/height", "Height", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)), + p("previewRegion/showBox", "Show Box", Value::Bool(true), ParamUi::Checkbox), + p("previewRegion/boxColor", "Box Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color), + ] +} + +/// Preview viewing mode: the processed pixels inside the Preview Region, the original +/// outside, and (Show Box) the region's outline. +pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) { + let c = b.to_px(ctx.params.v2("previewRegion/center")); + let (hw, hh) = (ctx.params.f("previewRegion/width") * b.scale * 0.5, ctx.params.f("previewRegion/height") * b.scale * 0.5); + let (x0, x1, y0, y1) = (c.0 - hw, c.0 + hw, c.1 - hh, c.1 + hh); + let show = ctx.params.b("previewRegion/showBox"); + let col = ctx.params.color("previewRegion/boxColor"); + let w = b.img.width as usize; + b.img.data.par_iter_mut().zip(orig.data.par_iter()).enumerate().for_each(|(i, (px, o))| { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let inside = x >= x0 && x < x1 && y >= y0 && y < y1; + if !inside { + *px = *o; + } + if show { + let near = |v: f64, e: f64| (v - e).abs() < 0.75; + let on = ((near(x, x0) || near(x, x1)) && y >= y0 - 0.75 && y < y1 + 0.75) || ((near(y, y0) || near(y, y1)) && x >= x0 - 0.75 && x < x1 + 0.75); + if on { + *px = [col[0], col[1], col[2], 1.0]; + } + } + }); +} + +/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of +/// candidate boxes (real grain shows best where the picture has no detail). +pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> { + let (w, h) = (img.width as usize, img.height as usize); + let size = size.clamp(3, w.min(h).max(3)); + if w < size || h < size || count == 0 { + return vec![]; + } + let low = Plane::luma(img); + let step = (size / 2).max(1); + let mut cands: Vec<(f32, usize, usize)> = Vec::new(); + for y in (0..=h - size).step_by(step) { + for x in (0..=w - size).step_by(step) { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = low.data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= img.data[yy * w + xx][3] > 0.5; + } + } + if opaque { + cands.push((sq / n - (s / n).powi(2), x, y)); + } + } + } + cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); + cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() +} + +/// Draw sample boxes' outlines (Noise Samples viewing mode). +pub(crate) fn draw_boxes(img: &mut Image, boxes: &[(usize, usize, usize)], c: [f32; 4]) { + for &(x, y, s) in boxes { + for k in 0..s { + for (px, py) in [(x + k, y), (x + k, y + s - 1), (x, y + k), (x + s - 1, y + k)] { + img.set(px as u32, py as u32, [c[0], c[1], c[2], 1.0]); + } + } + } +} + // ---- Add Grain ---- fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let look = GrainLook::from_params(ctx); - if look.intensity <= 0.0 { + // Viewing Mode (Final Output for projects saved before it existed). + let view = ctx.params.get("viewingMode").map(|v| v.as_enum()).unwrap_or(2); + if look.intensity <= 0.0 && view != 1 { return b; } + let orig = b.img.clone(); let (w, h) = (b.img.width as usize, b.img.height as usize); let planes = look.planes(w, h, look.size * b.scale as f32, b.scale, 101); let amp = look.intensity * 0.25; b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let (c, a) = unpremul(*px); + let weight = look.weight(c); + if view == 1 { + // Blending Matte: where (and how strongly) the grain applies. + let v = weight.clamp(0.0, 1.0); + *px = [v, v, v, 1.0]; + return; + } if a <= 0.0 { return; } let raw = if look.mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; let g = look.color(raw); - let weight = look.weight(c); let o = [0, 1, 2].map(|k| look.blend(c[k], g[k] * amp * look.channel_intensity[k] * weight)); *px = premul(o, a); }); + if view == 0 { + preview_compose(ctx, &mut b, &orig); + } b } @@ -333,18 +456,104 @@ fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Remove Grain's Viewing Mode options. +pub(crate) const REMOVE_GRAIN_VIEWS: [&str; 4] = ["Preview", "Noise Samples", "Blending Matte", "Final Output"]; + +/// Grain sampling controls (Match Grain, Remove Grain): how many flat boxes of what size the +/// grain is measured in. +pub(crate) fn sampling_params() -> Vec { + vec![ + p("sampling/samplingProcess", "Sampling Process", Value::Enum(0), popup(&["Automatic"])), + p("sampling/numberOfSamples", "Number of Samples", num(80.0), slider(1.0, 500.0, 1.0, 200.0, 0)), + p("sampling/sampleSize", "Sample Size", num(9.0), slider(3.0, 64.0, 3.0, 32.0, 0)), + p("sampling/sampleBoxColor", "Sample Box Color", crate::col(1.0, 1.0, 0.0), ParamUi::Color), + ] +} + +/// The sample boxes for the effect's Sampling settings. +pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(usize, usize, usize)> { + let n = ctx.params.get("sampling/numberOfSamples").map(Value::as_f64).unwrap_or(80.0).clamp(1.0, 500.0) as usize; + let s = (ctx.params.get("sampling/sampleSize").map(Value::as_f64).unwrap_or(9.0) * scale).round().max(3.0) as usize; + sample_boxes(img, n, s) +} + fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0; - if amt <= 0.0 && um_amount <= 0.0 { + let view = pr.get("viewingMode").map(|v| v.as_enum()).unwrap_or(3); + let boxes = sampling_boxes(ctx, &b.img, b.scale); + if view == 1 { + let c = pr.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); + draw_boxes(&mut b.img, &boxes, c); return b; } + if view == 2 { + b.img.data.iter_mut().for_each(|p| *p = [1.0, 1.0, 1.0, 1.0]); + return b; + } + let temporal = pr.b("temporalFiltering/enabled"); + if amt <= 0.0 && um_amount <= 0.0 && !temporal { + return b; + } + let before = b.img.clone(); + // Temporal Filtering: steady pixels average with the neighbouring frames (grain changes + // every frame, the picture does not); Motion Sensitivity keeps moving areas out of it. + if temporal { + let fps = ctx.fps(); + let host = ctx.env.host; + let at = |t: f64| host.and_then(|h| h.self_at(t, ctx.env.effect_index)).filter(|o| o.img.width == b.img.width && o.img.height == b.img.height); + let (prev, next) = (at(ctx.time - 1.0 / fps), at(ctx.time + 1.0 / fps)); + let k = (pr.get("temporalFiltering/amount").map(Value::as_f64).unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; + let sens = pr.get("temporalFiltering/motionSensitivity").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0) as f32; + let thr = 0.02 + (1.0 - sens) * 0.2; + if prev.is_some() || next.is_some() { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut acc = *px; + let mut n = 1.0; + for o in [&prev, &next].into_iter().flatten() { + let q = o.img.data[i]; + let d = (0..3).map(|c| (q[c] - px[c]).abs()).fold(0.0f32, f32::max); + if d < thr { + let wgt = 1.0 - d / thr; + for c in 0..4 { + acc[c] += q[c] * wgt; + } + n += wgt; + } + } + for c in 0..4 { + px[c] += (acc[c] / n - px[c]) * k; + } + }); + } + if amt <= 0.0 && um_amount <= 0.0 { + if view == 0 { + preview_compose(ctx, &mut b, &before); + } + return b; + } + } + // The grain level measured in the sample boxes scales the smoothing. + let level = { + let mut m = vec![false; b.img.data.len()]; + let w = b.img.width as usize; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + let st = crate::noise2::grain_stats_masked(&b.img, Some(&m)); + let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; + if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } + }; let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; let multichannel = pr.e("noiseReductionSettings/mode") == 0; let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); let r = (2.0 * b.scale).round().max(1.0) as usize; - let eps = (0.02 * amt).powi(2); + let eps = (0.02 * amt * level).powi(2); let orig = split(&b.img); let mut ch = orig.clone(); if amt > 0.0 { @@ -387,6 +596,9 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { p[3] = a; }); b.img = out; + if view == 0 { + preview_compose(ctx, &mut b, &before); + } b } @@ -533,7 +745,9 @@ pub fn specs() -> Vec { vec![ { let (tweaking, color, application, animation) = grain_params(); - spec("ec.noise.addgrain", "Add Grain", [tweaking, color, application, animation].into_iter().flatten().collect(), add_grain) + let head = + vec![p("viewingMode", "Viewing Mode", Value::Enum(2), popup(&ADD_GRAIN_VIEWS)), p("preset", "Preset", Value::Enum(0), popup(&GRAIN_PRESETS))]; + spec("ec.noise.addgrain", "Add Grain", [head, preview_params(), tweaking, color, application, animation].into_iter().flatten().collect(), add_grain) }, spec( "ec.noise.median", @@ -557,15 +771,26 @@ pub fn specs() -> Vec { spec( "ec.noise.removegrain", "Remove Grain", - vec![ - p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), - p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)), - p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])), - p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), - p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)), - p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)), - p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), - ], + [ + vec![p("viewingMode", "Viewing Mode", Value::Enum(3), popup(&REMOVE_GRAIN_VIEWS))], + preview_params(), + vec![ + p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), + p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)), + p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])), + p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)), + p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)), + p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("temporalFiltering/enabled", "Enabled", Value::Bool(false), ParamUi::Checkbox), + p("temporalFiltering/amount", "Amount", num(100.0), pct()), + p("temporalFiltering/motionSensitivity", "Motion Sensitivity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + ], + sampling_params(), + ] + .into_iter() + .flatten() + .collect(), remove_grain, ), spec( @@ -768,6 +993,71 @@ mod tests { assert!(tinted.img.data.iter().any(|p| (p[0] - 0.4).abs() > 1e-3)); } + #[test] + fn grain_preview_presets_balance_and_views() { + let base = gray(64, 64, 0.5); + let grain = [("tweaking/intensity", num(3.0))]; + let with = |extra: &[(&str, Value)]| run("ec.noise.addgrain", &[grain.as_slice(), extra].concat(), base.clone(), 0.0).img; + // Preview: grain only inside the region; its box outline drawn. + let prev = with(&[ + ("viewingMode", Value::Enum(0)), + ("previewRegion/center", Value::Vec2([32.0, 32.0])), + ("previewRegion/width", num(20.0)), + ("previewRegion/height", num(20.0)), + ]); + assert_eq!(prev.get(2, 2), base.get(2, 2)); + assert_ne!(prev.get(32, 32), base.get(32, 32)); + assert_eq!(prev.get(22, 32), [1.0, 1.0, 1.0, 1.0], "box outline"); + // Blending Matte shows the tonal weight (1 at mid grey with the defaults). + assert_eq!(with(&[("viewingMode", Value::Enum(1))]).get(5, 5), [1.0, 1.0, 1.0, 1.0]); + // Presets and channel balance change the grain. + let plain = with(&[]); + assert_ne!(with(&[("preset", Value::Enum(3))]), plain); + let no_red = with(&[("color/redBalance", num(0.0))]); + assert!(no_red.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-6)); + // Sample boxes sit in the flat part of a picture. + let mut half = gray(64, 64, 0.5); + for y in 0..64 { + for x in 32..64 { + half.set(x, y, if (x + y) % 2 == 0 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let boxes = sample_boxes(&half, 4, 8); + assert_eq!(boxes.len(), 4); + assert!(boxes.iter().all(|(x, _, s)| x + s <= 32), "{boxes:?}"); + // Remove Grain's Noise Samples view draws them. + let shown = run("ec.noise.removegrain", &[("viewingMode", Value::Enum(1))], gray(64, 64, 0.5), 0.0).img; + assert!(shown.data.iter().any(|p| p == &[1.0, 1.0, 0.0, 1.0])); + } + + #[test] + fn remove_grain_temporal_filtering_averages_steady_frames() { + /// Flat grey with per-frame grain. + struct Grainy; + impl crate::EffectHost for Grainy { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: frame((t * 10.0).round() as u32), offset: [0.0; 2], scale: 1.0 }) + } + } + fn frame(f: u32) -> Image { + crate::util::gen_image(32, 32, |x, y| { + let n = (hash1(x as u32, y as u32, f.wrapping_mul(977)) - 0.5) * 0.06; + [0.5 + n, 0.5 + n, 0.5 + n, 1.0] + }) + } + let env = crate::EffectEnv { host: Some(&Grainy), frame_rate: 10.0, ..Default::default() }; + let vals = [("noiseReductionSettings/noiseReduction", num(0.0)), ("temporalFiltering/enabled", Value::Bool(true))]; + let out = crate::run_fx("ec.noise.removegrain", &vals, frame(5), 0.5, env).img; + let dev = |i: &Image| i.data.iter().map(|p| (p[0] - 0.5).abs()).sum::(); + assert!(dev(&out) < dev(&frame(5)) * 0.75, "{} vs {}", dev(&out), dev(&frame(5))); + } + #[test] fn noise_alpha_cycle_repeats() { let at = |phase: f64| { diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index cc7667a6..4c22f6d8 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -7,7 +7,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Image; use rayon::prelude::*; -use crate::noise::{GrainLook, fbm, grain_params, median_image}; +use crate::noise::{GrainLook, draw_boxes, fbm, grain_params, median_image, preview_params, sampling_boxes, sampling_params}; use crate::util::{Plane, gauss_plane, layer_or_self, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; @@ -29,7 +29,14 @@ pub(crate) struct GrainStats { pub size: f32, } +#[cfg(test)] pub(crate) fn grain_stats(img: &Image) -> GrainStats { + grain_stats_masked(img, None) +} + +/// [`grain_stats`] over the pixels `mask` selects (all opaque pixels without one). +pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainStats { + let sel = |i: usize| mask.is_none_or(|m| m[i]); let (w, h) = (img.width as usize, img.height as usize); if w < 3 || h < 3 { return GrainStats::default(); @@ -44,7 +51,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { let hp: Vec = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect(); let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64); for (i, v) in hp.iter().enumerate() { - if straight[i].1 > 0.5 { + if straight[i].1 > 0.5 && sel(i) { sum += *v as f64; sq += (*v as f64) * (*v as f64); n += 1.0; @@ -57,7 +64,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { for y in 0..h { for x in 0..w - 1 { let i = y * w + x; - if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 { + if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 && sel(i) && sel(i + 1) { rho_num += (hp[i] * hp[i + 1]) as f64; rho_den += (hp[i] * hp[i]) as f64; } @@ -68,15 +75,45 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Match Grain's Viewing Mode options. +const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; + fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() { return b; } + // MATCH_GRAIN_VIEWS order. let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); - let src_stats = grain_stats(&source); + let src_boxes = sampling_boxes(ctx, &source, b.scale); + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); + if view == 1 || view == 2 { + // Noise Samples (on the source) / Compensation Samples (on this layer). + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + draw_boxes(&mut img, &boxes, box_col); + b.img = img; + return b; + } + let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { + if boxes.is_empty() { + return None; + } + let w = img.width as usize; + let mut m = vec![false; img.data.len()]; + for &(x, y, s) in boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + Some(m) + }; + let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() }; + let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; + let orig = b.img.clone(); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -100,11 +137,7 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let g = look.color(raw); let weight = look.weight(c); match view { - 1 => { - let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0)); - *px = [o[0], o[1], o[2], 1.0]; - } - 2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0], + 3 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0], _ => { if a <= 0.0 { return; @@ -114,6 +147,9 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); + if view == 0 { + crate::noise::preview_compose(ctx, &mut b, &orig); + } b } @@ -214,13 +250,13 @@ pub fn specs() -> Vec { "ec.noise.matchgrain", "Match Grain", vec![ - p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), + p("viewingMode", "Viewing Mode", Value::Enum(4), popup(&MATCH_GRAIN_VIEWS)), p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden), p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), ] .into_iter() - .chain([tweaking, color, application, animation].into_iter().flatten()) + .chain([preview_params(), sampling_params(), tweaking, color, application, animation].into_iter().flatten()) .collect(), match_grain, ), diff --git a/docs/effects.md b/docs/effects.md index eb0ab1ed..001a4320 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2892 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 2927 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**: 296 implemented in full, 7 partial (what is missing is listed). +- **Status**: 299 implemented in full, 4 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -263,18 +263,18 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 32 | | 32 | Implemented | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | -| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | +| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | | Noise | `ec.noise.noise` | 3 | | 32 | Implemented | | Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | | Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | | Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | -| Remove Grain | `ec.noise.removegrain` | 7 | | 32 | Partial: no preview region, sampling or temporal filtering | +| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented | | Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented | ## Obsolete From 863ff22dc4c4c1de75b29eb831bf315db0fa15a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:48:50 -0400 Subject: [PATCH 16/17] M9.13: Caustics Sky group and light types, Wave World wireframe controls, ground and dry areas Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 7 +- crates/effects/src/lib.rs | 3 + crates/effects/src/sim2.rs | 184 +++++++++++++++++++++++++++++++--- docs/effects.md | 8 +- 4 files changed, 179 insertions(+), 23 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 2828080c..4ac0c4dd 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -7,12 +7,7 @@ use crate::{CATEGORIES, registry}; /// Effects whose implementation is known to fall short of the reference behaviour: /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. -pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.vr.digitalglitch", "reduced control set"), - ("ec.vr.converter", "common layouts only"), - ("ec.sim.caustics", "no Sky group or light type"), - ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), -]; +pub const PARTIAL: &[(&str, &str)] = &[("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only")]; /// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path, /// `group/param`) of an instance of effect `effect`, given the instance's current values diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d4ea8b81..39dd3594 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -589,6 +589,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("hairColor", "Hair Color"), ("bottom", "Bottom"), ("water", "Water"), + ("sky", "Sky"), ("lighting", "Lighting"), ("material", "Material"), ("shape", "Shape"), @@ -598,6 +599,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("rendering", "Rendering"), ("flowMap", "Flow Map"), ("heightMapControls", "Height Map Controls"), + ("wireframeControls", "Wireframe Controls"), + ("ground", "Ground"), ("simulation", "Simulation"), ("producer1", "Producer 1"), ("producer2", "Producer 2"), diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 5994316e..9e4f092e 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -947,11 +947,38 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); let (lx, ly) = b.to_px(lpos); - let lv = { - let v = [(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]; - let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + // Light Type: Distant Source (one direction, from the light position above the layer + // centre), Point Source (from the light position to each pixel) or First Comp Light. + let light_type = pr.e("lighting/lightType"); + let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None }; + let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) { + (2, Some(l)) => { + let c = [l.color[0], l.color[1], l.color[2], 1.0]; + // Comp space: z away from the viewer; the light above the water is at −z. + let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]); + (c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1) + } + (2, None) => (lc, 0.0, lx, ly, lheight, false), + (1, _) => (lc, li, lx, ly, lheight, true), + _ => (lc, li, lx, ly, lheight, false), + }; + let norm3 = |v: [f64; 3]| { + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] }; + let lv_at = |x: f64, y: f64| { + if point { + norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight]) + } else { + norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) + } + }; + // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). + let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, &b, &o, pr.e("sky/skySizeDiffers") == 1)); + let sky_scaling = pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01); + let sky_repeat = pr.e("sky/repeatMode"); + let sky_int = pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32; + let convergence = pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0); let grad = |x: usize, y: usize| -> (f64, f64) { match &height { Some(pl) => { @@ -991,6 +1018,7 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); [(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32] }; + let lv = lv_at(x as f64 + 0.5, y as f64 + 0.5); let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0); let hv = { let v = [lv[0], lv[1], lv[2] + 1.0]; @@ -999,9 +1027,21 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { }; let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li; let light = ambient + diffuse * li * nl + cstr * focus * li; + // The surface: its colour lit like the water, or the sky it reflects (sampled along + // the surface normal; Convergence pulls the reflection towards the pixel itself). + let surface: [f32; 3] = match &sky { + Some(sk) => { + let reach = (1.0 - convergence) * lw * 0.5; + let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach); + let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling); + let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat)); + std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q]) + } + None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)), + }; let cc: [f32; 3] = std::array::from_fn(|q| { let base = c[q] * light * lc[q]; - base * (1.0 - surf_op) + surf[q] * surf_op * (ambient + diffuse * li * nl) + spec * lc[q] + base * (1.0 - surf_op) + surface[q] * surf_op + spec * lc[q] }); let a = (a + surf_op * (1.0 - a)).min(1.0); *px = [cc[0] * a, cc[1] * a, cc[2] * a, a]; @@ -1085,7 +1125,36 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let dt = 1.0 / (sps * sub as f32); let cell = 1.0 / (nx - 1) as f32; let weights: Vec> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect(); - let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); + // Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where + // it rises above Height the grid is dry (a wall the waves reflect from); shallow water + // slows the waves by Wave Strength. + let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| { + let host = ctx.env.host?; + host.layer_at(id, ctx.env.comp_time - ctx.time, true) + }); + let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32; + let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32; + let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32; + // Water depth per grid point (None without ground: deep everywhere). + let depth: Option> = ground.as_ref().map(|lp| { + let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0)); + (0..nx * ny) + .map(|i| { + let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64); + let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1])); + level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a + }) + .collect() + }); + let speed_k: Vec = match &depth { + Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(), + None => vec![1.0; nx * ny], + }; + let mut key = + params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); + if let Some(d) = &depth { + key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); + } let steps = crate::sim::steps_at(ctx.time + preroll); let st = WAVE_CACHE.run( key, @@ -1101,7 +1170,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let i = y * nx + x; let g = |xx: usize, yy: usize| w.u[yy * nx + xx]; let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i]; - acc[i] = c2 * l - damping * 4.0 * w.v[i]; + acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i]; } } for i in 0..nx * ny { @@ -1121,6 +1190,13 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { } } } + // Dry ground holds the surface flat. + for i in 0..nx * ny { + if speed_k[i] <= 0.0 { + w.u[i] = 0.0; + w.v[i] = 0.0; + } + } // Edges: reflective ones mirror; the others absorb in a thin sponge layer. let refl = |side: u32| reflect == 5 || reflect == side; let sponge = 4usize.min(nx / 4).max(1); @@ -1168,22 +1244,38 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let contrast = pr.f("heightMapControls/contrast") as f32; let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32; + let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1; + let dry_at = |x: f64, y: f64| -> bool { + let Some(d) = &depth else { return false }; + let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize; + let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; + d[gy * st.nx + gx] <= 0.0 + }; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let lx = (x as f64 + 0.5 - b.offset[0]) / s; let ly = (y as f64 + 0.5 - b.offset[1]) / s; let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma); - *px = [v * alpha, v * alpha, v * alpha, alpha]; + let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha }; + *px = [v * al, v * al, v * al, al]; } }); } else { - // Wireframe preview: the grid seen at an angle, heights lifting the lines. - let lift = lh as f32 * 0.25; + // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, + // tilted by Vertical Rotation, heights scaled by Vertical Scale. + let hr = pr.f("wireframeControls/horizontalRotation").to_radians(); + let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians(); + let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5); + let (sh, ch) = hr.sin_cos(); + let (sv, cv) = vr.sin_cos(); let to = |x: usize, y: usize| -> [f32; 2] { - let lx = x as f64 / (st.nx - 1) as f64 * lw; - let ly = y as f64 / (st.nx - 1) as f64 * lw; - let (px, py) = b.to_px([lx, lh * 0.25 + ly * 0.6]); - [px as f32, py as f32 - st.u[y * st.nx + x] * lift * s as f32] + // Grid point relative to the layer centre (layer px), height in layer px. + let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5; + let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5; + let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5; + let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch); + let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]); + [px as f32, py as f32] }; let mut lines = Vec::new(); for y in 0..st.ny { @@ -1680,6 +1772,13 @@ pub fn specs() -> Vec { p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer), + p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), + p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), + p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), + p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)), p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), @@ -1697,9 +1796,13 @@ pub fn specs() -> Vec { { let mut v = vec![ p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), + p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle), + p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)), + p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), + p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])), p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), @@ -1707,6 +1810,10 @@ pub fn specs() -> Vec { p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), + p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer), + p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), ]; v.extend(producer_params(1, 0.5, (0.5, 0.5))); v.extend(producer_params(2, 0.0, (0.25, 0.25))); @@ -1941,6 +2048,57 @@ mod tests { assert!(sum_diff(&b, &src) > 0.1); } + #[test] + fn caustics_sky_and_light_types() { + let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]); + let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; + let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img; + // A mirror-like surface shows the (green) sky. + let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))]; + let p = run_h(&mirror).get(16, 16); + assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}"); + // Point Source lights pixels by their own direction to the light: the image is no + // longer uniform; Distant Source lights the flat water evenly. + let lit = |t: u32| { + run_h(&[ + ("lighting/lightType", Value::Enum(t)), + ("lighting/lightPosition", pt(0.0, 0.0)), + ("lighting/lightHeight", num(0.2)), + ("water/surfaceOpacity", num(0.0)), + ]) + }; + let (distant, point) = (lit(0), lit(1)); + assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5); + assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30)); + // First Comp Light without a comp light: ambient only. + let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]); + assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]); + } + + #[test] + fn wave_world_wireframe_controls_ground_and_dry_areas() { + let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))]; + let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5); + assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))])); + assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))])); + // Ground (left dark, right light): the light side rises above the water and is dry. + let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; + let hm = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]); + v.extend_from_slice(extra); + run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img + }; + let solid = hm(&[]); + let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]); + assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0); + assert!(clear.get(4, 16)[3] > 0.99, "wet side stays"); + // Dry ground stays flat: mid grey (brightness 0.5) there. + assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4); + // Wave Strength slows waves over shallow water: the surface differs. + assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))])); + } + #[test] fn wave_world_seek_and_waves() { let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))]; diff --git a/docs/effects.md b/docs/effects.md index 001a4320..11f003fd 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2927 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 2942 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**: 299 implemented in full, 4 partial (what is missing is listed). +- **Status**: 301 implemented in full, 2 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -329,11 +329,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented | | CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | | Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | -| Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | +| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented | | Foam | `ec.sim.foam` | 36 | | 32 | Implemented | | Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | | Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | -| Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group | +| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented | ## Stylize From 70e7800b74082fb5c0588e930168ec294c0509d4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 07:15:36 -0400 Subject: [PATCH 17/17] M9.13: VR Converter Facebook / EAC cube maps and FullDome fisheye, VR Digital Glitch full controls; PARTIAL list empty Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/lib.rs | 2 + crates/effects/src/vr.rs | 187 ++++++++++++++++++++++++++++++---- docs/effects.md | 8 +- 4 files changed, 172 insertions(+), 27 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 4ac0c4dd..2303cc55 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -7,7 +7,7 @@ use crate::{CATEGORIES, registry}; /// Effects whose implementation is known to fall short of the reference behaviour: /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. -pub const PARTIAL: &[(&str, &str)] = &[("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only")]; +pub const PARTIAL: &[(&str, &str)] = &[]; /// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path, /// `group/param`) of an instance of effect `effect`, given the instance's current values diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 39dd3594..95dc6418 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -601,6 +601,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("heightMapControls", "Height Map Controls"), ("wireframeControls", "Wireframe Controls"), ("ground", "Ground"), + ("geometric", "Geometric Distortion"), + ("target", "Target Point"), ("simulation", "Simulation"), ("producer1", "Producer 1"), ("producer2", "Producer 2"), diff --git a/crates/effects/src/vr.rs b/crates/effects/src/vr.rs index 26d71dfc..af311054 100644 --- a/crates/effects/src/vr.rs +++ b/crates/effects/src/vr.rs @@ -154,7 +154,20 @@ fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 { // ---------------------------------------------------------------- projections (VR Converter) -pub const PROJECTIONS: [&str; 6] = ["Equirectangular 2:1", "Cube-map 4:3", "Cube-map Pano2VR 3:2", "Cube-map GearVR 6:1", "Sphere-map", "2D Source"]; +/// VR Converter's projections / layouts. Cube-map Facebook 3:2 and Cube-map EAC 3:2 put +/// left, front, right over bottom, back, top (EAC with equi-angular faces); Fisheye +/// (FullDome) is an angular fisheye looking up at the zenith with the front at the bottom. +pub const PROJECTIONS: [&str; 9] = [ + "Equirectangular 2:1", + "Cube-map 4:3", + "Cube-map Pano2VR 3:2", + "Cube-map GearVR 6:1", + "Sphere-map", + "2D Source", + "Cube-map Facebook 3:2", + "Cube-map EAC 3:2", + "Fisheye (FullDome)", +]; /// Cube faces: (forward, right, up). const FACES: [(V3, V3, V3); 6] = [ @@ -173,6 +186,8 @@ fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) { 1 => ((4, 3), [(2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1)]), // 3 × 2: right, left, top / bottom, front, back. 2 => ((3, 2), [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]), + // 3 × 2: left, front, right / bottom, back, top (Facebook, EAC). + 6 | 7 => ((3, 2), [(2, 0), (0, 0), (2, 1), (0, 1), (1, 0), (1, 1)]), // 6 × 1 strip in face order. _ => ((6, 1), [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]), } @@ -191,16 +206,34 @@ impl Proj { pub fn dir(&self, x: f64, y: f64, w: f64, h: f64) -> Option { match self.kind { 0 => Some(equi_dir(x, y, w, h)), - 1..=3 => { + 1..=3 | 6 | 7 => { let ((gw, gh), cells) = cube_grid(self.kind); let (cw, ch) = (w / gw as f64, h / gh as f64); let (cx, cy) = ((x / cw).floor(), (y / ch).floor()); let f = cells.iter().position(|&(c, r)| c as f64 == cx && r as f64 == cy)?; - let a = (x - cx * cw) / cw * 2.0 - 1.0; - let b = (y - cy * ch) / ch * 2.0 - 1.0; + let mut a = (x - cx * cw) / cw * 2.0 - 1.0; + let mut b = (y - cy * ch) / ch * 2.0 - 1.0; + if self.kind == 7 { + // Equi-angular faces: positions are linear in angle. + a = (a * std::f64::consts::FRAC_PI_4).tan(); + b = (b * std::f64::consts::FRAC_PI_4).tan(); + } let (fw, r, u) = FACES[f]; Some(norm([fw[0] + a * r[0] - b * u[0], fw[1] + a * r[1] - b * u[1], fw[2] + a * r[2] - b * u[2]])) } + 8 => { + // FullDome: 180° angular fisheye centred on the zenith (+Y), front at the bottom. + let s = w.min(h) / 2.0; + let (u, v) = ((x - w / 2.0) / s, (y - h / 2.0) / s); + let r = (u * u + v * v).sqrt(); + if r > 1.0 { + return None; + } + let th = r * std::f64::consts::FRAC_PI_2; + let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) }; + // Local: forward = +Y, image right = +X, image down = +Z. + Some([th.sin() * cu, th.cos(), th.sin() * cv]) + } 4 => { // Angular fisheye centred on +Z; radius ∝ angle, `fov` across the image width. let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians(); @@ -231,17 +264,32 @@ impl Proj { let (x, y) = equi_px(d, w, h); Some((x, y, None)) } - 1..=3 => { + 1..=3 | 6 | 7 => { let f = (0..6).max_by(|&i, &j| dot(d, FACES[i].0).total_cmp(&dot(d, FACES[j].0)))?; let (fw, r, u) = FACES[f]; let k = dot(d, fw); - let (a, b) = (dot(d, r) / k, -dot(d, u) / k); + let (mut a, mut b) = (dot(d, r) / k, -dot(d, u) / k); + if self.kind == 7 { + a = a.atan() / std::f64::consts::FRAC_PI_4; + b = b.atan() / std::f64::consts::FRAC_PI_4; + } let ((gw, gh), cells) = cube_grid(self.kind); let (cw, ch) = (w / gw as f64, h / gh as f64); let (c, rr) = cells[f]; let (x0, y0) = (c as f64 * cw, rr as f64 * ch); Some((x0 + (a + 1.0) * 0.5 * cw, y0 + (b + 1.0) * 0.5 * ch, Some([x0, y0, x0 + cw, y0 + ch]))) } + 8 => { + let th = d[1].clamp(-1.0, 1.0).acos(); + if th > std::f64::consts::FRAC_PI_2 + 1e-9 { + return None; + } + let s = w.min(h) / 2.0; + let r = th / std::f64::consts::FRAC_PI_2; + let l = (d[0] * d[0] + d[2] * d[2]).sqrt(); + let (cu, cv) = if l > 1e-12 { (d[0] / l, d[2] / l) } else { (0.0, 0.0) }; + Some((w / 2.0 + cu * r * s, h / 2.0 + cv * r * s, None)) + } 4 => { let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians(); let th = d[2].clamp(-1.0, 1.0).acos(); @@ -578,7 +626,7 @@ fn vr_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { let from = Proj { kind: ctx.params.e("sourceProjection"), fov: ctx.params.f("sourceHorizontalFov") }; let to = Proj { kind: ctx.params.e("targetProjection"), fov: ctx.params.f("targetHorizontalFov") }; let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll")); - if from.kind == to.kind && (from.kind <= 3 || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 { + if from.kind == to.kind && (!matches!(from.kind, 4 | 5) || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 { return b; } let m = rot_params(ctx); @@ -825,39 +873,86 @@ fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// VR Digital Glitch: horizontal bands of the panorama jump sideways (Geometric Distortion, +/// split into Horizontal / Vertical displacement), their colour channels split apart (Color +/// Distortion with per-channel offsets) and scan lines darken alternate rows (Scanline +/// Spacing apart). The pattern re-randomises Distortion Rate times a second, and Distortion +/// Evolution (optionally cycling) moves it smoothly in between. Target Point limits the glitch +/// to a region of the sphere (Radius, Feather; radius 0 = everywhere). fn vr_glitch(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amp = (pr.f("masterAmplitude") / 100.0).max(0.0); if amp <= 0.0 { return b; } + let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); let rate = pr.f("distortionRate").max(0.0); let geo = pr.f("geometricDistortion") / 100.0 * amp; + let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0); let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0); let rgb = pr.f("colorDistortion") / 100.0 * amp; + let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)]; let scan = (pr.f("scanlines") / 100.0 * amp) as f32; + let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize; let seed = (pr.f("randomSeed") as u32) ^ ctx.seed; - // The glitch pattern changes `rate` times a second (held between changes). - let slot = if rate > 0.0 { (ctx.time * rate).floor() as i64 } else { 0 } as u32; + // Evolution: a fraction of a slot that blends towards the next pattern. + let mut evo = g("distortionEvolution", 0.0) / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0)); + } + let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo; + let slot = base.floor() as i64 as u32; + let frac = base - base.floor(); + // Target Point. + let target = mv(&rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0), [0.0, 0.0, 1.0]); + let radius = g("target/radius", 0.0).to_radians(); + let feather = g("target/feather", 0.0).to_radians(); b.img = per_eye(&b.img, layout(ctx), |e, eye| { let (w, h) = (e.width as f64, e.height as f64); let bands = complexity.round() as u32; let mut o = Image::new(e.width, e.height); o.rows_mut().for_each(|(y, row)| { let band = ((y as f64 / h) * bands as f64) as u32; - let r0 = hash1(band, slot, seed ^ (eye as u32 * 977)); - let on = r0 < 0.35; - let shift = if on { (hash1(band, slot + 1, seed) as f64 * 2.0 - 1.0) * geo * w * 0.15 } else { 0.0 }; - let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, slot + 2, seed) as f64) } else { 0.0 }; - let dark = if y % 2 == 0 { 1.0 - scan * 0.5 } else { 1.0 }; + let at = |s: u32| { + let r0 = hash1(band, s, seed ^ (eye as u32 * 977)); + let on = r0 < 0.35; + let shift = if on { (hash1(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 }; + let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, s + 2, seed) as f64) } else { 0.0 }; + (shift, split) + }; + let (s0, p0) = at(slot); + let (shift, split) = if frac > 0.0 { + let (s1, p1) = at(slot + 1); + (s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac) + } else { + (s0, p0) + }; + let (dx, dy) = (shift * w * 0.15 * hdisp, shift * h * 0.15 * vdisp); + let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 }; for (x, px) in row.iter_mut().enumerate() { - let sx = x as f64 + 0.5 - shift; - let sy = y as f64 + 0.5; + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let k = if radius > 0.0 { + let a = angle(equi_dir(fx, fy, w, h), target); + if a <= radius { + 1.0 + } else if feather > 0.0 { + (1.0 - (a - radius) / feather).clamp(0.0, 1.0) + } else { + 0.0 + } + } else { + 1.0 + }; + let orig = e.data[y * e.width as usize + x]; + if k <= 0.0 { + *px = orig; + continue; + } + let (sx, sy) = (fx - dx * k, (fy - dy * k).clamp(0.5, h - 0.5)); // Horizontal shifts wrap around the seam (the frame is a full turn). - let g = sample_equi(e, sx, sy); - let r = sample_equi(e, sx - split, sy); - let bb = sample_equi(e, sx + split, sy); - *px = [r[0] * dark, g[1] * dark, bb[2] * dark, g[3].max(r[3]).max(bb[3])]; + let c: [Px; 3] = std::array::from_fn(|i| sample_equi(e, sx + offs[i] * split * k, sy)); + let d = 1.0 - (1.0 - dark) * k as f32; + *px = [c[0][0] * d, c[1][1] * d, c[2][2] * d, c[0][3].max(c[1][3]).max(c[2][3])]; } }); o @@ -947,9 +1042,22 @@ pub fn specs() -> Vec { p("masterAmplitude", "Master Amplitude", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)), p("distortionRate", "Distortion Rate", num(5.0), slider(0.0, 60.0, 0.0, 30.0, 2)), p("distortionComplexity", "Distortion Complexity", num(16.0), slider(1.0, 100.0, 1.0, 64.0, 0)), + ang("distortionEvolution", "Distortion Evolution"), + p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox), + p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("geometric/horizontalDisplacement", "Horizontal Displacement", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("geometric/verticalDisplacement", "Vertical Displacement", num(0.0), slider(0.0, 400.0, 0.0, 200.0, 1)), p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("color/redOffset", "Red Offset", num(-1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("color/greenOffset", "Green Offset", num(0.0), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("color/blueOffset", "Blue Offset", num(1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)), p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("scanlineSpacing", "Scanline Spacing", num(2.0), slider(2.0, 100.0, 2.0, 20.0, 0)), + ang("target/tilt", "Target Tilt"), + ang("target/pan", "Target Pan"), + p("target/radius", "Target Radius", num(0.0), slider(0.0, 180.0, 0.0, 180.0, 1)), + p("target/feather", "Target Feather", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)), p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), ], vr_glitch, @@ -1056,7 +1164,7 @@ mod tests { #[test] fn projections_are_inverse() { - for kind in 0..6u32 { + for kind in 0..9u32 { let pr = Proj { kind, fov: if kind == 4 { 360.0 } else { 100.0 } }; let (w, h) = (240.0, 120.0); for (x, y) in [(10.5, 20.5), (100.5, 60.5), (230.5, 110.5), (130.5, 33.5)] { @@ -1070,7 +1178,7 @@ mod tests { #[test] fn converter_round_trips_equirect_through_cube_maps() { let img = sphere_img(128, 64); - for target in [1u32, 2, 3] { + for target in [1u32, 2, 3, 6, 7] { let cube = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(target))], img.clone(), 0.0, EffectEnv::default()); let back = run_fx( "ec.vr.converter", @@ -1098,6 +1206,41 @@ mod tests { } } + #[test] + fn digital_glitch_full_controls() { + let img = sphere_img(128, 64); + let run = |vals: &[(&str, Value)], t: f64| run_fx("ec.vr.digitalglitch", vals, img.clone(), t, EffectEnv::default()).img; + let base = run(&[], 0.0); + for (id, v) in + [("distortionEvolution", num(90.0)), ("geometric/verticalDisplacement", num(100.0)), ("color/redOffset", num(3.0)), ("scanlineSpacing", num(5.0))] + { + assert_ne!(run(&[(id, v.clone())], 0.0), base, "{id}"); + } + // Cycle Evolution: one full cycle is the start again. + let cyc = + |e: f64| run(&[("distortionEvolution", num(e)), ("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))], 0.0); + assert_eq!(cyc(0.0), cyc(360.0)); + // Target Point: a small region around the front; the back stays untouched. + let t = run(&[("target/radius", num(30.0))], 0.0); + assert_eq!(t.get(0, 32), img.get(0, 32), "behind the viewer"); + } + + #[test] + fn converter_fulldome_and_eac() { + let img = sphere_img(128, 64); + let dome = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(8))], img.clone(), 0.0, EffectEnv::default()).img; + // The centre looks at the zenith (+Y); the bottom edge at the front (+Z). + let c = dome.get(64, 32); + assert!((c[1] - 0.9).abs() < 0.03, "{c:?}"); + let f = dome.get(64, 62); + assert!(f[2] > 0.8, "{f:?}"); + // EAC faces are equi-angular: a quarter of the front face sits at 22.5 degrees. + let eac = Proj { kind: 7, fov: 90.0 }; + let cw = 128.0 / 3.0; + let d = eac.dir(cw + 0.25 * cw, 16.0, 128.0, 64.0).unwrap(); + assert!((d[0].atan2(d[2]).to_degrees() + 22.5).abs() < 0.5, "{d:?}"); + } + #[test] fn rotate_sphere_pans_and_round_trips() { let img = sphere_img(96, 48); diff --git a/docs/effects.md b/docs/effects.md index 11f003fd..fb990148 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2942 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 2955 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**: 301 implemented in full, 2 partial (what is missing is listed). +- **Status**: 303 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -221,9 +221,9 @@ Effects are addressed by id (`ec..`) or display name in commands | VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented | | VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented | | VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented | -| VR Converter | `ec.vr.converter` | 8 | | 32 | Partial: common layouts only | +| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented | | VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented | -| VR Digital Glitch | `ec.vr.digitalglitch` | 8 | | 32 | Partial: reduced control set | +| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented | | VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented | | VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented | | VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented |