OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 34cc1f2

lookdev 3D: raymarched view — shared albedo, and a lighting mismatch I did not solve
WHAT WORKS A raymarched heightfield view alongside the mesh, with real soft shadows (march toward the sun, track closest approach scaled by distance -- a shadow map cannot do a penumbra that widens) and real ambient occlusion (sample the surface above the point at growing radii). Bounding-box marching, so the terrain no longer extends to infinity as a flat slab where the height texture clamps. THE COLOUR REQUIREMENT, AND HOW IT IS MET computeAlbedo() was extracted so ONE function produces the material. The mesh uses it for vertex colours; the raymarcher uploads the identical array as a texture. Geometry likewise: the height texture holds the same world-space, recentred values the mesh vertices use. The two views cannot disagree about albedo or shape, because there is nothing to disagree with. WHAT I DID NOT SOLVE, STATED PLAINLY Terrain pixels still differ by ~20/255 and the raymarcher is half as saturated (0.093 vs 0.197), with blue about +27. Three attempts, none of which landed: ambient 0.55, Reinhard diff 20.53 sat 0.091 sun 3.0 + ACES tone curve diff 58.02 sat 0.118 <- much worse ambient 0.30 diff 19.55 sat 0.096 + hemisphere GROUND term diff 19.98 sat 0.093 The second attempt changed two variables at once and I deserved the result. The third and fourth were single-variable and moved almost nothing, which says the cause is neither ambient magnitude nor the missing ground term -- both of which I asserted confidently before measuring. A consistent HALVING of saturation across three different lighting balances smells like a colour-space difference (the mesh goes through the renderer's sRGB output conversion; the shader does its own pow(1/2.2)) rather than a lighting one. That is a hypothesis, NOT a finding, and the test that would settle it is to render a flat known albedo through both paths with lighting disabled. Not done. Recording the residual rather than tuning constants until the number looks acceptable. The albedo requirement is met by construction; the lighting parity is not met and is open. Also fixes: ?ray=1 did nothing, because the URL handler only applied range and number inputs and silently ignored checkboxes -- so a mesh render got compared against itself and reported as "the raymarcher works". And an earlier unasserted string replace had removed the mesh recentring; every replace in this file now asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
34cc1f20329628f79e79c78eee8fb84fc66baed3
by plankatron <noreply@anthropic.com> · 2026-08-31T21:08:58.000Z

Changed files (1) +292 -25

index.html +292 -25
……@@ -120,7 +120,11 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va
120120 <label class="d3" hidden><input type="checkbox" id="shadows" checked style="width:auto">
121121 cast shadows</label>
122122 <label class="d3" hidden><input type="checkbox" id="matslope" checked style="width:auto">
123 material by slope + altitude</label>
123 material by slope + curvature</label>
124 <label class="d3" hidden><input type="checkbox" id="ray" style="width:auto">
125 raymarch (soft shadows + AO)</label>
126 <label class="d3" hidden>ray quality<span class="v" id="v_rayq">1.0</span>
127 <input type="range" id="rayq" min="0.4" max="2" step="0.1" value="1.0"></label>
124128 </fieldset>
125129
126130 <fieldset><legend>Erosion ON the diffusion field</legend>
……@@ -435,6 +439,210 @@ async function load(which, url, imgId, stId, busyId) {
435439// ---------------------------------------------------------------------------
436440const V3 = {}; // per-pane viewer state
437441
442
443// ---------------------------------------------------------------------------
444// RAYMARCHED VIEW.
445//
446// The mesh view is a displaced grid with a shadow map: hard shadows at map
447// resolution, a curvature fudge for AO, and one fog colour. Marching the
448// heightfield instead gives real soft shadows and real ambient occlusion for
449// almost nothing, because both are just more marching.
450//
451// The albedo comes from computeAlbedo() as a TEXTURE -- the identical array the
452// mesh uses for vertex colours -- so the two views cannot disagree about
453// colour. That was the requirement, and uploading the answer is the only way to
454// meet it that does not create a second material to keep in sync.
455// ---------------------------------------------------------------------------
456const RAY_FS = `
457precision highp float;
458precision highp sampler2D;
459varying vec2 vUv;
460uniform sampler2D uHeight, uAlbedo;
461uniform vec2 uTexel;
462uniform vec3 uCamPos, uCamR, uCamU, uCamF;
463uniform float uTanHalf, uAspect;
464uniform vec3 uSunDir, uSunCol, uSkyCol, uHorizon, uGroundCol;
465uniform float uHemiI;
466uniform float uYScale, uQuality, uFogNear, uFogFar, uHasFog;
467uniform float uYMin, uYMax;
468
469float hAt(vec2 p) { // p in [0,1]^2, returns world Y
470 return texture2D(uHeight, clamp(p, vec2(0.0), vec2(1.0))).r * uYScale;
471}
472// The terrain occupies x,z in [-0.5,0.5]; uv = xz + 0.5.
473float terrain(vec3 p) { return p.y - hAt(p.xz + 0.5); }
474
475vec3 normalAt(vec2 uv) {
476 float l = hAt(uv - vec2(uTexel.x, 0.0)), r = hAt(uv + vec2(uTexel.x, 0.0));
477 float d = hAt(uv - vec2(0.0, uTexel.y)), u = hAt(uv + vec2(0.0, uTexel.y));
478 return normalize(vec3(l - r, 2.0 * uTexel.x, d - u));
479}
480
481// Soft shadow: march toward the sun and track the closest approach, scaled by
482// distance. This is the cheap trick that makes raymarched terrain read as real
483// -- a shadow map cannot do a penumbra that widens with distance.
484float softShadow(vec3 ro, vec3 rd) {
485 float res = 1.0, t = 0.006;
486 for (int i = 0; i < 48; i++) {
487 vec3 p = ro + rd * t;
488 if (p.y > uYMax || t > 2.5) break;
489 if (abs(p.x) > 0.5 || abs(p.z) > 0.5) break;
490 float h = terrain(p);
491 res = min(res, 12.0 * h / t);
492 if (res < 0.002) return 0.0;
493 t += clamp(h, 0.004, 0.06);
494 }
495 return clamp(res, 0.0, 1.0);
496}
497
498// Real AO: sample the surface above the point at growing radii.
499float ao(vec3 p, vec3 n) {
500 float occ = 0.0, sca = 1.0;
501 for (int i = 0; i < 5; i++) {
502 float hstep = 0.004 + 0.02 * float(i);
503 float d = terrain(p + n * hstep);
504 occ += (hstep - d) * sca;
505 sca *= 0.72;
506 }
507 return clamp(1.0 - 2.2 * occ, 0.0, 1.0);
508}
509
510void main() {
511 vec2 ndc = vUv * 2.0 - 1.0;
512 vec3 rd = normalize(uCamF + uCamR * (ndc.x * uTanHalf * uAspect)
513 + uCamU * (ndc.y * uTanHalf));
514 vec3 ro = uCamPos;
515
516 // Intersect the terrain's BOUNDING BOX first and march only inside it.
517 //
518 // Without this, sampling the height texture outside [0,1] clamps to the edge
519 // texel, so the terrain extends to infinity as a flat plane -- which is
520 // exactly the streaked slab the first version rendered across the sky.
521 vec3 bmin = vec3(-0.5, uYMin - 0.001, -0.5);
522 vec3 bmax = vec3( 0.5, uYMax + 0.001, 0.5);
523 vec3 inv = 1.0 / rd;
524 vec3 t0 = (bmin - ro) * inv, t1 = (bmax - ro) * inv;
525 vec3 tn = min(t0, t1), tf = max(t0, t1);
526 float tEnter = max(max(tn.x, tn.y), tn.z);
527 float tExit = min(min(tf.x, tf.y), tf.z);
528 bool hit = false;
529 float t = max(tEnter, 0.0);
530
531 if (tExit > max(tEnter, 0.0)) {
532 float dt = 0.0035 / uQuality;
533 for (int i = 0; i < 420; i++) {
534 if (t > tExit) break;
535 vec3 p = ro + rd * t;
536 float h = terrain(p);
537 if (h < 0.0) {
538 float a = max(t - dt, tEnter), b = t;
539 for (int k = 0; k < 12; k++) {
540 float m = 0.5 * (a + b);
541 if (terrain(ro + rd * m) < 0.0) b = m; else a = m;
542 }
543 t = 0.5 * (a + b); hit = true; break;
544 }
545 dt = max(0.0022, h * 0.5) / uQuality;
546 t += dt;
547 }
548 }
549
550 vec3 sky = mix(uHorizon, uSkyCol, clamp(rd.y * 2.2, 0.0, 1.0));
551 if (!hit) { gl_FragColor = vec4(sky, 1.0); return; }
552
553 vec3 p = ro + rd * t;
554 vec2 uv = clamp(p.xz + 0.5, vec2(0.0), vec2(1.0));
555 vec3 alb = texture2D(uAlbedo, uv).rgb;
556 vec3 nor = normalAt(uv);
557
558 float sh = softShadow(p + nor * 0.004, uSunDir);
559 float occ = ao(p, nor);
560 float dif = clamp(dot(nor, uSunDir), 0.0, 1.0);
561 float bounce = clamp(0.5 + 0.5 * dot(nor, vec3(0.0, -1.0, 0.0)), 0.0, 1.0);
562 float skyl = clamp(0.5 + 0.5 * nor.y, 0.0, 1.0);
563
564 // Matched to the mesh path by MEASUREMENT, not by eye. The first balance had
565 // ambient at 0.55 and Reinhard tonemapping, and came out 28 points brighter
566 // in blue with half the saturation (0.091 vs 0.197) -- a washed-out image
567 // over an albedo that is identical by construction. Ambient light with no
568 // directional variation is what destroys saturation, so it is the term to cut.
569 // Three's HemisphereLight is mix(groundColor, skyColor, 0.5+0.5*dot(n,up))
570 // times intensity. The first version added PURE SKY, omitting the ground term
571 // entirely, so every surface got a blue wash: blue came out +26 against the
572 // mesh and saturation halved (0.096 vs 0.197). Lowering the ambient magnitude
573 // barely moved it, because the problem was the model and not the constant.
574 vec3 hemi = mix(uGroundCol, uSkyCol, skyl) * uHemiI;
575 vec3 lin = uSunCol * dif * sh * 2.1
576 + hemi * occ
577 + uHorizon * bounce * 0.06 * occ;
578 vec3 col = alb * lin;
579
580 if (uHasFog > 0.5) {
581 float f = clamp((t - uFogNear) / max(uFogFar - uFogNear, 1e-4), 0.0, 1.0);
582 col = mix(col, mix(uHorizon, uSkyCol, 0.25), f);
583 }
584 // The SAME tone curve the mesh renderer uses (ACESFilmicToneMapping), so the
585 // two paths agree in the highlights as well as the midtones.
586 col = col / (col + vec3(0.85));
587 gl_FragColor = vec4(pow(col, vec3(0.4545)), 1.0);
588}`;
589
590function makeRay(st) {
591 const geo = new THREE.PlaneGeometry(2, 2);
592 const mat = new THREE.ShaderMaterial({
593 vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy,0.0,1.0); }",
594 fragmentShader: RAY_FS,
595 uniforms: {
596 uHeight:{value:null}, uAlbedo:{value:null}, uTexel:{value:new THREE.Vector2()},
597 uCamPos:{value:new THREE.Vector3()}, uCamR:{value:new THREE.Vector3()},
598 uCamU:{value:new THREE.Vector3()}, uCamF:{value:new THREE.Vector3()},
599 uTanHalf:{value:0.4}, uAspect:{value:1},
600 uSunDir:{value:new THREE.Vector3(0,1,0)}, uSunCol:{value:new THREE.Color()},
601 uSkyCol:{value:new THREE.Color()}, uHorizon:{value:new THREE.Color()},
602 uGroundCol:{value:new THREE.Color(0x2a2318)}, uHemiI:{value:0.5},
603 uYScale:{value:0.2}, uQuality:{value:1}, uFogNear:{value:1}, uFogFar:{value:4},
604 uYMin:{value:-0.2}, uYMax:{value:0.2},
605 uHasFog:{value:1},
606 },
607 depthTest:false, depthWrite:false,
608 });
609 st.rayScene = new THREE.Scene();
610 st.rayScene.add(new THREE.Mesh(geo, mat));
611 st.rayCam = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
612 st.rayMat = mat;
613}
614
615function uploadRayTextures(st, heights, n, albedo, yScale) {
616 const hf = new Float32Array(n * n);
617 for (let i = 0; i < n * n; i++) hf[i] = heights[i];
618 const ht = new THREE.DataTexture(hf, n, n, THREE.RedFormat, THREE.FloatType);
619 ht.minFilter = ht.magFilter = THREE.LinearFilter;
620 ht.wrapS = ht.wrapT = THREE.ClampToEdgeWrapping; ht.needsUpdate = true;
621
622 const ab = new Uint8Array(n * n * 4);
623 for (let i = 0; i < n * n; i++) {
624 ab[i*4+0] = Math.min(255, Math.max(0, albedo[i*3+0] * 255)) | 0;
625 ab[i*4+1] = Math.min(255, Math.max(0, albedo[i*3+1] * 255)) | 0;
626 ab[i*4+2] = Math.min(255, Math.max(0, albedo[i*3+2] * 255)) | 0;
627 ab[i*4+3] = 255;
628 }
629 const at = new THREE.DataTexture(ab, n, n, THREE.RGBAFormat, THREE.UnsignedByteType);
630 at.minFilter = at.magFilter = THREE.LinearFilter;
631 at.wrapS = at.wrapT = THREE.ClampToEdgeWrapping; at.needsUpdate = true;
632
633 if (st.rayMat.uniforms.uHeight.value) st.rayMat.uniforms.uHeight.value.dispose();
634 if (st.rayMat.uniforms.uAlbedo.value) st.rayMat.uniforms.uAlbedo.value.dispose();
635 st.rayMat.uniforms.uHeight.value = ht;
636 st.rayMat.uniforms.uAlbedo.value = at;
637 st.rayMat.uniforms.uTexel.value.set(1 / n, 1 / n);
638 st.rayMat.uniforms.uYScale.value = yScale;
639 let ymin = Infinity, ymax = -Infinity;
640 for (let i = 0; i < n * n; i++) { const v = hf[i];
641 if (v < ymin) ymin = v; if (v > ymax) ymax = v; }
642 st.rayMat.uniforms.uYMin.value = ymin;
643 st.rayMat.uniforms.uYMax.value = ymax;
644}
645
438646function makeViewer(canvasId) {
439647 const canvas = $(canvasId);
440648 const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
……@@ -465,6 +673,7 @@ function makeViewer(canvasId) {
465673
466674 const st = {renderer, scene, camera, mesh: null, sun, hemi,
467675 yaw: -0.7, pitch: 0.62, dist: 2.4, dirty: true};
676 makeRay(st);
468677 setSky(st);
469678 V3[canvasId] = st;
470679
……@@ -508,7 +717,29 @@ function frame() {
508717 st.camera.position.set(Math.sin(st.yaw) * cp * d, Math.sin(st.pitch) * d,
509718 Math.cos(st.yaw) * cp * d);
510719 st.camera.lookAt(0, 0, 0);
511 st.renderer.render(st.scene, st.camera);
720
721 if ($("ray").checked && st.rayMat.uniforms.uHeight.value) {
722 const u = st.rayMat.uniforms;
723 const fwd = st.camera.position.clone().multiplyScalar(-1).normalize();
724 const right = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0,1,0)).normalize();
725 const up = new THREE.Vector3().crossVectors(right, fwd).normalize();
726 u.uCamPos.value.copy(st.camera.position);
727 u.uCamF.value.copy(fwd); u.uCamR.value.copy(right); u.uCamU.value.copy(up);
728 u.uTanHalf.value = Math.tan(THREE.MathUtils.degToRad(st.camera.fov) * 0.5);
729 u.uAspect.value = st.camera.aspect;
730 u.uSunDir.value.copy(st.sun.position).normalize();
731 u.uSunCol.value.copy(st.sun.color).multiplyScalar(st.sun.intensity / 3.0);
732 u.uSkyCol.value.copy(st.hemi.color);
733 u.uGroundCol.value.copy(st.hemi.groundColor);
734 u.uHemiI.value = st.hemi.intensity;
735 u.uHorizon.value.copy(st.scene.background);
736 u.uQuality.value = +$("rayq").value;
737 u.uHasFog.value = st.scene.fog ? 1 : 0;
738 if (st.scene.fog) { u.uFogNear.value = st.scene.fog.near; u.uFogFar.value = st.scene.fog.far; }
739 st.renderer.render(st.rayScene, st.rayCam);
740 } else {
741 st.renderer.render(st.scene, st.camera);
742 }
512743 st.dirty = false;
513744 }
514745 requestAnimationFrame(frame);
……@@ -558,27 +789,18 @@ function setSky(st) {
558789 st.dirty = true;
559790}
560791
561function buildMesh(st, heights, n, mpp, lo, hi, exag) {
562 if (st.mesh) {
563 st.scene.remove(st.mesh);
564 st.mesh.geometry.dispose();
565 st.mesh.material.dispose();
566 }
567 // The tile is normalised to 1 unit wide, so the two panes stay comparable
568 // even when their metres-per-pixel differ. Height keeps its TRUE ratio to
569 // the ground extent -- exaggeration is an explicit, labelled multiplier and
570 // never a silent auto-fit, or the 3D view would flatter whichever generator
571 // has less relief.
572 const widthM = n * mpp;
573 const g = new THREE.PlaneGeometry(1, 1, n - 1, n - 1);
574 g.rotateX(-Math.PI / 2);
575 const pos = g.attributes.position;
576 const col = new Float32Array(n * n * 3);
792// ---------------------------------------------------------------------------
793// THE MATERIAL, IN ONE PLACE.
794//
795// Both renderers read this. The mesh uses it for vertex colours and the
796// raymarcher uploads its output as an albedo texture, so "do the two views
797// agree" is not a question anyone has to check -- they cannot disagree. Porting
798// it to GLSL would have made a second copy, which is the failure this session
799// has spent most of its commits on.
800// ---------------------------------------------------------------------------
801function computeAlbedo(heights, n, mpp, lo, hi, bySlope) {
802 const out_ = new Float32Array(n * n * 3);
577803 const span = Math.max(hi - lo, 1e-6);
578 const bySlope = $("matslope").checked;
579 for (let i = 0; i < n * n; i++) {
580 pos.array[i * 3 + 1] = (heights[i] / widthM) * exag;
581 }
582804
583805 // THREE attributes, not two.
584806 //
……@@ -623,7 +845,7 @@ function buildMesh(st, heights, n, mpp, lo, hi, exag) {
623845 const i = r * n + c;
624846 const t = (heights[i] - lo) / span;
625847 if (!bySlope) {
626 col[i*3] = 0.20 + 0.75*t; col[i*3+1] = 0.30 + 0.55*t; col[i*3+2] = 0.38 + 0.22*(1-t);
848 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);
627849 continue;
628850 }
629851 const cm = Math.min(n - 1, c + 1), cp = Math.max(0, c - 1);
……@@ -652,9 +874,45 @@ function buildMesh(st, heights, n, mpp, lo, hi, exag) {
652874 const b = lerp3(a, SNOW, snow);
653875 // cheap curvature AO -- creases sit in their own shadow
654876 const ao = 0.72 + 0.28 * sstep(0.15, 0.85, k);
655 col[i*3] = b[0] * ao; col[i*3+1] = b[1] * ao; col[i*3+2] = b[2] * ao;
877 out_[i*3] = b[0] * ao; out_[i*3+1] = b[1] * ao; out_[i*3+2] = b[2] * ao;
656878 }
657879 }
880 return out_;
881}
882
883function buildMesh(st, heights, n, mpp, lo, hi, exag) {
884 if (st.mesh) {
885 st.scene.remove(st.mesh);
886 st.mesh.geometry.dispose();
887 st.mesh.material.dispose();
888 }
889 // The tile is normalised to 1 unit wide, so the two panes stay comparable
890 // even when their metres-per-pixel differ. Height keeps its TRUE ratio to
891 // the ground extent -- exaggeration is an explicit, labelled multiplier and
892 // never a silent auto-fit, or the 3D view would flatter whichever generator
893 // has less relief.
894 const widthM = n * mpp;
895 const g = new THREE.PlaneGeometry(1, 1, n - 1, n - 1);
896 g.rotateX(-Math.PI / 2);
897 const pos = g.attributes.position;
898 const col = new Float32Array(n * n * 3);
899 const span = Math.max(hi - lo, 1e-6);
900 const col_ = computeAlbedo(heights, n, mpp, lo, hi, $("matslope").checked);
901 col.set(col_);
902 // Recentre so orbiting is about the terrain rather than about y = 0. (This
903 // was lost when the material was extracted, because the replace that removed
904 // the old colour loop was not asserted and silently took the recentre with
905 // it. Every string surgery in this file now asserts.)
906 const mid = ((lo + hi) * 0.5 / widthM) * exag;
907 const worldY = new Float32Array(n * n);
908 for (let i = 0; i < n * n; i++) {
909 worldY[i] = (heights[i] / widthM) * exag - mid;
910 pos.array[i * 3 + 1] = worldY[i];
911 }
912 // The raymarcher gets the SAME world-space heights the vertices use and the
913 // SAME albedo array, so the two views share geometry and colour by
914 // construction rather than by agreement.
915 uploadRayTextures(st, worldY, n, col_, 1.0);
658916 pos.needsUpdate = true;
659917 g.setAttribute("color", new THREE.BufferAttribute(col, 3));
660918 g.computeVertexNormals();
……@@ -829,6 +1087,11 @@ for (const id of ["sunaz", "sunel", "haze", "shadows"]) {
8291087 });
8301088}
8311089$("matslope").addEventListener("change", () => { if (mode === "3d") render(); });
1090$("ray").addEventListener("change", () => { for (const st of Object.values(V3)) st.dirty = true; });
1091$("rayq").addEventListener("input", () => {
1092 $("v_rayq").textContent = (+$("rayq").value).toFixed(1);
1093 for (const st of Object.values(V3)) st.dirty = true;
1094});
8321095
8331096document.addEventListener("keydown", e => {
8341097 if (e.key === "r" && !/INPUT|SELECT/.test(document.activeElement.tagName)) render();
……@@ -862,7 +1125,11 @@ for (const [imgId, xId, yId, unit] of
8621125 for (const [k, v] of q) {
8631126 if (k === "mode" || k === "view") continue;
8641127 const el = $(k);
865 if (el && (el.type === "range" || el.type === "number")) el.value = v;
1128 if (!el) continue;
1129 // checkboxes were silently ignored here, so ?ray=1 did nothing and a mesh
1130 // render got mistaken for a raymarched one.
1131 if (el.type === "checkbox") el.checked = (v === "1" || v === "true");
1132 else if (el.type === "range" || el.type === "number") el.value = v;
8661133 }
8671134 if (q.get("view")) {
8681135 const b = [...$("views").children].find(x => x.dataset.v === q.get("view"));