OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 5d768ee

lookdev: 18 more knobs, including the input-feature-size control the engine cannot reach
The app could turn the mountain and erosion parameters but not the eleven values erosion_node_eval bakes. Those are baked because GpuCompiler's push-constant block is seven floats; this path emits GLSL text and has no such cap, so with erosion_node_eval_full in place they are now sliders: rounding (x,y,z,w), onset (x,y,z,w), assumed_slope (magnitude, mix), height_amp, erosion lacunarity, gully_weight, detail, normalization, the height offset and fade_div. rounding_input (rounding.z) is the one that matters. runevision: "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." Pinned at 0.1 while terrain_height went 20 km -> 60 km. Verified live rather than assumed -- each knob moves the field monotonically: rounding_input 0.02 / 0.1 / 0.4 / 1.5 -> mean |diff| 0.52 / 0 / 2.00 / 9.51 m assumed_slope_mix 0.0 / 0.5 / 1.0 -> mean |diff| 30.24 / 14.35 / 0 m The first attempt at that check reported 0.00 m for every value and was WRONG: `kill $(pgrep -f ... | tail -1)` had killed the wrong pid, the new server never bound ("Address already in use"), and the renders came from the old process that did not know the parameter. Silence from a stale instrument looks exactly like a knob that does nothing. Confirm the listener pid changed before believing a before/after. Also adds sweep_mountain.py, which measures per-octave beta across a gain/octave sweep against two baselines (terrain-diffusion, and the filter with no mountain layer at all). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5d768eebe045465ed6615eac8a51a89f4f3409dd
by plankatron <noreply@anthropic.com> · 2026-08-31T16:48:47.000Z

Changed files (4) +182 -1

