Stabilize WebGL reveal timing

This commit is contained in:
2026-06-07 16:42:09 +02:00
parent 9695d48368
commit 53c24e4fae
7 changed files with 217 additions and 22 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
* Defines the canonical page geometry used by the WebGL book renderer.
*/
import { BaseModule } from './base-module.js';
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-forced-font-mask';
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-reveal-clock';
export const BOOK_TEXTURE_WIDTH = 3072;
+45
View File
@@ -34,9 +34,11 @@ class BookTextureRendererModule extends BaseModule {
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30;
this.pipelineTimings = [];
this.bindMethods([
'initialize',
'markPipelineTiming',
'waitForTextureFonts',
'ensureTextureFontFace',
'createPageCanvases',
@@ -69,6 +71,9 @@ class BookTextureRendererModule extends BaseModule {
this.pageFormat = this.getModule('book-page-format');
this.pagination = this.getModule('book-pagination');
this.localization = this.getModule('localization');
window.BookTextureRendererDebug = {
pipelineTimings: this.pipelineTimings
};
this.reportProgress(10, 'Waiting for book fonts');
await this.waitForTextureFonts();
this.reportProgress(20, 'Preparing page texture canvases');
@@ -104,6 +109,18 @@ class BookTextureRendererModule extends BaseModule {
return true;
}
markPipelineTiming(name, detail = {}) {
const entry = {
name,
at: performance.now(),
detail
};
this.pipelineTimings.push(entry);
if (this.pipelineTimings.length > 120) this.pipelineTimings.splice(0, this.pipelineTimings.length - 120);
document.documentElement.dataset.webglTexturePipeline = JSON.stringify(this.pipelineTimings);
return entry;
}
async waitForTextureFonts() {
if (!document.fonts) return;
await Promise.all([
@@ -140,6 +157,10 @@ class BookTextureRendererModule extends BaseModule {
drawSpread(spread = null, sides = null) {
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw,
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : []
});
this.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] };
sidesToDraw.forEach((side) => {
@@ -148,6 +169,9 @@ class BookTextureRendererModule extends BaseModule {
this.drawPageLines(side, this.currentSpread?.[side] || []);
});
this.publishSpread(sidesToDraw);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw
});
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
@@ -376,6 +400,10 @@ class BookTextureRendererModule extends BaseModule {
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
const wordTimings = detail.wordTimings;
this.markPipelineTiming('prepareRevealBlock:start', {
blockId: id,
wordTimingCount: wordTimings.length
});
this.activeAnimations.set(id, {
blockId,
wordTimings,
@@ -390,12 +418,20 @@ class BookTextureRendererModule extends BaseModule {
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length
});
}
startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id);
if (!animation) return false;
this.markPipelineTiming('startPreparedRevealAnimation', {
blockId: id,
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
});
animation.startedAt = performance.now();
animation.prepared = false;
animation.completed = false;
@@ -503,6 +539,10 @@ class BookTextureRendererModule extends BaseModule {
publishSpread(sides = null) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const wordCounts = {
left: this.revealWords?.left?.length || 0,
right: this.revealWords?.right?.length || 0
};
const detail = {
metrics: this.metrics,
hitMaps: this.hitMaps
@@ -537,6 +577,11 @@ class BookTextureRendererModule extends BaseModule {
};
});
if (Object.keys(reveal).length) detail.reveal = reveal;
this.markPipelineTiming('publishSpread', {
sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0,
wordCounts
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail
}));
+1 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260607-webgl-forced-font-mask';
const MODULE_CACHE_BUSTER = '20260607-webgl-reveal-clock';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/**
+1 -1
View File
@@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler');
// Module dependencies
this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser'];
this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer'];
// DOM elements
this.container = null;
+109 -9
View File
@@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-forced-font-mask';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-reveal-clock';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -161,6 +161,26 @@ let fpsDisplay = null;
let fpsWindowStartedAt = performance.now();
let fpsWindowFrames = 0;
const lastFrameTiming = {};
const loaderTimings = {};
const pageTextureTimings = [];
function markLoaderTiming(name) {
loaderTimings[name] = performance.now();
document.documentElement.dataset.webglLoaderTimings = JSON.stringify(loaderTimings);
}
function markPageTextureTiming(name, detail = {}) {
const entry = {
name,
at: performance.now(),
detail
};
pageTextureTimings.push(entry);
if (pageTextureTimings.length > 120) pageTextureTimings.splice(0, pageTextureTimings.length - 120);
document.documentElement.dataset.webglPageTextureTimings = JSON.stringify(pageTextureTimings);
return entry;
}
const book = new THREE.Group();
scene.add(book);
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1);
@@ -203,6 +223,7 @@ const pageRevealState = {
left: null,
right: null
};
const pageRevealClearLog = [];
await reportLabStep(52, 'Generating leather texture set');
const leatherTextures = createLeatherTextures();
await reportLabStep(56, 'Generating spine cloth texture set');
@@ -389,6 +410,9 @@ window.BookLabDebug = {
textures: generatedTextureCanvases,
ready: false,
renderedFrames: 0,
loaderTimings,
pageTextureTimings,
pageRevealClearLog,
get sceneAoPass() {
return sceneAoPass;
},
@@ -444,6 +468,9 @@ window.BookLabDebug = {
window.BookTextureRenderer?.publishSpread?.();
return true;
},
getRevealDebugState() {
return getRevealDebugState();
},
getTextureInfo() {
return {
pageTextureWidth,
@@ -1619,6 +1646,11 @@ function syncBookControls() {
function handlePageCanvases(event) {
const detail = event.detail || {};
markPageTextureTiming('handlePageCanvases:start', {
hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {})
});
if (detail.left) {
if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left);
@@ -1639,14 +1671,17 @@ function handlePageCanvases(event) {
height: leftCanvas.height,
source: 'book-texture-renderer'
});
markPageTextureTiming('handlePageCanvases:end');
}
function uploadPageTextureDirect(side, sourceCanvas) {
const canvas = side === 'left' ? leftCanvas : rightCanvas;
const texture = side === 'left' ? leftTexture : rightTexture;
clearPageReveal(side);
markPageTextureTiming('directUpload:start', { side });
clearPageReveal(side, 'direct-upload');
drawCanvasPageTexture(canvas, sourceCanvas, side);
texture.needsUpdate = true;
markPageTextureTiming('directUpload:end', { side });
}
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
@@ -1654,11 +1689,18 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const texture = side === 'left' ? leftTexture : rightTexture;
const shader = getPageRevealShader(side);
markPageTextureTiming('revealUpload:start', {
side,
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0
});
drawCanvasPageTexture(canvas, sourceCanvas, side);
texture.needsUpdate = true;
pageRevealState[side] = {
startedAt: revealDetail.startNow ? performance.now() : null,
pendingStart: false,
lastRevealFrameAt: null,
visualElapsedMs: 0,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
};
@@ -1673,6 +1715,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
shaderReady: Boolean(shader?.uniforms),
started: pageRevealState[side].startedAt != null
});
markPageTextureTiming('revealUpload:end', { side });
}
function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
@@ -1729,7 +1772,39 @@ function getPageRevealShader(side) {
return material?.userData?.bookRevealShader || null;
}
function clearPageReveal(side) {
function getRevealDebugState() {
return ['left', 'right'].reduce((state, side) => {
const shader = getPageRevealShader(side);
const uniforms = shader?.uniforms || {};
state[side] = {
active: Number(uniforms.bookRevealActive?.value || 0),
elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0),
visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0),
wordCount: Number(uniforms.bookRevealWordCount?.value || 0),
started: pageRevealState[side]?.startedAt != null,
pendingStart: pageRevealState[side]?.pendingStart === true,
durationMs: Number(pageRevealState[side]?.durationMs || 0),
blockIds: pageRevealState[side]?.blockIds || []
};
return state;
}, {});
}
function clearPageReveal(side, reason = 'clear') {
pageRevealClearLog.push({
side,
reason,
at: performance.now(),
state: pageRevealState[side] ? {
started: pageRevealState[side].startedAt != null,
pendingStart: pageRevealState[side].pendingStart === true,
visualElapsedMs: pageRevealState[side].visualElapsedMs || 0,
durationMs: pageRevealState[side].durationMs,
blockIds: pageRevealState[side].blockIds || []
} : null
});
if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40);
document.documentElement.dataset.webglRevealClearLog = JSON.stringify(pageRevealClearLog);
pageRevealState[side] = null;
const shader = getPageRevealShader(side);
if (shader?.uniforms?.bookRevealActive) {
@@ -1745,7 +1820,7 @@ function startPageRevealForBlock(blockId) {
const state = pageRevealState[side];
if (!state || state.startedAt != null) return;
if (!state.blockIds.map(value => String(value)).includes(id)) return;
state.startedAt = performance.now();
state.pendingStart = true;
const shader = getPageRevealShader(side);
if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0;
});
@@ -1758,7 +1833,7 @@ function fastForwardPageReveals(blockIds = []) {
if (!state) return;
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
if (!matches) return;
clearPageReveal(side);
clearPageReveal(side, 'fast-forward');
});
}
@@ -1768,18 +1843,29 @@ function updatePageRevealAnimations(now) {
if (!state) return;
const shader = getPageRevealShader(side);
if (!shader?.uniforms) {
clearPageReveal(side);
clearPageReveal(side, 'missing-shader');
return;
}
if (state.pendingStart) {
state.startedAt = now;
state.pendingStart = false;
state.lastRevealFrameAt = now;
state.visualElapsedMs = 0;
shader.uniforms.bookRevealElapsedMs.value = 0;
return;
}
if (state.startedAt == null) {
shader.uniforms.bookRevealElapsedMs.value = 0;
return;
}
const progress = THREE.MathUtils.clamp((now - state.startedAt) / state.durationMs, 0, 1);
shader.uniforms.bookRevealElapsedMs.value = Math.max(0, now - state.startedAt);
const revealFrameDeltaMs = state.lastRevealFrameAt == null ? 0 : Math.max(0, now - state.lastRevealFrameAt);
state.lastRevealFrameAt = now;
state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs);
const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1);
shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs;
if (progress < 1) return;
clearPageReveal(side);
clearPageReveal(side, 'duration-complete');
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
detail: {
side,
@@ -1790,6 +1876,11 @@ function updatePageRevealAnimations(now) {
}
function drawCanvasPageTexture(canvas, sourceCanvas, side) {
markPageTextureTiming('drawCanvasPageTexture:start', {
side,
width: canvas?.width || 0,
height: canvas?.height || 0
});
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f2ead0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -1803,6 +1894,7 @@ function drawCanvasPageTexture(canvas, sourceCanvas, side) {
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
updatePageTextureDebugState(side, canvas, sourceCanvas, true);
markPageTextureTiming('drawCanvasPageTexture:end', { side });
return true;
}
@@ -2858,12 +2950,20 @@ function loadAiRoomReflection() {
}
function primeSceneForLoader() {
markLoaderTiming('primeSceneForLoader:start');
updateCameraRig(0);
updateCandleShadowUniforms();
markLoaderTiming('bookShadowMaps:start');
updateBookShadowMaps();
markLoaderTiming('bookShadowMaps:end');
markLoaderTiming('tableReflection:start');
updateTableReflection();
markLoaderTiming('tableReflection:end');
markLoaderTiming('shaderCompile:start');
renderer.compile(scene, camera);
markLoaderTiming('shaderCompile:end');
staticSceneBuffersDirty = false;
markLoaderTiming('primeSceneForLoader:end');
}
function tintAmbientFromCanvas(canvas) {
-9
View File
@@ -58,12 +58,6 @@ class WebGLBookSceneModule extends BaseModule {
this.addEventListener(document, 'preference-updated', this.handlePreferenceUpdated);
this.addEventListener(document, 'localization:languageChanged', this.updateLocalizedText);
this.addEventListener(document, 'story:turn-start', this.triggerTextureRefresh);
this.addEventListener(document, 'story:turn-complete', this.triggerTextureRefresh);
this.addEventListener(document, 'story:history-updated', this.triggerTextureRefresh);
this.addEventListener(document, 'story:process-state', this.handleProcessState);
this.addEventListener(document, 'input', this.triggerTextureRefresh, true);
this.addEventListener(document, 'change', this.triggerTextureRefresh, true);
if (this.mode !== '3d') {
this.reportProgress(100, '2D book UI selected');
@@ -303,7 +297,6 @@ class WebGLBookSceneModule extends BaseModule {
await new Promise(resolve => requestAnimationFrame(resolve));
this.reportProgress(96, 'Binding WebGL page controls');
this.installTextureEventBridge();
this.triggerTextureRefresh();
return this.labImportPromise;
}
@@ -468,12 +461,10 @@ class WebGLBookSceneModule extends BaseModule {
if (this.mode === '3d') {
this.createLabHost();
this.installPreferenceBridge();
if (this.labImportPromise) this.triggerTextureRefresh();
}
const title = document.getElementById('game_title')?.textContent?.trim();
const label = document.getElementById('lab_title');
if (title && label) label.textContent = title;
this.triggerTextureRefresh();
}
refreshModalOverview() {