OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 706f155

lookdev 3D: sun, cast shadows, and material by slope and altitude
The cheap half of the wXcfWn look. The 3D tab had one fixed light, no shadows and a height-ramp vertex colour, so relief only read through tint -- terrain judged that way looks flatter than it is, which is a bad property in the tool you use to decide whether terrain looks right. SUN that casts. DirectionalLight with a 2048 PCF soft shadow map, azimuth and elevation on sliders. Sun elevation drives sun colour, sky, horizon and fog together, so a low sun warms the whole scene instead of only moving shadows. MATERIAL BY SLOPE AND ALTITUDE. grass -> dry -> rock -> snow, with snow suppressed on steep faces because it does not lie on cliffs. Slope is computed from the heightfield IN METRES, before exaggeration -- exaggerating the mesh must not turn grassland into cliff face. `ErosionResult.ridge` is still unused here and is the obvious next input; its own comment says "free; drives materials". HAZE toward the horizon colour. This is the half of runevision's 2025-06 argument we can do cheaply, and the comment says which half we cannot: 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 range. #45's atmosphere item stays open. A BUG WORTH RECORDING. The first sky did hue arithmetic -- `0.09 + 0.5 * t` -- 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. Replaced with explicit colour lerps between named endpoints, which is also readable: ember -> white sun, indigo -> blue zenith, warm -> pale horizon. Sun, haze and shadow toggles only reshade; they never refetch or rebuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
706f155082fccda9f03bf54f101d1ed793ab853f
by plankatron <noreply@anthropic.com> · 2026-08-31T20:53:56.000Z

Changed files (1) +125 -14

