OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 4ce4179

proc-nodes: a hypsometric curve, and the slope the filter already had
hypso_curve.inc turns the mountain layer's unimodal fBm into a bimodal land/sea distribution: abyssal plateau, continental slope centred BELOW sea level, platform, then an additive mountain ramp. Additive because blending toward a steeper mountain LINE folds the surface -- it pulls ground down below the ramp's own onset, and dual contouring renders that fold as a shelf that is not in the field. Gated for monotonicity, C1 (by a step-halving scaling test, not a second-derivative threshold), and actual bimodality. ErosionNoiseNode now calls erosion_node_eval_grad instead of _full. The filter accumulates height AND both slope components into one vec3 and every caller was reading .x and discarding .y/.z; _grad returns the same evaluation with a wider return, so .x and .y are bit-identical and this cannot move the terrain. Two globals carry the extra out: pn_out_grad analytic surface slope. Measured against central differences it is NOT accurate enough to drive a normal -- 29.2 deg mean error, growing 5.5 -> 29.2 as octaves go 1 -> 7, because erosion_filter drops d(combi_mask)/dp and d(fade_target)/dp and REPLACES both every octave. Kept because it is free and the measurement is worth having recorded. pn_out_ridge the filter's own crease/ridge map, which its comment already advertised as "free; drives materials". It is what actually ended up driving them. Declared in gpu_compiler alongside the other node->wrapper egress globals; a graph that references an undeclared global fails in glslc while a CPU oracle prints a healthy table, which is how the last one of these hid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4ce4179ca54eccdc5c93813c9e316a29bf30eef9
by plankatron <fleeky@gmail.com> · 2026-09-07T17:27:47.000Z

Changed files (5) +213 -1

