diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 07d57f5..78fab65 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -336,10 +336,17 @@ class BookTextureRendererModule extends BaseModule { ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height); ctx.drawImage(result.pageBitmap, 0, 0); result.pageBitmap.close?.(); - if (hasReveal && result.baseBitmap) { - this.revealBaseCanvases[side] = this.canvasFromBitmap(result.baseBitmap); + // The paper base is identical for every page of a side; the worker sends its bitmap + // only once, and we cache the canvas and reuse it for all reveals. This removes a + // large per-block canvas/bitmap allocation that was driving GC stalls. + if (result.baseBitmap) { + if (!this.cachedBaseCanvas) this.cachedBaseCanvas = {}; + this.cachedBaseCanvas[side] = this.canvasFromBitmap(result.baseBitmap); + result.baseBitmap.close?.(); + } + if (hasReveal) { + this.revealBaseCanvases[side] = this.cachedBaseCanvas?.[side] || null; } - result.baseBitmap?.close?.(); }); const published = this.publishSpread(sidesToDraw, options); this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase }); diff --git a/public/js/book-texture-worker.js b/public/js/book-texture-worker.js index 3e4e49e..977f9e5 100644 --- a/public/js/book-texture-worker.js +++ b/public/js/book-texture-worker.js @@ -9,6 +9,10 @@ let fontsReady = null; const imageCache = new Map(); // src -> ImageBitmap | null const surfaces = {}; // side -> { canvas, ctx } +// The reveal "base" layer is the plain paper background (drawPageBase) — identical for every +// page of a side at a given size. Send its bitmap only once per side+size; the main thread +// caches and reuses it, avoiding a large per-block ImageBitmap allocation (GC churn). +const sentBaseKeys = new Set(); function resolveImageSource(metadata = {}) { const explicit = String(metadata.url || metadata.src || '').trim(); @@ -319,7 +323,11 @@ async function renderSide(job, side) { drawPageBase(ctx, side, width, height); let baseBitmap = null; - if (job.hasReveal) baseBitmap = await createImageBitmap(surface.canvas); + const baseKey = `${side}:${width}x${height}`; + if (job.hasReveal && !sentBaseKeys.has(baseKey)) { + baseBitmap = await createImageBitmap(surface.canvas); + sentBaseKeys.add(baseKey); + } if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData); drawPageLines(ctx, metrics, side, job.spreads?.[side] || []); drawPageNumber(ctx, metrics, side, meta); diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index f4f1802..7cc85ea 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -7,7 +7,9 @@ import { BaseModule } from './base-module.js'; const TTS_GENERATION_TIMEOUT_MS = 60000; const ASSET_PRELOAD_TIMEOUT_MS = 60000; const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000; -const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2; +// Prepare only the next block's page render ahead of playback. Higher values let multiple +// large page rasterizations overlap, spiking allocation into multi-second GC stalls. +const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1; class SentenceQueueModule extends BaseModule { constructor() { @@ -223,14 +225,7 @@ class SentenceQueueModule extends BaseModule { sentence.playbackStartedAt = performance.now(); this.onSentenceReadyCallback(sentence, resolve); }); - // Start lookahead only after the current sentence has entered the display - // pipeline. This keeps future WebGL book preparation out of the first - // flip/reveal critical path while still overlapping it with playback. - window.requestAnimationFrame(() => { - if (this.isCurrentQueueItem(item, queueGeneration)) { - this.prefetchAhead(6, queueGeneration); - } - }); + this.scheduleLookaheadAfterDisplay(item, queueGeneration); await playbackFinished; if (!this.isCurrentQueueItem(item, queueGeneration)) return; } else { @@ -978,6 +973,18 @@ class SentenceQueueModule extends BaseModule { return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; } + scheduleLookaheadAfterDisplay(item, queueGeneration = this.queueGeneration) { + const run = () => { + if (this.isCurrentQueueItem(item, queueGeneration)) { + this.prefetchAhead(6, queueGeneration); + } + }; + window.requestAnimationFrame(() => { + const scheduleIdle = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 180)); + scheduleIdle(run, { timeout: 260 }); + }); + } + prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) { if (this.sentenceQueue.length <= 1) { document.dispatchEvent(new CustomEvent('story:process-state', { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 89c3084..747015d 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -145,7 +145,8 @@ const checks = [ ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)], ['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)], ['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)], - ['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)], + ['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)], + ['texture worker sends the static paper base bitmap once per side and the renderer reuses it', /sentBaseKeys/.test(textureWorkerSource) && /const baseKey = `\$\{side\}:\$\{width\}x\$\{height\}`/.test(textureWorkerSource) && /this\.cachedBaseCanvas\[side\] = this\.canvasFromBitmap/.test(textureRendererSource) && /this\.revealBaseCanvases\[side\] = this\.cachedBaseCanvas\?\.\[side\]/.test(textureRendererSource)], ['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource)], ['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)], ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],