OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 250f462

lookdev: run the erosion filter ON the diffusion field
#46 §4b option 1, made runnable. The model supplies the large forms it is demonstrably good at (beta steepening 4.18 -> 5.10, coherent catchments); the filter supplies detail below the model's 30 m floor. runevision's filter is explicitly "applied on top of any height function", so this is the intended use and not a hack. No GPU descriptor work needed. A heightmap transform is ONE erosion evaluation per pixel with no surface search, unlike dump_erosion_heightfield's 572 density evaluations per column, so the CPU shim is enough. UNITS WERE THE WHOLE DIFFICULTY, so there is a gate for them The filter is dimensionless: p is world over a horizontal unit L, height is normalised by a vertical unit H, and the slope must be in the SAME space -- dh/dp = (dh/dx_world) * L / H. Getting that factor wrong is precisely the 60x wrapper bug #45 found. So --check runs an identity gate: with strength 0 and no offset the filter contributes nothing, so the output must equal the input. Worst |out - in| over 200 heights is 0.000e+00. A unit error cannot survive it. The user-facing control is the coarsest gully in METRES, and L is derived from it -- a raw "scale" number is unreadable, which the panel relabelling just established. TWO THINGS THAT NEEDED CHANGING FROM THE REFERENCE - strength. runevision's 0.22 is relative to his input amplitude of 0.125. Ours is normalised by the tile's own relief, so 0.22 buries the large forms completely. 0.08 keeps them and adds gully detail. NOTE this makes strength non-scale-invariant across tiles of differing relief -- --vscale pins H if that matters. - the height offset. -0.65 * magnitude is a DC bias, harmless in a space where H is 0.125 and an ~800 m shift on a 2.3 km tile here. 0 for lookdev. Exposed in the app as a checkbox on the diffusion panel plus seven knobs, with derived metres for the gully band and the note that diffusion's Nyquist is 60 m. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250f462890a2beb4ea995c7918deac6afe6ebcfc
by plankatron <noreply@anthropic.com> · 2026-08-31T17:13:52.000Z

Changed files (3) +296 -1

