Something went wrong. Try again.
My website. cameron.stream
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936import { createWorld, advanceWorld, offerObject, SPECIES, SPECIES_ORDER, CELL, COLS, ROWS, X0, Z0, X1, Z1, START_POOL, waterLevel, daylight, heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, BLOCK, BLOCK_Y0, blockKey, footAt, debrisAt, footing, stormAt, shineLantern, douseLantern, moonlight, SWELL, swellHeight, cellAt,} from './tide-pool-world.js?v=42';
const root = document.querySelector('[data-tide-pool]');const status = document.querySelector('#tide-status');// The veil over the pool while it loads: the water in the ring rises as each part arrives.const veil = document.querySelector('#tide-veil'), veilNote = document.querySelector('#tide-veil-note');const rise = level => veil?.style.setProperty('--level', String(level));// Opt-in, local-only graphics diagnostics. No telemetry or persistent device fingerprint.const diagnosticMode = new URLSearchParams(location.search).get('graphics');const diagnosticModes = ['baseline', 'no-post', 'no-waves', 'no-caustics', 'no-shadows', 'minimal'];const diagnosing = diagnosticModes.includes(diagnosticMode);const enabled = effect => !diagnosing || (diagnosticMode !== 'minimal' && diagnosticMode !== `no-${effect}`);let diagnosticPanel;const diagnostics = { mode: diagnosticMode, losses: 0, restores: 0 };function reportGraphics(fields) { if (!diagnosing) return; Object.assign(diagnostics, fields); if (!diagnosticPanel) { diagnosticPanel = document.createElement('pre'); diagnosticPanel.id = 'tide-graphics-diagnostics'; diagnosticPanel.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;z-index:10000;max-height:35vh;overflow:auto;background:#111e;color:white;padding:8px;font:11px monospace;white-space:pre-wrap;pointer-events:none'; document.body.append(diagnosticPanel); } diagnosticPanel.textContent = JSON.stringify(diagnostics, null, 2);}reportGraphics({ state: 'starting' });if (root) initialize().catch(error => { console.error(error); reportGraphics({ state: 'startup-error', error: String(error) }); root.dataset.veiled = 'failed'; rise(.5); veilNote.textContent = 'This browser cannot show the pool'; status.textContent = 'The pool needs WebGL, which this browser cannot provide.';});
const $ = s => root.querySelector(s);const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
async function initialize() { rise(.4); // A local, pinned module. A failed import or unavailable WebGL leaves the veil up, with a note. const T = await import('/public/tide-pool-vendor/three.module.js'); rise(.6); const canvas = $('#tide-canvas'); const stage = $('#tide-stage'); const mobile = matchMedia('(pointer: coarse)').matches; // Drawn through the post-processing below, the scene never touches the canvas's own buffer except as one flat // full-screen pass, so multisampling it would only cost memory, which phones run short of. A laptop with two graphics // chips is asked for the faster one. const renderer = new T.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: mobile ? 'low-power' : 'high-performance' }); renderer.shadowMap.enabled = enabled('shadows'); if (diagnosing) { const gl = renderer.getContext(), info = gl.getExtension('WEBGL_debug_renderer_info'); reportGraphics({ renderer: info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), floatTargets: !!gl.getExtension('EXT_color_buffer_float'), floatLinear: !!gl.getExtension('OES_texture_float_linear') }); } renderer.shadowMap.type = T.PCFSoftShadowMap; // Shadows are read with four taps, a bilinear blend of the four nearest depths, where three.js takes sixteen: nearly // the same soft edge for a quarter of the work on every lit pixel. And a light that is out, as the lamps are by day // and the visitor's light is until it is held, is passed over rather than worked out to nothing. { const chunks = T.ShaderChunk; const shadows = chunks.shadowmap_pars_fragment.replace(/#elif defined\( SHADOWMAP_TYPE_PCF_SOFT \)[\s\S]*?\) \* \( 1\.0 \/ 9\.0 \);/, `#elif defined( SHADOWMAP_TYPE_PCF_SOFT ) vec2 texel = shadowCoord.xy * shadowMapSize - .5, f = fract( texel ), d = vec2( 1. ) / shadowMapSize, uv = ( floor( texel ) + .5 ) * d; shadow = mix( mix( texture2DCompare( shadowMap, uv, shadowCoord.z ), texture2DCompare( shadowMap, uv + vec2( d.x, 0. ), shadowCoord.z ), f.x ), mix( texture2DCompare( shadowMap, uv + vec2( 0., d.y ), shadowCoord.z ), texture2DCompare( shadowMap, uv + d, shadowCoord.z ), f.x ), f.y );`); if (shadows !== chunks.shadowmap_pars_fragment) chunks.shadowmap_pars_fragment = shadows; let lights = chunks.lights_fragment_begin; for (const kind of ['point', 'spot']) { lights = lights.replace(new RegExp(`(\\t\\t${kind}Light = ${kind}Lights\\[ i \\];[\\s\\S]*?RE_Direct\\([^;]*;)`), `if ( ${kind}Lights[ i ].color != vec3( 0.0 ) ) {\n$1\n}`); } chunks.lights_fragment_begin = lights; } renderer.outputColorSpace = T.SRGBColorSpace; // Khronos PBR Neutral: highlights roll off without clipping, and authored colours stay true rather than drifting grey. renderer.toneMapping = T.NeutralToneMapping; renderer.toneMappingExposure = 1.25; const scene = new T.Scene(); scene.background = new T.Color(0x0a0a0a); scene.fog = null; const camera = new T.OrthographicCamera(-6, 6, 6, -6, 0.1, 140); // Post-processing, in as few passes as it can be, since every pass costs a fixed amount on top of its pixels. The scene // is drawn into a float image, all at once, or, where the graphics card has room for it, everything solid first, then a // copy of that for the water to be seen through, then the water and the glows over it. Its brightest parts are gathered at a quarter of the size, which the graphics card then // halves again and again down to a single pixel in one step, and one small pass reads a glow back out of those // halvings, near and far, so lamps, eyes and glints bleed softly past their edges. The miniature's blur runs across the // image; the last pass, straight to the screen, runs it down, adds the glow, tone maps, and draws the veil's ring while // the view opens out of it. const FULL = 'varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }'; // A miniature under glass: a band across the middle of the view stays sharp and everything softens toward the top and // bottom, as in a photograph of a small world taken close up. One Gaussian blur, run across and then down. const TILT = ` uniform vec2 uTexel; uniform float uAmount, uFocus, uBand, uRamp; vec3 tilt(sampler2D image, vec2 uv, vec2 dir) { float blur = smoothstep(uBand, uBand + uRamp, abs(uv.y - uFocus)) * uAmount; vec3 sum = texture2D(image, uv).rgb; if (blur < .02) return sum; vec2 step = dir * uTexel * blur; sum = sum * .2270270270 + (texture2D(image, uv + step * 1.3846153846).rgb + texture2D(image, uv - step * 1.3846153846).rgb) * .3162162162; return sum + (texture2D(image, uv + step * 3.2307692308).rgb + texture2D(image, uv - step * 3.2307692308).rgb) * .0702702703; }`; // The float-target bloom/blur path loses the context on the Pixel 10 Pro, while // direct rendering with the same water, caustics and shadows stays up. Extension // support alone is not a reliability test. Use that proven path for touch devices; // explicit graphics diagnostics can still opt into post-processing for isolation. const usePost = enabled('post') && (!mobile || diagnosing); const post = usePost && (renderer.extensions.has('EXT_color_buffer_float') || renderer.extensions.has('EXT_color_buffer_half_float')) ? (() => { const target = (options = {}) => new T.WebGLRenderTarget(1, 1, { type: T.HalfFloatType, depthBuffer: false, ...options }); const sceneTarget = target({ depthBuffer: true }), blurTarget = target(), solidTarget = target(); // The water and the glows over it, a scene of their own while the solid one is to be seen through them. const overlay = new T.Scene(), hosted = []; let seeing = false; // What the water needs to be seen through: the solid scene, and how far away each pixel of it lies. const seeThrough = { uRefract: { value: solidTarget.texture }, uProject: { value: new T.Matrix4() }, uScreen: { value: new T.Vector2() }, uDepthRange: { value: new T.Vector3(camera.near, camera.far, 50) }, uRefractOn: { value: 0 } }; const brightTarget = target({ generateMipmaps: true, minFilter: T.LinearMipmapLinearFilter }), glowTarget = target(); const tilt = { uTexel: { value: new T.Vector2() }, uAmount: { value: 2 }, uFocus: { value: .5 }, uBand: { value: .13 }, uRamp: { value: .34 } }; const bright = new T.ShaderMaterial({ uniforms: { tScene: { value: sceneTarget.texture }, uTexel: tilt.uTexel, uThreshold: { value: .72 } }, vertexShader: FULL, fragmentShader: ` uniform sampler2D tScene; uniform vec2 uTexel; uniform float uThreshold; varying vec2 vUv; void main() { // Every pixel of the scene under this one, each weighed on its own, so an eye one pixel across still glows. vec3 sum = vec3(0.); for (int j = 0; j < 4; j++) for (int i = 0; i < 4; i++) { vec3 c = texture2D(tScene, vUv + (vec2(float(i), float(j)) - 1.5) * uTexel).rgb; sum += c * smoothstep(uThreshold, uThreshold + .08, dot(c, vec3(.2126, .7152, .0722))); } gl_FragColor = vec4(sum / 16., 1.); }`, }); const glow = new T.ShaderMaterial({ uniforms: { tBright: { value: brightTarget.texture }, uTexel: { value: new T.Vector2() }, uStrength: { value: mobile ? .35 : .45 } }, vertexShader: FULL, fragmentShader: ` uniform sampler2D tBright; uniform vec2 uTexel; uniform float uStrength; varying vec2 vUv; // One halving, read through a tent three of its pixels across, so its square pixels never show, however bright. vec3 level(float lod) { vec2 d = uTexel * exp2(lod); vec3 sum = textureLod(tBright, vUv, lod).rgb * 4.; sum += (textureLod(tBright, vUv + vec2(d.x, 0.), lod) + textureLod(tBright, vUv - vec2(d.x, 0.), lod) + textureLod(tBright, vUv + vec2(0., d.y), lod) + textureLod(tBright, vUv - vec2(0., d.y), lod)).rgb * 2.; sum += (textureLod(tBright, vUv + d, lod) + textureLod(tBright, vUv - d, lod) + textureLod(tBright, vUv + vec2(d.x, -d.y), lod) + textureLod(tBright, vUv + vec2(-d.x, d.y), lod)).rgb; return sum / 16.; } void main() { // Near and far: these reach about 3, 12, 34, 100 and 200 pixels of the finished image. gl_FragColor = vec4((level(0.) * .56 + level(2.) * .58 + level(3.5) * .6 + level(5.) * .62 + level(6.) * .64) * uStrength, 1.); }`, }); const solid = new T.ShaderMaterial({ uniforms: { tColor: { value: sceneTarget.texture }, tDepth: { value: null }, uDepthRange: seeThrough.uDepthRange }, vertexShader: FULL, fragmentShader: ` uniform sampler2D tColor, tDepth; uniform vec3 uDepthRange; varying vec2 vUv; // The colour, and how far away it lies. void main() { gl_FragColor = vec4(texture2D(tColor, vUv).rgb, uDepthRange.x + texture2D(tDepth, vUv).r * (uDepthRange.y - uDepthRange.x) - uDepthRange.z); }`, }); const across = new T.ShaderMaterial({ uniforms: { tScene: { value: sceneTarget.texture }, ...tilt }, vertexShader: FULL, fragmentShader: `uniform sampler2D tScene; varying vec2 vUv; ${TILT} void main() { gl_FragColor = vec4(tilt(tScene, vUv, vec2(1., 0.)), 1.); }`, }); // The veil's ring, drawn over the first frames so the view can open out of it: the pool shows through the ring's // middle, then the opening widens past the edges of the screen and the ring runs out ahead of it like a ripple, its // lines keeping their width as it grows, and fades. In pixels of the finished image, after tone mapping, so the dark // and the ring match the page's exactly. const final = new T.ShaderMaterial({ uniforms: { tBlur: { value: blurTarget.texture }, tGlow: { value: glowTarget.texture }, ...tilt, uOpening: { value: 0 }, uIris: { value: new T.Vector4() }, uBands: { value: new T.Vector4() } }, vertexShader: FULL, fragmentShader: `uniform sampler2D tBlur, tGlow; uniform float uOpening; uniform vec4 uIris, uBands; varying vec2 vUv; ${TILT} float edge(float d, float r) { return smoothstep(r - .75, r + .75, d); } void main() { gl_FragColor = vec4(tilt(tBlur, vUv, vec2(0., 1.)) + texture2D(tGlow, vUv).rgb, 1.); #include <tonemapping_fragment> #include <colorspace_fragment> // Less than one step of the screen's colour of noise, so the long dark gradients of night don't band. gl_FragColor.rgb += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - .5) / 255.; if (uOpening > .5) { // uIris: the centre, the opening's radius, and how soft its edge has grown. uBands: the ring's heavy line, the // gap, and the fine line outside it, and how strongly it still shows. float d = distance(gl_FragCoord.xy, uIris.xy), r = uIris.z, a = r + uBands.x, b = a + uBands.y; vec3 col = mix(gl_FragColor.rgb, vec3(.0392), smoothstep(r - uIris.w, r + uIris.w, d)); float ring = edge(d, r) - edge(d, a) + edge(d, b) - edge(d, b + uBands.z); gl_FragColor.rgb = mix(col, vec3(.898), ring * uBands.w); } }`, }); const quad = new T.Mesh(new T.BufferGeometry(), bright); quad.geometry.setAttribute('position', new T.Float32BufferAttribute([-1, -1, 0, 3, -1, 0, -1, 3, 0], 3)); quad.geometry.setAttribute('uv', new T.Float32BufferAttribute([0, 0, 2, 0, 0, 2], 2)); quad.frustumCulled = false; const flat = new T.OrthographicCamera(-1, 1, 1, -1, 0, 1), size = new T.Vector2(); const pass = (material, target) => { quad.material = material; renderer.setRenderTarget(target); renderer.render(quad, flat); }; return { tilt, final, seeThrough, host(object) { hosted.push(object); (seeing ? overlay : scene).add(object); }, // Seeing through the water costs a copy of the whole image and a second pass over it, so it is dropped first // when frames run slow. see(on) { if (on === seeing) return; seeing = on; sceneTarget.depthTexture?.dispose(); sceneTarget.depthTexture = on ? new T.DepthTexture(sceneTarget.width, sceneTarget.height) : null; sceneTarget.dispose(); if (!on) solidTarget.dispose(); solid.uniforms.tDepth.value = sceneTarget.depthTexture; seeThrough.uRefractOn.value = on ? 1 : 0; for (const object of hosted) (on ? overlay : scene).add(object); }, resize() { renderer.getDrawingBufferSize(size); const w = Math.max(1, size.x), h = Math.max(1, size.y), qw = Math.max(1, Math.ceil(w / 4)), qh = Math.max(1, Math.ceil(h / 4)); sceneTarget.setSize(w, h); blurTarget.setSize(w, h); solidTarget.setSize(w, h); brightTarget.setSize(qw, qh); glowTarget.setSize(qw, qh); tilt.uTexel.value.set(1 / w, 1 / h); glow.uniforms.uTexel.value.set(1 / qw, 1 / qh); seeThrough.uScreen.value.set(1 / w, 1 / h); }, render() { renderer.setRenderTarget(sceneTarget); renderer.render(scene, camera); if (seeing) { seeThrough.uProject.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); pass(solid, solidTarget); renderer.setRenderTarget(sceneTarget); renderer.autoClear = false; renderer.render(overlay, camera); renderer.autoClear = true; } pass(bright, brightTarget); pass(glow, glowTarget); pass(across, blurTarget); pass(final, null); }, }; })() : null; // The water and the glows over it go where the post-processing wants them. reportGraphics({ postProcessing: !!post }); const host = object => post ? post.host(object) : scene.add(object);
// Light follows a slow day. Eyes and visors are unlit, so they read as signal lamps at night. // Image-based light: a generated violet dusk sky, prefiltered into an environment map that lights and reflects on every surface. const sky = new T.Scene(); sky.add(new T.Mesh(new T.SphereGeometry(10, 32, 16), new T.ShaderMaterial({ side: T.BackSide, depthWrite: false, vertexShader: 'varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }', fragmentShader: `varying vec3 vDir; void main() { float up = vDir.y; vec3 zenith = vec3(.16, .13, .28), horizon = vec3(.55, .48, .62), ground = vec3(.05, .045, .06); vec3 col = up > 0. ? mix(horizon, zenith, pow(up, .6)) : mix(horizon * .5, ground, pow(-up, .4)); // A soft sun low in the sky, where the key light comes from. vec3 sunDir = normalize(vec3(-.7, .45, -.2)); col += vec3(1., .88, .82) * pow(max(dot(vDir, sunDir), 0.), 24.) * 3.; gl_FragColor = vec4(col, 1.); }`, }))); function makeEnvironment() { const pmrem = new T.PMREMGenerator(renderer); const map = pmrem.fromScene(sky, .04).texture; pmrem.dispose(); return map; } scene.environment = makeEnvironment(); const hemi = new T.HemisphereLight(0xd8ccdf, 0x141118, 1.4); const skyDay = hemi.color.clone(), skyNight = new T.Color(0xaab2f2); scene.add(hemi); const sun = new T.DirectionalLight(0xf4efe8, 3.8); sun.castShadow = true; sun.shadow.mapSize.set(mobile ? 1024 : 2048, mobile ? 1024 : 2048); sun.shadow.normalBias = .03; sun.shadow.bias = -.0004; // Its shadows are drawn afresh every other frame, which nothing on the shelf moves fast enough to show. sun.shadow.autoUpdate = false; sun.shadow.needsUpdate = true; scene.add(sun, sun.target); const fill = new T.DirectionalLight(0xc0b4cc, 1.6); fill.position.set(14, 6, 18); scene.add(fill);
// Every lit surface darkens and cools under the water line and carries moving caustic light. const gridTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 4), COLS, ROWS, T.RGBAFormat, T.HalfFloatType); gridTexture.magFilter = gridTexture.minFilter = T.LinearFilter; gridTexture.needsUpdate = true; const shared = { uWater: { value: -.35 }, uTime: { value: 0 }, uLight: { value: 1 }, uSun: { value: new T.Vector3(0, 1, 0) }, uView: { value: new T.Vector3(0, 1, 0) }, uGrid: { value: gridTexture }, uGridBox: { value: new T.Vector4(X0, Z0, X1 - X0, Z1 - Z0) } }; // The water's motion from the simulation: how it runs (r, g) and the foam on it (b); and the loose sand on the ground (a). const flowTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 4), COLS, ROWS, T.RGBAFormat, T.HalfFloatType); flowTexture.magFilter = flowTexture.minFilter = T.LinearFilter; flowTexture.needsUpdate = true; shared.uFlow = { value: flowTexture }; // Night: the lamps that lay paths of light on the water, how far into night and dusk it is, and the haze toward the far edge. const LAMPS = mobile ? 8 : 16; shared.uLamps = { value: Array.from({ length: LAMPS }, () => new T.Vector4(0, 0, 0, 0)) }; shared.uLampColor = { value: new T.Color(0xf0dcf6) }; shared.uNight = { value: 0 }; shared.uDusk = { value: 0 }; shared.uStorm = { value: 0 }; shared.uMoon = { value: 1 }; // At night the plankton glows where the water is stirred: the plankton itself, and the bodies moving through the water. // The living things too small to see, a cell apiece: r = plankton in the water, g = the film grown on the floor. const planktonTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 2), COLS, ROWS, T.RGFormat, T.HalfFloatType); planktonTexture.magFilter = planktonTexture.minFilter = T.LinearFilter; planktonTexture.needsUpdate = true; shared.uPlankton = { value: planktonTexture }; shared.uHaze = { value: new T.Vector4(0, 1, 0, 0) }; shared.uHazeZoom = { value: 7 }; // The swell the simulation brings in at its seaward edge, so the open sea beyond carries the same waves on. shared.uSwell = { value: SWELL.map(s => new T.Vector4(s.a, s.kx, s.kz, s.w)) }; shared.uSwellPhase = { value: new T.Vector3(...SWELL.map(s => s.phase)) }; shared.uSwellLift = { value: 1 }; shared.uHazeColor = { value: new T.Color(0x1a1524) }; // The simulation grid as a texture: r = water surface, g = ground, b = set stone, a = how damp the ground still is. // Sampled with a cubic B-spline (four bilinear taps) so shorelines and hollows come out round, not grid-shaped. const GRID = ` uniform sampler2D uGrid; uniform vec4 uGridBox; vec4 gridAt(vec2 p) { vec2 size = vec2(${COLS}., ${ROWS}.); vec2 uv = (p - uGridBox.xy) / uGridBox.zw * size - .5; vec2 i = floor(uv), f = uv - i; vec2 w0 = (1. - f) * (1. - f) * (1. - f) / 6., w1 = (4. - 6. * f * f + 3. * f * f * f) / 6.; vec2 w2 = (1. + 3. * f + 3. * f * f - 3. * f * f * f) / 6., w3 = f * f * f / 6.; vec2 g0 = w0 + w1, g1 = w2 + w3; vec2 h0 = (i - .5 + w1 / g0) / size, h1 = (i + 1.5 + w3 / g1) / size; return g0.y * (g0.x * texture2D(uGrid, vec2(h0.x, h0.y)) + g1.x * texture2D(uGrid, vec2(h1.x, h0.y))) + g1.y * (g0.x * texture2D(uGrid, vec2(h0.x, h1.y)) + g1.x * texture2D(uGrid, vec2(h1.x, h1.y))); } `; function submerge(material, extend) { material.onBeforeCompile = shader => { Object.assign(shader.uniforms, shared); extend?.(shader); shader.vertexShader = 'varying vec3 vWorldC;\n' + shader.vertexShader.replace('#include <project_vertex>', `#include <project_vertex> vec4 cw = vec4(transformed, 1.); #ifdef USE_INSTANCING cw = instanceMatrix * cw; #endif vWorldC = (modelMatrix * cw).xyz;`); shader.fragmentShader = 'varying vec3 vWorldC;\nuniform float uWater, uTime, uLight, uHazeZoom;\nuniform vec4 uHaze;\nuniform vec3 uHazeColor;\n' + (shader.fragmentShader.includes('gridAt(') ? '' : GRID) + 'uniform sampler2D uCaustics;\nuniform vec4 uCausticBox;\n' + shader.fragmentShader.replace('#include <dithering_fragment>', ` // The rock has the grid at its corners already, which is close enough between them. #ifdef ROCK_CELL vec4 local = vCell; #else vec4 local = gridAt(vWorldC.xz); #endif float column = local.r - local.g; float depth = local.r - vWorldC.y; float under = clamp(depth * 3., 0., 1.); // Water absorbs warm light first: deeper floors go cooler and a little darker, but stay easy to see, as in a clear pool. vec3 absorbed = gl_FragColor.rgb * mix(vec3(.84, .8, .96), vec3(.5, .47, .68), clamp(depth * .6, 0., 1.)); gl_FragColor.rgb = mix(gl_FragColor.rgb, absorbed, under); // Under water, the sunlight on everything has come through the surface above: gathered bright where its curves // focus it, and thinned between. The pattern is the simulated surface's own; see moveSurface. vec2 cuv = (vWorldC.xz - uCausticBox.xy) / uCausticBox.zw; float focus = all(greaterThan(cuv, vec2(0.))) && all(lessThan(cuv, vec2(1.))) ? clamp(texture2D(uCaustics, cuv).r * 4., 0., 4.) : 1.; gl_FragColor.rgb *= 1. + (focus - 1.) * under * uLight; // A faint wet line where the water meets the floor, broken up so it reads as a lapping edge. float lap = exp(-pow(depth / .018, 2.)) * (.55 + .45 * sin(vWorldC.x * 6.3 + vWorldC.z * 4.1 + uTime * 1.4)) * smoothstep(.01, .06, column); gl_FragColor.rgb += vec3(.82, .76, .92) * lap * .14 * uLight; // Toward the far edge of the view the shelf recedes into a faint violet haze. float ahead = dot(vWorldC.xz - uHaze.zw, uHaze.xy); gl_FragColor.rgb = mix(gl_FragColor.rgb, uHazeColor, smoothstep(uHazeZoom * .25, uHazeZoom * 1.9, ahead) * .5); #include <dithering_fragment>`); }; return material; }
const unitBox = new T.BoxGeometry(1, 1, 1); const materials = new Map(); function lit(color, { metal = false, rough } = {}) { const id = `${color}:${metal}:${rough}`; if (!materials.has(id)) materials.set(id, submerge(new T.MeshStandardMaterial({ color, metalness: metal ? .8 : .05, roughness: rough ?? (metal ? .32 : .78), flatShading: true }))); return materials.get(id); } const lamp = new T.MeshBasicMaterial({ color: 0xffffff }); const dummy = new T.Object3D(); const m4 = new T.Matrix4(), q = new T.Quaternion(), v1 = new T.Vector3(), v2 = new T.Vector3(), v3 = new T.Vector3(), s3 = new T.Vector3(); const tint = new T.Color();
// One grid, drawn twice: the ground shaped from its heights, the water from its surface. Both read the same texture. function gridGeometry(up) { const gw = COLS * up, gh = ROWS * up, pos = new Float32Array(gw * gh * 3), nor = new Float32Array(gw * gh * 3); for (let b = 0; b < gh; b++) for (let a = 0; a < gw; a++) { const k = (b * gw + a) * 3; pos[k] = X0 + (a + .5) / up * CELL; pos[k + 2] = Z0 + (b + .5) / up * CELL; nor[k + 1] = 1; } const index = new Uint32Array((gw - 1) * (gh - 1) * 6); let n = 0; for (let b = 0; b < gh - 1; b++) for (let a = 0; a < gw - 1; a++) { const i0 = b * gw + a, i1 = i0 + 1, i2 = i0 + gw, i3 = i2 + 1; index.set([i0, i2, i1, i1, i2, i3], n); n += 6; } const geo = new T.BufferGeometry(); geo.setAttribute('position', new T.BufferAttribute(pos, 3)); geo.setAttribute('normal', new T.BufferAttribute(nor, 3)); geo.setIndex(new T.BufferAttribute(index, 1)); return geo; } const grid = gridGeometry(2); // Rock: a coarser sheet that runs past the grid on the landward sides and is lit facet by facet. It is as fine as a cell // over the shelf and a little way past it, then coarser and coarser out to where it sinks into the dark. function stations(lo, hi, from, to, step) { const out = []; for (let x = from; x < to + step / 2; x += step) out.push(x); for (let x = from, s = step; x > lo + 1e-6;) { s = Math.min(s * 1.25, 4); x = Math.max(lo, x - s); out.unshift(x); } for (let x = out[out.length - 1], s = step; x < hi - 1e-6;) { s = Math.min(s * 1.25, 4); x = Math.min(hi, x + s); out.push(x); } return out; } function rockSheet(xs, zs) { const gw = xs.length, gh = zs.length; const pos = new Float32Array(gw * gh * 3), nor = new Float32Array(gw * gh * 3); for (let b = 0; b < gh; b++) for (let a = 0; a < gw; a++) { const k = (b * gw + a) * 3; pos[k] = xs[a]; pos[k + 2] = zs[b]; nor[k + 1] = 1; } const index = new Uint32Array((gw - 1) * (gh - 1) * 6); let n = 0; for (let b = 0; b < gh - 1; b++) for (let a = 0; a < gw - 1; a++) { const i0 = b * gw + a, i1 = i0 + 1, i2 = i0 + gw, i3 = i2 + 1; // Alternate the diagonal so the facets make a woven pattern instead of parallel ridges. index.set((a + b) % 2 ? [i0, i2, i1, i1, i2, i3] : [i0, i2, i3, i0, i3, i1], n); n += 6; } const geo = new T.BufferGeometry(); geo.setAttribute('position', new T.BufferAttribute(pos, 3)); geo.setAttribute('normal', new T.BufferAttribute(nor, 3)); geo.setIndex(new T.BufferAttribute(index, 1)); return geo; } // The rock's shape, shared by its colour pass and its shadow pass so boulders and ledges cast true shadows. const ROCK_HEAD = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\nvarying float vSand;\nuniform sampler2D uFlow;\n' + GRID + ` float rockHash(vec2 p) { return fract(sin(dot(floor(p * 2.01), vec2(127.1, 311.7))) * 43758.5453); } `; const ROCK_BODY = `#include <begin_vertex> vCell = gridAt(position.xz); vSand = texture2D(uFlow, (position.xz - uGridBox.xy) / uGridBox.zw).a; // How far outside the simulated shelf this point lies, on the landward sides only. vec2 lo = uGridBox.xy, hi = uGridBox.xy + uGridBox.zw; float out_ = max(max(lo.x - position.x, position.x - hi.x), lo.y - position.z); vBeyond = clamp(out_ / 22., 0., 1.); float dry = 1. - smoothstep(.0, .05, vCell.r - vCell.g); // Visual relief only: a small broken texture on dry rock, rising into banks beyond the shelf. float relief = (rockHash(position.xz) - .5) * .09 * dry * (1. - smoothstep(.1, .5, vSand)); float bank = smoothstep(0., 18., out_) * (1.2 + rockHash(position.xz * .37) * 1.4) + smoothstep(0., 3., out_) * .15; transformed.y = vCell.g + relief + bank;`; // Ambient occlusion, for the colour pass only: where the ground around stands higher than here, less of the sky reaches in. const ROCK_SHADE = ` float around = 0.; for (int k = 0; k < 6; k++) { float a = float(k) * 1.0472; around += max(gridAt(position.xz + vec2(cos(a), sin(a)) * .9).g - vCell.g, 0.); } vOcclude = clamp(1. - around / 6. * 2.2, .35, 1.);`; const rockDepth = new T.MeshDepthMaterial({ depthPacking: T.RGBADepthPacking }); rockDepth.onBeforeCompile = shader => { Object.assign(shader.uniforms, shared); shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include <begin_vertex>', ROCK_BODY); }; // The film on the floor, from the plankton texture's second channel. A grazed trail is about one cell wide, so it is // drawn with a sharper cubic than the grid's (Mitchell and Netravali's, in five taps) to keep it a line, not a smudge. const FILM = ` uniform sampler2D uPlankton; float filmAt(vec2 p) { vec2 size = vec2(${COLS}., ${ROWS}.); vec2 uv = (p - uGridBox.xy) / uGridBox.zw * size - .5; vec2 i = floor(uv), f = uv - i, e = 1. - f; vec2 w0 = (1. / 3. - 3. * f + 5. * f * f - 7. / 3. * f * f * f) / 6., w3 = (1. / 3. - 3. * e + 5. * e * e - 7. / 3. * e * e * e) / 6.; vec2 w12 = (7. * (f * f * f + e * e * e) - 12. * (f * f + e * e) + 32. / 3.) / 6.; vec2 t0 = (i - .5) / size, t3 = (i + 2.5) / size; vec2 t12 = (i + .5 + (7. * e * e * e - 12. * e * e + 16. / 3.) / 6. / w12) / size; float sum = texture2D(uPlankton, t12).g * w12.x * w12.y + (texture2D(uPlankton, vec2(t0.x, t12.y)).g * w0.x + texture2D(uPlankton, vec2(t3.x, t12.y)).g * w3.x) * w12.y + (texture2D(uPlankton, vec2(t12.x, t0.y)).g * w0.y + texture2D(uPlankton, vec2(t12.x, t3.y)).g * w3.y) * w12.x; return sum / (w12.x * w12.y + (w0.x + w3.x) * w12.y + (w0.y + w3.y) * w12.x); } `; const rockMaterial = submerge(new T.MeshLambertMaterial({ color: 0xffffff, flatShading: true }), shader => { shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include <begin_vertex>', ROCK_BODY + ROCK_SHADE); shader.fragmentShader = '#define ROCK_CELL\nvarying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\nvarying float vSand;\n' + shader.fragmentShader.replace('#include <color_fragment>', `#include <color_fragment> { // Dry rock is dark slate; standing water lights the floor beneath it; set stone reads paler. float wet = smoothstep(.0, .45, vCell.r - vCell.g); // Broad, soft drifts of tone across the rock; the facets themselves carry the geometry. float grain = .88 + .24 * (sin(vWorldC.x * .31 + sin(vWorldC.z * .23) * 2.) * sin(vWorldC.z * .27 - vWorldC.x * .11) * .5 + .5); // Tidal zonation: pale dry rock above the high-water line, darker stained rock below it where the sea reaches. float above = smoothstep(.12, .32, vWorldC.y); vec3 rock = mix(vec3(.034, .03, .042), vec3(.056, .05, .064), above) * grain; // Rock the tide has just left stays dark and wet and pales as it dries, so higher ground, uncovered first, dries first. rock = mix(rock, rock * vec3(.5, .46, .72), clamp(vCell.a, 0., 1.) * (1. - wet) * (1. - above)); vec3 tone = mix(rock, vec3(.048, .04, .068), wet); tone = mix(tone, vec3(.032, .027, .045), clamp(vCell.r - vCell.g - .7, 0., 1.) * .6); tone = mix(tone, vec3(.11, .1, .125) * grain, clamp(vCell.b, 0., 1.) * (1. - wet * .4)); // Loose sand, where it has settled or been heaped: pale and fine, darker while wet, rippled where the water runs over it. float ripples = .92 + .08 * sin(dot(vWorldC.xz, vec2(5.1, 3.7)) + sin(vWorldC.x * .7) * 2.); vec3 sand = mix(vec3(.074, .067, .076), vec3(.046, .042, .055), max(wet, clamp(vCell.a, 0., 1.) * .8)) * ripples; tone = mix(tone, sand, smoothstep(.15, .6, vSand)); // Far beyond the shelf the rock sinks into the dark. // A little paler by day, so the shelf reads at a glance; night keeps its dark. diffuseColor.rgb = tone * mix(1., .15, vBeyond) * vOcclude * (.85 + .4 * uLight); // Over it all, the living film: floors the water has covered in daylight grow a dim grey-green skin, faint where // they lie bare and fuller under water, lifted with depth so it still shows through a pool. Where a scraper has // grazed, the floor shows clean. float depth = vCell.r - vCell.g, deep = clamp(depth, 0., 1.); float film = smoothstep(0., .35, filmAt(vWorldC.xz)) * (1. - smoothstep(0., .03, vBeyond)); diffuseColor.rgb = mix(diffuseColor.rgb, vec3(.066, .082, .078) * grain * (1. + deep) * vOcclude, film * mix(mix(.05, .15, clamp(vCell.a, 0., 1.)), .35 + .35 * deep, smoothstep(0., .06, depth))); }`); // Rock the tide has just left is still wet: its facets catch the sun, or the moon, in a sheen that is brightest when // it has just come out of the water and dulls as it dries, flecked where the film of water on it has broken up. shader.fragmentShader = shader.fragmentShader.replace('#include <opaque_fragment>', ` #if NUM_DIR_LIGHTS > 0 { float damp = clamp(vCell.a, 0., 1.) * (1. - smoothstep(.0, .02, vCell.r - vCell.g)) * (1. - smoothstep(0., .03, vBeyond)); if (damp > .01) { vec3 h = normalize(directionalLights[0].direction + geometryViewDir); // A wet face is never quite flat: each small patch of it tilts its own way, so some of it always glints. // Each glint is a bead of water: a point of light in its own small patch, where that patch tilts toward the sun. vec2 at = vWorldC.xz * 22., cell = floor(at); vec2 tilt = vec2(fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453), fract(sin(dot(cell, vec2(269.5, 183.3))) * 43758.5453)) - .5; vec3 bumped = normalize(geometryNormal + vec3(tilt, 0.) * .5); float bead = 1. - smoothstep(.08, .22, length(fract(at) - .5 - tilt * .5)); float sheen = pow(max(dot(geometryNormal, h), 0.), 10.) * .12 + pow(max(dot(bumped, h), 0.), 60.) * 2.4 * bead; outgoingLight += directionalLights[0].color * sheen * damp * damp; } } #endif #include <opaque_fragment>`); shader.fragmentShader = FILM + shader.fragmentShader; }); const ground = new T.Mesh(rockSheet(stations(X0 - 44, X1 + 44, X0 - 4, X1 + 4, CELL), stations(Z0 - 44, Z1 + 30, Z0 - 4, Z1 + 4, CELL)), rockMaterial); ground.frustumCulled = false; ground.receiveShadow = true; ground.castShadow = true; ground.customDepthMaterial = rockDepth; scene.add(ground);
// The water's surface, simulated on the graphics card a few centimetres at a time: every ripple and wake is a wave // that runs, spreads, and bounces off the rock by the physics of real water. It follows Tessendorf's iWave, which gives // deep water's dispersion, longer waves outrunning shorter ones, so a drop sends out a widening train of ripples rather // than a single ring, and a swimmer leaves a wake behind it. Things moving through the water push it aside, drops punch // into it, gusts of wind ruffle it, and the current carries the ripples along. The same surface bends the sunlight // coming down through it, and the floor is lit where its curves gather that light: the caustics. const SURFACE = mobile ? [512, 352] : [1024, 704], STEP = 1 / 60, SOURCES = 24; const FINE = (X1 - X0) / SURFACE[0]; const canFloat = renderer.extensions.has('EXT_color_buffer_float'); const floatLinear = renderer.extensions.has('OES_texture_float_linear'); // Tessendorf's vertical derivative, the heart of iWave: a small symmetric kernel that turns the shape of the surface into // how it will move, with a response of |k|, the wave number, which is what gives deep water its dispersion. Fitted here // by least squares so that it holds a level surface level (it sums to nothing) and, with a touch of smoothing, never // pushes a ripple the wrong way, which would let it grow without end. const REACH = 3, kernel = new Array((2 * REACH + 1) ** 2).fill(0); { const classes = []; for (let i = 0; i <= REACH; i++) for (let j = 0; j <= i; j++) { const set = new Set(); for (const [a, b] of [[i, j], [j, i]]) for (const sa of [1, -1]) for (const sb of [1, -1]) set.add(`${a * sa},${b * sb}`); classes.push([...set].map(t => t.split(',').map(Number))); } const basis = (kx, ky) => classes.map(c => c.reduce((v, [i, j]) => v + Math.cos(kx * i + ky * j), 0)); const m = classes.length, A = Array.from({ length: m }, () => new Float64Array(m)), rhs = new Float64Array(m); const fit = (row, want, weight) => { for (let p = 0; p < m; p++) { rhs[p] += weight * row[p] * want; for (let q = 0; q < m; q++) A[p][q] += weight * row[p] * row[q]; } }; for (let a = 0; a <= 32; a++) for (let c = 0; c <= a; c++) { const kx = a / 32 * Math.PI, ky = c / 32 * Math.PI, k = Math.hypot(kx, ky); fit(basis(kx, ky), k * Math.exp(-((k / 2.7) ** 4)), k < 2.2 ? 1 : .3); } fit(basis(0, 0), 0, 1e5); for (let p = 0; p < m; p++) { let best = p; for (let r = p + 1; r < m; r++) if (Math.abs(A[r][p]) > Math.abs(A[best][p])) best = r; [A[p], A[best]] = [A[best], A[p]]; [rhs[p], rhs[best]] = [rhs[best], rhs[p]]; for (let r = p + 1; r < m; r++) { const f = A[r][p] / A[p][p]; for (let q = p; q < m; q++) A[r][q] -= f * A[p][q]; rhs[r] -= f * rhs[p]; } } const weights = new Float64Array(m); for (let p = m - 1; p >= 0; p--) { let v = rhs[p]; for (let q = p + 1; q < m; q++) v -= A[p][q] * weights[q]; weights[p] = v / A[p][p]; } const at = (i, j) => (j + REACH) * (2 * REACH + 1) + i + REACH; classes.forEach((c, p) => { for (const [i, j] of c) kernel[at(i, j)] = weights[p]; }); kernel[at(0, 0)] += .004; for (const [i, j] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) kernel[at(i, j)] -= .001; } shared.uCausticBox = { value: new T.Vector4(X0, Z0, X1 - X0, Z1 - Z0) }; // Two numbers a texel, the height now and a step ago. const surfaceTarget = () => new T.WebGLRenderTarget(SURFACE[0], SURFACE[1], { type: floatLinear ? T.FloatType : T.HalfFloatType, format: T.RGFormat, depthBuffer: false, minFilter: T.LinearFilter, magFilter: T.LinearFilter }); let surfaceRead = canFloat && diagnosticMode !== 'minimal' ? surfaceTarget() : null, surfaceWrite = canFloat && diagnosticMode !== 'minimal' ? surfaceTarget() : null; const flatCamera = new T.OrthographicCamera(-1, 1, 1, -1, 0, 1); const surfaceSources = Array.from({ length: SOURCES }, () => new T.Vector4()); const surfaceTexel = new T.Vector2(1 / SURFACE[0], 1 / SURFACE[1]); const surfaceMaterial = new T.ShaderMaterial({ uniforms: { uState: { value: null }, uGrid: shared.uGrid, uGridBox: shared.uGridBox, uFlow: shared.uFlow, uTexel: { value: surfaceTexel }, uKernel: { value: kernel }, uGravity: { value: 9.81 * STEP * STEP / FINE }, uDamp: { value: .45 }, uStep: { value: STEP }, uSources: { value: surfaceSources }, uSourceCount: { value: 0 }, uTime: { value: 0 }, uWind: { value: new T.Vector3(1, .3, .00006) }, uViscous: { value: .005 } }, vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }', fragmentShader: `${GRID} uniform sampler2D uState, uFlow; uniform vec2 uTexel; uniform float uKernel[${kernel.length}], uGravity, uDamp, uStep; uniform vec4 uSources[${SOURCES}]; uniform int uSourceCount; uniform float uTime, uViscous; uniform vec3 uWind; varying vec2 vUv; float windHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float windNoise(vec2 p) { vec2 i = floor(p), f = p - i; f = f * f * (3. - 2. * f); return mix(mix(windHash(i), windHash(i + vec2(1., 0.)), f.x), mix(windHash(i + vec2(0., 1.)), windHash(i + vec2(1., 1.)), f.x), f.y); } void main() { vec2 world = uGridBox.xy + vUv * uGridBox.zw; // Only water carries waves: they stop where the rock stands out of it, and turn back. Dry rock has nothing to work out. vec4 cell = gridAt(world); float wet = smoothstep(.004, .02, cell.r - cell.g); if (wet <= 0.) { gl_FragColor = vec4(0., 0., 0., 1.); return; } // The ripples ride the current. vec2 uv = vUv - texture2D(uFlow, vUv).rg * uStep / uGridBox.zw; vec2 s = texture2D(uState, uv).rg; float pull = 0.; for (int j = -${REACH}; j <= ${REACH}; j++) for (int i = -${REACH}; i <= ${REACH}; i++) pull += uKernel[(j + ${REACH}) * ${2 * REACH + 1} + i + ${REACH}] * texture2D(uState, uv + vec2(float(i), float(j)) * uTexel).r; // Waves die away over a second or two, and at the edges of the world, so nothing comes back off them. vec2 edge = min(vUv, 1. - vUv) / uTexel; float damp = uDamp + 10. * (1. - smoothstep(0., 30., min(edge.x, edge.y))); float h = (s.r * (2. - damp * uStep) - s.g - uGravity * pull) / (1. + damp * uStep); // Water's viscosity takes the shortest waves soonest. h += uViscous * (texture2D(uState, uv + vec2(uTexel.x, 0.)).r + texture2D(uState, uv - vec2(uTexel.x, 0.)).r + texture2D(uState, uv + vec2(0., uTexel.y)).r + texture2D(uState, uv - vec2(0., uTexel.y)).r - 4. * s.r); // The wind: the air's pressure flickers over the water as it passes, in gusts that touch down in patches and // move on, and raises ripples running with it, as a breeze does on any pool. vec2 wind = uWind.xy; float gust = smoothstep(.35, .85, windNoise(world * .12 - wind * uTime * .09) * .7 + windNoise(world * .31 + wind.yx * uTime * .05) * .3); h += uWind.z * (.25 + gust) * (windNoise(world * 4.5 - wind * uTime * 2.7) - .5) * 2.; for (int n = 0; n < ${SOURCES}; n++) { if (n >= uSourceCount) break; vec4 src = uSources[n]; vec2 off = (world - src.xy) / src.z; if (abs(off.x) < 3. && abs(off.y) < 3.) h += src.w * exp(-dot(off, off)); } gl_FragColor = vec4(h * wet, s.r * wet, 0., 1.); }`, depthTest: false, depthWrite: false, }); const surfaceScene = new T.Scene(); { const quad = new T.Mesh(new T.PlaneGeometry(2, 2), surfaceMaterial); quad.frustumCulled = false; surfaceScene.add(quad); } // The caustics, worked out as Evan Wallace's WebGL water does: a sheet of sunlight rays comes down through the surface, // each bent by the slope where it enters, and lands on the floor. Wherever the landing patch of a triangle of rays is // smaller than it would be through level water, the light there is that much brighter; wherever larger, dimmer. // Worked out only over the ground in view, finely, and afresh as the view moves. const CAUSTICS = mobile ? [320, 320] : [512, 512]; const causticTarget = canFloat ? new T.WebGLRenderTarget(CAUSTICS[0], CAUSTICS[1], { type: T.HalfFloatType, depthBuffer: false, minFilter: T.LinearFilter, magFilter: T.LinearFilter }) : null; const causticMaterial = new T.ShaderMaterial({ uniforms: { uState: { value: null }, uGrid: shared.uGrid, uGridBox: shared.uGridBox, uTexel: { value: surfaceTexel }, uLightDir: { value: new T.Vector3(0, -1, 0) }, uFine: { value: 1 / FINE }, uCausticBox: shared.uCausticBox }, vertexShader: `${GRID} uniform sampler2D uState; uniform vec2 uTexel; uniform vec3 uLightDir; uniform float uFine; uniform vec4 uCausticBox; varying vec2 vBefore, vAfter; varying float vWet; void main() { vec2 world = uCausticBox.xy + (position.xy * .5 + .5) * uCausticBox.zw, uv = (world - uGridBox.xy) / uGridBox.zw; vec4 cell = gridAt(world); float depth = max(cell.r - cell.g, 0.); // Where a ray of sunlight would land on the floor through level water, and where the real surface sends it. vec3 level = refract(uLightDir, vec3(0., 1., 0.), .75); vBefore = vAfter = world + level.xz * depth / -level.y; vWet = smoothstep(.02, .08, depth); // Where there is next to no water, nothing bends it. if (vWet > 0.) { // The slope here: the ripples on the surface, over the swell and the tide's own lie. vec2 fine = vec2(texture2D(uState, uv + vec2(uTexel.x, 0.)).r - texture2D(uState, uv - vec2(uTexel.x, 0.)).r, texture2D(uState, uv + vec2(0., uTexel.y)).r - texture2D(uState, uv - vec2(0., uTexel.y)).r) * .5 * uFine; const float e = .3; vec2 broad = vec2(gridAt(world + vec2(e, 0.)).r - gridAt(world - vec2(e, 0.)).r, gridAt(world + vec2(0., e)).r - gridAt(world - vec2(0., e)).r) / (2. * e); vec3 n = normalize(vec3(-(fine.x + broad.x * smoothstep(.03, .2, depth)), 1., -(fine.y + broad.y * smoothstep(.03, .2, depth)))); vec3 bent = refract(uLightDir, n, .75); vAfter = world + bent.xz * depth / -bent.y; } gl_Position = vec4((vAfter - uCausticBox.xy) / uCausticBox.zw * 2. - 1., 0., 1.); }`, fragmentShader: ` varying vec2 vBefore, vAfter; varying float vWet; void main() { float before = length(dFdx(vBefore)) * length(dFdy(vBefore)), after = length(dFdx(vAfter)) * length(dFdy(vAfter)); // Stored at a quarter, so a bright knot of light doesn't clip. gl_FragColor = vec4(vWet * before / max(after, 1e-10) * .25, 0., 0., 1.); }`, blending: T.CustomBlending, blendEquation: T.AddEquation, blendSrc: T.OneFactor, blendDst: T.OneFactor, depthTest: false, depthWrite: false, transparent: true, }); const causticScene = new T.Scene(); { const sheet = new T.Mesh(new T.PlaneGeometry(2, 2, CAUSTICS[0], CAUSTICS[1]), causticMaterial); sheet.frustumCulled = false; causticScene.add(sheet); } // Without float targets there is no surface to simulate: the water lies level, and the light falls evenly. const level = new T.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1), even = new T.DataTexture(new Uint8Array([64, 0, 0, 255]), 1, 1); level.needsUpdate = even.needsUpdate = true; shared.uSurface = { value: level }; shared.uCaustics = { value: enabled('caustics') ? (causticTarget?.texture ?? even) : even }; // Each frame the world has moved on, step the surface to keep up with it, disturbed by whatever went into or moved through // the water, then work out where it sends the light. Nothing moves while the world is paused. let surfaceWorld = null, surfaceTime = 0, surfaceDrop = -Infinity, causticKey = '', causticSteps = 0; const surfaceClear = new T.Color(); function moveSurface() { if (!surfaceRead) return; const clearAlpha = renderer.getClearAlpha(); renderer.getClearColor(surfaceClear); renderer.setClearColor(0x000000, 0); if (surfaceWorld !== world) { surfaceWorld = world; surfaceTime = world.time; surfaceDrop = world.time; for (const t of [surfaceRead, surfaceWrite]) { renderer.setRenderTarget(t); renderer.clear(true, false, false); } } let steps = enabled('waves') ? Math.floor((world.time - surfaceTime) / STEP + .02) : 0; if (steps > 0) { // A slow frame is not made up for all at once, which would only make the next one slower. if (steps > 2) { steps = 2; surfaceTime = world.time; } else surfaceTime += steps * STEP; const sources = []; const push = (x, z, r, a) => sources.push({ x, z, r, a, d: (x - view.x) ** 2 + (z - view.z) ** 2 }); // A drop punches a hollow in the water, which springs back as a train of ripples. for (const r of world.ripples) if (r.born > surfaceDrop) push(r.x, r.z, .18, -.03); for (const r of world.ripples) surfaceDrop = Math.max(surfaceDrop, r.born); // Anything moving through the water pushes it aside as it goes, and leaves a wake. for (const c of world.creatures) { if (c.speed < .06 || c.alt != null || depthAt(world, c.x, c.z) < .04) continue; const swim = SPECIES[c.sp].kind === 'swimmer'; push(c.x, c.z, swim ? .1 : .12 + SPECIES[c.sp].radius * .3, -(swim ? .0009 : .0012) * Math.min(c.speed, 1.2) * steps); } if (lanternHeld() && depthAt(world, lantern.x, lantern.z) > .05) { const pace = Math.hypot(lantern.vx, lantern.vz); if (pace > .1) push(lantern.x, lantern.z, .12, -.0006 * Math.min(pace, 2) * steps); } // The wind blows off the sea, freshening a little by day and hard in a storm. const storm = stormAt(world); surfaceMaterial.uniforms.uWind.value.z = .0003 * (.7 + .5 * daylight(world)) * (1 + storm * 4); sources.sort((a, b) => a.d - b.d); const count = Math.min(SOURCES, sources.length); for (let n = 0; n < count; n++) surfaceSources[n].set(sources[n].x, sources[n].z, sources[n].r, sources[n].a); for (let n = 0; n < steps; n++) { surfaceMaterial.uniforms.uState.value = surfaceRead.texture; surfaceMaterial.uniforms.uSourceCount.value = n ? 0 : count; surfaceMaterial.uniforms.uTime.value = surfaceTime - (steps - 1 - n) * STEP; renderer.setRenderTarget(surfaceWrite); renderer.render(surfaceScene, flatCamera); [surfaceRead, surfaceWrite] = [surfaceWrite, surfaceRead]; } shared.uSurface.value = surfaceRead.texture; } // The light it lets through, gathered on the floor over the ground in view: a square a little larger than all the // ground the camera sees, which moves in steps rather than with every nudge of the view, worked out afresh with every // other step of the surface, as often as the eye can follow it. const { width, height } = stage.getBoundingClientRect(), aspect = width / Math.max(1, height); // The ground in view is a rectangle turned with the view; the square takes in all of it at any turn. const across = view.zoom * 2 * aspect, deep = view.zoom * 2 / Math.sin(PITCH), c = Math.abs(Math.cos(view.yaw)), s = Math.abs(Math.sin(view.yaw)); const side = Math.min(X1 - X0, 2 ** (Math.ceil(Math.log2(Math.max(c * across + s * deep, s * across + c * deep) * 1.1) * 8) / 8)); const snap = side / 16, cx = Math.round(view.x / snap) * snap, cz = Math.round(view.z / snap) * snap, key = `${cx}:${cz}:${side}`; causticSteps += Math.max(steps, 0); if (enabled('caustics') && (key !== causticKey || causticSteps >= 2)) { causticKey = key; causticSteps = 0; shared.uCausticBox.value.set(cx - side / 2, cz - side / 2, side, side); causticMaterial.uniforms.uState.value = surfaceRead.texture; causticMaterial.uniforms.uLightDir.value.subVectors(sun.target.position, sun.position).normalize(); renderer.setRenderTarget(causticTarget); renderer.render(causticScene, flatCamera); } renderer.setRenderTarget(null); renderer.setClearColor(surfaceClear, clearAlpha); } // Water: the same grid lifted to the water surface, fading out where the water thins to nothing. const WATER = { transparent: true, depthWrite: false, uniforms: { ...(post?.seeThrough ?? { uRefract: { value: level }, uProject: { value: new T.Matrix4() }, uScreen: { value: new T.Vector2(1, 1) }, uDepthRange: { value: new T.Vector3(.1, 140, 50) }, uRefractOn: { value: 0 } }), uTime: shared.uTime, uLight: shared.uLight, uSun: shared.uSun, uView: shared.uView, uSurface: shared.uSurface, uSurfaceTexel: { value: surfaceTexel }, uFine: { value: 1 / FINE }, uStep: { value: STEP }, uGrid: shared.uGrid, uGridBox: shared.uGridBox, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater, uLamps: shared.uLamps, uLampColor: shared.uLampColor, uNight: shared.uNight, uDusk: shared.uDusk, uStorm: shared.uStorm, uPlankton: shared.uPlankton, uMoon: shared.uMoon, uSwell: shared.uSwell, uSwellPhase: shared.uSwellPhase, uSwellLift: shared.uSwellLift, uHaze: shared.uHaze, uHazeZoom: shared.uHazeZoom, uHazeColor: shared.uHazeColor }, vertexShader: `${GRID} uniform float uOpen, uTide, uTime, uStorm; varying vec3 vW; varying float vDepth; varying vec2 vSlope; void main() { vec3 p = position; vec4 cell = gridAt(p.xz); vDepth = mix(cell.r - cell.g, 2., uOpen); // The water's own shape, as the simulation has it: its slope lights the waves, where it is deep enough to be water // rather than a film on the rock. const float e = .3; vSlope = vec2(gridAt(p.xz + vec2(e, 0.)).r - gridAt(p.xz - vec2(e, 0.)).r, gridAt(p.xz + vec2(0., e)).r - gridAt(p.xz - vec2(0., e)).r) / (2. * e) * smoothstep(.03, .2, vDepth) * (1. - uOpen); p.y = mix(cell.r, uTide, uOpen) + .004; vec4 w = modelMatrix * vec4(p, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`, fragmentShader: ` varying vec3 vW; varying float vDepth; varying vec2 vSlope; uniform float uTime, uLight; uniform vec3 uSun, uView; uniform vec4 uLamps[${LAMPS}]; uniform vec3 uLampColor, uHazeColor; uniform float uNight, uDusk, uHazeZoom, uStorm; uniform vec4 uHaze; uniform sampler2D uPlankton, uSurface; uniform vec2 uSurfaceTexel; uniform float uMoon, uFine, uStep; uniform sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; uniform vec4 uSwell[3]; uniform vec3 uSwellPhase; uniform float uSwellLift; uniform sampler2D uRefract; uniform mat4 uProject; uniform vec2 uScreen; uniform vec3 uDepthRange; uniform float uRefractOn; float foamHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float foamNoise(vec2 p) { vec2 i = floor(p), f = p - i; f = f * f * (3. - 2. * f); return mix(mix(foamHash(i), foamHash(i + vec2(1., 0.)), f.x), mix(foamHash(i + vec2(0., 1.)), foamHash(i + vec2(1., 1.)), f.x), f.y); } void main() { if (vDepth < .012) discard; float d = vDepth; // How much world one pixel covers: the finest ripples fade as the view pulls back, where they would only shimmer. float px = length(fwidth(vW.xz)); float detail = 1. - smoothstep(.03, .12, px); vec2 suv = (vW.xz - uGridBox.xy) / uGridBox.zw; vec4 moving = texture2D(uFlow, suv) * (1. - uOpen); // The slope of the water: the ripples and wakes of the simulated surface, over the swell and the tide's own lie. vec4 here = texture2D(uSurface, suv); vec2 ripples = vec2(texture2D(uSurface, suv + vec2(uSurfaceTexel.x, 0.)).r - texture2D(uSurface, suv - vec2(uSurfaceTexel.x, 0.)).r, texture2D(uSurface, suv + vec2(0., uSurfaceTexel.y)).r - texture2D(uSurface, suv - vec2(0., uSurfaceTexel.y)).r) * .5 * uFine; vec2 g = vSlope + ripples * (1. - uOpen) * mix(.35, 1., detail) * smoothstep(.01, .05, d); // Out on the open sea the swell's slope comes straight from the waves themselves. if (uOpen > .5) for (int i = 0; i < 3; i++) { vec4 s = uSwell[i]; g += s.yz * s.x * uSwellLift * cos(s.y * vW.x + s.z * (vW.z - ${Z1.toFixed(2)}) + s.w * uTime + uSwellPhase[i]); } vec3 n = normalize(vec3(-g.x, 1., -g.y)); // Depth: a clear lavender-grey in the shallows, deep violet where the water is deep. float absorb = 1. - exp(-d * 1.3); vec3 body = mix(vec3(.13, .115, .18), vec3(.02, .017, .04), absorb) * (.55 + .45 * uLight); vec3 r = reflect(-uView, n); vec3 sky = mix(vec3(.09, .08, .12), vec3(.46, .42, .58), smoothstep(-.2, .9, r.y)) * (.35 + .65 * uLight); float fres = .02 + .98 * pow(1. - max(dot(n, uView), 0.), 5.); vec3 col = mix(body, sky, clamp(fres * 2.2, 0., 1.)); vec3 h = normalize(uSun + uView); float nh = max(dot(n, h), 0.); // The sun glints off whatever slopes face it; at night the moon's glitter is as bright as the moon is full. float glitter = mix(1., .1 + .95 * uMoon, uNight); col += mix(vec3(1., .95, 1.), vec3(.74, .8, 1.), uNight) * (pow(nh, 60.) * .07 * mix(1., .3 + uMoon, uNight) + pow(nh, 900.) * 1.1 * glitter * detail) * uLight; // Each lamp is mirrored by the same surface: a clean image on still water, broken into a trembling path of light // by the ripples, as harbour lights are. vec3 lampTint = pow(uLampColor, vec3(1. / 2.2)); float sparkle = 0.; for (int i = 0; i < ${LAMPS}; i++) { vec4 L = uLamps[i]; if (L.w <= 0.) continue; vec3 toL = L.xyz - vW; float lh = max(dot(n, normalize(normalize(toL) + uView)), 0.); float lampLight = L.w * (pow(lh, 70.) * .9 + pow(lh, 700.) * 3.) / (1. + dot(toL, toL) * .35); col += lampTint * lampLight; sparkle += lampLight; } // On still water at night, stars. float calm = (1. - smoothstep(.08, .26, length(g))) * uNight * smoothstep(.08, .3, d); if (calm > .01) { vec2 sp = vW.xz * 3.2 + n.xz * 1.2; float hS = fract(sin(dot(floor(sp), vec2(12.9898, 78.233))) * 43758.5453); vec2 at = fract(sp) - .5 - (vec2(fract(hS * 13.7), fract(hS * 7.31)) - .5) * .6; float size = max(px * 3.2 * 2.1, .045); float star = smoothstep(size, size * .2, length(at)) * step(.962, hS) * (hS > .993 ? 1.15 : .6) * (.7 + .3 * sin(uTime * 1.7 + hS * 90.)) * calm * (1. - .7 * uMoon); col += vec3(.86, .84, 1.) * star; sparkle += star; } // At night the plankton lights wherever the water is disturbed: in the wake of anything moving through it, in the // ripples where something drops in, where the waves break. Brighter where the plankton is rich, dim where grazed. if (uNight > .02) { // Only real disturbance sets it off, not the ripples a breeze raises. float stir = smoothstep(.05, .16, abs(here.r - here.g) / uStep) * 2. * (1. - uOpen) + moving.b * 1.5; float pl = texture2D(uPlankton, (vW.xz - uGridBox.xy) / uGridBox.zw).r; float spark = foamNoise(vW.xz * 16. + vec2(uTime * 1.3, -uTime * .9)); float glow = min(stir, 2.) * (.25 + pl * 1.5) * uNight * (.35 + .65 * smoothstep(.55, .85, spark)) * smoothstep(.02, .12, d) * (1.3 - .6 * uMoon); col += vec3(.5, .6, 1.) * glow * .55; sparkle += glow * .45; } // At dusk and dawn the pools take the colour of the sky. col = mix(col, col * vec3(1.1, .95, 1.04) + vec3(.05, .02, .04), uDusk * .6); float foam = 0.; // Broken water: where a wave has just broken or the water pours, foam lies in soft, torn patches, thick at first, // full of holes as it drifts off, and thinning away to nothing. if (moving.b > .04) { vec2 fp = vW.xz * 1.6; float pattern = foamNoise(fp) * .55 + foamNoise(fp * 2.3 + 3.7) * .3 + foamNoise(fp * 5.1 - 1.9) * .15; // However much foam there is, it stays torn: thick foam has smaller holes, never none. float amount = smoothstep(.06, .8, moving.b), th = .92 - amount * .45; float white = smoothstep(th, th + .22, pattern) * (.25 + .5 * amount); // Bubbles: an uneven grain, clotted in places and open in others, finer close up. float grain = foamNoise(fp * 6.1 + vec2(uTime * .15, 0.)) * .6 + foamNoise(fp * 14.3 - 2.3) * .4; white *= mix(.8, .35 + .9 * grain, .4 + .6 * detail); foam += white * mix(.6, 1., detail); // At the very edge of the water, where it runs up and draws back, a thin churned line of it. float line = (1. - smoothstep(.015, .07, d)) * smoothstep(.08, .4, moving.b); foam += line * smoothstep(.35, .75, foamNoise(vW.xz * 6.5 + vec2(uTime * .3, -uTime * .2))) * .7; } col = mix(col, vec3(.84, .8, .92) * (.5 + .5 * uLight), clamp(foam * .9, 0., .9)); float alpha = .1 + absorb * .5 + fres * 1.2 + foam + sparkle; // Colours above are chosen as they should look on screen; the pipeline expects linear light. vec3 linear = pow(max(col, 0.), vec3(2.2)); // Toward the far edge of the view the water recedes into the same faint haze as the shelf. float hz = smoothstep(uHazeZoom * .25, uHazeZoom * 1.9, dot(vW.xz - uHaze.zw, uHaze.xy)) * .5; float cover = clamp(alpha + hz * .3, 0., .94), fade = smoothstep(.012, .04, d); vec3 surface = mix(linear, uHazeColor, hz); if (uRefractOn > 0.) { // Where this pixel lies on the image and how far it is from the eye. vec2 sc = gl_FragCoord.xy * uScreen; float eye = uDepthRange.x + gl_FragCoord.z * (uDepthRange.y - uDepthRange.x) - uDepthRange.z; // Seen through the water, the floor bends: shifted where the surface slopes, as far as a ray through the slope is // turned aside on its way down to the floor, so it swims under the ripples. It is only ever taken from what lies // beyond the water, never from something standing in front of it. vec2 bend = g * min(d, 1.2) * .5; vec4 through = texture2D(uRefract, sc + (uProject * vec4(bend.x, 0., bend.y, 0.)).xy * .5); if (through.a < eye) through = texture2D(uRefract, sc); gl_FragColor = vec4(mix(through.rgb, surface, cover), fade); } else gl_FragColor = vec4(surface, cover * fade); }`, }; const waterMaterial = new T.ShaderMaterial(WATER); const water = new T.Mesh(grid, waterMaterial); water.frustumCulled = false; water.renderOrder = 2; host(water); // Beyond the modelled shelf the open sea continues at the tide's own level. const openSea = new T.Mesh(new T.PlaneGeometry(400, 200).rotateX(-Math.PI / 2).translate(0, 0, Z1 + 100 - CELL), new T.ShaderMaterial({ ...WATER, uniforms: { ...WATER.uniforms, uOpen: { value: 1 } } })); openSea.renderOrder = 2; host(openSea); // Upload the simulation's grid: surface, ground, set stone, dampness. const cellCount = COLS * ROWS, gridData = gridTexture.image.data; const toHalf = T.DataUtils.toHalfFloat; // The water moves on thirty times a second, so there is never anything new to send more often than that. let gridWorld = null, gridTime = 0; function uploadGrid() { if (world === gridWorld && Math.abs(world.time - gridTime) < 1 / 30 - 1e-6) return; gridWorld = world; gridTime = world.time; const { w, h, built, damp, wall } = world; for (let k = 0; k < cellCount; k++) { const o = k * 4; // The water's floor is the ground, or the top of a wall of plates standing in it. gridData[o] = toHalf(w[k]); gridData[o + 1] = toHalf(h[k] + wall[k]); gridData[o + 2] = toHalf(Math.min(1, built[k] * 3)); gridData[o + 3] = toHalf(damp[k]); } gridTexture.needsUpdate = true; } let planktonClock = 0; function uploadPlankton(dt) { planktonClock -= dt; if (planktonClock > 0) return; planktonClock = .25; const data = planktonTexture.image.data, { plankton, film } = world; for (let k = 0; k < cellCount; k++) { data[k * 2] = toHalf(plankton[k]); data[k * 2 + 1] = toHalf(film[k]); } planktonTexture.needsUpdate = true; } // The current, as the simulation runs it: eased over a second or so, so the ripples it carries drift with the tide rather // than shudder with every wave, held back among a structure's frames, and turned into an eddy behind a tide wheel. And // foam: it gathers where a wave steepens and breaks in the shallows, where water pours fast, and at the edge of a surge, // then drifts with the water and fades. Both follow the world's own clock, so nothing moves while it is paused. const current = new Float32Array(cellCount * 2), foam = new Float32Array(cellCount), foamNext = new Float32Array(cellCount); const lastSurface = new Float32Array(cellCount); let flowWorld = null, flowTime = 0, lastDrop = -Infinity; function uploadFlow() { const { w, h, wall, qx, qz } = world, data = flowTexture.image.data; const fresh = flowWorld !== world; if (!fresh && Math.abs(world.time - flowTime) < 1 / 30 - 1e-6) return; if (fresh) { flowWorld = world; flowTime = world.time; lastSurface.set(w); current.fill(0); foam.fill(0); lastDrop = world.time; } for (const r of world.ripples) { if (r.born <= lastDrop) continue; const k = cellAt(r.x, r.z); if (w[k] - h[k] - wall[k] > .03) foam[k] = Math.min(.85, foam[k] + .45); } lastDrop = Math.max(lastDrop, ...world.ripples.map(r => r.born)); const dt = Math.min(.25, world.time - flowTime); if (dt <= 0 && !fresh) return; flowTime = world.time; const ease = 1 - Math.exp(-Math.max(dt, 0) / 1.2), fade = Math.exp(-Math.max(dt, 0) / 4.5); for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { const k = j * COLS + i, d = w[k] - h[k] - wall[k]; let u = 0, v = 0, froth = 0; if (d > .008) { const deep = Math.max(d, .03); u = ((i > 0 ? qx[k - 1] : 0) + qx[k]) * .5 / deep; v = ((j > 0 ? qz[k - COLS] : 0) + qz[k]) * .5 / deep; const speed = Math.hypot(u, v), rise = dt > 0 ? (w[k] - lastSurface[k]) / dt : 0; // Water running fast over the shallows breaks and whitens; so does the front of a surge as it climbs the rock; so // does a surface steep enough to spill; and the waterline itself churns wherever the water moves at its edge. const wet = n => n >= 0 && n < cellCount && w[n] - h[n] - wall[n] > .008; const edge = (i > 0 && !wet(k - 1)) || (i < COLS - 1 && !wet(k + 1)) || !wet(k - COLS) || !wet(k + COLS); // The water's own slope, from its wet neighbours only: dry rock beside it is not a wave. const level = n => wet(n) ? w[n] : w[k]; const steep = i > 0 && i < COLS - 1 && j > 0 && j < ROWS - 1 ? Math.hypot(level(k + 1) - level(k - 1), level(k + COLS) - level(k - COLS)) / (2 * CELL) : 0; froth = smooth(.6, 1.2, speed / Math.sqrt(9.81 * deep)) * smooth(.015, .05, d) + smooth(.05, .15, rise) * (1 - smooth(.08, .4, d)) + smooth(.1, .25, steep) * (1 - smooth(.15, .6, d)) + (edge ? smooth(.15, .45, speed) + smooth(.04, .12, Math.abs(rise)) : 0) * .6; const turn = swirl[k], c = Math.cos(turn) * drag[k], sn = Math.sin(turn) * drag[k]; [u, v] = [u * c - v * sn, u * sn + v * c]; } lastSurface[k] = w[k]; current[k * 2] += (u - current[k * 2]) * ease; current[k * 2 + 1] += (v - current[k * 2 + 1]) * ease; // Foam left behind on the rock as the water draws back drains away quickly. foam[k] = d > .008 ? Math.min(.9, foam[k] * fade + froth * dt * 2) : foam[k] * fade * fade * fade; } // The foam is carried along: each cell takes the foam from where its water came from. for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { const k = j * COLS + i; const fx = clamp(i - current[k * 2] * dt / CELL, 0, COLS - 1.001), fz = clamp(j - current[k * 2 + 1] * dt / CELL, 0, ROWS - 1.001); const a = Math.floor(fx), b = Math.floor(fz), tx = fx - a, tz = fz - b, n = b * COLS + a; foamNext[k] = (foam[n] * (1 - tx) + foam[n + 1] * tx) * (1 - tz) + (foam[n + COLS] * (1 - tx) + foam[n + COLS + 1] * tx) * tz; } foam.set(foamNext); for (let k = 0; k < cellCount; k++) { data[k * 4] = toHalf(clamp(current[k * 2], -2, 2)); data[k * 4 + 1] = toHalf(clamp(current[k * 2 + 1], -2, 2)); data[k * 4 + 2] = toHalf(foam[k]); data[k * 4 + 3] = toHalf(Math.min(1, world.sand[k] * 6)); } flowTexture.needsUpdate = true; } const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
// Feed shows as a soft wash of light that spreads through the water and fades while the film takes it up. const bloomTexture = (() => { const cv = document.createElement('canvas'); cv.width = cv.height = 128; const g = cv.getContext('2d'), grad = g.createRadialGradient(64, 64, 0, 64, 64, 64); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(.45, 'rgba(255,255,255,.45)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = grad; g.fillRect(0, 0, 128, 128); return new T.CanvasTexture(cv); })(); const blooms = []; const bloomMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2).rotateX(-Math.PI / 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 8); bloomMesh.frustumCulled = false; bloomMesh.renderOrder = 5; bloomMesh.count = 0; bloomMesh.setColorAt(0, new T.Color()); host(bloomMesh); const bloomColor = new T.Color(), bloomBase = new T.Color(0xc9d8d0); function drawBlooms() { while (blooms.length && world.time - blooms[0].born > 9) blooms.shift(); blooms.forEach((b, k) => { const age = Math.max(0, world.time - b.born), t = Math.min(1, age / 9); const size = .5 + 1.3 * (1 - Math.pow(1 - t, 3)); const glow = Math.min(1, age * 1.5) * (1 - t) * .5; bloomMesh.setMatrixAt(k, m4.compose(v1.set(b.x, surfaceAt(world, b.x, b.z) + .025, b.z), q.setFromAxisAngle(v2.set(0, 1, 0), age * .15), s3.set(size, 1, size))); bloomMesh.setColorAt(k, bloomColor.copy(bloomBase).multiplyScalar(glow)); }); flush(bloomMesh, blooms.length); }
// The visitor's light at night: a small ball of light under the pointer, or a finger, floating a little above whatever // is beneath it, rock or water alike, and rising over each rock as it passes. It lights what is around it; over water // the fish come to it and the plankton sparks in its wake. It fades in and out rather than switching. const HOVER = .55; const lantern = { x: 0, y: 0, z: 0, vx: 0, vz: 0, at: 0, tick: 0, cx: 0, cy: 0, on: false, lit: false, glow: 0 }; const lanternHeld = () => lantern.glow > .02; function holdLantern(e) { lantern.cx = e.clientX; lantern.cy = e.clientY; lantern.on = true; wake(); } function dropLantern() { lantern.on = false; lantern.lit = false; douseLantern(world); wake(); } // The highest floor, rock or water, within a small reach, so the light clears a rock before it is over it. function floorNear(x, z) { let top = -Infinity; for (const [dx, dz] of [[0, 0], [.35, 0], [-.35, 0], [0, .35], [0, -.35]]) top = Math.max(top, heightAt(world, x + dx, z + dz), surfaceAt(world, x + dx, z + dz)); return top; } function tendLantern() { const now = performance.now(), dt = Math.min(.1, (now - (lantern.tick || now)) / 1000); lantern.tick = now; // Put down once the view has been left to wander on its own. const night = clamp((.5 - daylight(world)) / .25, 0, 1); let hit = lantern.on && !director.on && night > 0 && floorAt(lantern.cx, lantern.cy); if (hit) { // It floats at a height that eases toward the floor beneath plus a little, so it glides up over a rock and down // again, and sits wherever the pointer's line of sight crosses that height, so it stays under the pointer. const target = floorNear(hit.x, hit.z) + HOVER; lantern.y = lantern.lit ? lantern.y + (target - lantern.y) * Math.min(1, dt * 7) : target; hit = groundAt(lantern.cx, lantern.cy, lantern.y); const floor = hit && floorNear(hit.x, hit.z); if (hit && lantern.y < floor + .35) { lantern.y = floor + .35; hit = groundAt(lantern.cx, lantern.cy, lantern.y); } } const lit = !!hit && onShelf(world, hit.x, hit.z); lantern.glow += ((lit ? night : 0) - lantern.glow) * Math.min(1, dt * 5); if (!lit) { if (lantern.lit) { lantern.lit = false; douseLantern(world); } return; } const step = Math.max(.016, (now - lantern.at) / 1000); const vx = lantern.lit ? (hit.x - lantern.x) / step : 0, vz = lantern.lit ? (hit.z - lantern.z) / step : 0; // Smoothed, so the trail it drags follows the hand rather than every twitch. lantern.vx += (vx - lantern.vx) * .35; lantern.vz += (vz - lantern.vz) * .35; Object.assign(lantern, { x: hit.x, z: hit.z, at: now, lit: true }); shineLantern(world, hit.x, hit.z); } // Lights at night. Each frame the brightest near the view lay their paths on the water, and the few brightest light what is around them. const lampSpots = []; const lampLights = Array.from({ length: mobile ? 2 : 4 }, () => { const l = new T.PointLight(0xe8d4f2, 0, 6, 1.6); scene.add(l); return l; }); // The visitor's light is a real light: it shines down and around from where it floats, and on larger screens everything // near it casts a shadow away from it. const orbLight = new T.SpotLight(0xece2ff, 0, 10, 1.42, .75, 1.5); orbLight.castShadow = !mobile; orbLight.shadow.mapSize.set(768, 768); orbLight.shadow.bias = -.0006; orbLight.shadow.normalBias = .03; orbLight.shadow.camera.near = .05; orbLight.shadow.camera.far = 10; // Its shadows are drawn only while it is held; the rest of the time it is out and casts none. orbLight.shadow.autoUpdate = false; scene.add(orbLight, orbLight.target); function emit(x, y, z, strength) { if (strength > .02) lampSpots.push({ x, y, z, s: strength }); } function castLamps(night) { if (lanternHeld()) lampSpots.push({ x: lantern.x, y: lantern.y, z: lantern.z, s: lantern.glow * 2.2, held: true }); const weight = l => l.held ? 1e9 : l.s / (1 + ((l.x - view.x) ** 2 + (l.z - view.z) ** 2) / (view.zoom * view.zoom * 4)); lampSpots.sort((a, b) => weight(b) - weight(a)); // The visitor's light carries its own fade; the lamps burn with the night. const strength = l => l.held ? l.s : l.s * night; shared.uLamps.value.forEach((v, i) => { const l = lampSpots[i]; if (l && strength(l) > .01) v.set(l.x, l.y, l.z, strength(l)); else v.set(0, 0, 0, 0); }); const lamps = lampSpots.filter(l => !l.held); lampLights.forEach((light, i) => { const l = lamps[i]; if (l && strength(l) > .02) { light.position.set(l.x, l.y + .12, l.z); light.intensity = strength(l) * 3; } else light.intensity = 0; }); const held = lampSpots.find(l => l.held); if (held) { orbLight.position.set(held.x, held.y, held.z); orbLight.target.position.set(held.x, held.y - 5, held.z); orbLight.intensity = held.s * 7; orbLight.shadow.needsUpdate = orbLight.castShadow; } else orbLight.intensity = 0; lampSpots.length = 0; } const GLOWS = 160; const glowMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), GLOWS + 4); glowMesh.frustumCulled = false; glowMesh.renderOrder = 6; glowMesh.count = 0; glowMesh.setColorAt(0, new T.Color()); host(glowMesh);
// Pools are named as they form. A name shows only when asked for: pointed at, tapped, or jumped to. const labels = new Map(); let pointedPool = null, shownPool = null, shownUntil = 0; const namedPool = name => name && name !== 'sea' && name !== 'puddle' ? name : null; function showPool(name, seconds = 2.5) { shownPool = name; shownUntil = performance.now() + seconds * 1000; } function placeLabels() { const bounds = stage.getBoundingClientRect(); const live = new Set(); for (const b of world.bodies) { live.add(b.name); let el = labels.get(b.name); if (!el) { el = document.createElement('span'); el.className = 'tide-label'; el.setAttribute('aria-hidden', 'true'); stage.append(el); labels.set(b.name, el); } const text = `<b>${b.name}</b> pool · ${Math.round(b.area)} m²`; if (el.dataset.text !== text) { el.innerHTML = text; el.dataset.text = text; } v1.set(b.x, surfaceAt(world, b.x, b.z), b.z).project(camera); const sx = (v1.x + 1) / 2 * bounds.width, sy = (1 - v1.y) / 2 * bounds.height; const show = view.zoom < 15 && sx > -40 && sx < bounds.width - 120 && sy > 150 && sy < bounds.height - (bounds.width < 720 ? 230 : 150); el.style.transform = `translate(${(sx - 20).toFixed(1)}px, ${sy.toFixed(1)}px)`; const asked = b.name === pointedPool || b.name === shownPool && performance.now() < shownUntil; el.style.opacity = show && asked ? String(clamp((15 - view.zoom) / 3, 0, 1) * .8) : '0'; } for (const [name, el] of labels) if (!live.has(name)) { el.remove(); labels.delete(name); } }
// Instanced parts. Each species is a rig of a few shared shapes, written into matrices every frame. const hex = new T.CylinderGeometry(1, 1, 1, 6); const dome = new T.SphereGeometry(1, 10, 5, 0, Math.PI * 2, 0, Math.PI / 2); const stone = new T.IcosahedronGeometry(1, 0); const parts = []; function part(geometry, material, max, { shadow = true, colored = false, fixed = false } = {}) { const mesh = new T.InstancedMesh(geometry, material, max); mesh.castShadow = shadow; mesh.receiveShadow = true; mesh.count = 0; mesh.frustumCulled = false; if (colored) mesh.setColorAt(0, tint.set(0xffffff)); scene.add(mesh); // Fixed parts keep their instances between frames and are redrawn only when what they show changes. const p = { mesh, n: 0, max, colored, fixed }; parts.push(p); return p; } // Only the instances in use are sent to the graphics card, and a mesh with none is left out of the frame altogether, // shadows and all. function flush(mesh, n) { mesh.count = n; mesh.visible = n > 0; if (!n) return; mesh.instanceMatrix.clearUpdateRanges(); mesh.instanceMatrix.addUpdateRange(0, n * 16); mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) { mesh.instanceColor.clearUpdateRanges(); mesh.instanceColor.addUpdateRange(0, n * 3); mesh.instanceColor.needsUpdate = true; } } const brass = lit(0xa88d5f, { metal: true }); const joints = lit(0xb89a66, { metal: true }); const frameSteel = lit(0x4a4252, { metal: true, rough: .5 }), deckSteel = lit(0x6a6072, { metal: true, rough: .45 }); const podPanel = lit(0x7c7288, { rough: .6 }), brassTrim = lit(0xb89a66, { metal: true }), plateSteel = lit(0x3e3747, { metal: true, rough: .55 }); const P = { // Scraper: a low ribbed slab on treads with a brass blade. scBody: part(unitBox, lit(0xb3aea6), 40), scCap: part(unitBox, lit(0x7fa39a), 40), scRib: part(unitBox, lit(0x3c3a40), 120), scTread: part(unitBox, lit(0x252428), 80), scBlade: part(unitBox, brass, 40), // Tab: a folded porcelain plate with a wagging fin. tbBody: part(unitBox, lit(0xd9d3dc), 44), tbFin: part(unitBox, lit(0x9fc3cf), 132, { shadow: false }), tbKeel: part(unitBox, lit(0x2c2a30), 44), // Pylon: stacked, alternately turned blocks with a crown of rods. pyBase: part(unitBox, lit(0x2a282d), 16), pySeg: part(unitBox, lit(0x77737a), 96), pyTop: part(unitBox, lit(0xd8c7a4), 16), pyRod: part(unitBox, joints, 96, { shadow: false }), // Collector: a hexagonal carapace on six jointed legs with two long arms. clBody: part(hex, lit(0x6b6274), 8), clHead: part(unitBox, lit(0xb59a5e), 8), clFin: part(unitBox, brass, 8), // Mason: a square body with a carrying tray. msBody: part(unitBox, lit(0xd6d0c2), 8), msTray: part(unitBox, lit(0x2e2c31), 8), msHead: part(unitBox, lit(0xb98a74), 8), // Breaker: a heavy block with a diamond ridge and two hammers. brBody: part(unitBox, lit(0x3a393e), 8), brRidge: part(unitBox, lit(0x8e4a52), 8), brHammer: part(unitBox, lit(0x6a676f), 16), // Borer: a low segmented body on treads, a brass auger at the front, and a heap of spoil riding on its back. boBody: part(unitBox, lit(0x4e463f), 10), boPlate: part(unitBox, lit(0x9a7a4e), 30), boTread: part(unitBox, lit(0x252428), 20), boAuger: part(new T.CylinderGeometry(0, 1, 1, 7), lit(0xa88d5f, { metal: true }), 10), boSpoil: part(new T.IcosahedronGeometry(1, 0), lit(0x2a2630), 10), limb: part(unitBox, lit(0x9a8a6a, { metal: true, rough: .45 }), 380), knee: part(unitBox, joints, 220), eye: part(unitBox, lamp, 260, { shadow: false, colored: true }), // Artificer: an upright hexagonal body on thin legs, a brass collar, a lamp-eyed head, a spool on its back. arBody: part(hex, lit(0x4a4038), 6), arCollar: part(hex, lit(0xb89a66, { metal: true }), 6), arHead: part(unitBox, lit(0x8a79b8), 6), arSpool: part(hex, lit(0x3a3540), 6), arTool: part(unitBox, lit(0xd6b980, { metal: true }), 12), // Machines: posts and beams of dark bronze, brass fittings, a gate plate, wheel rims and paddles, and lamps. mcPost: part(unitBox, lit(0x4b4036, { metal: true, rough: .5 }), 120), mcBrass: part(unitBox, lit(0xb89a66, { metal: true }), 120), mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 80), mcPaddle: part(unitBox, lit(0x6b5f55), 200), // Structure parts: violet steel lattice, grated decks, panelled pods, and brass fittings. bkFrame: part(unitBox, frameSteel, 9000, { fixed: true }), bkDeck: part(unitBox, deckSteel, 4000, { fixed: true }), bkPanel: part(unitBox, podPanel, 600, { fixed: true }), bkBrass: part(unitBox, brassTrim, 2500, { fixed: true }), bkPlate: part(unitBox, plateSteel, 700, { fixed: true }), // The same parts again for pieces in motion: dropping into place, or coming away and falling. lvFrame: part(unitBox, frameSteel, 2000), lvDeck: part(unitBox, deckSteel, 600), lvPanel: part(unitBox, podPanel, 120), lvBrass: part(unitBox, brassTrim, 500), lvPlate: part(unitBox, plateSteel, 120), bkGear: part(unitBox, lit(0xb89a66, { metal: true }), 400), mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 400, { shadow: false, colored: true }), // Objects and masonry. brass: part(hex, brass, 80), brassBoss: part(unitBox, lit(0xe0c48a, { metal: true }), 80), shell: part(dome, lit(0xd9ccb4, { rough: .55 }), 80), pebble: part(stone, lit(0x8a8f8c), 80), husk: part(unitBox, lit(0x2e2c31), 160), scrap: part(unitBox, lit(0x4a4650), 80), crop: part(unitBox, new T.MeshBasicMaterial({ color: 0xd4b9df }), 16, { shadow: false }), contact: part(new T.RingGeometry(.88, 1, 40).rotateX(-Math.PI / 2), new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 260, { shadow: false, colored: true }), }; host(P.contact.mesh); // The drop marker is a cursor, so it shows over whatever stands between it and the eye. const cursorRing = part(new T.RingGeometry(.88, 1, 40).rotateX(-Math.PI / 2), new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, blending: T.AdditiveBlending, depthWrite: false, depthTest: false }), 1, { shadow: false, colored: true }); cursorRing.mesh.renderOrder = 7; host(cursorRing.mesh);
const base = new T.Matrix4(), local = new T.Matrix4(); let cosA = 1, sinA = 0, bx = 0, by = 0, bz = 0; function setBase(x, y, z, angle, scale = 1) { bx = x; by = y; bz = z; cosA = Math.cos(angle); sinA = Math.sin(angle); base.compose(v1.set(x, y, z), q.setFromAxisAngle(v2.set(0, 1, 0), angle), s3.set(scale, scale, scale)); } const toWorld = (out, lx, ly, lz) => out.set(bx + lx * cosA + lz * sinA, by + ly, bz - lx * sinA + lz * cosA); function put(p, matrix, color) { if (p.n >= p.max) return; p.mesh.setMatrixAt(p.n, matrix); if (color && p.colored) p.mesh.setColorAt(p.n, color); p.n++; } function box(p, x, y, z, sx, sy, sz, rx = 0, ry = 0, rz = 0, color) { dummy.position.set(x, y, z); dummy.rotation.set(rx, ry, rz); dummy.scale.set(sx, sy, sz); dummy.updateMatrix(); put(p, local.multiplyMatrices(base, dummy.matrix), color); } const up = new T.Vector3(0, 1, 0); function segment(p, a, b, thickness) { const length = a.distanceTo(b); if (length < 1e-4) return; v3.subVectors(b, a).divideScalar(length); q.setFromUnitVectors(up, v3); put(p, m4.compose(v1.addVectors(a, b).multiplyScalar(.5), q, s3.set(thickness, length, thickness))); } function cube(p, at, size) { put(p, m4.compose(at, q.identity(), s3.set(size, size, size))); } const hip = new T.Vector3(), knee = new T.Vector3(), foot = new T.Vector3(); // A two-segment leg: the foot swings on the gait, lifts in the swing phase, and the knee bows outward. function leg(c, lx, ly, lz, fx, fz, side, offset, { stride = .1, lift = .07, thick = .028, kneeUp = .1 } = {}) { const swing = Math.sin(c.phase + offset), raise = Math.max(0, Math.cos(c.phase + offset)); const moving = Math.min(1, c.speed * 3); toWorld(hip, lx, ly, lz); toWorld(foot, fx, 0, fz + swing * stride * moving); foot.y = heightAt(world, foot.x, foot.z) + .01 + raise * lift * moving; toWorld(knee, (lx + fx) / 2 + side * .08, 0, (lz + fz) / 2 + swing * stride * .5 * moving); knee.y = Math.max(hip.y, foot.y) + kneeUp; segment(P.limb, hip, knee, thick); segment(P.limb, knee, foot, thick * .8); cube(P.knee, knee, thick * 1.7); } // Each kind has its own colour, on one part of its body and in its eyes, so at night the lights on the shelf say who is where. const ACCENT = { scraper: 0xa8e0d0, tab: 0xbfe6f2, pylon: 0xf2dcae, collector: 0xf0cf86, mason: 0xf0b49a, breaker: 0xf08c98, borer: 0xe0bd86, artificer: 0xcdb9ff }; const accents = Object.fromEntries(Object.entries(ACCENT).map(([sp, hex]) => [sp, new T.Color(hex)])); const eyeColor = new T.Color(), dimEye = new T.Color(0x3a3540), alarm = new T.Color(0xf0a8b8); let eyeBoost = 1; function eye(c) { eyeColor.copy(dimEye).lerp(accents[c.sp], clamp(c.energy * 1.4, 0, 1)); if (c.energy < .15) eyeColor.lerp(alarm, .5 + .5 * Math.sin(world.time * 6)); return eyeColor.multiplyScalar(eyeBoost); }
const DRAW = { scraper(c) { const y = footAt(world, c) + Math.sin(c.phase * 2) * .006 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.scBody, 0, .1, 0, .32, .1, .46); box(P.scCap, 0, .18, -.03, .24, .07, .32, c.gesture * .2); for (let k = 0; k < 3; k++) box(P.scRib, 0, .22, -.13 + k * .1, .2, .014, .03); for (const side of [-1, 1]) box(P.scTread, side * .18, .055, 0, .07, .1, .44); // Grazing as it creeps, the blade rasps from side to side. box(P.scBlade, 0, .04, .26, .3, .03, .05, 0, c.gesture * (c.task?.creep ? 2.2 : .6)); box(P.eye, 0, .15, .232, .12, .024, .02, 0, 0, 0, eye(c)); }, tab(c) { setBase(c.x, c.y, c.z, c.angle); const wag = Math.sin(c.phase * 3) * .45; box(P.tbBody, 0, 0, 0, .07, .06, .24, 0, wag * .08); box(P.tbKeel, 0, -.04, 0, .02, .025, .16); dummy.position.set(0, 0, -.12); dummy.rotation.set(0, wag, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); const tail = local.multiplyMatrices(base, dummy.matrix); dummy.position.set(0, 0, -.05); dummy.rotation.set(0, 0, 0); dummy.scale.set(.012, .09, .1); dummy.updateMatrix(); put(P.tbFin, m4.multiplyMatrices(tail, dummy.matrix)); for (const side of [-1, 1]) box(P.tbFin, side * .06, 0, .04, .08, .006, .05, 0, 0, side * (.3 + Math.sin(c.phase * 2) * .15)); box(P.eye, 0, .018, .115, .075, .014, .012, 0, 0, 0, eye(c)); }, pylon(c) { const y = heightAt(world, c.x, c.z); setBase(c.x, y, c.z, c.id * .7); box(P.pyBase, 0, .02, 0, .46, .04, .46); const segments = c.size + 1; let h = .04; const open = c.open ?? 1; for (let k = 0; k < segments; k++) { const w = .32 - k * .03, sh = .15 * (.6 + .4 * open); box(k === segments - 1 ? P.pyTop : P.pySeg, 0, h + sh / 2, 0, w, sh, w, 0, (k % 2) * Math.PI / 4 + Math.sin(world.time * .3 + k) * .03); h += sh; } const closing = c.strike; for (let k = 0; k < 6; k++) { const a = k / 6 * Math.PI * 2 + world.time * .08; const pitch = (.95 - closing * .7 + Math.sin(world.time * 1.3 + k + c.id) * .12 * open) * (.15 + .85 * open); const length = (.26 + c.size * .05) * (.45 + .55 * open); toWorld(hip, 0, h, 0); foot.set(hip.x + Math.cos(a) * Math.sin(pitch) * length, hip.y + Math.cos(pitch) * length, hip.z + Math.sin(a) * Math.sin(pitch) * length); segment(P.pyRod, hip, foot, .018); put(P.eye, m4.compose(foot, q.identity(), s3.set(.035, .035, .035)), eye(c).multiplyScalar(.35 + .65 * open)); } }, collector(c) { const y = footAt(world, c) + Math.sin(c.phase * 2) * .01 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.clBody, 0, .27, 0, .22, .13, .26, 0, Math.PI / 6); box(P.clFin, 0, .37, -.02, .02, .08, .28); box(P.clHead, 0, .26, .25, .16, .09, .1, c.gesture); for (const side of [-1, 1]) box(P.eye, side * .05, .3, .302, .035, .03, .012, 0, 0, 0, eye(c)); for (let k = 0; k < 3; k++) for (const side of [-1, 1]) { leg(c, side * .17, .24, -.12 + k * .12, side * .42, -.22 + k * .22, side, k * 2.1 + (side > 0 ? Math.PI : 0), { thick: .024 }); } const holding = c.carrying !== null; for (const side of [-1, 1]) { toWorld(hip, side * .1, .24, .22); toWorld(foot, side * (holding ? .07 : .09), holding ? .2 : .1 + Math.max(0, c.gesture) * .2, holding ? .4 : .44); segment(P.limb, hip, foot, .022); cube(P.knee, foot, .045); } }, mason(c) { const y = footAt(world, c) + Math.sin(c.phase * 2) * .008 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.msBody, 0, .29, 0, .34, .2, .4); box(P.msTray, 0, .405, 0, .38, .03, .38); box(P.msHead, 0, .3, .245, .18, .1, .1, c.gesture); box(P.eye, 0, .31, .298, .14, .022, .012, 0, 0, 0, eye(c)); for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { leg(c, sx * .16, .22, sz * .14, sx * .3, sz * .22, sx, o, { thick: .045, stride: .08, lift: .05, kneeUp: .07 }); } }, breaker(c) { const y = footAt(world, c) + Math.sin(c.phase * 2) * .012 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.brBody, 0, .35, 0, .44, .24, .6); box(P.brRidge, 0, .47, -.04, .2, .2, .48, 0, 0, Math.PI / 4); box(P.eye, 0, .37, .302, .3, .028, .012, 0, 0, 0, eye(c)); for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { leg(c, sx * .22, .26, sz * .2, sx * .38, sz * .3, sx, o, { thick: .06, stride: .09, lift: .06, kneeUp: .08 }); } // Hammers rise with a strike, then fall back as it decays. const raise = c.strike > 0 ? Math.sin(c.strike * Math.PI) : 0; for (const side of [-1, 1]) { toWorld(hip, side * .27, .38, .2); toWorld(foot, side * .3, .16 + raise * .45, .44 - raise * .12); segment(P.limb, hip, foot, .05); cube(P.knee, hip, .07); put(P.brHammer, m4.compose(foot, q.setFromAxisAngle(up, c.angle), s3.set(.13, .11, .16))); } }, };
DRAW.artificer = c => { const y = footAt(world, c) + Math.sin(c.phase * 2) * .01 * c.speed; setBase(c.x, y, c.z, c.angle); for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { leg(c, sx * .1, .34, sz * .08, sx * .26, sz * .2, sx, o, { thick: .026, stride: .1, lift: .07, kneeUp: .14 }); } box(P.arBody, 0, .56, 0, .13, .42, .13, 0, Math.PI / 6); box(P.arCollar, 0, .79, 0, .16, .04, .16, 0, Math.PI / 6); box(P.arHead, 0, .88, .02, .16, .1, .15, c.gesture * .5); box(P.eye, 0, .89, .1, .12, .022, .012, 0, 0, 0, eye(c)); box(P.arSpool, 0, .6, -.15, .07, .16, .07, 0, 0, Math.PI / 2); // Long jointed arms; while assembling, the tool hand works in small quick strokes. const working = c.state === 'work' && (c.task?.kind === 'assemble' || c.task?.kind === 'erect') || c.state === 'climb'; for (const side of [-1, 1]) { const stroke = working ? Math.sin(c.age * 11 + side) * .06 : 0; toWorld(hip, side * .13, .74, .05); toWorld(knee, side * .2, .56, .2); toWorld(foot, side * .12, .46 + stroke + c.gesture * .2, .4); segment(P.limb, hip, knee, .024); segment(P.limb, knee, foot, .02); cube(P.knee, knee, .04); put(P.arTool, m4.compose(foot, q.setFromAxisAngle(up, c.angle + side), s3.set(.05, .05, .09))); } }; DRAW.borer = c => { const y = footAt(world, c) + Math.sin(c.phase * 2) * .006 * c.speed; setBase(c.x, y, c.z, c.angle); for (const side of [-1, 1]) box(P.boTread, side * .16, .05, 0, .07, .09, .56); box(P.boBody, 0, .13, -.02, .28, .13, .5); for (let k = 0; k < 3; k++) box(P.boPlate, 0, .22 - k * .01, -.2 + k * .17, .31 - k * .02, .05, .15, -.18); // The auger spins while it digs and idles slowly otherwise. const digging = c.task?.kind === 'dig' && c.state === 'work'; c.spin = (c.spin || 0) + (digging ? .45 : .03); dummy.position.set(0, .1, .36 + (digging ? Math.sin(c.age * 9) * .02 : 0)); dummy.rotation.set(Math.PI / 2, c.spin, 0); dummy.scale.set(.09, .2, .09); dummy.updateMatrix(); put(P.boAuger, local.multiplyMatrices(base, dummy.matrix)); box(P.eye, 0, .21, .25, .12, .02, .012, 0, 0, 0, eye(c)); if (c.spoil > .04) { const s = Math.sqrt(Math.min(1, c.spoil / .7)); box(P.boSpoil, 0, .27 + s * .06, -.12, .2 * s, .12 * s, .24 * s, .3, c.id, 0); } }; // Machines rise as they are assembled: frames first, fittings once complete. const lampColor = new T.Color(), glowColor = new T.Color(), lampWarm = new T.Color(0xe6d0f2), hub = new T.Matrix4(); function drawMachines() { let glows = 0; for (const m of world.machines) { const done = m.have >= m.need, grow = Math.max(.15, m.have / m.need); const y = heightAt(world, m.x, m.z); setBase(m.x, y, m.z, m.angle); if (m.kind === 'gate') { for (const side of [-1, 1]) { box(P.mcPost, side * .6, .45 * grow, 0, .1, .9 * grow, .12); box(P.mcBrass, side * .6, .9 * grow + .02, 0, .13, .04, .15); } if (done) { box(P.mcPost, 0, .93, 0, 1.3, .08, .1); // The plate slides down to shut and up to open. const plateY = .28 + (1 - m.shut) * .52; box(P.mcPlate, 0, plateY, 0, 1.08, .5, .06); for (const side of [-1, 1]) box(P.mcBrass, side * .3, plateY + .2, .04, .06, .06, .03); box(P.mcBrass, 0, .99, 0, .14, .06, .14, 0, m.shut * 2); } } else if (m.kind === 'beacon') { box(P.mcPost, 0, 1.1 * grow, 0, .09, 2.2 * grow, .09); for (const side of [-1, 1]) box(P.mcPost, side * .2, .25 * grow, 0, .06, .5 * grow, .06, 0, 0, -side * .3); if (done) { for (let k = 0; k < 4; k++) box(P.mcBrass, Math.cos(k * Math.PI / 2) * .13, 2.35, Math.sin(k * Math.PI / 2) * .13, .025, .32, .025); box(P.mcBrass, 0, 2.52, 0, .34, .03, .34); const lampAt = toWorld(v3, 0, 2.35, 0).clone(); emit(lampAt.x, lampAt.y, lampAt.z, m.lit * 1.8); // Lit, the lamp burns bright enough to bloom, and its light falls on what is around it. put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.1)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + m.lit * .85).multiplyScalar(1 + m.lit * 3)); } } } glows = drawFittings(glows); flush(glowMesh, glows); } // Structures are drawn part by part. Each part joins up with its neighbours: shared posts and beams are drawn // once, bracing crosses only outside faces, and rails run only along open deck edges. const H = BLOCK / 2, BRACE = BLOCK * Math.SQRT2, QUARTER = Math.PI / 4, DROP = .8, SPARK = .6; const FIXED = { frame: P.bkFrame, deck: P.bkDeck, panel: P.bkPanel, brass: P.bkBrass, plate: P.bkPlate }; const LIVE = { frame: P.lvFrame, deck: P.lvDeck, panel: P.lvPanel, brass: P.lvBrass, plate: P.lvPlate }; const SIDES4 = [[1, 0], [-1, 0], [0, 1], [0, -1]]; let drawnWorld = null, drawnVersion = -1, settleAt = Infinity, fittings = [], wheels = [], arriving = []; const drag = new Float32Array(cellCount).fill(1), swirl = new Float32Array(cellCount); // One part, drawn about the current base. A piece that has come away is drawn whole, joined to nothing. function drawPart(b, kit, alone, solid = false) { const at = alone ? () => null : (i, j, k) => world.blocks.get(blockKey(i, j, k)); const is = (n, ...kinds) => !!n && kinds.includes(n.kind); const side = (di, dj, k = b.k) => at(b.i + di, b.j + dj, k); const below = at(b.i, b.j, b.k - 1), above = at(b.i, b.j, b.k + 1); if (b.kind === 'frame') { const t = .055; // Standing on the ground, the lowest frame is cast solid inside: a footing the water cannot pass. if (solid) box(kit.plate, 0, H, 0, BLOCK * .88, BLOCK * .98, BLOCK * .88); // A post at each corner, drawn by the first frame, in a fixed order, among the four that share it. for (const sx of [-1, 1]) for (const sz of [-1, 1]) { const owners = [[0, 0], [sx, 0], [0, sz], [sx, sz]].map(([a, e]) => [b.i + a, b.j + e]).sort((p, q) => p[0] - q[0] || p[1] - q[1]); const first = alone ? [b.i, b.j] : owners.find(([i, j]) => is(at(i, j, b.k), 'frame')); if (first[0] === b.i && first[1] === b.j) box(kit.frame, sx * H, H, sz * H, t, BLOCK, t); } for (const [di, dj] of SIDES4) { const n = side(di, dj), open = !is(n, 'frame'), mine = open || di + dj > 0; const along = dj !== 0; // Top beams, and bottom beams where nothing bears from below. if (mine) { box(kit.frame, di * H, BLOCK, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); if (!is(below, 'frame', 'pipe')) box(kit.frame, di * H, 0, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); } // A brace across each outside face, turning with the level so the lattice zigzags. if (open) { const flip = (b.i + b.j + b.k) % 2 ? 1 : -1; if (along) box(kit.frame, 0, H, dj * H, t * .55, BRACE, t * .55, 0, 0, flip * QUARTER); else box(kit.frame, di * H, H, 0, t * .55, BRACE, t * .55, flip * QUARTER, 0, 0); } } } else if (b.kind === 'plate') { // A solid bulkhead: dark plate with ribs on its outside faces and a brass cap on the top course. box(kit.plate, 0, H, 0, BLOCK * .99, BLOCK * .99, BLOCK * .99); for (const [di, dj] of SIDES4) { if (is(side(di, dj), 'plate')) continue; const along = dj !== 0; for (const e of [-.5, .5]) box(kit.frame, along ? e * H : di * H * 1.02, H, along ? dj * H * 1.02 : e * H, .035, BLOCK * .96, .035); } if (!is(above, 'plate')) box(kit.brass, 0, BLOCK - .01, 0, BLOCK * 1.02, .03, BLOCK * 1.02); } else if (b.kind === 'deck') { // Grated planks across the floor of the cell. for (const o of [-.33, 0, .33]) box(kit.deck, 0, .02, o * BLOCK, BLOCK * .98, .03, BLOCK * .27); box(kit.deck, 0, -.02, 0, BLOCK * .98, .03, .04); for (const [di, dj] of SIDES4) { const n = side(di, dj); if (is(n, 'deck', 'frame', 'pod')) continue; // A rail along each open edge, on posts at its ends. const along = dj !== 0; box(kit.brass, di * H * .94, .2, dj * H * .94, along ? BLOCK : .02, .02, along ? .02 : BLOCK); for (const e of [-1, 1]) box(kit.deck, along ? e * H * .94 : di * H * .94, .1, along ? dj * H * .94 : e * H * .94, .025, .2, .025); } // Overhanging decks get a strut back to whatever holds them. if (!below) for (const [di, dj] of SIDES4) { if (!is(side(di, dj), 'frame', 'deck')) continue; const along = dj !== 0; if (along) box(kit.frame, 0, -H * .5, dj * H * .5, .03, BRACE * .5, .03, dj * QUARTER, 0, 0); else box(kit.frame, di * H * .5, -H * .5, 0, .03, BRACE * .5, .03, 0, 0, -di * QUARTER); break; } } else if (b.kind === 'pod') { // A small panelled dwelling with a brass-trimmed roof and a round window. box(kit.panel, 0, BLOCK * .42, 0, BLOCK * .82, BLOCK * .78, BLOCK * .82); box(kit.brass, 0, BLOCK * .84, 0, BLOCK * .9, .035, BLOCK * .9); box(kit.deck, 0, .02, 0, BLOCK * .96, .04, BLOCK * .96); return { face: SIDES4.find(([di, dj]) => !side(di, dj)) || [0, 1] }; } else if (b.kind === 'pipe') { const n = SIDES4.find(([di, dj]) => is(side(di, dj), 'frame')) || [0, 0]; const px = n[0] * H * .7, pz = n[1] * H * .7; box(kit.brass, px, H, pz, .07, BLOCK, .07); if (!is(below, 'pipe')) box(kit.brass, px, .04, pz, .11, .05, .11); if (!is(above, 'pipe')) box(kit.brass, px, BLOCK - .03, pz, .11, .05, .11); if (n[0] || n[1]) box(kit.brass, px / 2, H, pz / 2, n[0] ? H * .7 : .03, .03, n[1] ? H * .7 : .03); } else if (b.kind === 'lamp') { box(kit.frame, 0, .18, 0, .045, .36, .045); box(kit.brass, 0, .38, 0, .12, .025, .12); return {}; } else if (b.kind === 'wheel') { // An axle out from the frame beside it, braced at both ends. const n = SIDES4.find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; box(kit.brass, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H * 1.1 : .045, .045, n[1] ? H * 1.1 : .045); for (const e of [-1, 1]) box(kit.frame, n[0] * H * .9 + (n[1] ? e * H * .8 : 0), H * .5, n[1] * H * .9 + (n[0] ? e * H * .8 : 0), .04, H * 1.1, .04); return { face: n }; } else if (b.kind === 'gear') { const n = SIDES4.find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; box(kit.frame, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H : .04, .04, n[1] ? H : .04); return { face: n }; } return null; } const partX = b => X0 + (b.i + .5) * CELL, partZ = b => Z0 + (b.j + .5) * CELL, partY = b => BLOCK_Y0 + b.k * BLOCK; // Standing parts are drawn once and kept until something changes. Parts still dropping into place wait until they land. function rebuildStructures() { drawnWorld = world; drawnVersion = world.blockVersion; settleAt = Infinity; for (const p of parts) if (p.fixed) p.n = 0; fittings = []; wheels = []; arriving = []; drag.fill(1); swirl.fill(0); for (const b of world.blocks.values()) { if (world.time < b.born + DROP) { arriving.push(b); settleAt = Math.min(settleAt, b.born + DROP); continue; } const x = partX(b), y = partY(b), z = partZ(b); // A part worked loose by a breaker hangs a little askew until it is made good. setBase(x, y - b.wear * .025, z, b.wear * .07); const fitting = drawPart(b, FIXED, false, footing(world, b)); if (fitting) fittings.push({ b, x, y, z, face: fitting.face }); if (b.kind === 'wheel') wheels.push({ b, x, z }); // The water slows among a structure's footings, stops at its plates, and eddies behind its wheels. const k = b.j * COLS + b.i; if (b.kind === 'plate') drag[k] = 0; else if (b.kind === 'frame' && b.k * BLOCK + BLOCK_Y0 < world.w[k]) drag[k] *= .8; if (b.kind === 'wheel') for (let dj = -3; dj <= 3; dj++) for (let di = -3; di <= 3; di++) { const i = b.i + di, j = b.j + dj, d = Math.hypot(di, dj); if (i < 0 || j < 0 || i >= COLS || j >= ROWS || d > 3) continue; drag[j * COLS + i] *= .45 + .55 * d / 3; swirl[j * COLS + i] += (1 - d / 3) * 1.1 * (b.key % 2 ? 1 : -1); } } for (const p of parts) if (p.fixed) flush(p.mesh, p.n); } // Parts going on drop into place from above and strike a small spark as they seat; parts coming away tumble and fall. const tumble = new T.Euler(), pivot = new T.Matrix4().makeTranslation(0, -H, 0), sparkColor = new T.Color(0xf0d7a0); function drawMoving(glows) { for (const b of arriving) { const age = world.time - b.born; if (age < 0) continue; const e = clamp(age / DROP, 0, 1), seat = 1 - (1 - e) ** 3; setBase(partX(b), partY(b) + (1 - seat) * .9, partZ(b), (1 - seat) * .6, .75 + .25 * seat); drawPart(b, LIVE, false); } for (const b of world.blocks.values()) { const age = world.time - b.born - DROP * .8; if (age < 0 || age > SPARK || glows >= GLOWS) continue; const fade = 1 - age / SPARK; glowMesh.setMatrixAt(glows, m4.compose(v1.set(partX(b), partY(b) + H, partZ(b)), camera.quaternion, s3.setScalar(.25 + age * .9))); glowMesh.setColorAt(glows++, glowColor.copy(sparkColor).multiplyScalar(fade * fade * .9)); } for (const d of world.debris) { if (world.time < d.born) continue; const p = debrisAt(d, world.time); tumble.set(p.turn, p.turn * .6, p.turn * .3); m4.compose(v1.set(p.x, p.y, p.z), q.setFromEuler(tumble), s3.setScalar(1)); base.multiplyMatrices(m4, pivot); drawPart(d, LIVE, true); } return glows; } // Moving and lit fittings: gears turning with the tide, windows and lamps that burn at night. function drawFittings(glows) { if (drawnWorld !== world || drawnVersion !== world.blockVersion || world.time >= settleAt) rebuildStructures(); glows = drawMoving(glows); const night = clamp((.45 - daylight(world)) / .2, 0, 1); for (const f of fittings) { setBase(f.x, f.y, f.z, 0); // Windows and lamps burn only when a charged tide wheel nearby powers them. if (f.power === undefined || world.step % 30 === 0) f.power = wheels.some(w => w.b.charge > .03 && Math.hypot(w.x - f.x, w.z - f.z) < 6) ? 1 : 0; const lit = night * f.power; const shine = lit * (.6 + .4 * Math.sin(world.time * .7 + f.b.key)); if (f.b.kind === 'pod') { // Someone at home shows as a faint glow in the window, brighter when there is power. const home = f.b.resident != null && creatureById(world, f.b.resident)?.perch === f.b.key ? .6 : 0; toWorld(v3, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83); emit(v3.x, v3.y, v3.z, Math.max(f.power * .8, home * 1.2)); const glow = Math.max(shine * .9, home * (.5 + .5 * night)); box(P.mcLamp, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83, .07, .07, .07, 0, 0, 0, lampColor.set(0x2a2530).lerp(lampWarm, glow).multiplyScalar(1 + glow * 2)); } else if (f.b.kind === 'lamp') { const lampAt = toWorld(v3, 0, .45, 0).clone(); emit(lampAt.x, lampAt.y, lampAt.z, f.power); put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.08)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + lit * .85).multiplyScalar(1 + lit * 3)); } else if (f.b.kind === 'wheel') { // A paddle wheel turning about the axle; its charge shows in a small lamp at the hub. const yaw = f.face[0] ? 0 : Math.PI / 2; dummy.position.set(-f.face[0] * .06, H, -f.face[1] * .06); dummy.rotation.set(0, yaw, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); hub.multiplyMatrices(base, dummy.matrix); dummy.position.set(0, 0, 0); dummy.rotation.set(f.b.turn, 0, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); hub.multiply(dummy.matrix); for (const off of [-.1, .1]) { dummy.position.set(off, 0, 0); dummy.rotation.set(0, Math.PI / 2, 0); dummy.scale.set(.3, .3, .3); dummy.updateMatrix(); put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); } for (let k = 0; k < 8; k++) { const a = k / 8 * Math.PI * 2; dummy.position.set(0, Math.cos(a) * .25, Math.sin(a) * .25); dummy.rotation.set(-a, 0, 0); dummy.scale.set(.22, .02, .11); dummy.updateMatrix(); put(P.mcPaddle, m4.multiplyMatrices(hub, dummy.matrix)); } put(P.mcLamp, m4.compose(toWorld(v3, 0, H, 0), q.identity(), s3.setScalar(.04)), lampColor.set(0x3a3540).lerp(lampWarm, f.b.charge)); } else if (f.b.kind === 'gear') { // A toothed wheel turning in the plane of the frame beside it. const spin = world.gearTurn * (f.b.key % 2 ? 1 : -1), yaw = f.face[0] ? Math.PI / 2 : 0; dummy.position.set(-f.face[0] * .04, H, -f.face[1] * .04); dummy.rotation.set(0, yaw, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); hub.multiplyMatrices(base, dummy.matrix); dummy.position.set(0, 0, 0); dummy.rotation.set(0, 0, spin); dummy.scale.set(.19, .19, .3); dummy.updateMatrix(); put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); for (let k = 0; k < 8; k++) { const a = spin + k * Math.PI / 4; dummy.position.set(Math.cos(a) * .2, Math.sin(a) * .2, 0); dummy.rotation.set(0, 0, a); dummy.scale.set(.06, .04, .04); dummy.updateMatrix(); put(P.bkGear, m4.multiplyMatrices(hub, dummy.matrix)); } for (let k = 0; k < 2; k++) { dummy.position.set(0, 0, 0); dummy.rotation.set(0, 0, spin + k * Math.PI / 2); dummy.scale.set(.36, .025, .025); dummy.updateMatrix(); put(P.bkGear, m4.multiplyMatrices(hub, dummy.matrix)); } } } if (lanternHeld()) { const g = lantern.glow; put(P.mcLamp, m4.compose(v1.set(lantern.x, lantern.y + Math.sin(world.time * 2.1) * .03, lantern.z), q.identity(), s3.setScalar(.085 * (.6 + .4 * g))), lampColor.setRGB(5, 4.6, 5.8).multiplyScalar(g)); } return glows; } // How far each kind sinks before it is covered: settling into the silt, it sinks and tips a little as it goes. const SINK = { scrap: .13, husk: .17, pebble: .11 }; function drawObjects() { for (const o of world.objects) { const worn = o.place === 'worn'; const wearer = worn ? creatureById(world, o.owner) : null; const sunk = worn ? 0 : (o.sunk || 0) * (SINK[o.kind] || 0); const y = (worn && wearer ? footAt(world, wearer) + .2 : o.alt ?? heightAt(world, o.x, o.z)) + (worn ? 0 : o.height) - sunk; setBase(o.x, y, o.z, o.rotation + sunk * 2); if (o.kind === 'brass') { box(P.brass, 0, .03, 0, .07, .06, .07); box(P.brassBoss, 0, .07, 0, .04, .02, .04); } else if (o.kind === 'shell') worn ? box(P.shell, 0, 0, -.02, .2, .13, .26) : box(P.shell, 0, 0, 0, .14, .08, .12); else if (o.kind === 'pebble') box(P.pebble, 0, .04, 0, .1, .055, .08); else if (o.kind === 'husk') { box(P.husk, -.06, .05, 0, .22, .08, .3, .2, 0, .3); box(P.husk, .1, .03, .08, .14, .05, .14, -.3, .5, 0); } else box(P.scrap, 0, .04, 0, .09, .07, .09, .3, 0, .2); } }
// Where a body meets the water it leaves a faint ring, and a moving one sheds wider rings behind it. const ringColor = new T.Color(), ringBase = new T.Color(0xb9a6c8); const HEIGHT = { scraper: .25, tab: .05, pylon: 0, collector: .4, mason: .45, breaker: .55, borer: .3, artificer: .9 }; function drawContacts() { for (const c of world.creatures) { const S = SPECIES[c.sp]; const floor = S.kind === 'swimmer' ? c.y : footAt(world, c), surface = surfaceAt(world, c.x, c.z); if (surface - heightAt(world, c.x, c.z) < .02 || c.alt != null && c.alt > surface) continue; const top = c.sp === 'pylon' ? floor + (c.size + 1) * .15 * (.6 + .4 * (c.open ?? 1)) + .1 : floor + HEIGHT[c.sp]; // A ring only where a body breaks the surface; whatever is wholly under water leaves the surface alone. const breaks = top > surface && floor < surface; if (!breaks) continue; const y = surface + .004; const r = S.radius * (c.sp === 'tab' ? 1.6 : 1.25); const strength = .14; put(P.contact, m4.compose(v1.set(c.x, y, c.z), q.identity(), s3.set(r, 1, r)), ringColor.copy(ringBase).multiplyScalar(strength)); if (c.speed > .08) { const t = (c.phase * .12 + c.id * .37) % 1; const wide = r * (1 + t * 1.6); put(P.contact, m4.compose(v1.set(c.x, y, c.z), q.identity(), s3.set(wide, 1, wide)), ringColor.copy(ringBase).multiplyScalar(strength * .8 * (1 - t) * Math.min(1, c.speed * 2))); } } }
function drawCrop(c) { // Registration marks around the followed machine, like a crop on a contact sheet. const r = (SPECIES[c.sp].radius || .2) + .18; const y = (SPECIES[c.sp].kind === 'swimmer' ? c.y - .08 : footAt(world, c)) + .02; setBase(c.x, y, c.z, 0); const len = r * .45, t = .018; for (const sx of [-1, 1]) for (const sz of [-1, 1]) { box(P.crop, sx * (r - len / 2), 0, sz * r, len, t, t); box(P.crop, sx * r, 0, sz * (r - len / 2), t, t, len); } } const markerColor = new T.Color(0xe0c4eb); function drawMarker() { if (!marker.visible) return; // It lies along the slope under it, rock or water, and just clear of the highest point under its rim, so it never // cuts into a bank. const floor = (dx, dz) => Math.max(heightAt(world, cursor.x + dx, cursor.z + dz), surfaceAt(world, cursor.x + dx, cursor.z + dz)); const R = .24, y0 = floor(0, 0), east = floor(R, 0), west = floor(-R, 0), south = floor(0, R), north = floor(0, -R); const sx = (east - west) / (2 * R), sz = (south - north) / (2 * R); const rise = Math.max(0, east - y0 - sx * R, west - y0 + sx * R, south - y0 - sz * R, north - y0 + sz * R); q.setFromUnitVectors(v2.set(0, 1, 0), v3.set(-sx, 1, -sz).normalize()); put(cursorRing, m4.compose(v1.set(cursor.x, y0 + rise + .05, cursor.z), q, s3.set(R, 1, R)), markerColor); }
// Every visit grows its own shelf. Its number lives in the address, so a shelf can be shared; Begin again grows another. const newShelf = () => 1 + Math.floor(Math.random() * 999999); const asked = Number(new URL(location.href).searchParams.get('shelf')); // Unless the address names one, a visit opens on a shelf chosen for how much is going on in it. let shelf = Number.isInteger(asked) && asked > 0 && asked < 1e9 ? asked : 52; function markShelf() { const address = new URL(location.href); address.searchParams.set('shelf', String(shelf)); history.replaceState(history.state, '', address); } let world = createWorld(shelf, { settled: true }); markShelf(); let selected = null, following = false, lastEnded = null; let tool = 'brass'; let cursor = { x: START_POOL.x, z: START_POOL.z }; const marker = { visible: false };
// Camera: an orthographic, slightly turned view with pan, zoom, and a turn about the middle of the view. YAW is the turn // it opens at, looking in across the shelf with the sea behind. const YAW = -.3, PITCH = .74; const view = { x: START_POOL.x, z: START_POOL.z + .6, zoom: 7, yaw: YAW }, goal = { ...view }; let intro = null, widened = false; const ZMIN = 1.4, ZMAX = 20; const reduced = matchMedia('(prefers-reduced-motion: reduce)'); function placeCamera() { const { width, height } = stage.getBoundingClientRect(); const aspect = width / Math.max(1, height); camera.left = -view.zoom * aspect; camera.right = view.zoom * aspect; camera.top = view.zoom; camera.bottom = -view.zoom; const dir = v2.set(Math.sin(view.yaw) * Math.cos(PITCH), Math.sin(PITCH), Math.cos(view.yaw) * Math.cos(PITCH)); camera.position.set(view.x + dir.x * 50, -.5 + dir.y * 50, view.z + dir.z * 50); camera.lookAt(view.x, -.5, view.z); camera.updateProjectionMatrix(); camera.updateMatrixWorld(); const reach = view.zoom * Math.max(aspect, 1) + 3; Object.assign(sun.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach, near: 1, far: 90 }); sun.shadow.camera.updateProjectionMatrix(); } function clampGoal() { goal.zoom = clamp(goal.zoom, ZMIN, ZMAX); goal.x = clamp(goal.x, X0 + 3, X1 - 3); goal.z = clamp(goal.z, Z0 + 3, Z1 - 1); } function cameraSettled() { return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002 && Math.abs(goal.yaw - view.yaw) < .001; } // Watching: once the instruments have faded and nothing is being followed, the camera drifts on its own and eases toward // whatever is happening: a collapse, a fall, a new pool, a structure begun or growing, a birth, the lights at dusk, the school. const director = { on: false, shot: null, until: 0, phase: 0, wide: false }; const WEIGHT = { collapse: 9, fall: 6, form: 5, found: 5, debut: 4, erect: 3.5, complete: 3, birth: 3, arrive: 2.5, repair: 2 }; function candidates() { const light = daylight(world), picks = []; const add = (x, z, score, zoom) => { if (!Number.isFinite(x + z) || !onShelf(world, x, z)) return null; const pick = { x, z, score, zoom }; picks.push(pick); return pick; }; for (const e of world.events) { const age = world.time - e.time; if (age > 45 || e.x == null || !WEIGHT[e.type]) continue; const pick = add(e.x, e.z, WEIGHT[e.type] * (1 - age / 45), e.type === 'form' ? 6.5 : 3.6); if (pick) pick.caption = eventText(e); } // Structures draw the eye, more so when their lights are on and most of all as they come on at dusk. const dusk = clamp(1 - Math.abs(light - .5) / .25, 0, 1), dark = clamp((.52 - light) / .3, 0, 1); for (const t of world.structures) { let lights = 0; if (dark > .1) for (const b of world.blocks.values()) { if ((b.kind === 'lamp' || b.kind === 'pod') && Math.hypot(X0 + (b.i + .5) * CELL - t.x, Z0 + (b.j + .5) * CELL - t.z) < t.reach + 1) lights++; } const pick = add(t.x, t.z, 1 + t.n / 70 + lights * (.4 + dusk), clamp(3 + t.reach, 3.4, 6)); if (pick) pick.caption = lights && dark > .1 ? (dusk > .4 ? 'Its lamps come on as the dusk falls.' : 'A structure, its windows lit by the tide it turns.') : `A structure of ${t.n} parts, each chosen from what was already there.`; } // The school, where the most fish swim together; at night its glowing wake is the best thing on the shelf. const school = schoolAt(); const followed = school && school.n > 3 && add(school.x, school.z, 1 + school.n * .1 + dark * 3, 3.2); if (followed) { followed.school = true; followed.caption = dark > .3 ? 'The school turns together, and its wake lights the plankton.' : 'The tabs school together, turning as one.'; } // In a storm, the largest structure taking the waves. const storm = stormAt(world); if (storm > .1 && world.structures.length) { const t = [...world.structures].sort((a, b) => b.n - a.n)[0]; const pick = add(t.x, t.z, 4 + storm * 4, 4.2); if (pick) pick.caption = 'The storm batters what stands at the waterline.'; } return picks; } // The line under the wandering view, faded out and back in when it changes. const captionOut = $('#tide-caption'); function caption(text) { if (!text || text === captionOut.textContent) { captionOut.classList.toggle('is-shown', !!text); return; } captionOut.classList.add('is-changing'); setTimeout(() => { captionOut.textContent = text; captionOut.classList.remove('is-changing'); captionOut.classList.add('is-shown'); }, 600); } function tideLine() { const pools = world.bodies.length; return tideRising(world.time) ? 'The tide is coming in over the shelf.' : `The tide is going out${pools ? `, and ${pools === 1 ? 'a pool stands' : `${pools} pools stand`} apart from the sea` : ''}.`; } function schoolAt() { const fish = world.creatures.filter(c => c.sp === 'tab'); let best = null; for (const a of fish) { const near = fish.filter(b => Math.hypot(a.x - b.x, a.z - b.z) < 2); if (!best || near.length > best.length) best = near; } return best && { x: best.reduce((s, c) => s + c.x, 0) / best.length, z: best.reduce((s, c) => s + c.z, 0) / best.length, n: best.length }; } function direct(dt) { const now = performance.now(), last = director.shot; const urgent = last && world.events.some(e => (e.type === 'collapse' || e.type === 'fall') && world.time - e.time < 2 && e.x != null && Math.hypot(e.x - last.x, e.z - last.z) > 1); if (!last || now > director.until || urgent) { // Something else next time: not the same place, and not the school twice running. const picks = candidates().map(p => ({ ...p, score: p.score + Math.random() * .8 - (last && Math.hypot(p.x - last.x, p.z - last.z) < 2 ? 1.5 : 0) - (last?.school && p.school ? 3 : 0) })); picks.sort((a, b) => b.score - a.score); // Now and then a wide shot of the whole shelf between the close ones. director.wide = !urgent && !director.wide && Math.random() < .3; const middle = picks.length ? { x: picks.reduce((a, p) => a + p.x, 0) / picks.length, z: picks.reduce((a, p) => a + p.z, 0) / picks.length } : view; director.shot = director.wide ? { x: middle.x, z: middle.z, zoom: 9.5, caption: tideLine() } : picks[0] || { x: view.x, z: view.z, zoom: 7 }; director.until = now + (director.wide ? 14000 : 20000 + Math.random() * 12000); caption(director.shot.caption); director.told = now; } else if (now - director.told > 8000) { // Between one thing and the next, what the busiest machine in the shot is doing. director.told = now; const shot = director.shot, busy = world.creatures.filter(c => SPECIES[c.sp].kind === 'walker' && Math.hypot(c.x - shot.x, c.z - shot.z) < shot.zoom * .6) .map(c => ({ c, goal: goalText(world, c) })).filter(({ goal }) => !/^(pausing|deciding|resting)/.test(goal)); const pick = busy[Math.floor(Math.random() * busy.length)]; if (pick) caption(`${label(pick.c)}, ${/^[aeiou]/i.test(SPECIES[pick.c.sp].name) ? 'an' : 'a'} ${SPECIES[pick.c.sp].name.toLowerCase()}, is ${pick.goal}.`); } director.phase += dt; // A school swims on, so a shot of one keeps it in view. if (director.shot.school) { const school = schoolAt(); if (school) { director.shot.x += (school.x - director.shot.x) * Math.min(1, dt * .4); director.shot.z += (school.z - director.shot.z) * Math.min(1, dt * .4); } } // Never quite still: a slow circle around the subject, and a slow breath in and out. const shot = director.shot, t = director.phase; goal.x = shot.x + Math.cos(t * .07) * .7; goal.z = shot.z + Math.sin(t * .053) * .45; goal.zoom = shot.zoom * (1 + Math.sin(t * .041) * .06); clampGoal(); } function easeCamera(dt) { if (intro && performance.now() - intro.start > intro.hold && !intro.released) { intro.released = true; Object.assign(goal, intro.to); } if (intro?.released && cameraSettled()) intro = null; const k = reduced.matches ? 1 : 1 - Math.exp(-dt * (intro?.released ? 1.1 : director.on ? .32 : 7)); view.x += (goal.x - view.x) * k; view.z += (goal.z - view.z) * k; view.zoom += (goal.zoom - view.zoom) * k; view.yaw += (goal.yaw - view.yaw) * k; placeCamera(); }
const raycaster = new T.Raycaster(); const pickPlane = new T.Plane(new T.Vector3(0, 1, 0), .35); function groundAt(clientX, clientY, y = -.35) { const bounds = canvas.getBoundingClientRect(); const ndc = new T.Vector2((clientX - bounds.left) / bounds.width * 2 - 1, -(clientY - bounds.top) / bounds.height * 2 + 1); raycaster.setFromCamera(ndc, camera); pickPlane.constant = -y; const hit = raycaster.ray.intersectPlane(pickPlane, new T.Vector3()); return hit ? { x: hit.x, z: hit.z } : null; } // Refine the hit against the floor that is actually under the pointer. function floorAt(clientX, clientY) { let hit = groundAt(clientX, clientY, 0); for (let i = 0; hit && i < 3; i++) { const y = Math.max(heightAt(world, hit.x, hit.z), surfaceAt(world, hit.x, hit.z)); hit = groundAt(clientX, clientY, y); } return hit; } function screenOf(c) { const y = SPECIES[c.sp].kind === 'swimmer' ? c.y : footAt(world, c) + .2; v1.set(c.x, y, c.z).project(camera); const bounds = canvas.getBoundingClientRect(); return { x: (v1.x + 1) / 2 * bounds.width + bounds.left, y: (1 - v1.y) / 2 * bounds.height + bounds.top }; } function creatureAt(clientX, clientY) { const bounds = canvas.getBoundingClientRect(); const pixelsPerUnit = bounds.height / (view.zoom * 2); let best = null, score = Infinity; for (const c of world.creatures) { const s = screenOf(c); const d = Math.hypot(s.x - clientX, s.y - clientY); const r = Math.max(16, (SPECIES[c.sp].radius + .1) * pixelsPerUnit); if (d < r && d < score) { score = d; best = c; } } return best; }
// Lifting the veil. Shaders compile in the background where the browser allows it, and the title's face loads, first. const controls = () => root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]'); const prepare = () => Promise.race([ Promise.all([Promise.resolve().then(() => renderer.compileAsync(scene, camera)), document.fonts?.ready]).catch(() => {}), new Promise(done => setTimeout(done, 6000)), ]); // The view opens out of the ring: the water in it drains away to show the pool through the ring's middle, then the // opening widens past the edges of the screen as the ring runs out ahead of it like a ripple, and the title rises to // its place. const ring = $('.tide-veil-ring'), HOLD = .6, OPEN = 1.6; let reveal = null; function unveil(message) { reportGraphics({ state: 'opening' }); const motion = !!post && !reduced.matches && visible && !document.hidden; reveal = { start: performance.now(), motion }; if (motion) post.final.uniforms.uOpening.value = 1; frameTime = 1 / 60; qualityClock = 4; canvas.hidden = false; render(); if (motion) veil.dataset.iris = 'hole'; else reveal = null; delete root.dataset.veiled; root.dataset.ready = 'true'; root.dataset.arriving = 'true'; setTimeout(() => { delete root.dataset.arriving; }, 3000); if (intro) intro.start = performance.now(); glideTitle(motion ? HOLD * 1000 : 0); controls().forEach(b => { b.disabled = false; }); status.textContent = message; updateButtons(); readout(); schedule(); } function openIris() { const t = (performance.now() - reveal.start) / 1000; if (t >= HOLD + OPEN) { endReveal(); return; } if (t >= HOLD && veil.dataset.iris !== 'open') veil.dataset.iris = 'open'; const { width, height } = stage.getBoundingClientRect(), ratio = renderer.getPixelRatio(); const from = ring.getBoundingClientRect().width / 2 * .66, to = Math.hypot(width, height) / 2 + 2; const u = clamp((t - HOLD) / OPEN, 0, 1), e = u < .5 ? 4 * u ** 3 : 1 - (2 - 2 * u) ** 3 / 2, r = from + (to - from) * e; // The ring's proportions: its middle is .66 of its radius, the heavy line runs to .814, the fine one from .96 to 1. const R = from / .66 * ratio, thin = 1 - .5 * e; post.final.uniforms.uIris.value.set(width / 2 * ratio, height / 2 * ratio, r * ratio, (.75 + (r - from) * .08) * ratio); post.final.uniforms.uBands.value.set(.154 * R * thin, .146 * R * (1 + 2 * e), .04 * R * thin, 1 - clamp((e - .05) / .6, 0, 1)); } function endReveal() { reveal = null; if (post) post.final.uniforms.uOpening.value = 0; if (veil.dataset.iris) veil.dataset.iris = 'done'; wake(); } // The title under the ring glides up to its place at the top as the view opens. function glideTitle(delay) { const mark = $('.tide-mark'), title = $('.tide-heading h1'); if (root.dataset.titled) return; if (!mark?.animate || reduced.matches) { root.dataset.titled = 'true'; return; } const text = element => { const range = document.createRange(); range.selectNodeContents(element); return range.getBoundingClientRect(); }; const from = text(mark), to = text(title); mark.style.transform = 'none'; mark.style.transformOrigin = '0 0'; const box = mark.getBoundingClientRect(), plain = text(mark); const place = rect => { const k = rect.width / plain.width; return `translate(${rect.left - box.left - (plain.left - box.left) * k}px, ${rect.top - box.top - (plain.top - box.top) * k}px) scale(${k})`; }; const done = () => { root.dataset.titled = 'true'; }; mark.animate([{ transform: place(from) }, { transform: place(to) }], { duration: 1500, delay, easing: 'cubic-bezier(.65, 0, .35, 1)', fill: 'both' }) .finished.then(done, done); }
const pauseButton = $('#tide-pause'), soundButton = $('#tide-sound'); let paused = reduced.matches, visible = true, lost = false, frame = 0, previous = 0, dirty = true; let audio = null, sound = false; function updateButtons() { pauseButton.textContent = paused ? 'Play' : 'Pause'; pauseButton.setAttribute('aria-pressed', String(paused)); soundButton.textContent = sound ? 'Sound on' : 'Sound off'; soundButton.setAttribute('aria-pressed', String(sound)); } function syncSound() { if (!audio) return; if (sound && !paused && visible && !document.hidden && !lost) audio.resume().catch(() => {}); else audio.suspend().catch(() => {}); } let lastChime = -1; const TONES = { brass: 587.33, shell: 440, pebble: 293.66, feed: 349.23, birth: 659.25, end: 196, build: 329.63, steal: 523.25, arrive: 392 }; function chime(kind, volume = .06) { if (!audio || !sound || audio.state !== 'running' || paused || !visible || document.hidden) return; const now = audio.currentTime; if (now - lastChime < .2) return; lastChime = now; for (const [ratio, v] of [[1, volume], [2.01, volume * .25]]) { const osc = audio.createOscillator(), gain = audio.createGain(); osc.type = 'sine'; osc.frequency.value = (TONES[kind] || 220) * ratio; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(v, now + .008); gain.gain.exponentialRampToValueAtTime(.0001, now + 1.3); osc.connect(gain); gain.connect(audio.destination); osc.start(now); osc.stop(now + 1.35); osc.onended = () => { osc.disconnect(); gain.disconnect(); }; } }
function render() { shared.uTime.value = world.time; const light = daylight(world); // Night is its own time, not a dimmer day: cool moonlight, darker water, and the lamps take over. Dusk tints the pools. const night = clamp((.52 - light) / .3, 0, 1), dusk = clamp(1 - Math.abs(light - .55) / .3, 0, 1); shared.uLight.value = .55 + .45 * light; shared.uNight.value = night; shared.uDusk.value = dusk; // The far edge of the view, for the haze: straight away from the eye, across the ground. shared.uHaze.value.set(-Math.sin(view.yaw), -Math.cos(view.yaw), view.x, view.z); shared.uHazeZoom.value = view.zoom; shared.uWater.value = waterLevel(world); shared.uSwellLift.value = swellHeight(world); uploadGrid(); uploadFlow(); uploadPlankton(1 / 30); // Sun by day, a cool low moon by night. const dayAngle = world.time / 420 * Math.PI * 2; sun.position.set(view.x - 14 + Math.cos(dayAngle) * 5, 10, view.z - 4 + Math.sin(dayAngle) * 4); sun.target.position.set(view.x, 0, view.z); if ((shadowTick = (shadowTick + 1) % 2) === 0) sun.shadow.needsUpdate = true; shared.uView.value.subVectors(camera.position, v1.set(view.x, -.5, view.z)).normalize(); // The water's glint light sits on the glitter path: mirrored about a near-level surface, so ripples catch it. const level = v2.set(.12 + Math.sin(dayAngle) * .05, 1, -.1).normalize(), eye = shared.uView.value; shared.uSun.value.copy(level).multiplyScalar(2 * level.dot(eye)).sub(eye).normalize(); // A storm is overcast: the sun and sky dim while it blows, and the water glints less. const storm = stormAt(world); shared.uStorm.value = storm; shared.uLight.value *= 1 - .3 * storm; // At night the light is the moon's: cool, and clear enough to follow everything by, brighter and more silver when it // is full. The eye adjusts to the dark as a camera's exposure would, so night reads by its colour and its lights. const moon = moonlight(world.time), mix = (a, b) => a + (b - a) * night; shared.uMoon.value = moon; sun.intensity = mix(4.6, 2.5 + 1.3 * moon) * (1 - .5 * storm); sun.color.setRGB(mix(.98, .64 + .08 * moon), mix(.95, .7 + .08 * moon), mix(.95, 1)); hemi.intensity = mix(.85, .62 + .2 * moon) * (1 - .25 * storm); hemi.color.lerpColors(skyDay, skyNight, night); scene.environmentIntensity = mix(1.15, .72 + .28 * moon) * (1 - .35 * storm); fill.intensity = mix(1.6, 1.25 + .3 * moon); renderer.toneMappingExposure = mix(1.25, 1.5); eyeBoost = 1 + 1.6 * night;
for (const p of parts) if (!p.fixed) p.n = 0; for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); tendLantern(); drawMachines(); castLamps(night); const focus = creatureById(world, selected); drawContacts(); drawBlooms(); if (focus) drawCrop(focus); drawMarker(); for (const p of parts) if (!p.fixed) flush(p.mesh, p.n); if (post) { // The sharp band lies across the middle of the view. It moves to a machine only while the view follows it, never // far from the middle, and eases there rather than jumping, so a machine picked near the edge doesn't blur the rest. let at = .5; if (focus && following) { v1.set(focus.x, SPECIES[focus.sp].kind === 'swimmer' ? focus.y : footAt(world, focus), focus.z).project(camera); at = clamp((v1.y + 1) / 2, .32, .68); } tiltFocus += (at - tiltFocus) * .08; at = tiltFocus; const amount = clamp(.55 + (view.zoom - 1.4) * .15, .55, 1) * 1.8 * renderer.getPixelRatio(); // On a tall screen the sharp band takes a larger share of the height, so a phone keeps as much of the shelf in focus. const band = .17 * clamp(stage.clientHeight / Math.max(1, stage.clientWidth) * .75, 1, 1.8); post.tilt.uFocus.value = at; post.tilt.uAmount.value = amount; post.tilt.uBand.value = band; } moveSurface(); if (reveal?.motion) openIris(); if (post) post.render(); else renderer.render(scene, camera); placeLabels(); pointCompass(); drawMap(); dirty = false; }
// Readouts refresh a few times a second, not every frame. const timeOut = $('#tide-time'), tideOut = $('#tide-level'), basinOut = $('#tide-basin'); const chart = $('#tide-chart'), chartContext = chart.getContext('2d'); const counts = [...root.querySelectorAll('[data-species]')]; // Pool buttons follow the pools that exist right now. const basinGroup = $('.tide-basins'); function syncBasins(here) { const names = [...world.bodies].sort((a, b) => b.area - a.area).slice(0, 9).map(b => b.name); if (basinGroup.dataset.names !== names.join()) { basinGroup.dataset.names = names.join(); basinGroup.replaceChildren(...names.map((name, i) => { const b = document.createElement('button'); b.type = 'button'; b.dataset.basin = name; b.textContent = name; b.setAttribute('aria-label', `Pool ${name}`); b.addEventListener('click', () => goToBasin(i)); b.addEventListener('pointerenter', () => { pointedPool = name; wake(); }); b.addEventListener('pointerleave', () => { pointedPool = null; wake(); }); b.addEventListener('focus', () => { showPool(name, 3); wake(); }); return b; })); } basinGroup.querySelectorAll('button').forEach(b => b.setAttribute('aria-current', String(b.dataset.basin === here))); } const clock = t => { const hours = ((t / 420 * 24 + 7.7) % 24 + 24) % 24; return `${String(Math.floor(hours)).padStart(2, '0')}:${String(Math.floor(hours % 1 * 60)).padStart(2, '0')}`; }; function readout() { timeOut.textContent = clock(world.time); const tide = tideOf(world.time); tideOut.textContent = `${tideRising(world.time) ? '▲' : '▼'} ${Math.round(tide * 100)}%`; const here = bodyAt(world, view.x, view.z); basinOut.textContent = here === 'sea' ? 'Sea' : here === 'puddle' ? '·' : here || 'Shelf'; syncBasins(here); const focus = creatureById(world, selected); const n = SPECIES_ORDER.map(s => world.creatures.filter(c => c.sp === s).length); counts.forEach(b => { b.querySelector('[data-count]').textContent = n[SPECIES_ORDER.indexOf(b.dataset.species)]; b.setAttribute('aria-current', String(focus?.sp === b.dataset.species)); }); drawChart(focus?.sp); paintTerrain(); inspect(); } function drawChart(highlight) { const w = chart.width = chart.clientWidth * devicePixelRatio || 240, h = chart.height = 56 * devicePixelRatio; const g = chartContext; g.clearRect(0, 0, w, h); const rows = world.census; if (rows.length < 2) return; const order = SPECIES_ORDER.map((s, i) => i).sort((a, b) => (SPECIES_ORDER[a] === highlight) - (SPECIES_ORDER[b] === highlight)); for (const i of order) { const max = SPECIES[SPECIES_ORDER[i]].max; g.beginPath(); rows.forEach((r, k) => { const x = k / (Math.max(rows.length, 30) - 1) * w, y = h - 2 - r.n[i] / max * (h - 6); if (k === 0) g.moveTo(x, y); else { g.lineTo(x, rows[k - 1] ? h - 2 - rows[k - 1].n[i] / max * (h - 6) : y); g.lineTo(x, y); } }); const on = SPECIES_ORDER[i] === highlight; g.strokeStyle = on ? '#d4b9df' : highlight ? '#3d3a40' : ['#b3aea6', '#d9d3dc', '#77737a', '#8d8496', '#d6d0c2', '#5d5b62'][i]; g.lineWidth = (on ? 2 : 1) * devicePixelRatio; g.stroke(); } }
// The specimen sheet follows one machine, and keeps its record once it stops. const sheet = $('#tide-inspect'); const fields = { kind: $('#tide-inspect-kind'), title: $('#tide-inspect-title'), gen: $('#tide-gen'), age: $('#tide-age'), where: $('#tide-where'), lineage: $('#tide-lineage'), bar: $('#tide-energy-bar'), energy: $('#tide-energy'), goal: $('#tide-goal'), carrying: $('#tide-carrying'), log: $('#tide-log') }; const followButton = $('#tide-follow'); const placeName = (x, z, short) => { const b = bodyAt(world, x, z); return b === 'sea' ? (short ? 'Sea' : 'in the sea') : b === 'puddle' ? (short ? 'Puddle' : 'in a puddle') : b ? (short ? b : `in pool ${b}`) : (short ? 'Shelf' : 'on the dry shelf'); }; const duration = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; const DEBUT = { pylon: 'It filters the water, and folds shut when the water is disturbed.', borer: 'It digs.', artificer: 'It builds structures one part at a time, and machines: sluice gates and beacons.', collector: 'It hoards brass.', mason: 'It fills hollows and builds walls.', breaker: 'It hunts.', }; function eventText(e, subject) { const who = e.who === subject ? 'It' : e.who; switch (e.type) { case 'birth': return `${who} cast ${e.other}.`; case 'end': return e.detail === 'eaten' ? `${who} was taken by ${e.other}.` : e.detail === 'caught' ? `${who} was caught by ${e.other}.` : e.detail === 'toppled' ? `${who} was toppled by ${e.other}.` : `${who} ${e.detail}.`; case 'steal': return `${who} lifted brass from ${e.other}.`; case 'hoard': return `${who} hoarded ${e.detail}.`; case 'collect': return `${who} picked up brass.`; case 'gather': return `${who} picked up ${e.detail}.`; case 'build': return `${who} set a ${e.detail} block.`; case 'dismantle': return `${who} knocked a block loose.`; case 'scavenge': return `${who} salvaged a ${e.detail}.`; case 'shelter': return `${who} moved into a shell.`; case 'crack': return `${who} cracked ${e.other}'s shell.`; case 'arrive': return `${who} arrived from the sea.`; case 'debut': return `A${e.sp === 'artificer' ? 'n' : ''} ${SPECIES[e.sp].name.toLowerCase()} has arrived. ${DEBUT[e.sp]}`; case 'plan': return `${who} began a ${MACHINES[e.detail].name}.`; case 'assemble': return `${who} fitted a part to a ${MACHINES[e.detail].name}.`; case 'complete': return `${who} finished a ${MACHINES[e.detail].name}.`; case 'wreck': return `${who} wrecked a ${MACHINES[e.detail].name}.`; case 'found': return `${who} set the first frame of a new structure.`; case 'erect': return `${who} fitted ${e.detail} part${e.detail === 1 ? '' : 's'} to a structure.`; case 'unbolt': return `${who} tore a ${e.detail} off a structure.`; case 'collapse': return `Part of a structure gave way: ${e.detail} pieces fell.`; case 'fall': return `${who} fell from a structure.`; case 'storm': return 'A storm is coming in off the sea.'; case 'calm': return 'The storm has passed.'; case 'move-in': return `${who} moved into a pod.`; case 'nest': return `${who} moved its hoard up onto a deck.`; case 'loosen': return `${who} worked a ${e.detail} loose.`; case 'repair': return `${who} made good ${e.detail === 1 ? 'a loosened part' : e.detail + ' loosened parts'}.`; case 'dam': return `${who} raised the dam on pool ${e.detail}.`; case 'form': return `Pool ${e.detail} has formed.`; case 'inspect': return `${who} inspected ${e.detail}.`; case 'grow': return `${who} grew a segment.`; case 'wash': return `The tide brought ${e.detail}.`; default: return null; } } function inspect() { if (selected === null) { sheet.hidden = true; return; } const c = creatureById(world, selected); sheet.hidden = false; if (c) { const S = SPECIES[c.sp]; sheet.classList.remove('is-ended'); fields.kind.textContent = `${S.name}${S.core ? ' · brass core' : ''}`; fields.title.textContent = label(c); fields.gen.textContent = String(c.gen); fields.age.textContent = duration(c.age); fields.where.textContent = placeName(c.x, c.z, true); const parent = world.creatures.find(o => o.id === c.parent) || world.ended.find(o => o.id === c.parent); fields.lineage.textContent = `${parent ? `of ${parent.label || label(parent)}` : c.gen === 1 ? 'founder' : 'of —'}${c.kids ? ` · ${c.kids} cast` : ''}`; fields.bar.style.width = `${Math.round(clamp(c.energy, 0, 1) * 100)}%`; fields.energy.textContent = `Energy ${Math.round(c.energy * 100)}%${c.energy < .15 ? ' · failing' : ''}${c.sp === 'pylon' ? ` · ${c.size + 1} segments` : ''}${c.eaten ? ` · ${c.eaten} taken` : ''}`; fields.goal.textContent = goalText(world, c).replace(/^./, m => m.toUpperCase()) + '.'; const held = world.objects.find(o => o.id === c.carrying); fields.carrying.textContent = [held ? `Carrying ${held.kind}` : '', c.shell !== null ? 'Wearing a shell' : '', c.sp === 'collector' ? `Hoard: ${world.objects.filter(o => o.place === 'hoard' && o.owner === c.id).length} brass` : ''].filter(Boolean).join(' · '); lastEnded = null; } else { const record = world.ended.find(e => e.id === selected) || lastEnded; if (!record) { release(); return; } lastEnded = record; sheet.classList.add('is-ended'); fields.kind.textContent = `${SPECIES[record.sp].name} · stopped at ${clock(record.time)}`; fields.title.textContent = record.label; fields.goal.textContent = record.cause === 'eaten' ? `Taken by ${record.by}.` : record.cause === 'caught' ? `Caught by ${record.by}.` : record.cause === 'toppled' ? `Toppled by ${record.by}.` : record.cause === 'stranded' ? 'Stranded when the basin drained.' : record.cause === 'starved' ? 'Ran out of energy.' : 'Wore out with age.'; fields.carrying.textContent = SPECIES[record.sp].core ? 'Its husk and brass core remain in the water.' : ''; fields.bar.style.width = '0%'; fields.energy.textContent = 'Energy 0%'; if (following) setFollow(false); } const name = c ? label(c) : lastEnded?.label; const lines = world.events.filter(e => e.who === name || e.other === name).slice(-4).reverse(); fields.log.replaceChildren(...lines.map(e => { const text = eventText(e, name); if (!text) return null; const li = document.createElement('li'), time = document.createElement('time'); time.textContent = clock(e.time); li.append(time, text); return li; }).filter(Boolean)); } function setFollow(on) { following = on; followButton.setAttribute('aria-pressed', String(on)); followButton.textContent = on ? 'Following' : 'Follow'; } function select(c, { announce = true } = {}) { intro = null; selected = c.id; lastEnded = null; root.dataset.engaged = 'true'; setFollow(true); if (goal.zoom > 4) goal.zoom = 3.6; goal.x = c.x; goal.z = c.z; clampGoal(); if (announce) status.textContent = `Following ${label(c)}, a ${SPECIES[c.sp].name.toLowerCase()}. ${goalText(world, c).replace(/^./, m => m.toUpperCase())}.`; readout(); wake(); } function release() { selected = null; lastEnded = null; setFollow(false); sheet.hidden = true; readout(); wake(); } followButton.addEventListener('click', () => { const c = creatureById(world, selected); if (!c) return; setFollow(!following); if (following) { goal.x = c.x; goal.z = c.z; clampGoal(); } wake(); }); // The close button in the sheet's corner lets go just as Release does. root.querySelectorAll('#tide-release, #tide-inspect-close').forEach(b => b.addEventListener('click', () => { release(); status.textContent = 'Released. Tap any machine to follow it.'; canvas.focus(); }));
// A quiet ticker of notable events near the view. const ticker = $('#tide-ticker'); let tickerSeen = 0; function tick() { const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan', 'found', 'collapse', 'fall', 'storm', 'calm'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { const text = eventText(e); if (!text) continue; const li = document.createElement('li'), time = document.createElement('time'); time.textContent = clock(e.time); li.append(time, text); ticker.append(li); chime(e.type, e.type === 'end' ? .05 : .035); // Arrivals are news: they show in the status line even before the visitor has touched anything. if (e.type === 'debut' && selected === null) { status.textContent = text; status.dataset.news = 'true'; } } while (ticker.children.length > 4) ticker.firstElementChild.remove(); }
let readoutClock = 0, lastFrame = 0, tiltFocus = .5, shadowTick = 0; function loop(now) { frame = 0; if (!visible || document.hidden || lost) { previous = 0; return; } const dt = lastFrame ? Math.min((now - lastFrame) / 1000, .1) : 1 / 60; lastFrame = now; if (!paused) tuneQuality(dt); if (!paused) { // Keep real-time behavior on slower GPUs without destabilizing the solver. if (previous) { let elapsed = Math.min((now - previous) / 1000, .25); while (elapsed > 0) { const step = Math.min(elapsed, .05); advanceWorld(world, step); elapsed -= step; } } previous = now; readoutClock -= dt; if (readoutClock <= 0) { readoutClock = .4; readout(); tick(); } } else previous = 0; const driving = drive(dt); aim(dt); easeCamera(dt); render(); // Something in this frame may already have asked for the next one, as the pointer's hover does while the view glides // under it; asking again would run the loop twice the next frame, then four times, and so on until the page stalls. if (!frame && (!paused || driving || !cameraSettled() || reveal)) frame = requestAnimationFrame(loop); if (!frame) lastFrame = 0; } // Where the camera wants to be this frame: on the machine being followed, or wherever the wandering eye has settled. function aim(dt) { const focus = following && creatureById(world, selected); if (focus) { // On narrow screens the specimen sheet covers the lower half, so frame the machine above it. const lift = stage.clientWidth < 720 ? view.zoom * .42 / Math.sin(PITCH) : 0; const upScreen = v2.setFromMatrixColumn(camera.matrixWorld, 1); upScreen.y = 0; upScreen.normalize(); goal.x = focus.x - upScreen.x * lift; goal.z = focus.z - upScreen.z * lift; clampGoal(); } else if (director.on && !paused) direct(dt); } function wake() { dirty = true; if (!frame && visible && !document.hidden && !lost) frame = requestAnimationFrame(loop); } function schedule() { syncSound(); if (visible && !document.hidden && !lost) wake(); else { cancelAnimationFrame(frame); frame = 0; previous = 0; lastFrame = 0; } } // Resolution: as sharp as the screen allows within a budget of pixels, eased down only if frames actually slow, and // further and faster the slower they are, and back up once there is room again, though not straight back to where // frames were too slow unless that was a while ago, so it doesn't swing back and forth. const BUDGET = mobile ? 2.4e6 : 4.2e6; let quality = 1, frameTime = 1 / 60, qualityClock = 3, tooSlow = Infinity, slowAt = 0; const pixelRatio = (width, height) => Math.max(Math.min(devicePixelRatio, 1) * Math.min(1, quality / .8), Math.min(devicePixelRatio, mobile ? 1.5 : 2, Math.sqrt(BUDGET / Math.max(1, width * height))) * quality); function tuneQuality(dt) { frameTime += (dt - frameTime) * .05; qualityClock -= dt; if (qualityClock > 0) return; qualityClock = 2; const now = performance.now(); let next = quality; if (frameTime > 1 / 42) { tooSlow = quality; slowAt = now; next = Math.max(.6, quality * (frameTime > 1 / 28 ? .8 : .9)); } else if (frameTime < 1 / 56 && (quality * 1.08 < tooSlow * .97 || now - slowAt > 30000)) next = Math.min(1, quality * 1.08); if (Math.abs(next - quality) > .01) { quality = next; post?.see(!mobile && quality > .95); resize(); } } function resize() { const { width, height } = stage.getBoundingClientRect(); renderer.setPixelRatio(pixelRatio(width, height)); renderer.setSize(width, height, false); post?.resize(); // A tall screen sees less across, so it opens further out, once. if (width / height < .8 && !widened) { widened = true; goal.zoom = view.zoom = goal.zoom * 1.45; if (intro) intro.to.zoom *= 1.45; } placeCamera(); sizeMap(); wake(); }
const names = { brass: 'Brass', shell: 'Shell', pebble: 'Pebble', feed: 'Feed' }; const hints = { look: 'Tap a machine to follow it.', brass: 'A collector will come for it.', shell: 'A scraper may move into it.', pebble: 'A mason will set it on a cairn.', feed: 'Film and plankton bloom where it lands.', }; function drop(x, z) { delete status.dataset.news; if (!onShelf(world, x, z)) { status.textContent = 'Tap on the shelf, not beyond it.'; return; } const o = offerObject(world, tool, x, z); if (!o) { status.textContent = 'The pool is full of keepsakes. Begin again for a fresh pool.'; return; } if (paused && o.height) o.height = 0; root.dataset.engaged = 'true'; const pool = placeName(x, z); const here = namedPool(bodyAt(world, x, z)); if (here) showPool(here); const drawn = o.id !== null && world.creatures.find(c => c.task?.object === o.id); if (tool === 'feed') { blooms.push({ x, z, born: world.time }); if (blooms.length > 8) blooms.shift(); const grazers = world.creatures.filter(c => c.sp === 'scraper' && c.task?.kind === 'graze' && Math.hypot(c.task.at.x - x, c.task.at.z - z) < .5).length; status.textContent = `Feed blooms ${pool}.${grazers ? ` ${grazers} scraper${grazers === 1 ? '' : 's'} drift toward it.` : ''}${paused ? ' Press Play when you want to watch.' : ''}`; } else status.textContent = `${names[tool]} dropped ${pool}.${drawn ? ` ${label(drawn)} turns toward it.` : ''}${paused ? ' Press Play when you want to watch.' : ''}`; chime(tool); readout(); wake(); }
// Pointer: drag to pan, pinch or scroll to zoom, tap to select or drop. const pointers = new Map(); let gesture = null; function panBy(dx, dy) { const bounds = canvas.getBoundingClientRect(); const unit = view.zoom * 2 / bounds.height; const right = v1.setFromMatrixColumn(camera.matrixWorld, 0); right.y = 0; right.normalize(); const upScreen = v2.setFromMatrixColumn(camera.matrixWorld, 1); upScreen.y = 0; upScreen.normalize(); const fore = unit / Math.sin(PITCH); goal.x -= right.x * dx * unit - upScreen.x * dy * fore; goal.z -= right.z * dx * unit - upScreen.z * dy * fore; view.x = goal.x; view.z = goal.z; clampGoal(); view.x = goal.x; view.z = goal.z; } function zoomAt(factor, clientX, clientY) { intro = null; const before = clientX !== undefined && groundAt(clientX, clientY); goal.zoom = clamp(goal.zoom * factor, ZMIN, ZMAX); view.zoom = goal.zoom; placeCamera(); if (before && !following) { const after = groundAt(clientX, clientY); if (after) { goal.x += before.x - after.x; goal.z += before.z - after.z; clampGoal(); view.x = goal.x; view.z = goal.z; } } wake(); } // Turns the view about its middle; a positive turn swings the view to the left, so the scene turns clockwise. function turnBy(angle) { goal.yaw += angle; view.yaw = goal.yaw; placeCamera(); wake(); } const wrapAngle = a => a - Math.round(a / (Math.PI * 2)) * Math.PI * 2; // A double tap or double click goes in closer on that spot, or on the machine being followed. function zoomInto(clientX, clientY) { intro = null; root.dataset.engaged = 'true'; const hit = !following && floorAt(clientX, clientY); if (hit) { goal.x = hit.x; goal.z = hit.z; } goal.zoom = goal.zoom * .5; clampGoal(); wake(); } let lastTap = null; canvas.addEventListener('pointerdown', e => { try { canvas.setPointerCapture?.(e.pointerId); } catch { /* a pointer the page cannot capture still works without it */ } pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (e.pointerType !== 'mouse') holdLantern(e); // The right button, or the left with Option or Alt held, turns the view instead of panning it. if (pointers.size === 1) gesture = { x: e.clientX, y: e.clientY, moved: false, turn: e.pointerType === 'mouse' && (e.button === 2 || e.altKey) }; else if (pointers.size === 2) { const [a, b] = [...pointers.values()]; gesture = { pinch: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, angle: Math.atan2(b.y - a.y, b.x - a.x), twist: 0, moved: true }; } }); canvas.addEventListener('contextmenu', e => e.preventDefault()); // Where a resting mouse is, so the marker can keep up with it when the view moves under it. let hovering = null; function hover(point) { hovering = { clientX: point.clientX, clientY: point.clientY }; const hit = floorAt(point.clientX, point.clientY); const over = creatureAt(point.clientX, point.clientY); marker.visible = !!hit && tool !== 'look' && !over && onShelf(world, hit.x, hit.z); if (hit) cursor = hit; pointedPool = hit ? namedPool(bodyAt(world, hit.x, hit.z)) : null; holdLantern(point); canvas.classList.toggle('is-dropping', marker.visible); canvas.style.cursor = over ? 'pointer' : ''; wake(); } canvas.addEventListener('pointermove', e => { const known = pointers.get(e.pointerId); if (!known) { if (e.pointerType === 'mouse') hover(e); return; } const dx = e.clientX - known.x, dy = e.clientY - known.y; known.x = e.clientX; known.y = e.clientY; if (e.pointerType === 'mouse') hovering = { clientX: e.clientX, clientY: e.clientY }; if (e.pointerType !== 'mouse' && pointers.size === 1) holdLantern(e); if (pointers.size === 1 && gesture) { if (!gesture.moved && Math.hypot(e.clientX - gesture.x, e.clientY - gesture.y) > 6) { gesture.moved = true; canvas.classList.add('is-dragging'); intro = null; root.dataset.engaged = 'true'; // Turning keeps a followed machine in the middle; panning lets go of it. if (following && !gesture.turn) setFollow(false); } if (gesture.moved) { // Half a turn across the width of the view, the ground near the eye following the hand. if (gesture.turn) turnBy(-dx * Math.PI / Math.max(1, canvas.clientWidth)); else { panBy(dx, dy); wake(); } } } else if (pointers.size === 2 && gesture?.pinch) { const [a, b] = [...pointers.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y), mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; if (!following) panBy(mid.x - gesture.mid.x, mid.y - gesture.mid.y); zoomAt(gesture.pinch / Math.max(1, d), mid.x, mid.y); // Twisting two fingers turns the view with them, once the twist is plainly meant, so a pinch alone stays level. const angle = Math.atan2(b.y - a.y, b.x - a.x), twist = wrapAngle(angle - gesture.angle); gesture.twist += twist; gesture.angle = angle; if (gesture.turning) turnBy(twist); else if (Math.abs(gesture.twist) > .2) { gesture.turning = true; turnBy(gesture.twist); } gesture.pinch = d; gesture.mid = mid; } }); function endPointer(e, cancelled) { if (!pointers.has(e.pointerId)) return; pointers.delete(e.pointerId); if (e.pointerType !== 'mouse' && !pointers.size) dropLantern(); canvas.classList.remove('is-dragging'); if (pointers.size) { gesture = null; lastTap = null; return; } const tap = gesture && !gesture.moved && !gesture.turn && !cancelled; gesture = null; if (!tap) { lastTap = null; return; } // The second tap of a double tap zooms in rather than tapping again. const now = performance.now(); if (lastTap && now - lastTap.time < 320 && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < 24) { lastTap = null; zoomInto(e.clientX, e.clientY); return; } lastTap = { time: now, x: e.clientX, y: e.clientY }; const hitCreature = creatureAt(e.clientX, e.clientY); if (hitCreature) { select(hitCreature); return; } const hit = floorAt(e.clientX, e.clientY); if (tool === 'look') { if (selected !== null) { release(); status.textContent = 'Released. Tap any machine to follow it.'; } else status.textContent = 'Tap a machine to follow it, or choose a material to drop.'; return; } if (hit) { cursor = hit; drop(hit.x, hit.z); } } canvas.addEventListener('pointerup', e => endPointer(e, false)); canvas.addEventListener('pointercancel', e => endPointer(e, true)); canvas.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !pointers.size) { hovering = null; marker.visible = false; pointedPool = null; dropLantern(); } }); canvas.addEventListener('wheel', e => { e.preventDefault(); const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; zoomAt(Math.exp(clamp(delta, -120, 120) * .0022), e.clientX, e.clientY); }, { passive: false }); canvas.addEventListener('focus', () => { if (tool !== 'look') marker.visible = true; wake(); }); canvas.addEventListener('blur', () => { marker.visible = false; wake(); });
function stepSelection(direction) { const list = [...world.creatures].sort((a, b) => a.x - b.x || a.z - b.z); if (!list.length) return; const i = list.findIndex(c => c.id === selected); const next = i < 0 ? list.reduce((a, c) => Math.hypot(c.x - view.x, c.z - view.z) < Math.hypot(a.x - view.x, a.z - view.z) ? c : a) : list[(i + direction + list.length) % list.length]; select(next); } // Where Space or Enter acts: under a resting mouse, else at the keyboard's marker while it is in view, else in the // middle of the view. function onScreen(p) { v1.set(p.x, heightAt(world, p.x, p.z), p.z).project(camera); return Math.abs(v1.x) < .95 && Math.abs(v1.y) < .95; } function aimPoint() { if (hovering) return floorAt(hovering.clientX, hovering.clientY); if (marker.visible && onScreen(cursor)) return cursor; const b = canvas.getBoundingClientRect(); return floorAt(b.left + b.width / 2, b.top + b.height / 2); } function act() { const at = aimPoint(); if (!at) return; cursor = { x: at.x, z: at.z }; if (tool === 'look') { const near = world.creatures.reduce((a, c) => !a || Math.hypot(c.x - at.x, c.z - at.z) < Math.hypot(a.x - at.x, a.z - at.z) ? c : a, null); if (near && Math.hypot(near.x - at.x, near.z - at.z) < 1.5) select(near); else status.textContent = 'No machine there. Brackets step between machines.'; } else { drop(at.x, at.z); marker.visible = true; } wake(); } // Z and X step through what a tap does, in the order of the buttons. function cycleTool(direction) { const i = toolButtons.findIndex(b => b.dataset.tool === tool); toolButtons[(i + direction + toolButtons.length) % toolButtons.length].click(); } // Screen-relative arrows move the marker; with Shift they pan the view. function nudge(key, shift) { const axis = new T.Vector3().setFromMatrixColumn(camera.matrixWorld, key === 'ArrowLeft' || key === 'ArrowRight' ? 0 : 1); axis.y = 0; axis.normalize(); const direction = key === 'ArrowLeft' || key === 'ArrowDown' ? -1 : 1; const step = shift ? view.zoom * .25 : .25; if (shift) { if (following) setFollow(false); goal.x += axis.x * step * direction; goal.z += axis.z * step * direction; clampGoal(); } else { const next = { x: cursor.x + axis.x * step * direction, z: cursor.z + axis.z * step * direction }; if (onShelf(world, next.x, next.z)) cursor = next; if (Math.abs(cursor.x - goal.x) > view.zoom * .8 || Math.abs(cursor.z - goal.z) > view.zoom * .6) { goal.x = cursor.x; goal.z = cursor.z; clampGoal(); } } marker.visible = true; wake(); }
// Keys work from anywhere on the page but a text field, and leave the browser's own shortcuts alone. Held keys move the // view the way a game's camera moves: W, A, S and D glide it across the screen, Q and E turn it, R and F zoom, and // Shift goes faster. They are read by where they sit on the keyboard, so other layouts work too. const DRIVE = { KeyW: [0, 1, 0, 0], KeyA: [-1, 0, 0, 0], KeyS: [0, -1, 0, 0], KeyD: [1, 0, 0, 0], KeyQ: [0, 0, 1, 0], KeyE: [0, 0, -1, 0], KeyR: [0, 0, 0, -1], KeyF: [0, 0, 0, 1] }; const held = new Set(), pace = { x: 0, y: 0, turn: 0, zoom: 0 }; let hurry = false, edging = false, edgeSince = 0; const typing = el => el?.closest?.('input:not([type="checkbox"]), textarea, select, [contenteditable]'); const pressable = el => el !== canvas && el?.closest?.('button, a, summary, input, label'); addEventListener('keydown', e => { hurry = e.shiftKey; // With a modifier down the keys belong to the browser; a Command shortcut can also swallow the key-up, so let go. if (e.metaKey || e.ctrlKey || e.altKey) { held.clear(); return; } if (!root.dataset.ready || !visible || typing(e.target)) return; if (DRIVE[e.code]) { e.preventDefault(); if (held.has(e.code)) return; held.add(e.code); intro = null; root.dataset.engaged = 'true'; // Gliding lets go of a followed machine; turning and zooming keep it in the middle. if (following && (DRIVE[e.code][0] || DRIVE[e.code][1])) setFollow(false); stir(); wake(); } else if (command(e)) { e.preventDefault(); stir(); } }); addEventListener('keyup', e => { hurry = e.shiftKey; held.delete(e.code); }); addEventListener('blur', () => held.clear()); function command(e) { const { key } = e; if (key === '?') { toggleSettings(); return true; } if (key === 'Escape') { if (!settingsPanel.hidden) { toggleSettings(false); return true; } if (selected !== null) { release(); status.textContent = 'Released.'; return true; } return false; } if (key === ' ' || key === 'Enter') { if (pressable(e.target)) return false; act(); return true; } if (e.code === 'KeyZ' || e.code === 'KeyX') { cycleTool(e.code === 'KeyX' ? 1 : -1); return true; } if (key === '[' || key === ']') { stepSelection(key === ']' ? 1 : -1); return true; } if (key.length === 1 && '+=-_'.includes(key)) { zoomAt(key === '+' || key === '=' ? .8 : 1.25); return true; } if (/^[1-9]$/.test(key)) { goToBasin(+key - 1); return true; } // Arrows still scroll the notes when they are being read. if (key.startsWith('Arrow') && !e.target.closest?.('.tide-notes, .tide-settings')) { nudge(key, e.shiftKey); return true; } return false; } // Eases toward the held keys' pace and away again on release, so the view starts and stops softly. Reports whether it // is moving, which keeps frames coming even while the world is paused. function drive(dt) { let x = 0, y = 0, turn = 0, zoom = 0; for (const code of held) { const [a, b, c, d] = DRIVE[code]; x += a; y += b; turn += c; zoom += d; } const length = Math.hypot(x, y) || 1, fast = hurry ? 2.5 : 1; x = x / length * .6 * fast; y = y / length * .6 * fast; // With edge scrolling on, a mouse resting near the edge of the view carries it that way, faster the closer it is. // It waits a moment first, so a mouse passing over the edge on its way somewhere doesn't move anything. const edge = options.edge && hovering && !pointers.size && document.hasFocus() && edgePull(); edging = !!edge; if (!edge) edgeSince = 0; else if (!edgeSince) edgeSince = performance.now(); const pulling = edge && performance.now() - edgeSince > 250; if (pulling) { x += edge.x * .9; y += edge.y * .9; intro = null; if (following) setFollow(false); } const k = 1 - Math.exp(-dt * (reduced.matches ? 60 : 10)); pace.x += (x - pace.x) * k; pace.y += (y - pace.y) * k; pace.turn += (clamp(turn, -1, 1) * 1.4 * fast - pace.turn) * k; pace.zoom += (clamp(zoom, -1, 1) * 1.1 * fast - pace.zoom) * k; if (!held.size && !pulling && Math.abs(pace.x) + Math.abs(pace.y) + Math.abs(pace.turn) + Math.abs(pace.zoom) < .01) { pace.x = pace.y = pace.turn = pace.zoom = 0; // Waiting at the edge still needs frames to notice when the wait is over. return edging; } // Pace is in screen heights a second; panning by the drag that would carry the ground the other way moves the view. const height = canvas.getBoundingClientRect().height * dt; if (pace.x || pace.y) panBy(-pace.x * height, pace.y * height); if (pace.turn) turnBy(pace.turn * dt); if (pace.zoom) { goal.zoom = clamp(goal.zoom * Math.exp(pace.zoom * dt), ZMIN, ZMAX); view.zoom = goal.zoom; placeCamera(); } if (hovering) hover(hovering); return true; } function edgePull() { const b = canvas.getBoundingClientRect(), EDGE = 36, { clientX: x, clientY: y } = hovering; const near = d => Math.max(0, 1 - d / EDGE); const pull = { x: near(b.right - x) - near(x - b.left), y: near(y - b.top) - near(b.bottom - y) }; return pull.x || pull.y ? pull : null; }
function goToBasin(i) { intro = null; const p = [...world.bodies].sort((a, b) => b.area - a.area)[i]; if (!p) return; if (following) setFollow(false); goal.x = p.x; goal.z = p.z + .3; goal.zoom = Math.min(Math.max(goal.zoom, 4.5), 9); cursor = { x: p.x, z: p.z }; clampGoal(); showPool(p.name, 3); const n = world.creatures.filter(c => bodyAt(world, c.x, c.z) === p.name).length; status.textContent = `Pool ${p.name}. ${Math.round(p.area)} m² of water, ${n} machine${n === 1 ? '' : 's'}.`; readout(); wake(); } $('#tide-zoom-in').addEventListener('click', () => zoomAt(.75)); $('#tide-zoom-out').addEventListener('click', () => zoomAt(1.33)); counts.forEach(b => b.addEventListener('click', () => { // Step through the members of one species, starting with the one nearest the view. const members = world.creatures.filter(c => c.sp === b.dataset.species) .sort((a, c) => Math.hypot(a.x - view.x, a.z - view.z) - Math.hypot(c.x - view.x, c.z - view.z)); if (!members.length) { status.textContent = `No ${SPECIES[b.dataset.species].name.toLowerCase()}s right now. The sea may bring one at high tide.`; return; } const current = members.findIndex(c => c.id === selected); select(current >= 0 ? members[(current + 1) % members.length] : members[0]); }));
const toolButtons = [...root.querySelectorAll('[data-tool]')]; toolButtons.forEach(button => button.addEventListener('click', () => { tool = button.dataset.tool; toolButtons.forEach(b => b.setAttribute('aria-pressed', String(b === button))); const instructions = $('#tide-instructions'); instructions.replaceChildren(document.createTextNode(tool === 'look' ? 'Tap a machine to follow it.' : `Tap the water to drop ${tool}.`)); const hint = document.createElement('span'); hint.textContent = hints[tool]; instructions.append(hint); status.textContent = tool === 'look' ? hints.look : `${names[tool]} selected. ${hints[tool]}`; marker.visible = tool !== 'look' && document.activeElement === canvas; wake(); })); // Settings stay in this browser, and only these few choices. // The map starts hidden. Choices saved before it did keep everything but the map. const OPTIONS = 'tide-pool-settings'; const options = { wander: true, edge: false, map: false }; try { const saved = localStorage.getItem(OPTIONS); if (saved) Object.assign(options, JSON.parse(saved)); else { const { map, ...earlier } = JSON.parse(localStorage.getItem('tide-pool-options') || '{}'); Object.assign(options, earlier); } } catch { /* the defaults do */ } const settingsPanel = $('#tide-settings'), settingsButton = $('#tide-settings-toggle'); function toggleSettings(open = settingsPanel.hidden) { settingsPanel.hidden = !open; settingsButton.setAttribute('aria-expanded', String(open)); root.dataset.engaged = 'true'; } settingsButton.addEventListener('click', () => toggleSettings()); $('#tide-settings-close').addEventListener('click', () => { toggleSettings(false); settingsButton.focus(); }); function applyOptions() { root.dataset.map = options.map ? 'on' : 'off'; // Turning the wandering off stops it where it is. if (!options.wander && director.on) { director.on = false; director.shot = null; Object.assign(goal, { x: view.x, z: view.z, zoom: view.zoom }); } sizeMap(); wake(); } root.querySelectorAll('[data-option]').forEach(box => { box.checked = !!options[box.dataset.option]; box.addEventListener('change', () => { options[box.dataset.option] = box.checked; try { localStorage.setItem(OPTIONS, JSON.stringify(options)); } catch { /* kept for this visit only */ } applyOptions(); }); });
// The compass shows which way the view is turned, and turns it back to where it began. const compass = $('#tide-compass'), needle = compass.querySelector('svg'); let shownYaw = null; function pointCompass() { if (Math.abs(view.yaw - (shownYaw ?? Infinity)) < .002) return; shownYaw = view.yaw; needle.style.transform = `rotate(${view.yaw}rad)`; } compass.addEventListener('click', () => { intro = null; goal.yaw = view.yaw + wrapAngle(YAW - view.yaw); status.textContent = 'The view turns back to face in across the shelf.'; wake(); }); $('#tide-machine-prev').addEventListener('click', () => stepSelection(-1)); $('#tide-machine-next').addEventListener('click', () => stepSelection(1));
// The map: the whole shelf from above, the sea along its foot, with the water, the structures, the machines, and the // part of the shelf in view. Tap or drag on it to go there. const mapCanvas = $('#tide-map'), mapContext = mapCanvas.getContext('2d'); const terrain = document.createElement('canvas'); terrain.width = COLS; terrain.height = ROWS; const terrainContext = terrain.getContext('2d'), terrainImage = terrainContext.createImageData(COLS, ROWS); let mapSize = null, mapClock = 0; function sizeMap() { const width = options.map ? mapCanvas.clientWidth : 0; mapSize = width ? { width, height: width * ROWS / COLS, ratio: Math.min(devicePixelRatio, 2) } : null; if (!mapSize) return; mapCanvas.width = Math.round(mapSize.width * mapSize.ratio); mapCanvas.height = Math.round(mapSize.height * mapSize.ratio); paintTerrain(); drawMap(true); } function paintTerrain() { if (!mapSize) return; const { w, h, wall } = world, data = terrainImage.data; for (let k = 0; k < COLS * ROWS; k++) { const ground = h[k] + wall[k], depth = w[k] - ground, o = k * 4; if (depth > .02) { // Water: violet, deeper the deeper it lies. const d = clamp(depth / .9, 0, 1); data[o] = 46 + 34 * d; data[o + 1] = 38 + 26 * d; data[o + 2] = 74 + 76 * d; } else { // Rock: darker low down, lighter where it stands up. const r = clamp((ground + 1.2) / 2.6, 0, 1); data[o] = data[o + 1] = 18 + 44 * r; data[o + 2] = 20 + 46 * r; } data[o + 3] = 255; } terrainContext.putImageData(terrainImage, 0, 0); } function drawMap(now) { if (!mapSize) return; const t = performance.now(); if (!now && t - mapClock < 90) return; mapClock = t; const { width, height, ratio } = mapSize, g = mapContext; g.setTransform(ratio, 0, 0, ratio, 0, 0); g.imageSmoothingEnabled = true; g.drawImage(terrain, 0, 0, width, height); const sx = width / (X1 - X0), sz = height / (Z1 - Z0); g.fillStyle = '#d6b980'; for (const s of world.structures) g.fillRect((s.x - X0) * sx - 1.5, (s.z - Z0) * sz - 1.5, 3, 3); for (const c of world.creatures) { const on = c.id === selected; g.fillStyle = on ? '#d4b9df' : '#e5e0e8'; g.beginPath(); g.arc((c.x - X0) * sx, (c.z - Z0) * sz, on ? 2.6 : 1.1, 0, Math.PI * 2); g.fill(); } // The part in view: the corners of the screen laid on the ground, its far edge marked so its turn reads. const b = canvas.getBoundingClientRect(); const corners = [[b.left, b.top], [b.right, b.top], [b.right, b.bottom], [b.left, b.bottom]].map(([x, y]) => groundAt(x, y)); if (corners.every(Boolean)) { const at = corners.map(p => [(p.x - X0) * sx, (p.z - Z0) * sz]); g.beginPath(); at.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); g.fillStyle = '#ffffff12'; g.fill(); g.lineWidth = 1; g.strokeStyle = '#e5e5e5aa'; g.stroke(); g.beginPath(); g.moveTo(...at[0]); g.lineTo(...at[1]); g.lineWidth = 2; g.strokeStyle = '#d4b9df'; g.stroke(); } } function mapTo(e) { const b = mapCanvas.getBoundingClientRect(); intro = null; root.dataset.engaged = 'true'; if (following) setFollow(false); goal.x = X0 + (e.clientX - b.left) / b.width * (X1 - X0); goal.z = Z0 + (e.clientY - b.top) / b.height * (Z1 - Z0); clampGoal(); wake(); } mapCanvas.addEventListener('pointerdown', e => { try { mapCanvas.setPointerCapture?.(e.pointerId); } catch { /* the tap alone still goes there */ } mapTo(e); }); mapCanvas.addEventListener('pointermove', e => { if (mapCanvas.hasPointerCapture?.(e.pointerId)) mapTo(e); });
pauseButton.addEventListener('click', () => { paused = !paused; updateButtons(); schedule(); status.textContent = paused ? 'The pool is resting. You can still pan, zoom, and follow.' : 'The pool is awake.'; }); soundButton.addEventListener('click', async () => { try { if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)(); sound = !sound; if (sound) await audio.resume(); updateButtons(); syncSound(); status.textContent = sound ? 'Sound on. Small tones mark drops, births, and endings.' : 'Sound off.'; if (sound) chime('shell'); } catch { sound = false; updateButtons(); status.textContent = 'Sound is unavailable. The pool can stay silent.'; } }); $('#tide-describe').addEventListener('click', () => { const c = creatureById(world, selected); const focus = c ? ` You are following ${label(c)}, ${goalText(world, c)}.` : ''; $('#tide-description').textContent = `${paused ? 'Paused. ' : ''}${describeWorld(world)}${focus}`; }); $('#tide-reset').addEventListener('click', () => { shelf = newShelf(); markShelf(); world = createWorld(shelf, { settled: true }); tickerSeen = 0; ticker.replaceChildren(); release(); if (paused) world.objects.forEach(o => { o.height = 0; }); status.textContent = `A new shore, shelf ${shelf}, a few tides into its life. Everything on it was dug and built by the machines.`; readout(); wake(); }); reduced.addEventListener('change', () => { if (reduced.matches) { paused = true; updateButtons(); schedule(); status.textContent = 'Paused for reduced motion. Play is available when you want it.'; } }); document.addEventListener('visibilitychange', schedule); new IntersectionObserver(entries => { visible = entries[0].isIntersecting; schedule(); }, { threshold: .05 }).observe(stage); new ResizeObserver(resize).observe(stage); // If the browser takes the 3D view away, the veil comes back until it is returned, and then the view opens out of the // ring again, where it was. let restoring = 0; canvas.addEventListener('webglcontextlost', e => { diagnostics.losses++; reportGraphics({ state: 'context-lost', losses: diagnostics.losses, statusMessage: e.statusMessage || '(none)' }); e.preventDefault(); lost = true; schedule(); if (reveal) endReveal(); delete root.dataset.ready; delete veil.dataset.iris; root.dataset.veiled = 'lost'; rise(.5); veilNote.textContent = 'Bringing the pool back'; controls().forEach(b => { b.disabled = true; }); status.textContent = 'The 3D view was interrupted. Bringing it back.'; clearTimeout(restoring); restoring = setTimeout(() => { veilNote.textContent = 'The pool went dark. Reload the page to return to it'; status.textContent = 'The 3D view was interrupted. Reload the page to return to the pool.'; }, 8000); }); canvas.addEventListener('webglcontextrestored', async () => { diagnostics.restores++; reportGraphics({ state: 'restoring', restores: diagnostics.restores }); clearTimeout(restoring); veilNote.textContent = 'Bringing the pool back'; scene.environment = makeEnvironment(); resize(); await prepare(); if (renderer.getContext().isContextLost()) return; lost = false; unveil('The pool is back.'); }); window.addEventListener('pagehide', () => { visible = false; schedule(); }); window.addEventListener('pageshow', () => { visible = true; schedule(); });
if (paused) world.objects.forEach(o => { o.height = 0; }); applyOptions(); post?.see(!mobile); resize(); // Settle on the first pool and the structure standing in it, close enough to see who is at work. if (world.blocks.size) { let x = 0, z = 0; for (const b of world.blocks.values()) { x += X0 + (b.i + .5) * CELL; z += Z0 + (b.j + .5) * CELL; } const tall = stage.clientWidth / Math.max(1, stage.clientHeight) < .8; Object.assign(goal, { x: (START_POOL.x + x / world.blocks.size) / 2, z: (START_POOL.z + z / world.blocks.size) / 2 + .8, zoom: 6.3 * (tall ? 1.45 : 1) }); Object.assign(view, goal); clampGoal(); Object.assign(view, goal); } if (!reduced.matches) { // Open close on the busiest spot in the first pool, then draw back to show the shelf around it. const central = world.creatures.filter(c => Math.hypot(c.x - START_POOL.x, c.z - START_POOL.z) < START_POOL.r); const busiest = central.reduce((a, c) => { const n = central.filter(o => Math.hypot(o.x - c.x, o.z - c.z) < 2.5).length; return n > a.n ? { c, n } : a; }, { c: null, n: 0 }).c; if (busiest) { intro = { start: Infinity, hold: 1000, to: { ...goal } }; Object.assign(view, { x: busiest.x, z: busiest.z, zoom: 2.6 }); Object.assign(goal, view); placeCamera(); } } rise(.85); await prepare(); // Give the ring a moment to settle in before it opens, so a quick load doesn't flash it. await new Promise(done => setTimeout(done, Math.max(0, 1300 - performance.now()))); if (!lost) unveil(paused ? 'Paused for reduced motion. Press Play when you want to watch.' : `${mobile ? 'Drag to look around, and twist two fingers to turn.' : 'Drag, or hold W A S D, to look around. Press ? for keys.'} Tap a machine to follow it.`); // When nobody is touching it, the instruments fade and the shelf has the screen to itself. Any touch brings them back. let idleTimer = 0; const busy = () => held.size || edging || root.querySelector('.tide-notes[open], .tide-settings:not([hidden]), :is(.tide-heading, .tide-hud, .tide-inspect, .tide-controls, .tide-nav, .tide-utility):hover') || document.activeElement !== canvas && root.contains(document.activeElement) && document.activeElement.matches(':focus-visible'); function settleIdle() { if (busy() || !root.dataset.ready) { idleTimer = setTimeout(settleIdle, 2500); return; } root.dataset.idle = 'true'; // Left alone, and not following anything, the camera starts to wander the shelf on its own. if (!following && !reduced.matches && options.wander) { director.on = true; director.shot = null; wake(); } } function stir(first) { // Any touch stops the wandering camera where it is. if (director.on) { director.on = false; director.shot = null; Object.assign(goal, { x: view.x, z: view.z, zoom: view.zoom }); } delete root.dataset.idle; clearTimeout(idleTimer); idleTimer = setTimeout(settleIdle, first === true ? 9000 : 5000); } for (const type of ['pointermove', 'pointerdown', 'wheel', 'keydown', 'focusin']) root.addEventListener(type, stir, { passive: true }); stir(true); // Test and debugging hook; read-only by convention. window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, watch: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { advanceWorld(world, 1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, steer: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { drive(1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, director, advance: seconds => { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, .05); readout(); }, get world() { return world; }, lantern, probe: (x, y) => floorAt(x, y), floor: (x, z) => Math.max(heightAt(world, x, z), surfaceAt(world, x, z)), select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal };}