// GENERATED by tools/gen_terrain_noise_shaderinc.py — DO NOT EDIT.
// Source: proc-nodes/src/shared/erosion_filter.inc
//
// The SAME text the density shader compiles. Included here so the
// terrain MATERIAL can evaluate the same field per-fragment and
// recover the surface normal the geometry is too coarse to carry.
// Re-run the generator after editing the .inc.
// =========================================================================
// erosion_filter.inc — THE erosion filter. One copy. Compiled BOTH as GLSL
// and as C++ from this same text.
//
// WHY THIS FILE EXISTS.
//
// This algorithm existed four times: the GLSL library string in
// proc-nodes/src/gpu/gpu_compiler.cpp, its C++ twin in
// proc-nodes/src/nodes/noise_nodes.cpp, a THIRD C++ copy in
// dc-terrain/src/dc_terrain_generator_procnodes.cpp, and a fourth in
// hyper-tps/shaders/erosion_terrain.gdshader. They were not kept in step.
// The GPU wrapper called it with scale=0.08333 and the dc-terrain CPU
// wrapper with scale=5.0 — a 60x difference in both gully frequency and
// gully amplitude — so the surface the mesher drew and the surface
// sample_density/collision reported were different functions. Nothing
// caught it because nothing compared them.
//
// This is dc_shader_source.h's invariant one level down:
//
// The GPU and the CPU evaluate the SAME TEXT.
//
// Not two implementations kept in step by review. One file, included by
// both. A divergence is now a compile error or an oracle failure, not a
// thing you find eighteen months later in a collision bug.
//
// HOW THE DUAL COMPILATION WORKS.
//
// The body below is written in the subset GLSL and C++ agree on: vec2/vec3
// constructors, member access, arithmetic operators, and the builtins
// floor/fract/dot/max/min/exp/cos/sin/pow. In a shader this text is
// injected verbatim and every one of those is a GLSL builtin. In C++ it is
// #included after erosion_cpp_shim.h, which supplies the same names with
// the same componentwise semantics. There is no #ifdef in here on purpose —
// a branch is a place for the two languages to disagree.
//
// LICENSING -- THIS FILE IS MPL-2.0. It was MIT until the advanced filter
// landed; erosion_filter and phacelle_noise are runevision's rewrite and
// carry MPL-2.0, so this file does too.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Advanced Terrain Erosion Filter + Phacelle Noise:
// (c) 2025 Rune Skovbo Johansen, MPL-2.0
// https://www.shadertoy.com/view/wXcfWn
//
// MPL-2.0 is FILE-level copyleft, which is part of why the algorithm lives
// in its own file rather than pasted into gpu_compiler.cpp: the obligation
// travels with these functions. The generated erosion_filter_glsl.gen.h is
// this text verbatim and carries the same terms.
//
// Still MIT, and still present:
//
// Gradient noise + derivatives (grad_noised_2d): (c) 2017 Inigo Quilez,
// MIT, https://www.shadertoy.com/view/XdXBRH
// Eroded terrain noise: (c) 2020 Clay John, MIT,
// https://www.shadertoy.com/view/MtGcWh
// Terrain erosion noise: (c) 2023 Fewes, MIT,
// https://www.shadertoy.com/view/7ljcRW
// Clean terrain erosion filter:(c) 2025 Rune Skovbo Johansen, MIT,
// https://www.shadertoy.com/view/33cXW8
//
// PROVENANCE. The body started as a byte-identical extraction of
// gpu_compiler.cpp lines 378-490 (verified by diff, not by eye) and has
// since changed in two places, each with its reasoning written at the
// function it changed:
//
// erosion_hash float trick -> integer lattice hash, because the float
// one was unstable at the coordinates this project uses,
// which made GPU/CPU parity impossible in principle.
// gullies_2d and the frequency approach -> phacelle_noise and the fade
// erosion_filter approach: runevision's 2026 rewrite.
//
// grad_noised_2d and fractal_noise_2d are still the extracted text.
// =========================================================================
// =========================================================================
// Erosion terrain functions — iq/runevision/clayjohn
// =========================================================================
// Hash for erosion noise (same constants as iq/runevision)
// Integer lattice hash. Both call sites -- grad_noised_2d's floor(p) corners
// and gullies_2d's p_int + grid_offset -- pass EXACT integers, so this hashes
// the integer directly and is bit-identical on every implementation that has
// 32-bit unsigned arithmetic. GLSL and C++ both define unsigned wraparound.
//
// WHAT IT REPLACED, AND WHY. The previous hash was iq's float trick:
//
// inner = fract(x.x * x.y * (x.x + x.y)); return -1 + 2*fract(16*k*inner);
//
// That product passes 2^24 quickly -- at lattice coord 100 it is already
// ~3.5e5 with float32 ULP coarse enough to matter, and by 1000 it is ~1e8 --
// after which fract() has no resolution left. The failure is not a gentle
// loss of quality: fract is a step function, so a 1-ULP difference anywhere
// upstream lands on the far side of an integer boundary and the hash returns
// an UNRELATED gradient. Measured against a float64 reference, the fraction
// of lattice points whose hash flips this way:
//
// lattice coord 1 10 100 1000 1e6
// flipped 0.54% 0.83% 83.86% 99.49% noise dies entirely
//
// (At 1e6 the product exceeds float32 entirely, fract returns 0 everywhere,
// and the hash degenerates to a constant -- the terrain goes flat.)
//
// map_dc_erosion's detail layer runs at scale 200, which puts it at lattice
// coord 100 at x=10km and ~654 by its fourth octave: squarely in the >84%
// regime. That is why the GPU and the CPU disagreed by 23 m on IDENTICAL
// source, and why single-sourcing alone could never have made them agree.
// Measured: turning the detail layer off dropped worst |glsl - c++| from
// 23.2251 m to 0.0171 m. See dc-terrain/tests/test_erosion_parity.cpp.
//
// The new ceiling is 2^24 (16.7M) -- the point where a float stops
// representing consecutive integers at all, so floor() itself would fail
// first. At 1 m voxels that is 16,777 km, against a 57.3 km world.
//
// The mix is pcg2d (Jarzynski & Olano, "Hash Functions for GPU Rendering",
// JCGT 2020), written with scalar uints rather than uvec2 because GLSL
// `inout` and C++ references are not the same thing and this file must
// compile as both. Output is masked to 24 bits before the float conversion:
// 2^24-1 is exactly representable in float32 and the scale is a power of
// two, so the conversion is exact rather than merely round-to-nearest on
// both sides.
//
// THIS CHANGES EVERY SEED. The world it generates is a different world from
// the one the float hash generated. That was the accepted cost of making the
// meshed surface and the collidable surface the same surface.
vec2 erosion_hash(vec2 x) {
uint vx = uint(int(x.x));
uint vy = uint(int(x.y));
vx = vx * 1664525u + 1013904223u;
vy = vy * 1664525u + 1013904223u;
vx += vy * 1664525u;
vy += vx * 1664525u;
vx = vx ^ (vx >> 16u);
vy = vy ^ (vy >> 16u);
vx += vy * 1664525u;
vy += vx * 1664525u;
vx = vx ^ (vx >> 16u);
vy = vy ^ (vy >> 16u);
return vec2(
-1.0 + 2.0 * (float(vx >> 8u) * (1.0 / 16777216.0)),
-1.0 + 2.0 * (float(vy >> 8u) * (1.0 / 16777216.0)));
}
// iq's gradient noise with analytical derivatives (2D)
// Returns vec3(height, dh/dx, dh/dy)
vec3 grad_noised_2d(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
vec2 u = f*f*f*(f*(f*6.0-15.0)+10.0);
vec2 du = 30.0*f*f*(f*(f-2.0)+1.0);
vec2 ga = erosion_hash(vec2(i.x, i.y));
vec2 gb = erosion_hash(vec2(i.x+1.0, i.y));
vec2 gc = erosion_hash(vec2(i.x, i.y+1.0));
vec2 gd = erosion_hash(vec2(i.x+1.0, i.y+1.0));
float va = dot(ga, f);
float vb = dot(gb, f - vec2(1.0, 0.0));
float vc = dot(gc, f - vec2(0.0, 1.0));
float vd = dot(gd, f - vec2(1.0, 1.0));
float height = va + u.x*(vb-va) + u.y*(vc-va) + u.x*u.y*(va-vb-vc+vd);
vec2 deriv = ga + (gb-ga)*u.x + (gc-ga)*u.y + (ga-gb-gc+gd)*u.x*u.y +
vec2(du.x * (u.y*(va-vb-vc+vd) + vb-va),
du.y * (u.x*(va-vb-vc+vd) + vc-va));
return vec3(height, deriv.x, deriv.y);
}
// fBM of grad_noised_2d with derivative accumulation
// NYQUIST OCTAVE LIMIT, as a FRACTIONAL count.
//
// An octave whose wavelength falls below about twice the voxel size cannot be
// represented by the mesher: it aliases, and in a dual-contouring mesher that
// shows up as a field of thin vertical spikes. This generator had no protection
// at all -- the wrapper's mountain/relief/detail layers passed literal octave
// counts, and the graph nodes' min(N, pn_max_octaves) was disarmed by setting
// pn_max_octaves = 99. The aliasing is therefore always present and merely
// below notice at low mountain_scale; raising mountain_scale shortens every
// mountain octave together and drives the fine ones under the voxel size.
//
// Returns how many octaves survive, FRACTIONALLY. That matters: a hard integer
// cutoff makes an octave appear all at once when a chunk refines, which moves
// the surface and pops geometry between LOD levels -- the documented reason
// octave clamping was turned off here in the first place. A fractional limit
// lets the caller fade the last octave in and out continuously instead.
//
// voxel <= 0 means "caller does not know / does not care" and disables the
// limit. DCTerrain::sample_density passes 0 deliberately (dc_terrain.cpp:2841),
// so collision always evaluates the full field.
float nyquist_octaves(float base_wavelength, float voxel, float lac) {
if (voxel <= 0.0 || lac <= 1.0) {
return 999.0;
}
float ratio = base_wavelength / max(2.0 * voxel, 1e-6);
if (ratio <= 1.0) {
return 0.0;
}
return log(ratio) / log(lac);
}
// ELEVATION-WEIGHTED fBm -- a multifractal, and the answer to "all the mountain
// ranges look the same".
//
// Plain fBm gives every location the same amount of every octave, so the terrain
// is statistically HOMOGENEOUS: equally rugged in the basins and on the peaks.
// That sameness is what reads as "no variance between ranges" no matter how the
// octave count or persistence are tuned -- those change how rough it is
// everywhere, not where the roughness goes.
//
// A multifractal makes each octave's amplitude depend on the terrain already
// accumulated, so high ground becomes rugged and low ground stays smooth. Real
// ranges do this: jagged peaks above smooth valley floors.
//
// WHY NOT MUSGRAVE'S HETERO TERRAIN, which is the textbook choice and is already
// in this library as perlin_hetero: it MULTIPLIES the running value each octave,
// so its output range depends on offset, roughness and octave count together and
// grows without a fixed bound. Blending that against fBm would need a
// normalisation measured per parameter set, and an unbounded field one slider
// away from a user is a NaN waiting to happen. Weighting the AMPLITUDE instead
// keeps fBm's envelope exactly, so `variance` is a safe 0..1 dial and 0 is
// bit-identical to fractal_noise_2d.
//
// THE DERIVATIVE IS APPROXIMATE. d(weight)/dp is dropped -- only the noise term
// is differentiated. That is deliberate and it is safe HERE because this
// gradient is consumed for gully DIRECTION, which erosion_filter normalises
// (normalize_safe) and then largely discards anyway: assumed_slope mixes at 1.0,
// so the real slope survives only through slope_length in the initial mask. Do
// not reuse this derivative anywhere that needs it to be exact.
// iq's GRADIENT TRICK, from "fbm with derivatives"
// (https://iquilezles.org/articles/morenoise/). Each octave is damped by the
// gradient ACCUMULATED so far, so steep ground loses its fine octaves while
// flats keep them -- a cheap stand-in for water running faster on slopes.
//
// This is NOT what fractal_multifractal_2d_lod below does. That one weights by
// accumulated ELEVATION (Musgrave: high ground gets more detail). Same shape of
// idea, different axis, and the two are worth having side by side.
//
// Following iq exactly, the accumulator is the RAW per-octave derivative, with
// neither the amplitude nor the frequency folded in, and the weight is
// 1/(1 + damping * dot(d, d)). damping = 0 reduces this to plain fBm.
//
// CAVEAT, and it is the same one the multifractal already carries: the returned
// .yz is no longer the exact gradient of the returned .x, because the weight is
// itself position-dependent and its own derivative is not included. The erosion
// filter uses the input slope only for gully DIRECTION and replaces its
// magnitude (assumed_slope), so this matters less here than it would elsewhere.
vec3 fractal_gradient_2d_lod(vec2 p, float freq, int octaves, float lac,
float gain, float damping, float max_octaves) {
vec3 n = vec3(0.0);
vec2 dsum = vec2(0.0);
float nf = freq;
float na = 1.0;
for (int i = 0; i < octaves; i++) {
float lw = clamp(max_octaves - float(i), 0.0, 1.0);
if (lw <= 0.0) {
na *= gain;
nf *= lac;
continue;
}
vec3 d = grad_noised_2d(p * nf);
dsum += vec2(d.y, d.z);
float w = 1.0 / (1.0 + damping * dot(dsum, dsum));
n += vec3(d.x * na * w * lw, d.y * na * nf * w * lw, d.z * na * nf * w * lw);
na *= gain;
nf *= lac;
}
return n;
}
vec3 fractal_multifractal_2d_lod(vec2 p, float freq, int octaves, float lac,
float gain, float variance, float max_octaves) {
vec3 n = vec3(0.0);
float nf = freq;
float na = 1.0;
for (int i = 0; i < octaves; i++) {
float lw = clamp(max_octaves - float(i), 0.0, 1.0);
if (lw <= 0.0) {
na *= gain;
nf *= lac;
continue;
}
// Weight from the elevation SO FAR. The 3.0 maps a typical fBm value
// range of about +/-0.33 onto 0..1; below that the weight floors and the
// octave is suppressed, which is the point.
float w = mix(1.0, clamp(0.5 + n.x * 3.0, 0.0, 1.0), variance);
vec3 d = grad_noised_2d(p * nf);
n += vec3(d.x * na * w * lw, d.y * na * nf * w * lw, d.z * na * nf * w * lw);
na *= gain;
nf *= lac;
}
return n;
}
vec3 fractal_multifractal_2d(vec2 p, float freq, int octaves, float lac,
float gain, float variance) {
return fractal_multifractal_2d_lod(p, freq, octaves, lac, gain, variance, 999.0);
}
vec3 fractal_noise_2d_lod(vec2 p, float freq, int octaves, float lac, float gain,
float max_octaves) {
vec3 n = vec3(0.0);
float nf = freq;
float na = 1.0;
for (int i = 0; i < octaves; i++) {
// Fractional taper: full weight while the octave is comfortably above
// Nyquist, fading to zero as it crosses. Continuous in max_octaves, so a
// chunk refining to a finer LOD grows the octave in rather than snapping
// it on and moving the surface under the player.
float w = clamp(max_octaves - float(i), 0.0, 1.0);
if (w > 0.0) {
vec3 d = grad_noised_2d(p * nf);
n += vec3(d.x * na * w, d.y * na * nf * w, d.z * na * nf * w);
}
na *= gain;
nf *= lac;
}
return n;
}
// Unlimited fBm. Kept so existing callers -- GradientNoiseNode's emitted GLSL,
// ErosionNoiseNode's mesh path -- are untouched by the LOD work.
vec3 fractal_noise_2d(vec2 p, float freq, int octaves, float lac, float gain) {
return fractal_noise_2d_lod(p, freq, octaves, lac, gain, 999.0);
}
// -------------------------------------------------------------------------
// THE ADVANCED FILTER — runevision's 2026 rewrite, MPL-2.0.
//
// What was here before was the FREQUENCY APPROACH: gullies_2d took an
// unnormalised slope and built its stripe direction as
//
// side_dir = vec2(-slope.y, slope.x) * 2.0 * PI
//
// so stripe FREQUENCY was proportional to slope magnitude, and at zero slope
// the stripes became infinitely thick. That is the 2018 clayjohn / 2023 Fewes
// lineage. It has two consequences runevision documents and we had both:
// valleys get bulges at the bottom and cannot be made V-shaped, and the only
// way to hide it is to fade erosion out at low altitude -- which is exactly
// what the WATER_HEIGHT smoothstep in erosion_node_eval was doing. We
// inherited a workaround for a defect and shipped it as a feature.
//
// The fade approach keeps stripe width CONSTANT (phacelle_noise takes a
// NORMALISED direction plus a separate freq) and instead fades the stripes
// towards a caller-supplied fade target as steepness approaches zero. That
// single change is what makes the rest possible:
//
// - normalized gullies: because side_dir is applied AFTER interpolation
// rather than inside the 16-cell loop, the interpolated (cos, sin) pair
// can be treated as a point on a circle and renormalised, giving gullies
// a consistent magnitude and therefore crisp ridges. Partial (k=2, i.e.
// normalization 0.5) because full normalisation produces spiky loop
// artefacts where the waves cancel.
// - straight gullies: gully_slope accumulates sign(ph.y) rather than ph.y,
// as if the gullies were extruded triangle waves, so smaller gullies
// branch off larger ones cleanly instead of curling along them. The
// OUTPUT derivative keeps the true sine; only the internal one is faked,
// which is why there are two slope accumulators.
// - stacked fading: each octave's mask multiplies into a combi-mask
// (combi_mask = pow_inv(combi_mask, detail) * new_mask) and its faded
// gullies become the next octave's fade target, so large ridges survive
// the small octaves instead of dissolving into them.
// - the ridge map: falls out for free, tracked in parallel without the
// rounding and gully-weight terms that would otherwise smear it.
//
// LICENSING. erosion_filter and phacelle_noise below are
// Advanced Terrain Erosion Filter / Phacelle Noise
// (c) 2025 Rune Skovbo Johansen, MPL-2.0
// https://www.shadertoy.com/view/wXcfWn
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Translated with reference to Luke Mitchell's burstable C# port
// (github.com/lpmitchell/AdvancedTerrainErosion, MPL-2.0 for these two
// functions). grad_noised_2d remains iq's, MIT; see the header.
//
// NO `out` PARAMETERS AND NO CONSTRUCTORS BELOW. GLSL `out` is not a C++
// reference and GLSL's positional struct constructor is not C++ aggregate
// initialisation, so the result comes back as a struct whose fields are
// assigned one at a time. That is the subset both languages agree on.
// -------------------------------------------------------------------------
float clamp01(float t) {
return clamp(t, 0.0, 1.0);
}
// Flip, raise to the power, flip back. Controls how aggressively higher
// frequency gullies are restricted to steeper ground: lower = more detail.
float pow_inv(float t, float power) {
return 1.0 - pow(1.0 - clamp01(t), power);
}
// Mirror of sqrt around the diagonal. The obvious shaping function here is
// pow(slope, 0.5), and it produces visible folds at peaks and valleys because
// the sqrt curve starts VERTICAL -- the tiniest slant already gets near-full
// erosion. This starts gently and mostly removes them.
float ease_out(float t) {
float v = 1.0 - clamp01(t);
return 1.0 - v * v;
}
// Variable-size ease-in chained onto the shaping function; this is what
// "rounding" of ridges and creases actually is. Quadratic below `smoothing`,
// linear above, meeting with matching value and slope.
//
// The division is safe for the values this file passes: t is always a
// magnitude (>= 0) and the t >= smoothing branch catches smoothing == 0.
float smooth_start(float t, float smoothing) {
if (t >= smoothing) {
return t - 0.5 * smoothing;
}
return 0.5 * t * t / smoothing;
}
// normalize() is undefined at zero length in GLSL. gully_slope genuinely can
// reach zero (flat ground, first octave), so it needs the guard.
vec2 normalize_safe(vec2 v) {
float l = length(v);
if (l > 1e-20) {
return v / l;
}
return vec2(0.0);
}
// PHACELLE NOISE — phase + cell. A stripe pattern aligned to norm_dir, built
// by interpolating cosine and sine waves from a 4x4 neighbourhood of randomly
// offset cell points.
//
// norm_dir MUST be normalised. This is the whole difference from the
// old gullies_2d, which took a raw slope and let its magnitude
// set the frequency.
// freq stripes per cell. Keep near 1; large values distort.
// offset phase offset, 1.0 = a full cycle.
// normalization degree of renormalisation, 0..1. At 0.5, raw magnitudes
// below 0.5 are left alone and everything above is pulled to
// 1 -- the k=2 partial normalisation that gives consistent
// gully magnitude without the loop artefacts full
// normalisation produces where the waves cancel exactly.
//
// Returns (cos, sin) normalised, plus side_dir, which the caller multiplies
// onto the sine to get the cosine's derivatives. Postponing that multiply out
// of the loop is what makes the normalisation possible at all: side_dir is
// constant across all 16 samples, so it can be factored out.
vec4 phacelle_noise(vec2 p, vec2 norm_dir, float freq, float offset, float normalization) {
// PN_TAU, not TAU: Godot shading language RESERVES TAU as a builtin, and
// this .inc is compiled as a fog shader as well as GLSL 450 and C++.
// Measured: `SHADER ERROR: Redefinition of 'TAU'` from a FogVolume.
const float PN_TAU = 6.28318530717958647;
vec2 side_dir = vec2(-norm_dir.y, norm_dir.x) * freq * PN_TAU;
offset *= PN_TAU;
vec2 p_int = floor(p);
vec2 p_frac = p - p_int;
vec2 phase_dir = vec2(0.0);
float weight_sum = 0.0;
for (int i = -1; i <= 2; i++) {
for (int j = -1; j <= 2; j++) {
vec2 grid_offset = vec2(float(i), float(j));
vec2 grid_point = p_int + grid_offset;
vec2 random_offset = erosion_hash(grid_point) * 0.5;
vec2 v = p_frac - grid_offset - random_offset;
// Bell weight, 1 at distance 0 and ~0 at 1.5. The 0.01111 makes it
// exactly 0 there, which removes faint grid-line artefacts: with
// random offsets up to 0.5, 1.5 is the closest a cell point OUTSIDE
// the 4x4 neighbourhood can be.
float sqr_dist = dot(v, v);
float weight = max(0.0, exp(-sqr_dist * 2.0) - 0.01111);
weight_sum += weight;
float wave_input = dot(v, side_dir) + offset;
phase_dir += vec2(cos(wave_input), sin(wave_input)) * weight;
}
}
vec2 interpolated = phase_dir / max(weight_sum, 1e-6);
float magnitude = sqrt(dot(interpolated, interpolated));
magnitude = max(1.0 - normalization, magnitude);
return vec4(interpolated.x / magnitude, interpolated.y / magnitude,
side_dir.x, side_dir.y);
}
struct ErosionResult {
vec3 delta; // height and slope delta, to add to the input
float magnitude; // summed per-octave strength; scales the height offset
float ridge; // -1 in creases, +1 on ridges. Free; drives materials.
};
// rounding = (ridges, creases, input scale, per-octave multiplier)
// onset = (input, per-octave, ridge-map input, ridge-map per-octave)
// assumed_slope = (pretend magnitude, how much to mix it in)
//
// ASSUMED SLOPE deserves a note. The input height function's slope goes to
// zero at its own peaks and valleys, where the eroded result should be just as
// steep as anywhere else, so using the real slope makes the first octave
// dominate exactly where it should not. runevision tried several fixes and
// settled on pretending the input has a fixed slope everywhere. With the
// reference mix of 1.0 the real slope is not used for gully direction at all,
// which has a useful side effect here: this filter is insensitive to the
// slope-magnitude mismatches that the CPU/GPU divergence was made of.
// max_octaves is FRACTIONAL, and it is the only way to vary octave count with
// LOD without stepping the ground.
//
// Truncating the loop at an integer moves the surface bodily: measured on
// map_dc_diffusion's settings, 7 -> 5 octaves shifts the mean height 20.4 m and
// 7 -> 4 shifts it 48.1 m. Two things cause that and they partly cancel, which
// is why the obvious fix makes it worse -- pinning `magnitude` to its limit
// takes 7 -> 4 from +48 m to -280 m. The octaves are not a truncatable series:
// each one sets `fade_target` and multiplies into `combi_mask` for the next, so
// dropping one changes where every finer octave is allowed to act.
//
// The taper is the same one fractal_noise_2d_lod already uses for the wrapper's
// fBm layers: weight the last partial octave by its fraction rather than
// including or excluding it whole. Everything that accumulates -- the height,
// the slope, the magnitude that drives the offset, the gully steering -- is
// weighted together, so an octave fades in continuously.
//
// 999 (the default every existing caller passes) makes every weight exactly 1.0
// and the arithmetic bit-identical to before the parameter existed.
ErosionResult erosion_filter(vec2 p, vec3 height_and_slope, float fade_target,
float strength, float gully_weight, float detail,
vec4 rounding, vec4 onset, vec2 assumed_slope,
float scale, int octaves, float lacunarity,
float gain, float cell_scale, float normalization,
float max_octaves) {
strength *= scale;
fade_target = clamp(fade_target, -1.0, 1.0);
vec3 input_has = height_and_slope;
float freq = 1.0 / (scale * cell_scale);
vec2 slope2 = vec2(height_and_slope.y, height_and_slope.z);
float slope_length = max(length(slope2), 1e-10);
float magnitude = 0.0;
float rounding_mult = 1.0;
float rounding_for_input = mix(rounding.y, rounding.x, clamp01(fade_target + 0.5)) * rounding.z;
// The combined accumulating mask: initially the input slope, then each
// octave's own slope multiplied in.
float combi_mask = ease_out(smooth_start(slope_length * onset.x, rounding_for_input * onset.x));
// The ridge map runs the same machinery WITHOUT rounding or gully weight,
// because both of those smear exactly the lines it is trying to draw.
float ridge_combi_mask = ease_out(slope_length * onset.z);
float ridge_fade_target = fade_target;
vec2 gully_slope = mix(slope2, slope2 / slope_length * assumed_slope.x, assumed_slope.y);
for (int i = 0; i < octaves; i++) {
vec4 ph = phacelle_noise(p * freq, normalize_safe(gully_slope), cell_scale, 0.25, normalization);
// p was multiplied by freq, so the derivative is too. Negated because
// these are slope directions that point DOWNhill.
vec2 ph_zw = vec2(ph.z, ph.w) * (-freq);
// The octave's own weight: 1 for a full octave, its fraction for the
// last partial one, 0 beyond. Applied to everything this iteration
// contributes, so the octave arrives continuously rather than at once.
float ow = clamp(max_octaves - float(i), 0.0, 1.0);
float sw = strength * ow;
float sloping = abs(ph.y);
// STRAIGHT GULLIES: sign(), not the sine itself. Pretending the gully
// slope is constant top to bottom -- an extruded triangle wave rather
// than a sine -- is what stops small gullies running parallel along
// large ones before branching. Note this feeds the INTERNAL slope only.
gully_slope += sign(ph.y) * ph_zw * sw * gully_weight;
vec3 gullies = vec3(ph.x, ph.y * ph_zw.x, ph.y * ph_zw.y);
vec3 faded = mix(vec3(fade_target, 0.0, 0.0), gullies * gully_weight, combi_mask);
height_and_slope += faded * sw;
magnitude += sw;
// STACKED FADING: this octave's faded gullies become the next octave's
// fade target, and its slope mask multiplies into the combi-mask. Each
// octave therefore restricts where the next one may act at all.
// STRUCTURAL TERMS BLEND BY THE SAME WEIGHT. fade_target and combi_mask
// are not accumulated, they are REPLACED each octave -- so weighting
// only the additive terms would still let a partial octave re-shape
// every finer one the moment it appears. Mixing from the previous value
// is what makes the whole chain continuous.
fade_target = mix(fade_target, faded.x, ow);
float rounding_for_octave = mix(rounding.y, rounding.x, clamp01(ph.x + 0.5)) * rounding_mult;
float new_mask = ease_out(smooth_start(sloping * onset.y, rounding_for_octave * onset.y));
combi_mask = mix(combi_mask, pow_inv(combi_mask, detail) * new_mask, ow);
ridge_fade_target = mix(ridge_fade_target, gullies.x, ridge_combi_mask * ow);
ridge_combi_mask *= mix(1.0, ease_out(sloping * onset.w), ow);
strength *= gain;
freq *= lacunarity;
rounding_mult *= rounding.w;
}
ErosionResult r;
r.delta = height_and_slope - input_has;
r.magnitude = magnitude;
// The last octave would otherwise appear with much thicker lines than the
// rest, never having been filtered through a mask; fading it towards
// neutral is what turns a fade target into a ridge map.
r.ridge = ridge_fade_target * (1.0 - ridge_combi_mask);
return r;
}
// =========================================================================
// ErosionNoiseNode's wrapper, hoisted.
//
// The erosion_filter above was duplicated three times; THIS was duplicated
// twice, and the second copy is where the 60x argument bug actually lived.
// The remap into the Shadertoy's [0,1] height space, the water-line strength
// gate and the height offset existed once as a GLSL string
// (ErosionNoiseNode::compile_glsl) and once as C++ (ProcNodesBridge::evaluate),
// and the C++ one had none of them. Fixing the arguments would have patched
// that instance; hoisting the wrapper removes the place the next one can
// appear.
//
// gn is the upstream GradientNoiseNode's vec3(height, dh/dx, dh/dz).
// GradientNoiseNode emits gn_slope = vec3(gn_result.y, 0.0, gn_result.z), so
// its .x and .z are gn.y and gn.z respectively -- that indirection is why the
// two 0.5 factors below look asymmetric. They are not.
//
// Returns vec2(height * height_scale, erosion), where erosion is the
// delta normalised by the octave-sum magnitude: -1 in creases, +1 on ridges,
// which is what the colour shader consumes.
//
// The five constants are ErosionNoiseNode's, not the caller's
// (noise_nodes.cpp:1229-1233). They are deliberately NOT parameters: they
// were literals in both former copies, and promoting them to arguments now
// would just move the divergence to the call sites.
// The node wrapper with EVERY constant exposed. erosion_node_eval below is
// this function with runevision's reference values supplied, and is what the
// engine calls; this one exists because the lookdev tool needs to turn those
// values into knobs and the alternative was a second copy of the wrapper.
//
// The five that used to be baked are baked for a REASON on the engine path --
// GpuCompiler's push-constant block is param0..param6, seven floats, a hard cap
// (gpu_compiler.cpp, get_shader_template), and rounding and onset alone would
// need eight. That cap does not apply to a consumer that emits GLSL text, which
// is why the lookdev renderer can reach them and the node still cannot.
//
// rounding.z is the one runevision singles out in the blog post: "the erosion
// function doesn't know about the sizes of the terrain features coming from
// [the input height function], so an input value is provided for tweaking the
// rounding of those." It is the input-feature-size control, it has been pinned
// at 0.1 throughout, and terrain_height has since moved 20 km -> 60 km.
// EVERY KNOB, NAMED FOR WHAT IT DOES.
//
// The old signature carried `detail`, `normalization`, `rounding` and `onset` --
// four names that say nothing about their effect, three of them packed into
// vec4s whose components had no names at all. Tuning meant reading the filter
// body to find out which component you were changing. runevision's article
// describes each of these in words; these are those words.
//
// erosion_strength how much the filter displaces the input
// gully_depth scales the gully contribution down
// fine_gully_slope_restriction LOWER values keep high-frequency gullies to
// steeper slopes -- the "detail only on the
// sloped parts" control
// gully_normalize_threshold only gullies above this get normalised, which
// is what keeps peaks pointy
// crease_rounding rounding applied in creases
// ridge_rounding rounding applied on ridges
// input_feature_rounding rounding for the INPUT height function's own
// feature size -- the one runevision singles
// out, and the one that must move when
// terrain_height does
// rounding_per_octave rounding multiplier compounded per octave
// gully_onset_slope slope at which gullies begin at all
// octave_onset_slope per-octave slope gate
// ridge_onset_slope the same for the ridge map
// ridge_octave_onset per-octave ridge gate
// assumed_slope the slope magnitude to pretend the input has
// assumed_slope_mix 0 = use the real slope, 1 = fully assumed
// height_offset flat offset, times the octave magnitude
// height_offset_follows_altitude mixes that offset toward -fade_target
// input_height_amplitude scales the incoming field into filter space
// octave_lacunarity frequency step per octave
// peak_height_divisor what counts as "a peak" for the fade target
// gully_band_scale coarsest gully wavelength, as a fraction of
// terrain_height
// cell_size phacelle cell size; too large distorts, too
// small goes grainy
vec2 erosion_node_eval_full(vec2 p, vec3 gn, float height_scale, float erosion_strength,
float cell_size, int octaves, float gain,
float peak_height_divisor, float gully_band_scale,
float input_height_amplitude, float octave_lacunarity, float gully_depth,
float fine_gully_slope_restriction,
float gully_normalize_threshold, vec2 height_offset_pair,
vec4 rounding_set, vec4 onset_set, vec2 assumed_slope_pair,
float max_octaves) {
vec3 n = vec3(gn.x * input_height_amplitude, gn.y * input_height_amplitude,
gn.z * input_height_amplitude);
float fade_target = clamp(n.x / (input_height_amplitude * peak_height_divisor), -1.0, 1.0);
n = vec3(n.x * 0.5 + 0.5, n.y * 0.5, n.z * 0.5);
ErosionResult h = erosion_filter(p, n, fade_target,
erosion_strength, gully_depth, fine_gully_slope_restriction,
rounding_set, onset_set, assumed_slope_pair,
gully_band_scale, octaves, octave_lacunarity, gain, cell_size,
gully_normalize_threshold, max_octaves);
float offset = mix(height_offset_pair.x, -fade_target, height_offset_pair.y) * h.magnitude;
float eroded = n.x + h.delta.x + offset;
return vec2(eroded * height_scale, h.ridge);
}
// THE SAME EVALUATION, KEEPING THE SLOPE IT ALREADY COMPUTED.
//
// erosion_filter accumulates height AND both slope components into one vec3 and
// returns all three as `delta`; every caller then used `.x` and discarded `.y`
// and `.z`. Meanwhile edge_intersect.glsl reconstructs the very same gradient by
// central differences -- SIX more full evaluations of this filter per edge, each
// one seven octaves of phacelle noise. Measured on the real device: the octave
// loop takes the pipeline from 491 us to 3795 us per 32^3 grid, and
// edge_intersect is 91% of the total.
//
// So this returns what was already there. The slope is in the same space as the
// height: d(filter output)/dp where p = world / terrain_height, and n's own
// components carried the 0.5 factors from the [-1,1] -> [0,1] remap, which is
// why .y and .z are halved on the way in and are NOT re-scaled here -- the
// caller converts to world units exactly as it converts the height.
//
// vec4 rather than a second function with an out-parameter: `out` is a GLSL
// keyword the C++ shim would have to imitate, and this file is compiled as
// both. Returning a wider vector is the one thing both languages spell the same.
//
// .x = eroded height * height_scale (identical to erosion_node_eval_full)
// .y = ridge (identical)
// .z = d(eroded)/dp.x (NEW -- was computed and dropped)
// .w = d(eroded)/dp.z (NEW)
vec4 erosion_node_eval_grad(vec2 p, vec3 gn, float height_scale, float erosion_strength,
float cell_size, int octaves, float gain,
float peak_height_divisor, float gully_band_scale,
float input_height_amplitude, float octave_lacunarity, float gully_depth,
float fine_gully_slope_restriction,
float gully_normalize_threshold, vec2 height_offset_pair,
vec4 rounding_set, vec4 onset_set, vec2 assumed_slope_pair,
float max_octaves) {
vec3 n = vec3(gn.x * input_height_amplitude, gn.y * input_height_amplitude,
gn.z * input_height_amplitude);
float fade_target = clamp(n.x / (input_height_amplitude * peak_height_divisor), -1.0, 1.0);
n = vec3(n.x * 0.5 + 0.5, n.y * 0.5, n.z * 0.5);
ErosionResult h = erosion_filter(p, n, fade_target,
erosion_strength, gully_depth, fine_gully_slope_restriction,
rounding_set, onset_set, assumed_slope_pair,
gully_band_scale, octaves, octave_lacunarity, gain, cell_size,
gully_normalize_threshold, max_octaves);
float offset = mix(height_offset_pair.x, -fade_target, height_offset_pair.y) * h.magnitude;
float eroded = n.x + h.delta.x + offset;
// The offset is a function of fade_target, which is a function of the INPUT
// height, so strictly it carries a gradient of its own. It is dropped here
// for the same reason the wrapper drops d(relief_amp)/dp: with
// height_offset_follows_altitude at its default 0 the term is a constant
// times magnitude and its gradient is exactly zero. Raise that knob and this
// becomes an approximation -- stated here rather than discovered later.
return vec4(eroded * height_scale, h.ridge,
(n.y + h.delta.y) * height_scale,
(n.z + h.delta.z) * height_scale);
}
// runevision's reference values, for callers that do not tune.
//
// slope_power IS GONE from this signature. It was accepted and ignored -- the
// frequency approach's exponent on slope magnitude, which the fade approach
// does not have -- and a parameter that silently does nothing is worse than no
// parameter at all. Callers drop the argument.
vec2 erosion_node_eval(vec2 p, vec3 gn, float height_scale, float erosion_strength,
float cell_size, int octaves, float gain,
float peak_height_divisor, float gully_band_scale, float max_octaves) {
// Base-noise shaping, unchanged from the Shadertoy lineage.
// The advanced filter's parameters. These are runevision's reference
// defaults, and they are CONSTANTS here rather than node sockets because
// GpuCompiler's push-constant block is param0..param6 -- seven floats,
// hard cap (gpu_compiler.cpp, get_shader_template). rounding and onset
// alone would need eight. Promoting them would mean a second uniform
// buffer through the whole node pipeline; baking them is what the five
// constants above them have always done.
// EROSION_SCALE is a PARAMETER now, not a constant. It sets the gully band:
// the coarsest gully wavelength is terrain_height * erosion_scale *
// cell_scale, in world metres, halving per octave. Baked, it made the band a
// fixed FRACTION of the terrain scale -- so tripling terrain_height from
// 20 km to 60 km to buy view distance tripled the gully wavelength with it,
// 2100 m to 6300 m, and the coarsest octave became a smear across a whole
// mountain rather than a channel down its side. The two bands have no reason
// to be locked together.
// POINTY PEAKS; normalising the
// gullies otherwise rounds them off
// Height offset, mix(a, -fade_target, b) * magnitude. b = 0 makes it a
// flat -0.65 of the accumulated magnitude; raise b to tie the offset to
// altitude instead.
// slope_power is ACCEPTED AND IGNORED. It was the frequency approach's
// exponent on slope magnitude, and the fade approach has no such term --
// stripe frequency no longer depends on slope at all. The socket stays so
// existing graphs keep loading; wiring it to something would be worse than
// leaving it dead, because a parameter that silently does nothing else is
// harder to find than one that does nothing.
// THE FADE TARGET: -1 in valleys, +1 on peaks. Overshoot is fine; the
// filter clamps. Derived from altitude here, which is what runevision's
// reference does and is not robust in general -- a high valley reads as a
// peak. Curvature would be principled but needs second derivatives from
// every upstream node.
//
// fade_div sets what counts as "a peak". runevision uses 0.6 for a height
// field of order 1. It is a PARAMETER rather than that constant because
// the caller may hand this function a combined terrain field several times
// larger -- dc-terrain folds its mountain layer into gn, which is ~8x the
// bare gradient noise -- and 0.6 against that saturates the fade target to
// +/-1 everywhere, which is "every point is a peak" and erases the fade
// approach's whole reason to exist. Scale it with the field.
// [-1,1] -> [0,1], as the Shadertoy does.
// NO WATER-LINE GATE. The old wrapper multiplied strength by
// smoothstep(WATER_HEIGHT +/- 0.1, n.x), inherited from the Shadertoys,
// where it exists to hide the frequency approach's bulges at valley
// bottoms. The fade approach does not produce them, so the gate has
// nothing to hide and was actively wrong here: WATER_HEIGHT 0.465 lives in
// the Shadertoy's [0,1] height space and has no relationship to this
// world's floor_y/ceiling_y.
return erosion_node_eval_full(p, gn, height_scale, erosion_strength,
cell_size, octaves, gain, peak_height_divisor, gully_band_scale,
/* input_height_amplitude */ 0.25,
/* octave_lacunarity */ 2.0,
/* gully_depth */ 0.5,
/* fine_gully_slope_restriction */ 1.5,
/* gully_normalize_threshold */ 0.5,
/* height_offset, follows_alt */ vec2(-0.65, 0.0),
/* crease/ridge/input/per_oct */ vec4(0.1, 0.0, 0.1, 2.0),
/* gully/octave/ridge/ridge_oct */ vec4(1.25, 1.25, 2.8, 1.5),
/* assumed_slope, mix */ vec2(0.7, 1.0),
/* fractional octave taper */ max_octaves);
}