Files
effectcraft/crates/effects/src/noise3.rs
heojunfoandClaude Opus 5.5 09b3042e20 Effects: Fractal and Turbulent Noise stay inside the layer's alpha (#939)
Masks are applied before effects, but the noise was composited with the
Opacity alpha alone, so a masked solid filled the whole layer. The result
is now multiplied by the original alpha on the CPU and on both GPU paths
(kernels.wgsl for the default settings, fx_noise.wgsl for the rest).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:31:31 +09:00

669 lines
30 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fractal Noise and Turbulent Noise: one fractal value-noise engine with the parameter tree of
//! the two effects (Fractal Type, Noise Type, Overflow, the Transform / Sub Settings / Evolution
//! Options twirl-downs, Opacity and Blending Mode).
//!
//! Each "noise layer" (octave) is 3D value noise (x, y, evolution) on an integer lattice. The
//! Noise Type picks the interpolation between lattice values (Block = nearest, Linear,
//! Soft Linear = quadratic B-spline, Spline = Catmull-Rom). Successive layers are scaled by Sub
//! Scaling, weighted by Sub Influence, rotated by Sub Rotation and shifted by Sub Offset, and the
//! Fractal Type shapes each layer (turbulent types fold the signed noise; dynamic types warp the
//! domain; Max keeps the strongest layer; Strings keeps thin lines). Contrast / Brightness /
//! Overflow then map the sum to the output range. Cycle Evolution loops the evolution by
//! cross-fading two evolution offsets one cycle apart; Turbulent Noise's Turbulence Factor makes
//! finer layers evolve and drift faster than coarse ones.
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::generate::{fade, lattice};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider};
/// Fractal Type options (shared by both effects).
pub const FRACTAL_TYPES: [&str; 9] =
["Basic", "Turbulent Smooth", "Turbulent Basic", "Turbulent Sharp", "Dynamic", "Dynamic Twist", "Dynamic Progressive", "Max", "Strings"];
/// Noise Type options: interpolation between lattice values.
pub const NOISE_TYPES: [&str; 4] = ["Block", "Linear", "Soft Linear", "Spline"];
/// Overflow options.
pub const OVERFLOWS: [&str; 4] = ["Clip", "Soft Clamp", "Wrap Back", "Allow HDR Results"];
/// Blending Mode options: None renders the noise alone, the rest composite it over the layer.
pub const BLEND_MODES: [&str; 24] = [
"None",
"Normal",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Difference",
"Exclusion",
"Hue",
"Saturation",
"Color",
"Luminosity",
"Stencil Alpha",
"Stencil Luma",
"Silhouette Alpha",
"Silhouette Luma",
"Alpha Add",
"Luminescent Premul",
];
/// Index of "None" / "Normal" in [`BLEND_MODES`].
pub const BLEND_NONE: u32 = 0;
pub const BLEND_NORMAL: u32 = 1;
/// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation).
pub const NOISE_SOFT_LINEAR: u32 = 2;
fn spec(id: &'static str, name: &'static str, params: Vec<ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true }
}
fn params(turbulent: bool) -> Vec<ParamSpec> {
let pct = || slider(0.0, 100.0, 0.0, 100.0, 1);
let scale = || slider(1.0, 10000.0, 20.0, 600.0, 1);
let mut v = vec![
p("fractalType", "Fractal Type", Value::Enum(if turbulent { 1 } else { 0 }), popup(&FRACTAL_TYPES)),
p("noiseType", "Noise Type", Value::Enum(NOISE_SOFT_LINEAR), popup(&NOISE_TYPES)),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)),
p("brightness", "Brightness", num(0.0), slider(-10000.0, 10000.0, -200.0, 200.0, 1)),
p("overflow", "Overflow", Value::Enum(if turbulent { 0 } else { 3 }), popup(&OVERFLOWS)),
p("transform/rotation", "Rotation", num(0.0), ParamUi::Angle),
p("transform/uniformScaling", "Uniform Scaling", Value::Bool(true), ParamUi::Checkbox),
p("transform/scale", "Scale", num(100.0), scale()),
p("transform/scaleWidth", "Scale Width", num(100.0), scale()),
p("transform/scaleHeight", "Scale Height", num(100.0), scale()),
p("transform/offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("transform/perspectiveOffset", "Perspective Offset", Value::Bool(false), ParamUi::Checkbox),
p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 10.0, 1)),
p("subSettings/subInfluence", "Sub Influence (%)", num(70.0), slider(0.0, 100.0, 25.0, 100.0, 1)),
p("subSettings/subScaling", "Sub Scaling", num(56.0), slider(10.0, 100.0, 25.0, 100.0, 1)),
];
if !turbulent {
v.extend([
p("subSettings/subRotation", "Sub Rotation", num(0.0), ParamUi::Angle),
p("subSettings/subOffset", "Sub Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point),
p("subSettings/centerSubscale", "Center Subscale", Value::Bool(false), ParamUi::Checkbox),
]);
}
v.push(p("evolution", "Evolution", num(0.0), ParamUi::Angle));
if turbulent {
v.extend([
p("evolutionOptions/turbulenceFactor", "Turbulence Factor", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 2)),
p("evolutionOptions/randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
]);
} else {
v.extend([
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, 30.0, 0)),
p("evolutionOptions/seed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
]);
}
v.extend([
p("opacity", "Opacity", num(100.0), pct()),
p("blendingMode", "Blending Mode", Value::Enum(BLEND_NORMAL), popup(&BLEND_MODES)),
// Not in After Effects: a mix with the untouched layer (kept for older projects).
p("blend", "Blend With Original", num(0.0), pct()),
]);
v
}
pub fn specs() -> Vec<EffectSpec> {
vec![spec("ec.noise.fractal", "Fractal Noise", params(false), fractal_noise), spec("ec.noise.turbulent", "Turbulent Noise", params(true), turbulent_noise)]
}
// ---------------------------------------------------------------- noise kernels
/// Soft Linear's gain about mid-grey. Averaging 3 × 3 lattice values leaves the B-spline 0.55² of
/// their variance, against 0.784² (181/231 per axis) for the quintic fade Soft Linear used before
/// (#259); the gain keeps the noise's contrast.
const SOFT_LINEAR_GAIN: f32 = (181.0 / 231.0) / 0.55;
#[inline]
fn catmull(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {
0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t * t + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t * t * t)
}
/// Uniform quadratic B-spline weights of the lattice values before, at and after the nearest one,
/// for `t` = position − nearest + 0.5 in 0..1.
#[inline]
fn bspline(t: f32) -> [f32; 3] {
let s = 1.0 - t;
[0.5 * s * s, 0.5 + t * s, 0.5 * t * t]
}
/// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the
/// evolution axis z always fades smoothly).
///
/// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic
/// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values
/// has zero slope across every lattice line, which the turbulent types and high contrast turned
/// into a visible grid (#259); the B-spline's slope there is half the neighbours' difference.
pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
// Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's.
let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 };
let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor());
let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0));
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let slice = |dz: i32| -> f32 {
let c = |dx: i32, dy: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
match noise_type {
0 => c(0, 0),
1 => l(l(c(0, 0), c(1, 0), tx), l(c(0, 1), c(1, 1), tx), ty),
NOISE_SOFT_LINEAR => {
let (wx, wy) = (bspline(tx), bspline(ty));
let row = |dy: i32| wx[0] * c(-1, dy) + wx[1] * c(0, dy) + wx[2] * c(1, dy);
wy[0] * row(-1) + wy[1] * row(0) + wy[2] * row(1)
}
_ => {
let row = |dy: i32| catmull(c(-1, dy), c(0, dy), c(1, dy), c(2, dy), tx);
catmull(row(-1), row(0), row(1), row(2), ty)
}
}
};
let n = l(slice(0), slice(1), fz);
if noise_type == NOISE_SOFT_LINEAR { 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN } else { n }
}
/// Everything the per-pixel evaluation needs, resolved from the parameters.
pub(crate) struct Fractal {
pub kind: u32,
pub noise_type: u32,
pub contrast: f32,
pub brightness: f32,
pub overflow: u32,
pub invert: bool,
/// Rotation (sin, cos).
pub rot: (f32, f32),
/// Noise cell size in pixels along the rotated x / y axes.
pub size: [f32; 2],
/// Offset Turbulence in pixels, and the layer centre (for Perspective Offset).
pub origin: [f32; 2],
pub center: [f32; 2],
pub perspective: bool,
pub octaves: f32,
pub influence: f32,
/// Sub Scaling as a fraction (each layer is this much smaller).
pub sub_scaling: f32,
pub sub_rotation: f32,
/// Sub Offset in pixels.
pub sub_offset: [f32; 2],
pub center_subscale: bool,
/// Evolution in revolutions.
pub evolution: f32,
/// Cycle length in revolutions when Cycle Evolution is on.
pub cycle: Option<f32>,
pub turbulence: f32,
pub seed: u32,
}
#[inline]
fn shape(kind: u32, n: f32) -> f32 {
let s = n * 2.0 - 1.0;
match kind {
1 => 1.0 - s * s,
2 => 1.0 - s.abs(),
3 => (1.0 - s.abs()).powi(2),
8 => 1.0 - (s.abs() / 0.12).clamp(0.0, 1.0).powi(2),
_ => n,
}
}
impl Fractal {
pub(crate) fn from_params(ctx: &EffectCtx, b: &Buf, turbulent: bool) -> Fractal {
let pr = ctx.params;
let uniform = pr.get("transform/uniformScaling").is_none_or(Value::as_bool);
let s = pr.f("transform/scale");
let (sw, sh) = if uniform { (s, s) } else { (pr.f("transform/scaleWidth"), pr.f("transform/scaleHeight")) };
let rot = (pr.f("transform/rotation") as f32).to_radians();
let (ox, oy) = b.to_px(pr.v2("transform/offset"));
let (cx, cy) = b.to_px([ctx.layer_size[0] / 2.0, ctx.layer_size[1] / 2.0]);
let so = pr.v2("subSettings/subOffset");
let cycle = (!turbulent && pr.b("evolutionOptions/cycleEvolution")).then(|| pr.f("evolutionOptions/cycle").round().max(1.0) as f32);
let seed = if turbulent { (pr.f("evolutionOptions/randomSeed") as i64 as u32) ^ 0x7a3d } else { pr.f("evolutionOptions/seed") as u32 ^ 0x51ed };
Fractal {
kind: pr.e("fractalType"),
noise_type: pr.get("noiseType").map(Value::as_enum).unwrap_or(NOISE_SOFT_LINEAR),
contrast: pr.f("contrast") as f32 / 100.0,
brightness: pr.f("brightness") as f32 / 100.0,
overflow: pr.e("overflow"),
invert: pr.b("invert"),
rot: rot.sin_cos(),
size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32],
origin: [ox as f32, oy as f32],
center: [cx as f32, cy as f32],
perspective: pr.b("transform/perspectiveOffset"),
octaves: pr.f("complexity").clamp(1.0, 20.0) as f32,
influence: (pr.f("subSettings/subInfluence") as f32 / 100.0).clamp(0.0, 1.0),
sub_scaling: (pr.f("subSettings/subScaling") as f32 / 100.0).clamp(0.1, 1.0),
sub_rotation: (pr.f("subSettings/subRotation") as f32).to_radians(),
sub_offset: [(so[0] * b.scale) as f32, (so[1] * b.scale) as f32],
center_subscale: pr.b("subSettings/centerSubscale"),
evolution: pr.f("evolution") as f32 / 360.0,
cycle,
turbulence: if turbulent { pr.f("evolutionOptions/turbulenceFactor").max(0.0) as f32 } else { 0.0 },
seed,
}
}
/// One noise layer at lattice coordinates (u, v) for octave `o` (evolution offset `z`), with
/// Cycle Evolution's loop. With turbulence, finer layers evolve faster.
#[inline]
fn layer(&self, u: f32, v: f32, o: f32, z: f32, seed: u32) -> f32 {
match self.cycle {
Some(c) => {
let t = self.evolution.rem_euclid(c);
let w = t / c;
let a = typed_noise(u, v, t + z, seed, self.noise_type);
let b = typed_noise(u, v, t - c + z, seed, self.noise_type);
a * (1.0 - w) + b * w
}
None => typed_noise(u, v, self.evolution * (1.0 + self.turbulence * o) + z, seed, self.noise_type),
}
}
/// Raw fractal value (before contrast / overflow) at pixel centre (x, y).
pub(crate) fn raw(&self, x: f32, y: f32) -> f32 {
let n_oct = self.octaves.ceil() as usize;
let frac = self.octaves - self.octaves.floor();
let (sr, cr) = self.rot;
let (mut sum, mut norm, mut amp, mut f, mut best) = (0.0f32, 0.0f32, 1.0f32, 1.0f32, 0.0f32);
let mut depth = 1.0f32;
for o in 0..n_oct {
let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 };
let of = o as f32;
// This layer's origin: Perspective Offset moves finer (farther) layers less;
// turbulence makes them drift faster.
let k = if self.perspective { depth } else { 1.0 } * (1.0 + self.turbulence * of);
let mut org = self.origin;
if k != 1.0 {
org = [self.center[0] + (self.origin[0] - self.center[0]) * k, self.center[1] + (self.origin[1] - self.center[1]) * k];
}
if !self.center_subscale {
org = [org[0] + self.sub_offset[0] * of, org[1] + self.sub_offset[1] * of];
}
let (dx, dy) = (x - org[0], y - org[1]);
let (mut u, mut v) = ((dx * cr + dy * sr) / self.size[0], (-dx * sr + dy * cr) / self.size[1]);
if self.sub_rotation != 0.0 {
let (s, c) = (self.sub_rotation * of).sin_cos();
(u, v) = (u * c + v * s, -u * s + v * c);
}
let (mut u, mut v) = (u * f, v * f);
let z = of * 7.31;
let seed = self.seed.wrapping_add(o as u32);
if (4..=6).contains(&self.kind) {
// Dynamic: domain warp by a coarse field (Twist rotates, Progressive grows with depth).
let k = if self.kind == 6 { 0.5 + of * 0.35 } else { 1.0 };
let a = self.layer(u * 0.5, v * 0.5, of, z + 3.1, seed ^ 0x55) - 0.5;
let bb = self.layer(u * 0.5, v * 0.5, of, z + 5.7, seed ^ 0xaa) - 0.5;
if self.kind == 5 {
let (s, c) = (a * 3.0).sin_cos();
(u, v) = (u * c - v * s, u * s + v * c);
} else {
u += a * 1.5 * k;
v += bb * 1.5 * k;
}
}
let n = shape(self.kind, self.layer(u, v, of, z, seed));
if self.kind == 7 {
best = best.max(n * amp);
}
sum += n * amp * w;
norm += amp * w;
amp *= self.influence;
f /= self.sub_scaling;
depth *= self.sub_scaling;
}
if self.kind == 7 { best } else { sum / norm.max(1e-6) }
}
/// Final grey value: contrast, brightness, overflow and invert applied.
pub(crate) fn value(&self, x: f32, y: f32) -> f32 {
let v = (self.raw(x, y) - 0.5) * self.contrast + 0.5 + self.brightness;
let v = overflow(v, self.overflow);
if self.invert { 1.0 - v } else { v }
}
}
/// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the
/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the
/// pixel, in the same f32 order.
pub struct FractalGpu {
pub kind: u32,
pub noise_type: u32,
pub seed: u32,
pub invert: bool,
pub overflow: u32,
pub contrast: f32,
pub brightness: f32,
/// Rotation (sin, cos).
pub rot: (f32, f32),
pub size: [f32; 2],
/// Cycle Evolution: (t, cycle, t / cycle).
pub cycle: Option<[f32; 3]>,
pub sub_rotation: bool,
/// Σ amplitude × weight (the normaliser).
pub norm: f32,
/// Per octave: origin x, y, sub-rotation sin, cos, frequency, amplitude, weight, evolution
/// (turbulence applied), z offset, Dynamic Progressive warp factor.
pub octaves: Vec<[f32; 10]>,
/// Blending Mode (`None` = the noise alone), Opacity and Blend With Original.
pub mode: Option<BlendMode>,
pub opacity: f32,
pub blend: f32,
}
/// [`FractalGpu`] for a buffer with `offset` / `scale`.
pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: bool) -> FractalGpu {
let b = Buf { img: effectcraft_raster::Image::new(0, 0), offset, scale };
let fr = Fractal::from_params(ctx, &b, turbulent);
let n_oct = fr.octaves.ceil() as usize;
let frac = fr.octaves - fr.octaves.floor();
let (mut norm, mut amp, mut f, mut depth) = (0.0f32, 1.0f32, 1.0f32, 1.0f32);
let mut octaves = Vec::with_capacity(n_oct);
for o in 0..n_oct {
let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 };
let of = o as f32;
let k = if fr.perspective { depth } else { 1.0 } * (1.0 + fr.turbulence * of);
let mut org = fr.origin;
if k != 1.0 {
org = [fr.center[0] + (fr.origin[0] - fr.center[0]) * k, fr.center[1] + (fr.origin[1] - fr.center[1]) * k];
}
if !fr.center_subscale {
org = [org[0] + fr.sub_offset[0] * of, org[1] + fr.sub_offset[1] * of];
}
let (s, c) = (fr.sub_rotation * of).sin_cos();
let evo = fr.evolution * (1.0 + fr.turbulence * of);
let dk = if fr.kind == 6 { 0.5 + of * 0.35 } else { 1.0 };
octaves.push([org[0], org[1], s, c, f, amp, w, evo, of * 7.31, dk]);
norm += amp * w;
amp *= fr.influence;
f /= fr.sub_scaling;
depth *= fr.sub_scaling;
}
let cycle = fr.cycle.map(|c| {
let t = fr.evolution.rem_euclid(c);
[t, c, t / c]
});
FractalGpu {
kind: fr.kind,
noise_type: fr.noise_type,
seed: fr.seed,
invert: fr.invert,
overflow: fr.overflow,
contrast: fr.contrast,
brightness: fr.brightness,
rot: fr.rot,
size: fr.size,
cycle,
sub_rotation: fr.sub_rotation != 0.0,
norm,
octaves,
mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) {
BLEND_NONE => None,
m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)),
},
opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0),
blend: ctx.params.f("blend") as f32 / 100.0,
}
}
/// Map a value by an [`OVERFLOWS`] option.
pub fn overflow(v: f32, mode: u32) -> f32 {
match mode {
1 => (0.5 + 0.5 * (2.0 * (v - 0.5)).tanh() / 1f32.tanh()).clamp(0.0, 1.0),
2 => {
let t = v.rem_euclid(2.0);
if t > 1.0 { 2.0 - t } else { t }
}
3 => v,
_ => v.clamp(0.0, 1.0),
}
}
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
BLEND_NONE => src,
m => {
let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal);
blend_pixel(bm, orig, src, 0.5)
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
}
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
let fr = Fractal::from_params(ctx, &b, turbulent);
let opacity = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let mode = ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE);
let blend = ctx.params.f("blend") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let v = fr.value(x as f32 + 0.5, y as f32 + 0.5);
*px = composite(*px, v, opacity, mode, blend);
}
});
b
}
fn fractal_noise(ctx: &EffectCtx, b: Buf) -> Buf {
render(ctx, b, false)
}
fn turbulent_noise(ctx: &EffectCtx, b: Buf) -> Buf {
render(ctx, b, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn run(id: &str, vals: &[(&str, Value)]) -> Image {
run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img
}
/// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and
/// the new controls take values that keep the old look.
#[test]
fn old_fractal_noise_instances_upgrade() {
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, Property};
let spec = crate::find("ec.noise.fractal").unwrap();
let mut next = 1;
let mut ids = Ids(&mut next);
let mut g = ids.group(spec.id, "Fractal Noise");
g.kind = GroupKind::Effect { effect: spec.id.into() };
for (m, v) in [
("fractalType", Value::Enum(1)),
("invert", Value::Bool(false)),
("contrast", num(150.0)),
("brightness", num(0.0)),
("scale", num(40.0)),
("offset", Value::Vec2([5.0, 6.0])),
("rotation", num(30.0)),
("complexity", num(3.0)),
("evolution", num(0.0)),
("seed", num(9.0)),
("opacity", num(100.0)),
("blend", num(0.0)),
] {
let id = ids.alloc();
g.children.push(Property::new(id, m, m, v).into());
}
assert!(crate::migrate::upgrade_instance(spec, &mut g, &mut ids, [40.0, 24.0]));
assert_eq!(g.prop("transform/scale").unwrap().value, num(40.0));
assert_eq!(g.prop("transform/offset").unwrap().value, Value::Vec2([5.0, 6.0]));
assert_eq!(g.prop("evolutionOptions/seed").unwrap().value, num(9.0));
assert!(g.get("scale").is_none() && g.get("seed").is_none());
// Clip, half-weight half-size octaves and the noise drawn alone, as before.
assert_eq!(g.get("overflow").unwrap().value, Value::Enum(0));
assert_eq!(g.prop("subSettings/subInfluence").unwrap().value, num(50.0));
assert_eq!(g.prop("subSettings/subScaling").unwrap().value, num(50.0));
assert_eq!(g.get("blendingMode").unwrap().value, Value::Enum(BLEND_NONE));
assert_eq!(g.sub("transform").unwrap().name, "Transform");
}
#[test]
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(4, 4), a.get(5, 5));
let others: Vec<Image> = (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]);
}
#[test]
fn scale_width_height_apply_without_uniform_scaling() {
let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))];
let uni = run("ec.noise.fractal", &[base[0].clone(), base[1].clone(), ("transform/scaleWidth", num(50.0))]);
let plain = run("ec.noise.fractal", base);
assert_eq!(uni, plain, "Scale Width is ignored while Uniform Scaling is on");
let wide = run(
"ec.noise.fractal",
&[base[0].clone(), ("transform/uniformScaling", Value::Bool(false)), ("transform/scaleWidth", num(400.0)), ("transform/scaleHeight", num(8.0))],
);
// Stretched horizontally: horizontal neighbours are more alike than vertical ones.
let (mut dh, mut dv) = (0.0, 0.0);
for y in 0..23 {
for x in 0..39 {
dh += (wide.get(x + 1, y)[0] - wide.get(x, y)[0]).abs();
dv += (wide.get(x, y + 1)[0] - wide.get(x, y)[0]).abs();
}
}
assert!(dh < dv * 0.5, "{dh} {dv}");
}
#[test]
fn overflow_hdr_keeps_out_of_range_values() {
let hi = run("ec.noise.fractal", &[("contrast", num(400.0)), ("blendingMode", Value::Enum(BLEND_NONE))]);
assert!(hi.data.iter().any(|p| p[0] > 1.0 || p[0] < 0.0));
let clip = run("ec.noise.fractal", &[("contrast", num(400.0)), ("overflow", Value::Enum(0)), ("blendingMode", Value::Enum(BLEND_NONE))]);
assert!(clip.data.iter().all(|p| (0.0..=1.0).contains(&p[0])));
}
#[test]
fn cycle_evolution_loops() {
let at = |evo: f64| {
run("ec.noise.fractal", &[("evolution", num(evo)), ("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(2.0))])
};
assert_eq!(at(0.0), at(720.0));
assert_ne!(at(0.0), at(360.0));
}
#[test]
fn blending_modes_composite_over_the_layer() {
let none = run("ec.noise.fractal", &[("blendingMode", Value::Enum(BLEND_NONE))]);
let mul = run("ec.noise.fractal", &[("blendingMode", Value::Enum(3))]);
// Multiply keeps the layer's colour cast (blue > red), None is grey.
assert!(none.data.iter().all(|p| (p[0] - p[2]).abs() < 1e-6));
assert!(mul.data.iter().all(|p| p[2] >= p[0]));
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
}
#[test]
fn noise_stays_inside_the_layer_alpha() {
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = match i % 40 {
0..10 => 0.0,
10..20 => 0.5,
_ => 1.0,
};
*p = p.map(|c| c * a);
}
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
for (o, p) in img.data.iter().zip(&out.data) {
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
}
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
}
}
#[test]
fn sub_settings_and_fractal_types_change_the_result() {
let base = run("ec.noise.fractal", &[]);
for (k, v) in [
("subSettings/subInfluence", num(20.0)),
("subSettings/subScaling", num(80.0)),
("subSettings/subRotation", num(45.0)),
("subSettings/subOffset", Value::Vec2([7.0, 3.0])),
] {
assert_ne!(run("ec.noise.fractal", &[(k, v)]), base, "{k}");
}
for t in 1..FRACTAL_TYPES.len() as u32 {
assert_ne!(run("ec.noise.fractal", &[("fractalType", Value::Enum(t))]), base, "type {t}");
}
// Turbulence factor only matters while evolving.
let t0 = run("ec.noise.turbulent", &[("evolution", num(200.0)), ("evolutionOptions/turbulenceFactor", num(0.0))]);
let t1 = run("ec.noise.turbulent", &[("evolution", num(200.0)), ("evolutionOptions/turbulenceFactor", num(2.0))]);
assert_ne!(t0, t1);
}
/// Turbulent Basic Fractal Noise drawn alone at `w` × `h`.
fn turbulent_basic(w: u32, h: u32, vals: &[(&str, Value)]) -> Image {
let mut all = vec![("fractalType", Value::Enum(2)), ("blendingMode", Value::Enum(BLEND_NONE))];
all.extend_from_slice(vals);
run_fx("ec.noise.fractal", &all, Image::filled(w, h, [0.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()).img
}
/// (x, y, ∂/∂x, ∂/∂y) of the grey value at every inner pixel (central differences).
fn gradients(img: &Image) -> Vec<(i64, i64, f64, f64)> {
let v = |x: i64, y: i64| img.get(x, y)[0] as f64;
let (w, h) = (img.width as i64, img.height as i64);
(1..h - 1).flat_map(|y| (1..w - 1).map(move |x| (x, y, (v(x + 1, y) - v(x - 1, y)) / 2.0, (v(x, y + 1) - v(x, y - 1)) / 2.0))).collect()
}
/// #259: Soft Linear faded between two lattice values with zero slope across every lattice
/// line, which Turbulent Basic and high contrast showed as a square grid.
#[test]
fn soft_linear_has_no_lattice_creases() {
// One 16 px layer whose lattice lines run through the pixel centres x, y = 16k: the slope
// across them is about the average slope (with the fade it was 2% of it).
let one = turbulent_basic(160, 96, &[("complexity", num(1.0)), ("transform/scale", num(16.0)), ("transform/offset", Value::Vec2([0.5, 0.5]))]);
let (mut on, mut n_on, mut all, mut n_all) = (0.0, 0.0, 0.0, 0.0);
for (x, y, gx, gy) in gradients(&one) {
for (k, g) in [(x, gx), (y, gy)] {
all += g.abs();
n_all += 1.0;
if k % 16 == 0 {
on += g.abs();
n_on += 1.0;
}
}
}
let across = (on / n_on) / (all / n_all);
assert!(across > 0.5, "slope across lattice lines / average slope: {across}");
// The issue's settings at a quarter size: gradients run along the lattice axes about as
// strongly as along the diagonals (with the fade, 1.2× as strongly).
let img = turbulent_basic(480, 270, &[("contrast", num(140.0)), ("brightness", num(-35.0)), ("transform/scale", num(105.0))]);
let (mut axis, mut diag) = (0.0, 0.0);
for (_, _, gx, gy) in gradients(&img) {
let a = gy.atan2(gx).to_degrees().rem_euclid(90.0);
if !(15.0..=75.0).contains(&a) {
axis += gx.hypot(gy);
} else if (30.0..=60.0).contains(&a) {
diag += gx.hypot(gy);
}
}
assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag);
}
}