| … | … | @@ -111,6 +111,16 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va |
| 111 | 111 | </div> |
| 112 | 112 | <label id="exagrow" hidden>3D vertical exaggeration<span class="v" id="v_exag">1</span> |
| 113 | 113 | <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>° |
| 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>° |
| 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> |
| 114 | 124 | </fieldset> |
| 115 | 125 | |
| 116 | 126 | <fieldset><legend>Erosion ON the diffusion field</legend> |
| … | … | @@ -429,18 +439,33 @@ function makeViewer(canvasId) { |
| 429 | 439 | const canvas = $(canvasId); |
| 430 | 440 | const renderer = new THREE.WebGLRenderer({canvas, antialias: true}); |
| 431 | 441 | 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; |
| 432 | 446 | const scene = new THREE.Scene(); |
| 433 | | scene.background = new THREE.Color(0x0d1114); |
| 434 | 447 | |
| 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; |
| 439 | 461 | 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); |
| 441 | 465 | |
| 442 | | const st = {renderer, scene, camera, mesh: null, |
| 466 | const st = {renderer, scene, camera, mesh: null, sun, hemi, |
| 443 | 467 | yaw: -0.7, pitch: 0.62, dist: 2.4, dirty: true}; |
| 468 | setSky(st); |
| 444 | 469 | V3[canvasId] = st; |
| 445 | 470 | |
| 446 | 471 | let down = false, sx = 0, sy = 0, y0 = 0, p0 = 0; |
| … | … | @@ -490,6 +515,49 @@ function frame() { |
| 490 | 515 | } |
| 491 | 516 | requestAnimationFrame(frame); |
| 492 | 517 | |
| 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. |
| 520 | function 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 | |
| 493 | 561 | function buildMesh(st, heights, n, mpp, lo, hi, exag) { |
| 494 | 562 | if (st.mesh) { |
| 495 | 563 | st.scene.remove(st.mesh); |
| … | … | @@ -507,13 +575,43 @@ function buildMesh(st, heights, n, mpp, lo, hi, exag) { |
| 507 | 575 | const pos = g.attributes.position; |
| 508 | 576 | const col = new Float32Array(n * n * 3); |
| 509 | 577 | const span = Math.max(hi - lo, 1e-6); |
| 578 | const bySlope = $("matslope").checked; |
| 510 | 579 | 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 | } |
| 517 | 615 | } |
| 518 | 616 | // Recentre so orbiting is about the terrain, not about y = 0. |
| 519 | 617 | const mid = ((lo + hi) * 0.5 / widthM) * exag; |
| … | … | @@ -522,9 +620,11 @@ function buildMesh(st, heights, n, mpp, lo, hi, exag) { |
| 522 | 620 | g.setAttribute("color", new THREE.BufferAttribute(col, 3)); |
| 523 | 621 | g.computeVertexNormals(); |
| 524 | 622 | |
| 525 | | const m = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.95, |
| 623 | const m = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.92, |
| 526 | 624 | metalness: 0.0, flatShading: false}); |
| 527 | 625 | st.mesh = new THREE.Mesh(g, m); |
| 626 | st.mesh.castShadow = true; |
| 627 | st.mesh.receiveShadow = true; |
| 528 | 628 | st.scene.add(st.mesh); |
| 529 | 629 | st.dirty = true; |
| 530 | 630 | } |
| … | … | @@ -666,6 +766,7 @@ $("modes").onclick = e => { |
| 666 | 766 | const three = mode === "3d"; |
| 667 | 767 | $("views").style.display = three ? "none" : ""; |
| 668 | 768 | $("exagrow").hidden = !three; |
| 769 | for (const el of document.querySelectorAll(".d3")) el.hidden = !three; |
| 669 | 770 | for (const [img, cv] of [["img_e","cv_e"], ["img_d","cv_d"]]) { |
| 670 | 771 | $(img).hidden = three; |
| 671 | 772 | $(cv).hidden = !three; |
| … | … | @@ -679,6 +780,16 @@ $("exag").addEventListener("input", () => { |
| 679 | 780 | $("v_exag").textContent = $("exag").value; |
| 680 | 781 | if (mode === "3d") render(); |
| 681 | 782 | }); |
| 783 | // Sun, haze and shadows only reshade -- no refetch, no rebuild. |
| 784 | for (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(); }); |
| 682 | 793 | |
| 683 | 794 | document.addEventListener("keydown", e => { |
| 684 | 795 | if (e.key === "r" && !/INPUT|SELECT/.test(document.activeElement.tagName)) render(); |