Fix WebGL reveal timing and flip prewarm

This commit is contained in:
2026-06-09 10:05:23 +02:00
parent d665a0f237
commit fe51410a3b
3 changed files with 213 additions and 59 deletions
+117 -31
View File
@@ -65,8 +65,10 @@ class BookTextureRendererModule extends BaseModule {
'buildRevealRegions',
'collectRevealRegionCandidates',
'createRevealRegionForLine',
'assignRevealTiming',
'getLineInkRect',
'getLineNaturalWidth',
'getLineWordCount',
'getImageRevealDurationMs',
'getInlineStyleState',
'updateInlineStyleState',
@@ -76,6 +78,8 @@ class BookTextureRendererModule extends BaseModule {
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'preloadAdditionalRevealSpreads',
'spreadContainsBlock',
'hasPreparedRevealBlock',
'createAnimationState',
'publishPreparedReveal',
@@ -118,6 +122,11 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = spread || { left: [], right: [] };
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const id = String(latestBlockId);
if (this.activeAnimations.has(id)) {
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread, ['left', 'right']);
}
return;
}
this.drawSpread(this.currentSpread);
@@ -591,34 +600,7 @@ class BookTextureRendererModule extends BaseModule {
byBlock.forEach((blockRegions, blockId) => {
const animation = this.activeAnimations.get(blockId);
if (!animation || animation.completed) return;
const fixedRegions = blockRegions.filter(region => region.fixedDurationMs > 0);
const textRegions = blockRegions.filter(region => !(region.fixedDurationMs > 0));
let delay = 0;
const textDuration = Math.max(0, Number(animation.totalDuration || 0));
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
textRegions.forEach((region) => {
const duration = totalArea > 0
? Math.max(1, textDuration * (Math.max(1, region.area) / totalArea))
: Math.max(1, textDuration / Math.max(1, textRegions.length));
regions.push({
...region,
timing: {
delay,
duration
}
});
delay += duration;
});
fixedRegions.forEach((region) => {
regions.push({
...region,
timing: {
delay,
duration: Math.max(1, region.fixedDurationMs)
}
});
delay += Math.max(1, region.fixedDurationMs);
});
regions.push(...this.assignRevealTiming(blockRegions, animation));
});
const sideRegions = regions.filter(region => region.side === side);
if (!sideRegions.length) return null;
@@ -635,7 +617,7 @@ class BookTextureRendererModule extends BaseModule {
});
return {
blockIds: Array.from(byBlock.keys()),
durationMs: regions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
baseCanvas: null,
lineRects: sideRegions.map(region => ({
blockId: region.blockId,
@@ -664,6 +646,67 @@ class BookTextureRendererModule extends BaseModule {
return candidates;
}
assignRevealTiming(blockRegions = [], animation = {}) {
const wordTimings = Array.isArray(animation.wordTimings) ? animation.wordTimings : [];
const totalDuration = Math.max(
Number(animation.totalDuration || 0),
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
);
const sortedRegions = [...blockRegions].sort((a, b) => {
const aStart = Math.max(0, Number(a.wordStart || 0));
const bStart = Math.max(0, Number(b.wordStart || 0));
if (aStart !== bStart) return aStart - bStart;
const aLine = Math.max(0, Number(a.lineIndex || 0));
const bLine = Math.max(0, Number(b.lineIndex || 0));
return aLine - bLine;
});
const timedRegions = [];
const textRegions = sortedRegions.filter(region => !(region.fixedDurationMs > 0));
const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0);
let fallbackDelay = 0;
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
textRegions.forEach((region) => {
const wordStart = Math.max(0, Number(region.wordStart || 0));
const wordEnd = Math.max(wordStart + 1, Number(region.wordEnd || wordStart + 1));
const firstTiming = wordTimings[wordStart] || null;
const lastTiming = wordTimings[Math.min(wordTimings.length - 1, wordEnd - 1)] || firstTiming;
let delay = firstTiming ? Math.max(0, Number(firstTiming.delay || 0)) : fallbackDelay;
let duration = lastTiming
? Math.max(1, (Number(lastTiming.delay || 0) + Number(lastTiming.duration || 0)) - delay)
: 0;
if (!Number.isFinite(duration) || duration <= 0) {
duration = totalArea > 0
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea))
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
delay = fallbackDelay;
}
timedRegions.push({
...region,
timing: { delay, duration }
});
fallbackDelay = Math.max(fallbackDelay, delay + duration);
});
fixedRegions.forEach((region) => {
timedRegions.push({
...region,
timing: {
delay: fallbackDelay,
duration: Math.max(1, region.fixedDurationMs)
}
});
fallbackDelay += Math.max(1, region.fixedDurationMs);
});
return timedRegions.sort((a, b) => {
const aDelay = Number(a.timing?.delay || 0);
const bDelay = Number(b.timing?.delay || 0);
if (aDelay !== bDelay) return aDelay - bDelay;
return Number(a.lineIndex || 0) - Number(b.lineIndex || 0);
});
}
createRevealRegionForLine(side, lineRecord = {}) {
const blockId = String(lineRecord?.blockId ?? '');
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
@@ -680,10 +723,12 @@ class BookTextureRendererModule extends BaseModule {
}
const rect = this.getLineInkRect(side, lineRecord);
if (!rect) return null;
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0);
const wordStart = Math.max(0, Number(lineRecord.blockWordStart || 0));
const wordCount = Math.max(1, this.getLineWordCount(lineRecord.line || {}));
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, wordStart, wordStart + wordCount);
}
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0) {
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, wordStart = 0, wordEnd = 0) {
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
const left = Math.max(0, x - padding);
const top = Math.max(0, y - padding);
@@ -696,6 +741,8 @@ class BookTextureRendererModule extends BaseModule {
blockId,
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
fixedDurationMs,
wordStart,
wordEnd,
area: rectWidth * rectHeight,
pixelRect: { x: left, y: top, right, bottom },
rect: {
@@ -743,6 +790,25 @@ class BookTextureRendererModule extends BaseModule {
}, 0);
}
getLineWordCount(line = {}) {
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
let count = 0;
let previousWasGlue = true;
nodes.forEach((node) => {
if (!node) return;
if (node.type === 'glue') {
previousWasGlue = true;
return;
}
if (node.type === 'penalty') return;
if (node.type === 'box' && node.value) {
if (previousWasGlue) count += 1;
previousWasGlue = false;
}
});
return count;
}
getImageRevealDurationMs(lineRecord = {}) {
const metadata = lineRecord.metadata || {};
const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0);
@@ -822,6 +888,7 @@ class BookTextureRendererModule extends BaseModule {
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = ['left', 'right'];
const published = this.drawSpread(spread, sides, { preloadOnly });
if (!preloadOnly) this.preloadAdditionalRevealSpreads(id, spread);
if (preloadOnly && published) {
this.preparedRevealCache.set(id, {
...published,
@@ -837,6 +904,25 @@ class BookTextureRendererModule extends BaseModule {
});
}
preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
if (!spreads.length) return;
const primaryIndex = Number(primarySpread?.index);
spreads.forEach((spread) => {
if (!spread || Number(spread.index) === primaryIndex) return;
if (!this.spreadContainsBlock(spread, blockId)) return;
this.drawSpread(spread, ['left', 'right'], { preloadOnly: true });
});
}
spreadContainsBlock(spread = {}, blockId = '') {
const id = String(blockId ?? '');
return ['left', 'right'].some((side) => {
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => String(line?.blockId ?? '') === id);
});
}
hasPreparedRevealBlock(blockId) {
const id = String(blockId ?? '');
return Boolean(id && this.preparedRevealCache.has(id));
+90 -24
View File
@@ -211,6 +211,7 @@ const fastFlipOverlap = 5;
let activeFlips = [];
let pendingPageFlips = 0;
const pendingRevealStartBlockIds = new Set();
const activeRevealBlockStarts = new Map();
const paperColor = new THREE.Color(0xece4ca);
const inkColor = '#1a1009';
@@ -2020,28 +2021,30 @@ function handlePageCanvases(event) {
preloadOnly: Boolean(detail.preloadOnly),
pageMeta: currentPageMeta
});
const leftReveal = attachRevealPageMeta(detail.reveal?.left, detail.pageMeta?.left || currentPageMeta.left || null);
const rightReveal = attachRevealPageMeta(detail.reveal?.right, detail.pageMeta?.right || currentPageMeta.right || null);
if (detail.preloadOnly) {
if (detail.left) {
const texture = preloadPageTexture('left', detail.left, detail.reveal?.left);
rememberResidentPageTexture(currentPageMeta.left, texture, detail.left);
const texture = preloadPageTexture('left', detail.left, leftReveal, detail.pageMeta?.left || null);
rememberResidentPageTexture(detail.pageMeta?.left || null, texture, detail.left);
}
if (detail.right) {
const texture = preloadPageTexture('right', detail.right, detail.reveal?.right);
rememberResidentPageTexture(currentPageMeta.right, texture, detail.right);
const texture = preloadPageTexture('right', detail.right, rightReveal, detail.pageMeta?.right || null);
rememberResidentPageTexture(detail.pageMeta?.right || null, texture, detail.right);
}
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
return;
}
if (detail.left) {
if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left);
if (leftReveal) {
beginPageReveal('left', detail.left, leftReveal);
} else {
uploadPageTextureDirect('left', detail.left, currentPageMeta.left);
}
}
if (detail.right) {
if (detail.reveal?.right) {
beginPageReveal('right', detail.right, detail.reveal.right);
if (rightReveal) {
beginPageReveal('right', detail.right, rightReveal);
} else {
uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
}
@@ -2055,16 +2058,26 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:end');
}
function getRevealCacheKey(revealDetail = {}) {
const ids = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [];
return ids.map(id => String(id)).join('|') || 'direct';
function attachRevealPageMeta(revealDetail = null, pageMeta = null) {
if (!revealDetail) return null;
return {
...revealDetail,
pageMeta: pageMeta ? { ...pageMeta } : null
};
}
function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
function getRevealCacheKey(revealDetail = {}) {
const ids = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [];
const pageIndex = Number(revealDetail.pageMeta?.pageIndex);
const pageKey = Number.isFinite(pageIndex) ? `page:${Math.max(0, Math.round(pageIndex))}` : 'page:unknown';
return `${pageKey}:${ids.map(id => String(id)).join('|') || 'direct'}`;
}
function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = null) {
if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null;
const key = getRevealCacheKey(revealDetail);
const key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null });
markPageTextureTiming('preloadTexture:start', {
side,
key,
@@ -2312,13 +2325,19 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
}
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null);
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
const activeStartedAt = revealBlockIds
.map(blockId => activeRevealBlockStarts.get(blockId))
.filter(value => Number.isFinite(Number(value)))
.sort((a, b) => a - b)[0] ?? null;
pageRevealState[side] = {
startedAt: revealDetail.startNow ? performance.now() : null,
startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null),
pendingStart: false,
lastRevealFrameAt: null,
visualElapsedMs: 0,
visualElapsedMs: activeStartedAt ? Math.max(0, performance.now() - activeStartedAt) : 0,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [],
blockIds: revealBlockIds,
baseTexture,
fastForwarding: false,
fastForwardStartedAt: null,
@@ -2328,6 +2347,21 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
if (material?.userData) material.userData.pendingPageReveal = revealDetail;
if (shader?.uniforms) applyPendingPageReveal(side, shader);
else if (material) material.needsUpdate = true;
if (shader?.uniforms?.bookRevealElapsedMs) {
shader.uniforms.bookRevealElapsedMs.value = pageRevealState[side].visualElapsedMs;
}
if (side === 'right' && isRightBodyPageComplete()) {
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
prewarmFlipTextures(1, targetSpread).then(() => {
markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread });
}).catch((error) => {
recordPageCacheProblem({
type: 'right-page-flip-prewarm-error',
targetSpread,
message: error?.message || String(error)
});
});
}
document.documentElement.dataset.webglRevealDebug = JSON.stringify({
side,
blockIds: pageRevealState[side].blockIds,
@@ -2450,6 +2484,7 @@ function clearPageReveal(side, reason = 'clear') {
function startPageRevealForBlock(blockId) {
const id = String(blockId ?? '');
if (!id) return;
if (!activeRevealBlockStarts.has(id)) activeRevealBlockStarts.set(id, performance.now());
if (activeFlips.length > 0) {
pendingRevealStartBlockIds.add(id);
markPageTextureTiming('revealStart:deferred-for-flip', {
@@ -2463,6 +2498,7 @@ function startPageRevealForBlock(blockId) {
if (!state || state.startedAt != null) return;
if (!state.blockIds.map(value => String(value)).includes(id)) return;
state.pendingStart = true;
state.startedAt = activeRevealBlockStarts.get(id) || performance.now();
const shader = getPageRevealShader(side);
if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0;
});
@@ -2493,18 +2529,17 @@ function updatePageRevealAnimations(now) {
return;
}
if (state.pendingStart) {
state.startedAt = now;
if (state.startedAt == null) state.startedAt = now;
state.pendingStart = false;
state.lastRevealFrameAt = now;
state.visualElapsedMs = 0;
shader.uniforms.bookRevealElapsedMs.value = 0;
state.visualElapsedMs = Math.max(0, now - state.startedAt);
shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs;
return;
}
if (state.startedAt == null) {
shader.uniforms.bookRevealElapsedMs.value = 0;
return;
}
const revealFrameDeltaMs = state.lastRevealFrameAt == null ? 0 : Math.max(0, now - state.lastRevealFrameAt);
state.lastRevealFrameAt = now;
if (state.fastForwarding) {
const fastElapsed = Math.max(0, now - Number(state.fastForwardStartedAt || now));
@@ -2515,7 +2550,7 @@ function updatePageRevealAnimations(now) {
fastProgress
);
} else {
state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs);
state.visualElapsedMs = Math.max(0, now - state.startedAt);
}
const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1);
shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs;
@@ -2995,8 +3030,8 @@ function lineYAtX(points, x) {
}
function setActivePageGeometry(flip, surface) {
const geometry = createFlippingPageGeometry(surface);
if (!flip.mesh) {
const geometry = createFlippingPageGeometry(surface);
flip.mesh = new THREE.Mesh(geometry, [
materials.flipPageSurface,
materials.flipPageBackSurface,
@@ -3009,8 +3044,11 @@ function setActivePageGeometry(flip, surface) {
book.add(flip.mesh);
return;
}
flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry;
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
const geometry = createFlippingPageGeometry(surface);
flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry;
}
}
function createFlippingPageGeometry(surface) {
@@ -3085,6 +3123,34 @@ function createFlippingPageGeometry(surface) {
}
}
function updateFlippingPageGeometry(geometry, surface) {
const position = geometry?.getAttribute?.('position');
if (!position || !surface?.length || !surface[0]?.length) return false;
const widthSegments = surface.length - 1;
const depthSegments = surface[0].length - 1;
const expectedVertexCount = (widthSegments + 1) * (depthSegments + 1) * 2;
if (position.count !== expectedVertexCount) return false;
const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001));
const array = position.array;
let offset = 0;
surface.forEach((rowPoints) => {
rowPoints.forEach((point) => {
array[offset] = point.x;
array[offset + 1] = point.y + pageThickness;
array[offset + 2] = point.z;
offset += 3;
array[offset] = point.x;
array[offset + 1] = point.y;
array[offset + 2] = point.z;
offset += 3;
});
});
position.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
return true;
}
function finishActiveFlip(flip) {
removeFlipMesh(flip);
activeFlips = activeFlips.filter((active) => active !== flip);