From b58167d0c7b415e42bff5d39174fec5ad93f0ed4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH] M9.3: Noise & Grain effects (Match Grain, Median (Legacy), Curl Noise) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/noise2.rs | 368 ++++++++++++++++++++++++++++++++++- 1 file changed, 365 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index bb1953bc..c53ada32 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -1,7 +1,369 @@ -//! Noise & Grain effects, batch 2. +//! Noise & Grain effects, batch 2: Match Grain (grain statistics measured from another layer +//! and synthesised onto this one), Median (Legacy) and Curl Noise (divergence-free flow +//! distortion from the curl of a fractal noise potential). -use crate::EffectSpec; +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Image; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::noise::{fbm, median_image}; +use crate::util::{Plane, gauss_plane, layer_or_self, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- Match Grain + +/// Grain statistics of an image: per-channel standard deviation of the high-pass residual +/// (over opaque pixels) and a grain size from the lag-1 autocorrelation. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct GrainStats { + pub std: [f32; 3], + pub size: f32, +} + +pub(crate) fn grain_stats(img: &Image) -> GrainStats { + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + let straight: Vec<([f32; 3], f32)> = img.data.par_iter().map(|&p| unpremul(p)).collect(); + let mut std = [0.0f32; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + for (k, s) in std.iter_mut().enumerate() { + let pl = Plane { w, h, data: straight.iter().map(|(c, _)| c[k]).collect() }; + let low = gauss_plane(&pl, 1.5, 1.5); + 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 { + sum += *v as f64; + sq += (*v as f64) * (*v as f64); + n += 1.0; + } + } + if n > 1.0 { + let mean = sum / n; + *s = ((sq / n - mean * mean).max(0.0)).sqrt() as f32; + } + 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 { + rho_num += (hp[i] * hp[i + 1]) as f64; + rho_den += (hp[i] * hp[i]) as f64; + } + } + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + +fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { + if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() { + return b; + } + let view = ctx.params.e("viewingMode"); + let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); + let src_stats = grain_stats(&source); + let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; + let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() }; + let intensity = ctx.params.f("intensity") as f32; + let chan = [ctx.params.f("redIntensity") as f32, ctx.params.f("greenIntensity") as f32, ctx.params.f("blueIntensity") as f32]; + let amp: [f32; 3] = [0, 1, 2].map(|k| { + let s = src_stats.std[k]; + let e = own.std[k] * comp; + (s * s - e * e).max(0.0).sqrt() * intensity * chan[k] + }); + let size = (src_stats.size.max(0.5) * ctx.params.f("size") as f32 * b.scale as f32).max(0.05); + let aspect = ctx.params.f("aspectRatio").max(0.05) as f32; + let soft = ctx.params.f("softness") * b.scale; + let mono = ctx.params.b("monochromatic"); + let sat = ctx.params.f("saturation") as f32; + let blend_mode = ctx.params.e("blendingMode"); + let (ws, wm, wh) = (ctx.params.f("shadows") as f32, ctx.params.f("midtones") as f32, ctx.params.f("highlights") as f32); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37); + let ft = ctx.time * 24.0 * ctx.params.f("animationSpeed"); + let frame = if ctx.params.b("animateSmoothly") { ft as f32 } else { ft.floor() as f32 }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let nch = if mono { 1 } else { 3 }; + let mut planes: Vec = (0..nch) + .map(|k| { + let mut pl = Plane::new(w, h); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = value_noise(x as f32 / size, y as f32 / (size * aspect), frame, seed.wrapping_add(k as u32 * 131)) - 0.5; + } + }); + pl + }) + .collect(); + if soft > 0.05 { + planes = planes.iter().map(|pl| gauss_plane(pl, soft, soft * aspect as f64)).collect(); + } + // Normalise each synthetic plane to unit standard deviation so the measured stats transfer. + for pl in planes.iter_mut() { + let n = pl.data.len().max(1) as f64; + let mean = pl.data.iter().map(|&v| v as f64).sum::() / n; + let var = pl.data.iter().map(|&v| (v as f64 - mean).powi(2)).sum::() / n; + let k = if var > 1e-12 { 1.0 / var.sqrt() } else { 0.0 }; + *pl = pl.map(|v| ((v as f64 - mean) * k) as f32); + } + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + let mut g = if mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; + let gm = (g[0] + g[1] + g[2]) / 3.0; + g = g.map(|v| gm + (v - gm) * sat); + let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let s = 1.0 - smoothstep(0.0, 0.5, l); + let hi = smoothstep(0.5, 1.0, l); + let weight = ws * s + wm * (1.0 - s - hi) + wh * hi; + 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], + _ => { + if a <= 0.0 { + return; + } + let o = [0, 1, 2].map(|k| { + let n = g[k] * amp[k] * weight; + let v = match blend_mode { + 1 => c[k] * (1.0 + n * 2.0), + 2 => 1.0 - (1.0 - c[k]) * (1.0 - n * 2.0), + _ => c[k] + n, + }; + v.max(0.0) + }); + *px = premul(o, a); + } + } + }); + b +} + +// ---------------------------------------------------------------- Median (Legacy) + +fn median_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return b; + } + if ctx.params.b("operateOnAlphaChannel") { + b.img = median_image(&b.img, r); + return b; + } + // Colour only: median of straight colour, alpha kept. + let mut straight = b.img.clone(); + straight.data.par_iter_mut().for_each(|px| { + let (c, _) = unpremul(*px); + *px = [c[0], c[1], c[2], 1.0]; + }); + let m = median_image(&straight, r); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, q)| { + *px = premul([q[0], q[1], q[2]], px[3]); + }); + b +} + +// ---------------------------------------------------------------- Curl Noise + +fn curl_noise(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("displacementAmount") * b.scale; + let view = ctx.params.e("view"); + if amount.abs() < 1e-6 && view == 0 { + return b; + } + let scale = (ctx.params.f("scale").max(1.0) * b.scale) as f32; + let complexity = ctx.params.f("complexity") as f32; + let rot = ctx.params.f("rotation").to_radians() as f32; + let off = ctx.params.v2("offset"); + let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32); + let evo = (ctx.params.f("evolution") / 360.0) as f32; + let seed = ctx.params.f("randomSeed") as i64 as u32 ^ 0x51_7cc1; + let steps = ctx.params.f("steps").clamp(1.0, 16.0) as usize; + let edge_wrap = ctx.params.e("edgeBehavior") == 1; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let (cr, sr) = (rot.cos(), rot.sin()); + // Potential field on the pixel grid. + let mut pot = Plane::new(w, h); + pot.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy); + let u = (dx * cr + dy * sr) / scale; + let vv = (-dx * sr + dy * cr) / scale; + *v = fbm(u, vv, evo * 4.0, seed, complexity); + } + }); + // Curl: v = (dψ/dy, -dψ/dx), normalised so the typical speed is ~1 px per unit amount. + let mut vx = Plane::new(w, h); + let mut vy = Plane::new(w, h); + vx.data.par_chunks_mut(w.max(1)).zip(vy.data.par_chunks_mut(w.max(1))).enumerate().for_each(|(y, (rx, ry))| { + for x in 0..w { + let (xi, yi) = (x as i64, y as i64); + let gx = (pot.get_clamped(xi + 1, yi) - pot.get_clamped(xi - 1, yi)) * 0.5; + let gy = (pot.get_clamped(xi, yi + 1) - pot.get_clamped(xi, yi - 1)) * 0.5; + rx[x] = gy * scale; + ry[x] = -gx * scale; + } + }); + if view == 1 { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (a, c) = (vx.data[i], vy.data[i]); + *px = [(0.5 + a * 0.5).clamp(0.0, 1.0), (0.5 + c * 0.5).clamp(0.0, 1.0), 0.5, 1.0]; + }); + return b; + } + let src = b.img.clone(); + let dt = amount as f32 / steps as f32; + let (wf, hf) = (w as f64, h as f64); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (mut sx, mut sy) = (x as f64 + 0.5, y as f64 + 0.5); + for _ in 0..steps { + // Trace the streamline backwards (semi-Lagrangian advection). + let (u, v) = (vx.sample(sx, sy), vy.sample(sx, sy)); + sx -= (u * dt) as f64; + sy -= (v * dt) as f64; + } + *px = if edge_wrap { src.sample_bilinear_clamped(sx.rem_euclid(wf), sy.rem_euclid(hf)) } else { src.sample_bilinear_clamped(sx, sy) }; + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + let mult = || slider(0.0, 10.0, 0.0, 2.0, 2); + vec![ + spec( + "ec.noise.matchgrain", + "Match Grain", + vec![ + p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), + p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), + p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), + p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)), + p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("aspectRatio", "Aspect Ratio", num(1.0), slider(0.05, 20.0, 0.25, 4.0, 2)), + p("redIntensity", "Red Intensity", num(1.0), mult()), + p("greenIntensity", "Green Intensity", num(1.0), mult()), + p("blueIntensity", "Blue Intensity", num(1.0), mult()), + p("monochromatic", "Monochromatic", Value::Bool(false), ParamUi::Checkbox), + p("saturation", "Saturation", num(1.0), mult()), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Multiply", "Screen"])), + p("shadows", "Shadows", num(1.0), mult()), + p("midtones", "Midtones", num(1.0), mult()), + p("highlights", "Highlights", num(1.0), mult()), + p("animationSpeed", "Animation Speed", num(1.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("animateSmoothly", "Animate Smoothly", Value::Bool(true), ParamUi::Checkbox), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + match_grain, + ), + spec( + "ec.noise.medianlegacy", + "Median (Legacy)", + vec![ + p("radius", "Radius", num(1.0), slider(0.0, 255.0, 0.0, 20.0, 0)), + p("operateOnAlphaChannel", "Operate On Alpha Channel", Value::Bool(false), ParamUi::Checkbox), + ], + median_legacy, + ), + spec( + "ec.noise.curlnoise", + "Curl Noise", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Output", "Flow Field"])), + p("displacementAmount", "Displacement Amount", num(20.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("complexity", "Complexity", num(3.0), slider(1.0, 20.0, 1.0, 10.0, 1)), + p("evolution", "Evolution", num(0.0), ParamUi::Angle), + p("steps", "Flow Steps", num(4.0), slider(1.0, 16.0, 1.0, 16.0, 0)), + p("edgeBehavior", "Edge Behavior", Value::Enum(0), popup(&["Clamp", "Wrap"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + curl_noise, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel2::tests::FakeHost; + use crate::{EffectEnv, run_fx}; + + fn noisy(w: u32, h: u32, amp: f32, seed: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let n = (crate::util::hash1(x, y, seed) - 0.5) * 2.0 * amp; + img.set(x, y, [0.5 + n, 0.5 + n, 0.5 + n, 1.0]); + } + } + img + } + + #[test] + fn match_grain_transfers_grain_strength() { + let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); + let none = run_fx("ec.noise.matchgrain", &[], flat.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, flat.data); + let host = FakeHost(noisy(48, 48, 0.1, 3)); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat.clone(), 0.5, env); + let target = grain_stats(&host.0).std[0]; + let got = grain_stats(&out.img).std[0]; + assert!(got > target * 0.4 && got < target * 2.5, "target {target} got {got}"); + let again = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat, 0.5, env); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn median_legacy_radius_zero_identity_and_removes_speck() { + let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]); + img.set(4, 4, [1.0, 1.0, 1.0, 1.0]); + let id = run_fx("ec.noise.medianlegacy", &[("radius", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let out = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img.clone(), 0.0, EffectEnv::default()); + assert!((out.img.get(4, 4)[0] - 0.2).abs() < 0.01); + let again = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img, 0.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn curl_noise_zero_is_identity_and_flow_preserves_flat() { + let img = crate::channel2::tests::sample(); + let id = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let flat = Image::filled(16, 16, [0.3, 0.6, 0.9, 1.0]); + let out = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], flat.clone(), 0.0, EffectEnv::default()); + assert!(out.img.data.iter().all(|p| (p[1] - 0.6).abs() < 1e-5)); + let mut grad = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + grad.set(x, y, [x as f32 / 32.0, y as f32 / 32.0, 0.0, 1.0]); + } + } + let a = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img.data, b.img.data); + assert!(a.img.data.iter().zip(grad.data.iter()).any(|(p, q)| (p[0] - q[0]).abs() > 0.01)); + } }