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
@@ -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);@@ -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.@@ -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;@@ -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);@@ -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))]);