diff --git a/public/tide-pool.js b/public/tide-pool.js index acf2e48..02490d8 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -8,8 +8,28 @@ 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.'; @@ -30,7 +50,12 @@ async function initialize() { // 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 = true; + 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 @@ -76,7 +101,7 @@ async function initialize() { 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; }`; - const post = renderer.extensions.has('EXT_color_buffer_float') || renderer.extensions.has('EXT_color_buffer_half_float') ? (() => { + const post = enabled('post') && (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. @@ -515,7 +540,7 @@ async function initialize() { // 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 ? surfaceTarget() : null, surfaceWrite = canFloat ? surfaceTarget() : null; + 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]); @@ -618,7 +643,7 @@ async function initialize() { 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: causticTarget?.texture ?? even }; + 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; @@ -632,7 +657,7 @@ async function initialize() { surfaceWorld = world; surfaceTime = world.time; surfaceDrop = world.time; for (const t of [surfaceRead, surfaceWrite]) { renderer.setRenderTarget(t); renderer.clear(true, false, false); } } - let steps = Math.floor((world.time - surfaceTime) / STEP + .02); + 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; @@ -676,7 +701,7 @@ async function initialize() { 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 (key !== causticKey || causticSteps >= 2) { + 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; @@ -1808,6 +1833,7 @@ async function initialize() { 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; @@ -2756,6 +2782,8 @@ async function initialize() { // 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; @@ -2770,6 +2798,8 @@ async function initialize() { }, 8000); }); canvas.addEventListener('webglcontextrestored', async () => { + diagnostics.restores++; + reportGraphics({ state: 'restoring', restores: diagnostics.restores }); clearTimeout(restoring); veilNote.textContent = 'Bringing the pool back'; scene.environment = makeEnvironment(); diff --git a/scripts/test-tide-graphics.mjs b/scripts/test-tide-graphics.mjs new file mode 100644 index 0000000..1e311f0 --- /dev/null +++ b/scripts/test-tide-graphics.mjs @@ -0,0 +1,32 @@ +// Optional Playwright test. Candidate assets are intercepted locally; the server is read-only. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +const { chromium } = await import(pathToFileURL(process.env.TIDE_PLAYWRIGHT).href); +const source = await readFile(new URL('../public/tide-pool.js', import.meta.url), 'utf8'); +const world = await readFile(new URL('../public/tide-pool-world.js', import.meta.url), 'utf8'); +const browser = await chromium.launch({ headless: true, executablePath: process.env.TIDE_CHROMIUM, args: ['--enable-unsafe-swiftshader'] }); +try { + for (const mode of ['baseline', 'minimal', 'no-post', 'no-waves', 'no-caustics', 'no-shadows']) { + const page = await browser.newPage({ viewport: { width: 393, height: 852 }, hasTouch: true, isMobile: true, reducedMotion: 'reduce' }); + const errors = []; + page.on('pageerror', e => errors.push(String(e))); + page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); + await page.route('**/public/tide-pool.js?*', r => r.fulfill({ contentType: 'text/javascript', body: source })); + await page.route('**/public/tide-pool-world.js?*', r => r.fulfill({ contentType: 'text/javascript', body: world })); + await page.goto(`${process.argv[2] || 'http://localhost:3097'}/tide-pool?shelf=41&graphics=${mode}`); + await page.waitForFunction(() => window.__tidePool, null, { timeout: 60000 }); + await page.evaluate(() => window.__tidePool.watch(.1)); + assert.deepEqual(errors, []); + const diagnostics = JSON.parse(await page.locator('#tide-graphics-diagnostics').textContent()); + assert.equal(diagnostics.mode, mode); + assert.equal(diagnostics.losses, 0); + console.log(`${mode}: passed`); + if (mode === 'minimal') { + await page.evaluate(() => document.querySelector('#tide-canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()); + await page.waitForFunction(() => JSON.parse(document.querySelector('#tide-graphics-diagnostics').textContent).losses === 1); + console.log('context-loss diagnostics: passed'); + } + await page.close(); + } +} finally { await browser.close(); } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index aaa0f4d..81c8356 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -189,7 +189,7 @@ export function TidePoolContent() {
Rendered with Three.js. Software license.
- + ); }