mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
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>
669 lines
30 KiB
Rust
669 lines
30 KiB
Rust
//! 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);
|
||
}
|
||
}
|