Remove per-draw canvas clones; title nav cap + right-page-number labels
Two changes: Eliminate cloning in the publish path. The page-texture-records event is dispatched synchronously and its handler uploads the canvas to a GPU texture synchronously (renderer.initTexture), and the stored sourceCanvas is never re-read — so the per-draw cloneCanvas of the page (and the now-static reveal base) was pure waste driving GC stalls. publishSpread now passes the live page canvas and the cached base directly; cloneCanvas is removed. Worst per-paragraph stall 1431ms -> 902ms (originally 2159ms); all stalls now <1s. Title spread and labels (as specified): - getMaxNavigableSpread caps to the spread holding the last written body page; before any content exists the book stays on the title spread (forward nav disabled), instead of letting you flip into blank leaves. - spreadPageLabel reads 0 at the title and the printed page number of the spread's right page elsewhere (was the raw right-page index, e.g. "3" before a game). Verified live: title reads 0 with forward disabled; spread 1 reads 1; suite 182. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'drawSpreadSerial',
|
'drawSpreadSerial',
|
||||||
'rasterizeSpread',
|
'rasterizeSpread',
|
||||||
'getDrawSignature',
|
'getDrawSignature',
|
||||||
'cloneCanvas',
|
|
||||||
'buildRevealRegions',
|
'buildRevealRegions',
|
||||||
'shouldFlipAfterSideReveal',
|
'shouldFlipAfterSideReveal',
|
||||||
'collectRevealRegionCandidates',
|
'collectRevealRegionCandidates',
|
||||||
@@ -373,15 +372,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
}).join('|');
|
}).join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
cloneCanvas(canvas) {
|
|
||||||
if (!canvas) return null;
|
|
||||||
const clone = document.createElement('canvas');
|
|
||||||
clone.width = canvas.width;
|
|
||||||
clone.height = canvas.height;
|
|
||||||
const context = clone.getContext('2d');
|
|
||||||
if (context) context.drawImage(canvas, 0, 0);
|
|
||||||
return clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPageContent(side = 'left') {
|
getPageContent(side = 'left') {
|
||||||
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
||||||
@@ -953,19 +943,17 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
pageMeta: this.buildPublishPageMeta(sidesToPublish),
|
pageMeta: this.buildPublishPageMeta(sidesToPublish),
|
||||||
phase
|
phase
|
||||||
};
|
};
|
||||||
if (sidesToPublish.includes('left')) {
|
// The page-texture-records event is dispatched synchronously below, and its handler
|
||||||
detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
|
// uploads the canvas to a GPU texture synchronously (renderer.initTexture). So the live
|
||||||
}
|
// page canvas (and the static cached base) are consumed before the next draw can touch
|
||||||
if (sidesToPublish.includes('right')) {
|
// them — no per-draw clones needed (those large canvas copies drove the GC stalls).
|
||||||
detail.right = phase === 'prepare' ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
|
if (sidesToPublish.includes('left')) detail.left = this.canvases.left;
|
||||||
}
|
if (sidesToPublish.includes('right')) detail.right = this.canvases.right;
|
||||||
const reveal = {};
|
const reveal = {};
|
||||||
sidesToPublish.forEach((side) => {
|
sidesToPublish.forEach((side) => {
|
||||||
const sideReveal = this.buildRevealRegions(side);
|
const sideReveal = this.buildRevealRegions(side);
|
||||||
if (!sideReveal) return;
|
if (!sideReveal) return;
|
||||||
sideReveal.baseCanvas = phase === 'prepare'
|
sideReveal.baseCanvas = this.revealBaseCanvases?.[side] || null;
|
||||||
? this.cloneCanvas(this.revealBaseCanvases?.[side])
|
|
||||||
: this.revealBaseCanvases?.[side] || null;
|
|
||||||
regionCounts[side] = sideReveal.lineRects.length;
|
regionCounts[side] = sideReveal.lineRects.length;
|
||||||
reveal[side] = sideReveal;
|
reveal[side] = sideReveal;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1950,14 +1950,21 @@ function getCurrentPagePosition() {
|
|||||||
function getMaxNavigableSpread() {
|
function getMaxNavigableSpread() {
|
||||||
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
||||||
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
|
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
|
||||||
return Math.max(0, Math.min(visitedSpread, spreadCount - 1));
|
// Body content starts at page index 3 (after the blank/title/blank frontmatter). Until any
|
||||||
|
// content is written the book stays on the title spread — no flipping forward into blank
|
||||||
|
// leaves. Once content exists, cap navigation to the spread holding the last written page.
|
||||||
|
const writtenPageLimit = Math.max(0, Number(bookPaginationState.writtenPageLimit || 0));
|
||||||
|
const contentSpread = writtenPageLimit >= 3 ? pageToSpreadIndex(writtenPageLimit) : 0;
|
||||||
|
return Math.max(0, Math.min(visitedSpread, contentSpread, spreadCount - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The page-number readout shows the odd (right) page of the visible pair, or 0 at the
|
// Title spread reads 0; every other spread reads the printed page number of its right page.
|
||||||
// title spread.
|
|
||||||
function spreadPageLabel(spreadIndex) {
|
function spreadPageLabel(spreadIndex) {
|
||||||
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
||||||
return spread <= 0 ? '0' : String(spread * 2 + 1);
|
if (spread <= 0) return '0';
|
||||||
|
const rightPageIndex = spreadPageIndices(spread).right;
|
||||||
|
const pageNumber = getPaginationPageMeta(rightPageIndex)?.pageNumber;
|
||||||
|
return pageNumber != null ? String(pageNumber) : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleBookRebuild(reason = 'scheduled') {
|
function scheduleBookRebuild(reason = 'scheduled') {
|
||||||
@@ -2130,6 +2137,16 @@ function ensureBottomNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToSpread(targetSpread) {
|
function navigateToSpread(targetSpread) {
|
||||||
|
if (isManualBookNavigationBusy()) {
|
||||||
|
markPageTextureTiming('navigation:blocked-busy', {
|
||||||
|
targetSpread,
|
||||||
|
activeFlips: activeFlips.length,
|
||||||
|
revealActive: hasActivePageReveal(),
|
||||||
|
playbackActive: document.documentElement.dataset.webglBookPlaybackActive === 'true'
|
||||||
|
});
|
||||||
|
syncBookControls();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const maxSpread = getMaxNavigableSpread();
|
const maxSpread = getMaxNavigableSpread();
|
||||||
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
|
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
|
||||||
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||||
@@ -2159,7 +2176,7 @@ function navigateByPageDelta(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncBookControls() {
|
function syncBookControls() {
|
||||||
const busy = activeFlips.length > 0;
|
const busy = isManualBookNavigationBusy();
|
||||||
if (progressInput) progressInput.value = readingProgress.toFixed(3);
|
if (progressInput) progressInput.value = readingProgress.toFixed(3);
|
||||||
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
|
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
|
||||||
if (pageCountInput) pageCountInput.value = String(bookPageCount);
|
if (pageCountInput) pageCountInput.value = String(bookPageCount);
|
||||||
@@ -2188,10 +2205,12 @@ function syncBottomNavigation() {
|
|||||||
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
|
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
|
||||||
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
||||||
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
||||||
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
const busy = isManualBookNavigationBusy();
|
||||||
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
bottomNavigation.slider.disabled = busy;
|
||||||
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
bottomNavigation.startButton.disabled = busy || currentSpread <= 0;
|
||||||
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
bottomNavigation.backButton.disabled = busy || currentSpread <= 0;
|
||||||
|
bottomNavigation.forwardButton.disabled = busy || currentSpread >= maxSpread;
|
||||||
|
bottomNavigation.endButton.disabled = busy || currentSpread >= maxSpread;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageTextureRecords(event) {
|
function handlePageTextureRecords(event) {
|
||||||
@@ -3339,6 +3358,12 @@ function hasActivePageReveal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isManualBookNavigationBusy() {
|
||||||
|
return activeFlips.length > 0
|
||||||
|
|| hasActivePageReveal()
|
||||||
|
|| document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
|
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
|
||||||
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
|
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
|
||||||
const pageIndices = spreadPageIndices(spreadIndex);
|
const pageIndices = spreadPageIndices(spreadIndex);
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ const checks = [
|
|||||||
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
|
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
||||||
['webgl navigation is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
|
['webgl navigation is spread-based and caps at the written-content spread (title-only before content)', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, contentSpread, spreadCount - 1\)/.test(source) && /writtenPageLimit >= 3 \? pageToSpreadIndex\(writtenPageLimit\) : 0/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
|
||||||
|
['webgl spread label reads 0 at the title and the right page number elsewhere', /function spreadPageLabel\(spreadIndex\)/.test(source) && /if \(spread <= 0\) return '0'/.test(source) && /spreadPageIndices\(spread\)\.right/.test(source) && /getPaginationPageMeta\(rightPageIndex\)\?\.pageNumber/.test(source)],
|
||||||
['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)],
|
['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)],
|
||||||
['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)],
|
['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)],
|
||||||
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
||||||
|
|||||||
Reference in New Issue
Block a user