aquatic-world / src /01-core /60-loop.js
Mike0021's picture
Upload folder using huggingface_hub
b08c4a1 verified
Raw History Blame Contribute Delete
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);
}
}