Profiling the per-paragraph playback stutter showed the JS heap sawtoothing (37<->71MB) with
0.4-2.2s long tasks once per block — GC pauses from large (24-48MB) per-block canvas/ImageBitmap
allocations, not pagination (buildPages was ~29ms). These pauses freeze the flip/reveal
animation, which is also why the title flip looked un-animated.
- The reveal "base" layer is the plain paper background, identical for every page of a side.
The worker now sends its bitmap once per side+size; the renderer caches the canvas and reuses
it for all reveals, removing a large per-block bitmap+canvas allocation.
- WEBGL_BOOK_PREFETCH_LOOKAHEAD 2 -> 1 so only the next block's page render is prepared, instead
of letting multiple large rasterizations overlap.
Verified live: per-paragraph long tasks roughly halved (10 -> 5 over the same window) and worst
case 2159ms -> 1431ms. Residual ~1.4s stall remains from the per-block page bitmap + prepared-
page snapshot clone + texture upload; further reduction needs reworking those to reuse buffers.
Suite 181.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>