Rework WebGL book playback to single ownership and fix flip/reveal pipeline
Establish book-playback-timeline as the sole playback owner driving the scene through formal webgl-book:* events (not the BookLabDebug surface), with a single reveal clock in the scene render loop and webgl-page-cache as the only texture cache. Remove the legacy dual playback path and the ownsPageFlipCommit gating. Fixes: - Flip page detached/folded at the spine: restore the raw page-cap line for flip geometry (matches the prototype/pre-regression), removing normalizeFlipLineToVisiblePage which moved the pivot off the spine arc. - Flip textures: distance-based UVs (no horizontal compression), direction-aware face material (source on the camera-facing side), source meta derived from the visible spread (manual flips), prewarm shape fix. - Reveal: flash removed on the static page and the flip back surface; spanning blocks rebuild the reveal plan at activate and continue the reveal on the next spread after the fill flip. - Cache staleness is contentVersion-primary; nav clamps to spreadCount. Docs updated to describe the intended single-owner architecture. Regression checks updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,14 +29,10 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.currentSpread = null;
|
||||
this.activeAnimations = new Map();
|
||||
this.revealedBlockIds = new Set();
|
||||
this.pendingRevealBlockIds = new Set();
|
||||
this.revealBaseCanvases = null;
|
||||
this.revealPublishBlockIds = null;
|
||||
this.lastDrawSignature = null;
|
||||
this.lastDrawSkipLoggedAt = 0;
|
||||
this.animationFrameId = null;
|
||||
this.lastAnimationFrameAt = 0;
|
||||
this.targetFrameDurationMs = 1000 / 60;
|
||||
this.pipelineTimings = [];
|
||||
this.imageCache = new Map();
|
||||
this.pageContentVersions = new Map();
|
||||
@@ -76,7 +72,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'applyTextStyle',
|
||||
'getPageContent',
|
||||
'buildLineSegments',
|
||||
'startRevealAnimation',
|
||||
'prepareRevealBlock',
|
||||
'preloadAdditionalRevealSpreads',
|
||||
'spreadContainsBlock',
|
||||
@@ -88,9 +83,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'stopAnimations',
|
||||
'getBlockSides',
|
||||
'getAnimatedSides',
|
||||
'markPendingReveal',
|
||||
'requestAnimationFrame',
|
||||
'tickAnimations',
|
||||
'publishSpread',
|
||||
'buildPageTextureRecords',
|
||||
'cachePublishedPages',
|
||||
@@ -114,62 +106,9 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.reportProgress(20, 'Preparing page texture canvases');
|
||||
this.createPageCanvases();
|
||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
||||
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
||||
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
|
||||
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
|
||||
const latestBlockId = event.detail?.latestBlockId;
|
||||
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
|
||||
const visibility = event.detail?.visibility || 'current';
|
||||
this.currentSpread = spread || { left: [], right: [] };
|
||||
const timelineOwnsPlayback = window.BookPlaybackTimeline?.ownsPageFlipCommit === true;
|
||||
if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) {
|
||||
this.markPipelineTiming('spreadUpdate:skip-during-flip', {
|
||||
spreadIndex,
|
||||
visibility
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
|
||||
this.markPendingReveal(latestBlockId);
|
||||
const id = String(latestBlockId);
|
||||
if (timelineOwnsPlayback && visibility !== 'future-ready') {
|
||||
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-reveal', {
|
||||
spreadIndex,
|
||||
latestBlockId: id,
|
||||
visibility
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
|
||||
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right'], {
|
||||
phase: 'prepare',
|
||||
publishEvent: !timelineOwnsPlayback
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.activeAnimations.has(id)) {
|
||||
this.revealPublishBlockIds = new Set([id]);
|
||||
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
|
||||
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
|
||||
if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) {
|
||||
this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' });
|
||||
return;
|
||||
}
|
||||
this.drawSpread(this.currentSpread, ['left', 'right']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (timelineOwnsPlayback && visibility !== 'future-ready' && latestBlockId) {
|
||||
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-commit', {
|
||||
spreadIndex,
|
||||
latestBlockId,
|
||||
latestRenderedBlockId,
|
||||
visibility
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.drawSpread(this.currentSpread);
|
||||
});
|
||||
// The renderer is a pure renderer. It does not react to pagination spread
|
||||
// updates with draws or reveals — the playback owner (book-playback-timeline)
|
||||
// drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership".
|
||||
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
||||
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
||||
this.completeRevealBlockIds(event.detail?.blockIds || []);
|
||||
@@ -185,18 +124,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
return true;
|
||||
}
|
||||
|
||||
stripUnrenderedLines(spread = {}, latestRenderedBlockId = 0) {
|
||||
const latestRendered = Math.max(0, Number(latestRenderedBlockId || 0));
|
||||
return {
|
||||
...spread,
|
||||
left: (Array.isArray(spread.left) ? spread.left : [])
|
||||
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
|
||||
right: (Array.isArray(spread.right) ? spread.right : [])
|
||||
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
|
||||
pageMeta: spread.pageMeta || { left: null, right: null }
|
||||
};
|
||||
}
|
||||
|
||||
markPipelineTiming(name, detail = {}) {
|
||||
const entry = {
|
||||
name,
|
||||
@@ -916,35 +843,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
|
||||
}
|
||||
|
||||
startRevealAnimation(detail = {}) {
|
||||
const blockId = detail.blockId ?? detail.id ?? null;
|
||||
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
||||
const existing = this.activeAnimations.get(String(blockId));
|
||||
if (existing && existing.prepared) {
|
||||
this.startPreparedRevealAnimation(blockId);
|
||||
return;
|
||||
}
|
||||
this.activeAnimations.set(String(blockId), {
|
||||
blockId,
|
||||
wordTimings: detail.wordTimings,
|
||||
startedAt: performance.now(),
|
||||
totalDuration: Math.max(
|
||||
Number(detail.totalDuration || 0),
|
||||
...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
|
||||
),
|
||||
completed: false
|
||||
});
|
||||
this.pendingRevealBlockIds.delete(String(blockId));
|
||||
this.revealPublishBlockIds = new Set([String(blockId)]);
|
||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']);
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
||||
detail: {
|
||||
blockId
|
||||
}
|
||||
}));
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
|
||||
createAnimationState(blockId, wordTimings = [], detail = {}) {
|
||||
return {
|
||||
blockId,
|
||||
@@ -972,10 +870,12 @@ class BookTextureRendererModule extends BaseModule {
|
||||
wordTimingCount: wordTimings.length,
|
||||
phase
|
||||
});
|
||||
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
||||
// forceRebuild: the cached plan was built before the block's continuation was
|
||||
// committed (it would be right-only). Discard it and redraw from current spreads.
|
||||
if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id);
|
||||
if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
||||
const cached = this.pageCache.takePreparedRevealPlan(id);
|
||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||
this.pendingRevealBlockIds.delete(id);
|
||||
this.publishPreparedReveal(cached, options);
|
||||
this.markPipelineTiming('prepareRevealBlock:end', {
|
||||
blockId: id,
|
||||
@@ -990,7 +890,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
}
|
||||
|
||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||
this.pendingRevealBlockIds.delete(id);
|
||||
this.revealPublishBlockIds = new Set([id]);
|
||||
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
||||
const sides = ['left', 'right'];
|
||||
@@ -1079,7 +978,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
}
|
||||
}));
|
||||
}
|
||||
this.requestAnimationFrame();
|
||||
return {
|
||||
blockId: animation.blockId,
|
||||
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
||||
@@ -1098,7 +996,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
this.pendingRevealBlockIds.clear();
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
||||
detail: {
|
||||
blockIds
|
||||
@@ -1115,17 +1012,11 @@ class BookTextureRendererModule extends BaseModule {
|
||||
const animation = this.activeAnimations.get(id);
|
||||
if (animation) animation.completed = true;
|
||||
this.revealedBlockIds.add(id);
|
||||
this.pendingRevealBlockIds.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
stopAnimations() {
|
||||
this.activeAnimations.clear();
|
||||
this.pendingRevealBlockIds.clear();
|
||||
if (this.animationFrameId) {
|
||||
clearTimeout(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||
}
|
||||
|
||||
@@ -1151,62 +1042,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
return sides.length ? sides : ['left', 'right'];
|
||||
}
|
||||
|
||||
markPendingReveal(blockId) {
|
||||
const id = String(blockId ?? '');
|
||||
if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return;
|
||||
this.pendingRevealBlockIds.add(id);
|
||||
}
|
||||
|
||||
requestAnimationFrame() {
|
||||
if (this.animationFrameId) return;
|
||||
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
|
||||
}
|
||||
|
||||
isWebGLPageFlipActive() {
|
||||
return document.documentElement.dataset.webglPageFlipActive === 'true';
|
||||
}
|
||||
|
||||
tickAnimations(now) {
|
||||
this.animationFrameId = null;
|
||||
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
|
||||
this.requestAnimationFrame();
|
||||
return;
|
||||
}
|
||||
this.lastAnimationFrameAt = now;
|
||||
|
||||
let hasActive = false;
|
||||
const currentNow = performance.now();
|
||||
if (this.isWebGLPageFlipActive()) {
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (animation.completed) return;
|
||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
||||
hasActive = true;
|
||||
if (animation.startedAt != null) {
|
||||
animation.startedAt += this.targetFrameDurationMs;
|
||||
}
|
||||
});
|
||||
if (hasActive) this.requestAnimationFrame();
|
||||
return;
|
||||
}
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (animation.completed) return;
|
||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
||||
if (animation.startedAt == null) {
|
||||
hasActive = true;
|
||||
return;
|
||||
}
|
||||
const lastTiming = animation.wordTimings.at(-1);
|
||||
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
||||
if (currentNow - animation.startedAt >= total + 50) {
|
||||
animation.completed = true;
|
||||
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
||||
} else {
|
||||
hasActive = true;
|
||||
}
|
||||
});
|
||||
if (hasActive) this.requestAnimationFrame();
|
||||
}
|
||||
|
||||
publishSpread(sides = null, options = {}) {
|
||||
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||
const phase = this.getDrawPhase(options);
|
||||
|
||||
Reference in New Issue
Block a user