// The lake, drawn with three.js. Almost all of the look lives in one // fragment shader on a single plane: ice, snow, cracks, thin spots, open // water, drill holes, sonar pings and the fog of what the agent hasn't // learned yet. Everything else is a few low-poly props. import { WebGLRenderer, Scene, PerspectiveCamera, PlaneGeometry, Mesh, ShaderMaterial, DataTexture, RGBAFormat, UnsignedByteType, LinearFilter, Vector2, Vector3, Color, SphereGeometry, BackSide, ConeGeometry, CylinderGeometry, BoxGeometry, InstancedMesh, Object3D, MeshLambertMaterial, MeshBasicMaterial, HemisphereLight, DirectionalLight, Group, Fog, BufferGeometry, Float32BufferAttribute, Points, AdditiveBlending, CanvasTexture, SpriteMaterial, Sprite, PointLight, MathUtils, SRGBColorSpace, Vector4, } from "three"; import { COLS, ROWS } from "./world.js"; const ACCENT = new Color("#ff4d00"); // Cell (x, y) to world space. North is away from the camera (-z). export const cellToWorld = (x, y) => new Vector3(x - (COLS - 1) / 2, 0, (ROWS - 1) / 2 - y); const NOISE = /* glsl */ ` float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); } float rstep(float a, float b, float x) { return 1.0 - smoothstep(b, a, x); } // falls from 1 at a to 0 at b float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + 17.1; a *= 0.5; } return v; } // Distance to the nearest Voronoi edge, for the cracks in the ice. float cracks(vec2 p) { vec2 n = floor(p), f = fract(p); vec2 mg, mr; float md = 8.0; for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) { vec2 g = vec2(float(i), float(j)); vec2 o = vec2(hash(n + g), hash(n + g + 7.7)); vec2 r = g + o - f; float d = dot(r, r); if (d < md) { md = d; mr = r; mg = g; } } md = 8.0; for (int j = -2; j <= 2; j++) for (int i = -2; i <= 2; i++) { vec2 g = mg + vec2(float(i), float(j)); vec2 o = vec2(hash(n + g), hash(n + g + 7.7)); vec2 r = g + o - f; if (dot(mr - r, mr - r) > 0.00001) md = min(md, dot(0.5 * (mr + r), normalize(r - mr))); } return md; } vec3 skyColor(vec3 d, vec3 sun) { float h = clamp(d.y, -0.2, 1.0); vec3 top = vec3(0.008, 0.012, 0.03), mid = vec3(0.04, 0.06, 0.11), low = vec3(0.32, 0.15, 0.08); vec3 c = mix(low, mid, smoothstep(-0.02, 0.18, h)); c = mix(c, top, smoothstep(0.18, 0.7, h)); float s = max(dot(normalize(d), sun), 0.0); c += vec3(1.0, 0.36, 0.08) * (pow(s, 6.0) * 0.55 + pow(s, 60.0) * 0.9) * smoothstep(-0.1, 0.12, h); return c; } `; const ICE_VERT = /* glsl */ ` varying vec3 vWorld; void main() { vec4 w = modelMatrix * vec4(position, 1.0); vWorld = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; } `; const ICE_FRAG = /* glsl */ ` uniform sampler2D uData; // r: ice thickness / 10in, g: what the agent knows, b: drilled, a: sled tracks uniform float uTime, uFog, uTruth, uLite; uniform vec2 uSled, uHover; uniform vec4 uPing, uFlash, uLook; // xy: world position, z: start time, w: kind uniform vec3 uSun; varying vec3 vWorld; ${NOISE} float sdRoundBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; } void main() { vec2 p = vWorld.xz; vec2 grid = vec2(${COLS.toFixed(1)}, ${ROWS.toFixed(1)}); vec2 g = vec2(p.x + grid.x * 0.5, grid.y * 0.5 - p.y); // grid units, y grows north vec2 cell = floor(g); bool onGrid = all(greaterThanEqual(cell, vec2(0.0))) && all(lessThan(cell, grid)); vec4 here = texture2D(uData, (cell + 0.5) / grid); // this cell, exactly vec2 warp = (vec2(fbm(p * 1.3 + 2.0), fbm(p * 1.3 + 9.0)) - 0.5) * 1.1 + (vec2(noise(p * 4.0), noise(p * 4.0 + 5.0)) - 0.5) * 0.35; vec4 soft = texture2D(uData, clamp(g + warp, vec2(0.5), grid - 0.5) / grid); // blended with neighbors, edges roughened float shoreN = fbm(p * 0.55 + 3.0); float lake = sdRoundBox(p, grid * 0.5 + vec2(0.75, 0.6), 1.6) + (shoreN - 0.5) * 0.9; vec3 V = normalize(cameraPosition - vWorld); // ---- snowy shore ---- float drift = fbm(p * 0.9 + 11.0); vec3 shore = mix(vec3(0.16, 0.19, 0.25), vec3(0.34, 0.38, 0.46), drift); shore += vec3(0.5, 0.56, 0.66) * pow(fbm(p * vec2(0.7, 2.4) + 5.0), 3.0) * 0.5; // wind-packed drifts shore += vec3(1.0, 0.45, 0.18) * 0.07 * smoothstep(-6.0, 6.0, -p.x - p.y * 0.6); // ---- ice ---- float n1 = fbm(p * 1.6), n2 = fbm(p * 5.0 + 4.0); float th = onGrid ? soft.r + (n1 - 0.5) * 0.16 : 0.62; vec3 deep = vec3(0.006, 0.02, 0.032), clear = vec3(0.025, 0.085, 0.12), milky = vec3(0.12, 0.2, 0.26); vec3 ice = mix(deep, clear, smoothstep(0.0, 0.55, th)); ice = mix(ice, milky, smoothstep(0.5, 1.0, th) * 0.75 * smoothstep(0.3, 0.8, n1)); // Trapped bubbles: tiny bright specks frozen in the clear ice. vec2 bc = floor(p * 26.0); float bh = hash(bc); ice += vec3(0.35, 0.5, 0.6) * step(0.985, bh) * rstep(0.32, 0.1, length(fract(p * 26.0) - 0.5)) * (1.0 - smoothstep(0.6, 1.0, th) * 0.5); // Pressure cracks: long bright seams in some places, fine crazing in others. float crMask = smoothstep(0.42, 0.7, fbm(p * 0.45 + 21.0)); float cr1 = cracks(p * 0.85 + n1 * 0.9); float cr2 = cracks(p * 2.6 + n2 * 0.5 + 4.0); float crack = rstep(0.03, 0.0, cr1) * (0.45 + 0.55 * crMask) + rstep(0.018, 0.0, cr2) * crMask * 0.45; ice += vec3(0.5, 0.75, 0.9) * crack * 0.22 * smoothstep(0.25, 0.5, th); ice += vec3(0.05, 0.12, 0.16) * rstep(0.12, 0.0, cr1) * 0.5 * smoothstep(0.25, 0.5, th); // glow beside the seam // Snow: wind-streaked drifts with crisp edges. vec2 sp = vec2(p.x * 0.55 + p.y * 0.2, p.y * 1.9 - p.x * 0.3); float snowN = fbm(sp + 7.3) + (n2 - 0.5) * 0.12; float snowCover = smoothstep(0.56, 0.6, snowN) * smoothstep(0.38, 0.6, th); float snowSoft = smoothstep(0.5, 0.6, snowN) * smoothstep(0.38, 0.6, th) * 0.35; vec3 snow = mix(vec3(0.3, 0.35, 0.43), vec3(0.62, 0.67, 0.75), smoothstep(0.3, 0.8, fbm(p * 3.2 + 1.0))); snow += vec3(1.0, 0.55, 0.3) * 0.06 * smoothstep(0.6, 0.9, snowN); // sunset catching the drift tops ice = mix(ice, snow, max(snowCover * 0.92, snowSoft)); // Glints: snow crystals that sparkle as the view shifts. vec2 gc = floor(p * 60.0); float gh = hash(gc + 3.1); float glint = step(0.993, gh) * pow(0.5 + 0.5 * sin(uTime * (1.5 + gh * 3.0) + gh * 60.0 + dot(cameraPosition.xz, vec2(3.0, 2.0))), 8.0); ice += vec3(0.9, 0.95, 1.0) * glint * max(snowCover, 0.25) * 0.8; // Thin ice: gray and slushy, with a faint warning glow in the truth view. float thin = 1.0 - smoothstep(0.32, 0.42, th + (n1 - 0.5) * 0.22); vec3 slush = vec3(0.035, 0.045, 0.05) + 0.03 * n2; ice = mix(ice, slush, thin * 0.8 * uTruth); float thinEdge = thin * (1.0 - thin) * 4.0; ice += vec3(1.0, 0.28, 0.04) * uTruth * (thin * 0.035 + thinEdge * 0.06) * (0.65 + 0.35 * sin(uTime * 1.6 + p.x * 1.3)); // Open water: black, moving, holding the sunset. float water = (1.0 - smoothstep(0.02, 0.07, th)) * (onGrid ? 1.0 : 0.0); vec2 wob = vec2(fbm(p * 3.0 + uTime * 0.25), fbm(p * 3.0 - uTime * 0.2)) - 0.5; vec3 Nw = normalize(vec3(wob.x * 0.25, 1.0, wob.y * 0.25)); vec3 waterCol = vec3(0.004, 0.01, 0.016) + skyColor(reflect(-V, Nw), uSun) * 0.55; ice = mix(ice, waterCol, water); // Reflections: glossy where the ice is bare. vec3 N = normalize(vec3((n1 - 0.5) * 0.08, 1.0, (n2 - 0.5) * 0.08)); vec3 R = reflect(-V, N); float fres = 0.04 + 0.96 * pow(1.0 - max(dot(V, N), 0.0), 5.0); float gloss = (1.0 - snowCover) * (1.0 - water); ice += skyColor(R, uSun) * fres * gloss * 0.9; ice += vec3(1.0, 0.5, 0.2) * pow(max(dot(R, uSun), 0.0), 140.0) * gloss * 1.4; // Faint survey grid. vec2 gl = abs(fract(g) - 0.5); float line = onGrid ? (1.0 - smoothstep(0.47, 0.497, max(gl.x, gl.y))) : 1.0; ice = mix(ice + vec3(0.035, 0.055, 0.07), ice, line); // Sled tracks. float tracks = onGrid ? soft.a : 0.0; float rails = rstep(0.08, 0.0, abs(abs(fract(g.x + 0.5) - 0.5) - 0.11)) + rstep(0.08, 0.0, abs(abs(fract(g.y + 0.5) - 0.5) - 0.11)); ice = mix(ice, ice * 0.72 + vec3(0.02, 0.03, 0.04), clamp(tracks * rails * 0.6, 0.0, 0.5)); // Drill holes: a dark bore with a bright rim of shaved ice. if (onGrid && here.b > 0.5) { float d = length(fract(g) - vec2(0.5, 0.42)); ice = mix(ice, vec3(0.0, 0.006, 0.01) + skyColor(R, uSun) * 0.15, rstep(0.075, 0.06, d)); ice += vec3(0.75, 0.86, 0.95) * rstep(0.02, 0.0, abs(d - 0.085)) * 0.55; ice += vec3(0.6, 0.7, 0.8) * rstep(0.2, 0.09, d) * step(0.6, hash(floor((fract(g) - 0.5) * 40.0) + cell)) * 0.2; } // Lantern light around the sled. float ld = length(p - uSled); ice += vec3(1.0, 0.42, 0.12) * exp(-ld * ld * 1.6) * 0.42; // Hovered cell outline. if (onGrid && cell == uHover) ice += vec3(0.5, 0.8, 1.0) * (smoothstep(0.43, 0.49, max(gl.x, gl.y)) * 0.8 + 0.05); // Sonar ping: rings racing out from the sled. float pt = uTime - uPing.z; if (pt > 0.0 && pt < 2.6) { float pd = length(p - uPing.xy); for (int i = 0; i < 3; i++) { float r = (pt - float(i) * 0.22) * 2.4; if (r > 0.0) ice += vec3(0.35, 0.85, 1.0) * exp(-pow((pd - r) * 7.0, 2.0)) * (1.0 - pt / 2.6) * 0.8; } } // Look around: a soft lit ring over the eight neighbors. float lt = uTime - uLook.z; if (lt > 0.0 && lt < 1.6) { float d = max(abs(p.x - uLook.x), abs(p.y - uLook.y)); ice += vec3(0.45, 0.75, 1.0) * rstep(1.55, 1.2, d) * smoothstep(0.35, 0.6, d) * sin(lt / 1.6 * 3.14159) * 0.22; } // Flash: orange for a refused step, ice blue for a drill. float ft = uTime - uFlash.z; if (ft > 0.0 && ft < 1.4) { float d = max(abs(p.x - uFlash.x), abs(p.y - uFlash.y)); vec3 fc = uFlash.w > 0.5 ? vec3(1.0, 0.3, 0.0) : vec3(0.55, 0.85, 1.0); float box = rstep(0.5, 0.44, d); float edge = rstep(0.03, 0.0, abs(d - 0.47)); ice += fc * (box * 0.25 + edge * 0.9) * exp(-ft * 2.4); } // The model's eye: everything it hasn't learned about is fog. // Rolling mist, soft at the edges of the grid and thinning where the agent has looked. float known = soft.g; vec2 inside = min(g, grid - g); float gridMask = smoothstep(-0.35, 0.3, min(inside.x, inside.y) + (fbm(p * 1.7 + uTime * 0.08) - 0.5) * 0.6); float m1 = fbm(p * 0.7 + vec2(uTime * 0.06, uTime * 0.025)); float m2 = fbm(p * 2.1 - vec2(uTime * 0.11, -uTime * 0.04) + m1 * 1.5); vec3 fogCol = mix(vec3(0.045, 0.055, 0.075), vec3(0.17, 0.19, 0.23), smoothstep(0.25, 0.85, m1 * 0.6 + m2 * 0.5)); fogCol += vec3(1.0, 0.5, 0.25) * 0.025 * smoothstep(0.5, 0.9, m2); // the sunset caught in the mist float fogAmt = uFog * gridMask * (1.0 - smoothstep(0.15, 0.95, known + (m2 - 0.5) * 0.25)); ice = mix(ice, fogCol, fogAmt * 0.93); // Known only by sight (look_around), not measured: a lighter veil. float seenOnly = uFog * gridMask * smoothstep(0.2, 0.5, known) * (1.0 - smoothstep(0.6, 0.95, known)); ice = mix(ice, fogCol, seenOnly * 0.45); // Blend ice into the shore with a pale rim of shelf ice. float edgeMix = smoothstep(-0.05, 0.25, lake); vec3 col = mix(ice, shore, edgeMix); col += vec3(0.6, 0.7, 0.8) * rstep(0.12, 0.0, abs(lake - 0.08)) * 0.12; // Distance haze toward the far shore. vec3 viewDir = normalize(vWorld - cameraPosition); vec3 hz = mix(vec3(0.012, 0.016, 0.024), skyColor(normalize(vec3(viewDir.x, 0.0, viewDir.z)), uSun) * 0.45, 0.35); // same tone as the far treeline float far = smoothstep(10.0, 40.0, length(vWorld - cameraPosition)); col = mix(col, hz, far); col += (hash(gl_FragCoord.xy + fract(uTime) * 91.0) - 0.5) / 255.0; // dither against banding gl_FragColor = vec4(col, 1.0); } `; const SKY_FRAG = /* glsl */ ` uniform vec3 uSun; uniform float uTime; varying vec3 vWorld; ${NOISE} void main() { vec3 d = normalize(vWorld - cameraPosition); vec3 c = skyColor(d, uSun); vec2 sp = d.xz / max(d.y, 0.05) * 18.0; float star = step(0.9965, hash(floor(sp))) * smoothstep(0.08, 0.5, d.y) * (0.6 + 0.4 * sin(uTime * 2.0 + hash(floor(sp)) * 40.0)); c += vec3(0.8, 0.85, 1.0) * star * 0.7; // A far treeline: a soft ridge with pine spikes along it. float az = atan(d.x, -d.z); float ridge = 0.022 + 0.03 * fbm(vec2(az * 2.2, 1.0)); float k = fract(az * 70.0), id = hash(vec2(floor(az * 70.0), 4.0)); ridge += (0.012 + 0.02 * id) * max(0.0, 1.0 - abs(k - 0.5) * 2.6) * step(0.25, id); vec3 land = mix(vec3(0.012, 0.016, 0.024), c * 0.45, 0.35); c = mix(c, land, 1.0 - smoothstep(ridge - 0.002, ridge + 0.002, d.y)); gl_FragColor = vec4(c, 1.0); } `; function glowTexture(rgb) { const c = document.createElement("canvas"); c.width = c.height = 64; const g = c.getContext("2d"); const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, `rgba(${rgb},1)`); grad.addColorStop(0.25, `rgba(${rgb},0.45)`); grad.addColorStop(1, `rgba(${rgb},0)`); g.fillStyle = grad; g.fillRect(0, 0, 64, 64); const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace; return t; } export function webglAvailable() { try { const c = document.createElement("canvas"); return !!(c.getContext("webgl2") || c.getContext("webgl")); } catch { return false; } } export class LakeScene { constructor(container, { reducedMotion = false, lite = false } = {}) { this.container = container; this.reducedMotion = reducedMotion; this.lite = lite; this.clock = 0; this.anims = []; this.visible = true; const renderer = new WebGLRenderer({ antialias: window.devicePixelRatio < 2, powerPreference: "high-performance" }); this.maxDpr = lite ? 1 : Math.min(window.devicePixelRatio || 1, 1.75); renderer.setPixelRatio(this.maxDpr); renderer.setClearColor(0x050608); renderer.domElement.className = "lake-canvas"; container.prepend(renderer.domElement); this.renderer = renderer; const scene = new Scene(); scene.fog = new Fog(0x0b0c10, 16, 46); this.scene = scene; this.camera = new PerspectiveCamera(42, 1, 0.1, 160); const sun = new Vector3(-0.72, 0.1, -0.69).normalize(); // Sky dome. const sky = new Mesh(new SphereGeometry(80, 48, 24), new ShaderMaterial({ side: BackSide, depthWrite: false, fog: false, uniforms: { uSun: { value: sun }, uTime: { value: 0 } }, vertexShader: ICE_VERT, fragmentShader: SKY_FRAG, })); scene.add(sky); this.sky = sky; // Lake data: one texel per cell. this.data = new Uint8Array(COLS * ROWS * 4); this.tex = new DataTexture(this.data, COLS, ROWS, RGBAFormat, UnsignedByteType); this.tex.magFilter = LinearFilter; this.tex.minFilter = LinearFilter; this.tex.needsUpdate = true; this.uniforms = { uData: { value: this.tex }, uTime: { value: 0 }, uFog: { value: 0 }, uTruth: { value: 1 }, uLite: { value: lite ? 1 : 0 }, uSled: { value: new Vector2() }, uHover: { value: new Vector2(-9, -9) }, uPing: { value: new Vector4() }, uFlash: { value: new Vector4() }, uLook: { value: new Vector4() }, uSun: { value: sun }, }; for (const k of ["uPing", "uFlash", "uLook"]) this.uniforms[k].value.z = -99; const ice = new Mesh(new PlaneGeometry(120, 90, 1, 1), new ShaderMaterial({ uniforms: this.uniforms, vertexShader: ICE_VERT, fragmentShader: ICE_FRAG })); ice.rotation.x = -Math.PI / 2; scene.add(ice); this.ice = ice; scene.add(new HemisphereLight(0x33405a, 0x0a0a0c, 1.4)); const key = new DirectionalLight(0xff8a4a, 1.6); key.position.copy(sun).multiplyScalar(20).add(new Vector3(0, 4, 0)); scene.add(key); this.buildTrees(); this.buildSled(); this.buildShanty(); if (!lite) this.buildSnow(); this.labels = document.createElement("div"); this.labels.className = "lake-labels"; container.appendChild(this.labels); this.cellLabels = new Map(); this.axisLabels = []; for (let x = 0; x < COLS; x++) this.axisLabels.push(this.addAxis("ABCDEFGHI"[x], cellToWorld(x, -0.78))); for (let y = 0; y < ROWS; y++) this.axisLabels.push(this.addAxis(String(y + 1), cellToWorld(-0.85, y))); this.pointer = new Vector2(); this.parallax = new Vector2(); container.addEventListener("pointermove", (e) => { const r = container.getBoundingClientRect(); this.pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, ((e.clientY - r.top) / r.height) * 2 - 1); }); container.addEventListener("pointerleave", () => this.pointer.set(0, 0)); this.resize = this.resize.bind(this); new ResizeObserver(this.resize).observe(container); this.resize(); new IntersectionObserver(([e]) => { this.visible = e.isIntersecting; }).observe(container); this.frameTimes = []; this.last = performance.now(); renderer.setAnimationLoop(() => this.frame()); } addAxis(text, pos) { const el = document.createElement("span"); el.className = "axis"; el.textContent = text; this.labels.appendChild(el); return { el, pos }; } buildTrees() { const geo = mergeTree(); const mat = new MeshLambertMaterial({ color: 0x16211d, flatShading: true }); const count = this.lite ? 70 : 150; const trees = new InstancedMesh(geo, mat, count); const o = new Object3D(); let i = 0, tries = 0; let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); while (i < count && tries++ < 5000) { const a = rnd() * Math.PI * 2; const rx = 8.4 + rnd() * 7, rz = 6.6 + rnd() * 7; const x = Math.cos(a) * rx, z = Math.sin(a) * rz; if (z > 1.5) continue; // trees only on the far shore and the sides, so the lake stays clear if (Math.abs(x) < 1.6 && z > 3.6) continue; // the public access o.position.set(x, 0, z); const s = 0.45 + rnd() * 0.65; o.scale.set(s, s * (0.9 + rnd() * 0.6), s); o.rotation.y = rnd() * 6; o.updateMatrix(); trees.setMatrixAt(i++, o.matrix); } trees.count = i; this.scene.add(trees); // Rows of forest behind the shore, fading into the evening haze. const farCount = this.lite ? 140 : 320; const far = new InstancedMesh(geo, new MeshLambertMaterial({ color: 0x131c1a, flatShading: true }), farCount); for (let j = 0; j < farCount; j++) { const z = -9 - Math.pow(rnd(), 0.7) * 26; const x = (rnd() - 0.5) * (40 + -z * 1.6); o.position.set(x, 0, z); const s = 0.8 + rnd() * 0.9; o.scale.set(s, s * (0.9 + rnd() * 0.7), s); o.rotation.y = rnd() * 6; o.updateMatrix(); far.setMatrixAt(j, o.matrix); } this.scene.add(far); } buildSled() { const sled = new Group(); const shell = new Mesh(new BoxGeometry(0.34, 0.09, 0.5), new MeshLambertMaterial({ color: 0x15181d })); shell.position.y = 0.06; const load = new Mesh(new BoxGeometry(0.28, 0.1, 0.32), new MeshLambertMaterial({ color: ACCENT, emissive: 0x3a1000 })); load.position.set(0, 0.15, 0.04); const auger = new Mesh(new CylinderGeometry(0.014, 0.014, 0.5, 6), new MeshLambertMaterial({ color: 0x9aa3ad })); auger.rotation.x = Math.PI / 2; auger.position.set(0.11, 0.2, 0); sled.add(shell, load, auger); const lantern = new Sprite(new SpriteMaterial({ map: glowTexture("255,140,60"), blending: AdditiveBlending, depthWrite: false, transparent: true })); lantern.scale.set(0.9, 0.9, 1); lantern.position.set(-0.12, 0.3, -0.2); sled.add(lantern); const light = new PointLight(0xff7a30, 1.6, 2.6, 2); light.position.set(-0.12, 0.35, -0.2); sled.add(light); const shadow = new Sprite(new SpriteMaterial({ map: glowTexture("0,0,0"), transparent: true, opacity: 0.55, depthWrite: false })); shadow.scale.set(0.9, 0.9, 1); shadow.material.rotation = 0; sled.add(shadow); shadow.position.y = 0.01; sled.scale.setScalar(1.35); this.sled = sled; this.lantern = lantern; this.scene.add(sled); // Auger for drilling animations. const drill = new Group(); const bit = new Mesh(new CylinderGeometry(0.035, 0.012, 0.8, 8), new MeshLambertMaterial({ color: 0xc9d2db, emissive: 0x112233 })); bit.position.y = 0.4; const motor = new Mesh(new BoxGeometry(0.16, 0.12, 0.12), new MeshLambertMaterial({ color: ACCENT })); motor.position.y = 0.85; drill.add(bit, motor); drill.visible = false; this.drill = drill; this.scene.add(drill); } buildShanty() { const g = new Group(); const walls = new Mesh(new BoxGeometry(0.62, 0.48, 0.62), new MeshLambertMaterial({ color: 0x5a1d0c })); walls.position.y = 0.24; const roof = new Mesh(new ConeGeometry(0.52, 0.28, 4), new MeshLambertMaterial({ color: 0x1a1c20 })); roof.rotation.y = Math.PI / 4; roof.position.y = 0.62; const win = new Mesh(new BoxGeometry(0.16, 0.12, 0.01), new MeshBasicMaterial({ color: 0xffb36b })); win.position.set(0.12, 0.3, 0.315); const glow = new Sprite(new SpriteMaterial({ map: glowTexture("255,150,70"), blending: AdditiveBlending, transparent: true, depthWrite: false })); glow.scale.set(1.6, 1.6, 1); glow.position.set(0.12, 0.3, 0.4); const light = new PointLight(0xff8c40, 0, 3.5, 2); light.position.set(0.1, 0.4, 0.6); g.add(walls, roof, win, glow, light); g.visible = false; this.shanty = g; this.shantyLight = light; this.scene.add(g); } buildSnow() { const n = window.innerWidth < 700 ? 260 : 520; const pos = new Float32Array(n * 3), seed = new Float32Array(n); for (let i = 0; i < n; i++) { pos[i * 3] = (Math.random() - 0.5) * 22; pos[i * 3 + 1] = Math.random() * 7; pos[i * 3 + 2] = (Math.random() - 0.5) * 18; seed[i] = Math.random(); } const geo = new BufferGeometry(); geo.setAttribute("position", new Float32BufferAttribute(pos, 3)); geo.setAttribute("seed", new Float32BufferAttribute(seed, 1)); this.snowUniforms = { uTime: { value: 0 }, uScale: { value: 1 } }; const mat = new ShaderMaterial({ uniforms: this.snowUniforms, transparent: true, depthWrite: false, blending: AdditiveBlending, vertexShader: /* glsl */ ` attribute float seed; uniform float uTime, uScale; varying float vA; void main() { vec3 p = position; p.y = mod(p.y - uTime * (0.25 + seed * 0.3), 7.0); p.x += sin(uTime * 0.6 + seed * 20.0) * 0.25 + uTime * 0.12; p.x = mod(p.x + 11.0, 22.0) - 11.0; vec4 mv = modelViewMatrix * vec4(p, 1.0); gl_PointSize = (1.2 + seed * 2.6) * uScale * (8.0 / -mv.z); vA = 0.25 + seed * 0.5; gl_Position = projectionMatrix * mv; }`, fragmentShader: /* glsl */ ` varying float vA; void main() { float d = length(gl_PointCoord - 0.5); gl_FragColor = vec4(vec3(0.85, 0.9, 1.0), vA * (1.0 - smoothstep(0.1, 0.5, d))); }`, }); this.snow = new Points(geo, mat); this.scene.add(this.snow); } resize() { const w = this.container.clientWidth, h = this.container.clientHeight; if (!w || !h) return; this.renderer.setSize(w, h, false); this.camera.aspect = w / h; // Frame the whole grid at any aspect ratio: back the camera off on tall screens. // Wide screens get a low shot with the sunset in frame; tall phone screens a // steeper one, so the lake fills the view. In between, blend the two. const a = this.camera.aspect; const wideT = new Vector3(0, 0, -6.3), wide = new Vector3(0, 4.6, 16.6).multiplyScalar(Math.max(1, 1.55 / a)).add(wideT); const tallT = new Vector3(0, 0, -2.0), tall = new Vector3(0, 12, 12.5).multiplyScalar(Math.max(1, 0.95 / a)).add(tallT); const k = MathUtils.smoothstep(a, 0.85, 1.3); this.camTarget = tallT.lerp(wideT, k); this.baseCam = tall.lerp(wide, k); this.camera.updateProjectionMatrix(); if (this.snowUniforms) this.snowUniforms.uScale.value = h / 520 * this.renderer.getPixelRatio(); } // ---- state from the world ---- sync(lake, { fog }) { const d = this.data; for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) { const c = lake.cells[y * COLS + x]; const i = (y * COLS + x) * 4; const known = lake.known.get(`${x},${y}`); d[i] = Math.round(Math.min(1, c.inches / 10) * 255); d[i + 1] = !known ? 0 : known.inches !== undefined || known.fish !== undefined ? 255 : 110; d[i + 2] = c.drilled ? 255 : 0; d[i + 3] = lake.tracks?.has(`${x},${y}`) ? 255 : 0; } this.tex.needsUpdate = true; this.fogTarget = fog ? 1 : 0; this.uniforms.uTruth.value = 1; if (!this.sledPos) this.placeSled(lake.sled); if (lake.shanty && !this.shanty.visible) this.raiseShanty(lake.shanty); if (!lake.shanty && this.shanty.visible) { this.shanty.visible = false; this.shantyLight.intensity = 0; } // Persistent depth readouts over measured cells. for (const [k, info] of lake.known) { if (info.inches === undefined) continue; let lab = this.cellLabels.get(k); if (!lab) { const el = document.createElement("span"); const [x, y] = k.split(",").map(Number); el.className = "reading" + (info.inches < 4 ? " bad" : ""); el.textContent = info.inches === 0 ? "open" : `${info.inches}"`; this.labels.appendChild(el); lab = { el, pos: cellToWorld(x, y).add(new Vector3(0.24, 0, -0.3)) }; // the cell's far corner, clear of the sled this.cellLabels.set(k, lab); requestAnimationFrame(() => el.classList.add("in")); } } for (const [k, lab] of this.cellLabels) if (!lake.known.get(k)?.inches && lake.known.get(k)?.inches !== 0) { lab.el.remove(); this.cellLabels.delete(k); } } reset(lake) { for (const lab of this.cellLabels.values()) lab.el.remove(); this.cellLabels.clear(); this.anims = []; this.drill.visible = false; this.shanty.visible = false; this.shantyLight.intensity = 0; this.sledPos = null; for (const k of ["uPing", "uFlash", "uLook"]) this.uniforms[k].value.z = -99; this.sync(lake, { fog: this.fogTarget === 1 }); } placeSled([x, y]) { this.sledPos = cellToWorld(x, y); this.sled.position.copy(this.sledPos); } setHover(cell) { this.uniforms.uHover.value.set(cell ? cell[0] : -9, cell ? cell[1] : -9); } // ---- animations for tool events. Each returns a promise that resolves when it ends. ---- play(event, speed = 1) { const t = this.clock; const dur = (s) => s / speed; const wait = (s) => new Promise((r) => this.anims.push({ until: this.clock + dur(s), done: r })); switch (event.type) { case "look": { this.uniforms.uLook.value.set(this.sled.position.x, this.sled.position.z, t, 0); return wait(0.9); } case "sonar": { this.uniforms.uPing.value.set(this.sled.position.x, this.sled.position.z, t, 0); return wait(1.1); } case "drill": { const target = cellToWorld(...event.cell).add(new Vector3(0, 0, -0.08)); this.drill.position.copy(target).add(new Vector3(0, 0.9, 0)); this.drill.visible = true; const start = this.clock, D = dur(0.9); return new Promise((resolve) => this.anims.push({ tick: () => { const k = Math.min(1, (this.clock - start) / D); const down = k < 0.35 ? k / 0.35 : k < 0.75 ? 1 : 1 - (k - 0.75) / 0.25; this.drill.position.y = 0.9 - down * 0.85; this.drill.rotation.y += 0.5 * speed; if (k > 0.5 && !this.drillFlashed) { this.drillFlashed = true; this.uniforms.uFlash.value.set(target.x, target.z + 0.08, this.clock, event.inches < 4 ? 1 : 0); } if (k >= 1) { this.drill.visible = false; this.drillFlashed = false; return true; } }, done: resolve, })); } case "move": { const from = cellToWorld(...event.from), to = cellToWorld(...event.to); const start = this.clock, D = dur(0.85); const heading = Math.atan2(to.x - from.x, to.z - from.z); return new Promise((resolve) => this.anims.push({ tick: () => { const k = Math.min(1, (this.clock - start) / D); const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; this.sled.position.lerpVectors(from, to, e); this.sled.rotation.y = MathUtils.lerp(this.sled.rotation.y, heading, 0.2); this.sled.position.y = Math.sin(k * Math.PI) * 0.025; if (k >= 1) { this.sledPos = to.clone(); return true; } }, done: resolve, })); } case "blocked": { const c = cellToWorld(...event.cell); this.uniforms.uFlash.value.set(c.x, c.z, t, 1); this.shake = this.reducedMotion ? 0 : 0.06; return wait(0.9); } case "shanty": { this.raiseShanty(event.cell); return wait(1.4); } default: return wait(0.2); } } raiseShanty([x, y]) { const c = cellToWorld(x, y); this.shanty.position.set(c.x + 0.18, 0, c.z - 0.12); this.shanty.scale.setScalar(0.001); this.shanty.visible = true; const start = this.clock; this.uniforms.uPing.value.set(c.x, c.z, this.clock, 1); this.anims.push({ tick: () => { const k = Math.min(1, (this.clock - start) / 0.9); const s = 1 + Math.sin(k * Math.PI) * 0.12 * (1 - k); this.shanty.scale.setScalar(Math.max(0.001, easeOutBack(k)) * s); this.shantyLight.intensity = k * 2.2; return k >= 1; }, }); } frame() { const now = performance.now(); const dt = Math.min(0.05, (now - this.last) / 1000); this.last = now; this.clock += dt; if (!this.visible || document.hidden) return; // Watch the frame rate and step down quality on slow devices. this.frameTimes.push(dt); if (this.frameTimes.length > 90) { const avg = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length; this.frameTimes = []; if (avg > 0.03 && this.renderer.getPixelRatio() > 1) { this.renderer.setPixelRatio(1); this.resize(); } else if (avg > 0.04 && this.snow) { this.snow.visible = false; } } this.anims = this.anims.filter((a) => { const finished = a.tick ? a.tick() : this.clock >= a.until; if (finished) a.done?.(); return !finished; }); const u = this.uniforms; u.uTime.value = this.clock; this.sky.material.uniforms.uTime.value = this.clock; if (this.snowUniforms) this.snowUniforms.uTime.value = this.reducedMotion ? this.clock * 0.3 : this.clock; u.uFog.value += ((this.fogTarget ?? 0) - u.uFog.value) * Math.min(1, dt * 3); u.uSled.value.set(this.sled.position.x, this.sled.position.z); this.lantern.material.opacity = 0.85 + Math.sin(this.clock * 9) * 0.05 + Math.sin(this.clock * 23) * 0.04; // Camera: a slow drift and a little parallax from the pointer. const drift = this.reducedMotion ? 0 : 1; this.parallax.lerp(this.pointer, 0.04); const cam = this.camera; cam.position.copy(this.baseCam); cam.position.x += (Math.sin(this.clock * 0.07) * 0.5 + this.parallax.x * 0.7) * drift; cam.position.y += (Math.sin(this.clock * 0.05) * 0.15 - this.parallax.y * 0.35) * drift; if (this.shake > 0.001) { cam.position.x += (Math.random() - 0.5) * this.shake; cam.position.y += (Math.random() - 0.5) * this.shake; this.shake *= 0.86; } cam.lookAt(this.camTarget); this.renderer.render(this.scene, cam); this.placeLabels(); } placeLabels() { const w = this.container.clientWidth, h = this.container.clientHeight; const v = new Vector3(); const place = ({ el, pos }) => { v.copy(pos).project(this.camera); el.style.transform = `translate(-50%, -50%) translate(${((v.x + 1) / 2) * w}px, ${((1 - v.y) / 2) * h}px)`; }; this.axisLabels.forEach(place); this.cellLabels.forEach(place); } // Which cell is under a screen point, for hover and tap. pick(clientX, clientY) { const r = this.container.getBoundingClientRect(); const ndc = new Vector3(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1, 0.5); ndc.unproject(this.camera); const dir = ndc.sub(this.camera.position).normalize(); const t = -this.camera.position.y / dir.y; const hit = this.camera.position.clone().add(dir.multiplyScalar(t)); const x = Math.round(hit.x + (COLS - 1) / 2), y = Math.round((ROWS - 1) / 2 - hit.z); return x >= 0 && y >= 0 && x < COLS && y < ROWS ? [x, y] : null; } } function easeOutBack(k) { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(k - 1, 3) + c1 * Math.pow(k - 1, 2); } // A stylized pine: three stacked cones in one geometry. function mergeTree() { const parts = [ [new CylinderGeometry(0.04, 0.05, 0.3, 5), 0.15], [new ConeGeometry(0.42, 0.7, 7), 0.55], [new ConeGeometry(0.33, 0.6, 7), 0.88], [new ConeGeometry(0.22, 0.5, 7), 1.18], ]; const pos = [], norm = []; for (const [geo, y] of parts) { geo.translate(0, y, 0); const g = geo.toNonIndexed(); pos.push(...g.attributes.position.array); norm.push(...g.attributes.normal.array); } const out = new BufferGeometry(); out.setAttribute("position", new Float32BufferAttribute(pos, 3)); out.setAttribute("normal", new Float32BufferAttribute(norm, 3)); return out; }