OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 0f442dc

lookdev: show the metres each knob actually produces, and add the diffusion seed
The panel showed raw engine values, which are close to meaningless alone: terrain_height is a HORIZONTAL scale despite its name, every wavelength is terrain_height divided by another knob, detail_scale is inverse, and the erosion band is a product of three knobs. Each control now carries its derived metres, and gain-bearing knobs show gain*lacunarity with a warning above 1 -- the single number that was wrong in the shipping mountain layer (0.68 * 1.87 = 1.27). A "bands vs the view" block sits above the layers and states the problem permanently, in orange, at the shipping defaults: mountain 60 km -> 750 m (8 oct, lac 1.87) coarsest octave WIDER than view base gn 20 km -> 5.0 km (3 oct, lac 2.00) coarsest octave WIDER than view erosion 6.3 km -> 98 m (7 oct, lac 2.00) detail 100 m -> 15 m (4 oct, lac 1.87) view 15 km across Which is the actual defect: in a playable view the mountain layer's first three octaves are off-screen, so what you see is its rough TAIL. Lowering the gain removes the tail but cannot help, because the head is not in frame. Also adds the diffusion seed as a control, with "New location" and "New seed" buttons, because they are the two ways to get different terrain and they cost very differently -- measured, fresh tiles, direct to the service: tile km across km2 fresh s km2/s cached s 128 3.84 14.7 5.05 2.9 0.01 256 7.68 59.0 5.48 10.8 0.04 512 15.36 235.9 8.32 28.4 0.05 1024 30.72 943.7 22.67 41.6 0.16 ~4.5 s of fixed overhead per request, so throughput climbs with tile size and 1024 is the efficient unit at ~42 km2/s. A seed change costs ~3 s extra and flushes every cached tile; moving i1/j1 does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0f442dccb5c1c7ab7a3842eb887f58d5066d6746
by plankatron <noreply@anthropic.com> · 2026-08-31T17:02:01.000Z

Changed files (2) +138 -7