SConstruct +9 -0
……@@ -131,6 +131,15 @@ glsl_inc_gen = [
131131 "src/shared/blender_ramp_blend.inc",
132132 "python3 tools/gen_glsl_inc.py $SOURCE $TARGET BLENDER_RAMP_BLEND_GLSL",
133133 ),
134 # The land/sea transfer curve, seventh .inc. Depends on nothing else.
135 # Emitted into the noise library beside the erosion filter because the
136 # WRAPPER calls it (on the mountain layer, before erosion), and the
137 # wrapper only exists where that library does.
138 env.Command(
139 "src/gpu/hypso_curve_glsl.gen.h",
140 "src/shared/hypso_curve.inc",
141 "python3 tools/gen_glsl_inc.py $SOURCE $TARGET HYPSO_CURVE_GLSL",
142 ),
134143 # Ashima simplex, sixth .inc. NOT Blender's -- it is proc-nodes' own
135144 # Simplex Noise node, which blender-bridge mirrors as a node group.
136145 env.Command(
src/gpu/gpu_compiler.cpp +28 -0
……@@ -1,5 +1,6 @@
11#include "gpu_compiler.h"
22#include "erosion_filter_glsl.gen.h"
3#include "hypso_curve_glsl.gen.h"
34#include <godot_cpp/classes/rd_shader_source.hpp>
45#include <godot_cpp/classes/rd_shader_spirv.hpp>
56#include "../graph/proc_node_graph.h"
……@@ -37,11 +38,38 @@ String GpuCompiler::get_erosion_library_glsl() {
3738 // shader meshed and the surface collision reported were different
3839 // functions. One text now, compiled as GLSL here and as C++ there.
3940 + String(EROSION_FILTER_GLSL)
41 // The land/sea transfer curve. Beside the filter rather than inside it:
42 // it is not erosion, it runs BEFORE erosion on the mountain layer, and
43 // the wrapper is its only caller. Free when unused -- an uncalled GLSL
44 // function costs nothing after dead-code elimination.
45 + String(HYPSO_CURVE_GLSL)
4046 // Wrapper-supplied inputs to ErosionNoiseNode, declared here so a shader
4147 // that never sets them still compiles and still behaves exactly as before
4248 // (zero offset, runevision's 0.6 divisor). Same pattern as pn_max_octaves.
4349 + String("\nvec3 pn_terrain_offset = vec3(0.0);\nfloat pn_fade_div = 0.6;\n"
4450 "float pn_erosion_scale = 0.15;\n")
51 // ── THE ONE THAT GOES THE OTHER WAY ────────────────────────────────
52 //
53 // Every other pn_* global above is WRAPPER -> NODE. This one is
54 // NODE -> WRAPPER: ErosionNoiseNode writes the filter's ridge channel
55 // into it, and a caller that wants ridge reads it after calling
56 // proc_density.
57 //
58 // WHY A GLOBAL AND NOT A RETURN VALUE. compile_to_glsl_function fixes
59 // the signature at `float f(vec3 pos)`, so the density function has
60 // exactly one float of egress and it is spent on density. ridge is
61 // computed on every sample regardless (erosion_filter.inc:490, "Free;
62 // drives materials") and was reachable only from inside the density
63 // shader -- AltitudeColorNode reads the same value as `en_erosion`.
64 // This is the door out for a caller that is NOT a node.
65 //
66 // ⚠ WRITE-ONLY FROM THE GPU PATH. ProcNodesBridge::evaluate is the CPU
67 // twin and does not set this; anything reading it must be GPU-side, or
68 // it reads 0.0 and gets a plausible-looking flat field. The density
69 // itself is untouched -- this assigns a global and nothing else, so
70 // test_erosion_parity stays bit-identical.
71 + String("float pn_out_ridge = 0.0;\n")
72 + String("vec2 pn_out_grad = vec2(0.0);\n")
4573 // EVERY REMAINING FILTER KNOB, as a wrapper-settable global.
4674 //
4775 // These were baked literals inside erosion_node_eval because
src/nodes/noise_nodes.cpp +10 -1
……@@ -1387,7 +1387,7 @@ String ErosionNoiseNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_i
13871387 // of one, and no amount of filter quality fixes it. Handing the filter the
13881388 // mountain's height AND gradient is the whole point of a slope-aware filter.
13891389 return R"(
1390 vec2 en_result = erosion_node_eval_full(
1390 vec4 en_result = erosion_node_eval_grad(
13911391 vec2(pos.x, pos.z),
13921392 vec3(gn_height, gn_slope.x, gn_slope.z) + pn_terrain_offset,
13931393 pc.param1, pc.param2, pc.param4, int(pc.param5), pc.param6,
……@@ -1403,6 +1403,15 @@ String ErosionNoiseNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_i
14031403 vec2(pn_ero_assumed_slope, pn_ero_assumed_slope_mix),
14041404 pn_ero_max_octaves);
14051405 float en_erosion = en_result.y;
1406 pn_out_ridge = en_erosion;
1407 // SECOND NODE -> WRAPPER egress, same pattern as pn_out_ridge above.
1408 // erosion_filter already accumulated both slope components; _full returned
1409 // vec2 and threw them away, so every consumer that wanted a normal rebuilt
1410 // it with SIX more full evaluations. _grad is the same evaluation with a
1411 // wider return -- .x and .y are bit-identical to _full's, which is why this
1412 // swap cannot move the terrain. Measured: 6 taps 16.4 ms vs 1 tap 1.57 ms
1413 // fullscreen at 1080p, which is the whole reason this egress exists.
1414 pn_out_grad = vec2(en_result.z, en_result.w);
14061415 pos.y = en_result.x;
14071416)";
14081417}
src/shared/erosion_wrapper_glsl.h +14 -0
……@@ -852,6 +852,13 @@ inline std::string erosion_wrapper_glsl(const ErosionWrapperParams& p) {
852852 // no LOD to taper against; the engine is where that parameter earns its
853853 // keep.
854854 " EN_ROUND, EN_ONSET, EN_ASLOPE, 999.0);\n"
855 // NODE -> WRAPPER egress, matching ErosionNoiseNode's emitted snippet.
856 // This wrapper calls erosion_node_eval_full directly instead of going
857 // through the node, so without this line the lookdev and standalone
858 // paths would compute ridge and drop it while the engine path exports
859 // it -- the two-paths-disagree failure this file's header warns about.
860 // Assigns a global and nothing else; d is untouched.
861 " pn_out_ridge = en.y;\n"
855862 " float d = en.x - y_mapped;\n"
856863 " d *= (y_range / EN_HS);\n"
857864 " return clip_d(d, y);\n"
……@@ -870,6 +877,13 @@ inline std::string erosion_wrapper_glsl(const ErosionWrapperParams& p) {
870877 // no LOD to taper against; the engine is where that parameter earns its
871878 // keep.
872879 " EN_ROUND, EN_ONSET, EN_ASLOPE, 999.0);\n"
880 // NODE -> WRAPPER egress, matching ErosionNoiseNode's emitted snippet.
881 // This wrapper calls erosion_node_eval_full directly instead of going
882 // through the node, so without this line the lookdev and standalone
883 // paths would compute ridge and drop it while the engine path exports
884 // it -- the two-paths-disagree failure this file's header warns about.
885 // Assigns a global and nothing else; d is untouched.
886 " pn_out_ridge = en.y;\n"
873887 " float d = en.x - y_mapped;\n"
874888 " d *= (y_range / EN_HS);\n"
875889 " return clip_d(d, y);\n"
src/shared/hypso_curve.inc +152 -0
……@@ -0,0 +1,152 @@
1// =========================================================================
2// hypso_curve.inc — the analytic land/sea transfer curve.
3//
4// ONE TEXT, compiled as GLSL (injected into the density shader) and as C++
5// (through erosion_cpp_shim.h, for the oracle). Same rule as
6// erosion_filter.inc: only the subset both languages agree on.
7//
8// WHAT IT IS FOR. A plain fBm has a UNIMODAL height distribution, so
9// intersecting it with a water plane at any level gives a coastline of
10// uniform fractal character everywhere -- no shelves, no basins, and no
11// reason for land to clump. Earth's hypsography is BIMODAL: ~85% of the
12// surface sits in two narrow bands, because continental crust and oceanic
13// crust have different densities. This curve puts that shape into the
14// height field directly, with no real-world data.
15//
16// WHY A BLEND AND NOT A PIECEWISE JOIN. The obvious construction --
17// `h < sea ? ocean(h) : land(h)` -- is C0 but not C1: the slope jumps at
18// the join, which is EXACTLY the waterline, and a slope discontinuity along
19// every coastline renders as a crease ring. Blending two regimes with a
20// smoothstep is C1 by construction because smoothstep is.
21//
22// WHERE THE BIMODALITY COMES FROM. Not from a threshold. Output values
23// cluster where the curve is FLAT (low |dhv/dh|), so a flat abyssal
24// plateau and a land ramp separated by a narrow STEEP section produce two
25// modes with few samples between them -- the continental slope. `shelf` is
26// the half-width of that steep section: smaller = sharper coast = more
27// strongly bimodal.
28//
29// THE WATERLINE IS DERIVED. The curve is monotonic, so sea level in OUTPUT
30// space is simply hypso_curve(sea).x. Do not also hand-set a water height;
31// read it from here, or the two disagree and the shoreline renders where
32// the terrain is not.
33//
34// PEAK LIFT is additive on the upper tail only, so it raises summits
35// without steepening mid-slopes or deepening valleys. That matters beyond
36// looks: total relief is bounded by the physics band (character movement
37// degrades with |y|), and lifting only the tail is how peak height stops
38// competing with valley depth for the same budget.
39//
40// h input height, nominally [-1, 1]
41// sea input value at the middle of the continental slope
42// shelf half-width of that slope, in input units (>0)
43// abyss OUTPUT value of the abyssal plateau (negative)
44// land_slope output units per input unit above sea
45// peak_onset input value where the summit lift begins
46// peak_gain output units added at h = 1
47//
48// Returns (hv, dhv/dh). The derivative is not decoration: the erosion
49// filter is slope-aware and the wrapper hands it a gradient, so remapping
50// the height without remapping the gradient by the chain rule would carve
51// gullies aligned to a surface that is no longer there.
52// =========================================================================
53
54// WHY THREE REGIMES AND NOT TWO. A first version had an abyssal plateau and
55// a linear land ramp, and the gate measured its output histogram as ONE mode
56// plus a smear -- because a ramp has no mode. Earth's bimodality is two
57// PLATEAUS: the continental platform (~0..+1 km) and the abyssal plain
58// (~-4..-5 km), with the continental slope between them. So the land side
59// needs a low-gradient shelf of its own, and the mountains rise ABOVE that.
60//
61// abyss --[continental slope]-- platform --[range front]-- mountains
62// flat steep gentle steeper + peak lift
63//
64// Two smoothsteps composing three lines. Both are C1, so the whole is.
65
66// smoothstep's derivative. GLSL has smoothstep but not its derivative, and
67// writing 6t(1-t)/(hi-lo) at three call sites is how one of them ends up
68// missing the 1/(hi-lo).
69// ⚠ DO NOT NAME A LOCAL `out`. It is a GLSL storage qualifier and a perfectly
70// ordinary identifier in C++, so a C++-only test compiles it and the SHADER
71// dies with "syntax error, unexpected OUT" -- which is what happened: the GPU
72// fell back to the CPU mesher and the only symptom was that terrain generation
73// got slow. Same class as erosion_filter.inc's PN_TAU note. `in`, `inout`,
74// `filter`, `sample` and `active` are reserved the same way.
75
76float hypso_dsmooth(float lo, float hi, float x) {
77 float d = hi - lo;
78 if (d <= 0.0) return 0.0;
79 float t = clamp((x - lo) / d, 0.0, 1.0);
80 return 6.0 * t * (1.0 - t) / d;
81}
82
83// h input height, nominally [-1, 1]
84// sea input value that IS sea level; the curve returns 0 there
85// shelf_width how far below sea the gentle shelf runs before dropping
86// slope_width half-width of the steep drop (smaller = sharper break)
87// abyss OUTPUT value of the abyssal plain (negative)
88// plat_slope output per input on the continental platform (small)
89// mtn_onset input value where the mountain front begins
90// mtn_width half-width of that front
91// mtn_slope output per input in the mountains (larger)
92// peak_onset input value where the summit lift begins
93// peak_gain output added at h = 1
94//
95// Returns (hv, dhv/dh). The derivative is not decoration: the erosion
96// filter is slope-aware and the wrapper hands it a gradient, so remapping
97// the height without remapping the gradient by the chain rule would carve
98// gullies aligned to a surface that is no longer there.
99vec2 hypso_curve(float h, float sea, float shelf_width, float slope_width,
100 float abyss,
101 float plat_slope, float mtn_onset, float mtn_width,
102 float mtn_slope, float peak_onset, float peak_gain) {
103 // --- regime 1: the continental platform, a gentle line through (sea, 0)
104 float plat = plat_slope * (h - sea);
105
106 // --- the continental slope, CENTRED BELOW SEA LEVEL.
107 //
108 // ⚠ THIS IS WHY THE FIRST VERSION WAS A CLIFF. The blend was centred ON
109 // `sea`, so the steep section straddled the waterline and land met deep
110 // water with nothing in between -- a wall, roughly 50x steeper than the
111 // platform behind it. That steepness IS the bimodality, so it cannot be
112 // removed; it has to be MOVED.
113 //
114 // On Earth the steep part is submerged: land -> beach -> a gentle shelf
115 // ~100 m deep and tens of km wide -> shelf break -> continental slope ->
116 // abyssal plain. Putting the blend `shelf_width` BELOW sea leaves the
117 // gentle platform line running down through the waterline, and that
118 // stretch is the continental shelf.
119 //
120 // Requires shelf_width > slope_width, or the drop reaches back up through
121 // sea level and the shelf does not exist.
122 float slope_mid = sea - shelf_width;
123 float c0 = slope_mid - slope_width;
124 float c1 = slope_mid + slope_width;
125 float w = smoothstep(c0, c1, h);
126 float dw = hypso_dsmooth(c0, c1, h);
127 float base = abyss + (plat - abyss) * w;
128 float dbase = (plat - abyss) * dw + w * plat_slope;
129
130 // --- the mountain front, ADDED not blended to. See the note above:
131 // blending toward a steeper LINE folds the terrain below its onset,
132 // measured at dhv/dh = -0.112. An additive ramp cannot fold.
133 float a = mtn_onset - mtn_width;
134 float b = mtn_onset + mtn_width;
135 float sm = smoothstep(a, b, h);
136 float dsm = hypso_dsmooth(a, b, h);
137 // (h-a)*smoothstep: exactly 0 below a (smoothstep clamps), asymptotically
138 // linear with unit slope above b, C1 throughout, never decreasing.
139 float ramp = (h - a) * sm;
140 float dramp = sm + (h - a) * dsm;
141 float extra = mtn_slope - plat_slope; // the slope the range ADDS
142
143 float hv = base + extra * ramp;
144 float dhv = dbase + extra * dramp;
145
146 // --- summit lift, upper tail only. Same additive shape, same reason.
147 if (peak_gain != 0.0) {
148 hv += peak_gain * smoothstep(peak_onset, 1.0, h);
149 dhv += peak_gain * hypso_dsmooth(peak_onset, 1.0, h);
150 }
151 return vec2(hv, dhv);
152}