Stabilize WebGL title and timeline texture flow

This commit is contained in:
2026-06-17 08:31:46 +02:00
parent ef358c5cfd
commit c19ebe3089
5 changed files with 211 additions and 76 deletions
+48 -8
View File
@@ -39,6 +39,7 @@ class BookPlaybackTimelineModule extends BaseModule {
'waitForVisualCompletion', 'waitForVisualCompletion',
'waitForRevealCommit', 'waitForRevealCommit',
'requestPageFlip', 'requestPageFlip',
'prepareFlipPlan',
'waitForPageFlipFinished', 'waitForPageFlipFinished',
'prewarmSegmentTextures', 'prewarmSegmentTextures',
'getPageMetaForIndex', 'getPageMetaForIndex',
@@ -361,6 +362,11 @@ class BookPlaybackTimelineModule extends BaseModule {
const startedAt = Number(segment.revealStartedAt) const startedAt = Number(segment.revealStartedAt)
|| await (segment.revealStartedPromise || Promise.resolve(performance.now())); || await (segment.revealStartedPromise || Promise.resolve(performance.now()));
const duration = this.getRightRevealDurationMs(segment); const duration = this.getRightRevealDurationMs(segment);
segment.plannedRightRevealDurationMs = duration;
this.recordDiagnostic('wait-right-reveal-planned', {
...segment,
plannedRightRevealDurationMs: duration
});
const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now())); const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now()));
const remaining = Math.max(0, duration - elapsed); const remaining = Math.max(0, duration - elapsed);
const planned = new Promise(resolve => { const planned = new Promise(resolve => {
@@ -413,13 +419,7 @@ class BookPlaybackTimelineModule extends BaseModule {
async requestPageFlip(direction = 1, options = {}) { async requestPageFlip(direction = 1, options = {}) {
if (this.isChoiceAwaitingPlayer()) return false; if (this.isChoiceAwaitingPlayer()) return false;
await this.pageCache?.prewarmNavigationWindow?.({ const flipPlan = await this.prepareFlipPlan(direction, options);
currentSpread: this.getVisibleSpreadIndex(),
targetSpread: options.targetSpread,
endSpread: Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1),
getPageMetaForIndex: this.getPageMetaForIndex,
recordMiss: false
});
await this.assertSegmentReady({ await this.assertSegmentReady({
blockId: options.blockId ?? null, blockId: options.blockId ?? null,
targetSpreadIndex: options.targetSpread, targetSpreadIndex: options.targetSpread,
@@ -432,11 +432,48 @@ class BookPlaybackTimelineModule extends BaseModule {
window.BookLabDebug.requestPageFlip(direction, { window.BookLabDebug.requestPageFlip(direction, {
force: options.force === true, force: options.force === true,
reason: options.reason || 'timeline', reason: options.reason || 'timeline',
targetSpread: options.targetSpread targetSpread: options.targetSpread,
prewarm: flipPlan.prewarm,
flipPlan
}); });
return wait; return wait;
} }
async prepareFlipPlan(direction = 1, options = {}) {
const currentSpread = this.getVisibleSpreadIndex();
const targetSpread = Number.isFinite(Number(options.targetSpread))
? Math.max(0, Math.round(Number(options.targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
const prewarm = await this.pageCache?.prewarmNavigationWindow?.({
currentSpread,
targetSpread,
endSpread: Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1),
getPageMetaForIndex: this.getPageMetaForIndex,
recordMiss: false
});
const sourceSide = direction > 0 ? 'right' : 'left';
const backSide = direction > 0 ? 'left' : 'right';
const sourcePageIndex = currentSpread * 2 + (sourceSide === 'right' ? 1 : 0);
const backPageIndex = targetSpread * 2 + (backSide === 'right' ? 1 : 0);
const plan = {
direction,
currentSpread,
targetSpread,
sourceSide,
backSide,
sourcePageMeta: this.getPageMetaForIndex(sourcePageIndex),
backPageMeta: this.getPageMetaForIndex(backPageIndex),
prewarm,
createdAt: performance.now()
};
this.markBenchmark('flip-plan-ready', plan);
this.recordDiagnostic('flip-plan-ready', {
...plan,
targetSpreadIndex: targetSpread
});
return plan;
}
async prewarmSegmentTextures(segment = {}) { async prewarmSegmentTextures(segment = {}) {
if (!this.pageCache || typeof this.pageCache.prewarmNavigationWindow !== 'function') return null; if (!this.pageCache || typeof this.pageCache.prewarmNavigationWindow !== 'function') return null;
const targetSpread = Math.max(0, Number(segment.targetSpreadIndex || 0)); const targetSpread = Math.max(0, Number(segment.targetSpreadIndex || 0));
@@ -604,6 +641,9 @@ class BookPlaybackTimelineModule extends BaseModule {
spreadIndex: segment.targetSpreadIndex ?? null, spreadIndex: segment.targetSpreadIndex ?? null,
status: segment.status || null, status: segment.status || null,
revealSides: Array.isArray(segment.revealSides) ? segment.revealSides : [], revealSides: Array.isArray(segment.revealSides) ? segment.revealSides : [],
plannedRightRevealDurationMs: Number.isFinite(Number(segment.plannedRightRevealDurationMs))
? Math.round(Number(segment.plannedRightRevealDurationMs))
: undefined,
at: Math.round(performance.now()) at: Math.round(performance.now())
}); });
while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift(); while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift();
+50 -27
View File
@@ -7,10 +7,11 @@ import { BaseModule } from './base-module.js';
class BookTextureRendererModule extends BaseModule { class BookTextureRendererModule extends BaseModule {
constructor() { constructor() {
super('book-texture-renderer', 'Book Texture Renderer'); super('book-texture-renderer', 'Book Texture Renderer');
this.dependencies = ['book-page-format', 'book-pagination', 'localization', 'webgl-page-cache']; this.dependencies = ['book-page-format', 'book-pagination', 'localization', 'game-config', 'webgl-page-cache'];
this.pageFormat = null; this.pageFormat = null;
this.pagination = null; this.pagination = null;
this.localization = null; this.localization = null;
this.gameConfig = null;
this.pageCache = null; this.pageCache = null;
this.metrics = null; this.metrics = null;
this.canvases = { this.canvases = {
@@ -103,6 +104,7 @@ class BookTextureRendererModule extends BaseModule {
this.pageFormat = this.getModule('book-page-format'); this.pageFormat = this.getModule('book-page-format');
this.pagination = this.getModule('book-pagination'); this.pagination = this.getModule('book-pagination');
this.localization = this.getModule('localization'); this.localization = this.getModule('localization');
this.gameConfig = this.getModule('game-config');
this.pageCache = this.getModule('webgl-page-cache'); this.pageCache = this.getModule('webgl-page-cache');
window.BookTextureRendererDebug = { window.BookTextureRendererDebug = {
pipelineTimings: this.pipelineTimings pipelineTimings: this.pipelineTimings
@@ -119,6 +121,7 @@ class BookTextureRendererModule extends BaseModule {
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
const visibility = event.detail?.visibility || 'current'; const visibility = event.detail?.visibility || 'current';
this.currentSpread = spread || { left: [], right: [] }; this.currentSpread = spread || { left: [], right: [] };
const timelineOwnsPlayback = window.BookPlaybackTimeline?.ownsPageFlipCommit === true;
if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) { if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) {
this.markPipelineTiming('spreadUpdate:skip-during-flip', { this.markPipelineTiming('spreadUpdate:skip-during-flip', {
spreadIndex, spreadIndex,
@@ -129,8 +132,19 @@ class BookTextureRendererModule extends BaseModule {
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId); this.markPendingReveal(latestBlockId);
const id = String(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)) { if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right']); this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right'], {
phase: 'prepare',
publishEvent: !timelineOwnsPlayback
});
return; return;
} }
if (this.activeAnimations.has(id)) { if (this.activeAnimations.has(id)) {
@@ -145,6 +159,15 @@ class BookTextureRendererModule extends BaseModule {
} }
return; return;
} }
if (timelineOwnsPlayback && visibility !== 'future-ready' && latestBlockId) {
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-commit', {
spreadIndex,
latestBlockId,
latestRenderedBlockId,
visibility
});
return;
}
this.drawSpread(this.currentSpread); this.drawSpread(this.currentSpread);
}); });
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
@@ -230,7 +253,7 @@ class BookTextureRendererModule extends BaseModule {
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0; const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const phase = this.getDrawPhase(options); const phase = this.getDrawPhase(options);
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw); const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
if (phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) { if (options.force !== true && phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) {
const now = performance.now(); const now = performance.now();
if (now - this.lastDrawSkipLoggedAt > 1000) { if (now - this.lastDrawSkipLoggedAt > 1000) {
this.lastDrawSkipLoggedAt = now; this.lastDrawSkipLoggedAt = now;
@@ -326,11 +349,17 @@ class BookTextureRendererModule extends BaseModule {
const ctx = this.contexts[side]; const ctx = this.contexts[side];
if (!ctx || !this.metrics) return; if (!ctx || !this.metrics) return;
const content = this.getPageContent(side); const content = this.getPageContent(side);
const titleText = document.getElementById('game_title')?.textContent?.trim() || ''; const metadata = this.gameConfig?.getMetadata?.() || {};
const authorText = document.getElementById('game_author')?.textContent?.trim() || ''; const titleText = document.getElementById('game_title')?.textContent?.trim() || metadata.title || '';
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || ''; const authorText = document.getElementById('game_author')?.textContent?.trim()
|| (metadata.author ? this.localization?.t?.('title.byAuthor', { author: metadata.author }) : '')
|| '';
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '';
const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || ''; const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '';
const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || ''; const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || [
metadata.version ? this.localization?.t?.('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean).join(' | ');
const centerX = content.x + content.width * 0.5; const centerX = content.x + content.width * 0.5;
const font = this.metrics.typography.fontFamily; const font = this.metrics.typography.fontFamily;
@@ -658,7 +687,8 @@ class BookTextureRendererModule extends BaseModule {
blockId: region.blockId, blockId: region.blockId,
lineIndex: region.lineIndex, lineIndex: region.lineIndex,
rect: region.rect, rect: region.rect,
timing: region.timing timing: region.timing,
timingArea: region.timingArea || region.area || 0
})), })),
bounds: { bounds: {
x: bounds.x / this.metrics.width, x: bounds.x / this.metrics.width,
@@ -707,7 +737,7 @@ class BookTextureRendererModule extends BaseModule {
} }
assignRevealTiming(blockRegions = [], animation = {}) { assignRevealTiming(blockRegions = [], animation = {}) {
const totalDuration = Math.max( const requestedTotalDuration = Math.max(
Number(animation.totalDuration || 0), Number(animation.totalDuration || 0),
...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))) ...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
); );
@@ -723,24 +753,15 @@ class BookTextureRendererModule extends BaseModule {
const textRegions = sortedRegions.filter(region => !(region.fixedDurationMs > 0)); const textRegions = sortedRegions.filter(region => !(region.fixedDurationMs > 0));
const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0); const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0);
let fallbackDelay = 0; let fallbackDelay = 0;
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0); const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.timingArea || region.area), 0);
const wordTimings = Array.isArray(animation.wordTimings) ? animation.wordTimings : []; const lineHeight = Math.max(1, Number(this.metrics?.typographyLineHeightPx || 1));
const canUseLineWordSpans = wordTimings.length > 0 const estimatedTextWidth = totalArea / lineHeight;
&& textRegions.every(region => Number.isFinite(Number(region.blockWordStart)) && Number(region.blockWordCount) > 0); const totalDuration = requestedTotalDuration > 1
? requestedTotalDuration
if (canUseLineWordSpans) { : Math.max(800, estimatedTextWidth * 16);
textRegions.forEach((region) => {
const timing = this.getLineTimingFromWords(region, wordTimings);
timedRegions.push({
...region,
timing
});
fallbackDelay = Math.max(fallbackDelay, timing.delay + timing.duration);
});
} else {
textRegions.forEach((region) => { textRegions.forEach((region) => {
const duration = totalArea > 0 const duration = totalArea > 0
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea)) ? Math.max(1, totalDuration * (Math.max(1, region.timingArea || region.area) / totalArea))
: Math.max(1, totalDuration / Math.max(1, textRegions.length)); : Math.max(1, totalDuration / Math.max(1, textRegions.length));
timedRegions.push({ timedRegions.push({
...region, ...region,
@@ -748,7 +769,6 @@ class BookTextureRendererModule extends BaseModule {
}); });
fallbackDelay += duration; fallbackDelay += duration;
}); });
}
fixedRegions.forEach((region) => { fixedRegions.forEach((region) => {
timedRegions.push({ timedRegions.push({
@@ -813,6 +833,8 @@ class BookTextureRendererModule extends BaseModule {
const bottom = Math.min(this.metrics.height, y + height + padding); const bottom = Math.min(this.metrics.height, y + height + padding);
const rectWidth = Math.max(1, right - left); const rectWidth = Math.max(1, right - left);
const rectHeight = Math.max(1, bottom - top); const rectHeight = Math.max(1, bottom - top);
const timingWidth = Math.max(1, Number(lineRecord.timingWidthPx || width || rectWidth));
const timingHeight = Math.max(1, Number(lineRecord.timingHeightPx || height || rectHeight));
return { return {
side, side,
spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)), spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)),
@@ -822,6 +844,7 @@ class BookTextureRendererModule extends BaseModule {
blockWordCount: Number(lineRecord.lineWordCount ?? 0), blockWordCount: Number(lineRecord.lineWordCount ?? 0),
fixedDurationMs, fixedDurationMs,
area: rectWidth * rectHeight, area: rectWidth * rectHeight,
timingArea: timingWidth * timingHeight,
pixelRect: { x: left, y: top, right, bottom }, pixelRect: { x: left, y: top, right, bottom },
rect: { rect: {
x: left / this.metrics.width, x: left / this.metrics.width,
@@ -975,7 +998,7 @@ class BookTextureRendererModule extends BaseModule {
phase, phase,
publishEvent: options.publishEvent !== false publishEvent: options.publishEvent !== false
}); });
if (phase !== 'prepare') this.preloadAdditionalRevealSpreads(id, spread); this.preloadAdditionalRevealSpreads(id, spread);
if (phase === 'prepare' && published) { if (phase === 'prepare' && published) {
this.pageCache?.rememberPreparedRevealPlan?.(id, { this.pageCache?.rememberPreparedRevealPlan?.(id, {
...published, ...published,
+86 -21
View File
@@ -40,7 +40,7 @@ renderer.shadowMap.type = THREE.VSMShadowMap;
const generatedTextureCanvases = {}; const generatedTextureCanvases = {};
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
const reflectionPixelRatio = 1; const reflectionPixelRatio = 0.72;
const pageTextureWidth = 3072; const pageTextureWidth = 3072;
const reflectionTargetSize = new THREE.Vector2(); const reflectionTargetSize = new THREE.Vector2();
const pageRaycaster = new THREE.Raycaster(); const pageRaycaster = new THREE.Raycaster();
@@ -80,13 +80,13 @@ let tableDustTexture = null;
let tableGreaseTexture = null; let tableGreaseTexture = null;
const tableTopY = -0.02; const tableTopY = -0.02;
const bookTableContactClearance = 0.002; const bookTableContactClearance = 0.002;
const tableReflectionBaseWidth = 2048; const tableReflectionBaseWidth = 1536;
const tableReflectionBaseHeight = 1152; const tableReflectionBaseHeight = 864;
const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, { const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, {
colorSpace: THREE.SRGBColorSpace, colorSpace: THREE.SRGBColorSpace,
depthBuffer: true, depthBuffer: true,
stencilBuffer: false, stencilBuffer: false,
samples: renderer.capabilities.isWebGL2 ? 8 : 0 samples: renderer.capabilities.isWebGL2 ? 4 : 0
}); });
tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace; tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace;
tableReflectionTarget.texture.minFilter = THREE.LinearFilter; tableReflectionTarget.texture.minFilter = THREE.LinearFilter;
@@ -105,7 +105,7 @@ const reflectionUp = new THREE.Vector3();
const candleShadowSources = []; const candleShadowSources = [];
const candleWorldPosition = new THREE.Vector3(); const candleWorldPosition = new THREE.Vector3();
const flameWorldPosition = new THREE.Vector3(); const flameWorldPosition = new THREE.Vector3();
const bookShadowMapSize = 1536; const bookShadowMapSize = 1024;
const bookShadowTargets = Array.from({ length: 3 }, () => { const bookShadowTargets = Array.from({ length: 3 }, () => {
const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, { const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, {
colorSpace: THREE.NoColorSpace, colorSpace: THREE.NoColorSpace,
@@ -126,6 +126,10 @@ const bookShadowBiasMatrix = new THREE.Matrix4().set(
0, 0, 0.5, 0.5, 0, 0, 0.5, 0.5,
0, 0, 0, 1 0, 0, 0, 1
); );
const dynamicBufferRefreshIntervalMs = 1000 / 30;
const flipDynamicBufferGraceMs = 180;
let lastBookShadowRefreshAt = -Infinity;
let lastTableReflectionRefreshAt = -Infinity;
const bookShadowDepthMaterial = new THREE.MeshDepthMaterial({ const bookShadowDepthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking depthPacking: THREE.RGBADepthPacking
}); });
@@ -158,6 +162,7 @@ configureScenePostprocessing();
const clock = new THREE.Clock(); const clock = new THREE.Clock();
const targetFrameDurationMs = 1000 / 60; const targetFrameDurationMs = 1000 / 60;
const minRenderFrameIntervalMs = targetFrameDurationMs * 0.5;
let lastRenderFrameAt = 0; let lastRenderFrameAt = 0;
let fpsDisplay = null; let fpsDisplay = null;
let fpsWindowStartedAt = performance.now(); let fpsWindowStartedAt = performance.now();
@@ -626,7 +631,8 @@ window.BookLabDebug = {
preparedPageTextureCount: textureStoreState.preparedTextureCount || 0, preparedPageTextureCount: textureStoreState.preparedTextureCount || 0,
singlePageTextureStore: true, singlePageTextureStore: true,
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface, flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
mirrorRefreshesEveryFrame: true, mirrorRefreshesAtFps: Math.round(1000 / dynamicBufferRefreshIntervalMs),
mirrorDefersDuringFlipStartMs: flipDynamicBufferGraceMs,
mirrorRefreshesWhenStaticDirty: true, mirrorRefreshesWhenStaticDirty: true,
lastFlipTexturePreflight lastFlipTexturePreflight
}; };
@@ -668,6 +674,30 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0)); const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0)); const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0)); const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if (
window.BookPlaybackTimeline?.ownsPageFlipCommit === true
&& detail.visibility !== 'future-ready'
&& latestBlockId > 0
) {
markPageTextureTiming('spreadUpdate:timeline-owned-state-only', {
incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId,
latestRenderedBlockId
});
bookPaginationState = {
...bookPaginationState,
spreadCount: Math.max(1, Number(detail.spreadCount || bookPaginationState.spreadCount || 1)),
writtenPageLimit: Math.max(
Math.max(0, Number(bookPaginationState.writtenPageLimit || 0)),
Math.max(0, Number(detail.writtenPageLimit || 0))
)
};
growBookIfWritableLimitReached();
syncBookControls();
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
return;
}
if ( if (
latestBlockId > latestRenderedBlockId latestBlockId > latestRenderedBlockId
&& detail.visibility !== 'future-ready' && detail.visibility !== 'future-ready'
@@ -2464,6 +2494,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
visualElapsedMs: activeStartedAt ? Math.max(0, performance.now() - activeStartedAt) : 0, visualElapsedMs: activeStartedAt ? Math.max(0, performance.now() - activeStartedAt) : 0,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: revealBlockIds, blockIds: revealBlockIds,
pageMeta: revealDetail.pageMeta ? { ...revealDetail.pageMeta } : null,
baseTexture, baseTexture,
pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true, pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true,
fastForwarding: false, fastForwarding: false,
@@ -2602,6 +2633,14 @@ function syncFlipRevealShaderFromSource(sourceSide, targetMaterial = materials.f
return true; return true;
} }
function revealStateMatchesPage(side, pageMeta = null) {
const statePageIndex = Number(pageRevealState[side]?.pageMeta?.pageIndex);
const expectedPageIndex = Number(pageMeta?.pageIndex);
return Number.isFinite(statePageIndex)
&& Number.isFinite(expectedPageIndex)
&& Math.max(0, Math.round(statePageIndex)) === Math.max(0, Math.round(expectedPageIndex));
}
function getRevealDebugState() { function getRevealDebugState() {
return ['left', 'right'].reduce((state, side) => { return ['left', 'right'].reduce((state, side) => {
const shader = getPageRevealShader(side); const shader = getPageRevealShader(side);
@@ -2622,7 +2661,7 @@ function getRevealDebugState() {
}, {}); }, {});
} }
function clearPageReveal(side, reason = 'clear') { function clearPageReveal(side, reason = 'clear', options = {}) {
const previousState = pageRevealState[side]; const previousState = pageRevealState[side];
pageRevealClearLog.push({ pageRevealClearLog.push({
side, side,
@@ -2646,7 +2685,7 @@ function clearPageReveal(side, reason = 'clear') {
shader.uniforms.bookRevealRegionCount.value = 0; shader.uniforms.bookRevealRegionCount.value = 0;
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0; if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0;
} }
previousState?.baseTexture?.dispose?.(); if (options.preserveBaseTexture !== true) previousState?.baseTexture?.dispose?.();
} }
function startPageRevealForBlock(blockId) { function startPageRevealForBlock(blockId) {
@@ -2885,7 +2924,7 @@ async function startPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel) return false; if (activeFlips.length || !currentProceduralBookModel) return false;
if (!options.force && !canPageFlip(direction)) 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; const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
const prewarm = await prewarmFlipTextures(direction, targetSpread); const prewarm = options.prewarm || options.flipPlan?.prewarm || await prewarmFlipTextures(direction, targetSpread);
return startPageFlipPrepared(direction, { return startPageFlipPrepared(direction, {
...options, ...options,
targetSpread, targetSpread,
@@ -2922,7 +2961,7 @@ function startPageFlipPrepared(direction, options = {}) {
async function startFastPageFlip(direction, options = {}) { async function startFastPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
const prewarm = await prewarmFlipTextures(direction, targetSpread); const prewarm = options.prewarm || options.flipPlan?.prewarm || await prewarmFlipTextures(direction, targetSpread);
return startFastPageFlipPrepared(direction, { return startFastPageFlipPrepared(direction, {
...options, ...options,
targetSpread, targetSpread,
@@ -3040,14 +3079,16 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
} }
materials.flipPageSurface.map = sourceTexture; materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture(); materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
materials.flipPageSurface.userData.sourceRevealSide = pageRevealState[sourceSide] ? sourceSide : null; materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null;
materials.flipPageBackSurface.userData.sourceRevealSide = null; materials.flipPageBackSurface.userData.sourceRevealSide = revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageSurface.needsUpdate = true; materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true; materials.flipPageBackSurface.needsUpdate = true;
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface);
flip.sourceTexture = sourceTexture; flip.sourceTexture = sourceTexture;
flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null; flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null;
flip.backTexture = backTexture || getBlankPageTexture(); flip.backTexture = backTexture || getBlankPageTexture();
@@ -3070,14 +3111,14 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
if (flip.direction > 0) { if (flip.direction > 0) {
const blankTexture = getBlankPageTexture(); const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) { if (blankTexture && materials.rightPage.map !== blankTexture) {
clearPageReveal('right', 'page-flip-start'); clearPageReveal('right', 'page-flip-start', { preserveBaseTexture: sourceSide === 'right' });
materials.rightPage.map = blankTexture; materials.rightPage.map = blankTexture;
materials.rightPage.needsUpdate = true; materials.rightPage.needsUpdate = true;
} }
} else if (flip.direction < 0) { } else if (flip.direction < 0) {
const blankTexture = getBlankPageTexture(); const blankTexture = getBlankPageTexture();
if (blankTexture && materials.leftPage.map !== blankTexture) { if (blankTexture && materials.leftPage.map !== blankTexture) {
clearPageReveal('left', 'page-flip-start'); clearPageReveal('left', 'page-flip-start', { preserveBaseTexture: sourceSide === 'left' });
materials.leftPage.map = blankTexture; materials.leftPage.map = blankTexture;
materials.leftPage.needsUpdate = true; materials.leftPage.needsUpdate = true;
} }
@@ -3086,7 +3127,6 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
...lastFlipTexturePreflight, ...lastFlipTexturePreflight,
usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture()) usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture())
}); });
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
return true; return true;
} }
@@ -3094,7 +3134,7 @@ function resolveCurrentFlipSourceTexture(side) {
const pageMeta = currentPageMeta?.[side] || null; const pageMeta = currentPageMeta?.[side] || null;
if (pageMeta?.kind === 'blank') return getBlankPageTexture(); if (pageMeta?.kind === 'blank') return getBlankPageTexture();
const material = side === 'left' ? materials.leftPage : materials.rightPage; const material = side === 'left' ? materials.leftPage : materials.rightPage;
if (pageRevealState[side]) return material?.map || null; if (revealStateMatchesPage(side, pageMeta)) return material?.map || null;
const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta); const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
if (resident) return resident; if (resident) return resident;
return material?.map || null; return material?.map || null;
@@ -3222,7 +3262,8 @@ function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread') {
return; return;
} }
const material = side === 'left' ? materials.leftPage : materials.rightPage; const material = side === 'left' ? materials.leftPage : materials.rightPage;
clearPageReveal(side, reason); const activeRevealForPage = revealStateMatchesPage(side, pageMeta);
if (!activeRevealForPage) clearPageReveal(side, reason);
if (material.map !== texture) { if (material.map !== texture) {
material.map = texture; material.map = texture;
material.needsUpdate = true; material.needsUpdate = true;
@@ -3385,8 +3426,8 @@ function createFlippingPageGeometry(surface, direction = 1) {
const targetSide = -sourceSide; const targetSide = -sourceSide;
const topPageSide = direction > 0 ? targetSide : sourceSide; const topPageSide = direction > 0 ? targetSide : sourceSide;
const bottomPageSide = direction > 0 ? sourceSide : targetSide; const bottomPageSide = direction > 0 ? sourceSide : targetSide;
const topMaterialIndex = direction > 0 ? 1 : 0; const topMaterialIndex = 0;
const bottomMaterialIndex = direction > 0 ? 0 : 1; const bottomMaterialIndex = 1;
const push = (point, yOffset, uv) => { const push = (point, yOffset, uv) => {
const index = positions.length / 3; const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z); positions.push(point.x, point.y + yOffset, point.z);
@@ -4553,7 +4594,7 @@ function renderMirrorDebugView() {
function animate(now = performance.now()) { function animate(now = performance.now()) {
const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs; const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs;
if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) { if (lastRenderFrameAt && elapsedSinceLastFrame < minRenderFrameIntervalMs) {
requestAnimationFrame(animate); requestAnimationFrame(animate);
return; return;
} }
@@ -4603,12 +4644,36 @@ function animate(now = performance.now()) {
updateCandleShadowUniforms(); updateCandleShadowUniforms();
lastFrameTiming.update = performance.now() - updateStartedAt; lastFrameTiming.update = performance.now() - updateStartedAt;
renderedFrameCount += 1; renderedFrameCount += 1;
const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0;
const shadowStartedAt = performance.now(); const shadowStartedAt = performance.now();
const forceDynamicBufferRefresh = staticSceneBuffersDirty && activeFlips.length === 0;
const newestFlipAge = activeFlips.length
? Math.min(...activeFlips.map(flip => Math.max(0, now - Number(flip.startTime || now))))
: Infinity;
const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs;
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= dynamicBufferRefreshIntervalMs
);
const reflectionRefreshDue = !deferDynamicBuffersForFlipStart && (
forceDynamicBufferRefresh || now - lastTableReflectionRefreshAt >= dynamicBufferRefreshIntervalMs
);
const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue && !forceDynamicBufferRefresh;
const refreshShadowsThisFrame = shadowRefreshDue && (
!bothHeavyPassesDue || lastBookShadowRefreshAt <= lastTableReflectionRefreshAt
);
const refreshReflectionThisFrame = reflectionRefreshDue && (
!bothHeavyPassesDue || !refreshShadowsThisFrame
);
if (refreshShadowsThisFrame) {
updateBookShadowMaps(); updateBookShadowMaps();
lastBookShadowRefreshAt = now;
}
lastFrameTiming.shadows = performance.now() - shadowStartedAt; lastFrameTiming.shadows = performance.now() - shadowStartedAt;
const reflectionStartedAt = performance.now(); const reflectionStartedAt = performance.now();
const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0; if (refreshReflectionThisFrame) {
updateTableReflection(); updateTableReflection();
lastTableReflectionRefreshAt = now;
}
lastFrameTiming.reflection = performance.now() - reflectionStartedAt; lastFrameTiming.reflection = performance.now() - reflectionStartedAt;
const renderStartedAt = performance.now(); const renderStartedAt = performance.now();
if (tableDebugMode === tableDebugModes.mirror) { if (tableDebugMode === tableDebugModes.mirror) {
+7 -1
View File
@@ -11,7 +11,7 @@ const DEFAULT_PAGE_RESERVE = 50;
class WebGLBookSceneModule extends BaseModule { class WebGLBookSceneModule extends BaseModule {
constructor() { constructor() {
super('webgl-book-scene', 'WebGL Book Scene'); super('webgl-book-scene', 'WebGL Book Scene');
this.dependencies = ['persistence-manager', 'localization', 'game-config', 'book-texture-renderer']; this.dependencies = ['persistence-manager', 'localization', 'game-config', 'book-pagination', 'book-texture-renderer'];
this.persistenceManager = null; this.persistenceManager = null;
this.localization = null; this.localization = null;
this.gameConfig = null; this.gameConfig = null;
@@ -365,7 +365,13 @@ class WebGLBookSceneModule extends BaseModule {
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`); this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`);
await this.labImportPromise; await this.labImportPromise;
this.reportProgress(94, 'Uploading initial book page textures'); this.reportProgress(94, 'Uploading initial book page textures');
const pagination = this.getModule('book-pagination');
const initialSpread = pagination?.getCurrentSpread?.();
if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') {
window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true });
} else {
window.BookTextureRenderer?.publishSpread?.(); window.BookTextureRenderer?.publishSpread?.();
}
await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => requestAnimationFrame(resolve));
this.reportProgress(96, 'Binding WebGL page controls'); this.reportProgress(96, 'Binding WebGL page controls');
this.installTextureEventBridge(); this.installTextureEventBridge();
+9 -8
View File
@@ -165,7 +165,7 @@ const checks = [
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)], ['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)], ['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)], ['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)],
['webgl reveal line timings use global area timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.area\) \/ totalArea\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)], ['webgl reveal line timings use global area timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)],
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)], ['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)], ['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)], ['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
@@ -190,7 +190,7 @@ const checks = [
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)], ['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)], ['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)], ['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)], ['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = 0/.test(source) && /const bottomMaterialIndex = 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)],
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture \|\| getBlankPageTexture\(\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)], ['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture \|\| getBlankPageTexture\(\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)], ['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)], ['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
@@ -198,9 +198,9 @@ const checks = [
['webgl flip geometry samples the same visible page plane as the static stack', /normalizeFlipLineToVisiblePage/.test(source) && /currentProceduralBookModel\.spineHalf/.test(source) && /const pageStartX = side \* Math\.max/.test(source) && /normalizeFlipLineToVisiblePage\(topVisibleLine\(sourceSide\), sourceSide\)/.test(source)], ['webgl flip geometry samples the same visible page plane as the static stack', /normalizeFlipLineToVisiblePage/.test(source) && /currentProceduralBookModel\.spineHalf/.test(source) && /const pageStartX = side \* Math\.max/.test(source) && /normalizeFlipLineToVisiblePage\(topVisibleLine\(sourceSide\), sourceSide\)/.test(source)],
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)], ['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))], ['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
['webgl scene targets 60fps with browser-frame scheduling live mirror and static heavy refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source) && !/setTimeout\(animate/.test(source)], ['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 1/.test(source) && /const tableReflectionBaseWidth = 2048/.test(source) && /const tableReflectionBaseHeight = 1152/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)], ['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesEveryFrame/.test(source)], ['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)], ['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)],
['webgl page navigation is page-count based with explicit spread mapping', /function pageToSpreadIndex/.test(source) && /Math\.floor\(page \/ 2\) \+ 1/.test(source) && /function spreadIndexToPagePosition/.test(source) && /\(spread - 1\) \* 2/.test(source)], ['webgl page navigation is page-count based with explicit spread mapping', /function pageToSpreadIndex/.test(source) && /Math\.floor\(page \/ 2\) \+ 1/.test(source) && /function spreadIndexToPagePosition/.test(source) && /\(spread - 1\) \* 2/.test(source)],
['webgl reading progress sync does not rebuild pagination as a page-count change', /function syncReadingProgressToCurrentPage/.test(source) && !/notifyBookPageCountChanged/.test(methodBody(source, 'syncReadingProgressToCurrentPage'))], ['webgl reading progress sync does not rebuild pagination as a page-count change', /function syncReadingProgressToCurrentPage/.test(source) && !/notifyBookPageCountChanged/.test(methodBody(source, 'syncReadingProgressToCurrentPage'))],
@@ -212,12 +212,13 @@ const checks = [
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))], ['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
['webgl right-page reveal records arm a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /pageFlipAfterReveal/.test(textureRendererSource) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)], ['webgl right-page reveal records arm a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /pageFlipAfterReveal/.test(textureRendererSource) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)],
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)], ['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
['webgl line reveal timing uses TTS word spans instead of stretching split page fragments', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /getLineTimingFromWords/.test(textureRendererSource) && /const canUseLineWordSpans/.test(textureRendererSource)], ['webgl line reveal timing uses area-weighted regions instead of word-span timing', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)],
['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)], ['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)],
['webgl ordinary flip near-end uses resident target textures instead of renderer redraw', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end'\)/.test(source) && /function applyResidentSpreadTextures/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:skip-during-flip/.test(textureRendererSource)], ['webgl ordinary flip near-end uses resident target textures instead of renderer redraw', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end'\)/.test(source) && /function applyResidentSpreadTextures/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:skip-during-flip/.test(textureRendererSource)],
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(pageRevealState\[side\]\) return material\?\.map \|\| null/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide = pageRevealState\[sourceSide\] \? sourceSide : null/.test(source)], ['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
['webgl flipping page materials mirror active reveal shader uniforms', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source)], ['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)], ['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.pendingRevealBlockIds\.delete\(id\)/.test(textureRendererSource)], ['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.pendingRevealBlockIds\.delete\(id\)/.test(textureRendererSource)],
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)], ['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
['webgl playback coordinator rejects placeholder zero-duration reveal timings', /timingDuration/.test(playbackCoordinatorSource) && /ttsDuration/.test(playbackCoordinatorSource) && /timingDuration <= 0 && ttsDuration > 0/.test(playbackCoordinatorSource) && /sentence\.animation = \{[\s\S]*wordTimings,[\s\S]*totalDuration: calculated\.totalDuration/.test(playbackCoordinatorSource)], ['webgl playback coordinator rejects placeholder zero-duration reveal timings', /timingDuration/.test(playbackCoordinatorSource) && /ttsDuration/.test(playbackCoordinatorSource) && /timingDuration <= 0 && ttsDuration > 0/.test(playbackCoordinatorSource) && /sentence\.animation = \{[\s\S]*wordTimings,[\s\S]*totalDuration: calculated\.totalDuration/.test(playbackCoordinatorSource)],