/* ============================================================================ * 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); } }