OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / f0987a9

lookdev: diffusion alone vs diffusion+erosion, and erosion sized by depth:width
PANELS. Both are now the diffusion field: left the model alone, right the same ground with the filter on top. The old left panel was our fBm mountain layer, and that comparison is finished -- #46 measured fBm producing texture (flat beta) where the model produces landscape (beta steepening 4.18 -> 5.10). The open question is no longer which generator wins but how the filter should sit on the one that does. STRENGTH WAS NOT A PHYSICAL QUANTITY, which is why it had to be tuned by eye. Reported as "erosion strength needed to be really low to look realistic". It did, and the reason is that `strength` is a fraction of H -- the input tile's own relief -- so the same number means different metres on every tile and its geometry depends on the gully band it happens to be paired with. Measured on a 2.3 km-relief tile with a 240 m coarsest gully: strength 0.22 (runevision's) -> depth:width 2.19 strength 0.15 -> 1.50 strength 0.08 -> 0.80 strength 0.02 -> 0.20 Real valleys run 0.05-0.25. runevision's 0.22 is correct for HIS input amplitude of 0.125 and absurd against ours, and nothing in the parameter said so. depth = 2 * strength * H, so strength = ratio * gully_metres / (2*H). The knob is now the RATIO and strength follows. Gated: requested vs measured comes out within 4-8%, and the SAME ratio gives the same geometry at 240 m and 480 m gully bands -- the residual is a constant per-band offset (+3.8% / -7.6%), not noise, so a small band dependence remains and is recorded rather than hidden. Same lesson as terrain_height and the panel relabelling: expose the physical quantity, not the raw multiplier. A knob whose meaning depends on the data it is applied to cannot be tuned, only fiddled with. Also brings across the explore page's hypsometric tint and exaggeration, so both pages colour the same way and both panels share one absolute ramp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f0987a9c4eb5e8546402c39e505d280c8a648e22
by plankatron <noreply@anthropic.com> · 2026-08-31T20:44:49.000Z

Changed files (3) +68 -10

