Checkpoint WebGL book playback refactor state

This commit is contained in:
2026-06-10 01:07:22 +02:00
parent 171cafeb65
commit b41340151d
8 changed files with 824 additions and 370 deletions
+244 -220
View File
@@ -213,6 +213,7 @@ let activeFlips = [];
let pendingPageFlips = 0;
const pendingRevealStartBlockIds = new Set();
const activeRevealBlockStarts = new Map();
let lastFlipTexturePreflight = null;
const paperColor = new THREE.Color(0xece4ca);
const inkColor = '#1a1009';
@@ -246,19 +247,24 @@ function createPageCanvasTexture(sourceCanvas) {
}
function getBlankPageTexture() {
if (blankPageTexture) return blankPageTexture;
blankPageTexture = createPageCanvasTexture(createPageCanvas('blank'));
return blankPageTexture;
return pageTextureStore?.getBlankTexture?.() || createPageCanvasTexture(createPageCanvas('blank'));
}
const preparedPageTextures = {
left: new Map(),
right: new Map()
};
const residentPageTextures = new Map();
const maxResidentPageTextures = 192;
let blankPageTexture = null;
const pageCacheProblemLog = [];
const pageTextureStore = window.moduleRegistry?.getModule?.('webgl-page-cache') || window.WebGLPageCache || null;
pageTextureStore?.configureTextureRuntime?.({
THREE,
renderer,
configureTexture: configurePageCanvasTexture,
createBlankCanvas: () => createPageCanvas('blank'),
maxResidentTextureCount: maxResidentPageTextures,
maxPreparedTextureCount: 128
});
pageTextureStore?.registerVisibleTexture?.('left', leftTexture, leftCanvas);
pageTextureStore?.registerVisibleTexture?.('right', rightTexture, rightCanvas);
await reportLabStep(50, 'Initializing page texture store VRAM window');
pageTextureStore?.getBlankTexture?.();
await prewarmNavigationTextureWindow('loader-prime', { recordMiss: false });
let currentPageMeta = {
left: null,
right: null
@@ -589,14 +595,18 @@ window.BookLabDebug = {
};
},
getRuntimeInvariants() {
const textureStoreState = pageTextureStore?.getRuntimeState?.() || {};
return {
targetFrameDurationMs,
residentPageTextureCount: residentPageTextures.size,
residentPageTextureCount: textureStoreState.residentTextureCount || 0,
maxResidentPageTextures,
pageCacheProblemCount: pageCacheProblemLog.length,
pageCacheProblemCount: textureStoreState.problemCount || 0,
preparedPageTextureCount: textureStoreState.preparedTextureCount || 0,
singlePageTextureStore: true,
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
mirrorRefreshesEveryFrame: true,
mirrorRefreshesWhenStaticDirty: true
mirrorRefreshesWhenStaticDirty: true,
lastFlipTexturePreflight
};
},
projectPointerToPage(clientX, clientY) {
@@ -610,7 +620,7 @@ window.BookLabDebug = {
};
window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
document.addEventListener('webgl-book:page-reveal-start', (event) => {
startPageRevealForBlock(event.detail?.blockId);
});
@@ -621,7 +631,7 @@ document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {});
});
document.addEventListener('webgl-book:page-cache-problem', (event) => {
recordPageCacheProblem(event.detail || {});
pageTextureStore?.recordProblem?.(event.detail || {});
});
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
@@ -630,7 +640,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if (
latestBlockId > latestRenderedBlockId
&& detail.allowFutureUnrendered !== true
&& detail.visibility !== 'future-ready'
&& activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) {
@@ -2028,35 +2038,34 @@ function syncBottomNavigation() {
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
}
function handlePageCanvases(event) {
const detail = event.detail || {};
function handlePageTextureRecords(event) {
const detail = normalizePageTextureRecordDetail(event.detail || {});
if (detail.pageMeta) {
const hasLeftMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'left');
const hasRightMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'right');
currentPageMeta = {
left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null,
right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null
};
currentPageMeta = normalizePageMetaPair(detail.pageMeta, currentPageMeta);
}
markPageTextureTiming('handlePageCanvases:start', {
markPageTextureTiming('handlePageTextureRecords:start', {
hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {}),
preloadOnly: Boolean(detail.preloadOnly),
phase: detail.phase || 'activate',
pageMeta: currentPageMeta
});
const leftReveal = attachRevealPageMeta(detail.reveal?.left, detail.pageMeta?.left || currentPageMeta.left || null);
const rightReveal = attachRevealPageMeta(detail.reveal?.right, detail.pageMeta?.right || currentPageMeta.right || null);
if (detail.preloadOnly) {
const leftReveal = attachRevealPageMeta(detail.reveal?.left, currentPageMeta.left || null);
const rightReveal = attachRevealPageMeta(detail.reveal?.right, currentPageMeta.right || null);
if (detail.phase === 'prepare') {
if (detail.left) {
const texture = preloadPageTexture('left', detail.left, leftReveal, detail.pageMeta?.left || null);
rememberResidentPageTexture(detail.pageMeta?.left || null, texture, detail.left);
const texture = preloadPageTexture('left', detail.left, leftReveal, currentPageMeta.left);
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true);
} else if (currentPageMeta.left?.kind === 'blank') {
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, getBlankPageTexture(), null, false);
}
if (detail.right) {
const texture = preloadPageTexture('right', detail.right, rightReveal, detail.pageMeta?.right || null);
rememberResidentPageTexture(detail.pageMeta?.right || null, texture, detail.right);
const texture = preloadPageTexture('right', detail.right, rightReveal, currentPageMeta.right);
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true);
} else if (currentPageMeta.right?.kind === 'blank') {
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, getBlankPageTexture(), null, false);
}
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
markPageTextureTiming('handlePageTextureRecords:prepare:end');
return;
}
if (detail.left) {
@@ -2073,13 +2082,110 @@ function handlePageCanvases(event) {
uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
}
}
if (!detail.left && currentPageMeta.left?.kind === 'blank') {
applyExplicitBlankPageTexture('left', currentPageMeta.left, 'page-texture-records');
}
if (!detail.right && currentPageMeta.right?.kind === 'blank') {
applyExplicitBlankPageTexture('right', currentPageMeta.right, 'page-texture-records');
}
markStaticSceneBuffersDirty();
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
width: leftCanvas.width,
height: leftCanvas.height,
source: 'book-texture-renderer'
});
markPageTextureTiming('handlePageCanvases:end');
markPageTextureTiming('handlePageTextureRecords:end');
prewarmNavigationTextureWindow('page-texture-records').catch((error) => {
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
}
function normalizePageTextureRecordDetail(detail = {}) {
if (!Array.isArray(detail.records) || detail.records.length === 0) {
return {
...detail,
phase: detail.phase === 'prepare' ? 'prepare' : 'activate'
};
}
return detail.records.reduce((normalized, record) => {
const side = record?.side === 'right' ? 'right' : 'left';
normalized[side] = record.canvas || normalized[side] || null;
normalized.pageMeta[side] = record.pageMeta || detail.pageMeta?.[side] || normalized.pageMeta[side] || null;
if (record.reveal) normalized.reveal[side] = record.reveal;
return normalized;
}, {
metrics: detail.metrics,
hitMaps: detail.hitMaps || {},
sides: detail.records.map(record => record?.side).filter(Boolean),
records: detail.records,
reveal: {},
pageMeta: {},
phase: detail.phase === 'prepare' ? 'prepare' : 'activate',
preparedFromCache: detail.preparedFromCache === true
});
}
function normalizePageMetaPair(pageMeta = {}, previousMeta = currentPageMeta) {
const spreadIndex = getSpreadIndexFromPageMeta(pageMeta)
?? getSpreadIndexFromPageMeta(previousMeta)
?? Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
const pageIndices = spreadPageIndices(spreadIndex);
return {
left: normalizePageSideMeta(pageMeta.left, pageIndices.left, 'left'),
right: normalizePageSideMeta(pageMeta.right, pageIndices.right, 'right')
};
}
function getSpreadIndexFromPageMeta(pageMeta = {}) {
const leftIndex = Number(pageMeta?.left?.pageIndex);
if (Number.isFinite(leftIndex)) return Math.floor(Math.max(0, leftIndex) / 2);
const rightIndex = Number(pageMeta?.right?.pageIndex);
if (Number.isFinite(rightIndex)) return Math.floor(Math.max(0, rightIndex) / 2);
return null;
}
function normalizePageSideMeta(meta = null, pageIndex = 0, side = 'left') {
const index = Math.max(0, Math.round(Number(meta?.pageIndex ?? pageIndex)));
if (!meta || meta.kind === 'blank') return makeBlankPageMeta(index, meta?.section || (index < 3 ? 'frontmatter' : 'body'));
return {
...meta,
pageIndex: index,
side
};
}
function makeBlankPageMeta(pageIndex = 0, section = 'body') {
return {
kind: 'blank',
section,
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
pageNumber: null,
omitPageNumber: true,
lineCount: 0,
maxBlockId: 0,
completenessScore: 0,
side: Math.max(0, Math.round(Number(pageIndex || 0))) % 2 === 0 ? 'left' : 'right'
};
}
function applyExplicitBlankPageTexture(side, pageMeta = null, reason = 'blank-page') {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const blankTexture = getBlankPageTexture();
clearPageReveal(side, reason);
if (material.map !== blankTexture) {
material.map = blankTexture;
material.needsUpdate = true;
}
pageTextureStore?.rememberResidentTexture?.(pageMeta || makeBlankPageMeta(side === 'left' ? 0 : 1), blankTexture, null, false);
markPageTextureTiming('explicitBlankTexture', {
side,
pageIndex: pageMeta?.pageIndex ?? null,
reason
});
return blankTexture;
}
function attachRevealPageMeta(revealDetail = null, pageMeta = null) {
@@ -2099,30 +2205,15 @@ function getRevealCacheKey(revealDetail = {}) {
function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = null) {
if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null;
const key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null });
markPageTextureTiming('preloadTexture:start', {
side,
key,
width: sourceCanvas.width,
height: sourceCanvas.height,
hasBaseTexture: Boolean(baseTexture)
hasBaseTexture: Boolean(revealDetail?.baseCanvas)
});
preparedPageTextures[side].set(key, {
texture,
baseTexture,
sourceCanvas,
revealDetail,
uploadedAt: performance.now()
});
if (preparedPageTextures[side].size > 128) {
const oldestKey = preparedPageTextures[side].keys().next().value;
const oldest = preparedPageTextures[side].get(oldestKey);
oldest?.texture?.dispose?.();
oldest?.baseTexture?.dispose?.();
preparedPageTextures[side].delete(oldestKey);
}
const texture = pageTextureStore?.preparePageTexture?.(side, key, pageMeta, sourceCanvas, revealDetail) || null;
markPageTextureTiming('preloadTexture:end', { side, key });
return texture;
}
@@ -2142,54 +2233,6 @@ function setPageFlipActiveFlag() {
}
}
function recordPageCacheProblem(detail = {}) {
const entry = {
...detail,
at: performance.now()
};
pageCacheProblemLog.push(entry);
if (pageCacheProblemLog.length > 80) pageCacheProblemLog.splice(0, pageCacheProblemLog.length - 80);
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(pageCacheProblemLog);
console.warn('WebGL page cache problem', entry);
}
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = makeResidentPageTextureKey(pageMeta);
const existing = residentPageTextures.get(key);
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
residentPageTextures.set(key, {
texture,
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
lastUsedAt: performance.now(),
ownsTexture,
pageMeta: {
...(existing?.pageMeta || {}),
...(pageMeta || {})
}
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
function isOlderPageTextureMeta(incoming = {}, existing = null) {
if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
function makePageMetaForCache(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const paginationMeta = getPaginationPageMeta(index) || {};
@@ -2201,16 +2244,6 @@ function makePageMetaForCache(pageIndex) {
};
}
function makeResidentPageTextureKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? makePageMetaForCache(pageMetaOrIndex)
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content');
const section = String(pageMeta.section || 'body');
return `${pageIndex}:${kind}:${section}`;
}
function spreadPageIndices(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return {
@@ -2219,67 +2252,6 @@ function spreadPageIndices(spreadIndex) {
};
}
function getResidentPageTexture(pageIndex) {
const key = makeResidentPageTextureKey(pageIndex);
const resident = residentPageTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
residentPageTextures.delete(key);
residentPageTextures.set(key, resident);
return resident.texture || null;
}
function getResidentPageTextureForMeta(pageMeta = null) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = makeResidentPageTextureKey(pageMeta);
const resident = residentPageTextures.get(key);
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
return getResidentPageTexture(pageIndex);
}
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
const residentKey = makeResidentPageTextureKey(meta);
if (residentPageTextures.has(residentKey)) {
getResidentPageTexture(meta.pageIndex);
return residentPageTextures.get(residentKey)?.texture || null;
}
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
const sourceCanvas = await cache?.getPageCanvas?.(meta);
if (!sourceCanvas) {
const pageMeta = getPaginationPageMeta(meta.pageIndex);
if (pageMeta?.kind === 'blank') {
const blankTexture = getBlankPageTexture();
rememberResidentPageTexture({ ...meta, ...pageMeta }, blankTexture, null, false);
return blankTexture;
}
recordPageCacheProblem({
type: 'db-cache-miss',
pageIndex: meta.pageIndex,
width: meta.width,
height: meta.height
});
return null;
}
const texture = createPageCanvasTexture(sourceCanvas);
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
residentPageTextures.set(residentKey, {
texture,
sourceCanvas,
lastUsedAt: performance.now(),
ownsTexture: true,
pageMeta: cachedMeta
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
function getPaginationPageMeta(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2);
@@ -2294,27 +2266,47 @@ function getPaginationPageMeta(pageIndex) {
}
async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex);
const [left, right] = await Promise.all([
preloadCachedPageTexture(indices.left),
preloadCachedPageTexture(indices.right)
]);
return {
return pageTextureStore?.prewarmSpreadTextures?.(spreadIndex, makePageMetaForCache) || {
spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))),
left,
right
left: null,
right: null
};
}
async function prewarmNavigationTextureWindow(reason = 'navigation-window', options = {}) {
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
const endSpread = Math.max(
0,
Math.round(Number(bookPaginationState.spreadCount || 1)) - 1,
pageToSpreadIndex(maxVisitedPagePosition)
);
markPageTextureTiming('textureStorePrewarm:start', {
reason,
currentSpread,
endSpread
});
const result = await pageTextureStore?.prewarmNavigationWindow?.({
currentSpread,
targetSpread: options.targetSpread,
endSpread,
getPageMetaForIndex: makePageMetaForCache,
recordMiss: options.recordMiss !== false
});
markPageTextureTiming('textureStorePrewarm:end', {
reason,
spreadCount: result ? Object.keys(result).length : 0
});
return result || {};
}
async function prewarmFlipTextures(direction, targetSpread = null) {
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
const nextSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
const [current, next] = await Promise.all([
prewarmSpreadTextures(currentSpread),
prewarmSpreadTextures(nextSpread)
]);
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
return {
current,
next
@@ -2323,19 +2315,22 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key);
const prepared = pageTextureStore?.takePreparedPageTexture?.(side, key) || null;
if (!prepared) return null;
preparedPageTextures[side].delete(key);
markPageTextureTiming('preloadTexture:activate', { side, key });
return prepared;
}
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
if (pageMeta?.kind === 'blank') {
applyExplicitBlankPageTexture(side, pageMeta, 'direct-upload');
return;
}
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const shouldUseResidentTexture = pageMeta?.kind !== 'title';
const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTextureForMeta(pageMeta)
? pageTextureStore?.getResidentTextureForMeta?.(pageMeta)
: null;
markPageTextureTiming('directUpload:start', {
side,
@@ -2356,7 +2351,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false);
pageTextureStore?.rememberResidentTexture?.(pageMeta, texture, sourceCanvas, false);
markPageTextureTiming('directUpload:end', { side });
}
@@ -2381,7 +2376,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
}
bindPageTextureSource(side, texture, sourceCanvas);
}
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null);
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
@@ -2397,6 +2392,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: revealBlockIds,
baseTexture,
pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true,
fastForwarding: false,
fastForwardStartedAt: null,
fastForwardStartElapsedMs: 0,
@@ -2408,12 +2404,12 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
if (shader?.uniforms?.bookRevealElapsedMs) {
shader.uniforms.bookRevealElapsedMs.value = pageRevealState[side].visualElapsedMs;
}
if (side === 'right' && isRightBodyPageComplete()) {
if (side === 'right' && revealDetail.pageFlipAfterReveal === true) {
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
prewarmFlipTextures(1, targetSpread).then(() => {
markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread });
}).catch((error) => {
recordPageCacheProblem({
pageTextureStore?.recordProblem?.({
type: 'right-page-flip-prewarm-error',
targetSpread,
message: error?.message || String(error)
@@ -2618,7 +2614,8 @@ function updatePageRevealAnimations(now) {
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
detail: {
side,
blockIds: state.blockIds
blockIds: state.blockIds,
pageFlipAfterReveal: state.pageFlipAfterReveal === true
}
}));
});
@@ -2632,8 +2629,11 @@ function bindPageTextureSource(side, texture, sourceCanvas) {
width: nextCanvas?.width || 0,
height: nextCanvas?.height || 0
});
texture.image = sourceCanvas || fallbackCanvas;
texture.needsUpdate = true;
const boundTexture = pageTextureStore?.bindVisibleTextureSource?.(side, sourceCanvas) || null;
if (!boundTexture) {
texture.image = nextCanvas;
texture.needsUpdate = true;
}
updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true);
markPageTextureTiming('bindPageTextureSource:end', { side });
}
@@ -2841,20 +2841,27 @@ function createPageFlip(direction, startTime, duration) {
function prepareStaticPageForFlip(flip, prewarm = null) {
if (!flip) return false;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
const sourceSide = flip.direction > 0 ? 'right' : 'left';
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null;
const targetSpread = Number.isFinite(Number(flip.targetSpread))
? Math.max(0, Math.round(Number(flip.targetSpread)))
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
const targetPages = spreadPageIndices(targetSpread);
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
const targetBackSide = flip.direction > 0 ? 'left' : 'right';
const targetBackPageIndex = targetPages[targetBackSide];
const targetBackPageMeta = getPaginationPageMeta(targetBackPageIndex) || makeBlankPageMeta(targetBackPageIndex);
const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right;
const residentBackTexture = prewarmedBackTexture || getResidentPageTexture(targetBackPageIndex);
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
if (!residentBackTexture && requiresWrittenTexture) {
recordPageCacheProblem({
type: 'flip-back-texture-missing',
const backTexture = resolveFlipBackTexture(targetBackPageMeta, prewarmedBackTexture);
const requiresWrittenTexture = targetBackPageMeta.kind !== 'blank'
&& targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
if (!sourceTexture || (!backTexture && requiresWrittenTexture)) {
pageTextureStore?.recordProblem?.({
type: !sourceTexture ? 'flip-source-texture-missing' : 'flip-back-texture-missing',
sourceSide,
sourcePageIndex: sourcePageMeta?.pageIndex ?? null,
targetBackPageIndex,
targetBackKind: targetBackPageMeta.kind,
targetSpread,
direction: flip.direction,
prewarmedCurrent: Boolean(prewarm?.current),
@@ -2862,9 +2869,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
});
return false;
}
const backTexture = residentBackTexture || getBlankPageTexture();
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture;
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
@@ -2872,8 +2878,24 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true;
flip.sourceTexture = sourceTexture;
flip.backTexture = backTexture;
flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null;
flip.backTexture = backTexture || getBlankPageTexture();
flip.backPageMeta = targetBackPageMeta ? { ...targetBackPageMeta } : null;
flip.targetBackPageIndex = targetBackPageIndex;
flip.sourcePageSide = sourceSide;
lastFlipTexturePreflight = {
direction: flip.direction,
sourceSide,
sourcePageIndex: sourcePageMeta?.pageIndex ?? null,
sourceKind: sourcePageMeta?.kind || 'content',
targetSpread,
targetBackSide,
targetBackPageIndex,
targetBackKind: targetBackPageMeta.kind,
hasSourceTexture: Boolean(sourceTexture),
hasBackTexture: Boolean(backTexture || getBlankPageTexture()),
sourceTextureMatchesBackTexture: sourceTexture === (backTexture || getBlankPageTexture())
};
if (flip.direction > 0) {
const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) {
@@ -2890,15 +2912,27 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
}
}
markPageTextureTiming('flipTexturePreflight:ready', {
direction: flip.direction,
sourceSide: flip.sourcePageSide,
targetSpread,
targetBackPageIndex,
usedResidentBackTexture: Boolean(residentBackTexture)
...lastFlipTexturePreflight,
usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture())
});
return true;
}
function resolveCurrentFlipSourceTexture(side) {
const pageMeta = currentPageMeta?.[side] || null;
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
if (resident) return resident;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
return material?.map || null;
}
function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
if (prewarmedTexture) return prewarmedTexture;
return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
}
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition();
@@ -2908,7 +2942,7 @@ function canPageFlip(direction) {
}
function handleRevealCommittedForPageFlip(detail = {}) {
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
const autoplayFlip = isTtsPlaybackActive();
@@ -2937,16 +2971,6 @@ async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
return flipped;
}
function isRightBodyPageComplete() {
const meta = currentPageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rendererDebug = window.BookTextureRenderer?.currentSpread || null;
const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0);
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
return maxLine >= expectedLines;
}
function isChoiceAwaitingPlayer() {
return document.documentElement.dataset.choiceAwaiting === 'true'
|| document.body?.dataset?.choiceAwaiting === 'true'