G1: Turbulent Displace Evolution changes the output (#943)

Turbulent Displace read its noise through value_noise, whose quintic fade also
eased the evolution axis. Near every whole revolution the pattern hardly moved:
Evolution 0 and 37 degrees (or 360 and 400) differed by well under a hundredth of
the frame, so different Evolution values looked identical, and the motion sped up
and slowed down within each revolution (the #1816 symptom).

Add value_noise_linear_z (x and y keep the fade, z is linear) on the CPU and in the
WGSL kernels and use it for Turbulent Displace on both paths. value_noise is
unchanged, so the other effects and the pinned simulation goldens are unaffected.

Tests: CPU test that non-equivalent Evolution values (fractional, past 360, past
720) all differ and that Cycle Evolution still matches 0/360; unit test of the
linear z axis; GPU-vs-CPU cases at small and large Evolution.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYXhNN7W7nB6sKjwwU7CDM
This commit is contained in:
Claude
2026-10-27 15:34:34 -05:00
parent dd12ed8f9c
commit 80b9aacea7
5 changed files with 98 additions and 6 deletions
+43 -2
View File
@@ -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);
+35 -2
View File
@@ -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.
+1 -1
View File
@@ -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;
+14 -1
View File
@@ -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);
+5
View File
@@ -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))]);