<!doctype html>
<meta charset="utf-8">
<title>Terrain Lookdev</title>
<style>
:root{
--bg:#12161a; --panel:#191f25; --edge:#28313a; --edge2:#3a4652;
--ink:#e2e8e6; --dim:#8c9aa1; --accent:#74c2cd; --warn:#d7a45c;
font-family:"IBM Plex Sans",system-ui,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-size:13px;height:100vh;
display:grid;grid-template-columns:290px 1fr;overflow:hidden}
h1{font-size:13px;margin:0 0 2px;letter-spacing:.02em}
.sub{color:var(--dim);font-size:11px;margin:0 0 14px;line-height:1.45}
aside{background:var(--panel);border-right:1px solid var(--edge);
padding:14px;overflow-y:auto}
fieldset{border:0;border-top:1px solid var(--edge);margin:14px 0 0;padding:12px 0 0}
legend{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:10px;
letter-spacing:.12em;text-transform:uppercase;color:var(--dim);padding:0}
label{display:grid;grid-template-columns:1fr auto;gap:2px 8px;margin:9px 0;
font-size:11.5px;align-items:center}
label .v{font-family:"IBM Plex Mono",ui-monospace,monospace;color:var(--accent);
font-size:11px;font-variant-numeric:tabular-nums}
input[type=range]{grid-column:1/-1;width:100%;accent-color:var(--accent);height:16px}
.d{grid-column:1/-1;font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:10px;
color:var(--dim);line-height:1.35;margin-top:-2px}
.d.warn{color:var(--warn)}
#bands{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:10.5px;
color:var(--dim);white-space:pre;line-height:1.5}
#bands .warn{color:var(--warn)}
input[type=number],select{background:#0e1216;color:var(--ink);border:1px solid var(--edge2);
border-radius:3px;padding:3px 5px;font:inherit;font-size:11.5px;width:100%}
button{background:#22303a;color:var(--ink);border:1px solid var(--edge2);border-radius:3px;
padding:6px 10px;font:inherit;font-size:11.5px;cursor:pointer}
button:hover{border-color:var(--accent)}
button.primary{background:var(--accent);color:#0d1417;border-color:var(--accent);font-weight:600}
.row{display:flex;gap:6px;margin:8px 0}
.row>*{flex:1}
main{display:flex;flex-direction:column;overflow:hidden}
.bar{display:flex;gap:14px;align-items:center;padding:9px 14px;
border-bottom:1px solid var(--edge);background:var(--panel);flex-wrap:wrap}
.bar .grow{flex:1}
.views{display:flex;gap:0;border:1px solid var(--edge2);border-radius:3px;overflow:hidden}
.views button{border:0;border-radius:0;border-right:1px solid var(--edge2);padding:5px 11px}
.views button:last-child{border-right:0}
.views button[aria-pressed=true]{background:var(--accent);color:#0d1417;font-weight:600}
.panes{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--edge);
overflow:hidden;min-height:0}
.pane{background:#0d1114;display:flex;flex-direction:column;min-height:0;position:relative}
.pane h2{margin:0;padding:7px 12px;font-size:11px;font-weight:600;letter-spacing:.02em;
border-bottom:1px solid var(--edge);display:flex;gap:10px;align-items:baseline}
.pane h2 .tag{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:9.5px;
letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
.imgwrap{flex:1;display:flex;align-items:center;justify-content:center;
overflow:hidden;padding:10px;min-height:0}
.imgwrap img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated;
border:1px solid var(--edge);display:block}
.imgwrap canvas{width:100%;height:100%;border:1px solid var(--edge);display:block;
cursor:grab;touch-action:none}
.imgwrap canvas:active{cursor:grabbing}
.stats{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:10.5px;color:var(--dim);
padding:6px 12px;border-top:1px solid var(--edge);white-space:pre-wrap;
font-variant-numeric:tabular-nums;min-height:26px}
.busy{position:absolute;inset:0;display:grid;place-items:center;background:#0d1114cc;
font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--accent)}
/* display:grid above beats the hidden attribute's UA display:none, so the
overlay would never go away. State lives on the attribute; say so here. */
.busy[hidden]{display:none}
img[hidden],canvas[hidden]{display:none}
.err{color:var(--warn)}
.note{font-size:10.5px;color:var(--dim);line-height:1.5;margin-top:12px;
border-top:1px solid var(--edge);padding-top:10px}
kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid var(--edge2);
border-radius:2px;padding:0 4px;font-size:10px}
</style>
<aside>
<h1>Terrain Lookdev</h1>
<p class="sub">hyper-games #46 §4 measured that these two differ in roughness.
It cannot say which is better to look at. That is what this is for.</p>
<div class="row">
<button class="primary" id="render">Render</button>
<button id="reset">Reset</button>
</div>
<label>Auto-render on change <input type="checkbox" id="auto" checked style="width:auto"></label>
<fieldset><legend>View</legend>
<label>Tile size (px)<span class="v" id="v_n">384</span>
<input type="range" id="n" min="128" max="768" step="64" value="384"></label>
<label>Metres per pixel<span class="v" id="v_mpp">30</span>
<input type="range" id="mpp" min="2" max="200" step="1" value="30"></label>
<label>Scan steps (quality)<span class="v" id="v_steps">256</span>
<input type="range" id="steps" min="64" max="768" step="32" value="256"></label>
<label>Erosion origin X (m)<input type="number" id="ox" value="0" step="1000"></label>
<label>Erosion origin Z (m)<input type="number" id="oz" value="0" step="1000"></label>
<label>Diffusion i1 (px)<input type="number" id="i1" value="0" step="256"></label>
<label>Diffusion j1 (px)<input type="number" id="j1" value="2048" step="256"></label>
<label>Diffusion seed<input type="number" id="seed" value="1234" step="1">
<span class="d">a whole new world, and it flushes every cached tile — so it
is applied only on Enter or the button below, never by scrolling or
arrow keys. Active seed is shown next to it.</span></label>
<div class="row"><button id="applyseed">Apply seed</button>
<span class="v" id="activeseed" style="align-self:center">1234</span></div>
<div class="row">
<button id="newloc">New location</button>
<button id="newseed">New seed</button>
</div>
<label id="exagrow" hidden>3D vertical exaggeration<span class="v" id="v_exag">1</span>
<input type="range" id="exag" min="0.2" max="6" step="0.1" value="1"></label>
<label class="d3" hidden>sun azimuth<span class="v" id="v_sunaz">315</span>°
<input type="range" id="sunaz" min="0" max="360" step="5" value="315"></label>
<label class="d3" hidden>sun elevation<span class="v" id="v_sunel">28</span>°
<input type="range" id="sunel" min="2" max="80" step="1" value="28"></label>
<label class="d3" hidden>haze<span class="v" id="v_haze">0.5</span>
<input type="range" id="haze" min="0" max="1" step="0.02" value="0.5"></label>
<label class="d3" hidden><input type="checkbox" id="shadows" checked style="width:auto">
cast shadows</label>
<label class="d3" hidden><input type="checkbox" id="matslope" checked style="width:auto">
material by slope + curvature</label>
<label class="d3" hidden><input type="checkbox" id="ray" style="width:auto">
raymarch (soft shadows + AO)</label>
<label class="d3" hidden>ray quality<span class="v" id="v_rayq">1.0</span>
<input type="range" id="rayq" min="0.4" max="2" step="0.1" value="1.0"></label>
</fieldset>
<fieldset><legend>Erosion ON the diffusion field</legend>
<label>apply erosion filter to diffusion
<input type="checkbox" id="erode" style="width:auto">
<span class="d">#46 §4b option 1 — the model supplies the large forms it is
good at, the filter supplies detail below its 30 m floor. runevision's
filter is explicitly "applied on top of any height function".</span></label>
<div id="grp_erode"></div>
</fieldset>
<fieldset><legend>Bands vs the view</legend>
<div id="bands"></div>
</fieldset>
<fieldset><legend>Mountain layer</legend>
<div id="grp_mountain"></div>
</fieldset>
<fieldset><legend>Erosion filter</legend>
<div id="grp_erosion"></div>
</fieldset>
<fieldset><legend>Detail + base noise</legend>
<div id="grp_detail"></div>
</fieldset>
<fieldset><legend>Filter shape · baked in the engine</legend>
<p class="note" style="margin:0 0 4px;border:0;padding:0">These are constants on
the engine path — <code>GpuCompiler</code>'s push-constant block is seven floats
and rounding plus onset need eight. This path emits GLSL text, so it has no such
cap. <b>rounding input</b> is runevision's input-feature-size control: <i>"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."</i> It has been pinned at 0.1 while terrain_height went
20 km → 60 km.</p>
<div id="grp_shape"></div>
</fieldset>
<p class="note">Both panels are shaded by the same code, so a difference on
screen is a difference in the terrain and not in the renderer.
Diffusion is always at its native 30 m/px — its <code>scale</code> knob
is bilinear (#46 §4.1) and would only blur.
Drag on a panel to pan. <kbd>R</kbd> renders.</p>
</aside>
<main>
<div class="bar">
<label style="display:flex;gap:5px;align-items:center;font-size:11.5px">
<input type="checkbox" id="lockramp" style="width:auto"> lock ramp
<span id="ramptxt" style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--dim)"></span>
</label>
<div class="views" id="modes">
<button data-m="2d" aria-pressed="true">2D</button>
<button data-m="3d" aria-pressed="false">3D</button>
</div>
<div class="views" id="views">
<button data-v="shaded-height" aria-pressed="true">Shaded height</button>
<button data-v="shade" aria-pressed="false">Hillshade</button>
<button data-v="slope" aria-pressed="false">Slope</button>
<button data-v="hypsometric" aria-pressed="false">Hypsometric</button>
<button data-v="height" aria-pressed="false">Height</button>
</div>
<label style="display:flex;gap:6px;align-items:center;font-size:11.5px;white-space:nowrap">
exag <span class="v" id="v_exag2">1</span>x
<input type="range" id="exag2" min="1" max="14" step="0.5" value="1" style="width:90px"></label>
<div class="grow"></div>
<div class="stats" id="scale" style="border:0;padding:0"></div>
</div>
<div class="panes">
<section class="pane">
<h2>terrain-diffusion <span class="tag">30 m model · the large forms</span></h2>
<div class="imgwrap"><img id="img_e" alt="erosion render">
<canvas id="cv_e" hidden></canvas></div>
<div class="stats" id="st_e">—</div>
<div class="busy" id="busy_e" hidden>rendering…</div>
</section>
<section class="pane">
<h2>+ erosion filter <span class="tag">erosion_filter.inc · the detail</span></h2>
<div class="imgwrap"><img id="img_d" alt="diffusion tile">
<canvas id="cv_d" hidden></canvas></div>
<div class="stats" id="st_d">—</div>
<div class="busy" id="busy_d" hidden>fetching…</div>
</section>
</div>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.min.js"></script>
<script>
const GROUPS = {
mountain: [["terrain_height",60000,1000,200000,500],["mountain_scale",1,0.05,8,0.05],
["mountain_height",0.55,0,2,0.01],["mountain_gain",0.68,0.2,0.9,0.01],
["mountain_variance",0,0,1,0.01],["mountain_octaves",8,1,12,1],
["relief_scale",0.5,0,8,0.05],["relief_floor",0.08,0,1,0.01]],
erosion: [["en_strength",0.15,0,1.5,0.005],["en_erosion_scale",0.15,0.002,0.5,0.002],
["en_cell_scale",0.7,0.05,4,0.01],["en_gain",0.5,0.2,0.9,0.01],
["en_octaves",7,1,12,1],["en_height_scale",5,0.1,40,0.1]],
detail: [["detail_scale",600,0,5000,10],["detail_height",30,0,500,1],
["detail_gain",0.5,0.2,0.9,0.01],["detail_octaves",4,1,10,1],
["noise_lacunarity",1.87,1.2,3,0.01],["gn_scale",3,0.1,40,0.1],
["gn_gain",0.1,0,0.9,0.01],["gn_detail",3,1,8,1],
["voxel_size",1,0.25,512,0.25]],
erode: [["e_upsample",2,1,4,1],
["e_gully_metres",240,30,20000,10],["e_depth_ratio",0.12,0,0.6,0.005],
["e_octaves",5,1,10,1],["e_gain",0.5,0.2,0.9,0.01],
["e_cell_scale",0.7,0.05,4,0.01],["e_rounding_input",0.1,0.005,4,0.005],
["e_offset",0,-2,1,0.01]],
shape: [["en_fade_div",4.8,0.5,40.0,0.1],
["en_height_amp",0.25,0.02,2.0,0.01],
["en_erosion_lac",2.0,1.2,4.0,0.01],
["en_gully_weight",0.5,0.0,2.0,0.01],
["en_detail",1.5,0.2,5.0,0.05],
["en_normalization",0.5,0.0,1.0,0.01],
["en_height_offset_a",-0.65,-2.0,1.0,0.01],
["en_height_offset_b",0.0,0.0,1.0,0.01],
["rounding_ridges",0.1,0.0,2.0,0.005],
["rounding_creases",0.0,0.0,2.0,0.005],
["rounding_input",0.1,0.005,4.0,0.005],
["rounding_per_oct",2.0,0.25,6.0,0.05],
["onset_input",1.25,0.0,6.0,0.05],
["onset_per_oct",1.25,0.0,6.0,0.05],
["onset_ridge_input",2.8,0.0,8.0,0.05],
["onset_ridge_oct",1.5,0.0,6.0,0.05],
["assumed_slope_mag",0.7,0.0,3.0,0.01],
["assumed_slope_mix",1.0,0.0,1.0,0.01]]
};
const $ = id => document.getElementById(id);
// The panel used to show raw engine values, which are close to meaningless on
// their own: terrain_height is a HORIZONTAL scale despite its name, every
// wavelength is terrain_height divided (or multiplied) by another knob, and
// detail_scale is inverse. Each control now carries the metres it actually
// produces.
const LABELS = {
terrain_height: "world scale (horizontal — sets every wavelength)",
mountain_scale: "mountain scale",
detail_scale: "detail scale (inverse)",
gn_scale: "base-noise scale (inverse)",
relief_scale: "relief scale (inverse)",
en_erosion_scale: "erosion scale",
rounding_input: "rounding input (input feature size)",
assumed_slope_mag:"assumed slope magnitude",
assumed_slope_mix:"assumed slope mix",
};
const num = k => +$(k).value;
function km(m) {
if (!isFinite(m)) return "—";
return m >= 1000 ? `${(m/1000).toFixed(m >= 10000 ? 0 : 1)} km` : `${m.toFixed(0)} m`;
}
// A band as "coarsest -> finest over N octaves".
function band(l0, lac, oct) {
const lmin = l0 / Math.pow(lac, Math.max(0, oct - 1));
return `${km(l0)} \u2192 ${km(lmin)} (${oct} oct, lac ${lac.toFixed(2)})`;
}
// gain*lacunarity > 1 means each octave adds MORE slope than the last, so the
// finest octave dominates and large forms disappear. This is the single number
// that was wrong in the shipping mountain layer (0.68 * 1.87 = 1.27).
function gl(gain, lac) {
const r = gain * lac;
return {txt: `gain \u00d7 lac = ${r.toFixed(2)}` +
(r > 1 ? " \u2014 finest octave dominates" : ""), warn: r > 1};
}
function derived(k) {
const TH = num("terrain_height"), LAC = num("noise_lacunarity");
switch (k) {
case "terrain_height": return {txt: `all wavelengths are ${km(TH)} / (their scale knob)`};
case "mountain_scale": return {txt: band(TH / num("mountain_scale"), LAC, num("mountain_octaves"))};
case "mountain_octaves": return {txt: band(TH / num("mountain_scale"), LAC, num("mountain_octaves"))};
case "mountain_gain": return gl(num("mountain_gain"), LAC);
case "relief_scale": return {txt: num("relief_scale") > 0
? `\u03bb ${km(TH / num("relief_scale"))}, 2 oct`
: "off"};
case "detail_scale": return {txt: num("detail_scale") > 0
? band(TH / num("detail_scale"), LAC, num("detail_octaves"))
: "off"};
case "detail_octaves": return {txt: band(TH / num("detail_scale"), LAC, num("detail_octaves"))};
case "detail_gain": return gl(num("detail_gain"), LAC);
case "gn_scale": return {txt: band(TH / num("gn_scale"), 2.0, num("gn_detail"))};
case "gn_detail": return {txt: band(TH / num("gn_scale"), 2.0, num("gn_detail"))};
case "gn_gain": return gl(num("gn_gain"), 2.0);
case "noise_lacunarity": return {txt: "octave spacing for the mountain and detail layers"};
case "en_erosion_scale":
case "en_cell_scale":
case "en_octaves": return {txt: band(TH * num("en_erosion_scale") * num("en_cell_scale"),
num("en_erosion_lac"), num("en_octaves"))};
case "en_gain": return gl(num("en_gain"), num("en_erosion_lac"));
case "voxel_size": return {txt: `band-limit; at ${num("mpp")} m/px use \u2265 ${2*num("mpp")} m`,
warn: num("voxel_size") < 2 * num("mpp")};
case "rounding_input": return {txt: "runevision's input-feature-size control; pinned at 0.1 in the engine"};
case "e_upsample": return {txt: `filter runs at ${(30/num("e_upsample")).toFixed(2)} m/px, ` +
`Nyquist ${(60/num("e_upsample")).toFixed(0)} m ` +
`(the model itself stops at 60 m)`};
case "e_gully_metres":
case "e_octaves": {
const fin = num("e_gully_metres") / Math.pow(2, num("e_octaves") - 1);
const nyq = 60 / num("e_upsample");
return {txt: band(num("e_gully_metres"), 2.0, num("e_octaves")) +
(fin < nyq ? ` below the ${nyq.toFixed(0)} m grid Nyquist — aliases`
: ` grid Nyquist ${nyq.toFixed(0)} m`),
warn: fin < nyq || num("e_gully_metres") > 400};
}
case "e_depth_ratio": {
const g = num("e_gully_metres");
return {txt: `gully ${(num("e_depth_ratio")*g).toFixed(0)} m deep on ${g.toFixed(0)} m wide` +
` — real valleys are 0.05–0.25`,
warn: num("e_depth_ratio") > 0.3};
}
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"};
case "assumed_slope_mix":return {txt: num("assumed_slope_mix") >= 1
? "1.0 = input slope MAGNITUDE replaced; direction kept"
: "blending real input slope magnitude back in"};
default: return null;
}
}
function updateDerived() {
for (const items of Object.values(GROUPS))
for (const [key] of items) {
const el = $("d_" + key); if (!el) continue;
const d = derived(key);
el.textContent = d ? d.txt : "";
el.classList.toggle("warn", !!(d && d.warn));
}
const TH = num("terrain_height"), LAC = num("noise_lacunarity");
const view = num("n") * num("mpp");
const rows = [
["mountain", TH / num("mountain_scale"), LAC, num("mountain_octaves")],
["base gn ", TH / num("gn_scale"), 2.0, num("gn_detail")],
["erosion ", TH * num("en_erosion_scale") * num("en_cell_scale"),
num("en_erosion_lac"), num("en_octaves")],
["detail ", TH / num("detail_scale"), LAC, num("detail_octaves")],
];
$("bands").innerHTML = rows.map(([nm, l0, lac, oct]) => {
const over = l0 > view;
return `${nm} ${(over ? "<span class=\"warn\">" : "<span>")}` +
`${band(l0, lac, oct)}${over ? " coarsest octave is WIDER than the view" : ""}</span>`;
}).join("\n") + `\nview ${km(view)} across`;
}
let view = "shaded-height";
let mode = "2d";
let RAMP = null; // pinned colour ramp [lo, hi], null = fit per tile
let ACTIVE_SEED = "1234"; // what the server is actually on; the field is a draft
for (const [grp, items] of Object.entries(GROUPS)) {
const host = $("grp_" + grp);
for (const [key, def, lo, hi, step] of items) {
const l = document.createElement("label");
l.innerHTML = `${LABELS[key] || key.replace(/_/g," ")}<span class="v" id="v_${key}">${def}</span>
<input type="range" id="${key}" min="${lo}" max="${hi}" step="${step}" value="${def}">
<span class="d" id="d_${key}"></span>`;
host.appendChild(l);
}
}
function knobParams() {
const p = new URLSearchParams();
for (const items of Object.values(GROUPS))
for (const [key] of items) p.set(key, $(key).value);
return p;
}
function syncLabels() {
for (const id of ["n","mpp","steps","exag"]) $("v_"+id).textContent = $(id).value;
for (const items of Object.values(GROUPS))
for (const [key] of items) $("v_"+key).textContent = $(key).value;
const n = +$("n").value, mpp = +$("mpp").value;
$("scale").textContent =
`erosion ${(n*mpp/1000).toFixed(2)} km @ ${mpp} m/px · ` +
`diffusion ${(n*30/1000).toFixed(2)} km @ 30 m/px` +
(mpp !== 30 ? " · SCALES DIFFER" : "");
$("scale").className = "stats" + (mpp !== 30 ? " err" : "");
updateDerived();
}
async function load(which, url, imgId, stId, busyId) {
$(busyId).hidden = false;
const t0 = performance.now();
try {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
const b = await r.blob();
const old = $(imgId).src;
$(imgId).src = URL.createObjectURL(b);
if (old.startsWith("blob:")) URL.revokeObjectURL(old);
const h = r.headers;
const ms = (performance.now() - t0).toFixed(0);
// Capture the ramp the first time it is locked, so both panels and every
// subsequent pan use the SAME scale.
if ($("lockramp").checked && !RAMP && h.get("X-Ramp")) {
RAMP = h.get("X-Ramp").split(",").map(Number);
$("ramptxt").textContent = `${RAMP[0].toFixed(0)} .. ${RAMP[1].toFixed(0)} m`;
}
let s = `min ${h.get("X-Min")} max ${h.get("X-Max")} ` +
`mean ${h.get("X-Mean")} sd ${h.get("X-Sd")} m ${ms} ms`;
if (h.get("X-Climate")) s += `\n${h.get("X-Climate")}`;
$(stId).textContent = s;
$(stId).classList.remove("err");
} catch (e) {
$(stId).textContent = String(e.message || e);
$(stId).classList.add("err");
} finally {
$(busyId).hidden = true;
}
}
// ---------------------------------------------------------------------------
// 3D. A different VIEW of the same heightfield the 2D panel shows -- same
// endpoint, same render path -- so the two tabs can never disagree about the
// terrain. Shading is a single directional light placed at the same azimuth
// and altitude as the 2D hillshade (315 deg / 45 deg), for the same reason the
// two panels share one shader: a difference on screen should be the terrain.
// ---------------------------------------------------------------------------
const V3 = {}; // per-pane viewer state
// ---------------------------------------------------------------------------
// RAYMARCHED VIEW.
//
// The mesh view is a displaced grid with a shadow map: hard shadows at map
// resolution, a curvature fudge for AO, and one fog colour. Marching the
// heightfield instead gives real soft shadows and real ambient occlusion for
// almost nothing, because both are just more marching.
//
// The albedo comes from computeAlbedo() as a TEXTURE -- the identical array the
// mesh uses for vertex colours -- so the two views cannot disagree about
// colour. That was the requirement, and uploading the answer is the only way to
// meet it that does not create a second material to keep in sync.
// ---------------------------------------------------------------------------
const RAY_FS = `
precision highp float;
precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uHeight, uAlbedo;
uniform vec2 uTexel;
uniform vec3 uCamPos, uCamR, uCamU, uCamF;
uniform float uTanHalf, uAspect;
uniform vec3 uSunDir, uSunCol, uSkyCol, uHorizon, uGroundCol;
uniform float uHemiI;
uniform float uYScale, uQuality, uFogNear, uFogFar, uHasFog;
uniform float uYMin, uYMax;
float hAt(vec2 p) { // p in [0,1]^2, returns world Y
return texture2D(uHeight, clamp(p, vec2(0.0), vec2(1.0))).r * uYScale;
}
// The terrain occupies x,z in [-0.5,0.5]; uv = xz + 0.5.
float terrain(vec3 p) { return p.y - hAt(p.xz + 0.5); }
vec3 normalAt(vec2 uv) {
float l = hAt(uv - vec2(uTexel.x, 0.0)), r = hAt(uv + vec2(uTexel.x, 0.0));
float d = hAt(uv - vec2(0.0, uTexel.y)), u = hAt(uv + vec2(0.0, uTexel.y));
return normalize(vec3(l - r, 2.0 * uTexel.x, d - u));
}
// Soft shadow: march toward the sun and track the closest approach, scaled by
// distance. This is the cheap trick that makes raymarched terrain read as real
// -- a shadow map cannot do a penumbra that widens with distance.
float softShadow(vec3 ro, vec3 rd) {
float res = 1.0, t = 0.006;
for (int i = 0; i < 48; i++) {
vec3 p = ro + rd * t;
if (p.y > uYMax || t > 2.5) break;
if (abs(p.x) > 0.5 || abs(p.z) > 0.5) break;
float h = terrain(p);
res = min(res, 12.0 * h / t);
if (res < 0.002) return 0.0;
t += clamp(h, 0.004, 0.06);
}
return clamp(res, 0.0, 1.0);
}
// Real AO: sample the surface above the point at growing radii.
float ao(vec3 p, vec3 n) {
float occ = 0.0, sca = 1.0;
for (int i = 0; i < 5; i++) {
float hstep = 0.004 + 0.02 * float(i);
float d = terrain(p + n * hstep);
occ += (hstep - d) * sca;
sca *= 0.72;
}
return clamp(1.0 - 2.2 * occ, 0.0, 1.0);
}
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
vec3 rd = normalize(uCamF + uCamR * (ndc.x * uTanHalf * uAspect)
+ uCamU * (ndc.y * uTanHalf));
vec3 ro = uCamPos;
// Intersect the terrain's BOUNDING BOX first and march only inside it.
//
// Without this, sampling the height texture outside [0,1] clamps to the edge
// texel, so the terrain extends to infinity as a flat plane -- which is
// exactly the streaked slab the first version rendered across the sky.
vec3 bmin = vec3(-0.5, uYMin - 0.001, -0.5);
vec3 bmax = vec3( 0.5, uYMax + 0.001, 0.5);
vec3 inv = 1.0 / rd;
vec3 t0 = (bmin - ro) * inv, t1 = (bmax - ro) * inv;
vec3 tn = min(t0, t1), tf = max(t0, t1);
float tEnter = max(max(tn.x, tn.y), tn.z);
float tExit = min(min(tf.x, tf.y), tf.z);
bool hit = false;
float t = max(tEnter, 0.0);
if (tExit > max(tEnter, 0.0)) {
float dt = 0.0035 / uQuality;
for (int i = 0; i < 420; i++) {
if (t > tExit) break;
vec3 p = ro + rd * t;
float h = terrain(p);
if (h < 0.0) {
float a = max(t - dt, tEnter), b = t;
for (int k = 0; k < 12; k++) {
float m = 0.5 * (a + b);
if (terrain(ro + rd * m) < 0.0) b = m; else a = m;
}
t = 0.5 * (a + b); hit = true; break;
}
dt = max(0.0022, h * 0.5) / uQuality;
t += dt;
}
}
vec3 sky = mix(uHorizon, uSkyCol, clamp(rd.y * 2.2, 0.0, 1.0));
if (!hit) { gl_FragColor = vec4(sky, 1.0); return; }
vec3 p = ro + rd * t;
vec2 uv = clamp(p.xz + 0.5, vec2(0.0), vec2(1.0));
vec3 alb = texture2D(uAlbedo, uv).rgb;
vec3 nor = normalAt(uv);
float sh = softShadow(p + nor * 0.004, uSunDir);
float occ = ao(p, nor);
float dif = clamp(dot(nor, uSunDir), 0.0, 1.0);
float bounce = clamp(0.5 + 0.5 * dot(nor, vec3(0.0, -1.0, 0.0)), 0.0, 1.0);
float skyl = clamp(0.5 + 0.5 * nor.y, 0.0, 1.0);
// Matched to the mesh path by MEASUREMENT, not by eye. The first balance had
// ambient at 0.55 and Reinhard tonemapping, and came out 28 points brighter
// in blue with half the saturation (0.091 vs 0.197) -- a washed-out image
// over an albedo that is identical by construction. Ambient light with no
// directional variation is what destroys saturation, so it is the term to cut.
// Three's HemisphereLight is mix(groundColor, skyColor, 0.5+0.5*dot(n,up))
// times intensity. The first version added PURE SKY, omitting the ground term
// entirely, so every surface got a blue wash: blue came out +26 against the
// mesh and saturation halved (0.096 vs 0.197). Lowering the ambient magnitude
// barely moved it, because the problem was the model and not the constant.
vec3 hemi = mix(uGroundCol, uSkyCol, skyl) * uHemiI;
vec3 lin = uSunCol * dif * sh * 2.1
+ hemi * occ
+ uHorizon * bounce * 0.06 * occ;
vec3 col = alb * lin;
if (uHasFog > 0.5) {
float f = clamp((t - uFogNear) / max(uFogFar - uFogNear, 1e-4), 0.0, 1.0);
col = mix(col, mix(uHorizon, uSkyCol, 0.25), f);
}
// The SAME tone curve the mesh renderer uses (ACESFilmicToneMapping), so the
// two paths agree in the highlights as well as the midtones.
col = col / (col + vec3(0.85));
gl_FragColor = vec4(pow(col, vec3(0.4545)), 1.0);
}`;
function makeRay(st) {
const geo = new THREE.PlaneGeometry(2, 2);
const mat = new THREE.ShaderMaterial({
vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy,0.0,1.0); }",
fragmentShader: RAY_FS,
uniforms: {
uHeight:{value:null}, uAlbedo:{value:null}, uTexel:{value:new THREE.Vector2()},
uCamPos:{value:new THREE.Vector3()}, uCamR:{value:new THREE.Vector3()},
uCamU:{value:new THREE.Vector3()}, uCamF:{value:new THREE.Vector3()},
uTanHalf:{value:0.4}, uAspect:{value:1},
uSunDir:{value:new THREE.Vector3(0,1,0)}, uSunCol:{value:new THREE.Color()},
uSkyCol:{value:new THREE.Color()}, uHorizon:{value:new THREE.Color()},
uGroundCol:{value:new THREE.Color(0x2a2318)}, uHemiI:{value:0.5},
uYScale:{value:0.2}, uQuality:{value:1}, uFogNear:{value:1}, uFogFar:{value:4},
uYMin:{value:-0.2}, uYMax:{value:0.2},
uHasFog:{value:1},
},
depthTest:false, depthWrite:false,
});
st.rayScene = new THREE.Scene();
st.rayScene.add(new THREE.Mesh(geo, mat));
st.rayCam = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
st.rayMat = mat;
}
function uploadRayTextures(st, heights, n, albedo, yScale) {
const hf = new Float32Array(n * n);
for (let i = 0; i < n * n; i++) hf[i] = heights[i];
const ht = new THREE.DataTexture(hf, n, n, THREE.RedFormat, THREE.FloatType);
ht.minFilter = ht.magFilter = THREE.LinearFilter;
ht.wrapS = ht.wrapT = THREE.ClampToEdgeWrapping; ht.needsUpdate = true;
const ab = new Uint8Array(n * n * 4);
for (let i = 0; i < n * n; i++) {
ab[i*4+0] = Math.min(255, Math.max(0, albedo[i*3+0] * 255)) | 0;
ab[i*4+1] = Math.min(255, Math.max(0, albedo[i*3+1] * 255)) | 0;
ab[i*4+2] = Math.min(255, Math.max(0, albedo[i*3+2] * 255)) | 0;
ab[i*4+3] = 255;
}
const at = new THREE.DataTexture(ab, n, n, THREE.RGBAFormat, THREE.UnsignedByteType);
at.minFilter = at.magFilter = THREE.LinearFilter;
at.wrapS = at.wrapT = THREE.ClampToEdgeWrapping; at.needsUpdate = true;
if (st.rayMat.uniforms.uHeight.value) st.rayMat.uniforms.uHeight.value.dispose();
if (st.rayMat.uniforms.uAlbedo.value) st.rayMat.uniforms.uAlbedo.value.dispose();
st.rayMat.uniforms.uHeight.value = ht;
st.rayMat.uniforms.uAlbedo.value = at;
st.rayMat.uniforms.uTexel.value.set(1 / n, 1 / n);
st.rayMat.uniforms.uYScale.value = yScale;
let ymin = Infinity, ymax = -Infinity;
for (let i = 0; i < n * n; i++) { const v = hf[i];
if (v < ymin) ymin = v; if (v > ymax) ymax = v; }
st.rayMat.uniforms.uYMin.value = ymin;
st.rayMat.uniforms.uYMax.value = ymax;
}
function makeViewer(canvasId) {
const canvas = $(canvasId);
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping; // keeps snow off the clamp
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.02, 100);
// A directional sun that CASTS, plus a hemisphere fill standing in for sky
// bounce. The old setup had a fixed light and no shadows, so relief only read
// through vertex colour -- terrain judged that way looks flatter than it is.
const sun = new THREE.DirectionalLight(0xfff1dd, 2.6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
const sc = sun.shadow.camera;
sc.left = -0.9; sc.right = 0.9; sc.top = 0.9; sc.bottom = -0.9;
sc.near = 0.01; sc.far = 6;
sun.shadow.bias = -0.0012;
sun.shadow.normalBias = 0.008;
scene.add(sun);
scene.add(sun.target);
const hemi = new THREE.HemisphereLight(0x9dbbd8, 0x2a2318, 0.5);
scene.add(hemi);
const st = {renderer, scene, camera, mesh: null, sun, hemi,
yaw: -0.7, pitch: 0.62, dist: 2.4, dirty: true};
makeRay(st);
setSky(st);
V3[canvasId] = st;
let down = false, sx = 0, sy = 0, y0 = 0, p0 = 0;
canvas.addEventListener("pointerdown", e => {
down = true; sx = e.clientX; sy = e.clientY; y0 = st.yaw; p0 = st.pitch;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", e => {
if (!down) return;
st.yaw = y0 - (e.clientX - sx) * 0.006;
st.pitch = Math.max(0.05, Math.min(1.5, p0 + (e.clientY - sy) * 0.005));
st.dirty = true;
});
canvas.addEventListener("pointerup", () => down = false);
canvas.addEventListener("wheel", e => {
e.preventDefault();
st.dist = Math.max(0.6, Math.min(8, st.dist * (1 + Math.sign(e.deltaY) * 0.12)));
st.dirty = true;
}, {passive: false});
return st;
}
// One shared frame loop. Only redraws panes marked dirty, so an idle 3D tab
// costs nothing.
function frame() {
for (const [id, st] of Object.entries(V3)) {
const cv = $(id);
if (cv.hidden || !st.mesh) continue;
const w = cv.clientWidth | 0, h = cv.clientHeight | 0;
if (!w || !h) continue;
if (cv.width !== w * st.renderer.getPixelRatio() ||
cv.height !== h * st.renderer.getPixelRatio()) {
st.renderer.setSize(w, h, false);
st.camera.aspect = w / h;
st.camera.updateProjectionMatrix();
st.dirty = true;
}
if (!st.dirty) continue;
const d = st.dist, cp = Math.cos(st.pitch);
st.camera.position.set(Math.sin(st.yaw) * cp * d, Math.sin(st.pitch) * d,
Math.cos(st.yaw) * cp * d);
st.camera.lookAt(0, 0, 0);
if ($("ray").checked && st.rayMat.uniforms.uHeight.value) {
const u = st.rayMat.uniforms;
const fwd = st.camera.position.clone().multiplyScalar(-1).normalize();
const right = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0,1,0)).normalize();
const up = new THREE.Vector3().crossVectors(right, fwd).normalize();
u.uCamPos.value.copy(st.camera.position);
u.uCamF.value.copy(fwd); u.uCamR.value.copy(right); u.uCamU.value.copy(up);
u.uTanHalf.value = Math.tan(THREE.MathUtils.degToRad(st.camera.fov) * 0.5);
u.uAspect.value = st.camera.aspect;
u.uSunDir.value.copy(st.sun.position).normalize();
u.uSunCol.value.copy(st.sun.color).multiplyScalar(st.sun.intensity / 3.0);
u.uSkyCol.value.copy(st.hemi.color);
u.uGroundCol.value.copy(st.hemi.groundColor);
u.uHemiI.value = st.hemi.intensity;
u.uHorizon.value.copy(st.scene.background);
u.uQuality.value = +$("rayq").value;
u.uHasFog.value = st.scene.fog ? 1 : 0;
if (st.scene.fog) { u.uFogNear.value = st.scene.fog.near; u.uFogFar.value = st.scene.fog.far; }
st.renderer.render(st.rayScene, st.rayCam);
} else {
st.renderer.render(st.scene, st.camera);
}
st.dirty = false;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// The sun drives BOTH the light and the sky colour, so a low sun warms the
// scene the way it should instead of only moving the shadows.
function setSky(st) {
const az = THREE.MathUtils.degToRad(+$("sunaz").value);
const el = THREE.MathUtils.degToRad(+$("sunel").value);
const d = 3.0;
st.sun.position.set(Math.sin(az) * Math.cos(el) * d, Math.sin(el) * d,
Math.cos(az) * Math.cos(el) * d);
st.sun.target.position.set(0, 0, 0);
st.sun.castShadow = $("shadows").checked;
// Sun elevation drives sun colour, sky colour and horizon colour together.
//
// These are explicit colour LERPS, not hue arithmetic. The first version did
// `0.09 + 0.5 * t` on the hue, which sweeps half the colour wheel from orange
// into GREEN and produced a mint sky at mid elevations. Hue is circular and
// interpolating along it is almost never what you mean.
const t = Math.min(1, Math.max(0, Math.sin(el)));
const mix = (a, b, k) => new THREE.Color(a).lerp(new THREE.Color(b), k);
st.sun.color.copy(mix(0xff9d5c, 0xfff4e2, Math.pow(t, 0.55))); // ember -> white
st.sun.intensity = 0.9 + 2.2 * t;
const zenith = mix(0x2b3a63, 0x4f86c6, t); // indigo -> blue
const horizon = mix(0xe4a071, 0xc8dced, Math.pow(t, 0.7)); // warm -> pale
st.hemi.color.copy(zenith);
st.hemi.groundColor.set(0x2a2318);
st.hemi.intensity = 0.28 + 0.34 * t;
st.scene.background = horizon.clone().lerp(zenith, 0.35);
// Aerial perspective, crudely: distance fades toward the HORIZON colour,
// which is the half of runevision's 2025-06 argument we can do cheaply. The
// half we cannot is that the target should itself vary with distance -- one
// fog colour cannot be both the blue of the mid-distance and the pale of the
// far, which is exactly why he says the fog trick fails at great range.
const haze = +$("haze").value;
st.scene.fog = haze > 0.01
? new THREE.Fog(horizon.clone().lerp(zenith, 0.25),
1.15 - 0.55 * haze, 5.5 - 3.0 * haze)
: null;
st.dirty = true;
}
// ---------------------------------------------------------------------------
// THE MATERIAL, IN ONE PLACE.
//
// Both renderers read this. The mesh uses it for vertex colours and the
// raymarcher uploads its output as an albedo texture, so "do the two views
// agree" is not a question anyone has to check -- they cannot disagree. Porting
// it to GLSL would have made a second copy, which is the failure this session
// has spent most of its commits on.
// ---------------------------------------------------------------------------
function computeAlbedo(heights, n, mpp, lo, hi, bySlope) {
const out_ = new Float32Array(n * n * 3);
const span = Math.max(hi - lo, 1e-6);
// THREE attributes, not two.
//
// Altitude and slope are both smooth, so a material built from them alone
// comes out monotone -- one dusty tan across the whole massif, which is what
// the first version did. The attribute that breaks it up is CURVATURE:
// concave ground collects water and sediment and grows things, convex ground
// sheds both and shows rock. It is the same signal the erosion filter already
// produces as ErosionResult.ridge ("-1 in creases, +1 on ridges. Free; drives
// materials.") and which this page was discarding.
//
// Curvature is computed here from the heightfield so BOTH panels get it,
// including bare diffusion where no erosion pass has run.
const curv = new Float32Array(n * n);
let cmax = 1e-9;
for (let r = 1; r < n - 1; r++) {
for (let c = 1; c < n - 1; c++) {
const i = r * n + c;
const lap = heights[i - 1] + heights[i + 1] +
heights[i - n] + heights[i + n] - 4 * heights[i];
curv[i] = lap;
const a = Math.abs(lap);
if (a > cmax) cmax = a;
}
}
// normalise robustly: the tail is a few extreme cells, so scale by a high
// percentile rather than the max or everything lands near zero.
const sorted = Float32Array.from(curv, Math.abs).sort();
const p95 = sorted[Math.floor(sorted.length * 0.95)] || cmax;
const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v;
const sstep = (a, b, x) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); };
const lerp3 = (A, B, k) => [A[0] + (B[0]-A[0])*k, A[1] + (B[1]-A[1])*k, A[2] + (B[2]-A[2])*k];
const GRASS = [0.20, 0.30, 0.13], MEADOW = [0.33, 0.40, 0.17];
const DRY = [0.47, 0.43, 0.26], SCREE = [0.44, 0.40, 0.36];
const ROCK = [0.34, 0.31, 0.30], ROCK_L = [0.52, 0.49, 0.46];
const SNOW = [0.95, 0.96, 0.98], WET = [0.16, 0.19, 0.15];
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
const i = r * n + c;
const t = (heights[i] - lo) / span;
if (!bySlope) {
out_[i*3] = 0.20 + 0.75*t; out_[i*3+1] = 0.30 + 0.55*t; out_[i*3+2] = 0.38 + 0.22*(1-t);
continue;
}
const cm = Math.min(n - 1, c + 1), cp = Math.max(0, c - 1);
const rm = Math.min(n - 1, r + 1), rp = Math.max(0, r - 1);
const dx = (heights[r*n+cm] - heights[r*n+cp]) / (mpp * (cm - cp || 1));
const dz = (heights[rm*n+c] - heights[rp*n+c]) / (mpp * (rm - rp || 1));
const slope = Math.hypot(dx, dz); // metres per metre
const k = clamp01(curv[i] / (p95 * 2) + 0.5); // 0 crease .. 1 ridge
const rock = sstep(0.30, 0.85, slope);
// snow lies later on steep ground and later in creases where it is shaded
const snowline = 0.58 + 0.22 * rock;
const snow = sstep(snowline, snowline + 0.16, t) * (1 - 0.7 * rock);
// creases are wet: darker, greener, and they hold their vegetation higher
const wet = sstep(0.55, 0.10, k);
// vegetation band: lush low, meadow mid, dry above
let veg = t < 0.5 ? lerp3(GRASS, MEADOW, sstep(0.0, 0.5, t))
: lerp3(MEADOW, DRY, sstep(0.5, 0.85, t));
veg = lerp3(veg, WET, 0.55 * wet);
// exposed rock is lighter on ridges, darker and mossier in creases
let rk = lerp3(ROCK, ROCK_L, sstep(0.45, 1.0, k));
rk = lerp3(rk, SCREE, sstep(0.75, 0.35, k) * 0.5);
const a = lerp3(veg, rk, rock);
const b = lerp3(a, SNOW, snow);
// cheap curvature AO -- creases sit in their own shadow
const ao = 0.72 + 0.28 * sstep(0.15, 0.85, k);
out_[i*3] = b[0] * ao; out_[i*3+1] = b[1] * ao; out_[i*3+2] = b[2] * ao;
}
}
return out_;
}
function buildMesh(st, heights, n, mpp, lo, hi, exag) {
if (st.mesh) {
st.scene.remove(st.mesh);
st.mesh.geometry.dispose();
st.mesh.material.dispose();
}
// The tile is normalised to 1 unit wide, so the two panes stay comparable
// even when their metres-per-pixel differ. Height keeps its TRUE ratio to
// the ground extent -- exaggeration is an explicit, labelled multiplier and
// never a silent auto-fit, or the 3D view would flatter whichever generator
// has less relief.
const widthM = n * mpp;
const g = new THREE.PlaneGeometry(1, 1, n - 1, n - 1);
g.rotateX(-Math.PI / 2);
const pos = g.attributes.position;
const col = new Float32Array(n * n * 3);
const span = Math.max(hi - lo, 1e-6);
const col_ = computeAlbedo(heights, n, mpp, lo, hi, $("matslope").checked);
col.set(col_);
// Recentre so orbiting is about the terrain rather than about y = 0. (This
// was lost when the material was extracted, because the replace that removed
// the old colour loop was not asserted and silently took the recentre with
// it. Every string surgery in this file now asserts.)
const mid = ((lo + hi) * 0.5 / widthM) * exag;
const worldY = new Float32Array(n * n);
for (let i = 0; i < n * n; i++) {
worldY[i] = (heights[i] / widthM) * exag - mid;
pos.array[i * 3 + 1] = worldY[i];
}
// The raymarcher gets the SAME world-space heights the vertices use and the
// SAME albedo array, so the two views share geometry and colour by
// construction rather than by agreement.
uploadRayTextures(st, worldY, n, col_, 1.0);
pos.needsUpdate = true;
g.setAttribute("color", new THREE.BufferAttribute(col, 3));
g.computeVertexNormals();
const m = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.92,
metalness: 0.0, flatShading: false});
st.mesh = new THREE.Mesh(g, m);
st.mesh.castShadow = true;
st.mesh.receiveShadow = true;
st.scene.add(st.mesh);
st.dirty = true;
}
async function load3d(url, canvasId, stId, busyId) {
$(busyId).hidden = false;
const t0 = performance.now();
try {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
const buf = await r.arrayBuffer();
const h = r.headers;
const n = +h.get("X-N"), mpp = +h.get("X-Mpp");
const lo = +h.get("X-Min"), hi = +h.get("X-Max");
const heights = new Float32Array(buf);
if (heights.length !== n * n) throw new Error(`expected ${n*n} floats, got ${heights.length}`);
const st = V3[canvasId] || makeViewer(canvasId);
buildMesh(st, heights, n, mpp, lo, hi, +$("exag").value);
const ms = (performance.now() - t0).toFixed(0);
let s = `min ${h.get("X-Min")} max ${h.get("X-Max")} mean ${h.get("X-Mean")} ` +
`sd ${h.get("X-Sd")} m ${n}x${n} @ ${mpp} m/px ${ms} ms`;
if (+h.get("X-Holes")) s += ` ${h.get("X-Holes")} holes filled`;
if (h.get("X-Climate")) s += `\n${h.get("X-Climate")}`;
$(stId).textContent = s;
$(stId).classList.remove("err");
} catch (e) {
$(stId).textContent = String(e.message || e);
$(stId).classList.add("err");
} finally {
$(busyId).hidden = true;
}
}
function erosionQuery() {
const p = knobParams();
p.set("n", $("n").value); p.set("mpp", $("mpp").value);
p.set("steps", $("steps").value);
p.set("ox", $("ox").value); p.set("oz", $("oz").value);
return p;
}
function render() {
syncLabels();
const p = erosionQuery();
const q = new URLSearchParams({n: $("n").value, i1: $("i1").value,
j1: $("j1").value, seed: ACTIVE_SEED});
if ($("erode").checked) {
q.set("erode", "1");
for (const [key] of GROUPS.erode) q.set(key, $(key).value);
}
if (RAMP && $("lockramp").checked) {
for (const u of [p, q]) { u.set("vmin", RAMP[0]); u.set("vmax", RAMP[1]); }
}
// BOTH panels are the diffusion field. Left is the model alone; right is the
// same ground with the erosion filter on top. The old left panel was our fBm
// mountain layer, and that comparison is finished: #46 measured that fBm
// produces texture (flat beta) where the model produces landscape (beta
// steepening 4.18 -> 5.10). The open question is no longer which generator
// wins, it is how the filter should sit on top of the one that does.
const bare = new URLSearchParams(q); // diffusion alone
const eroded = new URLSearchParams(q); // diffusion + erosion
eroded.set("erode", "1");
for (const [key] of GROUPS.erode) eroded.set(key, $(key).value);
for (const u of [bare, eroded]) {
u.set("view", view);
u.set("exag", $("exag2").value);
}
if (mode === "3d") {
load3d("/api/diffusion.bin?" + bare, "cv_e", "st_e", "busy_e");
load3d("/api/diffusion.bin?" + eroded, "cv_d", "st_d", "busy_d");
} else {
load("e", "/api/diffusion.png?" + bare, "img_e", "st_e", "busy_e");
load("d", "/api/diffusion.png?" + eroded, "img_d", "st_d", "busy_d");
}
}
let timer = null;
function schedule() {
syncLabels();
if (!$("auto").checked) return;
clearTimeout(timer);
timer = setTimeout(render, 250);
}
$("exag2").addEventListener("input", () => {
$("v_exag2").textContent = (+$("exag2").value).toFixed(1).replace(/\.0$/, "");
schedule();
});
$("erode").addEventListener("change", render);
$("lockramp").addEventListener("change", () => {
if (!$("lockramp").checked) { RAMP = null; $("ramptxt").textContent = ""; }
render();
});
document.addEventListener("input", e => {
if (e.target.id === "seed") return; // deliberate only — see below
if (e.target.type === "range" || e.target.type === "number") schedule();
});
// Wheel over a number input changes its value in most browsers. For the seed
// that means silently regenerating the world, so refuse the gesture outright.
$("seed").addEventListener("wheel", e => { e.preventDefault(); $("seed").blur(); },
{passive: false});
$("seed").addEventListener("keydown", e => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") e.preventDefault();
if (e.key === "Enter") { e.preventDefault(); applySeed(); }
});
function applySeed() {
ACTIVE_SEED = $("seed").value;
$("activeseed").textContent = ACTIVE_SEED;
render();
}
$("applyseed").onclick = applySeed;
$("render").onclick = render;
// Two different ways to get different terrain, and they cost very differently.
$("newloc").onclick = () => {
$("i1").value = Math.floor(Math.random() * 200000);
$("j1").value = Math.floor(Math.random() * 200000);
render();
};
$("newseed").onclick = () => {
$("seed").value = Math.floor(Math.random() * 1e9);
applySeed();
};
$("reset").onclick = () => {
for (const items of Object.values(GROUPS))
for (const [key, def] of items) $(key).value = def;
render();
};
$("views").onclick = e => {
const b = e.target.closest("button"); if (!b) return;
view = b.dataset.v;
for (const x of $("views").children) x.setAttribute("aria-pressed", x === b);
render();
};
$("modes").onclick = e => {
const b = e.target.closest("button"); if (!b) return;
mode = b.dataset.m;
for (const x of $("modes").children) x.setAttribute("aria-pressed", x === b);
const three = mode === "3d";
$("views").style.display = three ? "none" : "";
$("exagrow").hidden = !three;
for (const el of document.querySelectorAll(".d3")) el.hidden = !three;
for (const [img, cv] of [["img_e","cv_e"], ["img_d","cv_d"]]) {
$(img).hidden = three;
$(cv).hidden = !three;
}
if (three && !V3.cv_e) { makeViewer("cv_e"); makeViewer("cv_d"); }
render();
};
// Exaggeration only reshapes the existing mesh; no refetch.
$("exag").addEventListener("input", () => {
$("v_exag").textContent = $("exag").value;
if (mode === "3d") render();
});
// Sun, haze and shadows only reshade -- no refetch, no rebuild.
for (const id of ["sunaz", "sunel", "haze", "shadows"]) {
$(id).addEventListener("input", () => {
$("v_sunaz").textContent = $("sunaz").value;
$("v_sunel").textContent = $("sunel").value;
$("v_haze").textContent = (+$("haze").value).toFixed(2);
for (const st of Object.values(V3)) setSky(st);
});
}
$("matslope").addEventListener("change", () => { if (mode === "3d") render(); });
$("ray").addEventListener("change", () => { for (const st of Object.values(V3)) st.dirty = true; });
$("rayq").addEventListener("input", () => {
$("v_rayq").textContent = (+$("rayq").value).toFixed(1);
for (const st of Object.values(V3)) st.dirty = true;
});
document.addEventListener("keydown", e => {
if (e.key === "r" && !/INPUT|SELECT/.test(document.activeElement.tagName)) render();
});
// Drag to pan. Erosion moves in metres, diffusion in its own pixels; both move
// by the same number of PIXELS so the two views stay in step visually.
for (const [imgId, xId, yId, unit] of
[["img_e","ox","oz",() => +$("mpp").value], ["img_d","i1","j1",() => 1]]) {
let sx = 0, sy = 0, x0 = 0, y0 = 0, down = false;
$(imgId).addEventListener("pointerdown", e => {
down = true; sx = e.clientX; sy = e.clientY;
x0 = +$(xId).value; y0 = +$(yId).value;
$(imgId).setPointerCapture(e.pointerId);
});
$(imgId).addEventListener("pointermove", e => {
if (!down) return;
const px = ($(imgId).naturalWidth || 1) / ($(imgId).clientWidth || 1);
const u = unit();
$(xId).value = Math.round(x0 - (e.clientX - sx) * px * u);
$(yId).value = Math.round(y0 - (e.clientY - sy) * px * u);
});
$(imgId).addEventListener("pointerup", () => { if (down) { down = false; render(); } });
}
// URL-driven initial state, so a view can be linked to and so headless
// verification can reach the 3D tab without a click.
// ?mode=3d&view=slope&ox=...&oz=...&i1=...&j1=...&n=...&mpp=... plus any knob
(function fromUrl() {
const q = new URLSearchParams(location.search);
for (const [k, v] of q) {
if (k === "mode" || k === "view") continue;
const el = $(k);
if (!el) continue;
// checkboxes were silently ignored here, so ?ray=1 did nothing and a mesh
// render got mistaken for a raymarched one.
if (el.type === "checkbox") el.checked = (v === "1" || v === "true");
else if (el.type === "range" || el.type === "number") el.value = v;
}
if (q.get("view")) {
const b = [...$("views").children].find(x => x.dataset.v === q.get("view"));
if (b) { view = b.dataset.v;
for (const x of $("views").children) x.setAttribute("aria-pressed", x === b); }
}
if (q.get("mode") === "3d") {
const b = [...$("modes").children].find(x => x.dataset.m === "3d");
if (b) b.click(); // click() runs the full tab handler, incl. render()
return true;
}
return false;
})() || (syncLabels(), render());
</script>