Add WebGL page cache and runtime checks

This commit is contained in:
2026-06-08 14:39:42 +02:00
parent 119cefd4bd
commit a73dc5725f
11 changed files with 891 additions and 32 deletions
+190 -16
View File
@@ -252,6 +252,8 @@ const preparedPageTextures = {
left: new Map(),
right: new Map()
};
const residentPageTextures = new Map();
const maxResidentPageTextures = 18;
let blankPageTexture = null;
let currentPageMeta = {
left: null,
@@ -529,9 +531,36 @@ window.BookLabDebug = {
writtenPageLimit: bookPaginationState.writtenPageLimit
};
},
setPaginationStateForTest(state = {}) {
bookPaginationState = {
spreadIndex: Math.max(0, Number(state.spreadIndex ?? bookPaginationState.spreadIndex ?? 0)),
spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)),
writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0))
};
growBookIfWritableLimitReached();
syncBookControls();
return this.getBookState();
},
navigateToPagePosition(value) {
return navigateToPagePosition(value);
},
startPageFlipForTest(direction, options = {}) {
return startPageFlip(direction, options);
},
advancePageFlipForTest(elapsedMs = normalFlipDuration + 16) {
if (!activeFlips.length) return this.getBookState();
const targetNow = activeFlips.reduce((maxTime, flip) => {
return Math.max(maxTime, flip.startTime + Math.max(0, Number(elapsedMs || 0)));
}, performance.now());
updateActiveFlips(targetNow);
return this.getBookState();
},
mapPageToSpread(value) {
return pageToSpreadIndex(value);
},
mapSpreadToPage(value) {
return spreadIndexToPagePosition(value);
},
redrawPageTextures() {
window.BookTextureRenderer?.publishSpread?.();
return true;
@@ -592,6 +621,17 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => {
: Math.round(value);
setPageReserve(nextReserve);
});
document.addEventListener('webgl-book:request-page-flip', (event) => {
const detail = event.detail || {};
const direction = Math.sign(Number(detail.direction || 1)) || 1;
const targetSpread = Number.isFinite(Number(detail.targetSpread))
? Math.max(0, Math.round(Number(detail.targetSpread)))
: null;
startPageFlip(direction, {
force: detail.force === true,
targetSpread
});
});
document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
pendingRightPageFlip = false;
@@ -1707,12 +1747,14 @@ function clampPageReserve(value, pageCount = bookPageCount) {
function pageToSpreadIndex(pagePosition) {
const page = Math.max(0, Math.round(Number(pagePosition || 0)));
return page <= 0 ? 0 : Math.ceil(page / 2);
return page <= 0 ? 0 : Math.floor(page / 2) + 1;
}
function spreadIndexToPagePosition(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return spread <= 0 ? 0 : Math.max(1, spread * 2 - 1);
if (spread <= 0) return 0;
if (spread === 1) return 1;
return (spread - 1) * 2;
}
function getWritablePageLimit() {
@@ -1728,7 +1770,6 @@ function syncReadingProgressToCurrentPage() {
if (Math.abs(nextProgress - readingProgress) < 0.0001) return;
readingProgress = nextProgress;
buildBook();
notifyBookPageCountChanged();
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
@@ -1816,17 +1857,30 @@ function ensureBottomNavigation() {
const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀');
const sliderWrap = document.createElement('div');
sliderWrap.className = 'webgl-book-nav-slider-wrap';
const minLabel = document.createElement('span');
minLabel.id = 'webgl_book_nav_min_label';
minLabel.className = 'webgl-book-nav-limit-label';
minLabel.textContent = '0';
const sliderTrack = document.createElement('div');
sliderTrack.className = 'webgl-book-nav-slider-track';
const pageLabel = document.createElement('output');
pageLabel.id = 'webgl_book_nav_page_label';
pageLabel.className = 'webgl-book-nav-page-label';
pageLabel.textContent = '0';
const maxLabel = document.createElement('span');
maxLabel.id = 'webgl_book_nav_max_label';
maxLabel.className = 'webgl-book-nav-limit-label';
maxLabel.textContent = String(bookPageCount);
const slider = document.createElement('input');
slider.id = 'webgl_book_nav_position';
slider.type = 'range';
slider.min = '0';
slider.step = '1';
slider.value = '0';
sliderWrap.appendChild(slider);
sliderTrack.appendChild(minLabel);
sliderTrack.appendChild(slider);
sliderTrack.appendChild(maxLabel);
sliderWrap.appendChild(sliderTrack);
sliderWrap.appendChild(pageLabel);
root.appendChild(sliderWrap);
const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶');
@@ -1850,6 +1904,8 @@ function ensureBottomNavigation() {
startButton,
backButton,
slider,
minLabel,
maxLabel,
pageLabel,
forwardButton,
endButton
@@ -1903,6 +1959,8 @@ function syncBottomNavigation() {
const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
bottomNavigation.minLabel.textContent = '0';
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}`);
@@ -1940,14 +1998,14 @@ function handlePageCanvases(event) {
if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left);
} else {
uploadPageTextureDirect('left', detail.left);
uploadPageTextureDirect('left', detail.left, currentPageMeta.left);
}
}
if (detail.right) {
if (detail.reveal?.right) {
beginPageReveal('right', detail.right, detail.reveal.right);
} else {
uploadPageTextureDirect('right', detail.right);
uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
}
}
markStaticSceneBuffersDirty();
@@ -1994,6 +2052,75 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
return texture;
}
function makePageMetaForCache(pageIndex) {
return {
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
width: pageTextureWidth,
height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH)
};
}
function spreadPageIndices(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return {
left: spread * 2,
right: spread * 2 + 1
};
}
function getResidentPageTexture(pageIndex) {
const key = makePageMetaForCache(pageIndex).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;
}
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
if (residentPageTextures.has(meta.pageIndex)) {
getResidentPageTexture(meta.pageIndex);
return residentPageTextures.get(meta.pageIndex)?.texture || null;
}
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
const sourceCanvas = await cache?.getPageCanvas?.(meta);
if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
residentPageTextures.set(meta.pageIndex, {
texture,
sourceCanvas,
lastUsedAt: performance.now()
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
oldest?.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex);
await Promise.all([
preloadCachedPageTexture(indices.left),
preloadCachedPageTexture(indices.right)
]);
}
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)));
await Promise.all([
prewarmSpreadTextures(currentSpread),
prewarmSpreadTextures(nextSpread)
]);
}
function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key);
@@ -2003,11 +2130,26 @@ function takePreparedPageTexture(side, revealDetail = {}) {
return prepared;
}
function uploadPageTextureDirect(side, sourceCanvas) {
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
markPageTextureTiming('directUpload:start', { side });
const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTexture(pageMeta.pageIndex)
: null;
markPageTextureTiming('directUpload:start', {
side,
pageIndex: pageMeta?.pageIndex ?? null,
usedResidentTexture: Boolean(residentTexture)
});
clearPageReveal(side, 'direct-upload');
if (residentTexture) {
if (material.map !== residentTexture) {
material.map = residentTexture;
material.needsUpdate = true;
}
markPageTextureTiming('directUpload:end', { side, usedResidentTexture: true });
return;
}
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
@@ -2360,8 +2502,20 @@ function textureHitPageSide(hit) {
return null;
}
function startPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
async function startPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel) return false;
if (!options.force && !canPageFlip(direction)) return false;
const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
await prewarmFlipTextures(direction, targetSpread);
return startPageFlipPrepared(direction, {
...options,
targetSpread
});
}
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);
@@ -2374,7 +2528,17 @@ function startPageFlip(direction, options = {}) {
return true;
}
function startFastPageFlip(direction, options = {}) {
async function startFastPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
await prewarmFlipTextures(direction, targetSpread);
return startFastPageFlipPrepared(direction, {
...options,
targetSpread
});
}
function startFastPageFlipPrepared(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
@@ -2424,7 +2588,12 @@ function prepareStaticPageForFlip(flip) {
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 backTexture = oppositeMaterial?.map || getBlankPageTexture();
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();
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
@@ -2733,15 +2902,20 @@ function createFlippingPageGeometry(surface) {
function finishActiveFlip(flip) {
removeFlipMesh(flip);
activeFlips = activeFlips.filter((active) => active !== flip);
if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) {
bookPaginationState = {
...bookPaginationState,
spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread)))
};
syncReadingProgressToCurrentPage();
}
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
detail: {
direction: flip.direction,
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null
}
}));
if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) {
syncReadingProgressToCurrentPage();
}
if (flip.commitBundleOnFinish) {
if (Number.isFinite(Number(flip.targetSpread))) {
syncBookControls();