Fix new-game title flip + cap lookahead prepare burst

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>
This commit is contained in:
2026-06-20 00:59:01 +02:00
parent 004c077181
commit 705d1ea6bf
6 changed files with 237 additions and 67 deletions
+83 -14
View File
@@ -140,6 +140,7 @@ const dynamicBufferRefreshIntervalMs = 1000 / 30;
// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing
// changing them then, which 8Hz captures imperceptibly.
const staticGeometryBufferRefreshIntervalMs = 1000 / 8;
const revealGeometryBufferRefreshIntervalMs = 1000 / 4;
const flipDynamicBufferGraceMs = 180;
let lastBookShadowRefreshAt = -Infinity;
let lastTableReflectionRefreshAt = -Infinity;
@@ -184,6 +185,8 @@ const lastFrameTiming = {};
const slowFrameLog = [];
const loaderTimings = {};
const pageTextureTimings = [];
let queuedNavigationPrewarm = null;
let queuedNavigationPrewarmHandle = null;
function markLoaderTiming(name) {
loaderTimings[name] = performance.now();
@@ -385,6 +388,10 @@ const materials = {
}),
flipPageSurface: new THREE.MeshStandardMaterial({
color: 0xeee6cc,
map: getBlankPageTexture(),
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.004, 0.004),
roughnessMap: paperTextures.roughness,
roughness: 0.92,
metalness: 0,
emissive: 0x100d08,
@@ -440,6 +447,8 @@ const materials = {
};
materials.flipPageBackSurface = materials.flipPageSurface.clone();
materials.flipPageBackSurface.map = getBlankPageTexture();
materials.flipPageBackSurface.normalMap = paperTextures.normal;
materials.flipPageBackSurface.roughnessMap = paperTextures.roughness;
materials.flipPageBackSurface.side = THREE.FrontSide;
materials.flipPageEdge = materials.pageSurface.clone();
materials.flipPageEdge.map = paperTextures.edge;
@@ -622,6 +631,15 @@ window.BookLabDebug = {
requestPageFlip(direction = 1, options = {}) {
return startPageFlip(direction, options);
},
async prewarmPageFlip(direction = 1, options = {}) {
const targetSpread = Number.isFinite(Number(options.targetSpread))
? Math.max(0, Math.round(Number(options.targetSpread)))
: null;
return prewarmFlipTextures(direction, targetSpread);
},
startPreparedPageFlip(direction = 1, options = {}) {
return startPageFlipPrepared(direction, options);
},
getRevealDebugState() {
return getRevealDebugState();
},
@@ -680,7 +698,9 @@ if (webglBookSceneModule) {
setPageReserve: (value) => setPageReserve(value),
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY)
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY),
prewarmPageFlip: (direction = 1, options = {}) => window.BookLabDebug.prewarmPageFlip(direction, options),
startPreparedPageFlip: (direction = 1, options = {}) => window.BookLabDebug.startPreparedPageFlip(direction, options)
};
}
@@ -716,6 +736,15 @@ document.addEventListener('story:client-reset', () => {
pageRevealFreezeAt = null;
clearPageReveal('left', 'client-reset');
clearPageReveal('right', 'client-reset');
// Return the book to the title spread so the new game's first block flips in from the
// title page. Otherwise the view stays on the previous game's spread, the segment's
// source and target spread match, and the title->content page turn is skipped.
if (Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))) !== 0) {
bookPaginationState = { ...bookPaginationState, spreadIndex: 0 };
const titleSpread = getPaginationSpread(0);
if (titleSpread) window.BookTextureRenderer?.drawSpread?.(titleSpread, ['left', 'right'], { force: true });
syncBookControls();
}
});
// Pagination spread updates only carry state. The playback owner decides when the
// visible spread changes (via flips). The scene jumps directly only for non-playback
@@ -2232,12 +2261,7 @@ function handlePageTextureRecords(event) {
source: 'book-texture-renderer'
});
markPageTextureTiming('handlePageTextureRecords:end');
prewarmNavigationTextureWindow('page-texture-records', { recordMiss: false }).catch((error) => {
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
scheduleNavigationTextureWindowPrewarm('page-texture-records', { recordMiss: false });
}
function normalizePageTextureRecordDetail(detail = {}) {
@@ -2441,6 +2465,35 @@ async function prewarmNavigationTextureWindow(reason = 'navigation-window', opti
return result || {};
}
function scheduleNavigationTextureWindowPrewarm(reason = 'navigation-window', options = {}) {
queuedNavigationPrewarm = {
reason,
options: { ...(options || {}) }
};
if (queuedNavigationPrewarmHandle !== null) return;
const run = () => {
queuedNavigationPrewarmHandle = null;
const queued = queuedNavigationPrewarm;
queuedNavigationPrewarm = null;
if (!queued) return;
if (activeFlips.length > 0 || hasActivePageReveal()) {
scheduleNavigationTextureWindowPrewarm(queued.reason, queued.options);
return;
}
prewarmNavigationTextureWindow(queued.reason, queued.options).catch((error) => {
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
};
if (typeof window.requestIdleCallback === 'function') {
queuedNavigationPrewarmHandle = window.requestIdleCallback(run, { timeout: 350 });
} else {
queuedNavigationPrewarmHandle = window.setTimeout(run, 80);
}
}
async function prewarmFlipTextures(direction, targetSpread = null) {
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
const nextSpread = Number.isFinite(Number(targetSpread))
@@ -2545,10 +2598,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null);
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
const activeStartedAt = revealBlockIds
.map(blockId => activeRevealBlockStarts.get(blockId))
.filter(value => Number.isFinite(Number(value)))
.sort((a, b) => a - b)[0] ?? null;
const activeStartedAt = getRevealStartTimeForBlockIds(revealBlockIds);
pageRevealState[side] = {
startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null),
@@ -2600,6 +2650,22 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
markPageTextureTiming('revealUpload:end', { side });
}
function getRevealStartTimeForBlockIds(blockIds = []) {
const startedAt = (Array.isArray(blockIds) ? blockIds : [])
.map(blockId => activeRevealBlockStarts.get(String(blockId)))
.filter(value => Number.isFinite(Number(value)))
.sort((a, b) => a - b)[0] ?? null;
if (startedAt !== null) return startedAt;
const pendingBlockId = (Array.isArray(blockIds) ? blockIds : [])
.map(blockId => String(blockId))
.find(blockId => pendingRevealStartBlockIds.has(blockId));
if (!pendingBlockId) return null;
const now = performance.now();
activeRevealBlockStarts.set(pendingBlockId, now);
pendingRevealStartBlockIds.delete(pendingBlockId);
return now;
}
function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const revealDetail = material?.userData?.pendingPageReveal;
@@ -3141,8 +3207,6 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true;
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface);
flip.sourceTexture = sourceTexture;
@@ -4733,7 +4797,12 @@ function animate(now = performance.now()) {
: Infinity;
const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs;
const geometryAnimating = activeFlips.length > 0;
const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs;
const revealAnimating = hasActivePageReveal();
const bufferRefreshIntervalMs = geometryAnimating
? dynamicBufferRefreshIntervalMs
: revealAnimating
? revealGeometryBufferRefreshIntervalMs
: staticGeometryBufferRefreshIntervalMs;
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs
);