From fe0d11c5ccee93fe176541f8de4a2f5ee3df8034 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 18:53:32 +0700 Subject: [PATCH] Effects: Fractal Noise's Soft Linear has no lattice creases (#259) Soft Linear was value noise with a quintic fade between two lattice values per axis. That fade has zero slope across every lattice line and at every lattice point, so the noise's contours met the lattice at right angles and flattened around its points. The turbulent fold (1 - |s|) and high contrast turned that into a visible square grid at each layer's cell size (Turbulent Basic, Contrast 140, Brightness -35: gradients 1.3x as strong along the axes as along the diagonals; Spline 1.1x). Soft Linear is now linear interpolation softened by a one-cell box filter, i.e. the uniform quadratic B-spline over the 3 x 3 lattice values around the nearest one (evolution still fades between two slices): smooth everywhere, with half the neighbours' difference as its slope on lattice lines, and cheaper than Spline (9 taps instead of 16). Averaging lowers the noise's spread, so a fixed gain about mid-grey restores the quintic fade's variance and existing projects keep their contrast and brightness (issue settings: mean 0.356 -> 0.362, std 0.147 -> 0.152, axis bias 1.28 -> 1.05). Only Fractal / Turbulent Noise's Soft Linear changes: generate's value_noise and its other callers are untouched. noise3 now shares generate's lattice hash and fade instead of copying them. The GPU kernels (pointwise Fractal Noise and fxn_fractal) call the same soft_linear_noise. No golden hashes pin these cases. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate.rs | 4 +-- crates/effects/src/noise3.rs | 54 +++++++++++++++------------- crates/gpu/src/shaders/fx_noise.wgsl | 2 +- crates/gpu/src/shaders/kernels.wgsl | 32 ++++++++++++++++- 4 files changed, 64 insertions(+), 28 deletions(-) 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..746943e6 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. 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;