Spaces:
Running
Running
Download src/01-core/60-loop.js from Mike0021/aquatic-world: direct link, hf CLI and curl.
- Browser
- Download file 22.6 kB
-
https://huggingface.co/spaces/Mike0021/aquatic-world/resolve/main/src/01-core/60-loop.js
- Command line
-
hf download hf://spaces/Mike0021/aquatic-world/src/01-core/60-loop.js
-
curl -L -o 60-loop.js https://huggingface.co/spaces/Mike0021/aquatic-world/resolve/main/src/01-core/60-loop.js
22.6 kB
| /* ============================================================================ | |
| * Aquatic World — the application: environment, frame graph, boot. | |
| * | |
| * The frame is: derive the sun from the clock → advance simulations → render | |
| * the scene twice (once mirrored for reflections, once for real) → run the | |
| * post chain → blit to the canvas. | |
| * | |
| * Core also ships three deliberately minimal fallback passes. If a content | |
| * module is missing or fails to compile, the world still renders as an ocean | |
| * instead of a black rectangle, and `AW.app.errors` says exactly what is | |
| * absent — a failure you can look at is far easier to fix than a blank screen. | |
| * ==========================================================================*/ | |
| const { clamp, lerp, damp, smoothstep, TAU, DEG } = AW.math; | |
| const bootHooks = []; | |
| AW.app.onBoot = (fn) => bootHooks.push(fn); | |
| /* ------------------------------------------------- environment (sun/moon) */ | |
| // East is +X, north is -Z. Hour angle runs from solar noon, so timeOfDay 12 | |
| // puts the sun at its highest and 6/18 at the horizon. | |
| function celestialBody(timeOfDay, latitudeDeg) { | |
| const lat = latitudeDeg * DEG; | |
| const dec = 0; // equinox; a fixed declination keeps the arc predictable | |
| const H = (timeOfDay - 12) * 15 * DEG; | |
| const sinAlt = Math.sin(lat) * Math.sin(dec) + Math.cos(lat) * Math.cos(dec) * Math.cos(H); | |
| const alt = Math.asin(clamp(sinAlt, -1, 1)); | |
| const cosAlt = Math.cos(alt); | |
| const denom = cosAlt * Math.cos(lat); | |
| const cosAz = denom > 1e-6 | |
| ? clamp((Math.sin(dec) - Math.sin(alt) * Math.sin(lat)) / denom, -1, 1) : 1; | |
| let az = Math.acos(cosAz); | |
| if (H > 0) az = TAU - az; // afternoon: mirror to the west | |
| return [cosAlt * Math.sin(az), Math.sin(alt), -cosAlt * Math.cos(az)]; | |
| } | |
| const _sun = [0, 1, 0], _moon = [0, 1, 0]; | |
| const _sunTint = [1, 1, 1]; | |
| const _ambient = [0, 0, 0]; | |
| function updateEnvironment() { | |
| const s = AW.settings; | |
| const u = AW.uniforms; | |
| AW.v3.norm(celestialBody(s.timeOfDay, s.latitude), _sun); | |
| AW.v3.norm(celestialBody(s.timeOfDay + 12, s.latitude), _moon); | |
| // Sunlight ramps up through civil twilight and reddens as it approaches the | |
| // horizon; both terms are in linear HDR and get tonemapped later. | |
| const elevation = _sun[1]; | |
| const day = smoothstep(-0.09, 0.20, elevation); | |
| const warm = smoothstep(-0.02, 0.26, elevation); | |
| _sunTint[0] = lerp(1.00, 1.00, warm); | |
| _sunTint[1] = lerp(0.34, 0.96, warm); | |
| _sunTint[2] = lerp(0.10, 0.92, warm); | |
| const sunPower = 13.5 * day * (0.35 + 0.65 * warm); | |
| u.uSunColor = [_sunTint[0] * sunPower, _sunTint[1] * sunPower, _sunTint[2] * sunPower]; | |
| u.uSunDir = _sun; | |
| const moonUp = smoothstep(-0.04, 0.16, _moon[1]); | |
| const moonPower = 0.42 * moonUp; | |
| u.uMoonDir = _moon; | |
| u.uMoonColor = [0.42 * moonPower, 0.52 * moonPower, 0.78 * moonPower]; | |
| // Ambient sky irradiance: blue by day, near-black by night, with a warm | |
| // floor around sunrise so nothing ever reads as pure black. | |
| const amb = lerp(0.035, 1.0, day); | |
| _ambient[0] = amb * lerp(0.055, 0.34, day) + 0.02 * moonUp; | |
| _ambient[1] = amb * lerp(0.065, 0.42, day) + 0.025 * moonUp; | |
| _ambient[2] = amb * lerp(0.095, 0.55, day) + 0.045 * moonUp; | |
| u.uAmbient = _ambient; | |
| u.uTurbidity = s.turbidity; | |
| u.uExposure = s.exposure; | |
| u.uWindDir = [Math.cos(s.windDir * DEG), Math.sin(s.windDir * DEG)]; | |
| u.uWindSpeed = s.windSpeed; | |
| // Waves grow with wind but saturate, so a storm is rough rather than absurd. | |
| u.uWaveAmp = s.waveAmp * clamp(Math.sqrt(s.windSpeed / 9), 0.35, 2.1); | |
| u.uChoppiness = s.choppiness; | |
| u.uFogDensity = s.fogDensity; | |
| u.uBloomStrength = s.bloom; | |
| u.uVignette = 0.34; | |
| u.uChroma = s.chroma; | |
| u.uQuality = s.quality; | |
| u.uUnderwater = AW.cam.underwater ? 1 : 0; | |
| } | |
| /* ------------------------------------------------------ fallback shaders */ | |
| // Registered unconditionally so they are always compilable; the passes that | |
| // use them switch themselves off the moment a real module claims the name. | |
| AW.shaders.fsCoreSky = ` | |
| in vec2 vUV; | |
| uniform mat4 uInvViewProj; | |
| uniform vec3 uCamPos; | |
| uniform vec3 uSunDir; | |
| uniform vec3 uSunColor; | |
| uniform vec3 uAmbient; | |
| out vec4 fragColor; | |
| void main() { | |
| vec4 p = uInvViewProj * vec4(vUV * 2.0 - 1.0, 1.0, 1.0); | |
| vec3 d = normalize(p.xyz / p.w - uCamPos); | |
| float up = clamp(d.y, 0.0, 1.0); | |
| vec3 sky = mix(uAmbient * 1.6 + vec3(0.30, 0.40, 0.52), | |
| uAmbient * 0.9 + vec3(0.05, 0.14, 0.36), pow(up, 0.55)); | |
| float s = max(dot(d, normalize(uSunDir)), 0.0); | |
| sky += uSunColor * (pow(s, 1800.0) * 6.0 + pow(s, 10.0) * 0.05); | |
| fragColor = vec4(max(sky, vec3(0.0)), 1.0); | |
| } | |
| `; | |
| AW.shaders.fsCoreWater = ` | |
| in vec2 vUV; | |
| uniform mat4 uInvViewProj; | |
| uniform vec3 uCamPos; | |
| uniform vec3 uSunDir; | |
| uniform vec3 uSunColor; | |
| uniform vec3 uAmbient; | |
| uniform vec2 uRes; | |
| uniform float uSeaLevel; | |
| uniform float uUnderwater; | |
| uniform float uTime; | |
| uniform sampler2D uSceneTex; | |
| uniform sampler2D uReflectTex; | |
| out vec4 fragColor; | |
| vec3 skyFallback(vec3 d) { | |
| float up = clamp(d.y, 0.0, 1.0); | |
| vec3 sky = mix(uAmbient * 1.6 + vec3(0.30, 0.40, 0.52), | |
| uAmbient * 0.9 + vec3(0.05, 0.14, 0.36), pow(up, 0.55)); | |
| float s = max(dot(d, normalize(uSunDir)), 0.0); | |
| return sky + uSunColor * (pow(s, 1800.0) * 6.0 + pow(s, 10.0) * 0.05); | |
| } | |
| void main() { | |
| vec4 p = uInvViewProj * vec4(vUV * 2.0 - 1.0, 1.0, 1.0); | |
| vec3 ro = uCamPos; | |
| vec3 rd = normalize(p.xyz / p.w - ro); | |
| if (uUnderwater > 0.5 || rd.y > -0.0005) { fragColor = texture(uSceneTex, vUV); return; } | |
| float t = (uSeaLevel - ro.y) / rd.y; | |
| if (t <= 0.0) { fragColor = texture(uSceneTex, vUV); return; } | |
| vec3 wp = ro + rd * t; | |
| // A cheap but shapely normal: crossed sine trains at two scales. | |
| vec2 q = wp.xz; | |
| vec2 g = vec2(cos(dot(q, vec2(0.31, 0.22)) + uTime * 1.3), | |
| sin(dot(q, vec2(-0.26, 0.37)) + uTime * 1.1)) * 1.5; | |
| g += vec2(sin(dot(q, vec2(0.83, -0.51)) + uTime * 2.1), | |
| cos(dot(q, vec2(0.61, 0.74)) + uTime * 1.9)) * 0.7; | |
| vec3 n = normalize(vec3(-g.x * 0.09, 1.0, -g.y * 0.09)); | |
| vec3 view = -rd; | |
| float fres = 0.02 + 0.98 * pow(1.0 - clamp(dot(n, view), 0.0, 1.0), 5.0); | |
| vec3 refl = skyFallback(reflect(rd, n)); | |
| vec2 ruv = vUV + n.xz * 0.10; | |
| vec3 reflPlanar = texture(uReflectTex, clamp(ruv, 0.0, 1.0)).rgb; | |
| refl = mix(refl, reflPlanar, clamp(fres * 1.2, 0.0, 0.92)); | |
| vec3 below = texture(uSceneTex, clamp(vUV + n.xz * 0.055, 0.0, 1.0)).rgb; | |
| below = mix(below * vec3(0.18, 0.52, 0.62), below * vec3(0.05, 0.30, 0.45), 0.5); | |
| float spec = pow(max(dot(reflect(-normalize(uSunDir), n), view), 0.0), 240.0); | |
| vec3 col = mix(below, refl, fres) + uSunColor * spec * 0.9; | |
| fragColor = vec4(col, 1.0); | |
| } | |
| `; | |
| AW.shaders.fsBlit = ` | |
| in vec2 vUV; | |
| uniform sampler2D uSource; | |
| out vec4 fragColor; | |
| void main() { fragColor = vec4(texture(uSource, vUV).rgb, 1.0); } | |
| `; | |
| AW.shaders.fsCoreComposite = ` | |
| in vec2 vUV; | |
| uniform sampler2D uWaterTex; | |
| uniform float uExposure; | |
| uniform vec3 uAmbient; | |
| out vec4 fragColor; | |
| vec3 aces(vec3 x) { | |
| const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14; | |
| return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); | |
| } | |
| void main() { | |
| vec3 col = texture(uWaterTex, vUV).rgb; | |
| vec2 d = vUV - 0.5; | |
| col *= 1.0 - dot(d, d) * 0.32; | |
| col = aces(col * uExposure); | |
| fragColor = vec4(pow(col, vec3(1.0 / 2.2)), 1.0); | |
| } | |
| `; | |
| /* -------------------------------------------------------- fallback passes */ | |
| function hasRealPass(name) { return !!AW.passes.get(name); } | |
| AW.passes.add({ | |
| name: '_coreSky', order: 4, phase: 'scene', reflect: true, | |
| enabled: () => !hasRealPass('sky'), | |
| run(ctx) { | |
| const p = AW.gl.program('_coreSky', { vs: ['awVsCommon'], fs: ['fsCoreSky'] }); | |
| p.use(); | |
| AW.gl.depthTest(true); | |
| AW.gl.depthWrite(true); | |
| AW.gl.blend('none'); | |
| AW.gl.drawFullscreen(); | |
| }, | |
| }); | |
| AW.passes.add({ | |
| name: '_coreWater', order: 41, phase: 'scene', reflect: false, | |
| enabled: () => !hasRealPass('water'), | |
| run(ctx) { | |
| const p = AW.gl.program('_coreWater', { vs: ['awVsCommon'], fs: ['fsCoreWater'] }); | |
| AW.rt('water').bind(); | |
| AW.gl.baseline(); | |
| AW.gl.depthTest(false); | |
| AW.gl.depthWrite(false); | |
| p.use(); | |
| AW.gl.drawFullscreen(); | |
| ctx.loaded = true; | |
| }, | |
| }); | |
| AW.passes.add({ | |
| name: '_coreComposite', order: 899, phase: 'post', | |
| enabled: () => !hasRealPass('composite'), | |
| run(ctx) { | |
| const p = AW.gl.program('_coreComposite', { vs: ['awVsCommon'], fs: ['fsCoreComposite'] }); | |
| AW.rt('ldr').bind(); | |
| AW.gl.baseline(); | |
| AW.gl.depthTest(false); | |
| AW.gl.depthWrite(false); | |
| p.use(); | |
| AW.gl.drawFullscreen(); | |
| }, | |
| }); | |
| /* --------------------------------------------------------------- targets */ | |
| function declareTargets() { | |
| const g = AW.gl; | |
| g.rt('scene', { scale: 1.0, format: 'rgba16f', depth: true, msaa: 4 }); | |
| g.rt('reflect', { scale: 0.5, format: 'rgba16f', depth: true, msaa: 0 }); | |
| g.rt('water', { scale: 1.0, format: 'rgba16f', msaa: 0 }); | |
| g.rt('bloomA', { scale: 0.25, format: 'rgba16f', msaa: 0 }); | |
| g.rt('bloomB', { scale: 0.25, format: 'rgba16f', msaa: 0 }); | |
| g.rt('ldr', { scale: 1.0, format: 'rgba8', msaa: 0 }); | |
| } | |
| /* ------------------------------------------------------------------ boot */ | |
| AW.app.initUniforms = function () { | |
| const u = AW.uniforms; | |
| u.uTime = 0; | |
| u.uRealTime = 0; | |
| u.uFrame = 0; | |
| u.uRes = [1, 1]; | |
| u.uPix = [1, 1]; | |
| u.uAspect = 1; | |
| u.uSeed = AW.settings.seed; | |
| u.uQuality = AW.settings.quality; | |
| u.uSeaLevel = 0; | |
| u.uUnderwater = 0; | |
| u.uClipPlane = [0, 0, 0, 1e9]; | |
| u.uCamPos = AW.cam.pos; | |
| u.uCamDir = AW.cam.fwd; | |
| u.uCamRight = AW.cam.right; | |
| u.uCamUp = AW.cam.up; | |
| u.uNear = AW.cam.near; | |
| u.uFar = AW.cam.far; | |
| u.uProj = AW.cam.proj; | |
| u.uView = AW.cam.view; | |
| u.uViewProj = AW.cam.viewProj; | |
| u.uInvViewProj = AW.cam.invViewProj; | |
| u.uPrevViewProj = AW.app.prevViewProj; | |
| u.uSunDir = [0, 1, 0]; | |
| u.uSunColor = [1, 1, 1]; | |
| u.uMoonDir = [0, -1, 0]; | |
| u.uMoonColor = [0, 0, 0]; | |
| u.uAmbient = [0.1, 0.12, 0.16]; | |
| u.uTurbidity = AW.settings.turbidity; | |
| u.uExposure = AW.settings.exposure; | |
| u.uWindDir = [1, 0]; | |
| u.uWindSpeed = AW.settings.windSpeed; | |
| u.uWaveAmp = AW.settings.waveAmp; | |
| u.uChoppiness = AW.settings.choppiness; | |
| u.uFogDensity = AW.settings.fogDensity; | |
| // Coastal-to-open-ocean default optics. Red is extinguished roughly ten | |
| // times faster than blue, which is what makes depth read as depth. | |
| u.uWaterAbsorb = [0.32, 0.062, 0.038]; | |
| u.uWaterScatter = [0.020, 0.085, 0.115]; | |
| u.uWaterScatterAniso = 0.72; | |
| u.uRefractIndex = 1.333; | |
| u.uBloomStrength = AW.settings.bloom; | |
| u.uVignette = 0.34; | |
| u.uChroma = AW.settings.chroma; | |
| }; | |
| AW.app.prevViewProj = AW.m4.create(); | |
| AW.app.lastMainViewProj = AW.m4.create(); | |
| // The auto-bound samplers resolve through AW.uniforms, so the app owns | |
| // pointing them at live textures. | |
| // | |
| // This runs every frame rather than once at boot, because a pass is free to | |
| // declare or re-fit its own targets at any time and `fit()` reallocates. A | |
| // stale reference here is the worst kind of bug: sampling a deleted texture | |
| // returns black with no error, no warning, and a plausible-looking dark image. | |
| // Re-running costs six comparisons and no GL calls. | |
| function bindTargetUniforms() { | |
| const u = AW.uniforms, rt = AW._rts; | |
| if (!rt.scene || !rt.scene.tex) return; | |
| u.uSceneTex = rt.scene.tex; | |
| u.uDepthTex = rt.scene.depthTex; | |
| u.uReflectTex = rt.reflect.tex; | |
| u.uWaterTex = rt.water.tex; | |
| u.uBloomTex = rt.bloomA.tex; | |
| } | |
| AW.app.bindTargetUniforms = bindTargetUniforms; | |
| AW.app.resize = function () { | |
| const c = AW.app.canvas; | |
| if (!c) return; | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| const cssW = c.clientWidth || window.innerWidth || 1280; | |
| const cssH = c.clientHeight || window.innerHeight || 720; | |
| let w = Math.max(4, Math.round(cssW * dpr)); | |
| let h = Math.max(4, Math.round(cssH * dpr)); | |
| // Cap the pixel count: past ~2.3 MP the raymarch cost outweighs the detail. | |
| const MAX_PIXELS = 1920 * 1200; | |
| if (w * h > MAX_PIXELS) { | |
| const k = Math.sqrt(MAX_PIXELS / (w * h)); | |
| w = Math.max(4, Math.round(w * k)); | |
| h = Math.max(4, Math.round(h * k)); | |
| } | |
| // Re-fit only when the size actually changed — but publish the uniforms | |
| // unconditionally. index.html pre-sizes the canvas, so at exactly that size | |
| // nothing needs reallocating and an early return here is tempting; doing that | |
| // used to leave uRes/uPix/uAspect at their initial [1,1] / 1.0 for the whole | |
| // session. Every pixel-footprint effect in the project reads them, so | |
| // "sub-pixel" dispersion silently became a ~1000 px channel shift and every | |
| // detail LOD was off by the resolution. Publish first, then decide. | |
| if (w !== c.width || h !== c.height) { | |
| c.width = w; c.height = h; | |
| AW.gl.fitTargets(w, h); | |
| AW.log('resize', w + 'x' + h); | |
| } | |
| bindTargetUniforms(); | |
| AW.app.aspect = w / h; | |
| AW.app.res = [w, h]; | |
| AW.uniforms.uRes = AW.app.res; | |
| AW.uniforms.uPix = [1 / w, 1 / h]; | |
| AW.uniforms.uAspect = w / h; | |
| }; | |
| AW.app.boot = function () { | |
| if (AW.app.booted) return; | |
| AW.app.booted = true; | |
| const canvas = document.getElementById('aw-canvas') || document.querySelector('canvas'); | |
| if (!canvas) { AW.error('boot', 'no canvas element found'); return; } | |
| // Belt and braces: the stylesheet may not have loaded yet, and a canvas with | |
| // default sizing would silently render the world at 300x150. | |
| canvas.style.display = 'block'; | |
| canvas.style.width = '100%'; | |
| canvas.style.height = '100%'; | |
| AW.app.canvas = canvas; | |
| if (!AW.gl.init(canvas)) return; | |
| AW.input.init(canvas); | |
| declareTargets(); | |
| AW.app.initUniforms(); | |
| AW.app.resize(); | |
| // resize() early-returns when the canvas already has the right size, which | |
| // it does here whenever the width/height attributes match the layout. Force | |
| // the allocation so targets are never 0x0 on the first frame. | |
| AW.gl.fitTargets(canvas.width, canvas.height); | |
| bindTargetUniforms(); | |
| for (const hook of bootHooks) AW.try('bootHook', hook); | |
| AW.log('booted', AW.gl.caps.colorBufferFloat ? 'HDR' : 'LDR-only', AW.gl.caps.maxSamples + 'x MSAA max'); | |
| requestAnimationFrame(AW.app.raf); | |
| }; | |
| /* ----------------------------------------------------------- frame graph */ | |
| const ctx = { | |
| gl: null, cam: null, rt: null, res: [0, 0], | |
| time: 0, dt: 0, view: 'main', quality: 2, settings: null, loaded: false, | |
| }; | |
| const CLIP_OFF = [0, 0, 0, 1e9]; | |
| const CLIP_REFLECT = [0, 1, 0, 0]; | |
| function runPasses(phase, view) { | |
| const list = AW.passes.active(phase, view); | |
| AW.app.stats.passes = list.length; | |
| for (let i = 0; i < list.length; i++) { | |
| const d = list[i]; | |
| AW.gl.baseline(); | |
| ctx.view = view; | |
| AW.try('pass:' + d.name, () => d.run(ctx)); | |
| } | |
| } | |
| AW.app.simulate = function (dt) { | |
| const s = AW.settings; | |
| AW.app.frameCount++; | |
| AW.app.stats.sims = 0; | |
| const u = AW.uniforms; | |
| u.uFrame = AW.app.frameCount; | |
| u.uRealTime = AW.app.realTime; | |
| if (!s.paused) { | |
| AW.app.simTime += dt; | |
| s.timeOfDay = (s.timeOfDay + (dt / 60) * s.timeScale) % 24; | |
| if (s.timeOfDay < 0) s.timeOfDay += 24; | |
| } | |
| u.uTime = AW.app.simTime; | |
| u.uSeed = s.seed; | |
| AW.cam.tick(dt); | |
| updateEnvironment(); | |
| ctx.gl = AW.gl; ctx.cam = AW.cam; ctx.rt = AW._rts; | |
| ctx.time = AW.app.simTime; ctx.dt = dt; | |
| ctx.quality = s.quality; ctx.settings = s; ctx.res = AW.app.res; | |
| for (const d of AW.sims.sorted()) { | |
| if (d.enabled && d.enabled() === false) continue; | |
| AW.app.stats.sims++; | |
| AW.try('sim:' + d.name, () => d.update(dt, ctx)); | |
| } | |
| }; | |
| AW.app.render = function () { | |
| const s = AW.settings; | |
| const g = AW.gl; | |
| const rt = AW._rts; | |
| if (!rt.scene) return; | |
| bindTargetUniforms(); | |
| AW.app.prevViewProj.set(AW.app.lastMainViewProj); | |
| AW.uniforms.uPrevViewProj = AW.app.prevViewProj; | |
| // --- scene, reflected. Skipped when the eye is under the surface: a mirror | |
| // image through the sea plane is meaningless from below and the water pass | |
| // does not sample it in that state. | |
| if (!AW.cam.underwater) { | |
| AW.cam.computeMatrices(AW.app.aspect, 'reflect'); | |
| AW.uniforms.uClipPlane = CLIP_REFLECT; | |
| AW.gl.frontFace('cw'); | |
| const target = rt.reflect; | |
| target.clear(0, 0, 0, 1); | |
| ctx.res = [target.w, target.h]; | |
| runPasses('scene', 'reflect'); | |
| target.resolve(); | |
| } | |
| // --- scene, real. | |
| AW.cam.computeMatrices(AW.app.aspect, 'main'); | |
| AW.uniforms.uClipPlane = CLIP_OFF; | |
| AW.uniforms.uUnderwater = AW.cam.underwater ? 1 : 0; | |
| AW.gl.frontFace('ccw'); | |
| const scene = rt.scene; | |
| scene.clear(0, 0, 0, 1); | |
| ctx.res = [scene.w, scene.h]; | |
| runPasses('scene', 'main'); | |
| scene.resolve(); | |
| AW.app.lastMainViewProj.set(AW.cam.viewProj); | |
| // --- post. The app binds `ldr` before each pass so a single-target pass | |
| // needs to do nothing, while a pass with its own targets just rebinds. | |
| ctx.res = [rt.ldr.w, rt.ldr.h]; | |
| rt.ldr.clear(0, 0, 0, 1); | |
| const post = AW.passes.active('post', 'main'); | |
| AW.app.stats.passes += post.length; | |
| for (const d of post) { | |
| AW.gl.baseline(); | |
| rt.ldr.bind(); | |
| ctx.view = 'main'; | |
| AW.try('pass:' + d.name, () => d.run(ctx)); | |
| } | |
| // --- present. A straight copy: the composite pass already tonemapped and | |
| // encoded to sRGB, so anything more here would double-apply. | |
| g.screen(); | |
| g.baseline(); | |
| g.depthTest(false); | |
| g.depthWrite(false); | |
| g.blend('none'); | |
| const b = AW.gl.program('_blit', { vs: ['awVsCommon'], fs: ['fsBlit'] }); | |
| b.u.uSource = rt.ldr.tex; // must precede use(): use() is what uploads | |
| b.use(); | |
| g.drawFullscreen(); | |
| }; | |
| AW.app.step = function (dt, opts) { | |
| const o = opts || {}; | |
| if (!AW.settings.paused) AW.app.realTime += dt; | |
| AW.app.simulate(dt); | |
| if (o.render !== false) AW.app.render(); | |
| if (!AW.app.ready) AW.markReady(); | |
| }; | |
| AW.app.raf = function (now) { | |
| const t = now || AW.now(); | |
| const last = AW.app._lastT || t; | |
| let dt = (t - last) / 1000; | |
| AW.app._lastT = t; | |
| // A tab that was hidden for a minute must not teleport the simulation. | |
| dt = clamp(dt, 0.0005, 0.05); | |
| AW.app.resize(); | |
| AW.try('frame', () => AW.app.step(dt)); | |
| const ms = AW.now() - t; | |
| const st = AW.app.stats; | |
| st.cpu = st.cpu * 0.9 + ms * 0.1; | |
| st.fps = st.fps ? st.fps * 0.94 + (1 / dt) * 0.06 : 1 / dt; | |
| st.ms = 1000 / (st.fps || 1); | |
| AW.input.endFrame(); | |
| if (!AW.gl.isLost) requestAnimationFrame(AW.app.raf); | |
| }; | |
| /* ------------------------------------------------------------ snapshots */ | |
| /** | |
| * Put the world into a known, populated, reproducible state and draw it. | |
| * This is the only supported way to take a screenshot: a freshly loaded page | |
| * has settled nothing, so it would show an empty ocean and no wildlife. | |
| * | |
| * AW.snap(6.2) // dawn | |
| * AW.snap(13, { cameraMode: 'dive' }) // under the surface | |
| * AW.snap(21, { settings: { windSpeed: 26 }, cam: { pos: [0, 3, 60] } }) | |
| */ | |
| AW.snap = function (hours, opts) { | |
| const o = opts || {}; | |
| // A snapshot must not depend on what ran before it. Without this reset a | |
| // second snap in the same session inherits the first one's weather and | |
| // camera, so every comparison is really a comparison of two scenarios and | |
| // the diff shows history rather than the change under test. | |
| Object.assign(AW.settings, AW.settingsDefaults); | |
| AW.reviseSettings(); | |
| // A sim opts into reproducibility by exposing reset(), which returns its | |
| // state to the canonical start. Without it a second snap in the same session | |
| // settles an already-settled world, so creatures sit somewhere new and two | |
| // "identical" snapshots differ. Modules that own no persistent state can | |
| // omit it; the hook is optional by design. | |
| for (const d of AW.sims.list()) { | |
| if (typeof d.reset === 'function') AW.try('sim.reset:' + d.name, () => d.reset()); | |
| } | |
| if (hours !== undefined && hours !== null) AW.settings.timeOfDay = ((hours % 24) + 24) % 24; | |
| if (o.settings) Object.assign(AW.settings, o.settings); | |
| // `mode` is the documented spelling; `cameraMode` is accepted so existing | |
| // scripts keep working. | |
| if (o.mode) AW.settings.cameraMode = o.mode; | |
| if (o.cameraMode) AW.settings.cameraMode = o.cameraMode; | |
| if (o.cam) { | |
| if (o.cam.pos) AW.v3.copy(AW.cam.pos, o.cam.pos); | |
| if (o.cam.yaw !== undefined) AW.cam.yaw = o.cam.yaw; | |
| if (o.cam.pitch !== undefined) AW.cam.pitch = o.cam.pitch; | |
| if (o.cam.fov !== undefined) AW.cam.fov = o.cam.fov * DEG; | |
| if (o.cam.roll !== undefined) AW.cam.roll = o.cam.roll; | |
| } else { | |
| // A canonical opening pose, so two snaps of the same scenario frame the | |
| // same water. The camera controllers would otherwise leave the eye | |
| // wherever the last caller parked it. | |
| // | |
| // This is the shelf at 1.26 km south-west of the origin looking back across | |
| // the basin — the same pose bootstrap opens on. It is deliberately not the | |
| // origin: a 120 m pit sits there, so the default framing would be nothing | |
| // but deep blue and every verification frame would under-represent the | |
| // water. Keep the two in step. | |
| AW.v3.set(AW.cam.pos, -890, 6.5, -890); | |
| AW.cam.yaw = 3.677; | |
| AW.cam.pitch = -0.05; | |
| AW.cam.roll = 0; | |
| AW.cam.speed = 12; | |
| } | |
| const dt = 1 / 60; | |
| const steps = o.steps === undefined ? 260 : o.steps; | |
| const warm = o.renderSteps === undefined ? 2 : o.renderSteps; | |
| AW.cam.tick(dt); | |
| for (let i = 0; i < steps; i++) AW.app.simulate(dt); | |
| // Freeze the wave clock so two runs of the same snapshot are pixel-comparable. | |
| AW.app.simTime = o.t === undefined ? 12 : o.t; | |
| AW.uniforms.uTime = AW.app.simTime; | |
| for (let i = 0; i < warm; i++) { | |
| // Pin the frame counter so per-frame dither and jitter — which every | |
| // raymarcher wants — land on the same values on every run. Without this | |
| // two snapshots of "the same" moment differ across most of the water, and | |
| // a before/after comparison shows the noise, not the change. | |
| AW.app.frameCount = 4096; | |
| AW.app.simulate(0); | |
| AW.app.render(); | |
| } | |
| AW.markReady(); | |
| const p = AW.cam.pos; | |
| return `snap t=${AW.settings.timeOfDay.toFixed(2)} mode=${AW.settings.cameraMode} ` | |
| + `pos=[${p[0].toFixed(0)},${p[1].toFixed(0)},${p[2].toFixed(0)}] ` | |
| + `under=${AW.cam.underwater} renders=${AW.app.frameCount} errors=${AW.app.errors.length}`; | |
| }; | |
| /* ----------------------------------------------------------------- start */ | |
| AW.app.simTime = 0; | |
| AW.app.realTime = 0; | |
| AW.app.frameCount = 0; | |
| AW.app.aspect = 16 / 9; | |
| AW.app.res = [1600, 900]; | |
| if (typeof document !== 'undefined') { | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => AW.try('boot', AW.app.boot)); | |
| } else { | |
| AW.try('boot', AW.app.boot); | |
| } | |
| } | |