Depth Perception

A spatial-perception experiment for BullzEye Golf.

Product Design / Spatial Computing / 3D / Prototyping

Role: Product Designer

Type: Spatial / 3D / AR exploration

Tools: Three.js, AI-assisted prototyping

Output: Interactive prototype

BullzEye Golf is building an iPhone-based AR system that reads the topology of a real golf green & communicates the optimal putting line.

The

Context

The

Challenge

The challenge I was given was deliberately narrow:

How do you make a surface that looks flat from above reveal the shape it actually has?

Rather than immediately designing an AR interface, I treated the brief as a problem of human perception.

A phone display is fundamentally a flat surface. Yet we routinely perceive depth from images through cues such as perspective, lighting, shadow, contrast and motion.

I started looking at how painters solved the same fundamental problem long before digital displays existed

But

How do we perceive depth on a 2D surface?

If a painter can make a flat canvas feel spatial, could similar perceptual cues make subtle terrain readable through a phone camera?

Vertical exaggeration

Can subtle elevation be amplified?

Shadows

Can physical depth become visually obvious?

Contours

Can elevation be communicated geometrically?

Lighting

Can surface orientation be revealed through light?

Motion / parallax

Can camera movement reveal the actual 3D structure?

V1:

Four LLMs,

one problem

I deliberately gave the same design brief to four different LLMs and let them independently interpret the problem.

I wasn't looking for a winner.

I wanted to see which implementation choices

produced useful perceptual cues.

ChatGPT

copied