index.html +125 -14
……@@ -111,6 +111,16 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va
111111 </div>
112112 <label id="exagrow" hidden>3D vertical exaggeration<span class="v" id="v_exag">1</span>
113113 <input type="range" id="exag" min="0.2" max="6" step="0.1" value="1"></label>
114 <label class="d3" hidden>sun azimuth<span class="v" id="v_sunaz">315</span>&deg;
115 <input type="range" id="sunaz" min="0" max="360" step="5" value="315"></label>
116 <label class="d3" hidden>sun elevation<span class="v" id="v_sunel">28</span>&deg;
117 <input type="range" id="sunel" min="2" max="80" step="1" value="28"></label>
118 <label class="d3" hidden>haze<span class="v" id="v_haze">0.5</span>
119 <input type="range" id="haze" min="0" max="1" step="0.02" value="0.5"></label>
120 <label class="d3" hidden><input type="checkbox" id="shadows" checked style="width:auto">
121 cast shadows</label>
122 <label class="d3" hidden><input type="checkbox" id="matslope" checked style="width:auto">
123 material by slope + altitude</label>
114124 </fieldset>
115125
116126 <fieldset><legend>Erosion ON the diffusion field</legend>
……@@ -429,18 +439,33 @@ function makeViewer(canvasId) {
429439 const canvas = $(canvasId);
430440 const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
431441 renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
442 renderer.shadowMap.enabled = true;
443 renderer.shadowMap.type = THREE.PCFSoftShadowMap;
444 renderer.toneMapping = THREE.ACESFilmicToneMapping; // keeps snow off the clamp
445 renderer.toneMappingExposure = 1.0;
432446 const scene = new THREE.Scene();
433 scene.background = new THREE.Color(0x0d1114);
434447
435 const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
436 const sun = new THREE.DirectionalLight(0xfff2e0, 2.1);
437 // azimuth 315 (NW), altitude 45 -- matching hillshade() in server.py
438 sun.position.set(-0.707 * 0.707, 0.707, -0.707 * 0.707).normalize();
448 const camera = new THREE.PerspectiveCamera(42, 1, 0.02, 100);
449
450 // A directional sun that CASTS, plus a hemisphere fill standing in for sky
451 // bounce. The old setup had a fixed light and no shadows, so relief only read
452 // through vertex colour -- terrain judged that way looks flatter than it is.
453 const sun = new THREE.DirectionalLight(0xfff1dd, 2.6);
454 sun.castShadow = true;
455 sun.shadow.mapSize.set(2048, 2048);
456 const sc = sun.shadow.camera;
457 sc.left = -0.9; sc.right = 0.9; sc.top = 0.9; sc.bottom = -0.9;
458 sc.near = 0.01; sc.far = 6;
459 sun.shadow.bias = -0.0012;
460 sun.shadow.normalBias = 0.008;
439461 scene.add(sun);
440 scene.add(new THREE.HemisphereLight(0x93a8c4, 0x201a14, 0.55));
462 scene.add(sun.target);
463 const hemi = new THREE.HemisphereLight(0x9dbbd8, 0x2a2318, 0.5);
464 scene.add(hemi);
441465
442 const st = {renderer, scene, camera, mesh: null,
466 const st = {renderer, scene, camera, mesh: null, sun, hemi,
443467 yaw: -0.7, pitch: 0.62, dist: 2.4, dirty: true};
468 setSky(st);
444469 V3[canvasId] = st;
445470
446471 let down = false, sx = 0, sy = 0, y0 = 0, p0 = 0;
……@@ -490,6 +515,49 @@ function frame() {
490515}
491516requestAnimationFrame(frame);
492517
518// The sun drives BOTH the light and the sky colour, so a low sun warms the
519// scene the way it should instead of only moving the shadows.
520function setSky(st) {
521 const az = THREE.MathUtils.degToRad(+$("sunaz").value);
522 const el = THREE.MathUtils.degToRad(+$("sunel").value);
523 const d = 3.0;
524 st.sun.position.set(Math.sin(az) * Math.cos(el) * d, Math.sin(el) * d,
525 Math.cos(az) * Math.cos(el) * d);
526 st.sun.target.position.set(0, 0, 0);
527 st.sun.castShadow = $("shadows").checked;
528
529 // Sun elevation drives sun colour, sky colour and horizon colour together.
530 //
531 // These are explicit colour LERPS, not hue arithmetic. The first version did
532 // `0.09 + 0.5 * t` on the hue, which sweeps half the colour wheel from orange
533 // into GREEN and produced a mint sky at mid elevations. Hue is circular and
534 // interpolating along it is almost never what you mean.
535 const t = Math.min(1, Math.max(0, Math.sin(el)));
536 const mix = (a, b, k) => new THREE.Color(a).lerp(new THREE.Color(b), k);
537
538 st.sun.color.copy(mix(0xff9d5c, 0xfff4e2, Math.pow(t, 0.55))); // ember -> white
539 st.sun.intensity = 0.9 + 2.2 * t;
540
541 const zenith = mix(0x2b3a63, 0x4f86c6, t); // indigo -> blue
542 const horizon = mix(0xe4a071, 0xc8dced, Math.pow(t, 0.7)); // warm -> pale
543 st.hemi.color.copy(zenith);
544 st.hemi.groundColor.set(0x2a2318);
545 st.hemi.intensity = 0.28 + 0.34 * t;
546 st.scene.background = horizon.clone().lerp(zenith, 0.35);
547
548 // Aerial perspective, crudely: distance fades toward the HORIZON colour,
549 // which is the half of runevision's 2025-06 argument we can do cheaply. The
550 // half we cannot is that the target should itself vary with distance -- one
551 // fog colour cannot be both the blue of the mid-distance and the pale of the
552 // far, which is exactly why he says the fog trick fails at great range.
553 const haze = +$("haze").value;
554 st.scene.fog = haze > 0.01
555 ? new THREE.Fog(horizon.clone().lerp(zenith, 0.25),
556 1.15 - 0.55 * haze, 5.5 - 3.0 * haze)
557 : null;
558 st.dirty = true;
559}
560
493561function buildMesh(st, heights, n, mpp, lo, hi, exag) {
494562 if (st.mesh) {
495563 st.scene.remove(st.mesh);
……@@ -507,13 +575,43 @@ function buildMesh(st, heights, n, mpp, lo, hi, exag) {
507575 const pos = g.attributes.position;
508576 const col = new Float32Array(n * n * 3);
509577 const span = Math.max(hi - lo, 1e-6);
578 const bySlope = $("matslope").checked;
510579 for (let i = 0; i < n * n; i++) {
511 const y = heights[i];
512 pos.array[i * 3 + 1] = (y / widthM) * exag;
513 const t = (y - lo) / span;
514 col[i * 3 + 0] = 0.20 + 0.75 * t;
515 col[i * 3 + 1] = 0.30 + 0.55 * t;
516 col[i * 3 + 2] = 0.38 + 0.22 * (1 - t);
580 pos.array[i * 3 + 1] = (heights[i] / widthM) * exag;
581 }
582 // Slope from the heightfield in METRES, before exaggeration, so the material
583 // describes the terrain and not the viewing choice. Exaggerating the mesh
584 // must not turn grassland into cliff face.
585 for (let r = 0; r < n; r++) {
586 for (let c = 0; c < n; c++) {
587 const i = r * n + c;
588 const y = heights[i];
589 const t = (y - lo) / span;
590 let R, G, B;
591 if (bySlope) {
592 const cm = Math.min(n - 1, c + 1), cp = Math.max(0, c - 1);
593 const rm = Math.min(n - 1, r + 1), rp = Math.max(0, r - 1);
594 const dx = (heights[r * n + cm] - heights[r * n + cp]) / (mpp * (cm - cp || 1));
595 const dz = (heights[rm * n + c] - heights[rp * n + c]) / (mpp * (rm - rp || 1));
596 const slope = Math.min(1, Math.hypot(dx, dz) / 1.2); // ~50 deg = 1
597 const rock = Math.pow(slope, 0.75);
598 const snow = Math.max(0, Math.min(1, (t - 0.62) / 0.18)) * (1 - rock * 0.85);
599 const veg = (1 - rock) * (1 - snow);
600 // grass -> dry -> rock -> snow
601 const gr = [0.24, 0.34, 0.16], dry = [0.46, 0.42, 0.24];
602 const rk = [0.36, 0.33, 0.31], sn = [0.94, 0.95, 0.97];
603 const lowveg = Math.min(1, Math.max(0, 1 - t * 2.0));
604 const vg = [gr[0]*lowveg + dry[0]*(1-lowveg),
605 gr[1]*lowveg + dry[1]*(1-lowveg),
606 gr[2]*lowveg + dry[2]*(1-lowveg)];
607 R = vg[0]*veg + rk[0]*rock + sn[0]*snow;
608 G = vg[1]*veg + rk[1]*rock + sn[1]*snow;
609 B = vg[2]*veg + rk[2]*rock + sn[2]*snow;
610 } else {
611 R = 0.20 + 0.75 * t; G = 0.30 + 0.55 * t; B = 0.38 + 0.22 * (1 - t);
612 }
613 col[i * 3 + 0] = R; col[i * 3 + 1] = G; col[i * 3 + 2] = B;
614 }
517615 }
518616 // Recentre so orbiting is about the terrain, not about y = 0.
519617 const mid = ((lo + hi) * 0.5 / widthM) * exag;
……@@ -522,9 +620,11 @@ function buildMesh(st, heights, n, mpp, lo, hi, exag) {
522620 g.setAttribute("color", new THREE.BufferAttribute(col, 3));
523621 g.computeVertexNormals();
524622
525 const m = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.95,
623 const m = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.92,
526624 metalness: 0.0, flatShading: false});
527625 st.mesh = new THREE.Mesh(g, m);
626 st.mesh.castShadow = true;
627 st.mesh.receiveShadow = true;
528628 st.scene.add(st.mesh);
529629 st.dirty = true;
530630}
……@@ -666,6 +766,7 @@ $("modes").onclick = e => {
666766 const three = mode === "3d";
667767 $("views").style.display = three ? "none" : "";
668768 $("exagrow").hidden = !three;
769 for (const el of document.querySelectorAll(".d3")) el.hidden = !three;
669770 for (const [img, cv] of [["img_e","cv_e"], ["img_d","cv_d"]]) {
670771 $(img).hidden = three;
671772 $(cv).hidden = !three;
……@@ -679,6 +780,16 @@ $("exag").addEventListener("input", () => {
679780 $("v_exag").textContent = $("exag").value;
680781 if (mode === "3d") render();
681782});
783// Sun, haze and shadows only reshade -- no refetch, no rebuild.
784for (const id of ["sunaz", "sunel", "haze", "shadows"]) {
785 $(id).addEventListener("input", () => {
786 $("v_sunaz").textContent = $("sunaz").value;
787 $("v_sunel").textContent = $("sunel").value;
788 $("v_haze").textContent = (+$("haze").value).toFixed(2);
789 for (const st of Object.values(V3)) setSky(st);
790 });
791}
792$("matslope").addEventListener("change", () => { if (mode === "3d") render(); });
682793
683794document.addEventListener("keydown", e => {
684795 if (e.key === "r" && !/INPUT|SELECT/.test(document.activeElement.tagName)) render();