Fix WebGL page cache and flip sequencing

This commit is contained in:
2026-06-08 23:08:13 +02:00
parent a73dc5725f
commit 419691000c
7 changed files with 364 additions and 60 deletions
+36 -8
View File
@@ -54,6 +54,7 @@ class BookPaginationModule extends BaseModule {
'countLineWords',
'getLineGeometry',
'getSpread',
'findSpreadIndexForBlock',
'getCurrentSpread',
'setCurrentSpread',
'handlePageCountChanged',
@@ -99,11 +100,17 @@ class BookPaginationModule extends BaseModule {
const token = ++this.refreshToken;
const detail = event?.detail || {};
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
const latestRenderedBlockId = Math.max(
0,
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
);
const latestBlockId = Math.max(
0,
Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1)
Number(detail.latestBlockId || (this.storyHistory?.nextBlockId || 1) - 1 || latestRenderedBlockId)
);
if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
const continuationBlockId = this.getContinuationBlockId(latestBlockId, latestRenderedBlockId);
const paginationEndBlockId = Math.max(latestRenderedBlockId, continuationBlockId);
if (!gameId || paginationEndBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
this.pages = this.buildPages([]);
this.spreads = this.buildSpreadsFromPages(this.pages);
this.latestBlockId = 0;
@@ -114,20 +121,31 @@ class BookPaginationModule extends BaseModule {
return;
}
const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId);
const blocks = await this.storyHistory.getBlocksRange(gameId, 1, paginationEndBlockId);
if (token !== this.refreshToken) return;
this.latestBlockId = latestBlockId;
this.latestRenderedBlockId = Math.max(
0,
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
);
this.latestRenderedBlockId = latestRenderedBlockId;
this.pages = this.buildPages(blocks);
this.spreads = this.buildSpreadsFromPages(this.pages);
this.persistPaginationMetrics(this.pages);
this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
const continuationSpreadIndex = this.findSpreadIndexForBlock(continuationBlockId);
const renderedSpreadIndex = this.findSpreadIndexForBlock(latestRenderedBlockId);
this.currentSpreadIndex = continuationSpreadIndex >= 0
? continuationSpreadIndex
: renderedSpreadIndex >= 0
? renderedSpreadIndex
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
this.publish();
}
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
const latest = Math.max(0, Number(latestBlockId || 0));
const rendered = Math.max(0, Number(latestRenderedBlockId || 0));
if (latest <= 0) return 0;
if (rendered <= 0) return 1;
return rendered < latest ? rendered + 1 : latest;
}
async preparePendingBlock(block = {}, options = {}) {
const token = options.activate === false ? this.refreshToken : ++this.refreshToken;
const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null;
@@ -857,6 +875,16 @@ class BookPaginationModule extends BaseModule {
return this.spreads[Math.max(0, Number(index || 0))] || { index: 0, left: [], right: [] };
}
findSpreadIndexForBlock(blockId) {
const id = Math.max(0, Number(blockId || 0));
if (id <= 0) return -1;
const spread = this.spreads.find(entry => ['left', 'right'].some((side) => {
const lines = Array.isArray(entry?.[side]) ? entry[side] : [];
return lines.some(line => Number(line?.blockId || 0) === id);
}));
return Number.isFinite(Number(spread?.index)) ? Math.max(0, Math.round(Number(spread.index))) : -1;
}
getCurrentSpread() {
return this.getSpread(this.currentSpreadIndex);
}
+98 -8
View File
@@ -38,9 +38,11 @@ class BookTextureRendererModule extends BaseModule {
this.lastDrawSkipLoggedAt = 0;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30;
this.targetFrameDurationMs = 1000 / 60;
this.pipelineTimings = [];
this.imageCache = new Map();
this.pendingPageCacheWrites = new Map();
this.pageContentVersions = new Map();
this.bindMethods([
'initialize',
@@ -84,6 +86,8 @@ class BookTextureRendererModule extends BaseModule {
'tickAnimations',
'publishSpread',
'cachePublishedPages',
'getPageCacheWriteKey',
'isOlderPageMeta',
'schedulePageCacheWrite',
'getPageCanvas',
'getHitMap',
@@ -641,7 +645,7 @@ class BookTextureRendererModule extends BaseModule {
});
this.pendingRevealBlockIds.delete(String(blockId));
this.revealPublishBlockIds = new Set([String(blockId)]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']);
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId
@@ -692,7 +696,7 @@ class BookTextureRendererModule extends BaseModule {
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = this.getBlockSides(blockId);
const sides = ['left', 'right'];
const published = this.drawSpread(spread, sides, { preloadOnly });
if (preloadOnly && published) {
this.preparedRevealCache.set(id, {
@@ -728,6 +732,7 @@ class BookTextureRendererModule extends BaseModule {
left: prepared.left || null,
right: prepared.right || null,
reveal: prepared.reveal || {},
pageMeta: prepared.pageMeta || {},
preparedFromCache: true
}
}));
@@ -856,7 +861,7 @@ class BookTextureRendererModule extends BaseModule {
metrics: this.metrics,
hitMaps: this.hitMaps,
sides: sidesToPublish,
pageMeta: this.currentSpread?.pageMeta || {}
pageMeta: this.buildPublishPageMeta(sidesToPublish)
};
if (options.preloadOnly) detail.preloadOnly = true;
if (sidesToPublish.includes('left')) {
@@ -907,6 +912,35 @@ class BookTextureRendererModule extends BaseModule {
return detail;
}
buildPublishPageMeta(sides = []) {
const baseMeta = this.currentSpread?.pageMeta || {};
return sides.reduce((meta, side) => {
const source = baseMeta[side] || null;
if (!source) {
meta[side] = null;
return meta;
}
const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0);
const lineCount = lines.length;
const pageIndex = Number(source.pageIndex);
const key = Number.isFinite(pageIndex) ? pageIndex : side;
const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1);
this.pageContentVersions.set(key, nextVersion);
meta[side] = {
...source,
contentVersion: nextVersion,
completenessScore: (maxBlockId * 1000) + lineCount,
maxBlockId,
lineCount
};
return meta;
}, {
left: Object.prototype.hasOwnProperty.call(baseMeta, 'left') ? baseMeta.left : null,
right: Object.prototype.hasOwnProperty.call(baseMeta, 'right') ? baseMeta.right : null
});
}
cachePublishedPages(sides = [], detail = {}) {
if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return;
sides.forEach((side) => {
@@ -919,10 +953,66 @@ class BookTextureRendererModule extends BaseModule {
schedulePageCacheWrite(pageMeta, canvas) {
const frozenCanvas = this.cloneCanvas(canvas);
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 16));
scheduler(() => {
this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas);
}, { timeout: 250 });
const key = this.getPageCacheWriteKey(pageMeta, frozenCanvas);
const pending = this.pendingPageCacheWrites.get(key);
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
const previousWrite = pending?.promise || Promise.resolve();
const write = previousWrite.catch(() => false).then(() => this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas))
.then((stored) => {
if (!stored) {
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
detail: {
type: 'db-write-failed',
pageIndex: pageMeta?.pageIndex ?? null,
key
}
}));
}
return stored;
})
.catch((error) => {
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
detail: {
type: 'db-write-error',
pageIndex: pageMeta?.pageIndex ?? null,
key,
message: error?.message || String(error)
}
}));
return false;
})
.finally(() => {
if (this.pendingPageCacheWrites.get(key)?.promise === write) {
this.pendingPageCacheWrites.delete(key);
}
});
this.pendingPageCacheWrites.set(key, {
promise: write,
pageMeta: { ...(pageMeta || {}) }
});
return write;
}
isOlderPageMeta(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;
}
getPageCacheWriteKey(pageMeta = {}, canvas = null) {
if (this.pageCache && typeof this.pageCache.makePageKey === 'function') {
return this.pageCache.makePageKey({
...pageMeta,
width: canvas?.width ?? pageMeta.width,
height: canvas?.height ?? pageMeta.height
});
}
return `${pageMeta.cacheKey || window.MODULE_CACHE_BUSTER || 'dev'}:page:${pageMeta.pageIndex}:${canvas?.width || pageMeta.width}x${canvas?.height || pageMeta.height}`;
}
getPageCanvas(side) {
+11 -8
View File
@@ -165,7 +165,7 @@ class SentenceQueueModule extends BaseModule {
if (!this.isProcessing) {
this.processNextSentence();
} else {
this.prefetchAhead(4, this.queueGeneration);
this.prefetchAhead(6, this.queueGeneration);
}
}
@@ -204,14 +204,15 @@ class SentenceQueueModule extends BaseModule {
if (!this.isWebGLBookPresentationPrepared(sentence)) {
await this.prefetchWebGLBookPresentation(sentence, {
queueGeneration,
queueIndex: 0
queueIndex: 0,
immediate: true
});
}
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Prefetch far enough ahead that media pauses do not block TTS
// generation for the next spoken paragraph.
this.prefetchAhead(4, queueGeneration);
this.prefetchAhead(6, queueGeneration);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Notify display handler with complete sentence
@@ -910,10 +911,12 @@ class SentenceQueueModule extends BaseModule {
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
}
await new Promise(resolve => {
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
scheduler(() => resolve(), { timeout: 120 });
});
if (!options.immediate) {
await new Promise(resolve => {
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
scheduler(() => resolve(), { timeout: 80 });
});
}
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
const spread = typeof bookPagination.preparePendingBlock === 'function'
@@ -957,7 +960,7 @@ class SentenceQueueModule extends BaseModule {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
}
prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) {
prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) {
if (this.sentenceQueue.length <= 1) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
+136 -25
View File
@@ -157,7 +157,7 @@ updateCameraRig(0);
configureScenePostprocessing();
const clock = new THREE.Clock();
const targetFrameDurationMs = 1000 / 30;
const targetFrameDurationMs = 1000 / 60;
let lastRenderFrameAt = 0;
let fpsDisplay = null;
let fpsWindowStartedAt = performance.now();
@@ -253,13 +253,15 @@ const preparedPageTextures = {
right: new Map()
};
const residentPageTextures = new Map();
const maxResidentPageTextures = 18;
const maxResidentPageTextures = 192;
let blankPageTexture = null;
const pageCacheProblemLog = [];
let currentPageMeta = {
left: null,
right: null
};
let pendingRightPageFlip = false;
let pendingRightPageFlipAutoplay = false;
const pageRevealState = {
left: null,
right: null
@@ -575,6 +577,16 @@ window.BookLabDebug = {
debug: getPageTextureDebugState()
};
},
getRuntimeInvariants() {
return {
targetFrameDurationMs,
residentPageTextureCount: residentPageTextures.size,
maxResidentPageTextures,
pageCacheProblemCount: pageCacheProblemLog.length,
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
mirrorRefreshesEveryFrame: true
};
},
projectPointerToPage(clientX, clientY) {
return projectPointerToPage(clientX, clientY);
},
@@ -596,6 +608,9 @@ document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {});
});
document.addEventListener('webgl-book:page-cache-problem', (event) => {
recordPageCacheProblem(event.detail || {});
});
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
const previousPageCount = bookPageCount;
@@ -611,6 +626,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
syncBookControls();
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
});
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
const detail = event.detail || {};
@@ -634,8 +650,7 @@ document.addEventListener('webgl-book:request-page-flip', (event) => {
});
document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
pendingRightPageFlip = false;
startPageFlip(1);
tryStartPendingRightPageFlip('continue', { force: true });
}
});
installBookControls();
@@ -1976,9 +1991,11 @@ function syncBottomNavigation() {
function handlePageCanvases(event) {
const detail = 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: detail.pageMeta.left || currentPageMeta.left || null,
right: detail.pageMeta.right || currentPageMeta.right || null
left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null,
right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null
};
}
markPageTextureTiming('handlePageCanvases:start', {
@@ -1989,8 +2006,14 @@ function handlePageCanvases(event) {
pageMeta: currentPageMeta
});
if (detail.preloadOnly) {
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
if (detail.right) preloadPageTexture('right', detail.right, detail.reveal?.right);
if (detail.left) {
const texture = preloadPageTexture('left', detail.left, detail.reveal?.left);
rememberResidentPageTexture(currentPageMeta.left, texture, detail.left);
}
if (detail.right) {
const texture = preloadPageTexture('right', detail.right, detail.reveal?.right);
rememberResidentPageTexture(currentPageMeta.right, texture, detail.right);
}
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
return;
}
@@ -2041,7 +2064,7 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
revealDetail,
uploadedAt: performance.now()
});
if (preparedPageTextures[side].size > 12) {
if (preparedPageTextures[side].size > 128) {
const oldestKey = preparedPageTextures[side].keys().next().value;
const oldest = preparedPageTextures[side].get(oldestKey);
oldest?.texture?.dispose?.();
@@ -2052,6 +2075,54 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
return texture;
}
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 = makePageMetaForCache(pageIndex).pageIndex;
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) {
return {
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
@@ -2078,6 +2149,15 @@ function getResidentPageTexture(pageIndex) {
return resident.texture || null;
}
function getResidentPageTextureForMeta(pageMeta = null) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = makePageMetaForCache(pageIndex).pageIndex;
const resident = residentPageTextures.get(key);
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
return getResidentPageTexture(pageIndex);
}
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
if (residentPageTextures.has(meta.pageIndex)) {
@@ -2086,17 +2166,28 @@ async function preloadCachedPageTexture(pageIndex) {
}
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
const sourceCanvas = await cache?.getPageCanvas?.(meta);
if (!sourceCanvas) return null;
if (!sourceCanvas) {
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(meta.pageIndex, {
texture,
sourceCanvas,
lastUsedAt: performance.now()
lastUsedAt: performance.now(),
ownsTexture: true,
pageMeta: cachedMeta
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
oldest?.texture?.dispose?.();
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
@@ -2134,7 +2225,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTexture(pageMeta.pageIndex)
? getResidentPageTextureForMeta(pageMeta)
: null;
markPageTextureTiming('directUpload:start', {
side,
@@ -2155,6 +2246,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false);
markPageTextureTiming('directUpload:end', { side });
}
@@ -2516,10 +2608,11 @@ async function startPageFlip(direction, options = {}) {
function startPageFlipPrepared(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel) return false;
if (!options.force && !canPageFlip(direction)) return false;
pendingRightPageFlip = false;
delete document.documentElement.dataset.webglPendingPageFlip;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false;
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
prepareStaticPageForFlip(flip);
activeFlips.push(flip);
@@ -2586,14 +2679,13 @@ function createPageFlip(direction, startTime, duration) {
function prepareStaticPageForFlip(flip) {
if (!flip) return;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
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 backTexture = getResidentPageTexture(targetBackPageIndex) || oppositeMaterial?.map || getBlankPageTexture();
const backTexture = getResidentPageTexture(targetBackPageIndex) || getBlankPageTexture();
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
@@ -2604,6 +2696,7 @@ function prepareStaticPageForFlip(flip) {
materials.flipPageBackSurface.needsUpdate = true;
flip.sourceTexture = sourceTexture;
flip.backTexture = backTexture;
flip.targetBackPageIndex = targetBackPageIndex;
if (flip.direction > 0) {
const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) {
@@ -2633,12 +2726,30 @@ function handleRevealCommittedForPageFlip(detail = {}) {
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
if (isTtsPlaybackActive()) {
startPageFlip(1);
return;
}
const autoplayFlip = isTtsPlaybackActive();
pendingRightPageFlip = true;
pendingRightPageFlipAutoplay = autoplayFlip;
document.documentElement.dataset.webglPendingPageFlip = 'right';
if (autoplayFlip) {
tryStartPendingRightPageFlip('tts-active');
}
}
async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false;
if (!options.force && !pendingRightPageFlipAutoplay) return false;
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
const flipped = await startPageFlip(1, {
force: options.force === true || pendingRightPageFlipAutoplay,
reason,
targetSpread
});
if (flipped) {
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
}
return flipped;
}
function isRightBodyPageComplete() {
@@ -2853,7 +2964,7 @@ function createFlippingPageGeometry(surface) {
rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, u, v));
bottomRow.push(push(point, 0, u, v));
bottomRow.push(push(point, 0, u, 1 - v));
});
topGrid.push(topRow);
bottomGrid.push(bottomRow);
@@ -3961,12 +4072,12 @@ function renderMirrorDebugView() {
function animate(now = performance.now()) {
const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs;
if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) {
setTimeout(animate, Math.max(1, targetFrameDurationMs - elapsedSinceLastFrame));
requestAnimationFrame(animate);
return;
}
const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs;
lastRenderFrameAt = now;
setTimeout(animate, targetFrameDurationMs);
requestAnimationFrame(animate);
const delta = Math.min(0.1, frameElapsedMs / 1000);
clock.getDelta();
const t = clock.elapsedTime;
@@ -4008,7 +4119,7 @@ function animate(now = performance.now()) {
updateBookShadowMaps();
lastFrameTiming.shadows = performance.now() - shadowStartedAt;
const reflectionStartedAt = performance.now();
const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0;
const refreshStaticSceneBuffers = true;
if (refreshStaticSceneBuffers) {
updateTableReflection();
}
+27 -4
View File
@@ -15,9 +15,9 @@ class WebGLPageCacheModule extends BaseModule {
this.db = null;
this.cacheStatus = 'uninitialized';
this.currentCacheSize = 0;
this.maxCacheSizeBytes = 180 * 1024 * 1024;
this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024;
this.memoryCanvasCache = new Map();
this.maxMemoryCanvasCount = 12;
this.maxMemoryCanvasCount = 256;
this.bindMethods([
'initialize',
@@ -27,6 +27,7 @@ class WebGLPageCacheModule extends BaseModule {
'makePageKey',
'canvasToBlob',
'blobToCanvas',
'isOlderPageEntry',
'manageCacheSize',
'calculateTotalCacheSize',
'deleteEntry',
@@ -45,7 +46,7 @@ class WebGLPageCacheModule extends BaseModule {
this.reportProgress(100, 'WebGL page texture cache ready');
return true;
} catch (error) {
console.warn('WebGLPageCache: IndexedDB unavailable, continuing without persistent page cache', error);
console.error('WebGLPageCache: IndexedDB unavailable; persistent page caching is in a problem state', error);
this.cacheStatus = 'error';
this.reportProgress(100, 'WebGL page texture cache unavailable');
return true;
@@ -100,7 +101,6 @@ class WebGLPageCacheModule extends BaseModule {
height: canvas.height,
cacheKey: pageMeta.cacheKey
});
if (this.memoryCanvasCache.has(key)) return true;
try {
const blob = await this.canvasToBlob(canvas);
if (!blob) return false;
@@ -109,6 +109,7 @@ class WebGLPageCacheModule extends BaseModule {
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
if (this.isOlderPageEntry(pageMeta, oldEntry)) return true;
await this.manageCacheSize(blob.size);
await new Promise((resolve, reject) => {
const request = this.tx('readwrite').put({
@@ -116,6 +117,10 @@ class WebGLPageCacheModule extends BaseModule {
pageIndex,
width: canvas.width,
height: canvas.height,
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
blob,
size: blob.size,
lastAccessed: Date.now()
@@ -161,6 +166,13 @@ class WebGLPageCacheModule extends BaseModule {
});
if (!entry?.blob) return null;
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
if (canvas) canvas.__webglPageCacheMeta = {
pageIndex: entry.pageIndex,
contentVersion: entry.contentVersion,
completenessScore: entry.completenessScore,
maxBlockId: entry.maxBlockId,
lineCount: entry.lineCount
};
if (canvas) this.rememberCanvas(key, canvas);
return canvas;
} catch (error) {
@@ -169,6 +181,17 @@ class WebGLPageCacheModule extends BaseModule {
}
}
isOlderPageEntry(pageMeta = {}, oldEntry = null) {
if (!oldEntry) return false;
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
canvasToBlob(canvas) {
return new Promise((resolve) => {
if (typeof canvas.toBlob !== 'function') {