erode_heightmap.cpp +27 -0
……@@ -75,6 +75,24 @@ struct Opts {
7575 // order 1, so H should be the input's own relief, not an arbitrary number.
7676 // 0 means "use the tile's (max-min)/2".
7777 float vscale = 0.0f;
78 // DEPTH : WIDTH, the scale-invariant way to ask for erosion.
79 //
80 // `strength` is a fraction of H, and H is the input's own relief -- so the
81 // same strength means different METRES on every tile, and the geometry it
82 // produces depends on the gully band it is paired with. Measured on a
83 // 2.3 km-relief tile with a 240 m coarsest gully:
84 //
85 // strength 0.22 (runevision's) -> depth:width 2.19
86 // strength 0.08 -> 0.80
87 // strength 0.02 -> 0.20
88 //
89 // Real valleys run about 0.05-0.25. runevision's 0.22 is right for HIS
90 // input amplitude of 0.125 and absurd for ours, which is why it had to be
91 // wound down by eye every time the tile changed.
92 //
93 // depth = 2 * strength * H, so strength = ratio * gully_metres / (2*H).
94 // Setting the ratio pins the GEOMETRY and lets strength follow.
95 float depth_ratio = 0.0f; // 0 = use --strength directly
7896};
7997
8098// One evaluation. Kept separate from the loop so --check can call it directly.
……@@ -130,6 +148,7 @@ int main(int argc, char** argv) {
130148 else if (!strcmp(a, "--rounding-input") && i+1<argc) o.rounding_input = (float)atof(next());
131149 else if (!strcmp(a, "--vscale") && i+1<argc) o.vscale = (float)atof(next());
132150 else if (!strcmp(a, "--offset") && i+1<argc) o.height_offset_a = (float)atof(next());
151 else if (!strcmp(a, "--depth-ratio") && i+1<argc) o.depth_ratio = (float)atof(next());
133152 else if (!strcmp(a, "--check")) check = true;
134153 else { fprintf(stderr, "unknown arg %s\n", a); return 2; }
135154 }
……@@ -174,6 +193,14 @@ int main(int argc, char** argv) {
174193 // is in metres and everything else follows.
175194 const float L = o.gully_metres / o.cell_scale;
176195
196 // Derive strength from the requested depth:width, so the same number gives
197 // the same geometry on any tile and at any gully band.
198 if (o.depth_ratio > 0.0f) {
199 o.strength = o.depth_ratio * o.gully_metres / (2.0f * H);
200 printf(" depth:width %.3f requested -> strength %.4f (H = %.0f m)\n",
201 o.depth_ratio, o.strength, H);
202 }
203
177204 printf("erode heightmap: %dx%d @ %g m/px = %.2f km across\n",
178205 n, n, o.mpp, n * o.mpp / 1000.0f);
179206 printf(" input relief %.1f .. %.1f m (mid %.1f, H %.1f)\n", lo, hi, mid, H);
index.html +35 -9
……@@ -168,22 +168,26 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va
168168 <button data-v="shaded-height" aria-pressed="true">Shaded height</button>
169169 <button data-v="shade" aria-pressed="false">Hillshade</button>
170170 <button data-v="slope" aria-pressed="false">Slope</button>
171 <button data-v="hypsometric" aria-pressed="false">Hypsometric</button>
171172 <button data-v="height" aria-pressed="false">Height</button>
172173 </div>
174 <label style="display:flex;gap:6px;align-items:center;font-size:11.5px;white-space:nowrap">
175 exag <span class="v" id="v_exag2">1</span>x
176 <input type="range" id="exag2" min="1" max="14" step="0.5" value="1" style="width:90px"></label>
173177 <div class="grow"></div>
174178 <div class="stats" id="scale" style="border:0;padding:0"></div>
175179 </div>
176180
177181 <div class="panes">
178182 <section class="pane">
179 <h2>Our erosion filter <span class="tag">erosion_filter.inc · gpu</span></h2>
183 <h2>terrain-diffusion <span class="tag">30 m model · the large forms</span></h2>
180184 <div class="imgwrap"><img id="img_e" alt="erosion render">
181185 <canvas id="cv_e" hidden></canvas></div>
182186 <div class="stats" id="st_e">—</div>
183187 <div class="busy" id="busy_e" hidden>rendering…</div>
184188 </section>
185189 <section class="pane">
186 <h2>terrain-diffusion <span class="tag">30 m model · seed 1234</span></h2>
190 <h2>+ erosion filter <span class="tag">erosion_filter.inc · the detail</span></h2>
187191 <div class="imgwrap"><img id="img_d" alt="diffusion tile">
188192 <canvas id="cv_d" hidden></canvas></div>
189193 <div class="stats" id="st_d">—</div>
……@@ -208,7 +212,7 @@ const GROUPS = {
208212 ["gn_gain",0.1,0,0.9,0.01],["gn_detail",3,1,8,1],
209213 ["voxel_size",1,0.25,512,0.25]],
210214 erode: [["e_upsample",2,1,4,1],
211 ["e_gully_metres",240,30,20000,10],["e_strength",0.08,0,1,0.005],
215 ["e_gully_metres",240,30,20000,10],["e_depth_ratio",0.12,0,0.6,0.005],
212216 ["e_octaves",5,1,10,1],["e_gain",0.5,0.2,0.9,0.01],
213217 ["e_cell_scale",0.7,0.05,4,0.01],["e_rounding_input",0.1,0.005,4,0.005],
214218 ["e_offset",0,-2,1,0.01]],
……@@ -308,7 +312,12 @@ function derived(k) {
308312 : ` grid Nyquist ${nyq.toFixed(0)} m`),
309313 warn: fin < nyq || num("e_gully_metres") > 400};
310314 }
311 case "e_strength": return {txt: "relative to the input tile's own relief; 0.22 is runevision's, far too strong here"};
315 case "e_depth_ratio": {
316 const g = num("e_gully_metres");
317 return {txt: `gully ${(num("e_depth_ratio")*g).toFixed(0)} m deep on ${g.toFixed(0)} m wide` +
318 ` — real valleys are 0.05–0.25`,
319 warn: num("e_depth_ratio") > 0.3};
320 }
312321 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"};
313322 case "assumed_slope_mix":return {txt: num("assumed_slope_mix") >= 1
314323 ? "1.0 = input slope MAGNITUDE replaced; direction kept"
……@@ -570,13 +579,26 @@ function render() {
570579 if (RAMP && $("lockramp").checked) {
571580 for (const u of [p, q]) { u.set("vmin", RAMP[0]); u.set("vmax", RAMP[1]); }
572581 }
582 // BOTH panels are the diffusion field. Left is the model alone; right is the
583 // same ground with the erosion filter on top. The old left panel was our fBm
584 // mountain layer, and that comparison is finished: #46 measured that fBm
585 // produces texture (flat beta) where the model produces landscape (beta
586 // steepening 4.18 -> 5.10). The open question is no longer which generator
587 // wins, it is how the filter should sit on top of the one that does.
588 const bare = new URLSearchParams(q); // diffusion alone
589 const eroded = new URLSearchParams(q); // diffusion + erosion
590 eroded.set("erode", "1");
591 for (const [key] of GROUPS.erode) eroded.set(key, $(key).value);
592 for (const u of [bare, eroded]) {
593 u.set("view", view);
594 u.set("exag", $("exag2").value);
595 }
573596 if (mode === "3d") {
574 load3d("/api/erosion.bin?" + p, "cv_e", "st_e", "busy_e");
575 load3d("/api/diffusion.bin?" + q, "cv_d", "st_d", "busy_d");
597 load3d("/api/diffusion.bin?" + bare, "cv_e", "st_e", "busy_e");
598 load3d("/api/diffusion.bin?" + eroded, "cv_d", "st_d", "busy_d");
576599 } else {
577 p.set("view", view); q.set("view", view);
578 load("e", "/api/erosion.png?" + p, "img_e", "st_e", "busy_e");
579 load("d", "/api/diffusion.png?" + q, "img_d", "st_d", "busy_d");
600 load("e", "/api/diffusion.png?" + bare, "img_e", "st_e", "busy_e");
601 load("d", "/api/diffusion.png?" + eroded, "img_d", "st_d", "busy_d");
580602 }
581603}
582604
……@@ -588,6 +610,10 @@ function schedule() {
588610 timer = setTimeout(render, 250);
589611}
590612
613$("exag2").addEventListener("input", () => {
614 $("v_exag2").textContent = (+$("exag2").value).toFixed(1).replace(/\.0$/, "");
615 schedule();
616});
591617$("erode").addEventListener("change", render);
592618$("lockramp").addEventListener("change", () => {
593619 if (!$("lockramp").checked) { RAMP = null; $("ramptxt").textContent = ""; }
server.py +6 -1
……@@ -186,6 +186,10 @@ ERODE_KNOBS = {
186186 # generates detail there. Without this the erosion band is floored at 60 m
187187 # and competes with the model instead of extending it.
188188 "e_upsample": ("i", 1, 4),
189 # depth:width. Scale-invariant, unlike e_strength which is a fraction of the
190 # tile's own relief. Real valleys run 0.05-0.25; runevision's strength 0.22
191 # lands at 2.19 on our input, which is why it had to be tuned down by eye.
192 "e_depth_ratio": ("f", 0.0, 1.0),
189193}
190194
191195
……@@ -223,7 +227,8 @@ def erode_field(h, mpp, args):
223227 ("e_octaves", "--octaves"), ("e_gain", "--gain"),
224228 ("e_cell_scale", "--cell-scale"),
225229 ("e_rounding_input", "--rounding-input"),
226 ("e_offset", "--offset"), ("e_vscale", "--vscale")):
230 ("e_offset", "--offset"), ("e_vscale", "--vscale"),
231 ("e_depth_ratio", "--depth-ratio")):
227232 if k == "e_upsample":
228233 continue
229234 v = gv(k)