diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 09b25a27..2b543352 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -262,7 +262,7 @@ fn circle(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- value noise / fBm (our own lattice noise with quintic fade) ---- #[inline] -fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 { +pub(crate) fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 { let mut h = (ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b); h ^= h >> 15; @@ -274,7 +274,7 @@ fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 { } #[inline] -fn fade(t: f32) -> f32 { +pub(crate) fn fade(t: f32) -> f32 { t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 37bf853a..fdc3140c 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -4,7 +4,7 @@ //! //! 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 = quintic fade, Spline = Catmull-Rom). Successive layers are scaled by Sub +//! 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 / @@ -17,7 +17,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use rayon::prelude::*; -use crate::generate::value_noise; +use crate::generate::{fade, lattice}; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider}; /// Fractal Type options (shared by both effects). @@ -120,36 +120,36 @@ pub fn specs() -> Vec { // ---------------------------------------------------------------- noise kernels -fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 { - // The same hash as `generate::value_noise`, so Soft Linear matches it bit for bit. - let mut h = - (ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b); - h ^= h >> 15; - h = h.wrapping_mul(0x2c1b_3c6d); - h ^= h >> 12; - h = h.wrapping_mul(0x297a_2d39); - h ^= h >> 15; - (h & 0xffff) as f32 / 65535.0 -} - -#[inline] -fn fade(t: f32) -> f32 { - t * t * t * (t * (t * 6.0 - 15.0) + 10.0) -} +/// 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 { - if noise_type == NOISE_SOFT_LINEAR { - return value_noise(x, y, z, seed); - } - let (x0, y0, z0) = (x.floor(), y.floor(), z.floor()); - let (tx, ty, fz) = (x - x0, y - y0, fade(z - z0)); + // 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 { @@ -157,13 +157,19 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 { 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) } } }; - l(slice(0), slice(1), fz) + 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. @@ -587,4 +593,53 @@ mod tests { 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); + } } diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 28439067..c508973b 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -528,7 +528,7 @@ fn fxn_slice(ix: i32, iy: i32, iz: i32, tx: f32, ty: f32, seed: u32, nt: u32) -> // noise3::typed_noise. fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { if (nt == 2u) { - return value_noise(x, y, z, seed); + return soft_linear_noise(x, y, z, seed); } let x0 = floor(x); let y0 = floor(y); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 8576fdf9..1e1ad167 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -535,6 +535,36 @@ fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { return a + (b - a) * fz; } +// Fractal Noise's Soft Linear (effects::noise3::typed_noise): the quadratic B-spline over the +// 3 × 3 lattice values around the nearest one, faded along evolution, and SOFT_LINEAR_GAIN. +const SOFT_LINEAR_GAIN: f32 = (181.0 / 231.0) / 0.55; + +fn bspline(t: f32) -> vec3 { + let s = 1.0 - t; + return vec3(0.5 * s * s, 0.5 + t * s, 0.5 * t * t); +} + +fn soft_linear_slice(ix: i32, iy: i32, iz: i32, wx: vec3, wy: vec3, seed: u32) -> f32 { + var sum = 0.0; + for (var j = 0; j < 3; j++) { + let y = iy + j - 1; + sum += wy[j] * (wx.x * lattice(ix - 1, y, iz, seed) + wx.y * lattice(ix, y, iz, seed) + wx.z * lattice(ix + 1, y, iz, seed)); + } + return sum; +} + +fn soft_linear_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { + let x0 = floor(x + 0.5); + let y0 = floor(y + 0.5); + let z0 = floor(z); + let wx = bspline(x + 0.5 - x0); + let wy = bspline(y + 0.5 - y0); + let a = soft_linear_slice(i32(x0), i32(y0), i32(z0), wx, wy, seed); + let b = soft_linear_slice(i32(x0), i32(y0), i32(z0) + 1, wx, wy, seed); + let n = a + (b - a) * fade(z - z0); + return 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN; +} + // generate::put: blend a generated straight colour (alpha ga) over the pixel. fn put(px: vec4, g: vec3, ga: f32, blend_orig: f32) -> vec4 { let g2 = vec4(g * ga, ga); @@ -800,7 +830,7 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (o + 1u == n_oct && frac > 0.0) { w = frac; } - var n = value_noise(u * f, v * f, evo + f32(o) * 7.31, seed + o); + var n = soft_linear_noise(u * f, v * f, evo + f32(o) * 7.31, seed + o); let s = n * 2.0 - 1.0; if (kind == 1u) { n = 1.0 - s * s;