index.html +127 -3
……@@ -23,6 +23,12 @@ label{display:grid;grid-template-columns:1fr auto;gap:2px 8px;margin:9px 0;
2323label .v{font-family:"IBM Plex Mono",ui-monospace,monospace;color:var(--accent);
2424 font-size:11px;font-variant-numeric:tabular-nums}
2525input[type=range]{grid-column:1/-1;width:100%;accent-color:var(--accent);height:16px}
26.d{grid-column:1/-1;font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:10px;
27 color:var(--dim);line-height:1.35;margin-top:-2px}
28.d.warn{color:var(--warn)}
29#bands{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:10.5px;
30 color:var(--dim);white-space:pre;line-height:1.5}
31#bands .warn{color:var(--warn)}
2632input[type=number],select{background:#0e1216;color:var(--ink);border:1px solid var(--edge2);
2733 border-radius:3px;padding:3px 5px;font:inherit;font-size:11.5px;width:100%}
2834button{background:#22303a;color:var(--ink);border:1px solid var(--edge2);border-radius:3px;
……@@ -93,10 +99,21 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va
9399 <label>Erosion origin Z (m)<input type="number" id="oz" value="0" step="1000"></label>
94100 <label>Diffusion i1 (px)<input type="number" id="i1" value="0" step="256"></label>
95101 <label>Diffusion j1 (px)<input type="number" id="j1" value="2048" step="256"></label>
102 <label>Diffusion seed<input type="number" id="seed" value="1234" step="1">
103 <span class="d">a new seed is a whole new world. it rebuilds the pipeline and
104 clears every cached tile, so it is much slower than moving i1/j1.</span></label>
105 <div class="row">
106 <button id="newloc">New location</button>
107 <button id="newseed">New seed</button>
108 </div>
96109 <label id="exagrow" hidden>3D vertical exaggeration<span class="v" id="v_exag">1</span>
97110 <input type="range" id="exag" min="0.2" max="6" step="0.1" value="1"></label>
98111 </fieldset>
99112
113 <fieldset><legend>Bands vs the view</legend>
114 <div id="bands"></div>
115 </fieldset>
116
100117 <fieldset><legend>Mountain layer</legend>
101118 <div id="grp_mountain"></div>
102119 </fieldset>
……@@ -194,6 +211,100 @@ const GROUPS = {
194211 ["assumed_slope_mix",1.0,0.0,1.0,0.01]]
195212};
196213const $ = id => document.getElementById(id);
214
215// The panel used to show raw engine values, which are close to meaningless on
216// their own: terrain_height is a HORIZONTAL scale despite its name, every
217// wavelength is terrain_height divided (or multiplied) by another knob, and
218// detail_scale is inverse. Each control now carries the metres it actually
219// produces.
220const LABELS = {
221 terrain_height: "world scale (horizontal — sets every wavelength)",
222 mountain_scale: "mountain scale",
223 detail_scale: "detail scale (inverse)",
224 gn_scale: "base-noise scale (inverse)",
225 relief_scale: "relief scale (inverse)",
226 en_erosion_scale: "erosion scale",
227 rounding_input: "rounding input (input feature size)",
228 assumed_slope_mag:"assumed slope magnitude",
229 assumed_slope_mix:"assumed slope mix",
230};
231
232const num = k => +$(k).value;
233function km(m) {
234 if (!isFinite(m)) return "—";
235 return m >= 1000 ? `${(m/1000).toFixed(m >= 10000 ? 0 : 1)} km` : `${m.toFixed(0)} m`;
236}
237// A band as "coarsest -> finest over N octaves".
238function band(l0, lac, oct) {
239 const lmin = l0 / Math.pow(lac, Math.max(0, oct - 1));
240 return `${km(l0)} \u2192 ${km(lmin)} (${oct} oct, lac ${lac.toFixed(2)})`;
241}
242// gain*lacunarity > 1 means each octave adds MORE slope than the last, so the
243// finest octave dominates and large forms disappear. This is the single number
244// that was wrong in the shipping mountain layer (0.68 * 1.87 = 1.27).
245function gl(gain, lac) {
246 const r = gain * lac;
247 return {txt: `gain \u00d7 lac = ${r.toFixed(2)}` +
248 (r > 1 ? " \u2014 finest octave dominates" : ""), warn: r > 1};
249}
250
251function derived(k) {
252 const TH = num("terrain_height"), LAC = num("noise_lacunarity");
253 switch (k) {
254 case "terrain_height": return {txt: `all wavelengths are ${km(TH)} / (their scale knob)`};
255 case "mountain_scale": return {txt: band(TH / num("mountain_scale"), LAC, num("mountain_octaves"))};
256 case "mountain_octaves": return {txt: band(TH / num("mountain_scale"), LAC, num("mountain_octaves"))};
257 case "mountain_gain": return gl(num("mountain_gain"), LAC);
258 case "relief_scale": return {txt: num("relief_scale") > 0
259 ? `\u03bb ${km(TH / num("relief_scale"))}, 2 oct`
260 : "off"};
261 case "detail_scale": return {txt: num("detail_scale") > 0
262 ? band(TH / num("detail_scale"), LAC, num("detail_octaves"))
263 : "off"};
264 case "detail_octaves": return {txt: band(TH / num("detail_scale"), LAC, num("detail_octaves"))};
265 case "detail_gain": return gl(num("detail_gain"), LAC);
266 case "gn_scale": return {txt: band(TH / num("gn_scale"), 2.0, num("gn_detail"))};
267 case "gn_detail": return {txt: band(TH / num("gn_scale"), 2.0, num("gn_detail"))};
268 case "gn_gain": return gl(num("gn_gain"), 2.0);
269 case "noise_lacunarity": return {txt: "octave spacing for the mountain and detail layers"};
270 case "en_erosion_scale":
271 case "en_cell_scale":
272 case "en_octaves": return {txt: band(TH * num("en_erosion_scale") * num("en_cell_scale"),
273 num("en_erosion_lac"), num("en_octaves"))};
274 case "en_gain": return gl(num("en_gain"), num("en_erosion_lac"));
275 case "voxel_size": return {txt: `band-limit; at ${num("mpp")} m/px use \u2265 ${2*num("mpp")} m`,
276 warn: num("voxel_size") < 2 * num("mpp")};
277 case "rounding_input": return {txt: "runevision's input-feature-size control; pinned at 0.1 in the engine"};
278 case "assumed_slope_mix":return {txt: num("assumed_slope_mix") >= 1
279 ? "1.0 = input slope MAGNITUDE replaced; direction kept"
280 : "blending real input slope magnitude back in"};
281 default: return null;
282 }
283}
284
285function updateDerived() {
286 for (const items of Object.values(GROUPS))
287 for (const [key] of items) {
288 const el = $("d_" + key); if (!el) continue;
289 const d = derived(key);
290 el.textContent = d ? d.txt : "";
291 el.classList.toggle("warn", !!(d && d.warn));
292 }
293 const TH = num("terrain_height"), LAC = num("noise_lacunarity");
294 const view = num("n") * num("mpp");
295 const rows = [
296 ["mountain", TH / num("mountain_scale"), LAC, num("mountain_octaves")],
297 ["base gn ", TH / num("gn_scale"), 2.0, num("gn_detail")],
298 ["erosion ", TH * num("en_erosion_scale") * num("en_cell_scale"),
299 num("en_erosion_lac"), num("en_octaves")],
300 ["detail ", TH / num("detail_scale"), LAC, num("detail_octaves")],
301 ];
302 $("bands").innerHTML = rows.map(([nm, l0, lac, oct]) => {
303 const over = l0 > view;
304 return `${nm} ${(over ? "<span class=\"warn\">" : "<span>")}` +
305 `${band(l0, lac, oct)}${over ? " coarsest octave is WIDER than the view" : ""}</span>`;
306 }).join("\n") + `\nview ${km(view)} across`;
307}
197308let view = "shaded-height";
198309let mode = "2d";
199310
……@@ -201,8 +312,9 @@ for (const [grp, items] of Object.entries(GROUPS)) {
201312 const host = $("grp_" + grp);
202313 for (const [key, def, lo, hi, step] of items) {
203314 const l = document.createElement("label");
204 l.innerHTML = `${key.replace(/_/g," ")}<span class="v" id="v_${key}">${def}</span>
205 <input type="range" id="${key}" min="${lo}" max="${hi}" step="${step}" value="${def}">`;
315 l.innerHTML = `${LABELS[key] || key.replace(/_/g," ")}<span class="v" id="v_${key}">${def}</span>
316 <input type="range" id="${key}" min="${lo}" max="${hi}" step="${step}" value="${def}">
317 <span class="d" id="d_${key}"></span>`;
206318 host.appendChild(l);
207319 }
208320}
……@@ -224,6 +336,7 @@ function syncLabels() {
224336 `diffusion ${(n*30/1000).toFixed(2)} km @ 30 m/px` +
225337 (mpp !== 30 ? " · SCALES DIFFER" : "");
226338 $("scale").className = "stats" + (mpp !== 30 ? " err" : "");
339 updateDerived();
227340}
228341
229342async function load(which, url, imgId, stId, busyId) {
……@@ -404,7 +517,8 @@ function erosionQuery() {
404517function render() {
405518 syncLabels();
406519 const p = erosionQuery();
407 const q = new URLSearchParams({n: $("n").value, i1: $("i1").value, j1: $("j1").value});
520 const q = new URLSearchParams({n: $("n").value, i1: $("i1").value,
521 j1: $("j1").value, seed: $("seed").value});
408522
409523 if (mode === "3d") {
410524 load3d("/api/erosion.bin?" + p, "cv_e", "st_e", "busy_e");
……@@ -428,6 +542,16 @@ document.addEventListener("input", e => {
428542 if (e.target.type === "range" || e.target.type === "number") schedule();
429543});
430544$("render").onclick = render;
545// Two different ways to get different terrain, and they cost very differently.
546$("newloc").onclick = () => {
547 $("i1").value = Math.floor(Math.random() * 200000);
548 $("j1").value = Math.floor(Math.random() * 200000);
549 render();
550};
551$("newseed").onclick = () => {
552 $("seed").value = Math.floor(Math.random() * 1e9);
553 render();
554};
431555$("reset").onclick = () => {
432556 for (const items of Object.values(GROUPS))
433557 for (const [key, def] of items) $(key).value = def;
server.py +11 -4
……@@ -137,8 +137,15 @@ def render_erosion(ox, oz, n, mpp, knobs, steps, bisect):
137137 return h
138138
139139
140def fetch_diffusion(url, i1, j1, n):
141 q = f"{url}/terrain?i1={int(i1)}&j1={int(j1)}&i2={int(i1)+int(n)}&j2={int(j1)+int(n)}&scale=1"
140def fetch_diffusion(url, i1, j1, n, seed=None):
141 q = (f"{url}/terrain?i1={int(i1)}&j1={int(j1)}"
142 f"&i2={int(i1)+int(n)}&j2={int(j1)+int(n)}&scale=1")
143 # A new seed is a whole new world. NOTE: the upstream handler calls
144 # change_seed(), which clears every cached tile and rebuilds the pipeline --
145 # so changing seed is far more expensive than moving, and it invalidates the
146 # cache for everyone. Move with i1/j1 to browse; change seed to start over.
147 if seed not in (None, ""):
148 q += f"&seed={int(seed)}"
142149 with urllib.request.urlopen(q, timeout=600) as r:
143150 hh = int(r.headers["X-Height"]); ww = int(r.headers["X-Width"])
144151 body = r.read()
……@@ -252,7 +259,7 @@ def api_diffusion():
252259 a = request.args
253260 n = int(_clamp(float(a.get("n", 384)), 64, 1024))
254261 i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
255 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n)
262 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
256263 png, lo, hi = colourise(h, 30.0, a.get("view", "shaded-height"))
257264 extra = {"X-Source": "terrain-diffusion", "X-Mpp": "30"}
258265 if climate is not None:
……@@ -311,7 +318,7 @@ def api_diffusion_bin():
311318 a = request.args
312319 n = int(_clamp(float(a.get("n", 384)), 64, 1024))
313320 i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
314 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n)
321 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
315322 extra = {"X-Source": "terrain-diffusion"}
316323 if climate is not None:
317324 extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"