OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / index.html

1147 lines · html ref:
<!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>&deg;
      <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>&deg;
      <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&nbsp;km → 60&nbsp;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&nbsp;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>