Implement WebGL book spread flip groundwork

This commit is contained in:
2026-06-08 09:03:35 +02:00
parent c86a304364
commit 86b6fa0419
8 changed files with 652 additions and 27 deletions
+104 -3
View File
@@ -185,8 +185,8 @@ function markPageTextureTiming(name, detail = {}) {
const book = new THREE.Group();
scene.add(book);
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1);
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0.28;
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0'), 0, 1);
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0;
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240');
let currentProceduralBookModel = null;
const progressInput = document.getElementById('progress_control');
@@ -235,10 +235,22 @@ function createPageCanvasTexture(sourceCanvas) {
return texture;
}
function getBlankPageTexture() {
if (blankPageTexture) return blankPageTexture;
blankPageTexture = createPageCanvasTexture(createPageCanvas('blank'));
return blankPageTexture;
}
const preparedPageTextures = {
left: new Map(),
right: new Map()
};
let blankPageTexture = null;
let currentPageMeta = {
left: null,
right: null
};
let pendingRightPageFlip = false;
const pageRevealState = {
left: null,
right: null
@@ -518,6 +530,15 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
fastForwardPageReveals(event.detail?.blockIds || []);
});
document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {});
});
document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
pendingRightPageFlip = false;
startPageFlip(1);
}
});
installBookControls();
installCameraControls();
resize();
@@ -1673,11 +1694,18 @@ function syncBookControls() {
function handlePageCanvases(event) {
const detail = event.detail || {};
if (detail.pageMeta) {
currentPageMeta = {
left: detail.pageMeta.left || currentPageMeta.left || null,
right: detail.pageMeta.right || currentPageMeta.right || null
};
}
markPageTextureTiming('handlePageCanvases:start', {
hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {}),
preloadOnly: Boolean(detail.preloadOnly)
preloadOnly: Boolean(detail.preloadOnly),
pageMeta: currentPageMeta
});
if (detail.preloadOnly) {
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
@@ -2111,8 +2139,11 @@ function textureHitPageSide(hit) {
function startPageFlip(direction) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
pendingRightPageFlip = false;
delete document.documentElement.dataset.webglPendingPageFlip;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false;
prepareStaticPageForFlip(flip);
activeFlips.push(flip);
syncBookControls();
updateActiveFlips(flip.startTime);
@@ -2123,6 +2154,7 @@ function startFastPageFlip(direction) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
prepareStaticPageForFlip(firstFlip);
const startTime = firstFlip.startTime;
const interval = fastFlipDuration / fastFlipOverlap;
for (let index = 0; index < fastFlipCount; index += 1) {
@@ -2142,11 +2174,13 @@ function startFastPageFlip(direction) {
function createPageFlip(direction, startTime, duration) {
const sourceSide = direction > 0 ? 1 : -1;
const sourcePageSide = direction > 0 ? 'right' : 'left';
const sourceLine = topVisibleLine(sourceSide);
const destinationLine = topVisibleLine(-sourceSide);
if (!sourceLine || !destinationLine) return null;
return {
direction,
sourcePageSide,
sourceLine,
destinationLine,
startTime,
@@ -2158,12 +2192,64 @@ function createPageFlip(direction, startTime, duration) {
};
}
function prepareStaticPageForFlip(flip) {
if (!flip) return;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
materials.flipPageSurface.map = sourceTexture;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageSurface.needsUpdate = true;
flip.sourceTexture = sourceTexture;
if (flip.direction > 0) {
const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) {
clearPageReveal('right', 'page-flip-start');
materials.rightPage.map = blankTexture;
materials.rightPage.needsUpdate = true;
}
}
}
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
if (direction > 0) return readingProgress < 1;
return readingProgress > 0;
}
function handleRevealCommittedForPageFlip(detail = {}) {
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
if (isTtsPlaybackActive()) {
startPageFlip(1);
return;
}
pendingRightPageFlip = true;
document.documentElement.dataset.webglPendingPageFlip = 'right';
}
function isRightBodyPageComplete() {
const meta = currentPageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rendererDebug = window.BookTextureRenderer?.currentSpread || null;
const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0);
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
return maxLine >= expectedLines;
}
function isChoiceAwaitingPlayer() {
return document.documentElement.dataset.choiceAwaiting === 'true'
|| document.body?.dataset?.choiceAwaiting === 'true'
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
}
function isTtsPlaybackActive() {
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
}
function topVisibleLine(side) {
const sideLines = currentProceduralBookModel.lines
.filter((line) => line.side === side)
@@ -2180,6 +2266,15 @@ function updateActiveFlips(now) {
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
setActivePageGeometry(flip, surface);
if (!flip.spreadAdvanced && t >= 0.82) {
flip.spreadAdvanced = true;
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
detail: {
direction: flip.direction,
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
}
}));
}
if (t >= 1) completed.push(flip);
});
completed.forEach((flip) => finishActiveFlip(flip));
@@ -2378,6 +2473,12 @@ function createFlippingPageGeometry(surface) {
function finishActiveFlip(flip) {
removeFlipMesh(flip);
activeFlips = activeFlips.filter((active) => active !== flip);
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
detail: {
direction: flip.direction,
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
}
}));
if (flip.commitBundleOnFinish) {
shiftReadingProgressByBundle(flip.direction);
return;