Add WebGL page cache and runtime checks
This commit is contained in:
@@ -7,10 +7,11 @@ import { BaseModule } from './base-module.js';
|
||||
class BookTextureRendererModule extends BaseModule {
|
||||
constructor() {
|
||||
super('book-texture-renderer', 'Book Texture Renderer');
|
||||
this.dependencies = ['book-page-format', 'book-pagination', 'localization'];
|
||||
this.dependencies = ['book-page-format', 'book-pagination', 'localization', 'webgl-page-cache'];
|
||||
this.pageFormat = null;
|
||||
this.pagination = null;
|
||||
this.localization = null;
|
||||
this.pageCache = null;
|
||||
this.metrics = null;
|
||||
this.canvases = {
|
||||
left: null,
|
||||
@@ -70,6 +71,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'buildLineSegments',
|
||||
'startRevealAnimation',
|
||||
'prepareRevealBlock',
|
||||
'hasPreparedRevealBlock',
|
||||
'createAnimationState',
|
||||
'publishPreparedReveal',
|
||||
'startPreparedRevealAnimation',
|
||||
@@ -81,6 +83,8 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'requestAnimationFrame',
|
||||
'tickAnimations',
|
||||
'publishSpread',
|
||||
'cachePublishedPages',
|
||||
'schedulePageCacheWrite',
|
||||
'getPageCanvas',
|
||||
'getHitMap',
|
||||
'handlePageCountChanged'
|
||||
@@ -91,6 +95,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.pageFormat = this.getModule('book-page-format');
|
||||
this.pagination = this.getModule('book-pagination');
|
||||
this.localization = this.getModule('localization');
|
||||
this.pageCache = this.getModule('webgl-page-cache');
|
||||
window.BookTextureRendererDebug = {
|
||||
pipelineTimings: this.pipelineTimings
|
||||
};
|
||||
@@ -704,6 +709,11 @@ class BookTextureRendererModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
hasPreparedRevealBlock(blockId) {
|
||||
const id = String(blockId ?? '');
|
||||
return Boolean(id && this.preparedRevealCache.has(id));
|
||||
}
|
||||
|
||||
publishPreparedReveal(prepared) {
|
||||
if (!prepared) return;
|
||||
this.markPipelineTiming('publishPreparedReveal', {
|
||||
@@ -884,6 +894,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
};
|
||||
});
|
||||
if (Object.keys(reveal).length) detail.reveal = reveal;
|
||||
this.cachePublishedPages(sidesToPublish, detail);
|
||||
this.markPipelineTiming('publishSpread', {
|
||||
sides: sidesToPublish,
|
||||
hasReveal: Object.keys(reveal).length > 0,
|
||||
@@ -896,6 +907,24 @@ class BookTextureRendererModule extends BaseModule {
|
||||
return detail;
|
||||
}
|
||||
|
||||
cachePublishedPages(sides = [], detail = {}) {
|
||||
if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return;
|
||||
sides.forEach((side) => {
|
||||
const canvas = detail[side];
|
||||
const pageMeta = detail.pageMeta?.[side] || null;
|
||||
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return;
|
||||
this.schedulePageCacheWrite(pageMeta, canvas);
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
getPageCanvas(side) {
|
||||
return this.canvases[side] || null;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ const ModuleLoader = (function() {
|
||||
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
|
||||
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
|
||||
{ id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 },
|
||||
{ id: 'webgl-page-cache', script: '/js/webgl-page-cache-module.js', weight: 5 },
|
||||
{ id: 'book-pagination', script: '/js/book-pagination-module.js', weight: 8 },
|
||||
{ id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 },
|
||||
{ id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
|
||||
|
||||
@@ -46,6 +46,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
'getPreparedSentence',
|
||||
'prefetchAhead',
|
||||
'prefetchWebGLBookPresentation',
|
||||
'isWebGLBookPresentationPrepared',
|
||||
'prepareSpeechMetadata',
|
||||
'preloadAssetsForItem',
|
||||
'normalizeTtsText',
|
||||
@@ -200,10 +201,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
const sentence = await this.getPreparedSentence(item);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
await this.prefetchWebGLBookPresentation(sentence, {
|
||||
queueGeneration,
|
||||
queueIndex: 0
|
||||
});
|
||||
if (!this.isWebGLBookPresentationPrepared(sentence)) {
|
||||
await this.prefetchWebGLBookPresentation(sentence, {
|
||||
queueGeneration,
|
||||
queueIndex: 0
|
||||
});
|
||||
}
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
|
||||
// Prefetch far enough ahead that media pauses do not block TTS
|
||||
@@ -898,6 +901,10 @@ class SentenceQueueModule extends BaseModule {
|
||||
const bookTextureRenderer = this.getModule('book-texture-renderer');
|
||||
if (!bookPagination || !bookTextureRenderer) return null;
|
||||
|
||||
if (this.isWebGLBookPresentationPrepared(sentence)) {
|
||||
return sentence.webglBookPresentation?.spread || null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
|
||||
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
|
||||
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
|
||||
@@ -929,10 +936,23 @@ class SentenceQueueModule extends BaseModule {
|
||||
spread,
|
||||
preloadOnly: true
|
||||
}, { preloadOnly: true });
|
||||
sentence.webglBookPresentation = {
|
||||
prepared: true,
|
||||
blockId,
|
||||
spread
|
||||
};
|
||||
}
|
||||
return spread;
|
||||
}
|
||||
|
||||
isWebGLBookPresentationPrepared(sentence) {
|
||||
const blockId = sentence?.blockId ?? sentence?.metadata?.blockId ?? null;
|
||||
if (blockId == null) return false;
|
||||
if (sentence?.webglBookPresentation?.prepared === true) return true;
|
||||
const bookTextureRenderer = this.getModule('book-texture-renderer');
|
||||
return Boolean(bookTextureRenderer?.hasPreparedRevealBlock?.(blockId));
|
||||
}
|
||||
|
||||
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
|
||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'renderSentence',
|
||||
'isWebGLMode',
|
||||
'prepareWebGLBookReveal',
|
||||
'waitForWebGLPageFlip',
|
||||
'renderStoryBlock',
|
||||
'prepareRenderableBlock',
|
||||
'prepareTextRenderable',
|
||||
@@ -1054,8 +1055,22 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|| { wordTimings: [], cueTimings: [], totalDuration: 0 };
|
||||
}
|
||||
|
||||
let preparedSpread = null;
|
||||
if (typeof bookPagination.preparePendingBlock === 'function') {
|
||||
await bookPagination.preparePendingBlock(sentence);
|
||||
const currentSpreadIndex = Math.max(0, Number(bookPagination.currentSpreadIndex || 0));
|
||||
const previewSpread = sentence.webglBookPresentation?.spread || await bookPagination.preparePendingBlock(sentence, {
|
||||
activate: false,
|
||||
publish: false,
|
||||
includeUnrenderedHistory: true
|
||||
});
|
||||
if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
|
||||
await this.waitForWebGLPageFlip({
|
||||
direction: 1,
|
||||
reason: 'pending-block-overflow',
|
||||
targetSpread: previewSpread.index
|
||||
});
|
||||
}
|
||||
preparedSpread = await bookPagination.preparePendingBlock(sentence);
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
|
||||
detail: {
|
||||
@@ -1069,7 +1084,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
blockId: sentence.blockId,
|
||||
wordTimings: sentence.animation?.wordTimings || [],
|
||||
cueTimings: sentence.animation?.cueTimings || [],
|
||||
totalDuration: sentence.animation?.totalDuration || 0
|
||||
totalDuration: sentence.animation?.totalDuration || 0,
|
||||
spread: preparedSpread
|
||||
};
|
||||
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
|
||||
bookTextureRenderer.prepareRevealBlock(revealDetail);
|
||||
@@ -1080,6 +1096,31 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
waitForWebGLPageFlip(detail = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.clearTimeout(timeout);
|
||||
document.removeEventListener('webgl-book:page-flip-finished', finish);
|
||||
resolve(true);
|
||||
};
|
||||
const timeout = window.setTimeout(finish, 1400);
|
||||
document.addEventListener('webgl-book:page-flip-finished', finish, { once: true });
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
|
||||
detail: {
|
||||
direction: Math.sign(Number(detail.direction || 1)) || 1,
|
||||
reason: detail.reason || 'pending-block-overflow',
|
||||
force: true,
|
||||
targetSpread: Number.isFinite(Number(detail.targetSpread))
|
||||
? Math.max(0, Math.round(Number(detail.targetSpread)))
|
||||
: null
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async rerenderStory() {
|
||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
||||
|
||||
+190
-16
@@ -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();
|
||||
|
||||
@@ -224,7 +224,7 @@ class WebGLBookSceneModule extends BaseModule {
|
||||
book.style.position = 'fixed';
|
||||
book.style.left = '1rem';
|
||||
book.style.top = '1rem';
|
||||
book.style.width = 'min(31rem, calc(100vw - 2rem))';
|
||||
book.style.width = 'min(44rem, calc(100vw - 2rem))';
|
||||
book.style.height = 'min(27rem, calc(100vh - 2rem))';
|
||||
book.style.background = 'rgba(18, 11, 8, 0.62)';
|
||||
book.style.border = '1px solid rgba(240, 205, 142, 0.28)';
|
||||
@@ -273,7 +273,8 @@ class WebGLBookSceneModule extends BaseModule {
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
padding: '1rem',
|
||||
overflow: 'auto',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
opacity: '1',
|
||||
mixBlendMode: 'normal',
|
||||
clipPath: 'none',
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* WebGL Page Cache Module
|
||||
* Persists fully typeset book page canvases in IndexedDB for fast VRAM prewarm.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class WebGLPageCacheModule extends BaseModule {
|
||||
constructor() {
|
||||
super('webgl-page-cache', 'WebGL Page Cache');
|
||||
|
||||
this.dependencies = [];
|
||||
this.dbName = 'webglPageTextureCacheDB';
|
||||
this.dbVersion = 1;
|
||||
this.storeName = 'webglPageTextureStore';
|
||||
this.db = null;
|
||||
this.cacheStatus = 'uninitialized';
|
||||
this.currentCacheSize = 0;
|
||||
this.maxCacheSizeBytes = 180 * 1024 * 1024;
|
||||
this.memoryCanvasCache = new Map();
|
||||
this.maxMemoryCanvasCount = 12;
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'openDB',
|
||||
'cachePageCanvas',
|
||||
'getPageCanvas',
|
||||
'makePageKey',
|
||||
'canvasToBlob',
|
||||
'blobToCanvas',
|
||||
'manageCacheSize',
|
||||
'calculateTotalCacheSize',
|
||||
'deleteEntry',
|
||||
'rememberCanvas',
|
||||
'tx'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(20, 'Opening WebGL page texture cache');
|
||||
try {
|
||||
await this.openDB();
|
||||
this.reportProgress(70, 'Measuring WebGL page texture cache');
|
||||
this.currentCacheSize = await this.calculateTotalCacheSize();
|
||||
this.cacheStatus = 'ready';
|
||||
this.reportProgress(100, 'WebGL page texture cache ready');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('WebGLPageCache: IndexedDB unavailable, continuing without persistent page cache', error);
|
||||
this.cacheStatus = 'error';
|
||||
this.reportProgress(100, 'WebGL page texture cache unavailable');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
openDB() {
|
||||
if (this.db) return Promise.resolve(this.db);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => reject(new Error('WebGL page texture cache upgrade blocked'));
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onversionchange = () => {
|
||||
this.db?.close?.();
|
||||
this.db = null;
|
||||
this.cacheStatus = 'uninitialized';
|
||||
};
|
||||
resolve(this.db);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const pageStore = db.createObjectStore(this.storeName, { keyPath: 'key' });
|
||||
pageStore.createIndex('lastAccessed', 'lastAccessed', { unique: false });
|
||||
pageStore.createIndex('size', 'size', { unique: false });
|
||||
pageStore.createIndex('pageIndex', 'pageIndex', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
tx(mode = 'readonly') {
|
||||
return this.db.transaction([this.storeName], mode).objectStore(this.storeName);
|
||||
}
|
||||
|
||||
makePageKey({ pageIndex, width, height, cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
|
||||
const safePage = Math.max(0, Math.round(Number(pageIndex || 0)));
|
||||
const safeWidth = Math.max(1, Math.round(Number(width || 0)));
|
||||
const safeHeight = Math.max(1, Math.round(Number(height || 0)));
|
||||
return `${cacheKey}:page:${safePage}:${safeWidth}x${safeHeight}`;
|
||||
}
|
||||
|
||||
async cachePageCanvas(pageMeta = {}, canvas = null) {
|
||||
if (!canvas || !this.db || this.cacheStatus !== 'ready') return false;
|
||||
const pageIndex = Number(pageMeta.pageIndex);
|
||||
if (!Number.isFinite(pageIndex) || pageIndex < 0) return false;
|
||||
const key = this.makePageKey({
|
||||
pageIndex,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
cacheKey: pageMeta.cacheKey
|
||||
});
|
||||
if (this.memoryCanvasCache.has(key)) return true;
|
||||
try {
|
||||
const blob = await this.canvasToBlob(canvas);
|
||||
if (!blob) return false;
|
||||
const oldEntry = await new Promise((resolve, reject) => {
|
||||
const request = this.tx('readonly').get(key);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await this.manageCacheSize(blob.size);
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = this.tx('readwrite').put({
|
||||
key,
|
||||
pageIndex,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
blob,
|
||||
size: blob.size,
|
||||
lastAccessed: Date.now()
|
||||
});
|
||||
request.onsuccess = () => {
|
||||
this.currentCacheSize += blob.size - Number(oldEntry?.size || 0);
|
||||
this.rememberCanvas(key, canvas);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('WebGLPageCache: Failed to cache page canvas', { pageIndex, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getPageCanvas(pageMeta = {}) {
|
||||
if (!this.db || this.cacheStatus !== 'ready') return null;
|
||||
const key = this.makePageKey(pageMeta);
|
||||
const cachedCanvas = this.memoryCanvasCache.get(key);
|
||||
if (cachedCanvas) {
|
||||
this.memoryCanvasCache.delete(key);
|
||||
this.memoryCanvasCache.set(key, cachedCanvas);
|
||||
return cachedCanvas;
|
||||
}
|
||||
try {
|
||||
const entry = await new Promise((resolve, reject) => {
|
||||
const store = this.tx('readwrite');
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => {
|
||||
const result = request.result || null;
|
||||
if (!result) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
result.lastAccessed = Date.now();
|
||||
store.put(result);
|
||||
resolve(result);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (!entry?.blob) return null;
|
||||
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
||||
if (canvas) this.rememberCanvas(key, canvas);
|
||||
return canvas;
|
||||
} catch (error) {
|
||||
console.warn('WebGLPageCache: Failed to read cached page canvas', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
canvasToBlob(canvas) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof canvas.toBlob !== 'function') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
canvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async blobToCanvas(blob, width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(Number(width || 1)));
|
||||
canvas.height = Math.max(1, Math.round(Number(height || 1)));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
rememberCanvas(key, canvas) {
|
||||
this.memoryCanvasCache.set(key, canvas);
|
||||
while (this.memoryCanvasCache.size > this.maxMemoryCanvasCount) {
|
||||
const oldestKey = this.memoryCanvasCache.keys().next().value;
|
||||
this.memoryCanvasCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
async manageCacheSize(sizeToAdd = 0) {
|
||||
if (!this.db || this.cacheStatus !== 'ready') return;
|
||||
if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) return;
|
||||
const entries = await new Promise((resolve, reject) => {
|
||||
const results = [];
|
||||
const request = this.tx('readonly').index('lastAccessed').openCursor();
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
results.push({
|
||||
key: cursor.value.key,
|
||||
size: Number(cursor.value.size || 0)
|
||||
});
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
for (const entry of entries) {
|
||||
if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) break;
|
||||
await this.deleteEntry(entry.key);
|
||||
this.currentCacheSize = Math.max(0, this.currentCacheSize - entry.size);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateTotalCacheSize() {
|
||||
if (!this.db) return 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
let total = 0;
|
||||
const request = this.tx('readonly').openCursor();
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(total);
|
||||
return;
|
||||
}
|
||||
total += Number(cursor.value.size || 0);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
deleteEntry(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.tx('readwrite').delete(key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const webglPageCache = new WebGLPageCacheModule();
|
||||
|
||||
export { webglPageCache as WebGLPageCache };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(webglPageCache);
|
||||
}
|
||||
|
||||
window.WebGLPageCache = webglPageCache;
|
||||
Reference in New Issue
Block a user