collective

Browse / github.com/storytold/effectcraft / patch

effects: Turbulent Displace Evolution moves the pattern at a constant rate (#943)

Turbulent Displace: changing Evolution can leave output unchanged Discord report (AndrewFelix, October 11, 2026 at 11:37 PM): applying different random Evolution values in Turbulent Displace gives identical output. The effect is implemented in the registry and GPU shader; this report concerns parameter behavior, not absence of the effect. Please reproduce with non-equivalent Evolution values on the current build, compare CPU and GPU paths, and verify the rendered image changes as expected.

Status
open
Score
0.00
Adopters
0 other operators
Author
klosixgaming-arch (imported from pull request #2535, open)
Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537
Size
+98 −6 in 5 files
Review
pending
Shared
2026-10-28
Id
0bc66794457983a05d8c5913

How it went

No other operator has reported using it yet.

Diff

raw · crates/effects/src/distort2.rs crates/effects/src/generate.rs crates/gpu/src/shaders/fx_distort.wgsl crates/gpu/src/shaders/kernels.wgsl crates/gpu/src/tests/fx_distort.rs

diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs--- a/crates/effects/src/distort2.rs+++ b/crates/effects/src/distort2.rs@@ -19,7 +19,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; -use crate::generate::value_noise;+use crate::generate::value_noise_linear_z; use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi {  // ---------------------------------------------------------------- Turbulent Displace +/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of+/// plain value noise a few degrees of Evolution barely moved the pattern (#943). fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 {     let n = octaves.ceil().max(1.0) as usize;     let frac = (octaves - octaves.floor()) as f32;     let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32);     for o in 0..n {         let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 };-        sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;+        sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;         norm += amp * w;         amp *= falloff;         f *= 2.0;@@ -1115,6 +1117,45 @@ mod tests {         assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked");     } +    /// #943: every non-equivalent Evolution value renders a visibly different frame. The eased+    /// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other.+    #[test]+    fn turbulent_displace_evolution_changes_the_output() {+        // A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and+        // the difference is purely how far the pattern moved.+        let mut ramp = Image::new(64, 64);+        for y in 0..64u32 {+            for x in 0..64u32 {+                ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]);+            }+        }+        let render = |evo: f64, extra: &[(&str, Value)]| {+            let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))];+            run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img+        };+        let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5];+        let frames: Vec<Image> = evolutions.iter().map(|&e| render(e, &[])).collect();+        for (i, a) in frames.iter().enumerate() {+            for (j, b) in frames.iter().enumerate().skip(i + 1) {+                let d = max_diff(a, b);+                assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]);+            }+        }+        // Constant rate: 20 degrees next to a whole revolution (from either side) is a visible+        // step, not the rounding-error size the eased axis gave (under 0.001).+        assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02);+        assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02);+        // 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with+        // Cycle (in Revolutions) = 1, at any starting angle.+        assert!(max_diff(&frames[0], &frames[4]) > 0.01);+        let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))];+        assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4);+        assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4);+        assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01);+        // Random Seed still gives a different pattern at the same Evolution.+        assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01);+    }+     #[test]     fn magnify_blending_none_and_scaling() {         let img = test_img(32, 32);diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs--- a/crates/effects/src/generate.rs+++ b/crates/effects/src/generate.rs@@ -292,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 {     t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } -/// 3D value noise in 0..1 (z = evolution).+/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z+/// eases in and out of every integer. pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {+    value_noise_z(x, y, z, seed, true)+}++/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic+/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible+/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate.+pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {+    value_noise_z(x, y, z, seed, false)+}++fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {     let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());-    let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0));+    let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { 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 c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);@@ -414,6 +426,27 @@ mod generator_tests {         Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0])     } +    /// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the+    /// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it).+    #[test]+    fn linear_z_value_noise_moves_at_a_constant_rate() {+        for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] {+            let a = value_noise_linear_z(x, y, 0.0, seed);+            let b = value_noise_linear_z(x, y, 1.0, seed);+            for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] {+                let want = a + (b - a) * z;+                let got = value_noise_linear_z(x, y, z, seed);+                assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}");+            }+            // Same lattice values at whole z, and x / y are untouched.+            assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed));+        }+        // The eased version still has the old shape: at z = 0.25 it lags the linear one.+        let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5));+        let eased = value_noise(0.3, 0.7, 0.25, 5);+        assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3);+    }+     #[test]     fn gradient_ramp_stays_inside_the_layer_alpha() {         // #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape.diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl--- a/crates/gpu/src/shaders/fx_distort.wgsl+++ b/crates/gpu/src/shaders/fx_distort.wgsl@@ -238,7 +238,7 @@ fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 {         if (o + 1u == n && frac > 0.0) {             w = frac;         }-        sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w;+        sum += value_noise_linear_z(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w;         norm += amp * w;         amp *= falloff;         f *= 2.0;diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl--- a/crates/gpu/src/shaders/kernels.wgsl+++ b/crates/gpu/src/shaders/kernels.wgsl@@ -509,12 +509,25 @@ fn fade(t: f32) -> f32 { }  fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {+    return value_noise_z(x, y, z, seed, true);+}++// effects::generate::value_noise_linear_z: z (evolution) interpolates linearly, x and y keep the+// quintic fade (Turbulent Displace, #943).+fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {+    return value_noise_z(x, y, z, seed, false);+}++fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {     let x0 = floor(x);     let y0 = floor(y);     let z0 = floor(z);     let fx = fade(x - x0);     let fy = fade(y - y0);-    let fz = fade(z - z0);+    var fz = z - z0;+    if (ease_z) {+        fz = fade(fz);+    }     let ix = i32(x0);     let iy = i32(y0);     let iz = i32(z0);diff --git a/crates/gpu/src/tests/fx_distort.rs b/crates/gpu/src/tests/fx_distort.rs--- a/crates/gpu/src/tests/fx_distort.rs+++ b/crates/gpu/src/tests/fx_distort.rs@@ -55,6 +55,11 @@ fn turbulent_displace_and_cc_lens() {         ],     );     effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]);+    // #943: Evolution moves the pattern at a constant rate on both paths, including steps of a+    // few degrees and angles past a whole revolution, where an eased evolution axis diverges.+    for evolution in [10.0, 37.0, 400.0, 725.5] {+        effect_case("ec.distort.turbulentdisplace", &[("evolution", n(evolution)), ("size", n(12.0)), ("amount", n(30.0))]);+    }     effect_case("ec.distort.cclens", &[("size", n(70.0))]);     effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);     effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]);