erode_heightmap.cpp +220 -0
……@@ -0,0 +1,220 @@
1// ============================================================================
2// ERODE A HEIGHTMAP — run runevision's filter on ANY input height field, in
3// particular a terrain-diffusion tile.
4//
5// WHY
6//
7// #46 §4 measured that the diffusion model produces landscape (coherent
8// catchments, beta steepening 4.18 -> 5.10) where our fBm mountain layer
9// produces texture (flat beta). The blog is explicit that the filter "can be
10// applied on top of any height function". So: let the model supply the large
11// forms it is demonstrably good at, and let the filter supply detail below its
12// 30 m floor. This is #46 §4b option 1, made runnable.
13//
14// It needs no GPU descriptor work. A heightmap transform is ONE erosion
15// evaluation per pixel with no surface search, so the CPU shim is enough --
16// unlike dump_erosion_heightfield, which pays 572 density evaluations per
17// column bisecting for a surface.
18//
19// UNITS, which is the whole difficulty
20//
21// The filter is dimensionless. Its p is world position over a horizontal unit
22// L, so the coarsest gully wavelength is L * erosion_scale * cell_scale metres.
23// Its height is normalised by a vertical unit H, and its SLOPE must be in the
24// same space: dh/dp = (dh/dx_world) * L / H. Get that factor wrong and the
25// gullies come out oriented correctly but scaled absurdly, which is exactly the
26// 60x wrapper bug #45 found.
27//
28// THE IDENTITY GATE
29//
30// With --strength 0 the filter contributes nothing, so the output must equal
31// the input to float precision. --check runs that and refuses to proceed if it
32// does not hold. A unit error that survives this cannot exist.
33//
34// Build:
35// cd dc-terrain
36// g++ -O2 -std=c++17 -I ../proc-nodes/src/shared -o /tmp/erode_heightmap \
37// ../terrain-lookdev/erode_heightmap.cpp
38//
39// Run:
40// /tmp/erode_heightmap --check
41// /tmp/erode_heightmap --in tile.f32 --n 512 --mpp 30 --out eroded.f32 \
42// --gully-metres 900 --strength 0.22 --octaves 5
43// ============================================================================
44#include <cstdio>
45#include <cstdlib>
46#include <cstring>
47#include <cmath>
48#include <vector>
49#include <string>
50
51#include "erosion_cpp_shim.h"
52
53using dcerosion::vec2;
54using dcerosion::vec3;
55using dcerosion::vec4;
56
57struct Opts {
58 int n = 512;
59 float mpp = 30.0f;
60 // The coarsest gully, in METRES. Everything else about the horizontal
61 // layout is derived from it, because a raw "scale" number is unreadable --
62 // the same lesson the lookdev panel relabelling just learned.
63 float gully_metres = 900.0f;
64 float strength = 0.22f; // runevision's reference
65 int octaves = 5; // runevision's reference
66 float gain = 0.5f;
67 float cell_scale = 0.7f;
68 float lacunarity = 2.0f;
69 float gully_weight = 0.5f;
70 float detail = 1.5f;
71 float normalization = 0.5f;
72 float rounding_input = 0.1f;
73 float height_offset_a = -0.65f;
74 // Vertical normalisation. The filter's strength is relative to a height of
75 // order 1, so H should be the input's own relief, not an arbitrary number.
76 // 0 means "use the tile's (max-min)/2".
77 float vscale = 0.0f;
78};
79
80// One evaluation. Kept separate from the loop so --check can call it directly.
81static float erode_point(const Opts& o, float h_norm, float dhdx, float dhdz,
82 float px, float pz, float L, float H, float* ridge) {
83 // world slope -> filter slope: multiply by L/H, matching p = world/L and
84 // height = metres/H.
85 vec3 gn;
86 gn.x = h_norm;
87 gn.y = dhdx * (L / H);
88 gn.z = dhdz * (L / H);
89
90 // height_amp 1 and height_scale 1 leave the filter's own [0,1] remap as the
91 // only vertical transform, so the inverse below is exact.
92 vec2 en = dcerosion::erosion_node_eval_full(
93 vec2(px / L, pz / L), gn,
94 /* height_scale */ 1.0f,
95 o.strength,
96 o.cell_scale, o.octaves, o.gain,
97 /* fade_div */ 1.0f,
98 /* erosion_scale */ o.gully_metres / (L * o.cell_scale),
99 /* height_amp */ 1.0f,
100 o.lacunarity, o.gully_weight, o.detail, o.normalization,
101 vec2(o.height_offset_a, 0.0f),
102 vec4(0.1f, 0.0f, o.rounding_input, 2.0f),
103 vec4(1.25f, 1.25f, 2.8f, 1.5f),
104 vec2(0.7f, 1.0f));
105 if (ridge) *ridge = en.y;
106 // invert the remap: the filter returned n.x*0.5+0.5 plus its delta.
107 return (en.x - 0.5f) * 2.0f;
108}
109
110int main(int argc, char** argv) {
111 Opts o;
112 const char* in_path = nullptr;
113 const char* out_path = "/tmp/eroded.f32";
114 const char* ridge_path = nullptr;
115 bool check = false;
116
117 for (int i = 1; i < argc; i++) {
118 const char* a = argv[i];
119 auto next = [&]() { return argv[++i]; };
120 if (!strcmp(a, "--in") && i+1 < argc) in_path = next();
121 else if (!strcmp(a, "--out") && i+1 < argc) out_path = next();
122 else if (!strcmp(a, "--ridge")&& i+1 < argc) ridge_path = next();
123 else if (!strcmp(a, "--n") && i+1 < argc) o.n = atoi(next());
124 else if (!strcmp(a, "--mpp") && i+1 < argc) o.mpp = (float)atof(next());
125 else if (!strcmp(a, "--gully-metres") && i+1<argc) o.gully_metres = (float)atof(next());
126 else if (!strcmp(a, "--strength") && i+1<argc) o.strength = (float)atof(next());
127 else if (!strcmp(a, "--octaves") && i+1<argc) o.octaves = atoi(next());
128 else if (!strcmp(a, "--gain") && i+1<argc) o.gain = (float)atof(next());
129 else if (!strcmp(a, "--cell-scale")&& i+1<argc) o.cell_scale = (float)atof(next());
130 else if (!strcmp(a, "--rounding-input") && i+1<argc) o.rounding_input = (float)atof(next());
131 else if (!strcmp(a, "--vscale") && i+1<argc) o.vscale = (float)atof(next());
132 else if (!strcmp(a, "--offset") && i+1<argc) o.height_offset_a = (float)atof(next());
133 else if (!strcmp(a, "--check")) check = true;
134 else { fprintf(stderr, "unknown arg %s\n", a); return 2; }
135 }
136
137 // ---- the identity gate -------------------------------------------------
138 if (check) {
139 Opts z = o; z.strength = 0.0f; z.height_offset_a = 0.0f;
140 double worst = 0.0;
141 for (int k = 0; k < 200; k++) {
142 float hn = -1.0f + 2.0f * (float)k / 199.0f;
143 float g = erode_point(z, hn, 0.013f, -0.007f,
144 100.0f * k, 70.0f * k, 6000.0f, 500.0f, nullptr);
145 double d = fabs((double)g - (double)hn);
146 if (d > worst) worst = d;
147 }
148 printf("IDENTITY GATE — strength 0 and no height offset must be a no-op\n");
149 printf(" worst |out - in| over 200 heights : %.3e\n", worst);
150 bool ok = worst < 1e-5;
151 printf(" VERDICT: %s\n", ok ? "unit conversion is exact."
152 : "WRONG. The height mapping does not invert; "
153 "do not trust any eroded tile.");
154 if (!ok || !in_path) return ok ? 0 : 1;
155 }
156
157 if (!in_path) { fprintf(stderr, "need --in\n"); return 2; }
158
159 const int n = o.n;
160 std::vector<float> h((size_t)n * n);
161 FILE* f = fopen(in_path, "rb");
162 if (!f) { perror("open --in"); return 1; }
163 if (fread(h.data(), sizeof(float), h.size(), f) != h.size()) {
164 fprintf(stderr, "short read: expected %zu floats\n", h.size()); return 1;
165 }
166 fclose(f);
167
168 float lo = h[0], hi = h[0];
169 for (float v : h) { if (v < lo) lo = v; if (v > hi) hi = v; }
170 const float mid = 0.5f * (lo + hi);
171 const float H = (o.vscale > 0.0f) ? o.vscale
172 : fmaxf(0.5f * (hi - lo), 1.0f);
173 // L is fixed by the gully band we asked for, so the number the user gives
174 // is in metres and everything else follows.
175 const float L = o.gully_metres / o.cell_scale;
176
177 printf("erode heightmap: %dx%d @ %g m/px = %.2f km across\n",
178 n, n, o.mpp, n * o.mpp / 1000.0f);
179 printf(" input relief %.1f .. %.1f m (mid %.1f, H %.1f)\n", lo, hi, mid, H);
180 printf(" gullies %.0f m -> %.0f m (%d oct, lac %.2f)\n",
181 o.gully_metres, o.gully_metres / powf(o.lacunarity, (float)(o.octaves - 1)),
182 o.octaves, o.lacunarity);
183 printf(" strength %.3g rounding_input %.3g offset %.3g\n",
184 o.strength, o.rounding_input, o.height_offset_a);
185
186 std::vector<float> out(h.size()), ridge(h.size());
187 for (int j = 0; j < n; j++) {
188 for (int i = 0; i < n; i++) {
189 // central differences, clamped at the border
190 int im = (i > 0) ? i - 1 : i, ip = (i < n - 1) ? i + 1 : i;
191 int jm = (j > 0) ? j - 1 : j, jp = (j < n - 1) ? j + 1 : j;
192 float dhdx = (h[(size_t)j * n + ip] - h[(size_t)j * n + im]) /
193 (o.mpp * (float)(ip - im));
194 float dhdz = (h[(size_t)jp * n + i] - h[(size_t)jm * n + i]) /
195 (o.mpp * (float)(jp - jm));
196 float hn = (h[(size_t)j * n + i] - mid) / H;
197 float r = 0.0f;
198 float g = erode_point(o, hn, dhdx, dhdz,
199 (float)i * o.mpp, (float)j * o.mpp, L, H, &r);
200 out[(size_t)j * n + i] = mid + g * H;
201 ridge[(size_t)j * n + i] = r;
202 }
203 }
204
205 float olo = out[0], ohi = out[0];
206 for (float v : out) { if (v < olo) olo = v; if (v > ohi) ohi = v; }
207 printf(" output relief %.1f .. %.1f m\n", olo, ohi);
208
209 f = fopen(out_path, "wb");
210 if (!f) { perror("open --out"); return 1; }
211 fwrite(out.data(), sizeof(float), out.size(), f);
212 fclose(f);
213 printf(" wrote %s\n", out_path);
214 if (ridge_path) {
215 f = fopen(ridge_path, "wb");
216 if (f) { fwrite(ridge.data(), sizeof(float), ridge.size(), f); fclose(f);
217 printf(" wrote %s (ridge: -1 creases, +1 ridges)\n", ridge_path); }
218 }
219 return 0;
220}
index.html +23 -0
……@@ -110,6 +110,15 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va
110110 <input type="range" id="exag" min="0.2" max="6" step="0.1" value="1"></label>
111111 </fieldset>
112112
113 <fieldset><legend>Erosion ON the diffusion field</legend>
114 <label>apply erosion filter to diffusion
115 <input type="checkbox" id="erode" style="width:auto">
116 <span class="d">#46 §4b option 1 — the model supplies the large forms it is
117 good at, the filter supplies detail below its 30 m floor. runevision's
118 filter is explicitly "applied on top of any height function".</span></label>
119 <div id="grp_erode"></div>
120 </fieldset>
121
113122 <fieldset><legend>Bands vs the view</legend>
114123 <div id="bands"></div>
115124 </fieldset>
……@@ -191,6 +200,10 @@ const GROUPS = {
191200 ["noise_lacunarity",1.87,1.2,3,0.01],["gn_scale",3,0.1,40,0.1],
192201 ["gn_gain",0.1,0,0.9,0.01],["gn_detail",3,1,8,1],
193202 ["voxel_size",1,0.25,512,0.25]],
203 erode: [["e_gully_metres",900,30,20000,10],["e_strength",0.08,0,1,0.005],
204 ["e_octaves",5,1,10,1],["e_gain",0.5,0.2,0.9,0.01],
205 ["e_cell_scale",0.7,0.05,4,0.01],["e_rounding_input",0.1,0.005,4,0.005],
206 ["e_offset",0,-2,1,0.01]],
194207 shape: [["en_fade_div",4.8,0.5,40.0,0.1],
195208 ["en_height_amp",0.25,0.02,2.0,0.01],
196209 ["en_erosion_lac",2.0,1.2,4.0,0.01],
……@@ -275,6 +288,11 @@ function derived(k) {
275288 case "voxel_size": return {txt: `band-limit; at ${num("mpp")} m/px use \u2265 ${2*num("mpp")} m`,
276289 warn: num("voxel_size") < 2 * num("mpp")};
277290 case "rounding_input": return {txt: "runevision's input-feature-size control; pinned at 0.1 in the engine"};
291 case "e_gully_metres":
292 case "e_octaves": return {txt: band(num("e_gully_metres"), 2.0, num("e_octaves")) +
293 " (diffusion's Nyquist is 60 m)"};
294 case "e_strength": return {txt: "relative to the input tile's own relief; 0.22 is runevision's, far too strong here"};
295 case "e_offset": return {txt: "DC bias x magnitude. -0.65 is the reference; it shifts a 2 km tile by ~800 m, so 0 for lookdev"};
278296 case "assumed_slope_mix":return {txt: num("assumed_slope_mix") >= 1
279297 ? "1.0 = input slope MAGNITUDE replaced; direction kept"
280298 : "blending real input slope magnitude back in"};
……@@ -519,6 +537,10 @@ function render() {
519537 const p = erosionQuery();
520538 const q = new URLSearchParams({n: $("n").value, i1: $("i1").value,
521539 j1: $("j1").value, seed: $("seed").value});
540 if ($("erode").checked) {
541 q.set("erode", "1");
542 for (const [key] of GROUPS.erode) q.set(key, $(key).value);
543 }
522544
523545 if (mode === "3d") {
524546 load3d("/api/erosion.bin?" + p, "cv_e", "st_e", "busy_e");
……@@ -538,6 +560,7 @@ function schedule() {
538560 timer = setTimeout(render, 250);
539561}
540562
563$("erode").addEventListener("change", render);
541564document.addEventListener("input", e => {
542565 if (e.target.type === "range" || e.target.type === "number") schedule();
543566});
server.py +53 -1
……@@ -83,6 +83,52 @@ KNOBS = {
8383}
8484
8585NO_SURFACE = -3.0e38
86
87# The erosion filter applied to a heightmap that came from somewhere else --
88# #46 §4b option 1. Allowlisted and clamped exactly like KNOBS: these reach a
89# command line.
90ERODE_KNOBS = {
91 "e_gully_metres": ("f", 30.0, 20000.0),
92 "e_strength": ("f", 0.0, 1.0),
93 "e_octaves": ("i", 1, 10),
94 "e_gain": ("f", 0.2, 0.9),
95 "e_cell_scale": ("f", 0.05, 4.0),
96 "e_rounding_input": ("f", 0.005, 4.0),
97 "e_offset": ("f", -2.0, 1.0),
98 "e_vscale": ("f", 0.0, 20000.0),
99}
100
101
102def erode_field(h, mpp, args):
103 """Run erode_heightmap on an in-memory field. Returns (height, ridge)."""
104 import tempfile
105 n = h.shape[0]
106 def gv(k):
107 kind, lo, hi = ERODE_KNOBS[k]
108 v = args.get(k)
109 if v is None or v == "":
110 return None
111 v = _clamp(float(v), lo, hi)
112 return int(round(v)) if kind == "i" else v
113 with tempfile.TemporaryDirectory() as td:
114 fin, fout, frg = f"{td}/in.f32", f"{td}/out.f32", f"{td}/ridge.f32"
115 np.nan_to_num(h, nan=float(np.nanmin(h))).astype("<f4").tofile(fin)
116 cmd = ["/tmp/erode_heightmap", "--in", fin, "--out", fout, "--ridge", frg,
117 "--n", str(n), "--mpp", repr(float(mpp))]
118 for k, flag in (("e_gully_metres", "--gully-metres"), ("e_strength", "--strength"),
119 ("e_octaves", "--octaves"), ("e_gain", "--gain"),
120 ("e_cell_scale", "--cell-scale"),
121 ("e_rounding_input", "--rounding-input"),
122 ("e_offset", "--offset"), ("e_vscale", "--vscale")):
123 v = gv(k)
124 if v is not None:
125 cmd += [flag, repr(v)]
126 pr = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
127 if pr.returncode != 0:
128 raise RuntimeError((pr.stderr or pr.stdout or "erode failed").strip()[:400])
129 out = np.fromfile(fout, dtype="<f4").reshape(n, n).astype(np.float64)
130 rg = np.fromfile(frg, dtype="<f4").reshape(n, n).astype(np.float64)
131 return out, rg
86132_cache = OrderedDict()
87133CACHE_MAX = 64
88134
……@@ -260,8 +306,11 @@ def api_diffusion():
260306 n = int(_clamp(float(a.get("n", 384)), 64, 1024))
261307 i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
262308 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
263 png, lo, hi = colourise(h, 30.0, a.get("view", "shaded-height"))
264309 extra = {"X-Source": "terrain-diffusion", "X-Mpp": "30"}
310 if a.get("erode") in ("1", "true", "on"):
311 h, _ridge = erode_field(h, 30.0, a)
312 extra["X-Source"] = "terrain-diffusion + erosion_filter"
313 png, lo, hi = colourise(h, 30.0, a.get("view", "shaded-height"))
265314 if climate is not None:
266315 extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"
267316 f"{climate[...,1].mean():.0f},"
……@@ -320,6 +369,9 @@ def api_diffusion_bin():
320369 i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
321370 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
322371 extra = {"X-Source": "terrain-diffusion"}
372 if a.get("erode") in ("1", "true", "on"):
373 h, _ridge = erode_field(h, 30.0, a)
374 extra["X-Source"] = "terrain-diffusion + erosion_filter"
323375 if climate is not None:
324376 extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"
325377 f"{climate[...,1].mean():.0f},"