copy

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <title>Golf Green — Spatial Perception Prototype</title> <script type="importmap"> { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.0/examples/jsm/" } } </script> <style> :root { --panel: rgba(248, 247, 242, 0.94); --border: rgba(20, 25, 20, 0.12); --text: #182019; --muted: #6d756d; --green: #526b4d; --green-dark: #344a32; --line: #263c27; } * { box-sizing: border-box; } html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #e9e8e2; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); } #app { position: fixed; inset: 0; } canvas { display: block; width: 100%; height: 100%; touch-action: none; } /* ----------------------------------------------------------- Minimal controls ----------------------------------------------------------- */ #panel { position: fixed; left: 18px; top: 18px; z-index: 10; width: 230px; padding: 15px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } .title { margin-bottom: 3px; font-size: 13px; font-weight: 650; letter-spacing: -0.01em; } .subtitle { margin-bottom: 14px; font-size: 11px; line-height: 1.4; color: var(--muted); } .group { margin-top: 13px; } .label { margin-bottom: 6px; font-size: 10px; font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } .segmented { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; background: rgba(255, 255, 255, 0.5); } button { border: 0; background: transparent; color: var(--text); font: inherit; font-size: 11px; cursor: pointer; } .segmented button { height: 28px; border-right: 1px solid var(--border); } .segmented button:last-child { border-right: 0; } .segmented button.active { background: var(--green); color: white; } .toggle-list { display: flex; flex-direction: column; gap: 7px; } .toggle { display: flex; align-items: center; justify-content: space-between; min-height: 24px; font-size: 11px; } .toggle input { appearance: none; width: 30px; height: 17px; margin: 0; border-radius: 20px; background: #cfd2cc; cursor: pointer; position: relative; transition: background 0.15s ease; } .toggle input::after { content: ""; position: absolute; width: 13px; height: 13px; left: 2px; top: 2px; border-radius: 50%; background: white; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); transition: transform 0.15s ease; } .toggle input:checked { background: var(--green); } .toggle input:checked::after { transform: translateX(13px); } #reset { width: 100%; height: 30px; margin-top: 14px; border: 1px solid var(--border); border-radius: 7px; background: rgba(255, 255, 255, 0.55); font-size: 11px; } #reset:hover { background: white; } #hint { position: fixed; left: 50%; bottom: 17px; transform: translateX(-50%); z-index: 5; padding: 7px 11px; border-radius: 7px; background: rgba(248, 247, 242, 0.85); color: var(--muted); font-size: 10px; white-space: nowrap; pointer-events: none; } @media (max-width: 600px) { #panel { left: 10px; top: 10px; width: 205px; } #hint { bottom: 10px; font-size: 9px; } } </style> </head> <body> <div id="app"></div> <div id="panel"> <div class="title">Spatial green study</div> <div class="subtitle"> Can a nearly flat surface become perceptually legible? </div> <div class="group"> <div class="label">Terrain exaggeration</div> <div class="segmented"> <button data-exaggeration="1" class="active">1×</button> <button data-exaggeration="3">3×</button> <button data-exaggeration="5">5×</button> </div> </div> <div class="group"> <div class="label">Perceptual cues</div> <div class="toggle-list"> <label class="toggle"> <span>Contours</span> <input id="contoursToggle" type="checkbox" checked /> </label> <label class="toggle"> <span>Shadows</span> <input id="shadowsToggle" type="checkbox" checked /> </label> <label class="toggle"> <span>Lighting</span> <input id="lightingToggle" type="checkbox" checked /> </label> <label class="toggle"> <span>Putt path</span> <input id="pathToggle" type="checkbox" checked /> </label> </div> </div> <button id="reset">Reset camera</button> </div> <div id="hint"> Drag to orbit · Scroll / pinch to zoom </div> <script type="module"> import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; /* ================================================================ CONFIGURATION ================================================================ */ const TERRAIN = { width: 22, depth: 15, // High enough for smooth terrain and contours. resolutionX: 180, resolutionZ: 130, // Base vertical scale. baseHeight: 0.55, // Contour interval in terrain-height units. contourInterval: 0.12 }; /* ================================================================ SCENE ================================================================ */ const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe9e8e2); const camera = new THREE.PerspectiveCamera( 42, window.innerWidth / window.innerHeight, 0.1, 100 ); // Start intentionally close to top-down. camera.position.set( 0, 19, 1.2 ); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setPixelRatio( Math.min(window.devicePixelRatio, 2) ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; document .getElementById("app") .appendChild(renderer.domElement); /* ================================================================ CAMERA CONTROLS ================================================================ */ const controls = new OrbitControls( camera, renderer.domElement ); controls.enableDamping = true; controls.dampingFactor = 0.06; controls.enablePan = false; controls.minDistance = 8; controls.maxDistance = 32; controls.minPolarAngle = 0.15; controls.maxPolarAngle = Math.PI / 2.05; controls.target.set(0, 0, 0); controls.saveState(); /* ================================================================ LIGHTING ================================================================ */ const ambientLight = new THREE.HemisphereLight( 0xf5f5ee, 0x70766d, 1.15 ); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.4 ); directionalLight.position.set( -7, 12, 5 ); directionalLight.castShadow = true; // Large shadow camera so the terrain shadow is soft and useful. directionalLight.shadow.mapSize.set( 2048, 2048 ); directionalLight.shadow.camera.left = -14; directionalLight.shadow.camera.right = 14; directionalLight.shadow.camera.top = 14; directionalLight.shadow.camera.bottom = -14; directionalLight.shadow.camera.near = 1; directionalLight.shadow.camera.far = 40; directionalLight.shadow.bias = -0.0005; scene.add(directionalLight); /* ================================================================ TERRAIN MODEL ================================================================ */ /* The key idea: height(x,z) is the source of truth. Everything else — mesh, contours, ball, hole and putt path — derives from this same function. */ let exaggeration = 1; /* Smooth Gaussian hill / depression helper. */ function gaussian( x, z, cx, cz, radiusX, radiusZ ) { const dx = (x - cx) / radiusX; const dz = (z - cz) / radiusZ; return Math.exp( -(dx * dx + dz * dz) ); } /* Base terrain. Components: 1. Broad raised area 2. Depression 3. Diagonal slope 4. Extremely subtle secondary undulation The values are intentionally small. */ function baseHeightAt(x, z) { // Broad raised area. const hill = 0.34 * gaussian( x, z, -4.0, -1.7, 4.2, 3.2 ); // Depression. const depression = -0.30 * gaussian( x, z, 3.2, 2.1, 3.1, 2.4 ); // Diagonal slope. // // Positive toward upper-right. const diagonalSlope = 0.032 * (x + 0.72 * z); // Very low-amplitude undulation. const gentleWave = 0.035 * Math.sin( x * 0.62 + z * 0.38 ); return ( hill + depression + diagonalSlope + gentleWave ); } /* Soft green boundary. This keeps the surface from having an obvious artificial rectangular edge. */ function boundaryFalloff(x, z) { const nx = Math.abs(x) / (TERRAIN.width * 0.5); const nz = Math.abs(z) / (TERRAIN.depth * 0.5); const edge = Math.max(nx, nz); if (edge < 0.78) { return 1; } const t = THREE.MathUtils.smoothstep( edge, 0.78, 1 ); return 1 - t * 0.75; } function heightAt(x, z) { const raw = baseHeightAt(x, z) * boundaryFalloff(x, z); return raw * TERRAIN.baseHeight * exaggeration; } /* ================================================================ TERRAIN MESH ================================================================ */ let terrainMesh = null; function buildTerrain() { if (terrainMesh) { scene.remove(terrainMesh); terrainMesh.geometry.dispose(); terrainMesh.material.dispose(); } const geometry = new THREE.PlaneGeometry( TERRAIN.width, TERRAIN.depth, TERRAIN.resolutionX, TERRAIN.resolutionZ ); /* PlaneGeometry is initially XY. Rotate it so: X = world X Y = elevation Z = world Z */ geometry.rotateX(-Math.PI / 2); const position = geometry.attributes.position; for ( let i = 0; i < position.count; i++ ) { const x = position.getX(i); const z = position.getZ(i); position.setY( i, heightAt(x, z) ); } position.needsUpdate = true; geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color: 0x526b4d, roughness: 0.92, metalness: 0, side: THREE.DoubleSide }); terrainMesh = new THREE.Mesh( geometry, material ); terrainMesh.receiveShadow = true; terrainMesh.castShadow = true; scene.add(terrainMesh); } /* ================================================================ CONTOURS ================================================================ */ /* Marching-squares style contour generation. Rather than drawing flat horizontal lines, we find where a particular elevation crosses each terrain cell and then put the resulting points directly onto the 3D terrain. The resulting lines therefore conform to the terrain. */ let contourGroup = new THREE.Group(); scene.add(contourGroup); function clearContours() { contourGroup.traverse( object => { if (object.geometry) { object.geometry.dispose(); } if (object.material) { object.material.dispose(); } } ); scene.remove(contourGroup); contourGroup = new THREE.Group(); scene.add(contourGroup); } function interpolatePoint( x1, z1, h1, x2, z2, h2, level ) { const denominator = h2 - h1; let t; if (Math.abs(denominator) < 0.000001) { t = 0.5; } else { t = (level - h1) / denominator; } t = THREE.MathUtils.clamp( t, 0, 1 ); return new THREE.Vector3( THREE.MathUtils.lerp(x1, x2, t), level, THREE.MathUtils.lerp(z1, z2, t) ); } /* One cell is represented as: A ----- B | | | | D ----- C We test each edge for a contour crossing. */ function contourSegmentsForCell( x0, z0, x1, z1, level ) { const hA = heightAt(x0, z0); const hB = heightAt(x1, z0); const hC = heightAt(x1, z1); const hD = heightAt(x0, z1); const points = []; function checkEdge( ax, az, ah, bx, bz, bh ) { const crosses = (ah < level && bh >= level) || (ah >= level && bh < level); if (crosses) { points.push( interpolatePoint( ax, az, ah, bx, bz, bh, level ) ); } } checkEdge( x0, z0, hA, x1, z0, hB ); checkEdge( x1, z0, hB, x1, z1, hC ); checkEdge( x1, z1, hC, x0, z1, hD ); checkEdge( x0, z1, hD, x0, z0, hA ); /* Normally a contour crosses two edges. Saddle cells can produce four crossings. Pairing adjacent crossings is sufficient for this exploratory prototype. */ const segments = []; for ( let i = 0; i + 1 < points.length; i += 2 ) { segments.push([ points[i], points[i + 1] ]); } return segments; } function buildContours() { clearContours(); /* Find terrain min/max. */ let minHeight = Infinity; let maxHeight = -Infinity; const sampleStep = 0.25; for ( let x = -TERRAIN.width / 2; x <= TERRAIN.width / 2; x += sampleStep ) { for ( let z = -TERRAIN.depth / 2; z <= TERRAIN.depth / 2; z += sampleStep ) { const h = heightAt(x, z); minHeight = Math.min(minHeight, h); maxHeight = Math.max(maxHeight, h); } } /* Snap contour levels to interval. */ const interval = TERRAIN.contourInterval * TERRAIN.baseHeight * exaggeration; const firstLevel = Math.ceil( minHeight / interval ) * interval; const material = new THREE.LineBasicMaterial({ color: 0x263c27, transparent: true, opacity: exaggeration === 1 ? 0.62 : 0.72 }); /* Resolution for contour extraction. Lower than terrain mesh resolution intentionally: enough for clean conceptual lines without unnecessary geometry. */ const cellsX = 90; const cellsZ = 65; const dx = TERRAIN.width / cellsX; const dz = TERRAIN.depth / cellsZ; for ( let level = firstLevel; level <= maxHeight; level += interval ) { const positions = []; for ( let ix = 0; ix < cellsX; ix++ ) { const x0 = -TERRAIN.width / 2 + ix * dx; const x1 = x0 + dx; for ( let iz = 0; iz < cellsZ; iz++ ) { const z0 = -TERRAIN.depth / 2 + iz * dz; const z1 = z0 + dz; const segments = contourSegmentsForCell( x0, z0, x1, z1, level ); for ( const segment of segments ) { for ( const p of segment ) { /* Offset very slightly along Y so the line avoids z-fighting. */ const actualY = heightAt( p.x, p.z ) + 0.012; positions.push( p.x, actualY, p.z ); } } } } if (positions.length === 0) { continue; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.Float32BufferAttribute( positions, 3 ) ); const lines = new THREE.LineSegments( geometry, material.clone() ); contourGroup.add(lines); } } /* ================================================================ GOLF BALL ================================================================ */ const ballGroup = new THREE.Group(); scene.add(ballGroup); const ballGeometry = new THREE.SphereGeometry( 0.16, 24, 16 ); const ballMaterial = new THREE.MeshStandardMaterial({ color: 0xf7f7f1, roughness: 0.65 }); const ball = new THREE.Mesh( ballGeometry, ballMaterial ); ball.castShadow = true; ballGroup.add(ball); // Ball position. const ballPosition = { x: -7.0, z: 4.0 }; function updateBall() { ball.position.set( ballPosition.x, heightAt( ballPosition.x, ballPosition.z ) + 0.16, ballPosition.z ); } updateBall(); /* ================================================================ HOLE ================================================================ */ const holeGroup = new THREE.Group(); scene.add(holeGroup); const holePosition = { x: 6.1, z: -3.1 }; // Dark circular hole. const holeGeometry = new THREE.CylinderGeometry( 0.22, 0.22, 0.025, 32 ); const holeMaterial = new THREE.MeshBasicMaterial({ color: 0x151815 }); const hole = new THREE.Mesh( holeGeometry, holeMaterial ); hole.rotation.x = Math.PI / 2; /* Instead of trying to cut an actual hole into the terrain, place a dark disk almost flush with the green. This is deliberate: the experiment is about topology, not golf-course mesh construction. */ hole.position.set( holePosition.x, heightAt( holePosition.x, holePosition.z ) + 0.008, holePosition.z ); holeGroup.add(hole); /* ================================================================ FLAG ================================================================ */ const flagPole = new THREE.Mesh( new THREE.CylinderGeometry( 0.018, 0.018, 0.8, 8 ), new THREE.MeshStandardMaterial({ color: 0x555b54, roughness: 0.8 }) ); flagPole.position.set( holePosition.x, heightAt( holePosition.x, holePosition.z ) + 0.4, holePosition.z ); flagPole.castShadow = true; holeGroup.add(flagPole); const flag = new THREE.Mesh( new THREE.PlaneGeometry( 0.45, 0.25 ), new THREE.MeshBasicMaterial({ color: 0xf2f0e6, side: THREE.DoubleSide }) ); flag.position.set( holePosition.x + 0.2, heightAt( holePosition.x, holePosition.z ) + 0.68, holePosition.z ); flag.rotation.y = Math.PI / 2; holeGroup.add(flag); /* ================================================================ PUTT PATH ================================================================ */ let pathGroup = new THREE.Group(); scene.add(pathGroup); /* Cubic Bézier curve. The path deliberately bends a little to communicate that the ball's intended route isn't simply a straight line. */ function bezierPoint( p0, p1, p2, p3, t ) { const mt = 1 - t; return new THREE.Vector3( mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x, 0, mt * mt * mt * p0.z + 3 * mt * mt * t * p1.z + 3 * mt * t * t * p2.z + t * t * t * p3.z ); } function buildPuttPath() { pathGroup.clear(); const p0 = { x: ballPosition.x, z: ballPosition.z }; const p1 = { x: -3.0, z: 2.4 }; const p2 = { x: 2.5, z: -1.8 }; const p3 = { x: holePosition.x, z: holePosition.z }; const points = []; const segments = 80; for ( let i = 0; i <= segments; i++ ) { const t = i / segments; const p = bezierPoint( p0, p1, p2, p3, t ); p.y = heightAt( p.x, p.z ) + 0.035; points.push(p); } const geometry = new THREE.BufferGeometry() .setFromPoints(points); const material = new THREE.LineBasicMaterial({ color: 0x283c2b, transparent: true, opacity: 0.82 }); const line = new THREE.Line( geometry, material ); pathGroup.add(line); /* Small direction markers. These are intentionally sparse and physical-looking, rather than UI arrows. */ for ( let i = 8; i < segments - 4; i += 10 ) { const a = points[i]; const b = points[i + 2]; const direction = new THREE.Vector3() .subVectors(b, a) .normalize(); const arrow = new THREE.ArrowHelper( direction, a, 0.28, 0x283c2b, 0.09, 0.055 ); pathGroup.add(arrow); } } /* ================================================================ SUBTLE GROUND / CONTEXT ================================================================ */ const groundGeometry = new THREE.PlaneGeometry( 60, 60 ); groundGeometry.rotateX( -Math.PI / 2 ); const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xd7d6cf, roughness: 1 }); const ground = new THREE.Mesh( groundGeometry, groundMaterial ); ground.position.y = -0.9; ground.receiveShadow = true; scene.add(ground); /* ================================================================ BUILD EVERYTHING ================================================================ */ function rebuildTerrain() { buildTerrain(); buildContours(); updateBall(); hole.position.y = heightAt( holePosition.x, holePosition.z ) + 0.008; flagPole.position.y = heightAt( holePosition.x, holePosition.z ) + 0.4; flag.position.y = heightAt( holePosition.x, holePosition.z ) + 0.68; buildPuttPath(); } rebuildTerrain(); /* ================================================================ UI ================================================================ */ const exaggerationButtons = document.querySelectorAll( "[data-exaggeration]" ); exaggerationButtons.forEach( button => { button.addEventListener( "click", () => { exaggeration = Number( button.dataset.exaggeration ); exaggerationButtons .forEach( b => b.classList.remove( "active" ) ); button.classList.add( "active" ); rebuildTerrain(); } ); } ); /* Contours. */ document .getElementById("contoursToggle") .addEventListener( "change", event => { contourGroup.visible = event.target.checked; } ); /* Shadows. We disable both the renderer's shadow map and the actual light's shadow casting. */ document .getElementById("shadowsToggle") .addEventListener( "change", event => { const enabled = event.target.checked; renderer.shadowMap.enabled = enabled; directionalLight.castShadow = enabled; terrainMesh.castShadow = enabled; terrainMesh.receiveShadow = enabled; ground.receiveShadow = enabled; ball.castShadow = enabled; flagPole.castShadow = enabled; } ); /* Lighting. This turns the directional perceptual lighting contribution on/off while leaving the scene geometrically unchanged. */ document .getElementById("lightingToggle") .addEventListener( "change", event => { const enabled = event.target.checked; directionalLight.visible = enabled; ambientLight.visible = enabled; } ); /* Putt path. */ document .getElementById("pathToggle") .addEventListener( "change", event => { pathGroup.visible = event.target.checked; } ); /* Reset camera. */ document .getElementById("reset") .addEventListener( "click", () => { controls.reset(); } ); /* ================================================================ RESIZE ================================================================ */ window.addEventListener( "resize", () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); } ); /* ================================================================ RENDER LOOP ================================================================ */ function animate() { controls.update(); renderer.render( scene, camera ); } renderer.setAnimationLoop( animate ); </script> </body> </html>

Simple baseline

5× exaggeration

Claude

copied

copy

<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Green topology – perception prototype</title> <style> html, body { margin: 0; height: 100%; overflow: hidden; background: #e9e8e3; } body { font: 12px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #222; } canvas { display: block; touch-action: none; } #panel { position: fixed; top: 10px; left: 10px; width: 176px; padding: 10px; background: #fbfbf9; border: 1px solid #b9b8b2; border-radius: 3px; } #panel .row { margin-bottom: 8px; } #panel .lbl { display: block; margin-bottom: 3px; color: #555; } #panel label { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; cursor: pointer; } #panel input[type=range] { width: 100%; margin: 0; } #panel button { font: inherit; padding: 3px 8px; background: #fff; color: #222; cursor: pointer; border: 1px solid #9a9992; border-radius: 2px; } #panel button:focus-visible, #panel input:focus-visible { outline: 2px solid #245b9e; outline-offset: 1px; } .seg { display: flex; } .seg button { flex: 1; border-radius: 0; margin-left: -1px; } .seg button:first-child { margin-left: 0; border-radius: 2px 0 0 2px; } .seg button:last-child { border-radius: 0 2px 2px 0; } .seg button.on { background: #222; color: #fff; border-color: #222; position: relative; } .btns { display: flex; gap: 4px; flex-wrap: wrap; } #readout { margin-top: 8px; padding-top: 8px; border-top: 1px solid #dcdbd5; color: #666; font-size: 11px; } #legend { position: fixed; left: 10px; bottom: 10px; padding: 6px 8px; display: none; background: #fbfbf9; border: 1px solid #b9b8b2; border-radius: 3px; color: #444; font-size: 11px; } #legend span { display: inline-block; vertical-align: middle; margin-right: 4px; } #legend .sw { width: 22px; height: 0; border-top: 3px solid #fff7d6; box-shadow: 0 0 0 1px #6b6b60; } #legend .sw.dash { border-top: 2px dashed #fff; box-shadow: none; outline: 1px solid #6b6b60; height: 0; } #legend .sw.arrow { border-top: 3px solid #d9822b; box-shadow: none; } #legend .item { margin-right: 12px; white-space: nowrap; } #hint { position: fixed; right: 10px; bottom: 10px; max-width: 220px; text-align: right; color: #6b6a64; font-size: 11px; } </style> </head> <body> <div id="panel"> <div class="row"> <span class="lbl">Terrain exaggeration</span> <div class="seg" id="exSeg"> <button data-ex="1" class="on">1×</button> <button data-ex="3">3×</button> <button data-ex="5">5×</button> </div> </div> <div class="row"> <label><input type="checkbox" id="cContours"> Contours</label> <label><input type="checkbox" id="cShadows"> Shadows</label> <label><input type="checkbox" id="cLighting"> Lighting</label> <label><input type="checkbox" id="cPath"> Putt path</label> </div> <div class="row"> <span class="lbl">Sun elevation <output id="sunOut">10°</output></span> <input type="range" id="sun" min="5" max="50" step="1" value="10"> </div> <div class="row btns"> <button id="bReset">Reset camera</button> </div> <div class="row btns"> <button id="bOff">All cues off</button> <button id="bOn">All cues on</button> </div> <div id="readout"></div> </div> <div id="legend"> <span class="item"><span class="sw"></span>predicted putt</span> <span class="item"><span class="sw dash"></span>straight line</span> <span class="item"><span class="sw arrow"></span>downhill pull (longer = steeper)</span> </div> <div id="hint">Drag to orbit · scroll / pinch to zoom.<br>Terrain and light stay fixed in the world.</div> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> <script> (function () { 'use strict'; /* ===================================================================== 1. CONFIG (all lengths in metres, true scale, before exaggeration) ===================================================================== */ const CFG = { radius: 10.5, // mean green radius; outline is irregular around this rings: 110, segs: 200, // polar mesh resolution interval: 0.02, // contour spacing in TRUE elevation (2 cm) majorEvery: 5, // every 5th contour is drawn heavier ball: { x: -4.6, z: -3.2 }, hole: { x: 3.6, z: 2.6 }, ballR: 0.13, // enlarged ~6x so it is legible from above holeR: 0.17, pinH: 1.3, sunAzimuth: [-0.85, -0.53], // horizontal direction TOWARD the sun (from upper-left of a top-down view) sunElevationDeg: 10, ambient: 0.5, sunIntensity: 1.6 }; /* ===================================================================== 2. TERRAIN FIELD + PUTT SOLVER + CONTOURS (pure math, no THREE) ===================================================================== */ //#region pure const TAU = Math.PI * 2; // Three overlapping features. Everything else in the prototype samples these two functions. const FEATURES = { hill: { A: 0.20, cx: 3.0, cz: -3.0, sx: 4.2, sz: 3.2 }, // broad raised area dip: { A: -0.14, cx: -3.5, cz: 3.2, sx: 2.6, sz: 2.6 }, // depression tilt: { s: 0.015, ux: 0.857, uz: -0.514 } // diagonal ~1.5% slope, rising toward +x/-z }; function gauss(f, x, z) { const dx = x - f.cx, dz = z - f.cz; return f.A * Math.exp(-(dx * dx / (2 * f.sx * f.sx) + dz * dz / (2 * f.sz * f.sz))); } function heightAt(x, z) { const t = FEATURES.tilt; return gauss(FEATURES.hill, x, z) + gauss(FEATURES.dip, x, z) + t.s * (t.ux * x + t.uz * z); } // analytic gradient [dh/dx, dh/dz] (dimensionless slope, 0.03 = 3 %) function gradAt(x, z) { const h = FEATURES.hill, d = FEATURES.dip, t = FEATURES.tilt; const gh = gauss(h, x, z), gd = gauss(d, x, z); return [ -gh * (x - h.cx) / (h.sx * h.sx) - gd * (x - d.cx) / (d.sx * d.sx) + t.s * t.ux, -gh * (z - h.cz) / (h.sz * h.sz) - gd * (z - d.cz) / (d.sz * d.sz) + t.s * t.uz ]; } function outlineRadius(th) { return CFG.radius * (1 + 0.10 * Math.sin(2 * th + 0.6) + 0.07 * Math.sin(3 * th + 2.1) + 0.04 * Math.sin(5 * th + 0.3)); } function smoothstep(a, b, x) { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); } /* Polar mesh: one centre vertex + `rings` rings of `segs` vertices. The outer ring follows the irregular outline, so the boundary is smooth (no stair-stepped grid edge). */ function buildGrid(rings, segs) { const nV = 1 + rings * segs; const pos = new Float32Array(nV * 3); const realN = new Float32Array(nV * 3); // true surface normals (analytic) const upN = new Float32Array(nV * 3); // all (0,1,0): used to switch shading OFF const col = new Float32Array(nV * 3); const green = [0.424, 0.604, 0.310], fringe = [0.33, 0.50, 0.26]; function put(v, x, z, t) { const h = heightAt(x, z), g = gradAt(x, z); const inv = 1 / Math.hypot(g[0], 1, g[1]); pos[v * 3] = x; pos[v * 3 + 1] = h; pos[v * 3 + 2] = z; realN[v * 3] = -g[0] * inv; realN[v * 3 + 1] = inv; realN[v * 3 + 2] = -g[1] * inv; upN[v * 3 + 1] = 1; const f = smoothstep(0.90, 0.96, t); for (let k = 0; k < 3; k++) col[v * 3 + k] = green[k] + (fringe[k] - green[k]) * f; } put(0, 0, 0, 0); for (let i = 1; i <= rings; i++) { const t = i / rings; for (let j = 0; j < segs; j++) { const th = (j / segs) * TAU, r = outlineRadius(th) * t; put(1 + (i - 1) * segs + j, r * Math.cos(th), r * Math.sin(th), t); } } const idx = []; for (let j = 0; j < segs; j++) idx.push(0, 1 + ((j + 1) % segs), 1 + j); // centre fan for (let i = 1; i < rings; i++) { for (let j = 0; j < segs; j++) { const a = 1 + (i - 1) * segs + j, b = 1 + (i - 1) * segs + ((j + 1) % segs); const c = 1 + i * segs + j, d = 1 + i * segs + ((j + 1) % segs); idx.push(a, b, c, b, d, c); } } return { pos, realN, upN, col, index: new Uint32Array(idx) }; } /* Contours by marching triangles over the SAME mesh that is rendered. For each triangle, for each elevation level that crosses it, emit one segment whose endpoints lie on the triangle edges, at y = level. So every line sits exactly on the surface. Because levels are fixed heights (every 2 cm), spacing tightens on steep ground by itself. */ function buildContours(pos, index, interval, majorEvery) { const minor = [], major = []; const EPS = 1e-7; // nudge levels off exact vertex heights for (let t = 0; t < index.length; t += 3) { const ia = index[t] * 3, ib = index[t + 1] * 3, ic = index[t + 2] * 3; const ha = pos[ia + 1], hb = pos[ib + 1], hc = pos[ic + 1]; const lo = Math.min(ha, hb, hc), hi = Math.max(ha, hb, hc); const k0 = Math.ceil(lo / interval), k1 = Math.floor(hi / interval); for (let k = k0; k <= k1; k++) { const L = k * interval + EPS; if (L < lo || L > hi) continue; const out = (((k % majorEvery) + majorEvery) % majorEvery === 0) ? major : minor; const pts = []; edge(ia, ib, ha, hb); edge(ib, ic, hb, hc); edge(ic, ia, hc, ha); if (pts.length === 6) out.push(pts[0], pts[1], pts[2], pts[3], pts[4], pts[5]); function edge(p, q, hp, hq) { if ((hp < L) === (hq < L)) return; const s = (L - hp) / (hq - hp); pts.push(pos[p] + s * (pos[q] - pos[p]), L, pos[p + 2] + s * (pos[q + 2] - pos[p + 2])); } } } return { minor: new Float32Array(minor), major: new Float32Array(major) }; } /* Putt "predictor". Not a golf simulator: a ball is launched, gets accelerated by the local downhill slope (g * 5/7 * gradient) and slowed by rolling friction. A small search over aim angle and speed finds the launch that ends at the hole, which produces the "break". The path is solved on the TRUE terrain; exaggeration only changes how it is drawn. */ function solvePutt(ball, hole) { const G = 9.81 * 5 / 7, MU = 0.55, DT = 0.02, STEPS = 700; const base = Math.atan2(hole.z - ball.z, hole.x - ball.x); function sim(ang, speed, keep) { let x = ball.x, z = ball.z, vx = Math.cos(ang) * speed, vz = Math.sin(ang) * speed; let best = { d: 1e9, sp: 0, i: 0 }; const pts = keep ? [[x, z]] : null; for (let i = 1; i <= STEPS; i++) { const g = gradAt(x, z); const sp = Math.hypot(vx, vz); let ax = -G * g[0], az = -G * g[1]; if (sp > 1e-6) { ax -= MU * vx / sp; az -= MU * vz / sp; } vx += ax * DT; vz += az * DT; x += vx * DT; z += vz * DT; if (keep) pts.push([x, z]); const d = Math.hypot(x - hole.x, z - hole.z), s2 = Math.hypot(vx, vz); if (d < best.d) best = { d, sp: s2, i }; else if (d > best.d + 1.0) break; // already past the hole if (s2 < 0.03) break; // stopped } return { best, pts }; } const cost = (b) => b.d + 0.06 * Math.abs(b.sp - 0.8); // land on the hole, arrive gently let bA = 0, bV = 3, bC = 1e9; function search(a0, a1, da, v0, v1, dv) { for (let a = a0; a <= a1 + 1e-9; a += da) { for (let v = v0; v <= v1 + 1e-9; v += dv) { const c = cost(sim(base + a, v, false).best); if (c < bC) { bC = c; bA = a; bV = v; } } } } const D = Math.PI / 180; search(-35 * D, 35 * D, 2 * D, 1.8, 5.2, 0.2); [[0.5 * D, 0.03], [0.1 * D, 0.008], [0.02 * D, 0.002]].forEach(([da, dv]) => { const a = bA, v = bV; search(a - 4 * da, a + 4 * da, da, Math.max(0.5, v - 4 * dv), v + 4 * dv, dv); }); const r = sim(base + bA, bV, true); const pts = r.pts.slice(0, r.best.i + 1); pts[pts.length - 1] = [hole.x, hole.z]; // finish exactly on the hole return { xz: resample(pts, 120), missDist: r.best.d, arrivalSpeed: r.best.sp, aim: bA / D, speed: bV }; } function resample(pts, n) { const cum = [0]; for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])); const total = cum[cum.length - 1], out = []; let k = 1; for (let m = 0; m < n; m++) { const s = (m / (n - 1)) * total; while (k < cum.length - 1 && cum[k] < s) k++; const seg = cum[k] - cum[k - 1] || 1, u = (s - cum[k - 1]) / seg; out.push([pts[k - 1][0] + u * (pts[k][0] - pts[k - 1][0]), pts[k - 1][1] + u * (pts[k][1] - pts[k - 1][1])]); } return out; } //#endregion pure /* ===================================================================== 3. SCENE, CAMERA, RENDERER ===================================================================== */ const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe9e8e3); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.shadowMap.enabled = true; // actual shadows are switched via sun.castShadow renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.body.appendChild(renderer.domElement); const camera = new THREE.PerspectiveCamera(35, 1, 1, 400); const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.enablePan = false; // terrain stays centred; orbit + zoom only controls.minDistance = 8; controls.maxDistance = 100; controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI * 0.47; // stop just above the horizon controls.rotateSpeed = 0.7; function fitDistance() { // distance at which a ~26 m wide square fits in both directions const halfV = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)); return 13.5 / (halfV * Math.min(1, camera.aspect)); } function resetCamera() { const d = fitDistance(), polar = THREE.MathUtils.degToRad(7); // nearly top-down camera.position.set(0, d * Math.cos(polar), d * Math.sin(polar)); controls.target.set(0, 0, 0); controls.update(); } /* ===================================================================== 4. LIGHTING & SHADOWS The sun is fixed in world space (hillshade convention: light from the upper-left). "Lighting" OFF -> terrain normals are swapped for straight-up normals, so every point gets the same shade (no gradient), while shadows keep working. "Shadows" OFF -> the sun stops casting shadows. ===================================================================== */ const ambient = new THREE.AmbientLight(0xffffff, CFG.ambient); scene.add(ambient); const sun = new THREE.DirectionalLight(0xffffff, CFG.sunIntensity); sun.shadow.mapSize.set(4096, 4096); sun.shadow.camera.left = -14; sun.shadow.camera.right = 14; sun.shadow.camera.top = 14; sun.shadow.camera.bottom = -14; sun.shadow.camera.near = 15; sun.shadow.camera.far = 70; sun.shadow.bias = -0.001; sun.shadow.normalBias = 0.02; scene.add(sun, sun.target); function setSunElevation(deg) { const e = THREE.MathUtils.degToRad(deg), c = Math.cos(e); const az = CFG.sunAzimuth, n = Math.hypot(az[0], az[1]); sun.position.set((az[0] / n) * c * 40, Math.sin(e) * 40, (az[1] / n) * c * 40); sun.target.position.set(0, 0, 0); } setSunElevation(CFG.sunElevationDeg); /* ===================================================================== 5. TERRAIN MESH + CONTOUR LINES Terrain geometry is built ONCE at true scale. Exaggeration is just mesh.scale.y, so the shape is identical at every exaggeration by construction. ===================================================================== */ const grid = buildGrid(CFG.rings, CFG.segs); const terrainGeo = new THREE.BufferGeometry(); terrainGeo.setAttribute('position', new THREE.BufferAttribute(grid.pos, 3)); terrainGeo.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(grid.upN), 3)); terrainGeo.setAttribute('color', new THREE.BufferAttribute(grid.col, 3)); terrainGeo.setIndex(new THREE.BufferAttribute(grid.index, 1)); const terrainMat = new THREE.MeshPhongMaterial({ vertexColors: true, specular: 0x000000, shininess: 0, polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 2 // keeps contour lines on top }); const terrain = new THREE.Mesh(terrainGeo, terrainMat); terrain.castShadow = true; terrain.receiveShadow = true; scene.add(terrain); const cont = buildContours(grid.pos, grid.index, CFG.interval, CFG.majorEvery); const contourGroup = new THREE.Group(); // scaled in y together with the terrain function lines(arr, color, opacity) { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(arr, 3)); return new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color, transparent: true, opacity })); } contourGroup.add(lines(cont.minor, 0x1f3320, 0.5), lines(cont.major, 0x0d1a0f, 0.95)); scene.add(contourGroup); let hMin = Infinity, hMax = -Infinity; for (let i = 1; i < grid.pos.length; i += 3) { hMin = Math.min(hMin, grid.pos[i]); hMax = Math.max(hMax, grid.pos[i]); } /* ===================================================================== 6. GOLF OBJECTS (ball, hole, pin, putt path, slope arrows) Their (x, z) never change. Only y is recomputed as height * exaggeration. ===================================================================== */ const UP = new THREE.Vector3(0, 1, 0); const surfY = (x, z, ex) => heightAt(x, z) * ex; const ball = new THREE.Mesh( new THREE.SphereGeometry(CFG.ballR, 24, 16), new THREE.MeshPhongMaterial({ color: 0xffffff, shininess: 30 }) ); ball.castShadow = true; scene.add(ball); const holeGroup = new THREE.Group(); const holeDisc = new THREE.Mesh(new THREE.CircleGeometry(CFG.holeR, 32).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x111111 })); const holeRim = new THREE.Mesh(new THREE.RingGeometry(CFG.holeR, CFG.holeR * 1.35, 32).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0xe8e5d6 })); holeGroup.add(holeRim, holeDisc); scene.add(holeGroup); const pin = new THREE.Mesh( new THREE.CylinderGeometry(0.025, 0.025, CFG.pinH, 8), new THREE.MeshPhongMaterial({ color: 0xf2f2ee }) ); pin.castShadow = true; scene.add(pin); // predicted putt (solved once, on true terrain) const putt = solvePutt(CFG.ball, CFG.hole); const puttMat = new THREE.MeshBasicMaterial({ color: 0xfff7d6 }); let puttMesh = null; // straight reference line ball -> hole, draped on the surface const STRAIGHT_N = 70; const straightGeo = new THREE.BufferGeometry(); straightGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(STRAIGHT_N * 3), 3)); const straight = new THREE.Line(straightGeo, new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 0.3, gapSize: 0.25, transparent: true, opacity: 0.85 })); scene.add(straight); // slope arrows: local downhill direction, length grows with steepness const ARROW_FRACS = [0.08, 0.22, 0.36, 0.50, 0.64, 0.78, 0.92]; const arrowMat = new THREE.MeshBasicMaterial({ color: 0xd9822b }); const shaftGeo = new THREE.CylinderGeometry(1, 1, 1, 8); const headGeo = new THREE.ConeGeometry(1, 1, 12); const arrows = ARROW_FRACS.map(() => { const g = new THREE.Group(); const shaft = new THREE.Mesh(shaftGeo, arrowMat), head = new THREE.Mesh(headGeo, arrowMat); g.add(shaft, head); g.userData = { shaft, head }; scene.add(g); return g; }); function placeArrow(a, B, T) { const dir = T.clone().sub(B), L = dir.length(); dir.normalize(); const headL = Math.min(0.32, L * 0.4), shaftL = L - headL; const { shaft, head } = a.userData; shaft.position.copy(B).addScaledVector(dir, shaftL / 2); shaft.scale.set(0.045, shaftL, 0.045); shaft.quaternion.setFromUnitVectors(UP, dir); head.position.copy(B).addScaledVector(dir, shaftL + headL / 2); head.scale.set(0.15, headL, 0.15); head.quaternion.setFromUnitVectors(UP, dir); } function updateObjects(ex) { const b = CFG.ball, h = CFG.hole; ball.position.set(b.x, surfY(b.x, b.z, ex) + CFG.ballR, b.z); // hole disc lies on the (exaggerated) surface, tilted with it const g = gradAt(h.x, h.z); const n = new THREE.Vector3(-g[0] * ex, 1, -g[1] * ex).normalize(); holeGroup.position.set(h.x, surfY(h.x, h.z, ex), h.z).addScaledVector(n, 0.012); holeGroup.quaternion.setFromUnitVectors(UP, n); pin.position.set(h.x, surfY(h.x, h.z, ex) + CFG.pinH / 2, h.z); // putt path as a thin tube following the surface const lift = 0.06; const pts = putt.xz.map(([x, z]) => new THREE.Vector3(x, surfY(x, z, ex) + lift, z)); const tube = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts, false, 'centripetal'), 240, 0.05, 6, false); if (puttMesh) { puttMesh.geometry.dispose(); puttMesh.geometry = tube; } else { puttMesh = new THREE.Mesh(tube, puttMat); scene.add(puttMesh); puttMesh.visible = state.path; } // straight line const sp = straightGeo.attributes.position; for (let i = 0; i < STRAIGHT_N; i++) { const u = i / (STRAIGHT_N - 1), x = b.x + (h.x - b.x) * u, z = b.z + (h.z - b.z) * u; sp.setXYZ(i, x, surfY(x, z, ex) + lift, z); } sp.needsUpdate = true; straight.computeLineDistances(); // arrows ARROW_FRACS.forEach((f, i) => { const [x, z] = putt.xz[Math.round(f * (putt.xz.length - 1))]; const gr = gradAt(x, z), s = Math.hypot(gr[0], gr[1]) || 1e-6; const len = Math.min(1.6, Math.max(0.3, s * 28)); const tx = x - (gr[0] / s) * len, tz = z - (gr[1] / s) * len; // downhill = -gradient placeArrow(arrows[i], new THREE.Vector3(x, surfY(x, z, ex) + lift, z), new THREE.Vector3(tx, surfY(tx, tz, ex) + lift, tz)); }); } /* ===================================================================== 7. EXAGGERATION (smooth tween so you can watch the SAME shape grow) ===================================================================== */ const state = { exTarget: 1, exCur: 1, contours: false, shadows: false, lighting: false, path: false }; function applyExaggeration(ex) { terrain.scale.y = ex; contourGroup.scale.y = ex; updateObjects(ex); } /* ===================================================================== 8. CUES (toggle handlers) ===================================================================== */ function applyCues() { contourGroup.visible = state.contours; sun.castShadow = state.shadows; terrainMat.needsUpdate = true; ball.material.needsUpdate = true; pin.material.needsUpdate = true; const normals = terrainGeo.attributes.normal; normals.array.set(state.lighting ? grid.realN : grid.upN); normals.needsUpdate = true; if (puttMesh) puttMesh.visible = state.path; straight.visible = state.path; arrows.forEach(a => (a.visible = state.path)); document.getElementById('legend').style.display = state.path ? 'block' : 'none'; } /* ===================================================================== 9. UI ===================================================================== */ const $ = (id) => document.getElementById(id); const checks = { contours: $('cContours'), shadows: $('cShadows'), lighting: $('cLighting'), path: $('cPath') }; function syncUI() { Object.keys(checks).forEach(k => (checks[k].checked = state[k])); document.querySelectorAll('#exSeg button').forEach(b => b.classList.toggle('on', +b.dataset.ex === state.exTarget)); } Object.keys(checks).forEach(k => checks[k].addEventListener('change', () => { state[k] = checks[k].checked; applyCues(); })); document.querySelectorAll('#exSeg button').forEach(b => b.addEventListener('click', () => { state.exTarget = +b.dataset.ex; syncUI(); })); $('sun').addEventListener('input', (e) => { setSunElevation(+e.target.value); $('sunOut').textContent = e.target.value + '°'; }); $('bReset').addEventListener('click', resetCamera); $('bOff').addEventListener('click', () => { Object.assign(state, { contours: false, shadows: false, lighting: false, path: false, exTarget: 1 }); syncUI(); applyCues(); }); $('bOn').addEventListener('click', () => { Object.assign(state, { contours: true, shadows: true, lighting: true, path: true }); syncUI(); applyCues(); }); $('readout').textContent = 'True relief ' + Math.round((hMax - hMin) * 100) + ' cm across ~21 m. Contours every ' + Math.round(CFG.interval * 100) + ' cm of true height; heavy line every ' + Math.round(CFG.interval * CFG.majorEvery * 100) + ' cm. Ball and hole are enlarged for legibility.'; /* ===================================================================== 10. LOOP ===================================================================== */ function resize() { const w = window.innerWidth, h = window.innerHeight; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); } window.addEventListener('resize', resize); let last = performance.now(); function frame(now) { const dt = Math.min(0.1, (now - last) / 1000); last = now; if (state.exCur !== state.exTarget) { state.exCur += (state.exTarget - state.exCur) * Math.min(1, dt * 9); if (Math.abs(state.exTarget - state.exCur) < 0.002) state.exCur = state.exTarget; applyExaggeration(state.exCur); } controls.update(); renderer.render(scene, camera); requestAnimationFrame(frame); } resize(); resetCamera(); applyExaggeration(1); applyCues(); syncUI(); requestAnimationFrame(frame); // handy for poking around in the console window.__green = { heightAt, gradAt, putt, state }; })(); </script> </body> </html>

Lighting controls

Sun elevation / azimuth

Gemini

copied

copy

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Spatial Topology Perception Prototype</title> <style> :root { --bg-color: #121212; --panel-bg: rgba(28, 28, 30, 0.85); --text-color: #e0e0e0; --text-dim: #888888; --accent: #4cd964; --border: rgba(255, 255, 255, 0.1); } body { margin: 0; padding: 0; overflow: hidden; background-color: var(--bg-color); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: var(--text-color); font-size: 13px; letter-spacing: -0.01em; } #canvas-container { width: 100vw; height: 100vh; display: block; } /* Minimal Control Panel */ #ui-panel { position: absolute; top: 20px; left: 20px; background: var(--panel-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--border); border-radius: 8px; padding: 16px; width: 220px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 10; user-select: none; } .control-group { margin-bottom: 14px; } .control-group:last-child { margin-bottom: 0; } label { display: block; color: var(--text-dim); margin-bottom: 6px; font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; } .button-row { display: flex; gap: 6px; } button { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--border); color: var(--text-color); padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; transition: all 0.15s ease; } button:hover { background: rgba(255, 255, 255, 0.12); } button.active { background: var(--text-color); color: var(--bg-color); border-color: var(--text-color); font-weight: 600; } .toggle-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .toggle-row span { color: var(--text-color); } /* Custom minimal checkbox toggle */ .switch { position: relative; display: inline-block; width: 34px; height: 18px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255,255,255,0.2); transition: .2s; border-radius: 18px; } .slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: white; transition: .2s; border-radius: 50%; } input:checked + .slider { background-color: #fff; } input:checked + .slider:before { transform: translateX(16px); background-color: var(--bg-color); } .divider { height: 1px; background: var(--border); margin: 12px 0; } #info-overlay { position: absolute; bottom: 20px; left: 20px; color: var(--text-dim); font-size: 11px; pointer-events: none; } </style> <!-- Import Map for Three.js modules --> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } } </script> </head> <body> <div id="canvas-container"></div> <div id="ui-panel"> <div class="control-group"> <label>Terrain Exaggeration</label> <div class="button-row"> <button class="exagg-btn active" data-val="1">1x</button> <button class="exagg-btn" data-val="3">3x</button> <button class="exagg-btn" data-val="5">5x</button> </div> </div> <div class="divider"></div> <div class="control-group"> <label>Perceptual Cues</label> <div class="toggle-row"> <span>Contours</span> <label class="switch"> <input type="checkbox" id="toggle-contours" checked> <span class="slider"></span> </label> </div> <div class="toggle-row"> <span>Shadows</span> <label class="switch"> <input type="checkbox" id="toggle-shadows" checked> <span class="slider"></span> </label> </div> <div class="toggle-row"> <span>Lighting</span> <label class="switch"> <input type="checkbox" id="toggle-lighting" checked> <span class="slider"></span> </label> </div> <div class="toggle-row"> <span>Putt Path</span> <label class="switch" style="margin-bottom:0;"> <input type="checkbox" id="toggle-path" checked> <span class="slider"></span> </label> </div> </div> <div class="divider"></div> <div class="control-group"> <button id="reset-cam" style="width:100%;">Reset Camera</button> </div> </div> <div id="info-overlay"> Drag to orbit &bull; Pinch/Scroll to zoom &bull; Space perception test </div> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // --- Setup Scene, Camera, Renderer --- const container = document.getElementById('canvas-container'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xFCF9F5); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 18, 0.1); // Near top-down initial view const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.maxPolarAngle = Math.PI / 2 - 0.05; // Don't go below ground controls.minDistance = 5; controls.maxDistance = 40; controls.target.set(0, 0, 0); // --- Lighting --- const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(12, 18, 10); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.bias = -0.001; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 40; const d = 12; dirLight.shadow.camera.left = -d; dirLight.shadow.camera.right = d; dirLight.shadow.camera.top = d; dirLight.shadow.camera.bottom = -d; scene.add(dirLight); const fillLight = new THREE.DirectionalLight(0x88bbff, 0.3); fillLight.position.set(-10, 10, -10); scene.add(fillLight); // --- Terrain Generation --- const width = 16; const height = 16; const segments = 128; const geometry = new THREE.PlaneGeometry(width, height, segments, segments); geometry.rotateX(-Math.PI / 2); const posAttr = geometry.attributes.position; const baseHeights = new Float32Array(posAttr.count); // Procedural height function defining: raised area, depression, diagonal slope function calculateBaseHeight(x, z) { // Broad raised area (top-right / quadrant 1) let highPlateau = Math.exp(-((x - 3.5)*(x - 3.5) + (z - 3.0)*(z - 3.0)) / 18) * 1.8; // Depression (bottom-left / quadrant 3) let depression = -Math.exp(-((x + 3.0)*(x + 3.0) + (z + 3.5)*(z + 3.5)) / 15) * 1.5; // Diagonal slope transition and subtle micro undulations let diagonalSlope = (x * 0.15) - (z * 0.1); let micro = Math.sin(x * 0.8) * Math.cos(z * 0.8) * 0.15; return highPlateau + depression + diagonalSlope + micro; } for (let i = 0; i < posAttr.count; i++) { const x = posAttr.getX(i); const z = posAttr.getZ(i); const h = calculateBaseHeight(x, z); baseHeights[i] = h; posAttr.setY(i, h); } geometry.computeVertexNormals(); let currentExaggeration = 1; function updateTerrainExaggeration(multiplier) { currentExaggeration = multiplier; for (let i = 0; i < posAttr.count; i++) { posAttr.setY(i, baseHeights[i] * currentExaggeration); } posAttr.needsUpdate = true; geometry.computeVertexNormals(); updateContours(); updatePuttPath(); } // Terrain Material const terrainMaterial = new THREE.MeshStandardMaterial({ color: 0x2a3e2d, roughness: 0.85, metalness: 0.05, flatShading: false }); const terrainMesh = new THREE.Mesh(geometry, terrainMaterial); terrainMesh.receiveShadow = true; terrainMesh.castShadow = true; scene.add(terrainMesh); // Subtle boundary ring / tee pad collar const collarGeo = new THREE.RingGeometry(7.5, 7.8, 64); collarGeo.rotateX(-Math.PI / 2); const collarMat = new THREE.MeshBasicMaterial({ color: 0x1c2b1e, side: THREE.DoubleSide }); const collarMesh = new THREE.Mesh(collarGeo, collarMat); collarMesh.position.y = -0.05; scene.add(collarMesh); // --- Contour Lines Generation --- const contourGroup = new THREE.Group(); scene.add(contourGroup); function updateContours() { while(contourGroup.children.length > 0) { contourGroup.remove(contourGroup.children[0]); } if (!document.getElementById('toggle-contours').checked) return; const minH = -2.0; const maxH = 2.5; const step = 0.25; const lineMat = new THREE.LineBasicMaterial({ color: 0x76c78a, transparent: true, opacity: 0.45, linewidth: 1 }); // Iterate through elevation slices and build contour line segments for (let targetH = minH; targetH <= maxH; targetH += step) { const scaledTargetH = targetH; // Evaluated against current exaggerated heights const points = []; // Simple grid edge traversal for contour line extraction const cols = segments + 1; const rows = segments + 1; const pos = terrainMesh.geometry.attributes.position; for (let zIdx = 0; zIdx < rows; zIdx++) { for (let xIdx = 0; xIdx < cols; xIdx++) { const idx = zIdx * cols + xIdx; const y = pos.getY(idx); // Check right neighbor if (xIdx < cols - 1) { const rightIdx = zIdx * cols + (xIdx + 1); const ry = pos.getY(rightIdx); if ((y <= scaledTargetH && ry > scaledTargetH) || (y > scaledTargetH && ry <= scaledTargetH)) { const t = (scaledTargetH - y) / (ry - y); const xCoord = THREE.MathUtils.lerp(pos.getX(idx), pos.getX(rightIdx), t); const zCoord = THREE.MathUtils.lerp(pos.getZ(idx), pos.getZ(rightIdx), t); points.push(new THREE.Vector3(xCoord, scaledTargetH + 0.015, zCoord)); } } // Check top neighbor if (zIdx < rows - 1) { const topIdx = (zIdx + 1) * cols + xIdx; const ty = pos.getY(topIdx); if ((y <= scaledTargetH && ty > scaledTargetH) || (y > scaledTargetH && ty <= scaledTargetH)) { const t = (scaledTargetH - y) / (ty - y); const xCoord = THREE.MathUtils.lerp(pos.getX(idx), pos.getX(topIdx), t); const zCoord = THREE.MathUtils.lerp(pos.getZ(idx), pos.getZ(topIdx), t); points.push(new THREE.Vector3(xCoord, scaledTargetH + 0.015, zCoord)); } } } } if (points.length > 0) { // Group segments into line geometry const lineGeo = new THREE.BufferGeometry().setFromPoints(points); const contourLine = new THREE.LineSegments(lineGeo, lineMat); contourGroup.add(contourLine); } } } // --- Golf Ball, Hole & Putt Path --- const ballPos = new THREE.Vector3(-3.5, 0, 2.5); const holePos = new THREE.Vector3(2.5, 0, -2.5); // Hole cup const holeGeo = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 32); const holeMat = new THREE.MeshBasicMaterial({ color: 0x0a120c }); const holeMesh = new THREE.Mesh(holeGeo, holeMat); scene.add(holeMesh); // Flagstick pin const pinGeo = new THREE.CylinderGeometry(0.02, 0.02, 2.5, 12); const pinMat = new THREE.MeshBasicMaterial({ color: 0xdddddd }); const pinMesh = new THREE.Mesh(pinGeo, pinMat); pinMesh.position.set(0, 1.25, 0); const flagGeo = new THREE.BufferGeometry(); // Simple triangular flag const vertices = new Float32Array([0, 1.2, 0, 0.6, 1.0, 0, 0, 0.8, 0]); flagGeo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); const flagMat = new THREE.MeshBasicMaterial({ color: 0xe63946, side: THREE.DoubleSide }); const flagMesh = new THREE.Mesh(flagGeo, flagMat); holeMesh.add(pinMesh); pinMesh.add(flagMesh); // Ball const ballGeo = new THREE.SphereGeometry(0.2, 32, 32); const ballMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3, metalness: 0.1 }); const ballMesh = new THREE.Mesh(ballGeo, ballMat); ballMesh.castShadow = true; scene.add(ballMesh); // Putt path line const pathLineMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2, transparent: true, opacity: 0.8 }); let pathMesh = null; function getTerrainHeightAt(x, z) { // Bilinear approximation or base lookup let h = calculateBaseHeight(x, z) * currentExaggeration; return h; } function updatePuttPath() { if (pathMesh) scene.remove(pathMesh); if (!document.getElementById('toggle-path').checked) return; // Position ball & hole relative to current height ballPos.y = getTerrainHeightAt(ballPos.x, ballPos.z) + 0.2; ballMesh.position.copy(ballPos); holePos.y = getTerrainHeightAt(holePos.x, holePos.z); holeMesh.position.copy(holePos); // Generate simulated gradient descent arc from ball to hole const points = []; let curr = ballPos.clone(); curr.y -= 0.2; // surface level points.push(curr.clone().add(new THREE.Vector3(0, 0.02, 0))); const target = holePos.clone(); const steps = 45; let pos = curr.clone(); for (let i = 1; i <= steps; i++) { const alpha = i / steps; // Blend straight line vector with downhill gradient pull let interp = new THREE.Vector3().lerpVectors(curr, target, alpha); // Add simulated gravity pull offset based on surrounding slope gradient const hLeft = getTerrainHeightAt(interp.x - 0.2, interp.z); const hRight = getTerrainHeightAt(interp.x + 0.2, interp.z); const hUp = getTerrainHeightAt(interp.x, interp.z - 0.2); const hDown = getTerrainHeightAt(interp.x, interp.z + 0.2); const slopeX = (hLeft - hRight); const slopeZ = (hUp - hDown); interp.x += slopeX * (1.0 - alpha) * 0.8; interp.z += slopeZ * (1.0 - alpha) * 0.8; interp.y = getTerrainHeightAt(interp.x, interp.z) + 0.03; points.push(interp); } const pathGeo = new THREE.BufferGeometry().setFromPoints(points); pathMesh = new THREE.Line(pathGeo, pathLineMat); scene.add(pathMesh); } // Initial setup calls updateContours(); updatePuttPath(); // --- UI Event Listeners --- document.querySelectorAll('.exagg-btn').forEach(btn => { btn.addEventListener('click', (e) => { document.querySelectorAll('.exagg-btn').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); const val = parseFloat(e.target.getAttribute('data-val')); updateTerrainExaggeration(val); }); }); document.getElementById('toggle-contours').addEventListener('change', (e) => { updateContours(); }); document.getElementById('toggle-shadows').addEventListener('change', (e) => { renderer.shadowMap.enabled = e.target.checked; terrainMesh.castShadow = e.target.checked; terrainMesh.receiveShadow = e.target.checked; ballMesh.castShadow = e.target.checked; dirLight.castShadow = e.target.checked; terrainMesh.material.needsUpdate = true; }); document.getElementById('toggle-lighting').addEventListener('change', (e) => { const active = e.target.checked; dirLight.intensity = active ? 1.2 : 0.0; fillLight.intensity = active ? 0.3 : 0.0; ambientLight.intensity = active ? 0.4 : 1.0; }); document.getElementById('toggle-path').addEventListener('change', (e) => { updatePuttPath(); }); document.getElementById('reset-cam').addEventListener('click', () => { camera.position.set(0, 18, 0.1); controls.target.set(0, 0, 0); controls.update(); }); // --- Window Resize --- window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // --- Animation Loop --- function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html>

More aggressive

terrain exploration

Grok

copied

copy

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Golf Green Topology Perception</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #FCF9F5; font-family: system-ui, -apple-system, sans-serif; } canvas { display: block; } #ui { position: absolute; top: 12px; left: 12px; background: rgba(20, 24, 18, 0.85); color: #FCF9F5; padding: 12px 14px; border-radius: 6px; font-size: 13px; line-height: 1.5; max-width: 220px; user-select: none; border: 1px solid rgba(255,255,255,0.08); } #ui h3 { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: #9aab8e; margin-bottom: 8px; } .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; } .row label { flex: 1; min-width: 70px; } button { background: #2a3226; color: #d8e0d0; border: 1px solid #3d4a36; border-radius: 4px; padding: 3px 8px; font-size: 12px; cursor: pointer; } button:hover { background: #364030; } button.active { background: #4a5c3e; border-color: #6a7e5a; color: #fff; } .toggle { width: 36px; height: 18px; background: #2a3226; border-radius: 9px; position: relative; cursor: pointer; border: 1px solid #3d4a36; flex-shrink: 0; } .toggle.on { background: #4a5c3e; border-color: #6a7e5a; } .toggle::after { content: ''; position: absolute; width: 12px; height: 12px; background: #c8d4bc; border-radius: 50%; top: 2px; left: 2px; transition: left 0.12s; } .toggle.on::after { left: 20px; } #hint { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); color: rgba(200, 210, 190, 0.55); font-size: 12px; pointer-events: none; } </style> </head> <body> <div id="ui"> <h3>Perception Controls</h3> <div class="row"> <label>Exaggeration</label> <button data-exag="1" class="active">1×</button> <button data-exag="3">3×</button> <button data-exag="5">5×</button> </div> <div class="row"> <label>Contours</label> <div class="toggle on" id="tog-contours"></div> </div> <div class="row"> <label>Shadows</label> <div class="toggle on" id="tog-shadows"></div> </div> <div class="row"> <label>Lighting</label> <div class="toggle on" id="tog-lighting"></div> </div> <div class="row"> <label>Putt path</label> <div class="toggle on" id="tog-path"></div> </div> <div class="row" style="margin-top:8px;"> <button id="btn-reset" style="width:100%;">Reset camera</button> </div> </div> <div id="hint">Drag to orbit · Scroll to zoom · Start near top-down</div> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // ───────────────────────────────────────────── // Config // ───────────────────────────────────────────── const SIZE = 12; // world units across green const SEGMENTS = 96; // resolution of terrain mesh const CONTOUR_LEVELS = 14; // number of isolines const BASE_HEIGHT_SCALE = 0.55; // subtle real-world scale let exaggeration = 1; let showContours = true; let showShadows = true; let showLighting = true; let showPath = true; // ───────────────────────────────────────────── // Height function – deliberate subtle topology // ───────────────────────────────────────────── // x,z in [-0.5, 0.5] function heightFn(x, z) { // Broad raised area (gentle mound, NW) const mound = Math.exp(-((x + 0.22) ** 2 + (z + 0.18) ** 2) / 0.085) * 0.72; // Depression (gentle bowl, SE) const bowl = -Math.exp(-((x - 0.25) ** 2 + (z - 0.22) ** 2) / 0.07) * 0.55; // Diagonal slope (SW → NE) const diag = (x + z) * 0.28; // Very soft secondary undulation const ripple = Math.sin(x * 4.2) * Math.cos(z * 3.6) * 0.06; // Edge falloff so the green sits cleanly const edge = 1 - Math.pow(Math.max(Math.abs(x), Math.abs(z)) * 1.65, 3.5); const clamped = Math.max(0, edge); return (mound + bowl + diag + ripple) * clamped; } // Analytical-ish gradient for normals / path function heightGrad(x, z, eps = 0.004) { const hx = (heightFn(x + eps, z) - heightFn(x - eps, z)) / (2 * eps); const hz = (heightFn(x, z + eps) - heightFn(x, z - eps)) / (2 * eps); return { hx, hz }; } // ───────────────────────────────────────────── // Scene setup // ───────────────────────────────────────────── const scene = new THREE.Scene(); scene.background = new THREE.Color(0xFCF9F5); scene.fog = new THREE.Fog(0x1a1f18, 18, 42); const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 100); // Start almost top-down – the key perceptual challenge camera.position.set(0.15, 11.5, 0.4); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 4; controls.maxDistance = 28; controls.maxPolarAngle = Math.PI * 0.48; // keep mostly overhead-ish controls.target.set(0, 0.1, 0); // ───────────────────────────────────────────── // Lights // ───────────────────────────────────────────── const ambient = new THREE.AmbientLight(0xFCF9F5, 0.35); scene.add(ambient); const hemi = new THREE.HemisphereLight(0xFCF9F5, 0xFCF9F5, 0.28); scene.add(hemi); const sun = new THREE.DirectionalLight(0xFCF9F5, 1.15); sun.position.set(6, 10, 4); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.near = 1; sun.shadow.camera.far = 30; sun.shadow.camera.left = -10; sun.shadow.camera.right = 10; sun.shadow.camera.top = 10; sun.shadow.camera.bottom = -10; sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.02; scene.add(sun); scene.add(sun.target); // ───────────────────────────────────────────── // Terrain mesh // ───────────────────────────────────────────── let terrainMesh; let basePositions; // Float32Array of original Y values (before exaggeration) function buildTerrain() { const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS); geo.rotateX(-Math.PI / 2); const pos = geo.attributes.position; basePositions = new Float32Array(pos.count); for (let i = 0; i < pos.count; i++) { const x = pos.getX(i) / SIZE; // normalize to ~[-0.5,0.5] const z = pos.getZ(i) / SIZE; const h = heightFn(x, z) * BASE_HEIGHT_SCALE; basePositions[i] = h; pos.setY(i, h * exaggeration); } geo.computeVertexNormals(); const mat = new THREE.MeshStandardMaterial({ color: 0x4a6b3a, roughness: 0.82, metalness: 0.02, flatShading: false, }); if (terrainMesh) { scene.remove(terrainMesh); terrainMesh.geometry.dispose(); terrainMesh.material.dispose(); } terrainMesh = new THREE.Mesh(geo, mat); terrainMesh.receiveShadow = true; terrainMesh.castShadow = true; scene.add(terrainMesh); } function applyExaggeration() { if (!terrainMesh) return; const pos = terrainMesh.geometry.attributes.position; for (let i = 0; i < pos.count; i++) { pos.setY(i, basePositions[i] * exaggeration); } pos.needsUpdate = true; terrainMesh.geometry.computeVertexNormals(); rebuildContours(); updateBallAndHole(); rebuildPath(); } // ───────────────────────────────────────────── // Contour lines (marching squares on height field) // ───────────────────────────────────────────── let contourGroup = new THREE.Group(); scene.add(contourGroup); function rebuildContours() { // clear while (contourGroup.children.length) { const c = contourGroup.children.pop(); c.geometry.dispose(); c.material.dispose(); } if (!showContours) return; const res = 72; // contour sample resolution const heights = new Float32Array((res + 1) * (res + 1)); let hMin = Infinity, hMax = -Infinity; for (let iz = 0; iz <= res; iz++) { for (let ix = 0; ix <= res; ix++) { const x = (ix / res - 0.5); const z = (iz / res - 0.5); const h = heightFn(x, z) * BASE_HEIGHT_SCALE * exaggeration; heights[iz * (res + 1) + ix] = h; if (h < hMin) hMin = h; if (h > hMax) hMax = h; } } // isolines const range = hMax - hMin; if (range < 1e-5) return; const levels = []; for (let i = 1; i < CONTOUR_LEVELS; i++) { levels.push(hMin + (range * i) / CONTOUR_LEVELS); } const mat = new THREE.LineBasicMaterial({ color: 0xFCF9F5, transparent: true, opacity: 0.72, depthWrite: false, }); for (const level of levels) { const segments = []; for (let iz = 0; iz < res; iz++) { for (let ix = 0; ix < res; ix++) { const i00 = iz * (res + 1) + ix; const i10 = i00 + 1; const i01 = (iz + 1) * (res + 1) + ix; const i11 = i01 + 1; const h00 = heights[i00], h10 = heights[i10]; const h01 = heights[i01], h11 = heights[i11]; // binary mask let code = 0; if (h00 >= level) code |= 1; if (h10 >= level) code |= 2; if (h11 >= level) code |= 4; if (h01 >= level) code |= 8; if (code === 0 || code === 15) continue; const x0 = (ix / res - 0.5) * SIZE; const z0 = (iz / res - 0.5) * SIZE; const cell = SIZE / res; const lerp = (a, b, t) => a + (b - a) * t; const edgePoint = (edge) => { // 0: bottom, 1: right, 2: top, 3: left let t, px, pz, py; switch (edge) { case 0: // bottom (00-10) t = (level - h00) / (h10 - h00 + 1e-9); px = x0 + t * cell; pz = z0; py = lerp(h00, h10, t); break; case 1: // right (10-11) t = (level - h10) / (h11 - h10 + 1e-9); px = x0 + cell; pz = z0 + t * cell; py = lerp(h10, h11, t); break; case 2: // top (01-11) t = (level - h01) / (h11 - h01 + 1e-9); px = x0 + t * cell; pz = z0 + cell; py = lerp(h01, h11, t); break; case 3: // left (00-01) t = (level - h00) / (h01 - h00 + 1e-9); px = x0; pz = z0 + t * cell; py = lerp(h00, h01, t); break; } return new THREE.Vector3(px, py + 0.012, pz); // slight lift to avoid z-fight }; // classic marching-squares edge pairs const table = { 1: [[3, 0]], 2: [[0, 1]], 3: [[3, 1]], 4: [[1, 2]], 5: [[3, 0], [1, 2]], 6: [[0, 2]], 7: [[3, 2]], 8: [[2, 3]], 9: [[0, 2]], 10: [[0, 1], [2, 3]], 11: [[1, 2]], 12: [[1, 3]], 13: [[0, 1]], 14: [[0, 3]] }; // fix ambiguous cases simply if (code === 5 || code === 10) { // choose one consistent pairing const mid = (h00 + h10 + h01 + h11) * 0.25; if (code === 5) { table[5] = mid >= level ? [[3, 2], [0, 1]] : [[3, 0], [1, 2]]; } else { table[10] = mid >= level ? [[0, 3], [1, 2]] : [[0, 1], [2, 3]]; } } const pairs = table[code] || []; for (const [eA, eB] of pairs) { segments.push(edgePoint(eA), edgePoint(eB)); } } } if (segments.length < 2) continue; const geo = new THREE.BufferGeometry().setFromPoints(segments); const line = new THREE.LineSegments(geo, mat.clone()); contourGroup.add(line); } } // ───────────────────────────────────────────── // Ball, hole, putt path // ───────────────────────────────────────────── const ball = new THREE.Mesh( new THREE.SphereGeometry(0.09, 16, 12), new THREE.MeshStandardMaterial({ color: 0xf0f0e8, roughness: 0.35, metalness: 0.05 }) ); ball.castShadow = true; scene.add(ball); const holeGroup = new THREE.Group(); const holeRim = new THREE.Mesh( new THREE.RingGeometry(0.11, 0.15, 24), new THREE.MeshStandardMaterial({ color: 0x1a1a18, roughness: 0.9, side: THREE.DoubleSide }) ); holeRim.rotation.x = -Math.PI / 2; holeGroup.add(holeRim); const holeCup = new THREE.Mesh( new THREE.CircleGeometry(0.11, 24), new THREE.MeshStandardMaterial({ color: 0x0a0a08, roughness: 1, side: THREE.DoubleSide }) ); holeCup.rotation.x = -Math.PI / 2; holeCup.position.y = -0.01; holeGroup.add(holeCup); scene.add(holeGroup); // Ball start & hole positions (normalized x,z) const ballXZ = { x: -0.32, z: 0.28 }; const holeXZ = { x: 0.18, z: -0.15 }; function surfaceY(nx, nz) { return heightFn(nx, nz) * BASE_HEIGHT_SCALE * exaggeration; } function updateBallAndHole() { const by = surfaceY(ballXZ.x, ballXZ.z); ball.position.set(ballXZ.x * SIZE, by + 0.09, ballXZ.z * SIZE); const hy = surfaceY(holeXZ.x, holeXZ.z); holeGroup.position.set(holeXZ.x * SIZE, hy + 0.002, holeXZ.z * SIZE); } let pathLine = null; function rebuildPath() { if (pathLine) { scene.remove(pathLine); pathLine.geometry.dispose(); pathLine.material.dispose(); pathLine = null; } if (!showPath) return; // Simple 2D curve that bends with the terrain gradient (not physics) const pts = []; const steps = 48; for (let i = 0; i <= steps; i++) { const t = i / steps; // lerp with a gentle arc influenced by slope let nx = THREE.MathUtils.lerp(ballXZ.x, holeXZ.x, t); let nz = THREE.MathUtils.lerp(ballXZ.z, holeXZ.z, t); // add a small lateral offset that follows the cross-slope const { hx, hz } = heightGrad(nx, nz); const cross = (hx * (holeXZ.z - ballXZ.z) - hz * (holeXZ.x - ballXZ.x)); const offset = Math.sin(t * Math.PI) * cross * 0.18; // perpendicular in xz const dx = holeXZ.x - ballXZ.x; const dz = holeXZ.z - ballXZ.z; const len = Math.hypot(dx, dz) || 1; nx += (-dz / len) * offset; nz += (dx / len) * offset; const y = surfaceY(nx, nz) + 0.025; pts.push(new THREE.Vector3(nx * SIZE, y, nz * SIZE)); } const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: 0xe8d48a, transparent: true, opacity: 0.85, depthWrite: false, }); pathLine = new THREE.Line(geo, mat); scene.add(pathLine); } // ───────────────────────────────────────────── // Ground plane (subtle surround) // ───────────────────────────────────────────── const ground = new THREE.Mesh( new THREE.CircleGeometry( ), new THREE.MeshStandardMaterial({ color: 0xFCF9F5, roughness: 0.95 }) ); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; ground.receiveShadow = true; scene.add(ground); // ───────────────────────────────────────────── // Init // ───────────────────────────────────────────── buildTerrain(); rebuildContours(); updateBallAndHole(); rebuildPath(); // ───────────────────────────────────────────── // UI wiring // ───────────────────────────────────────────── document.querySelectorAll('[data-exag]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('[data-exag]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); exaggeration = parseFloat(btn.dataset.exag); applyExaggeration(); }); }); function wireToggle(id, getter, setter) { const el = document.getElementById(id); el.addEventListener('click', () => { const next = !getter(); setter(next); el.classList.toggle('on', next); }); } wireToggle('tog-contours', () => showContours, v => { showContours = v; rebuildContours(); }); wireToggle('tog-shadows', () => showShadows, v => { showShadows = v; renderer.shadowMap.enabled = v; sun.castShadow = v; // force material update terrainMesh.material.needsUpdate = true; }); wireToggle('tog-lighting', () => showLighting, v => { showLighting = v; sun.intensity = v ? 1.15 : 0.15; ambient.intensity = v ? 0.35 : 0.55; hemi.intensity = v ? 0.28 : 0.15; }); wireToggle('tog-path', () => showPath, v => { showPath = v; rebuildPath(); }); document.getElementById('btn-reset').addEventListener('click', () => { camera.position.set(0.15, 11.5, 0.4); controls.target.set(0, 0.1, 0); controls.update(); }); // ───────────────────────────────────────────── // Resize & loop // ───────────────────────────────────────────── window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html>

Stronger shadows

and depth

Each prototype exposed a different useful mechanism. The next step was to combine them rather than choose one.

V2:

Combining the useful parts

I deliberately gave the same design brief to four different LLMs and let them independently interpret the problem.

I wasn't looking for a winner.

I wanted to see which implementation choices

produced useful perceptual cues.

  • procedural 3D terrain
  • terrain exaggeration
  • contour visualization
  • directional lighting
  • real-time shadows
  • sun elevation / azimuth
  • orbit / tilt / zoom
  • ball + hole + putting path

copied

copy

<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Reading the Ground — V2</title> <style> html,body{margin:0;height:100%;overflow:hidden;background:#e9e8e1;color:#20221f;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} #scene{position:fixed;inset:0} #title{position:fixed;top:18px;left:22px;z-index:5;pointer-events:none} #title h1{font-size:14px;line-height:1.1;margin:0;font-weight:600} #title p{font-size:10px;margin:5px 0 0;opacity:.62} #panel{position:fixed;right:18px;top:18px;z-index:5;width:225px;padding:12px 13px;border:1px solid rgba(30,35,30,.14);background:rgba(245,244,238,.9);backdrop-filter:blur(8px);border-radius:9px;font-size:11px} .row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:7px 0} .label{opacity:.72} button,input{font:inherit} button{border:1px solid rgba(30,35,30,.2);background:#f8f7f1;color:#222;border-radius:5px;padding:4px 6px;cursor:pointer} .seg{display:flex;gap:3px}.seg button{padding:3px 6px}.seg button.active{background:#242723;color:#fff;border-color:#242723} input[type=range]{width:105px} #hint{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);font-size:10px;opacity:.48;z-index:4;pointer-events:none} @media(max-width:600px){#panel{width:190px;right:9px;top:9px}#title{left:12px;top:12px}} </style> </head> <body> <div id="scene"></div> <div id="title"><h1>Reading the Ground</h1><p>An experiment in making subtle terrain perceptible.</p></div> <div id="panel"> <div class="row"><span class="label">Terrain</span><div class="seg" id="terrainBtns"><button data-v="1" class="active">1×</button><button data-v="2">2×</button><button data-v="3">3×</button><button data-v="5">5×</button></div></div> <div class="row"><span class="label">Contours</span><button id="contours">ON</button></div> <div class="row"><span class="label">Shadows</span><button id="shadows">ON</button></div> <div class="row"><span class="label">Sun elevation</span><input id="elev" type="range" min="15" max="85" value="48"></div> <div class="row"><span class="label">Sun azimuth</span><input id="az" type="range" min="0" max="360" value="125"></div> <div class="row"><span class="label">Reveal terrain</span><button id="reveal">REVEAL</button></div> <div class="row"><span class="label">Camera</span><button id="reset">RESET</button></div> </div> <div id="hint">Orbit · tilt · zoom</div> <script type="importmap"> {"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}} </script> <script type="module"> import * as THREE from 'three'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; const scene=new THREE.Scene(); scene.background=new THREE.Color(0xe9e8e1); const camera=new THREE.PerspectiveCamera(42,innerWidth/innerHeight,.1,200); const defaultCamera=new THREE.Vector3(0,20.5,.25); camera.position.copy(defaultCamera); const renderer=new THREE.WebGLRenderer({antialias:true}); renderer.setPixelRatio(Math.min(devicePixelRatio,2)); renderer.setSize(innerWidth,innerHeight); renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap; renderer.outputColorSpace=THREE.SRGBColorSpace; document.querySelector('#scene').appendChild(renderer.domElement); const controls=new OrbitControls(camera,renderer.domElement); controls.target.set(0,0,0); controls.enableDamping=true; controls.dampingFactor=.055; controls.minDistance=8; controls.maxDistance=38; controls.maxPolarAngle=Math.PI*.49; controls.minPolarAngle=.10; // One coherent, golf-green-like elevation field: const W=22,D=15,NX=150,NZ=105; function rawHeight(x,z){ const broadRise=.42*Math.exp(-(((x+3)/5.8)**2+((z-1)/4.4)**2)); const shallowDip=-.27*Math.exp(-(((x-4)/3.2)**2+((z+2.8)/2.7)**2)); const diagonal=.23*Math.tanh((x+.8*z-.5)/3.6); const shoulder=.10*Math.exp(-(((x-1.8)/6)**2+((z+4)/2)**2)); return broadRise+shallowDip+diagonal+shoulder; } let exaggeration=1; const terrainGeo=new THREE.PlaneGeometry(W,D,NX-1,NZ-1); terrainGeo.rotateX(-Math.PI/2); const pos=terrainGeo.attributes.position, baseHeights=[]; for(let i=0;i<pos.count;i++)baseHeights.push(rawHeight(pos.getX(i),pos.getZ(i))); function applyHeights(mult){ for(let i=0;i<pos.count;i++)pos.setY(i,baseHeights[i]*mult); pos.needsUpdate=true; terrainGeo.computeVertexNormals(); } const terrainMat=new THREE.MeshStandardMaterial({color:0x667b61,roughness:.94,metalness:0,side:THREE.DoubleSide}); const terrain=new THREE.Mesh(terrainGeo,terrainMat); terrain.receiveShadow=true; terrain.castShadow=true; scene.add(terrain); const apron=new THREE.Mesh(new THREE.PlaneGeometry(27,19),new THREE.MeshStandardMaterial({color:0xbfc2ad,roughness:1})); apron.rotation.x=-Math.PI/2; apron.receiveShadow=true; scene.add(apron); function updateApronY(){ terrainGeo.computeBoundingBox(); const lowestTerrainY=terrainGeo.boundingBox.min.y; const clearance=Math.max(0.08,Math.abs(lowestTerrainY)*0.02); apron.position.y=lowestTerrainY-clearance; } updateApronY(); // Golf context: cup, flag, ball. const cup=new THREE.Mesh(new THREE.CylinderGeometry(.28,.28,.035,32),new THREE.MeshStandardMaterial({color:0x171917,roughness:1})); scene.add(cup); const flag=new THREE.Mesh(new THREE.CylinderGeometry(.018,.018,1.35,10),new THREE.MeshStandardMaterial({color:0x242724,roughness:1})); scene.add(flag); const ball=new THREE.Mesh(new THREE.SphereGeometry(.19,24,16),new THREE.MeshStandardMaterial({color:0xf7f7f1,roughness:.52})); ball.castShadow=true; scene.add(ball); // Minimalist Low-Poly Golfer & Putter Stick const golferGroup = new THREE.Group(); const bodyMat = new THREE.MeshStandardMaterial({ color: 0x242724, roughness: 0.8 }); const skinMat = new THREE.MeshStandardMaterial({ color: 0xd4a373, roughness: 0.8 }); const torso = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 0.7, 8), bodyMat); torso.position.y = 0.55; torso.rotation.z = 0.2; golferGroup.add(torso); const head = new THREE.Mesh(new THREE.SphereGeometry(0.12, 12, 12), skinMat); head.position.set(0.1, 0.95, 0); golferGroup.add(head); const putterMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.8, roughness: 0.2 }); const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 1.4, 6), putterMat); shaft.position.set(0.2, 0.6, 0.2); shaft.rotation.z = -0.4; shaft.rotation.x = 0.3; golferGroup.add(shaft); const clubHead = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.03, 0.2), putterMat); clubHead.position.set(0.02, -0.05, 0.45); shaft.add(clubHead); golferGroup.castShadow = true; scene.add(golferGroup); const pathMat=new THREE.LineBasicMaterial({color:0xf1eee1,transparent:true,opacity:.78}); const pathGeo=new THREE.BufferGeometry(),pathLine=new THREE.Line(pathGeo,pathMat); scene.add(pathLine); function updateGolf(mult){ const bx=-5.6,bz=4.1,gx=5.2,gz=-2.6; ball.position.set(bx,rawHeight(bx,bz)*mult+.19,bz); cup.position.set(gx,rawHeight(gx,gz)*mult+.012,gz); flag.position.set(gx,cup.position.y+.68,gz); // Position and orient the golfer near the ball const gxPos = bx - 0.55; const gzPos = bz + 0.35; golferGroup.position.set(gxPos, rawHeight(gxPos, gzPos)*mult, gzPos); golferGroup.rotation.y = Math.atan2(gx - gxPos, gz - gzPos); const pts=[]; for(let i=0;i<=80;i++){ const t=i/80; let x=bx+(gx-bx)*t,z=bz+(gz-bz)*t; const sx=(rawHeight(x+.12,z)-rawHeight(x-.12,z))/.24; const sz=(rawHeight(x,z+.12)-rawHeight(x,z-.12))/.24; x+=(sx-sz)*.28*Math.sin(Math.PI*t); pts.push(new THREE.Vector3(x,rawHeight(x,z)*mult+.025,z)); } pathGeo.setFromPoints(pts); } applyHeights(1); updateGolf(1); // Marching-squares contours const contourGroup=new THREE.Group(); scene.add(contourGroup); function clearContours(){while(contourGroup.children.length)contourGroup.remove(contourGroup.children[0])} function interp(x1,z1,h1,x2,z2,h2,level){ const t=(level-h1)/(h2-h1); return new THREE.Vector3(THREE.MathUtils.lerp(x1,x2,t),level*exaggeration+.035,THREE.MathUtils.lerp(z1,z2,t)); } function buildContours(){ clearContours(); for(let level=-.25;level<=.51;level+=.10){ const pts=[]; for(let ix=0;ix<NX-1;ix++)for(let iz=0;iz<NZ-1;iz++){ const x0=-W/2+W*ix/(NX-1),x1=-W/2+W*(ix+1)/(NX-1); const z0=-D/2+D*iz/(NZ-1),z1=-D/2+D*(iz+1)/(NZ-1); const a=rawHeight(x0,z0),b=rawHeight(x1,z0),c=rawHeight(x1,z1),d=rawHeight(x0,z1),e=[]; if((a<level)!=(b<level))e.push(interp(x0,z0,a,x1,z0,b,level)); if((b<level)!=(c<level))e.push(interp(x1,z0,b,x1,z1,c,level)); if((c<level)!=(d<level))e.push(interp(x1,z1,c,x0,z1,d,level)); if((d<level)!=(a<level))e.push(interp(x0,z1,d,x0,z0,a,level)); if(e.length===2)pts.push(e[0],e[1]); else if(e.length===4)pts.push(e[0],e[1],e[2],e[3]); } contourGroup.add(new THREE.LineSegments( new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({color:0x263226,transparent:true,opacity:.32}) )); } } buildContours(); // Directional light & shadows scene.add(new THREE.HemisphereLight(0xe8eadf,0x777b70,.72)); const sun=new THREE.DirectionalLight(0xfff9e8,2); sun.castShadow=true; sun.shadow.mapSize.set(2048,2048); sun.shadow.camera.left=-16;sun.shadow.camera.right=16;sun.shadow.camera.top=12;sun.shadow.camera.bottom=-12; sun.shadow.camera.near=1;sun.shadow.camera.far=45;scene.add(sun); function updateSun(){ const elev=THREE.MathUtils.degToRad(+document.querySelector('#elev').value); const az=THREE.MathUtils.degToRad(+document.querySelector('#az').value),r=24; sun.position.set(r*Math.cos(elev)*Math.sin(az),r*Math.sin(elev),r*Math.cos(elev)*Math.cos(az)); sun.target.position.set(0,0,0);scene.add(sun.target); } updateSun(); let contourOn=true,shadowOn=true; function setTerrain(v){exaggeration=v;applyHeights(v);updateApronY();updateGolf(v);buildContours()} document.querySelectorAll('#terrainBtns button').forEach(b=>b.onclick=()=>{ document.querySelectorAll('#terrainBtns button').forEach(x=>x.classList.remove('active')); b.classList.add('active');setTerrain(+b.dataset.v); }); document.querySelector('#contours').onclick=e=>{contourOn=!contourOn;contourGroup.visible=contourOn;e.currentTarget.textContent=contourOn?'ON':'OFF'}; document.querySelector('#shadows').onclick=e=>{ shadowOn=!shadowOn;terrain.castShadow=shadowOn;terrain.receiveShadow=shadowOn;ball.castShadow=shadowOn;sun.castShadow=shadowOn; e.currentTarget.textContent=shadowOn?'ON':'OFF'; }; document.querySelector('#elev').oninput=updateSun;document.querySelector('#az').oninput=updateSun; document.querySelector('#reset').onclick=()=>{camera.position.copy(defaultCamera);controls.target.set(0,0,0);controls.update()}; let revealTarget=0,revealCurrent=0; document.querySelector('#reveal').onclick=()=>{ revealTarget=revealTarget?0:1; document.querySelector('#reveal').textContent=revealTarget?'NORMAL':'REVEAL'; }; const clock=new THREE.Clock(); function animate(){ requestAnimationFrame(animate); const dt=Math.min(clock.getDelta(),.05); revealCurrent=THREE.MathUtils.damp(revealCurrent,revealTarget,4.8,dt); const effective=1+4*revealCurrent; if(Math.abs(effective-exaggeration)>.01 && revealTarget){ exaggeration=effective;applyHeights(effective);updateApronY();updateGolf(effective);buildContours(); }else if(!revealTarget && revealCurrent>.002 && revealCurrent<.998){ exaggeration=effective;applyHeights(effective);updateApronY();updateGolf(effective);buildContours(); } sun.intensity=2+.65*revealCurrent; controls.update();renderer.render(scene,camera); } animate(); addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight)}); </script> </body> </html>

What actually made the terrain readable?

What actually made the terrain readable?

V2.1

Refinement

At higher exaggeration levels, the supporting base could intersect the terrain. I corrected the scene so the base dynamically remains beneath the lowest terrain point across exaggeration states.

Then I also added a Golfer for better understanding of the terrain.

Prototype

Try it yourself!

Move the camera.

Change the terrain exaggeration.

Move the sun.

Toggle shadows and contours.

Watch how the perception of the

same terrain changes.

What I learned

01

Depth doesn't come from one cue.

It emerges from multiple perceptual signals working together.

02

Lighting and shadow carried more perceptual weight than I expected.

They exposed surface orientation and depth more effectively than contours alone.

03

Exaggeration can bridge the gap between physical truth and perceptual truth.

The underlying terrain stayed the same; only its vertical expression changed.

04

Motion changes the problem completely.

Once the camera moves, the user isn't just interpreting an image anymore. They're experiencing the geometry.

The prototype started as an attempt to make a golf green readable. It became an experiment in how interfaces can help people perceive information that is physically present but visually difficult to see.

Design isn't about Aesthetics, it's about Storytelling, evoking emotions and driving actions