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:
+83
-14
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user