Move page rasterization to an OffscreenCanvas worker

Page text drawing (the bulk of drawSpread cost: layout, fonts, fillText across ~25 lines
x 2 pages at 3072px) ran synchronously on the main thread during prepare/lookahead, tanking
FPS at load and at flips/word boundaries.

New public/js/book-texture-worker.js owns rasterization off-thread: it loads the EB Garamond
faces via FontFace, draws base + title + lines + page number into an OffscreenCanvas, and
returns a full-page ImageBitmap plus a background-only base ImageBitmap (for the reveal mask)
per side. The main thread blits those onto the existing page canvases with one drawImage, so
the texture/reveal/scene pipeline downstream is unchanged. The worker also owns image loading
(fetch + createImageBitmap) and a DOM-free inline-tag parser (no document in a worker); the
renderer marshals the DOM-sourced title data in.

drawSpread is now async and serialized through a promise chain so the shared render state
(currentSpread, revealPublishBlockIds, spread override, reveal base) stays consistent across
the worker round trip even with concurrent lookahead prepares; the reveal context is passed
per draw rather than left on the instance. prepareRevealBlock / prepareContinuationRevealPlan /
preloadAdditionalRevealSpreads and their timeline callers await accordingly. The old
main-thread drawing methods are deleted (single implementation now lives in the worker).

Verified live: pages render correctly via the worker (text + drop caps crisp), worker fonts
load (probe returns fonts-ready + drawn), idle ~66fps, playback median ~60fps. Remaining
non-rasterization main-thread costs (procedural texture generation in the loader; pagination
text layout; per-frame reflection/shadow on content change) are separate follow-ups. Suite 166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 16:09:34 +02:00
parent 97f0b913be
commit 0e4d9e89d7
4 changed files with 513 additions and 397 deletions
+3 -3
View File
@@ -222,12 +222,12 @@ class BookPlaybackTimelineModule extends BaseModule {
: null;
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
const texturePlan = this.textureRenderer.prepareRevealBlock(
const texturePlan = await this.textureRenderer.prepareRevealBlock(
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
{ phase: 'prepare', publishEvent: false }
);
if (continuationSpread) {
this.textureRenderer.prepareContinuationRevealPlan({
await this.textureRenderer.prepareContinuationRevealPlan({
...revealDetail,
previewSpreads,
continuationSpread
@@ -317,7 +317,7 @@ class BookPlaybackTimelineModule extends BaseModule {
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
// both pages. No synchronous redraw on the critical path.
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
segment.activeTexturePlan = texturePlan;
this.applyTexturePlan(texturePlan, segment, 'activate');
await this.assertSegmentReady(segment, 'activate');