From da3760819786618a8ab152d0eb6236c3a09bb03d Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sun, 7 Jun 2026 17:37:31 +0200 Subject: [PATCH] Reduce WebGL page texture runtime stalls --- public/js/book-page-format-module.js | 2 +- public/js/book-texture-renderer-module.js | 3 -- public/js/loader.js | 2 +- public/js/webgl-book-lab.js | 47 +++++++++++++++-------- scripts/check-webgl-book-lab.js | 9 +++-- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index 1832fb6..688ef76 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-reveal-clock'; +import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-resize-prime'; 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 b7a9cf4..d7b241c 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -86,9 +86,6 @@ class BookTextureRendererModule extends BaseModule { this.currentSpread = spread || { left: [], right: [] }; if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { this.markPendingReveal(latestBlockId); - const pendingSides = this.getBlockSides(latestBlockId); - const immediateSides = ['left', 'right'].filter(side => !pendingSides.includes(side)); - if (immediateSides.length) this.drawSpread(this.currentSpread, immediateSides); return; } this.drawSpread(this.currentSpread); diff --git a/public/js/loader.js b/public/js/loader.js index 79476e1..181318a 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-reveal-clock'; +const MODULE_CACHE_BUSTER = '20260607-webgl-resize-prime'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 3e9b404..10d5d81 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-reveal-clock'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-resize-prime'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -55,6 +55,8 @@ const aoExcludedObjects = new Set(); let renderedFrameCount = 0; let staticSceneBuffersDirty = true; let lastStaticCameraSignature = ''; +let lastResizeWidth = 0; +let lastResizeHeight = 0; function reportLabProgress(percent, message) { if (typeof appInitialState.reportProgress === 'function') { @@ -404,9 +406,11 @@ notifyBookPageCountChanged(); await reportLabStep(82, 'Loading room reflection texture'); await loadAiRoomReflection(); await reportLabStep(86, 'Preparing static shadow and mirror maps'); +resize(); primeSceneForLoader(); await reportLabStep(90, 'Compiled WebGL scene passes'); window.BookLabDebug = { + cacheKey: window.MODULE_CACHE_BUSTER || null, textures: generatedTextureCanvases, ready: false, renderedFrames: 0, @@ -482,8 +486,8 @@ window.BookLabDebug = { return projectPointerToPage(clientX, clientY); }, exportTexture(name) { - if (name === 'left' || name === 'leftPage') return leftCanvas.toDataURL('image/png'); - if (name === 'right' || name === 'rightPage') return rightCanvas.toDataURL('image/png'); + if (name === 'left' || name === 'leftPage') return leftTexture.image?.toDataURL?.('image/png') || leftCanvas.toDataURL('image/png'); + if (name === 'right' || name === 'rightPage') return rightTexture.image?.toDataURL?.('image/png') || rightCanvas.toDataURL('image/png'); return generatedTextureCanvases[name]?.toDataURL('image/png') || null; } }; @@ -1675,17 +1679,14 @@ function handlePageCanvases(event) { } function uploadPageTextureDirect(side, sourceCanvas) { - const canvas = side === 'left' ? leftCanvas : rightCanvas; const texture = side === 'left' ? leftTexture : rightTexture; markPageTextureTiming('directUpload:start', { side }); clearPageReveal(side, 'direct-upload'); - drawCanvasPageTexture(canvas, sourceCanvas, side); - texture.needsUpdate = true; + bindPageTextureSource(side, texture, sourceCanvas); markPageTextureTiming('directUpload:end', { side }); } function beginPageReveal(side, sourceCanvas, revealDetail = {}) { - const canvas = side === 'left' ? leftCanvas : rightCanvas; const texture = side === 'left' ? leftTexture : rightTexture; const shader = getPageRevealShader(side); @@ -1693,8 +1694,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { side, wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0 }); - drawCanvasPageTexture(canvas, sourceCanvas, side); - texture.needsUpdate = true; + bindPageTextureSource(side, texture, sourceCanvas); pageRevealState[side] = { startedAt: revealDetail.startNow ? performance.now() : null, @@ -1875,12 +1875,21 @@ function updatePageRevealAnimations(now) { }); } -function drawCanvasPageTexture(canvas, sourceCanvas, side) { - markPageTextureTiming('drawCanvasPageTexture:start', { +function bindPageTextureSource(side, texture, sourceCanvas) { + const fallbackCanvas = side === 'left' ? leftCanvas : rightCanvas; + const nextCanvas = sourceCanvas || fallbackCanvas; + markPageTextureTiming('bindPageTextureSource:start', { side, - width: canvas?.width || 0, - height: canvas?.height || 0 + width: nextCanvas?.width || 0, + height: nextCanvas?.height || 0 }); + texture.image = sourceCanvas || fallbackCanvas; + texture.needsUpdate = true; + updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true); + markPageTextureTiming('bindPageTextureSource:end', { side }); +} + +function drawCanvasPageTexture(canvas, sourceCanvas, side) { const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f2ead0'; ctx.fillRect(0, 0, canvas.width, canvas.height); @@ -1894,7 +1903,6 @@ 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; } @@ -1916,11 +1924,15 @@ function updatePageTextureDebugState(side, canvas, source, painted) { height: canvas.height, sourceId: source?.id || 'book-texture-renderer', sourceTextLength: 0, - darkPixels: countPageTextureDarkPixels(canvas) + darkPixels: shouldSamplePageTextureDebug() ? countPageTextureDarkPixels(canvas) : null }; document.documentElement.dataset.webglPageTextures = JSON.stringify(state); } +function shouldSamplePageTextureDebug() { + return tableDebugMode !== tableDebugModes.none; +} + function countPageTextureDarkPixels(canvas) { const sampleCanvas = document.createElement('canvas'); const sampleSize = 64; @@ -2987,9 +2999,12 @@ function tintAmbientFromCanvas(canvas) { } function resize() { - markStaticSceneBuffersDirty(); const width = Math.max(1, window.innerWidth); const height = Math.max(1, window.innerHeight); + const sizeChanged = width !== lastResizeWidth || height !== lastResizeHeight; + lastResizeWidth = width; + lastResizeHeight = height; + if (sizeChanged) markStaticSceneBuffersDirty(); renderer.setSize(width, height, false); if (composer) composer.setSize(width, height); if (sceneAoPass) sceneAoPass.setSize(width, height); diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 200421c..4acac26 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -79,7 +79,7 @@ const checks = [ ['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)], ['debug AO remains scene-level', /scene debug: SSAO/.test(source)], ['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)], - ['render readiness flag is exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source)], + ['render readiness flag and cache key are exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source) && /cacheKey: window\.MODULE_CACHE_BUSTER/.test(source)], ['3D playback bypasses DOM word animation scheduling', /isWebGLPlaybackMode/.test(playbackCoordinatorSource) && /if \(this\.isWebGLPlaybackMode\(\)\)/.test(playbackCoordinatorSource) && /scheduleWebGLReveal/.test(playbackCoordinatorSource)], ['3D UI defers rendered history mark until playback completes', /deferRenderedMark/.test(uiDisplayHandlerSource) && /prepareWebGLBookReveal/.test(uiDisplayHandlerSource) && /markBlockRendered\(sentence\.blockId/.test(uiDisplayHandlerSource)], ['pagination can build a pending unrendered 3D block', /preparePendingBlock/.test(bookPaginationSource) && /book-pagination:prepare-block/.test(bookPaginationSource)], @@ -94,15 +94,18 @@ const checks = [ ['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 sizes render targets before static loader prime', /await reportLabStep\(86, 'Preparing static shadow and mirror maps'\);\s*resize\(\);\s*primeSceneForLoader\(\);/.test(source) && /lastResizeWidth/.test(source) && /lastResizeHeight/.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)], + ['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)], + ['webgl lab binds source canvases directly instead of copying whole page textures', /bindPageTextureSource/.test(source) && /texture\.image = sourceCanvas/.test(source) && !/drawCanvasPageTexture/.test(methodBody(source, 'uploadPageTextureDirect')) && !/drawCanvasPageTexture/.test(methodBody(source, 'beginPageReveal'))], + ['page texture dark-pixel sampling only runs in table debug mode', /function shouldSamplePageTextureDebug\(\)/.test(source) && /tableDebugMode !== tableDebugModes\.none/.test(source) && /shouldSamplePageTextureDebug\(\) \? countPageTextureDarkPixels\(canvas\) : null/.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)], + ['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.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'))]