Fix WebGL line reveal renderer

This commit is contained in:
2026-06-09 09:02:54 +02:00
parent 419691000c
commit d665a0f237
4 changed files with 363 additions and 148 deletions
+201 -79
View File
@@ -30,8 +30,6 @@ class BookTextureRendererModule extends BaseModule {
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;
@@ -64,7 +62,12 @@ class BookTextureRendererModule extends BaseModule {
'drawImageFitted',
'drawLine',
'drawWord',
'recordRevealRect',
'buildRevealRegions',
'collectRevealRegionCandidates',
'createRevealRegionForLine',
'getLineInkRect',
'getLineNaturalWidth',
'getImageRevealDurationMs',
'getInlineStyleState',
'updateInlineStyleState',
'getCanvasFont',
@@ -204,8 +207,6 @@ class BookTextureRendererModule extends BaseModule {
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;
@@ -220,8 +221,6 @@ class BookTextureRendererModule extends BaseModule {
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;
@@ -448,7 +447,6 @@ class BookTextureRendererModule extends BaseModule {
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9, 0);
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';
@@ -578,51 +576,177 @@ class BookTextureRendererModule extends BaseModule {
const value = segment?.value || '';
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
ctx.fillText(value, x, baseY);
const width = Number(segment?.width || 0) || ctx.measureText(value).width || fontPx;
this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex);
}
recordRevealRect(side, lineRecord, x, y, width, height, localWordIndex = 0) {
if (!this.revealBounds || !this.revealPublishBlockIds) return;
const blockId = String(lineRecord?.blockId ?? '');
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return;
const animation = this.activeAnimations.get(blockId);
if (!animation || animation.completed) return;
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
const nextRect = {
x: Math.max(0, x - padding),
y: Math.max(0, y - padding),
right: Math.min(this.metrics.width, x + width + padding),
bottom: Math.min(this.metrics.height, y + height + padding)
};
const current = this.revealBounds[side];
this.revealBounds[side] = current ? {
x: Math.min(current.x, nextRect.x),
y: Math.min(current.y, nextRect.y),
right: Math.max(current.right, nextRect.right),
bottom: Math.max(current.bottom, nextRect.bottom),
blockIds: current.blockIds.add(blockId)
} : {
...nextRect,
blockIds: new Set([blockId])
};
const globalWordIndex = Math.max(0, Number(lineRecord.blockWordStart || 0) + Number(localWordIndex || 0));
const timing = Array.isArray(animation.wordTimings) ? animation.wordTimings[globalWordIndex] : null;
if (!timing || !this.revealWords?.[side]) return;
this.revealWords[side].push({
blockId,
wordIndex: globalWordIndex,
rect: {
x: nextRect.x / this.metrics.width,
y: nextRect.y / this.metrics.height,
width: Math.max(0.001, (nextRect.right - nextRect.x) / this.metrics.width),
height: Math.max(0.001, (nextRect.bottom - nextRect.y) / this.metrics.height)
},
timing: {
delay: Math.max(0, Number(timing.delay || 0)),
duration: Math.max(1, Number(timing.duration || 1))
}
buildRevealRegions(side) {
if (!this.revealPublishBlockIds || !this.metrics) return null;
const candidates = this.collectRevealRegionCandidates();
if (!candidates.length) return null;
const byBlock = candidates.reduce((map, region) => {
if (!map.has(region.blockId)) map.set(region.blockId, []);
map.get(region.blockId).push(region);
return map;
}, new Map());
const regions = [];
byBlock.forEach((blockRegions, blockId) => {
const animation = this.activeAnimations.get(blockId);
if (!animation || animation.completed) return;
const fixedRegions = blockRegions.filter(region => region.fixedDurationMs > 0);
const textRegions = blockRegions.filter(region => !(region.fixedDurationMs > 0));
let delay = 0;
const textDuration = Math.max(0, Number(animation.totalDuration || 0));
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
textRegions.forEach((region) => {
const duration = totalArea > 0
? Math.max(1, textDuration * (Math.max(1, region.area) / totalArea))
: Math.max(1, textDuration / Math.max(1, textRegions.length));
regions.push({
...region,
timing: {
delay,
duration
}
});
delay += duration;
});
fixedRegions.forEach((region) => {
regions.push({
...region,
timing: {
delay,
duration: Math.max(1, region.fixedDurationMs)
}
});
delay += Math.max(1, region.fixedDurationMs);
});
});
const sideRegions = regions.filter(region => region.side === side);
if (!sideRegions.length) return null;
const bounds = sideRegions.reduce((box, region) => ({
x: Math.min(box.x, region.pixelRect.x),
y: Math.min(box.y, region.pixelRect.y),
right: Math.max(box.right, region.pixelRect.right),
bottom: Math.max(box.bottom, region.pixelRect.bottom)
}), {
x: this.metrics.width,
y: this.metrics.height,
right: 0,
bottom: 0
});
return {
blockIds: Array.from(byBlock.keys()),
durationMs: regions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
baseCanvas: null,
lineRects: sideRegions.map(region => ({
blockId: region.blockId,
lineIndex: region.lineIndex,
rect: region.rect,
timing: region.timing
})),
bounds: {
x: bounds.x / this.metrics.width,
y: bounds.y / this.metrics.height,
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
}
};
}
collectRevealRegionCandidates() {
const candidates = [];
['left', 'right'].forEach((side) => {
const spreadLines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
spreadLines.forEach((lineRecord) => {
const region = this.createRevealRegionForLine(side, lineRecord);
if (region) candidates.push(region);
});
});
return candidates;
}
createRevealRegionForLine(side, lineRecord = {}) {
const blockId = String(lineRecord?.blockId ?? '');
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
const animation = this.activeAnimations.get(blockId);
if (!animation || animation.completed) return null;
if (lineRecord.type === 'image' || lineRecord.kind === 'image') {
const content = this.getPageContent(side);
const rect = lineRecord.metadata?.imageLayout?.textureRect || {};
const x = content.x + Number(rect.x || 0);
const y = content.y + Number(rect.y || 0);
const width = Math.max(1, Number(rect.width || content.width));
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord));
}
const rect = this.getLineInkRect(side, lineRecord);
if (!rect) return null;
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0);
}
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0) {
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
const left = Math.max(0, x - padding);
const top = Math.max(0, y - padding);
const right = Math.min(this.metrics.width, x + width + padding);
const bottom = Math.min(this.metrics.height, y + height + padding);
const rectWidth = Math.max(1, right - left);
const rectHeight = Math.max(1, bottom - top);
return {
side,
blockId,
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
fixedDurationMs,
area: rectWidth * rectHeight,
pixelRect: { x: left, y: top, right, bottom },
rect: {
x: left / this.metrics.width,
y: top / this.metrics.height,
width: Math.max(0.001, rectWidth / this.metrics.width),
height: Math.max(0.001, rectHeight / this.metrics.height)
}
};
}
getLineInkRect(side, lineRecord = {}) {
const content = this.getPageContent(side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || this.metrics.typographyLineHeightPx || 30));
const line = lineRecord.line || {};
const naturalWidth = this.getLineNaturalWidth(line);
const centerOffset = line.align === 'center'
? Math.max(0, (content.width - naturalWidth) / 2)
: Number(line.offset || 0);
const measuredWidth = Number(line.measure || lineRecord.measure || 0);
const isJustified = line.align !== 'center' && !line.isFinal;
let x = content.x + centerOffset;
let y = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx);
let width = Math.max(1, Math.min(content.width - centerOffset, isJustified ? (measuredWidth || content.width - centerOffset) : (naturalWidth || measuredWidth || content.width - centerOffset)));
let height = lineHeightPx;
if (lineRecord.dropCapText) {
const dropCapFontPx = Math.round(fontPx * 2.68);
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
const dropCapWidth = fontPx * 2.9;
const normalRight = x + width;
x = Math.min(content.x, x);
y = Math.min(y, dropCapY);
width = Math.max(normalRight, content.x + dropCapWidth) - x;
height = Math.max((content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx)) + lineHeightPx, dropCapY + (dropCapFontPx * 0.9)) - y;
}
return { x, y, width, height };
}
getLineNaturalWidth(line = {}) {
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
return nodes.reduce((sum, node) => {
if (node?.type === 'box' || node?.type === 'glue') return sum + Number(node.width || 0);
return sum;
}, 0);
}
getImageRevealDurationMs(lineRecord = {}) {
const metadata = lineRecord.metadata || {};
const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0);
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
}
startRevealAnimation(detail = {}) {
@@ -822,6 +946,10 @@ class BookTextureRendererModule extends BaseModule {
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
}
isWebGLPageFlipActive() {
return document.documentElement.dataset.webglPageFlipActive === 'true';
}
tickAnimations(now) {
this.animationFrameId = null;
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
@@ -832,6 +960,18 @@ class BookTextureRendererModule extends BaseModule {
let hasActive = false;
const currentNow = performance.now();
if (this.isWebGLPageFlipActive()) {
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
hasActive = true;
if (animation.startedAt != null) {
animation.startedAt += this.targetFrameDurationMs;
}
});
if (hasActive) this.requestAnimationFrame();
return;
}
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
@@ -853,9 +993,9 @@ class BookTextureRendererModule extends BaseModule {
publishSpread(sides = null, options = {}) {
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 regionCounts = {
left: 0,
right: 0
};
const detail = {
metrics: this.metrics,
@@ -872,38 +1012,20 @@ class BookTextureRendererModule extends BaseModule {
}
const reveal = {};
sidesToPublish.forEach((side) => {
const bounds = this.revealBounds?.[side];
if (!bounds) return;
const blockIds = Array.from(bounds.blockIds || []);
const durationMs = blockIds.reduce((maxDuration, blockId) => {
const animation = this.activeAnimations.get(String(blockId));
return Math.max(maxDuration, Number(animation?.totalDuration || 0));
}, 0);
if (durationMs <= 0) return;
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,
rect: word.rect,
timing: word.timing
})),
bounds: {
x: bounds.x / this.metrics.width,
y: bounds.y / this.metrics.height,
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
}
};
const sideReveal = this.buildRevealRegions(side);
if (!sideReveal) return;
sideReveal.baseCanvas = options.preloadOnly
? this.cloneCanvas(this.revealBaseCanvases?.[side])
: this.revealBaseCanvases?.[side] || null;
regionCounts[side] = sideReveal.lineRects.length;
reveal[side] = sideReveal;
});
if (Object.keys(reveal).length) detail.reveal = reveal;
this.cachePublishedPages(sidesToPublish, detail);
this.markPipelineTiming('publishSpread', {
sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0,
wordCounts,
regionCounts,
preloadOnly: Boolean(options.preloadOnly)
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
+12 -1
View File
@@ -981,15 +981,26 @@ class UIDisplayHandlerModule extends BaseModule {
const generation = this.displayGeneration;
const sentenceGameId = sentence.gameId || null;
const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId);
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
try {
if (useWebGLBookReveal) {
await this.prepareWebGLBookReveal(sentence);
if (!isCurrent()) return null;
await this.playbackCoordinator.play(sentence);
if (!isCurrent()) return null;
if (sentence.blockId != null) this.markBlockRendered(sentence.blockId);
this.dispatchDeferredTagsForBlock(sentence);
if (sentence.onComplete) sentence.onComplete();
return null;
}
await this.ensureLiveTailWindow();
if (!isCurrent()) return null;
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
if (!isCurrent()) return null;
this.rebuildLayoutExclusions(this.renderedItems);
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
const element = await this.renderStoryBlock(sentence, {
animate: true,
playback: true,
+134 -59
View File
@@ -40,7 +40,7 @@ renderer.shadowMap.type = THREE.VSMShadowMap;
const generatedTextureCanvases = {};
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
const reflectionPixelRatio = 2;
const reflectionPixelRatio = 1;
const pageTextureWidth = 3072;
const reflectionTargetSize = new THREE.Vector2();
const pageRaycaster = new THREE.Raycaster();
@@ -80,8 +80,8 @@ let tableDustTexture = null;
let tableGreaseTexture = null;
const tableTopY = -0.02;
const bookTableContactClearance = 0.002;
const tableReflectionBaseWidth = 4096;
const tableReflectionBaseHeight = 2304;
const tableReflectionBaseWidth = 2048;
const tableReflectionBaseHeight = 1152;
const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, {
colorSpace: THREE.SRGBColorSpace,
depthBuffer: true,
@@ -210,10 +210,11 @@ const fastFlipCount = 10;
const fastFlipOverlap = 5;
let activeFlips = [];
let pendingPageFlips = 0;
const pendingRevealStartBlockIds = new Set();
const paperColor = new THREE.Color(0xece4ca);
const inkColor = '#1a1009';
const maxRevealWords = 256;
const maxRevealRegions = 128;
const completedRevealElapsedMs = 1000000000;
await reportLabStep(48, 'Preparing high-resolution page textures');
@@ -584,7 +585,8 @@ window.BookLabDebug = {
maxResidentPageTextures,
pageCacheProblemCount: pageCacheProblemLog.length,
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
mirrorRefreshesEveryFrame: true
mirrorRefreshesEveryFrame: true,
mirrorRefreshesWhenStaticDirty: true
};
},
projectPointerToPage(clientX, clientY) {
@@ -696,11 +698,11 @@ function buildTable() {
tableNormal.wrapT = THREE.RepeatWrapping;
tableNormal.repeat.set(2.15, 1.45);
tableNormal.anisotropy = renderer.capabilities.getMaxAnisotropy();
tableDustTexture = loadUtilityTexture('/assets/webgl/table_dust_4k.png');
tableDustTexture = loadUtilityTexture('/assets/webgl/table_dust_4k.png', { maxSize: 2048 });
tableDustTexture.wrapS = THREE.ClampToEdgeWrapping;
tableDustTexture.wrapT = THREE.ClampToEdgeWrapping;
tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
tableGreaseTexture = loadUtilityTexture('/assets/webgl/table_grease_4k.png');
tableGreaseTexture = loadUtilityTexture('/assets/webgl/table_grease_4k.png', { maxSize: 2048 });
tableGreaseTexture.wrapS = THREE.ClampToEdgeWrapping;
tableGreaseTexture.wrapT = THREE.ClampToEdgeWrapping;
tableGreaseTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
@@ -724,8 +726,21 @@ function buildTable() {
scene.add(tableMesh);
}
function loadUtilityTexture(url) {
const texture = new THREE.TextureLoader().load(url);
function loadUtilityTexture(url, options = {}) {
const texture = new THREE.TextureLoader().load(url, (loadedTexture) => {
const maxSize = Math.max(0, Number(options.maxSize || 0));
const image = loadedTexture.image;
const width = Number(image?.naturalWidth || image?.width || 0);
const height = Number(image?.naturalHeight || image?.height || 0);
if (!maxSize || !width || !height || (width <= maxSize && height <= maxSize)) return;
const scale = Math.min(maxSize / width, maxSize / height);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
loadedTexture.image = canvas;
loadedTexture.needsUpdate = true;
});
texture.colorSpace = THREE.NoColorSpace;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
@@ -738,7 +753,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-v2' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-line-v1' : 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 };
@@ -748,9 +763,9 @@ function configureBookShadowReceiver(material, strength) {
if (pageReveal) {
shader.uniforms.bookRevealActive = { value: 0 };
shader.uniforms.bookRevealElapsedMs = { value: completedRevealElapsedMs };
shader.uniforms.bookRevealWordCount = { value: 0 };
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.bookRevealRegionCount = { value: 0 };
shader.uniforms.bookRevealRegionRects = { value: Array.from({ length: maxRevealRegions }, () => new THREE.Vector4(0, 0, 0, 0)) };
shader.uniforms.bookRevealRegionTimings = { value: Array.from({ length: maxRevealRegions }, () => new THREE.Vector4(0, 1, 0, 0)) };
shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() };
shader.uniforms.bookRevealBaseMap = { value: leftTexture };
shader.uniforms.bookRevealUseBaseMap = { value: 0 };
@@ -793,9 +808,9 @@ function configureBookShadowReceiver(material, strength) {
uniform float bookTableTopY;
${pageReveal ? `uniform float bookRevealActive;
uniform float bookRevealElapsedMs;
uniform int bookRevealWordCount;
uniform vec4 bookRevealWordRects[256];
uniform vec4 bookRevealWordTimings[256];
uniform int bookRevealRegionCount;
uniform vec4 bookRevealRegionRects[128];
uniform vec4 bookRevealRegionTimings[128];
uniform vec3 bookRevealPaperColor;
uniform sampler2D bookRevealBaseMap;
uniform float bookRevealUseBaseMap;
@@ -803,17 +818,17 @@ function configureBookShadowReceiver(material, strength) {
float bookRevealVisibleMask(vec2 uv) {
float hidden = 0.0;
for (int i = 0; i < 256; i++) {
if (i >= bookRevealWordCount) break;
vec4 rect = bookRevealWordRects[i];
for (int i = 0; i < 128; i++) {
float enabled = step(float(i) + 0.5, float(bookRevealRegionCount));
vec4 rect = bookRevealRegionRects[i];
vec2 local = (uv - rect.xy) / max(rect.zw, vec2(0.0001));
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];
vec4 timing = bookRevealRegionTimings[i];
float progress = clamp((bookRevealElapsedMs - timing.x) / max(1.0, timing.y), 0.0, 1.0);
float scan = clamp(local.x * 0.88 + (1.0 - local.y) * 0.12, 0.0, 1.0);
float scan = clamp(local.x * 0.96 + (1.0 - local.y) * 0.04, 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));
hidden = max(hidden, enabled * inside * (1.0 - visible));
}
return hidden;
}` : ''}
@@ -2075,6 +2090,21 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
return texture;
}
function flushPendingRevealStarts() {
if (activeFlips.length > 0 || pendingRevealStartBlockIds.size === 0) return;
const blockIds = Array.from(pendingRevealStartBlockIds);
pendingRevealStartBlockIds.clear();
blockIds.forEach(blockId => startPageRevealForBlock(blockId));
}
function setPageFlipActiveFlag() {
if (activeFlips.length > 0) {
document.documentElement.dataset.webglPageFlipActive = 'true';
} else {
delete document.documentElement.dataset.webglPageFlipActive;
}
}
function recordPageCacheProblem(detail = {}) {
const entry = {
...detail,
@@ -2195,10 +2225,15 @@ async function preloadCachedPageTexture(pageIndex) {
async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex);
await Promise.all([
const [left, right] = await Promise.all([
preloadCachedPageTexture(indices.left),
preloadCachedPageTexture(indices.right)
]);
return {
spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))),
left,
right
};
}
async function prewarmFlipTextures(direction, targetSpread = null) {
@@ -2206,10 +2241,14 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
const nextSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
await Promise.all([
const [current, next] = await Promise.all([
prewarmSpreadTextures(currentSpread),
prewarmSpreadTextures(nextSpread)
]);
return {
current,
next
};
}
function takePreparedPageTexture(side, revealDetail = {}) {
@@ -2258,7 +2297,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
markPageTextureTiming('revealUpload:start', {
side,
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0,
regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0,
usedPreparedTexture: Boolean(prepared),
usedPreparedBaseTexture: Boolean(prepared?.baseTexture)
});
@@ -2292,7 +2331,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
document.documentElement.dataset.webglRevealDebug = JSON.stringify({
side,
blockIds: pageRevealState[side].blockIds,
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0,
regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0,
shaderReady: Boolean(shader?.uniforms),
started: pageRevealState[side].startedAt != null
});
@@ -2303,7 +2342,7 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const revealDetail = material?.userData?.pendingPageReveal;
if (!revealDetail || !shader?.uniforms) return false;
applyPageRevealWords(shader, revealDetail.wordRects || []);
applyPageRevealRegions(shader, revealDetail.lineRects || []);
shader.uniforms.bookRevealActive.value = 1;
shader.uniforms.bookRevealElapsedMs.value = 0;
const baseTexture = pageRevealState[side]?.baseTexture;
@@ -2312,7 +2351,7 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
document.documentElement.dataset.webglRevealDebug = JSON.stringify({
side,
blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [],
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0,
regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0,
shaderReady: true,
started: pageRevealState[side]?.startedAt != null
});
@@ -2320,20 +2359,19 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
return true;
}
function applyPageRevealWords(shader, words = []) {
const rectUniforms = shader.uniforms.bookRevealWordRects.value;
const timingUniforms = shader.uniforms.bookRevealWordTimings.value;
const source = Array.isArray(words) ? words.slice(0, maxRevealWords) : [];
shader.uniforms.bookRevealWordCount.value = source.length;
source.forEach((word, index) => {
const rect = word.rect || {};
const timing = word.timing || {};
const nextTiming = source[index + 1]?.timing || {};
function applyPageRevealRegions(shader, regions = []) {
const rectUniforms = shader.uniforms.bookRevealRegionRects.value;
const timingUniforms = shader.uniforms.bookRevealRegionTimings.value;
const source = Array.isArray(regions) ? regions : [];
if (source.length > maxRevealRegions) {
throw new Error(`WebGL reveal region count ${source.length} exceeds architectural maximum ${maxRevealRegions}`);
}
shader.uniforms.bookRevealRegionCount.value = source.length;
source.forEach((region, index) => {
const rect = region.rect || {};
const timing = region.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 duration = Math.max(1, 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);
@@ -2346,12 +2384,12 @@ function applyPageRevealWords(shader, words = []) {
);
timingUniforms[index].set(
delay,
Math.max(1, allottedDuration),
duration,
0,
0
);
});
for (let index = source.length; index < maxRevealWords; index += 1) {
for (let index = source.length; index < maxRevealRegions; index += 1) {
rectUniforms[index].set(0, 0, 0, 0);
timingUniforms[index].set(0, 1, 0, 0);
}
@@ -2370,7 +2408,7 @@ function getRevealDebugState() {
active: Number(uniforms.bookRevealActive?.value || 0),
elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0),
visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0),
wordCount: Number(uniforms.bookRevealWordCount?.value || 0),
regionCount: Number(uniforms.bookRevealRegionCount?.value || 0),
usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0),
fastForwarding: pageRevealState[side]?.fastForwarding === true,
started: pageRevealState[side]?.startedAt != null,
@@ -2403,7 +2441,7 @@ function clearPageReveal(side, reason = 'clear') {
if (shader?.uniforms?.bookRevealActive) {
shader.uniforms.bookRevealActive.value = 0;
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
shader.uniforms.bookRevealWordCount.value = 0;
shader.uniforms.bookRevealRegionCount.value = 0;
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0;
}
previousState?.baseTexture?.dispose?.();
@@ -2411,6 +2449,15 @@ function clearPageReveal(side, reason = 'clear') {
function startPageRevealForBlock(blockId) {
const id = String(blockId ?? '');
if (!id) return;
if (activeFlips.length > 0) {
pendingRevealStartBlockIds.add(id);
markPageTextureTiming('revealStart:deferred-for-flip', {
blockId: id,
activeFlips: activeFlips.length
});
return;
}
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state || state.startedAt != null) return;
@@ -2436,6 +2483,7 @@ function fastForwardPageReveals(blockIds = []) {
}
function updatePageRevealAnimations(now) {
if (activeFlips.length > 0) return;
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state) return;
@@ -2598,10 +2646,11 @@ async function startPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel) 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;
await prewarmFlipTextures(direction, targetSpread);
const prewarm = await prewarmFlipTextures(direction, targetSpread);
return startPageFlipPrepared(direction, {
...options,
targetSpread
targetSpread,
prewarm
});
}
@@ -2610,12 +2659,15 @@ function startPageFlipPrepared(direction, options = {}) {
if (!options.force && !canPageFlip(direction)) return false;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false;
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
return false;
}
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
prepareStaticPageForFlip(flip);
activeFlips.push(flip);
setPageFlipActiveFlag();
syncBookControls();
updateActiveFlips(flip.startTime);
return true;
@@ -2624,10 +2676,11 @@ function startPageFlipPrepared(direction, options = {}) {
async function startFastPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
await prewarmFlipTextures(direction, targetSpread);
const prewarm = await prewarmFlipTextures(direction, targetSpread);
return startFastPageFlipPrepared(direction, {
...options,
targetSpread
targetSpread,
prewarm
});
}
@@ -2635,7 +2688,7 @@ function startFastPageFlipPrepared(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
prepareStaticPageForFlip(firstFlip);
if (!prepareStaticPageForFlip(firstFlip, options.prewarm || null)) return false;
const startTime = firstFlip.startTime;
const interval = fastFlipDuration / fastFlipOverlap;
const skippedSpreads = Math.max(2, Number(options.skippedSpreads || fastFlipCount));
@@ -2651,6 +2704,7 @@ function startFastPageFlipPrepared(direction, options = {}) {
countAsPending: false
});
}
setPageFlipActiveFlag();
syncBookControls();
updateActiveFlips(startTime);
return true;
@@ -2676,8 +2730,8 @@ function createPageFlip(direction, startTime, duration) {
};
}
function prepareStaticPageForFlip(flip) {
if (!flip) return;
function prepareStaticPageForFlip(flip, prewarm = null) {
if (!flip) return false;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
const targetSpread = Number.isFinite(Number(flip.targetSpread))
@@ -2685,7 +2739,20 @@ function prepareStaticPageForFlip(flip) {
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
const targetPages = spreadPageIndices(targetSpread);
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
const backTexture = getResidentPageTexture(targetBackPageIndex) || getBlankPageTexture();
const residentBackTexture = getResidentPageTexture(targetBackPageIndex);
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
if (!residentBackTexture && requiresWrittenTexture) {
recordPageCacheProblem({
type: 'flip-back-texture-missing',
targetBackPageIndex,
targetSpread,
direction: flip.direction,
prewarmedCurrent: Boolean(prewarm?.current),
prewarmedNext: Boolean(prewarm?.next)
});
return false;
}
const backTexture = residentBackTexture || getBlankPageTexture();
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
@@ -2712,6 +2779,14 @@ function prepareStaticPageForFlip(flip) {
materials.leftPage.needsUpdate = true;
}
}
markPageTextureTiming('flipTexturePreflight:ready', {
direction: flip.direction,
sourceSide: flip.sourcePageSide,
targetSpread,
targetBackPageIndex,
usedResidentBackTexture: Boolean(residentBackTexture)
});
return true;
}
function canPageFlip(direction) {
@@ -3013,6 +3088,7 @@ function createFlippingPageGeometry(surface) {
function finishActiveFlip(flip) {
removeFlipMesh(flip);
activeFlips = activeFlips.filter((active) => active !== flip);
setPageFlipActiveFlag();
if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) {
bookPaginationState = {
...bookPaginationState,
@@ -3027,6 +3103,7 @@ function finishActiveFlip(flip) {
targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null
}
}));
flushPendingRevealStarts();
if (flip.commitBundleOnFinish) {
if (Number.isFinite(Number(flip.targetSpread))) {
syncBookControls();
@@ -4119,10 +4196,8 @@ function animate(now = performance.now()) {
updateBookShadowMaps();
lastFrameTiming.shadows = performance.now() - shadowStartedAt;
const reflectionStartedAt = performance.now();
const refreshStaticSceneBuffers = true;
if (refreshStaticSceneBuffers) {
updateTableReflection();
}
const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0;
updateTableReflection();
lastFrameTiming.reflection = performance.now() - reflectionStartedAt;
const renderStartedAt = performance.now();
if (tableDebugMode === tableDebugModes.mirror) {