Checkpoint WebGL book reveal optimization

This commit is contained in:
2026-06-08 08:19:20 +02:00
parent 7abd3387f3
commit c86a304364
13 changed files with 618 additions and 112 deletions
+4 -4
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-typography-a';
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c';
export const BOOK_TEXTURE_WIDTH = 3072;
@@ -24,9 +24,9 @@ class BookPageFormatModule extends BaseModule {
innerMinIn: 0.48,
innerMaxIn: 0.74,
innerThicknessFactor: 0.32,
outerBaseIn: 0.36,
outerThicknessFactor: 0.02,
outerMaxIn: 0.42
outerBaseIn: 0.27,
outerThicknessFactor: 0.015,
outerMaxIn: 0.315
}),
typography: Object.freeze({
fontFamily: '"EB Garamond", "EB Garamond 12", serif',
+60 -17
View File
@@ -32,6 +32,8 @@ class BookPaginationModule extends BaseModule {
'extractLayoutLine',
'extractRemainingLayoutText',
'extractLines',
'getActiveStyleTags',
'updateStyleTagStack',
'countLineWords',
'getLineGeometry',
'getSpread',
@@ -95,8 +97,8 @@ class BookPaginationModule extends BaseModule {
this.publish();
}
async preparePendingBlock(block = {}) {
const token = ++this.refreshToken;
async preparePendingBlock(block = {}, options = {}) {
const token = options.activate === false ? this.refreshToken : ++this.refreshToken;
const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null;
const latestRenderedBlockId = Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
const pendingBlockId = Math.max(0, Number(block.blockId || block.metadata?.blockId || 0));
@@ -104,10 +106,13 @@ class BookPaginationModule extends BaseModule {
return null;
}
const historyBlocks = latestRenderedBlockId > 0
? await this.storyHistory.getBlocksRange(gameId, 1, latestRenderedBlockId)
const historyEndBlockId = options.includeUnrenderedHistory
? Math.max(0, pendingBlockId - 1)
: latestRenderedBlockId;
const historyBlocks = historyEndBlockId > 0
? await this.storyHistory.getBlocksRange(gameId, 1, historyEndBlockId)
: [];
if (token !== this.refreshToken) return null;
if (options.activate !== false && token !== this.refreshToken) return null;
const normalizedBlock = {
...block,
@@ -121,26 +126,30 @@ class BookPaginationModule extends BaseModule {
gameId
}
};
this.latestBlockId = pendingBlockId;
this.latestRenderedBlockId = latestRenderedBlockId;
this.spreads = this.buildSpreads([...historyBlocks, normalizedBlock]);
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
const targetSpread = this.spreads.find(spread => ['left', 'right'].some(side => {
const preparedSpreads = this.buildSpreads([...historyBlocks, normalizedBlock]);
const targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => {
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
}));
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
this.publish();
if (options.activate !== false) {
this.latestBlockId = pendingBlockId;
this.latestRenderedBlockId = latestRenderedBlockId;
this.spreads = preparedSpreads;
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
}
if (options.publish !== false) this.publish();
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
detail: {
blockId: pendingBlockId,
spread: this.getCurrentSpread(),
spreadIndex: this.currentSpreadIndex,
latestBlockId: this.latestBlockId,
latestRenderedBlockId: this.latestRenderedBlockId
spread: targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()),
spreadIndex: targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: pendingBlockId,
latestRenderedBlockId,
preloadOnly: options.activate === false
}
}));
return this.getCurrentSpread();
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
}
buildSpreads(blocks = []) {
@@ -342,6 +351,7 @@ class BookPaginationModule extends BaseModule {
isFinal: lineIndex === layout.breaks.length - 2,
smallCaps: Boolean(metadata.smallCaps),
hyphenated: Boolean(endNode?.type === 'penalty' && endNode.penalty === 100),
activeStyleTags: this.getActiveStyleTags(layout.nodes, startBreak.position),
align: 'justify'
};
}
@@ -385,12 +395,45 @@ class BookPaginationModule extends BaseModule {
ratio: breaks[index].ratio || 0,
isFinal: index === breaks.length - 1,
hyphenated: Boolean(lineNodes.at(-1)?.type === 'penalty' && lineNodes.at(-1)?.penalty === 100),
activeStyleTags: this.getActiveStyleTags(nodes, start),
align: options.align || 'justify'
});
}
return lines;
}
getActiveStyleTags(nodes = [], endPosition = 0) {
const stack = [];
for (let index = 0; index < endPosition; index += 1) {
const node = nodes[index];
if (node?.type !== 'tag') continue;
this.updateStyleTagStack(stack, node.value);
}
return stack.map(tag => ({ ...tag }));
}
updateStyleTagStack(stack = [], value = '') {
const text = String(value || '');
if (!text.startsWith('<')) return stack;
if (text.startsWith('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
countLineWords(line = {}) {
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
let count = 0;
+209 -34
View File
@@ -28,9 +28,13 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations = new Map();
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.preparedRevealCache = new Map();
this.revealBounds = null;
this.revealWords = null;
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30;
@@ -43,15 +47,23 @@ class BookTextureRendererModule extends BaseModule {
'ensureTextureFontFace',
'createPageCanvases',
'drawSpread',
'getDrawSignature',
'cloneCanvas',
'drawPageBase',
'drawPageLines',
'drawLine',
'drawWord',
'recordRevealRect',
'getInlineStyleState',
'updateInlineStyleState',
'getCanvasFont',
'applyTextStyle',
'getPageContent',
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'createAnimationState',
'publishPreparedReveal',
'startPreparedRevealAnimation',
'fastForwardAnimations',
'stopAnimations',
@@ -121,21 +133,25 @@ class BookTextureRendererModule extends BaseModule {
async waitForTextureFonts() {
if (!document.fonts) return;
await Promise.all([
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf'),
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf', { style: 'normal', weight: '400' }),
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Italic.otf', { style: 'italic', weight: '400' }),
this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'),
this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf')
]);
await Promise.all([
document.fonts.load('24px "EB Garamond"'),
document.fonts.load('italic 24px "EB Garamond"'),
document.fonts.load('bold 24px "EB Garamond"'),
document.fonts.load('italic bold 24px "EB Garamond"'),
document.fonts.load('24px "EB Garamond 12"'),
document.fonts.load('72px "EB Garamond Initials"')
]);
await document.fonts.ready;
}
async ensureTextureFontFace(family, url) {
async ensureTextureFontFace(family, url, descriptors = {}) {
if (!window.FontFace) return;
const face = new FontFace(family, `url(${url})`);
const face = new FontFace(family, `url(${url})`, descriptors);
const loadedFace = await face.load();
document.fonts.add(loadedFace);
}
@@ -151,27 +167,66 @@ class BookTextureRendererModule extends BaseModule {
});
}
drawSpread(spread = null, sides = null) {
drawSpread(spread = null, sides = null, options = {}) {
const previousSpread = this.currentSpread;
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) {
const now = performance.now();
if (now - this.lastDrawSkipLoggedAt > 1000) {
this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
}
if (options.preloadOnly) this.currentSpread = previousSpread;
return null;
}
this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw,
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : []
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
preloadOnly: Boolean(options.preloadOnly)
});
this.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] };
this.revealBaseCanvases = { left: null, right: null };
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
this.drawPageLines(side, this.currentSpread?.[side] || []);
});
this.publishSpread(sidesToDraw);
const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw
sides: sidesToDraw,
preloadOnly: Boolean(options.preloadOnly)
});
this.revealBounds = null;
this.revealWords = null;
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature;
if (options.preloadOnly) this.currentSpread = previousSpread;
return published;
}
getDrawSignature(spread = null, sides = []) {
const source = spread || {};
return sides.map(side => {
const lines = Array.isArray(source[side]) ? source[side] : [];
const ids = lines.map(line => `${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
return `${side}[${ids}]`;
}).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;
}
drawPageBase(side) {
@@ -217,7 +272,6 @@ class BookTextureRendererModule extends BaseModule {
const content = this.getPageContent(side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : '';
const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
@@ -233,10 +287,13 @@ class BookTextureRendererModule extends BaseModule {
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], {
italic: lineRecord.fontStyle === 'italic'
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`;
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
if (lineRecord.dropCapText) {
ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68);
@@ -249,15 +306,65 @@ class BookTextureRendererModule extends BaseModule {
ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`;
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
}
this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => {
this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx);
this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
}
getInlineStyleState(tags = [], base = {}) {
const state = {
bold: Boolean(base.bold),
italic: Boolean(base.italic)
};
tags.forEach(tag => {
if (tag?.bold) state.bold = true;
if (tag?.italic) state.italic = true;
});
return state;
}
updateInlineStyleState(stack = [], value = '') {
const text = String(value || '');
if (!text.startsWith('<')) return stack;
if (text.startsWith('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
getCanvasFont(fontPx, smallCaps = false, style = {}) {
const metrics = this.metrics;
return [
style.italic ? 'italic' : '',
smallCaps ? 'small-caps' : '',
style.bold ? '700' : '',
`${fontPx}px`,
metrics.typography.fontFamily
].filter(Boolean).join(' ');
}
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
}
getPageContent(side = 'left') {
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
x: 0,
@@ -267,26 +374,31 @@ class BookTextureRendererModule extends BaseModule {
};
}
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) {
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
const segments = [];
let x = 0;
let currentSegment = null;
let previousWasGlue = true;
let currentWordIndex = -1;
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
nodes.forEach((node, index) => {
if (!node) return;
if (node.type === 'box' && node.value) {
const value = String(node.value);
const width = Number(node.width || ctx.measureText(value).width || 0);
if (currentSegment && !previousWasGlue) {
const style = this.getInlineStyleState(styleStack, baseStyle);
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
currentSegment.value += value;
currentSegment.width += width;
} else {
if (previousWasGlue) currentWordIndex += 1;
currentSegment = {
value,
x,
width,
wordIndex: segments.length
wordIndex: Math.max(0, currentWordIndex),
style
};
segments.push(currentSegment);
}
@@ -308,15 +420,19 @@ class BookTextureRendererModule extends BaseModule {
x += hyphenWidth;
}
previousWasGlue = false;
} else if (node.type === 'tag') {
this.updateInlineStyleState(styleStack, node.value);
}
});
return segments;
}
drawWord(ctx, value, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx) {
drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) {
const value = segment?.value || '';
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
ctx.fillText(value, x, baseY);
const width = ctx.measureText(value).width || fontPx;
const width = Number(segment?.width || 0) || ctx.measureText(value).width || fontPx;
this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex);
}
@@ -392,16 +508,8 @@ class BookTextureRendererModule extends BaseModule {
this.requestAnimationFrame();
}
prepareRevealBlock(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
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, {
createAnimationState(blockId, wordTimings = [], detail = {}) {
return {
blockId,
wordTimings,
startedAt: null,
@@ -411,16 +519,73 @@ class BookTextureRendererModule extends BaseModule {
),
completed: false,
prepared: true
};
}
prepareRevealBlock(detail = {}, options = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
const wordTimings = detail.wordTimings;
const preloadOnly = Boolean(detail.preloadOnly || options.preloadOnly);
this.markPipelineTiming('prepareRevealBlock:start', {
blockId: id,
wordTimingCount: wordTimings.length,
preloadOnly
});
if (!preloadOnly && this.preparedRevealCache.has(id)) {
const cached = this.preparedRevealCache.get(id);
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.publishPreparedReveal(cached);
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length,
reusedPreparedCanvas: true
});
return;
}
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = this.getBlockSides(blockId);
const published = this.drawSpread(spread, sides, { preloadOnly });
if (preloadOnly && published) {
this.preparedRevealCache.set(id, {
...published,
blockId,
wordTimings,
totalDuration: detail.totalDuration || 0
});
}
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length
wordTimingCount: wordTimings.length,
preloadOnly
});
}
publishPreparedReveal(prepared) {
if (!prepared) return;
this.markPipelineTiming('publishPreparedReveal', {
blockId: prepared.blockId,
sides: prepared.sides || [],
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail: {
metrics: prepared.metrics,
hitMaps: prepared.hitMaps || this.hitMaps,
left: prepared.left || null,
right: prepared.right || null,
reveal: prepared.reveal || {},
preparedFromCache: true
}
}));
}
startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id);
@@ -534,7 +699,7 @@ class BookTextureRendererModule extends BaseModule {
if (hasActive) this.requestAnimationFrame();
}
publishSpread(sides = null) {
publishSpread(sides = null, options = {}) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const wordCounts = {
left: this.revealWords?.left?.length || 0,
@@ -542,10 +707,16 @@ class BookTextureRendererModule extends BaseModule {
};
const detail = {
metrics: this.metrics,
hitMaps: this.hitMaps
hitMaps: this.hitMaps,
sides: sidesToPublish
};
if (sidesToPublish.includes('left')) detail.left = this.canvases.left;
if (sidesToPublish.includes('right')) detail.right = this.canvases.right;
if (options.preloadOnly) detail.preloadOnly = true;
if (sidesToPublish.includes('left')) {
detail.left = options.preloadOnly ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
}
if (sidesToPublish.includes('right')) {
detail.right = options.preloadOnly ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
}
const reveal = {};
sidesToPublish.forEach((side) => {
const bounds = this.revealBounds?.[side];
@@ -559,6 +730,7 @@ class BookTextureRendererModule extends BaseModule {
reveal[side] = {
blockIds,
durationMs,
baseCanvas: options.preloadOnly ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null,
wordRects: (this.revealWords?.[side] || []).map(word => ({
blockId: word.blockId,
wordIndex: word.wordIndex,
@@ -577,11 +749,13 @@ class BookTextureRendererModule extends BaseModule {
this.markPipelineTiming('publishSpread', {
sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0,
wordCounts
wordCounts,
preloadOnly: Boolean(options.preloadOnly)
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail
}));
return detail;
}
getPageCanvas(side) {
@@ -595,6 +769,7 @@ class BookTextureRendererModule extends BaseModule {
handlePageCountChanged(event) {
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
this.createPageCanvases();
this.lastDrawSignature = null;
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}
+7 -1
View File
@@ -20,6 +20,7 @@ class ChoiceDisplayModule extends BaseModule {
this.currentTurnId = 0;
this.autoTurnCounter = 0;
this.lastAutoTurn = new Map();
this.selectionInProgress = false;
this.template = {
cells: {
default: {
@@ -136,6 +137,7 @@ class ChoiceDisplayModule extends BaseModule {
};
this.currentGlossaryEntries = detail.glossaryEntries;
this.choices = this.normalizeChoices(detail.choices);
this.selectionInProgress = false;
this.render();
}
@@ -159,7 +161,7 @@ class ChoiceDisplayModule extends BaseModule {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
if (event.repeat || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
return;
}
@@ -434,6 +436,9 @@ class ChoiceDisplayModule extends BaseModule {
}
async selectChoice(index) {
if (this.selectionInProgress) {
return;
}
if (!this.socketClient) {
this.socketClient = this.getModule('socket-client');
}
@@ -442,6 +447,7 @@ class ChoiceDisplayModule extends BaseModule {
return;
}
this.selectionInProgress = true;
this.clear();
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index }
+1 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260607-webgl-typography-a';
const MODULE_CACHE_BUSTER = '20260608-webgl-mask-timing-c';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/**
+99 -27
View File
@@ -20,8 +20,10 @@ class SentenceQueueModule extends BaseModule {
this.isProcessing = false;
this.onSentenceReadyCallback = null;
// Cache in-flight TTS prefetches only. Layout belongs to the renderer.
// Cache prepared future queue items so the playback path can consume
// work that was already generated during lookahead.
this.prefetchingSpeech = new Map();
this.preparedSentenceCache = new Map();
this.autoplay = true;
this.inputMode = 'text';
this.lastContinueAt = 0;
@@ -43,6 +45,7 @@ class SentenceQueueModule extends BaseModule {
'getCacheKey',
'getPreparedSentence',
'prefetchAhead',
'prefetchWebGLBookPresentation',
'prepareSpeechMetadata',
'preloadAssetsForItem',
'normalizeTtsText',
@@ -156,9 +159,12 @@ class SentenceQueueModule extends BaseModule {
text: String(queueItem.text || '').trim()
});
// Process the queue if not already processing
// Process the queue if not already processing. If playback is already
// running, immediately start lookahead for the newly appended item.
if (!this.isProcessing) {
this.processNextSentence();
} else {
this.prefetchAhead(4, this.queueGeneration);
}
}
@@ -194,6 +200,11 @@ class SentenceQueueModule extends BaseModule {
const sentence = await this.getPreparedSentence(item);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
await this.prefetchWebGLBookPresentation(sentence, {
queueGeneration,
queueIndex: 0
});
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Prefetch far enough ahead that media pauses do not block TTS
// generation for the next spoken paragraph.
@@ -499,14 +510,15 @@ class SentenceQueueModule extends BaseModule {
* Prepare queue metadata. This module intentionally does not create layout:
* live rendering and history rendering must go through the same renderer.
*/
async prepareSentence(item) {
async prepareSentence(item, options = {}) {
const text = typeof item === 'string' ? item : item.text;
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const metadata = typeof item === 'object' && item !== null ? item : {};
const blocking = options.blocking !== false;
try {
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) {
await this.preloadAssetsForItem(metadata, { blocking: true, sentenceId: id });
await this.preloadAssetsForItem(metadata, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
return {
id,
@@ -529,7 +541,7 @@ class SentenceQueueModule extends BaseModule {
await this.preloadAssetsForItem({
type: 'paragraph',
cueMarkers: metadata.cueMarkers || []
}, { blocking: true, sentenceId: id });
}, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
}
const ttsData = await this.prepareSpeechMetadata(text, {
@@ -537,7 +549,7 @@ class SentenceQueueModule extends BaseModule {
blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null,
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
blocking: true
blocking
});
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
@@ -834,7 +846,7 @@ class SentenceQueueModule extends BaseModule {
resolve();
};
const onCommand = (event) => {
if (event.detail?.type === 'continue') {
if (event.detail?.type === 'continue' && !this.isChoiceAwaitingPlayer()) {
finish();
}
};
@@ -846,15 +858,81 @@ class SentenceQueueModule extends BaseModule {
return `${item?.id || ''}:${item?.text || ''}`;
}
isChoiceAwaitingPlayer() {
if (this.inputMode !== 'choice') {
return false;
}
const choicePanel = document.getElementById('story_choices');
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
}
async getPreparedSentence(item) {
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
const cacheKey = this.getCacheKey(item);
const prepared = this.preparedSentenceCache.get(cacheKey);
if (prepared) {
this.preparedSentenceCache.delete(cacheKey);
return prepared;
}
const pending = this.prefetchingSpeech.get(cacheKey);
if (pending) {
pending.catch(() => null);
const prefetched = await pending.catch(() => null);
if (prefetched) {
this.preparedSentenceCache.delete(cacheKey);
return prefetched;
}
}
return this.prepareSentence(item);
}
async prefetchWebGLBookPresentation(sentence, options = {}) {
if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null;
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|| document.body?.classList?.contains('webgl-mode');
if (!isWebGLMode) return null;
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
if (blockId == null) return null;
const bookPagination = this.getModule('book-pagination');
const bookTextureRenderer = this.getModule('book-texture-renderer');
if (!bookPagination || !bookTextureRenderer) return null;
if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
}
await new Promise(resolve => {
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
scheduler(() => resolve(), { timeout: 120 });
});
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
const spread = typeof bookPagination.preparePendingBlock === 'function'
? await bookPagination.preparePendingBlock(sentence, {
activate: false,
publish: false,
includeUnrenderedHistory: true
})
: null;
if (!spread) return null;
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock({
id: sentence.id,
blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread,
preloadOnly: true
}, { preloadOnly: true });
}
return spread;
}
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
}
@@ -888,35 +966,28 @@ class SentenceQueueModule extends BaseModule {
const promise = (async () => {
if (queueGeneration !== this.queueGeneration) return null;
await this.preloadAssetsForItem(nextItem, {
sentenceId: nextItem.id,
const prepared = await this.prepareSentence(nextItem, {
blocking: false,
prefetch: true
prefetch: true,
queueIndex: index
});
if (queueGeneration !== this.queueGeneration) return null;
if (!this.isSpeechItem(nextItem)) {
return null;
}
return this.prepareSpeechMetadata(nextItem.text || '', {
sentenceId: nextItem.id,
blockId: nextItem.blockId ?? null,
turnId: nextItem.turnId ?? null,
ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [],
queueIndex: index,
prefetch: true,
blocking: false
await this.prefetchWebGLBookPresentation(prepared, {
queueGeneration,
queueIndex: index
});
if (queueGeneration !== this.queueGeneration) return null;
this.preparedSentenceCache.set(nextCacheKey, prepared);
return prepared;
})()
.then(() => {
.then((prepared) => {
if (queueGeneration !== this.queueGeneration) return false;
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
}));
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
return true;
return prepared || true;
})
.catch(err => {
console.warn('SentenceQueue: Prefetch failed:', err);
@@ -1341,6 +1412,7 @@ class SentenceQueueModule extends BaseModule {
this.cancelGenerationRequests('sentence-queue-cleared');
this.cancelAssetPreloads('sentence-queue-cleared');
this.prefetchingSpeech.clear();
this.preparedSentenceCache.clear();
this.pauseBeforeNextReason = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }
+17 -1
View File
@@ -29,6 +29,7 @@ class UIControllerModule extends BaseModule {
this.ttsHandler = null;
this.socketClient = null;
this.animationQueue = null;
this.currentInputMode = document.documentElement.dataset.inputMode || 'none';
// Add TTS toggle state
this.ttsEnabled = false;
@@ -56,6 +57,7 @@ class UIControllerModule extends BaseModule {
'clearDisplay',
'sendCommand',
'isInteractiveClickTarget',
'isChoiceAwaitingPlayer',
'updateButtonStates'
]);
}
@@ -262,6 +264,9 @@ class UIControllerModule extends BaseModule {
if (!event.detail || event.detail.moduleId === this.id) return;
this.handleCommand(event.detail);
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none';
});
this.addEventListener(document, 'click', (event) => {
if (this.isInteractiveClickTarget(event.target)) {
@@ -270,7 +275,7 @@ class UIControllerModule extends BaseModule {
const playbackCoordinator = this.getModule('playback-coordinator');
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true';
if ((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) {
if (((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) && !this.isChoiceAwaitingPlayer()) {
this.handleCommand({ type: 'continue', source: 'book-click' });
}
@@ -667,6 +672,14 @@ class UIControllerModule extends BaseModule {
'.volume-toggle'
].join(',')));
}
isChoiceAwaitingPlayer() {
if (this.currentInputMode !== 'choice') {
return false;
}
const choicePanel = document.getElementById('story_choices');
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
}
handleCommand(command) {
// Route commands to appropriate handlers
@@ -679,6 +692,9 @@ class UIControllerModule extends BaseModule {
break;
case 'continue':
{
if (this.isChoiceAwaitingPlayer()) {
return;
}
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
}));
+1 -1
View File
@@ -1814,7 +1814,7 @@ class UIDisplayHandlerModule extends BaseModule {
}
getLatestHistoryBlockId() {
return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
return Math.max(0, Number((this.storyHistory?.nextBlockId || 1) - 1));
}
updateStoryScrollbar(detail = {}) {
+148 -25
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-typography-a';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -214,13 +214,31 @@ const leftCanvas = createPageCanvas('left');
const rightCanvas = createPageCanvas('right');
const leftTexture = new THREE.CanvasTexture(leftCanvas);
const rightTexture = new THREE.CanvasTexture(rightCanvas);
[leftTexture, rightTexture].forEach((texture) => {
function configurePageCanvasTexture(texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
texture.generateMipmaps = false;
return texture;
}
[leftTexture, rightTexture].forEach(configurePageCanvasTexture);
function createPageCanvasTexture(sourceCanvas) {
if (!sourceCanvas) return null;
const texture = configurePageCanvasTexture(new THREE.CanvasTexture(sourceCanvas));
texture.needsUpdate = true;
if (typeof renderer?.initTexture === 'function') {
renderer.initTexture(texture);
texture.needsUpdate = false;
}
return texture;
}
const preparedPageTextures = {
left: new Map(),
right: new Map()
};
const pageRevealState = {
left: null,
right: null
@@ -585,7 +603,7 @@ function configureBookShadowReceiver(material, strength) {
const isHardcoverPaper = material.userData?.isHardcoverPaper === true;
const isHeadband = material.userData?.isHeadband === true;
const pageReveal = material.userData?.bookPageReveal || null;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v1' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v2' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
material.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
@@ -599,7 +617,9 @@ function configureBookShadowReceiver(material, strength) {
shader.uniforms.bookRevealWordRects = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 0, 0, 0)) };
shader.uniforms.bookRevealWordTimings = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 1, 0, 0)) };
shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() };
shader.uniforms.bookRevealSoftness = { value: 0.035 };
shader.uniforms.bookRevealBaseMap = { value: leftTexture };
shader.uniforms.bookRevealUseBaseMap = { value: 0 };
shader.uniforms.bookRevealSoftness = { value: 0.025 };
material.userData.bookRevealShader = shader;
applyPendingPageReveal(pageReveal.side, shader);
}
@@ -642,6 +662,8 @@ function configureBookShadowReceiver(material, strength) {
uniform vec4 bookRevealWordRects[256];
uniform vec4 bookRevealWordTimings[256];
uniform vec3 bookRevealPaperColor;
uniform sampler2D bookRevealBaseMap;
uniform float bookRevealUseBaseMap;
uniform float bookRevealSoftness;
float bookRevealVisibleMask(vec2 uv) {
@@ -653,7 +675,7 @@ function configureBookShadowReceiver(material, strength) {
float inside = step(0.0, local.x) * step(0.0, local.y) * step(local.x, 1.0) * step(local.y, 1.0);
vec4 timing = bookRevealWordTimings[i];
float progress = clamp((bookRevealElapsedMs - timing.x) / max(1.0, timing.y), 0.0, 1.0);
float scan = clamp((local.x + (1.0 - local.y)) * 0.5, 0.0, 1.0);
float scan = clamp(local.x * 0.88 + (1.0 - local.y) * 0.12, 0.0, 1.0);
float feather = max(0.0001, bookRevealSoftness);
float visible = smoothstep(scan - feather, scan + feather, progress);
hidden = max(hidden, inside * (1.0 - visible));
@@ -805,8 +827,9 @@ function configureBookShadowReceiver(material, strength) {
if (bookRevealActive > 0.5) {
float hiddenInk = bookRevealVisibleMask(vMapUv);
float luminance = dot(sampledDiffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722));
float inkMask = 1.0 - smoothstep(0.26, 0.72, luminance);
sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, bookRevealPaperColor, hiddenInk * inkMask);
float inkMask = 1.0 - smoothstep(0.52, 0.9, luminance);
vec3 revealBaseColor = mix(bookRevealPaperColor, texture2D(bookRevealBaseMap, vMapUv).rgb, bookRevealUseBaseMap);
sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, revealBaseColor, clamp(hiddenInk * inkMask * 1.55, 0.0, 1.0));
}
diffuseColor *= sampledDiffuseColor;
#endif`
@@ -1653,8 +1676,15 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:start', {
hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {})
revealSides: Object.keys(detail.reveal || {}),
preloadOnly: Boolean(detail.preloadOnly)
});
if (detail.preloadOnly) {
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
if (detail.right) preloadPageTexture('right', detail.right, detail.reveal?.right);
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
return;
}
if (detail.left) {
if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left);
@@ -1678,10 +1708,59 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:end');
}
function getRevealCacheKey(revealDetail = {}) {
const ids = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [];
return ids.map(id => String(id)).join('|') || 'direct';
}
function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null;
const key = getRevealCacheKey(revealDetail);
markPageTextureTiming('preloadTexture:start', {
side,
key,
width: sourceCanvas.width,
height: sourceCanvas.height,
hasBaseTexture: Boolean(baseTexture)
});
preparedPageTextures[side].set(key, {
texture,
baseTexture,
sourceCanvas,
revealDetail,
uploadedAt: performance.now()
});
if (preparedPageTextures[side].size > 12) {
const oldestKey = preparedPageTextures[side].keys().next().value;
const oldest = preparedPageTextures[side].get(oldestKey);
oldest?.texture?.dispose?.();
oldest?.baseTexture?.dispose?.();
preparedPageTextures[side].delete(oldestKey);
}
markPageTextureTiming('preloadTexture:end', { side, key });
return texture;
}
function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key);
if (!prepared) return null;
preparedPageTextures[side].delete(key);
markPageTextureTiming('preloadTexture:activate', { side, key });
return prepared;
}
function uploadPageTextureDirect(side, sourceCanvas) {
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
markPageTextureTiming('directUpload:start', { side });
clearPageReveal(side, 'direct-upload');
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
markPageTextureTiming('directUpload:end', { side });
}
@@ -1689,12 +1768,25 @@ function uploadPageTextureDirect(side, sourceCanvas) {
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const texture = side === 'left' ? leftTexture : rightTexture;
const shader = getPageRevealShader(side);
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const prepared = takePreparedPageTexture(side, revealDetail);
markPageTextureTiming('revealUpload:start', {
side,
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0,
usedPreparedTexture: Boolean(prepared),
usedPreparedBaseTexture: Boolean(prepared?.baseTexture)
});
bindPageTextureSource(side, texture, sourceCanvas);
if (prepared?.texture) {
material.map = prepared.texture;
} else {
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
}
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null);
pageRevealState[side] = {
startedAt: revealDetail.startNow ? performance.now() : null,
@@ -1702,9 +1794,13 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
lastRevealFrameAt: null,
visualElapsedMs: 0,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [],
baseTexture,
fastForwarding: false,
fastForwardStartedAt: null,
fastForwardStartElapsedMs: 0,
fastForwardDurationMs: 260
};
const material = side === 'left' ? materials.leftPage : materials.rightPage;
if (material?.userData) material.userData.pendingPageReveal = revealDetail;
if (shader?.uniforms) applyPendingPageReveal(side, shader);
else if (material) material.needsUpdate = true;
@@ -1725,6 +1821,9 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
applyPageRevealWords(shader, revealDetail.wordRects || []);
shader.uniforms.bookRevealActive.value = 1;
shader.uniforms.bookRevealElapsedMs.value = 0;
const baseTexture = pageRevealState[side]?.baseTexture;
if (shader.uniforms.bookRevealBaseMap) shader.uniforms.bookRevealBaseMap.value = baseTexture || (side === 'left' ? leftTexture : rightTexture);
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = baseTexture ? 1 : 0;
document.documentElement.dataset.webglRevealDebug = JSON.stringify({
side,
blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [],
@@ -1744,6 +1843,12 @@ function applyPageRevealWords(shader, words = []) {
source.forEach((word, index) => {
const rect = word.rect || {};
const timing = word.timing || {};
const nextTiming = source[index + 1]?.timing || {};
const delay = Math.max(0, Number(timing.delay || 0));
const nextDelay = Number(nextTiming.delay);
const allottedDuration = Number.isFinite(nextDelay) && nextDelay > delay
? nextDelay - delay
: Number(timing.duration || 1);
const x = THREE.MathUtils.clamp(Number(rect.x || 0), 0, 1);
const y = THREE.MathUtils.clamp(Number(rect.y || 0), 0, 1);
const width = THREE.MathUtils.clamp(Number(rect.width || 0), 0, 1);
@@ -1755,8 +1860,8 @@ function applyPageRevealWords(shader, words = []) {
Math.max(0.0001, height)
);
timingUniforms[index].set(
Math.max(0, Number(timing.delay || 0)),
Math.max(1, Number(timing.duration || 1)),
delay,
Math.max(1, allottedDuration),
0,
0
);
@@ -1781,6 +1886,8 @@ function getRevealDebugState() {
elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0),
visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0),
wordCount: Number(uniforms.bookRevealWordCount?.value || 0),
usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0),
fastForwarding: pageRevealState[side]?.fastForwarding === true,
started: pageRevealState[side]?.startedAt != null,
pendingStart: pageRevealState[side]?.pendingStart === true,
durationMs: Number(pageRevealState[side]?.durationMs || 0),
@@ -1791,16 +1898,17 @@ function getRevealDebugState() {
}
function clearPageReveal(side, reason = 'clear') {
const previousState = pageRevealState[side];
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 || []
state: previousState ? {
started: previousState.startedAt != null,
pendingStart: previousState.pendingStart === true,
visualElapsedMs: previousState.visualElapsedMs || 0,
durationMs: previousState.durationMs,
blockIds: previousState.blockIds || []
} : null
});
if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40);
@@ -1811,7 +1919,9 @@ function clearPageReveal(side, reason = 'clear') {
shader.uniforms.bookRevealActive.value = 0;
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
shader.uniforms.bookRevealWordCount.value = 0;
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0;
}
previousState?.baseTexture?.dispose?.();
}
function startPageRevealForBlock(blockId) {
@@ -1833,7 +1943,10 @@ function fastForwardPageReveals(blockIds = []) {
if (!state) return;
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
if (!matches) return;
clearPageReveal(side, 'fast-forward');
state.fastForwarding = true;
state.fastForwardStartedAt = performance.now();
state.fastForwardStartElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0));
state.fastForwardDurationMs = 260;
});
}
@@ -1860,7 +1973,17 @@ function updatePageRevealAnimations(now) {
}
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);
if (state.fastForwarding) {
const fastElapsed = Math.max(0, now - Number(state.fastForwardStartedAt || now));
const fastProgress = THREE.MathUtils.clamp(fastElapsed / Math.max(1, Number(state.fastForwardDurationMs || 1)), 0, 1);
state.visualElapsedMs = THREE.MathUtils.lerp(
Math.max(0, Number(state.fastForwardStartElapsedMs || 0)),
state.durationMs,
fastProgress
);
} else {
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;
+2
View File
@@ -313,6 +313,8 @@ class WebGLBookSceneModule extends BaseModule {
return;
}
if (!target) return;
event.preventDefault();
event.stopPropagation();
if (type === 'pointermove' || type === 'mousemove') {
this.updateProjectedHover(target, event);
}