erosion_render.cpp +18 -0
……@@ -246,6 +246,24 @@ static void set_param(ErosionWrapperParams& p, const char* k, const char* v) {
246246 else if (!strcmp(k, "en_gain")) p.en_gain = f;
247247 else if (!strcmp(k, "en_octaves")) p.en_octaves = i;
248248 else if (!strcmp(k, "en_erosion_scale")) p.en_erosion_scale = f;
249 else if (!strcmp(k, "en_fade_div")) p.en_fade_div = f;
250 else if (!strcmp(k, "en_height_amp")) p.en_height_amp = f;
251 else if (!strcmp(k, "en_erosion_lac")) p.en_erosion_lac = f;
252 else if (!strcmp(k, "en_gully_weight")) p.en_gully_weight = f;
253 else if (!strcmp(k, "en_detail")) p.en_detail = f;
254 else if (!strcmp(k, "en_normalization")) p.en_normalization = f;
255 else if (!strcmp(k, "en_height_offset_a")) p.en_height_offset_a = f;
256 else if (!strcmp(k, "en_height_offset_b")) p.en_height_offset_b = f;
257 else if (!strcmp(k, "rounding_ridges")) p.rounding_ridges = f;
258 else if (!strcmp(k, "rounding_creases")) p.rounding_creases = f;
259 else if (!strcmp(k, "rounding_input")) p.rounding_input = f;
260 else if (!strcmp(k, "rounding_per_oct")) p.rounding_per_oct = f;
261 else if (!strcmp(k, "onset_input")) p.onset_input = f;
262 else if (!strcmp(k, "onset_per_oct")) p.onset_per_oct = f;
263 else if (!strcmp(k, "onset_ridge_input")) p.onset_ridge_input = f;
264 else if (!strcmp(k, "onset_ridge_oct")) p.onset_ridge_oct = f;
265 else if (!strcmp(k, "assumed_slope_mag")) p.assumed_slope_mag = f;
266 else if (!strcmp(k, "assumed_slope_mix")) p.assumed_slope_mix = f;
249267 else { fprintf(stderr, "unknown parameter '%s'\n", k); exit(2); }
250268}
251269
index.html +30 -1
……@@ -106,6 +106,17 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va
106106 <fieldset><legend>Detail + base noise</legend>
107107 <div id="grp_detail"></div>
108108 </fieldset>
109 <fieldset><legend>Filter shape · baked in the engine</legend>
110 <p class="note" style="margin:0 0 4px;border:0;padding:0">These are constants on
111 the engine path — <code>GpuCompiler</code>'s push-constant block is seven floats
112 and rounding plus onset need eight. This path emits GLSL text, so it has no such
113 cap. <b>rounding input</b> is runevision's input-feature-size control: <i>"the
114 erosion function doesn't know about the sizes of the terrain features coming
115 from [the input height function], so an input value is provided for tweaking
116 the rounding of those."</i> It has been pinned at 0.1 while terrain_height went
117 20&nbsp;km → 60&nbsp;km.</p>
118 <div id="grp_shape"></div>
119 </fieldset>
109120
110121 <p class="note">Both panels are shaded by the same code, so a difference on
111122 screen is a difference in the terrain and not in the renderer.
……@@ -162,7 +173,25 @@ const GROUPS = {
162173 ["detail_gain",0.5,0.2,0.9,0.01],["detail_octaves",4,1,10,1],
163174 ["noise_lacunarity",1.87,1.2,3,0.01],["gn_scale",3,0.1,40,0.1],
164175 ["gn_gain",0.1,0,0.9,0.01],["gn_detail",3,1,8,1],
165 ["voxel_size",1,0.25,512,0.25]]
176 ["voxel_size",1,0.25,512,0.25]],
177 shape: [["en_fade_div",4.8,0.5,40.0,0.1],
178 ["en_height_amp",0.25,0.02,2.0,0.01],
179 ["en_erosion_lac",2.0,1.2,4.0,0.01],
180 ["en_gully_weight",0.5,0.0,2.0,0.01],
181 ["en_detail",1.5,0.2,5.0,0.05],
182 ["en_normalization",0.5,0.0,1.0,0.01],
183 ["en_height_offset_a",-0.65,-2.0,1.0,0.01],
184 ["en_height_offset_b",0.0,0.0,1.0,0.01],
185 ["rounding_ridges",0.1,0.0,2.0,0.005],
186 ["rounding_creases",0.0,0.0,2.0,0.005],
187 ["rounding_input",0.1,0.005,4.0,0.005],
188 ["rounding_per_oct",2.0,0.25,6.0,0.05],
189 ["onset_input",1.25,0.0,6.0,0.05],
190 ["onset_per_oct",1.25,0.0,6.0,0.05],
191 ["onset_ridge_input",2.8,0.0,8.0,0.05],
192 ["onset_ridge_oct",1.5,0.0,6.0,0.05],
193 ["assumed_slope_mag",0.7,0.0,3.0,0.01],
194 ["assumed_slope_mix",1.0,0.0,1.0,0.01]]
166195};
167196const $ = id => document.getElementById(id);
168197let view = "shaded-height";
server.py +21 -0
……@@ -59,6 +59,27 @@ KNOBS = {
5959 "en_gain": ("f", 0.2, 0.9),
6060 "en_octaves": ("i", 1, 12),
6161 "en_erosion_scale": ("f", 0.002, 0.5),
62 # Baked on the engine path (7-float push-constant cap); reachable here
63 # because this path emits GLSL text. rounding_input is runevision's
64 # input-feature-size control.
65 "en_fade_div": ("f", 0.5, 40.0),
66 "en_height_amp": ("f", 0.02, 2.0),
67 "en_erosion_lac": ("f", 1.2, 4.0),
68 "en_gully_weight": ("f", 0.0, 2.0),
69 "en_detail": ("f", 0.2, 5.0),
70 "en_normalization": ("f", 0.0, 1.0),
71 "en_height_offset_a": ("f", -2.0, 1.0),
72 "en_height_offset_b": ("f", 0.0, 1.0),
73 "rounding_ridges": ("f", 0.0, 2.0),
74 "rounding_creases": ("f", 0.0, 2.0),
75 "rounding_input": ("f", 0.005, 4.0),
76 "rounding_per_oct": ("f", 0.25, 6.0),
77 "onset_input": ("f", 0.0, 6.0),
78 "onset_per_oct": ("f", 0.0, 6.0),
79 "onset_ridge_input": ("f", 0.0, 8.0),
80 "onset_ridge_oct": ("f", 0.0, 6.0),
81 "assumed_slope_mag": ("f", 0.0, 3.0),
82 "assumed_slope_mix": ("f", 0.0, 1.0),
6283}
6384
6485NO_SURFACE = -3.0e38
sweep_mountain.py +113 -0
……@@ -0,0 +1,113 @@
1#!/usr/bin/env python3
2"""
3MOUNTAIN-LAYER SWEEP — what gain and octave count let the erosion filter work?
4
5hyper-games #46. The filter is a faithful port of runevision's, and the
6reference feeds it ONE near-smooth fBm: freq 3, 3 octaves, gain 0.1, lac 2.0,
7amplitude 0.125 (lpmitchell/AdvancedTerrainErosion README, and the same config
8in the Shadertoy lineage). dc-terrain adds a mountain layer on top of that at
98 octaves, gain 0.68, lacunarity 1.87 -- gain*lac = 1.27, so the input's slope
10GROWS 27% per octave and the finest octave dominates it. The filter then puts
11gullies on every bump at every scale.
12
13CRITERION, so this is not eyeballing:
14
15 1. gain * lacunarity, which must be <= 1 for the input's slope not to grow
16 with frequency. Arithmetic, not opinion.
17 2. the per-octave local power-law exponent beta of the RESULT, measured by
18 the #46 §4 instrument. A generator with a drainage hierarchy STEEPENS
19 down the column (terrain-diffusion does: 4.18 -> 4.69 -> 5.10). One that
20 is texture at every scale stays flat (ours shipped at 3.58/3.44/3.78).
21
22Both baselines are rendered here too, so the column is read against something
23rather than in the abstract.
24"""
25import sys, urllib.parse, urllib.request
26import numpy as np
27sys.path.insert(0, "/storage/projects/code/godot/terrain-lookdev")
28from spectrum_probe import radial_power_spectrum, selftest
29
30LOOKDEV = "http://127.0.0.1:8818"
31N, MPP = 512, 30.0
32GEOM = dict(n=N, mpp=MPP, ox=120000, oz=40000, steps=256, voxel_size=2 * MPP)
33OCTAVE_BANDS = [(64, 128), (128, 256)] # 120-240 m, 60-120 m at this geometry
34FIT = (16, 64)
35
36
37def fetch(kind, **kw):
38 q = urllib.parse.urlencode(kw)
39 with urllib.request.urlopen(f"{LOOKDEV}/api/{kind}.bin?{q}", timeout=900) as r:
40 n = int(r.headers["X-N"]); mpp = float(r.headers["X-Mpp"])
41 buf = r.read()
42 return np.frombuffer(buf, dtype="<f4").reshape(n, n).astype(np.float64), mpp
43
44
45def betas(h, mpp):
46 """Local beta per octave, plus the sd of the field. No extrapolation."""
47 k, p = radial_power_spectrum(h)
48 out = []
49 for lo, hi in [FIT] + OCTAVE_BANDS:
50 m = (k > lo) & (k <= hi) & (p > 0)
51 if m.sum() < 4:
52 out.append(float("nan")); continue
53 s, _ = np.polyfit(np.log10(k[m]), np.log10(p[m]), 1)
54 out.append(-s)
55 return out
56
57
58def row(label, h, mpp, note=""):
59 b = betas(h, mpp)
60 trend = b[2] - b[1] # positive = steepens toward fine = hierarchy
61 print(f" {label:<26} {h.std():7.0f} {b[0]:7.2f} {b[1]:7.2f} {b[2]:7.2f} "
62 f"{trend:+7.2f} {note}")
63 return b, trend
64
65
66def main():
67 print("INSTRUMENT SELF-TEST")
68 if not selftest(verbose=False):
69 print(" FAILED"); return 2
70 print(" all pass\n")
71
72 print(f"{N}x{N} @ {MPP:g} m/px = {N*MPP/1000:.2f} km, voxel {GEOM['voxel_size']:g} m "
73 f"(band-limited to the sampling)")
74 print(f"beta measured locally inside each band. 'trend' = beta(60-120m) - beta(120-240m);")
75 print(f"POSITIVE means it steepens toward fine scales, i.e. large forms dominate.\n")
76 hdr = f" {'config':<26} {'sd m':>7} {'0.24-1km':>7} {'120-240':>7} {'60-120':>7} {'trend':>7}"
77 print(hdr); print(" " + "-" * (len(hdr) - 2))
78
79 # --- baselines ---------------------------------------------------------
80 hd, _ = fetch("diffusion", n=N, i1=0, j1=2048)
81 row("terrain-diffusion", hd, 30.0, "<- the target character")
82 hr, _ = fetch("erosion", **GEOM, mountain_height=0)
83 row("erosion, NO mountain", hr, MPP, "<- ~runevision's own config")
84 hs, _ = fetch("erosion", **GEOM)
85 row("SHIPPING (gain .68, oct 8)", hs, MPP, "<- gain*lac = 1.27")
86 print()
87
88 # --- gain sweep at 8 octaves ------------------------------------------
89 print(" gain sweep, mountain_octaves = 8")
90 best = None
91 for g in (0.60, 0.5348, 0.45, 0.35, 0.25, 0.15, 0.10):
92 h, _ = fetch("erosion", **GEOM, mountain_gain=g)
93 b, t = row(f" gain {g:.4f}", h, MPP, f"gain*lac {g*1.87:.2f}")
94 if best is None or t > best[1]:
95 best = (g, t)
96 print(f"\n best trend at gain {best[0]:.4f} ({best[1]:+.2f})\n")
97
98 # --- octave sweep at that gain ----------------------------------------
99 print(f" octave sweep, mountain_gain = {best[0]:.4f}")
100 for o in (8, 6, 5, 4, 3):
101 h, _ = fetch("erosion", **GEOM, mountain_gain=best[0], mountain_octaves=o)
102 row(f" octaves {o}", h, MPP)
103
104 # --- the reference shape, transplanted --------------------------------
105 print("\n runevision's own input shape on our mountain layer")
106 for g, o in ((0.10, 3), (0.15, 3), (0.25, 3), (0.25, 4), (0.35, 4)):
107 h, _ = fetch("erosion", **GEOM, mountain_gain=g, mountain_octaves=o)
108 row(f" gain {g:.2f} oct {o}", h, MPP, f"gain*lac {g*1.87:.2f}")
109 return 0
110
111
112if __name__ == "__main__":
113 sys.exit(main())