From 53c24e4fae414dc1c20a5c0bcf1ecd87e5b5fcec Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sun, 7 Jun 2026 16:42:09 +0200 Subject: [PATCH] Stabilize WebGL reveal timing --- public/js/book-page-format-module.js | 2 +- public/js/book-texture-renderer-module.js | 45 +++++++++ public/js/loader.js | 2 +- public/js/ui-display-handler-module.js | 2 +- public/js/webgl-book-lab.js | 118 ++++++++++++++++++++-- public/js/webgl-book-scene-module.js | 9 -- scripts/check-webgl-book-lab.js | 61 ++++++++++- 7 files changed, 217 insertions(+), 22 deletions(-) diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index 86be605..1832fb6 100644 --- a/public/js/book-page-format-module.js +++ b/public/js/book-page-format-module.js @@ -3,7 +3,7 @@ * Defines the canonical page geometry used by the WebGL book renderer. */ import { BaseModule } from './base-module.js'; -import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-forced-font-mask'; +import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-reveal-clock'; export const BOOK_TEXTURE_WIDTH = 3072; diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 46631b5..b7a9cf4 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -34,9 +34,11 @@ class BookTextureRendererModule extends BaseModule { this.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 30; + this.pipelineTimings = []; this.bindMethods([ 'initialize', + 'markPipelineTiming', 'waitForTextureFonts', 'ensureTextureFontFace', 'createPageCanvases', @@ -69,6 +71,9 @@ class BookTextureRendererModule extends BaseModule { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); + window.BookTextureRendererDebug = { + pipelineTimings: this.pipelineTimings + }; this.reportProgress(10, 'Waiting for book fonts'); await this.waitForTextureFonts(); this.reportProgress(20, 'Preparing page texture canvases'); @@ -104,6 +109,18 @@ class BookTextureRendererModule extends BaseModule { return true; } + markPipelineTiming(name, detail = {}) { + const entry = { + name, + at: performance.now(), + detail + }; + this.pipelineTimings.push(entry); + if (this.pipelineTimings.length > 120) this.pipelineTimings.splice(0, this.pipelineTimings.length - 120); + document.documentElement.dataset.webglTexturePipeline = JSON.stringify(this.pipelineTimings); + return entry; + } + async waitForTextureFonts() { if (!document.fonts) return; await Promise.all([ @@ -140,6 +157,10 @@ class BookTextureRendererModule extends BaseModule { drawSpread(spread = null, sides = null) { this.currentSpread = spread || { left: [], right: [] }; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; + this.markPipelineTiming('drawSpread:start', { + sides: sidesToDraw, + revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [] + }); this.revealBounds = { left: null, right: null }; this.revealWords = { left: [], right: [] }; sidesToDraw.forEach((side) => { @@ -148,6 +169,9 @@ class BookTextureRendererModule extends BaseModule { this.drawPageLines(side, this.currentSpread?.[side] || []); }); this.publishSpread(sidesToDraw); + this.markPipelineTiming('drawSpread:end', { + sides: sidesToDraw + }); this.revealBounds = null; this.revealWords = null; this.revealPublishBlockIds = null; @@ -376,6 +400,10 @@ class BookTextureRendererModule extends BaseModule { if (blockId == null || !Array.isArray(detail.wordTimings)) return; const id = String(blockId); const wordTimings = detail.wordTimings; + this.markPipelineTiming('prepareRevealBlock:start', { + blockId: id, + wordTimingCount: wordTimings.length + }); this.activeAnimations.set(id, { blockId, wordTimings, @@ -390,12 +418,20 @@ class BookTextureRendererModule extends BaseModule { this.pendingRevealBlockIds.delete(id); this.revealPublishBlockIds = new Set([id]); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); + this.markPipelineTiming('prepareRevealBlock:end', { + blockId: id, + wordTimingCount: wordTimings.length + }); } startPreparedRevealAnimation(blockId) { const id = String(blockId ?? ''); const animation = this.activeAnimations.get(id); if (!animation) return false; + this.markPipelineTiming('startPreparedRevealAnimation', { + blockId: id, + wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0 + }); animation.startedAt = performance.now(); animation.prepared = false; animation.completed = false; @@ -503,6 +539,10 @@ class BookTextureRendererModule extends BaseModule { publishSpread(sides = null) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; + const wordCounts = { + left: this.revealWords?.left?.length || 0, + right: this.revealWords?.right?.length || 0 + }; const detail = { metrics: this.metrics, hitMaps: this.hitMaps @@ -537,6 +577,11 @@ class BookTextureRendererModule extends BaseModule { }; }); if (Object.keys(reveal).length) detail.reveal = reveal; + this.markPipelineTiming('publishSpread', { + sides: sidesToPublish, + hasReveal: Object.keys(reveal).length > 0, + wordCounts + }); document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail })); diff --git a/public/js/loader.js b/public/js/loader.js index 57e6dfa..79476e1 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260607-webgl-forced-font-mask'; +const MODULE_CACHE_BUSTER = '20260607-webgl-reveal-clock'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 5f184c3..a403722 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule { super('ui-display-handler', 'UI Display Handler'); // Module dependencies - this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser']; + this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer']; // DOM elements this.container = null; diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 2284162..3e9b404 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; -import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-forced-font-mask'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-reveal-clock'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -161,6 +161,26 @@ let fpsDisplay = null; let fpsWindowStartedAt = performance.now(); let fpsWindowFrames = 0; const lastFrameTiming = {}; +const loaderTimings = {}; +const pageTextureTimings = []; + +function markLoaderTiming(name) { + loaderTimings[name] = performance.now(); + document.documentElement.dataset.webglLoaderTimings = JSON.stringify(loaderTimings); +} + +function markPageTextureTiming(name, detail = {}) { + const entry = { + name, + at: performance.now(), + detail + }; + pageTextureTimings.push(entry); + if (pageTextureTimings.length > 120) pageTextureTimings.splice(0, pageTextureTimings.length - 120); + document.documentElement.dataset.webglPageTextureTimings = JSON.stringify(pageTextureTimings); + return entry; +} + const book = new THREE.Group(); scene.add(book); const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1); @@ -203,6 +223,7 @@ const pageRevealState = { left: null, right: null }; +const pageRevealClearLog = []; await reportLabStep(52, 'Generating leather texture set'); const leatherTextures = createLeatherTextures(); await reportLabStep(56, 'Generating spine cloth texture set'); @@ -389,6 +410,9 @@ window.BookLabDebug = { textures: generatedTextureCanvases, ready: false, renderedFrames: 0, + loaderTimings, + pageTextureTimings, + pageRevealClearLog, get sceneAoPass() { return sceneAoPass; }, @@ -444,6 +468,9 @@ window.BookLabDebug = { window.BookTextureRenderer?.publishSpread?.(); return true; }, + getRevealDebugState() { + return getRevealDebugState(); + }, getTextureInfo() { return { pageTextureWidth, @@ -1619,6 +1646,11 @@ function syncBookControls() { function handlePageCanvases(event) { const detail = event.detail || {}; + markPageTextureTiming('handlePageCanvases:start', { + hasLeft: Boolean(detail.left), + hasRight: Boolean(detail.right), + revealSides: Object.keys(detail.reveal || {}) + }); if (detail.left) { if (detail.reveal?.left) { beginPageReveal('left', detail.left, detail.reveal.left); @@ -1639,14 +1671,17 @@ function handlePageCanvases(event) { height: leftCanvas.height, source: 'book-texture-renderer' }); + markPageTextureTiming('handlePageCanvases:end'); } function uploadPageTextureDirect(side, sourceCanvas) { const canvas = side === 'left' ? leftCanvas : rightCanvas; const texture = side === 'left' ? leftTexture : rightTexture; - clearPageReveal(side); + markPageTextureTiming('directUpload:start', { side }); + clearPageReveal(side, 'direct-upload'); drawCanvasPageTexture(canvas, sourceCanvas, side); texture.needsUpdate = true; + markPageTextureTiming('directUpload:end', { side }); } function beginPageReveal(side, sourceCanvas, revealDetail = {}) { @@ -1654,11 +1689,18 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { const texture = side === 'left' ? leftTexture : rightTexture; const shader = getPageRevealShader(side); + markPageTextureTiming('revealUpload:start', { + side, + wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0 + }); drawCanvasPageTexture(canvas, sourceCanvas, side); texture.needsUpdate = true; pageRevealState[side] = { startedAt: revealDetail.startNow ? performance.now() : null, + pendingStart: false, + lastRevealFrameAt: null, + visualElapsedMs: 0, durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [] }; @@ -1673,6 +1715,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { shaderReady: Boolean(shader?.uniforms), started: pageRevealState[side].startedAt != null }); + markPageTextureTiming('revealUpload:end', { side }); } function applyPendingPageReveal(side, shader = getPageRevealShader(side)) { @@ -1729,7 +1772,39 @@ function getPageRevealShader(side) { return material?.userData?.bookRevealShader || null; } -function clearPageReveal(side) { +function getRevealDebugState() { + return ['left', 'right'].reduce((state, side) => { + const shader = getPageRevealShader(side); + const uniforms = shader?.uniforms || {}; + state[side] = { + active: Number(uniforms.bookRevealActive?.value || 0), + elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0), + visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0), + wordCount: Number(uniforms.bookRevealWordCount?.value || 0), + started: pageRevealState[side]?.startedAt != null, + pendingStart: pageRevealState[side]?.pendingStart === true, + durationMs: Number(pageRevealState[side]?.durationMs || 0), + blockIds: pageRevealState[side]?.blockIds || [] + }; + return state; + }, {}); +} + +function clearPageReveal(side, reason = 'clear') { + pageRevealClearLog.push({ + side, + reason, + at: performance.now(), + state: pageRevealState[side] ? { + started: pageRevealState[side].startedAt != null, + pendingStart: pageRevealState[side].pendingStart === true, + visualElapsedMs: pageRevealState[side].visualElapsedMs || 0, + durationMs: pageRevealState[side].durationMs, + blockIds: pageRevealState[side].blockIds || [] + } : null + }); + if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40); + document.documentElement.dataset.webglRevealClearLog = JSON.stringify(pageRevealClearLog); pageRevealState[side] = null; const shader = getPageRevealShader(side); if (shader?.uniforms?.bookRevealActive) { @@ -1745,7 +1820,7 @@ function startPageRevealForBlock(blockId) { const state = pageRevealState[side]; if (!state || state.startedAt != null) return; if (!state.blockIds.map(value => String(value)).includes(id)) return; - state.startedAt = performance.now(); + state.pendingStart = true; const shader = getPageRevealShader(side); if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0; }); @@ -1758,7 +1833,7 @@ function fastForwardPageReveals(blockIds = []) { if (!state) return; const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId))); if (!matches) return; - clearPageReveal(side); + clearPageReveal(side, 'fast-forward'); }); } @@ -1768,18 +1843,29 @@ function updatePageRevealAnimations(now) { if (!state) return; const shader = getPageRevealShader(side); if (!shader?.uniforms) { - clearPageReveal(side); + clearPageReveal(side, 'missing-shader'); + return; + } + if (state.pendingStart) { + state.startedAt = now; + state.pendingStart = false; + state.lastRevealFrameAt = now; + state.visualElapsedMs = 0; + shader.uniforms.bookRevealElapsedMs.value = 0; return; } if (state.startedAt == null) { shader.uniforms.bookRevealElapsedMs.value = 0; return; } - const progress = THREE.MathUtils.clamp((now - state.startedAt) / state.durationMs, 0, 1); - shader.uniforms.bookRevealElapsedMs.value = Math.max(0, now - state.startedAt); + const revealFrameDeltaMs = state.lastRevealFrameAt == null ? 0 : Math.max(0, now - state.lastRevealFrameAt); + state.lastRevealFrameAt = now; + state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs); + const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1); + shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs; if (progress < 1) return; - clearPageReveal(side); + clearPageReveal(side, 'duration-complete'); document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', { detail: { side, @@ -1790,6 +1876,11 @@ function updatePageRevealAnimations(now) { } function drawCanvasPageTexture(canvas, sourceCanvas, side) { + markPageTextureTiming('drawCanvasPageTexture:start', { + side, + width: canvas?.width || 0, + height: canvas?.height || 0 + }); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f2ead0'; ctx.fillRect(0, 0, canvas.width, canvas.height); @@ -1803,6 +1894,7 @@ function drawCanvasPageTexture(canvas, sourceCanvas, side) { ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height); updatePageTextureDebugState(side, canvas, sourceCanvas, true); + markPageTextureTiming('drawCanvasPageTexture:end', { side }); return true; } @@ -2858,12 +2950,20 @@ function loadAiRoomReflection() { } function primeSceneForLoader() { + markLoaderTiming('primeSceneForLoader:start'); updateCameraRig(0); updateCandleShadowUniforms(); + markLoaderTiming('bookShadowMaps:start'); updateBookShadowMaps(); + markLoaderTiming('bookShadowMaps:end'); + markLoaderTiming('tableReflection:start'); updateTableReflection(); + markLoaderTiming('tableReflection:end'); + markLoaderTiming('shaderCompile:start'); renderer.compile(scene, camera); + markLoaderTiming('shaderCompile:end'); staticSceneBuffersDirty = false; + markLoaderTiming('primeSceneForLoader:end'); } function tintAmbientFromCanvas(canvas) { diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index 7d2ae37..d27705b 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -58,12 +58,6 @@ class WebGLBookSceneModule extends BaseModule { this.addEventListener(document, 'preference-updated', this.handlePreferenceUpdated); this.addEventListener(document, 'localization:languageChanged', this.updateLocalizedText); - this.addEventListener(document, 'story:turn-start', this.triggerTextureRefresh); - this.addEventListener(document, 'story:turn-complete', this.triggerTextureRefresh); - this.addEventListener(document, 'story:history-updated', this.triggerTextureRefresh); - this.addEventListener(document, 'story:process-state', this.handleProcessState); - this.addEventListener(document, 'input', this.triggerTextureRefresh, true); - this.addEventListener(document, 'change', this.triggerTextureRefresh, true); if (this.mode !== '3d') { this.reportProgress(100, '2D book UI selected'); @@ -303,7 +297,6 @@ class WebGLBookSceneModule extends BaseModule { await new Promise(resolve => requestAnimationFrame(resolve)); this.reportProgress(96, 'Binding WebGL page controls'); this.installTextureEventBridge(); - this.triggerTextureRefresh(); return this.labImportPromise; } @@ -468,12 +461,10 @@ class WebGLBookSceneModule extends BaseModule { if (this.mode === '3d') { this.createLabHost(); this.installPreferenceBridge(); - if (this.labImportPromise) this.triggerTextureRefresh(); } const title = document.getElementById('game_title')?.textContent?.trim(); const label = document.getElementById('lab_title'); if (title && label) label.textContent = title; - this.triggerTextureRefresh(); } refreshModalOverview() { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 52869ed..200421c 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -13,6 +13,48 @@ const uiDisplayHandlerPath = path.join(__dirname, '..', 'public', 'js', 'ui-disp const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8'); const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js'); const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8'); +const webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js'); +const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8'); +const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js'); +const loaderSource = fs.readFileSync(loaderPath, 'utf8'); + +function dependencyList(source, moduleId) { + const classStart = source.indexOf(`super('${moduleId}'`); + if (classStart < 0) return []; + const dependencyMatch = source.slice(classStart).match(/this\.dependencies\s*=\s*\[([^\]]*)\]/); + if (!dependencyMatch) return []; + return Array.from(dependencyMatch[1].matchAll(/'([^']+)'|"([^"]+)"/g)).map(match => match[1] || match[2]); +} + +function directGetModules(source) { + return Array.from(source.matchAll(/getModule\('([^']+)'\)|getModule\("([^"]+)"\)/g)).map(match => match[1] || match[2]); +} + +function undeclaredDirectDependencies(source, moduleId, optional = []) { + const declared = new Set(dependencyList(source, moduleId)); + const allowed = new Set([moduleId, ...declared, ...optional]); + return Array.from(new Set(directGetModules(source))).filter(id => !allowed.has(id)); +} + +function cacheBuster(source) { + return source.match(/MODULE_CACHE_BUSTER\s*=\s*'([^']+)'/)?.[1] || null; +} + +function methodBody(source, methodName) { + const start = source.indexOf(`${methodName}(`); + if (start < 0) return ''; + const braceStart = source.indexOf('{', start); + if (braceStart < 0) return ''; + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') { + depth -= 1; + if (depth === 0) return source.slice(braceStart + 1, index); + } + } + return ''; +} const checks = [ ['scene-level SSAO import', /SSAOPass/.test(source)], @@ -46,7 +88,24 @@ const checks = [ ['page reveal shader uses coordinate mask instead of comparing page textures', /bookRevealWordRects/.test(source) && /bookRevealWordTimings/.test(source) && /bookRevealElapsedMs/.test(source) && !/texture2D\(bookRevealMap/.test(source)], ['texture renderer explicitly gates initial font before painting', /waitForTextureFonts/.test(textureRendererSource) && /ensureTextureFontFace/.test(textureRendererSource) && /FontFace\(family/.test(textureRendererSource) && /document\.fonts\.load\('72px "EB Garamond Initials"'\)/.test(textureRendererSource)], ['texture renderer no longer republishes stale scene-ready textures', !/addEventListener\(document, 'webgl-book:scene-ready'/.test(textureRendererSource) && !/handleSceneReady\(\)\s*{\s*this\.publishSpread\(\)/.test(textureRendererSource) && !/drawEmptySpread/.test(textureRendererSource)], - ['prepared reveal never falls back to unmasked direct upload before shader compile', /pendingPageReveal/.test(source) && /applyPendingPageReveal/.test(source) && !/if \(!shader\?\.uniforms\) {\s*uploadPageTextureDirect\(side, sourceCanvas\)/.test(source)] + ['prepared reveal never falls back to unmasked direct upload before shader compile', /pendingPageReveal/.test(source) && /applyPendingPageReveal/.test(source) && !/if \(!shader\?\.uniforms\) {\s*uploadPageTextureDirect\(side, sourceCanvas\)/.test(source)], + ['ui display handler declares every direct module lookup', undeclaredDirectDependencies(uiDisplayHandlerSource, 'ui-display-handler').length === 0], + ['webgl scene declares every direct module lookup', undeclaredDirectDependencies(webglSceneSource, 'webgl-book-scene').length === 0], + ['loader cache key matches webgl procedural imports', cacheBuster(loaderSource) && source.includes(`procedural-book-model.js?v=${cacheBuster(loaderSource)}`) && proceduralBookSource.length > 0], + ['webgl lab exposes loader timing diagnostics', /loaderTimings/.test(source) && /markLoaderTiming/.test(source) && /primeSceneForLoader/.test(source)], + ['webgl lab records shader compile timing during loader prime', /markLoaderTiming\('shaderCompile:start'\)/.test(source) && /renderer\.compile\(scene, camera\)/.test(source) && /markLoaderTiming\('shaderCompile:end'\)/.test(source)], + ['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealWordCount/.test(source)], + ['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)], + ['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)], + ['webgl reveal visual clock caps missed-frame deltas', /visualElapsedMs/.test(source) && /revealFrameDeltaMs/.test(source) && /Math\.min\(revealFrameDeltaMs/.test(source)], + ['webgl lab records page texture upload/copy timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)], + ['webgl lab times direct and reveal texture uploads', /markPageTextureTiming\('directUpload:start'/.test(source) && /markPageTextureTiming\('revealUpload:start'/.test(source) && /markPageTextureTiming\('drawCanvasPageTexture:start'/.test(source)], + ['texture renderer exposes reveal pipeline diagnostics', /pipelineTimings/.test(textureRendererSource) && /markPipelineTiming/.test(textureRendererSource) && /webglTexturePipeline/.test(textureRendererSource)], + ['texture renderer records prepare draw publish and start reveal timing', /markPipelineTiming\('prepareRevealBlock:start'/.test(textureRendererSource) && /markPipelineTiming\('drawSpread:start'/.test(textureRendererSource) && /markPipelineTiming\('publishSpread'/.test(textureRendererSource) && /markPipelineTiming\('startPreparedRevealAnimation'/.test(textureRendererSource)], + ['texture renderer diagnostics include reveal word counts', /wordCounts/.test(textureRendererSource) && /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource)], + ['webgl scene avoids duplicate initial texture publish', !/this\.triggerTextureRefresh\(\)/.test(methodBody(webglSceneSource, 'initializeScene'))], + ['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)], + ['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))] ]; const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);