Stabilize WebGL book pagination restore

This commit is contained in:
2026-06-09 16:42:12 +02:00
parent fe51410a3b
commit 171cafeb65
7 changed files with 315 additions and 85 deletions
+112 -27
View File
@@ -204,6 +204,7 @@ let bookPaginationState = {
spreadCount: 1,
writtenPageLimit: 0
};
let maxVisitedPagePosition = 0;
const normalFlipDuration = 900;
const fastFlipDuration = 520;
const fastFlipCount = 10;
@@ -364,7 +365,7 @@ const materials = {
emissive: 0x100d08,
emissiveIntensity: 0.004,
envMapIntensity: 0.01,
side: THREE.DoubleSide
side: THREE.FrontSide
}),
leftPage: new THREE.MeshStandardMaterial({
color: 0xffffff,
@@ -414,7 +415,7 @@ const materials = {
};
materials.flipPageBackSurface = materials.flipPageSurface.clone();
materials.flipPageBackSurface.map = getBlankPageTexture();
materials.flipPageBackSurface.side = THREE.DoubleSide;
materials.flipPageBackSurface.side = THREE.FrontSide;
materials.flipPageEdge = materials.pageSurface.clone();
materials.flipPageEdge.map = paperTextures.edge;
materials.flipPageEdge.normalMap = paperTextures.normal;
@@ -531,6 +532,7 @@ window.BookLabDebug = {
pageReserve,
progress: readingProgress,
pagePosition: getCurrentPagePosition(),
maxVisitedPagePosition,
spreadIndex: bookPaginationState.spreadIndex,
writtenPageLimit: bookPaginationState.writtenPageLimit
};
@@ -541,10 +543,17 @@ window.BookLabDebug = {
spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)),
writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0))
};
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
growBookIfWritableLimitReached();
syncBookControls();
return this.getBookState();
},
setMaxVisitedPagePosition(value) {
const page = Math.max(0, Math.round(Number(value || 0)));
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, page);
syncBookControls();
return maxVisitedPagePosition;
},
navigateToPagePosition(value) {
return navigateToPagePosition(value);
},
@@ -616,9 +625,26 @@ document.addEventListener('webgl-book:page-cache-problem', (event) => {
});
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if (
latestBlockId > latestRenderedBlockId
&& detail.allowFutureUnrendered !== true
&& activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) {
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId,
latestRenderedBlockId
});
return;
}
const previousPageCount = bookPageCount;
bookPaginationState = {
spreadIndex: Math.max(0, Number(detail.spreadIndex || 0)),
spreadIndex: incomingSpreadIndex,
spreadCount: Math.max(1, Number(detail.spreadCount || 1)),
writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0))
};
@@ -1920,10 +1946,10 @@ function ensureBottomNavigation() {
startButton.addEventListener('click', () => navigateToPagePosition(0));
backButton.addEventListener('click', () => navigateByPageDelta(-1));
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
endButton.addEventListener('click', () => navigateToPagePosition(bookPaginationState.writtenPageLimit));
endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition));
slider.addEventListener('input', () => {
const requested = Number(slider.value);
const clamped = Math.min(requested, Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
const clamped = Math.min(requested, maxVisitedPagePosition, getWritablePageLimit());
if (requested !== clamped) slider.value = String(clamped);
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
});
@@ -1952,8 +1978,7 @@ function navigateByPageDelta(delta) {
function navigateToPagePosition(pagePosition) {
const writableLimit = getWritablePageLimit();
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, writtenLimit));
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, maxVisitedPagePosition));
const currentPage = getCurrentPagePosition();
if (targetPage === currentPage) {
syncBookControls();
@@ -1984,9 +2009,8 @@ function syncBookControls() {
function syncBottomNavigation() {
if (!bottomNavigation) return;
const currentPage = getCurrentPagePosition();
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
const writableLimit = getWritablePageLimit();
const navigableLimit = Math.min(writtenLimit, writableLimit);
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
@@ -1994,7 +2018,7 @@ function syncBottomNavigation() {
bottomNavigation.maxLabel.textContent = String(bookPageCount);
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? maxVisitedPagePosition / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`);
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
@@ -2132,7 +2156,7 @@ function recordPageCacheProblem(detail = {}) {
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 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?.();
@@ -2167,13 +2191,26 @@ function isOlderPageTextureMeta(incoming = {}, existing = null) {
}
function makePageMetaForCache(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const paginationMeta = getPaginationPageMeta(index) || {};
return {
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
...paginationMeta,
pageIndex: index,
width: pageTextureWidth,
height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH)
};
}
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 {
@@ -2183,7 +2220,7 @@ function spreadPageIndices(spreadIndex) {
}
function getResidentPageTexture(pageIndex) {
const key = makePageMetaForCache(pageIndex).pageIndex;
const key = makeResidentPageTextureKey(pageIndex);
const resident = residentPageTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
@@ -2195,7 +2232,7 @@ function getResidentPageTexture(pageIndex) {
function getResidentPageTextureForMeta(pageMeta = null) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = makePageMetaForCache(pageIndex).pageIndex;
const key = makeResidentPageTextureKey(pageMeta);
const resident = residentPageTextures.get(key);
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
return getResidentPageTexture(pageIndex);
@@ -2203,13 +2240,20 @@ function getResidentPageTextureForMeta(pageMeta = null) {
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
if (residentPageTextures.has(meta.pageIndex)) {
const residentKey = makeResidentPageTextureKey(meta);
if (residentPageTextures.has(residentKey)) {
getResidentPageTexture(meta.pageIndex);
return residentPageTextures.get(meta.pageIndex)?.texture || null;
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,
@@ -2220,7 +2264,7 @@ async function preloadCachedPageTexture(pageIndex) {
}
const texture = createPageCanvasTexture(sourceCanvas);
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
residentPageTextures.set(meta.pageIndex, {
residentPageTextures.set(residentKey, {
texture,
sourceCanvas,
lastUsedAt: performance.now(),
@@ -2236,6 +2280,19 @@ async function preloadCachedPageTexture(pageIndex) {
return texture;
}
function getPaginationPageMeta(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2);
const side = index % 2 === 0 ? 'left' : 'right';
const pagination = window.moduleRegistry?.getModule?.('book-pagination') || null;
const spread = typeof pagination?.getSpread === 'function'
? pagination.getSpread(spreadIndex)
: Array.isArray(pagination?.spreads)
? pagination.spreads[spreadIndex]
: null;
return spread?.pageMeta?.[side] || null;
}
async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex);
const [left, right] = await Promise.all([
@@ -2276,7 +2333,8 @@ function takePreparedPageTexture(side, revealDetail = {}) {
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))
const shouldUseResidentTexture = pageMeta?.kind !== 'title';
const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTextureForMeta(pageMeta)
: null;
markPageTextureTiming('directUpload:start', {
@@ -2703,6 +2761,13 @@ function startPageFlipPrepared(direction, options = {}) {
delete document.documentElement.dataset.webglPendingPageFlip;
activeFlips.push(flip);
setPageFlipActiveFlag();
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
detail: {
direction: flip.direction,
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null
}
}));
syncBookControls();
updateActiveFlips(flip.startTime);
return true;
@@ -2723,6 +2788,7 @@ function startFastPageFlipPrepared(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
firstFlip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
if (!prepareStaticPageForFlip(firstFlip, options.prewarm || null)) return false;
const startTime = firstFlip.startTime;
const interval = fastFlipDuration / fastFlipOverlap;
@@ -2740,6 +2806,14 @@ function startFastPageFlipPrepared(direction, options = {}) {
});
}
setPageFlipActiveFlag();
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
detail: {
direction: firstFlip.direction,
sourceSide: firstFlip.sourcePageSide || (firstFlip.direction > 0 ? 'right' : 'left'),
targetSpread: Number.isFinite(Number(firstFlip.targetSpread)) ? Math.max(0, Math.round(Number(firstFlip.targetSpread))) : null,
fast: true
}
}));
syncBookControls();
updateActiveFlips(startTime);
return true;
@@ -2774,7 +2848,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
: 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 residentBackTexture = getResidentPageTexture(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({
@@ -2827,7 +2902,7 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition();
const maxNavigablePage = Math.min(Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
if (direction > 0) return currentPage < maxNavigablePage;
return currentPage > 0;
}
@@ -3031,7 +3106,7 @@ function lineYAtX(points, x) {
function setActivePageGeometry(flip, surface) {
if (!flip.mesh) {
const geometry = createFlippingPageGeometry(surface);
const geometry = createFlippingPageGeometry(surface, flip.direction);
flip.mesh = new THREE.Mesh(geometry, [
materials.flipPageSurface,
materials.flipPageBackSurface,
@@ -3045,13 +3120,13 @@ function setActivePageGeometry(flip, surface) {
return;
}
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
const geometry = createFlippingPageGeometry(surface);
const geometry = createFlippingPageGeometry(surface, flip.direction);
flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry;
}
}
function createFlippingPageGeometry(surface) {
function createFlippingPageGeometry(surface, direction = 1) {
const positions = [];
const uvs = [];
const indices = [];
@@ -3063,10 +3138,12 @@ function createFlippingPageGeometry(surface) {
const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001));
const widthSegments = surface.length - 1;
const depthSegments = surface[0].length - 1;
const push = (point, yOffset, u, v) => {
const sourceSide = direction > 0 ? 1 : -1;
const targetSide = -sourceSide;
const push = (point, yOffset, uv) => {
const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z);
uvs.push(u, v);
uvs.push(uv.x, uv.y);
return index;
};
@@ -3076,8 +3153,8 @@ function createFlippingPageGeometry(surface) {
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, u, v));
bottomRow.push(push(point, 0, u, 1 - v));
topRow.push(push(point, pageThickness, pageUvForSide(sourceSide, u, v)));
bottomRow.push(push(point, 0, pageUvForSide(targetSide, u, v)));
});
topGrid.push(topRow);
bottomGrid.push(bottomRow);
@@ -3123,6 +3200,13 @@ function createFlippingPageGeometry(surface) {
}
}
function pageUvForSide(side, u, v) {
return {
x: side < 0 ? 1 - u : u,
y: 1 - v
};
}
function updateFlippingPageGeometry(geometry, surface) {
const position = geometry?.getAttribute?.('position');
if (!position || !surface?.length || !surface[0]?.length) return false;
@@ -3160,6 +3244,7 @@ function finishActiveFlip(flip) {
...bookPaginationState,
spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread)))
};
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
syncReadingProgressToCurrentPage();
}
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {