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>
After clearing the page-texture cache, the worker's first drawSpread had to load the EB
Garamond faces AND rasterize inside a single 4s draw-timeout budget. On a cold load that could
exceed 4s, so the timeout fired, the draw resolved to null (no title painted), the loader then
completed over a black scene, and the title only appeared on a later render ("the image
returned outside the loader's progress indicators").
The renderer now awaits the worker's fonts-ready signal before its first timed draw (with a
15s safety cap so it can't hang), so font loading happens during the loader as its own step
rather than inside a draw's timeout window. Draw timeout raised 4s -> 6s for cold-render
headroom. Verified live: title page renders within the loader, no texture-worker-timeout
problems. Suite 178.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the worker migration with prepare-burst pacing and a title-flip fix:
- New game from mid-game left the book on the previous game's spread, so the first block's
source and target spread matched and the title->content page turn was skipped. story:client-reset
now returns the book to the title spread (spread 0) so the first block flips 0->1 and animates.
Verified: requiresSpreadTransition src=0 tgt=1, page-flip-started/near-end fire.
- The lookahead burst-prepared many blocks at once, spiking allocation/GC into multi-second
main-thread stalls. WebGL book prepares are now serialized through a chain and capped to a
small lookahead window (TTS audio prefetch still spans the full window); future lookahead is
also deferred until the current sentence has entered the display pipeline, keeping it off the
first flip/reveal critical path. Worst game-start stall ~6s -> ~3.4s.
- Page flips now drive the scene through the sceneControl prewarm/startPreparedPageFlip API
(awaited) instead of an event, and the scene awaits the async initial spread draw.
Suite 177. Remaining: a per-block prepare stall (~1.6-3.4s for large blocks at game start)
that profiling has not yet attributed to a single function (likely GC from prepare-path
allocation) — needs a DevTools performance capture for exact attribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handlePageTextureRecords() called markStaticSceneBuffersDirty() on every page-texture update,
forcing a full SSAO + shadow + reflection recompute (23-47ms frames) on every block during
playback — even though a page-text change moves no geometry. AO and shadows depend only on
geometry; the soft tabletop reflection picks up the new page on its throttled cadence. Removed
the forced dirty so only real geometry changes (flips, camera, rebuild, resize) recompute the
static buffers. Playback median ~60->63fps; the per-block forced heavy frames are gone (the
remaining periodic ~23ms frames are the normal 8Hz throttled refresh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two robustness gaps from the worker migration, both raised in review:
- The raster worker had no failure recovery: a thrown createImageBitmap/font error or a
dropped message would leave the draw promise pending forever, stalling the serialized draw
chain and hanging prepare/playback. Added worker.onerror and a per-job timeout; both settle
the in-flight draw to a logged miss (texture-worker-error / -timeout) so the pipeline
degrades to last-good per the spec instead of hanging. A single settleRasterization path
clears the timer and resolves.
- prepareSpreadTextureRecordsForFlip() called drawSpread() without awaiting it. That was safe
when drawSpread was synchronous, but now that it is async (worker) the flip could race ahead
of the worker draw and miss the resident texture. prewarmFlipTextures now awaits both spread
draws before the resident-texture lookup.
Suite 168 (added assertions for worker error/timeout recovery and the awaited prewarm).
Normal draw path is behaviorally unchanged from the verified worker commit.
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>
Two regressions made the cursor stop communicating game state:
- The canvas had a hardcoded `cursor: grab`, overriding the document-level process-state
cursor everywhere over the 3D scene (always a hand). Removed it so the canvas inherits the
state cursor; grab is now shown only transiently while right-drag-rotating the camera.
- normalizeProcessState pinned ready/waiting-generating to the playing (feather) cursor
whenever playbackCoordinator.isPlaying was set, which lingered at choice prompts — so an
open choice showed the feather instead of the input cursor. Now, when an input prompt is
open AND no sentence is actively playing (timeline's webglBookPlaybackActive), the playback
overlay is stripped (playing-ready->ready, playing-generating->waiting-generating) and the
input/server cursor shows. Opening an input mode also refreshes the cursor immediately.
Verified live over the canvas: feather while a sentence plays, input arrow at a choice/idle,
and they switch correctly with playback state (no stuck feather, no constant grab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
primeSceneForLoader() compiled scene materials and rendered the shadow/reflection passes
once, but never ran the full composer pipeline — so the SSAO and output passes compiled
their programs and allocated their render targets on the first live frame after the loader
faded, tanking FPS for ~1-2s before it climbed to full.
Now the loader runs composer.render() twice during prime, and precompiles the flip page
materials (created lazily on first flip, so previously missed by renderer.compile) via a
throwaway probe mesh. The heavy first-frame work is paid behind the loader overlay instead.
Verified live: loader timings show composerWarmup taking ~1499ms during load (exactly the
cost that used to hit the first frame); after fade-out there are no over-budget tank frames
in the slow-frame log and idle settles at ~72fps. Static suite passes (165).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The table reflection (full scene re-render) and the book shadow maps each cost ~11ms/frame
and were refreshing at 30Hz even when nothing moved, so every idle/reveal frame paid for one
heavy pass on top of the ~12ms scene render — ~45-52fps.
These passes only need full-rate updates while the book geometry is actually moving (a page
flip). At idle, or during a text reveal where only the page texture mask animates, they now
refresh at 8Hz (candle flicker is the only thing changing them then, captured imperceptibly).
Most non-flip frames are then just the scene render.
pixelRatio is deliberately left at 2x: the book is tilted, so page glyphs are minified along
the tilt and the supersampling is the scene's antialiasing (the composer MSAA is disabled in
app-integration mode). Reducing it blurs text and exposes edge aliasing, so 60fps is bought
from the geometry-independent passes instead. Expressed pixelRatio via devicePixelRatio so it
stays native on HiDPI.
Verified live at WQHD/2x (screenshot-checked crisp text + clean edges): idle ~64fps median
(was 52), reveal ~66fps median (was ~33). Remaining single-digit dips are main-thread page
rasterization during background prepare — addressed by the worker migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
For a block that overflows onto the next spread, the plan is now prepared spanning-aware
during the background lookahead — the start spread's reveal timing is derived across both
preview spreads, and the continuation spread's plan is prepared and cached at the same time.
playback then follows a single path:
- activate reuses the prepared start plan (removed the synchronous forceRebuild rebuild).
- revealContinuationSpread reuses the prepared continuation plan (removed the redraw
fallback); a missing plan is surfaced as a problem, not silently redrawn.
This removes the parallel/immediate prepare distinction and the two fallbacks, leaving one
intended path, and moves the spanning draw work off the critical path.
Verified live on a real spanning block: right line reveals at its area share (~3.3s), the
flip fires, and the continuation appears ~0.3s after the flip (was ~2.7s) and animates
progressively across the next spread over the full TTS — no pop-in, no fast-forward, no
timeline-reveal-continuation-missing. Static suite passes (165).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A spanning block stalled ~2.1s before it began playing: activate ran prepareRevealBlock
with forceRebuild, synchronously redrawing the start spread (and preloading the
continuation spread) on the main thread, because the lookahead plan had been built with
right-only timing before pagination committed the overflow.
Build the start-spread plan spanning-aware during lookahead instead: when the preview
layout shows the block overflows, derive its timing across both preview spreads (via the
revealSpreadSourceOverride) and cache it. activate then reuses that plan — the same fast
cached-plan path non-spanning blocks already use — with no synchronous redraw. forceRebuild
is kept only as a fallback when a block spans but was not prepared spanning-aware (e.g. an
immediate prepare with no preview layout), and an evicted plan still rebuilds correctly
because pagination is committed by then.
Verified live: the spanning block's pre-playback gap dropped from ~2088ms to 139ms (equal
to non-spanning blocks), while the right line still reveals over its area share (~3.3s),
the continuation still animates from the start, and there are no fast-forwards or problems.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
For a paragraph that overflows onto the next spread, the continuation page was redrawn
synchronously after the flip (drawSpread on the main thread), so the next page stayed
blank for ~2.7s and then the carried-over lines popped in already ~24% revealed instead
of animating from the start.
Move that work off the critical path: during lookahead, prepare and cache the
continuation spread's reveal plan using the not-yet-committed preview spreads (so per-line
timing is computed across both spreads), then reuse it after the flip instead of redrawing.
- pagination: expose the preview spread layout on the returned preview spread so the owner
can detect the continuation spread (race-free; each call owns its preparedSpreads).
- renderer: revealSpreadSourceOverride lets region collection use preview spreads during
lookahead; prepareContinuationRevealPlan draws+caches the continuation plan (publishEvent
off); takeContinuationRevealPlan reuses it, re-stamped as an activate-phase publish.
- timeline: prepare the continuation plan during background (non-immediate) prepares;
revealContinuationSpread reuses it, falling back to the redraw when none was prepared.
Verified live on a spanning block: continuation now appears ~0.25s after the flip (was
~2.7s) at ve~3471 = the right line's duration, i.e. it animates from the start (no pop-in),
runs to ~full over the TTS, no fast-forward, no continuation-missing problems.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous commit changed per-line reveal-duration distribution from ink-area to
word-count. That dropped a deliberate precision decision (area gives sub-line
granularity) and, verified live on a spanning paragraph, it was what made the
continuation page fail to animate. Restore area-weighting for the per-line split.
The word-share scaling of the *total* duration for partial (spanning) blocks and the
timeline-module timing snapshot/restore are kept — they only preserve existing
word-timings, they do not change the area-based per-line distribution.
Verified: on a real spanning block the right line reveals over its area share (~3.3s),
the page flips, and the continuation animates progressively across the next spread
over the full TTS (no fast-forward, no reveal-all-at-once).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A paragraph that overflows the right page onto the next spread revealed its single
right-page line over the entire TTS, then timed out (timeline-reveal-commit-timeout)
and only flipped after the whole narration. Two root causes:
- At activate the reused lookahead segment played a sentence instance whose animation
word-timings were lost (wordTimings=[], totalDuration=0), so reveal timing fell back
to an area estimate spanning the full TTS. Snapshot the timings at prepare and restore
them at activate.
- Reveal duration was distributed by ink area, but just-paginated continuation lines
have ~0 area, so the one right-page line received the whole duration. Distribute by
word count (reliable) with area as fallback.
Now the right page reveals only its word share (~2.7s for a 6/55-word line), commits,
and flips while TTS continues; the continuation animates on the next spread. Also
rewrote the right-reveal wait to a single timer + commit/fast-forward listeners with
cleanup, removing the stray timeline-reveal-commit-timeout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Starting a new game reuses block ids (1,2,3...). The reveal clock's per-block
start times (activeRevealBlockStarts in the lab) and the renderer's animation/
revealed sets are keyed by block id and were never cleared on a client reset, so
a new game over already-cached content inherited the previous run's start times.
beginPageReveal then computed a huge elapsed and the shader treated the reveal as
already complete — showing everything at once instead of animating.
resetClientPlaybackAndDisplay (run on new game and restore) now emits
story:client-reset; the lab clears activeRevealBlockStarts/pending reveal state,
the texture renderer clears active animations and revealed-block ids, and the
timeline invalidates prepared segments. So each game starts with a clean reveal
clock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Navigation now operates on spread indices, not the non-contiguous page-position
scheme that mapped a forward step onto the same spread (so forward stalled and
triggered a no-op multi-flip). Forward/back move one spread; start/end and the
slider use spread indices. The page readout shows the odd page of the visible
pair (2*spread+1) or 0 at the title spread.
- Flipping forward could show the source page with its last word still masked: a
stale reveal mask left on the flip surface by a previous playback flip was not
cleared when the (finished) source page had no active reveal. Reset the flip
surface reveal shader in that case so the full page shows during the turn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reveal timing is now word-proportional per page: when a block's reveal only
covers part of the block (the continuation spread is not paginated at reveal
time), the page reveals only its share of the TTS, offset by the words before
it. The right page no longer absorbs the whole TTS before flipping; it flips at
normal pace and the continuation resumes on the next spread while TTS plays. No
effect when the regions already cover the whole block (unified plan / one page).
- Page flip start now shows the target spread's same-side page beneath the lifting
page (revealed as it turns away) instead of a blank that pops in after the flip.
Deferred (pending-reveal) sides stay blank so the masked reveal still lands via
activate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establish book-playback-timeline as the sole playback owner driving the
scene through formal webgl-book:* events (not the BookLabDebug surface),
with a single reveal clock in the scene render loop and webgl-page-cache as
the only texture cache. Remove the legacy dual playback path and the
ownsPageFlipCommit gating.
Fixes:
- Flip page detached/folded at the spine: restore the raw page-cap line for
flip geometry (matches the prototype/pre-regression), removing
normalizeFlipLineToVisiblePage which moved the pivot off the spine arc.
- Flip textures: distance-based UVs (no horizontal compression),
direction-aware face material (source on the camera-facing side), source
meta derived from the visible spread (manual flips), prewarm shape fix.
- Reveal: flash removed on the static page and the flip back surface;
spanning blocks rebuild the reveal plan at activate and continue the
reveal on the next spread after the fill flip.
- Cache staleness is contentVersion-primary; nav clamps to spreadCount.
Docs updated to describe the intended single-owner architecture. Regression
checks updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>