Queue WebGL book reveal masks

This commit is contained in:
2026-06-07 13:52:07 +02:00
parent 7fc083fb58
commit 9434950826
31 changed files with 383 additions and 73 deletions
+1 -1
View File
@@ -280,6 +280,6 @@
console.log(message);
};
</script>
<script type="module" src="/js/loader.js?v=20260607-webgl-shader-reveal"></script>
<script type="module" src="/js/loader.js?v=20260607-webgl-queued-mask-reveal"></script>
</body>
</html>
+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-shader-reveal';
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-queued-mask-reveal';
export const BOOK_TEXTURE_WIDTH = 3072;
+52
View File
@@ -21,6 +21,7 @@ class BookPaginationModule extends BaseModule {
this.bindMethods([
'initialize',
'refreshFromHistory',
'preparePendingBlock',
'buildSpreads',
'layoutTextBlock',
'getDropCapText',
@@ -50,6 +51,9 @@ class BookPaginationModule extends BaseModule {
this.reportProgress(35, 'Preparing book pagination metrics');
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
this.addEventListener(document, 'story:history-updated', this.refreshFromHistory);
this.addEventListener(document, 'book-pagination:prepare-block', (event) => {
this.preparePendingBlock(event.detail?.block || event.detail || {});
});
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
this.setCurrentSpread(event.detail?.spreadIndex);
});
@@ -91,6 +95,54 @@ class BookPaginationModule extends BaseModule {
this.publish();
}
async preparePendingBlock(block = {}) {
const token = ++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));
if (!gameId || pendingBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
return null;
}
const historyBlocks = latestRenderedBlockId > 0
? await this.storyHistory.getBlocksRange(gameId, 1, latestRenderedBlockId)
: [];
if (token !== this.refreshToken) return null;
const normalizedBlock = {
...block,
type: block.kind || block.type || 'paragraph',
kind: block.kind || block.type || 'paragraph',
blockId: pendingBlockId,
gameId,
metadata: {
...(block.metadata || {}),
blockId: pendingBlockId,
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 lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
}));
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
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
}
}));
return this.getCurrentSpread();
}
buildSpreads(blocks = []) {
const spreads = [];
let cursorLine = 0;
+90 -2
View File
@@ -29,6 +29,7 @@ class BookTextureRendererModule extends BaseModule {
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
@@ -47,6 +48,8 @@ class BookTextureRendererModule extends BaseModule {
'getPageContent',
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'startPreparedRevealAnimation',
'fastForwardAnimations',
'stopAnimations',
'getBlockSides',
@@ -90,6 +93,9 @@ class BookTextureRendererModule extends BaseModule {
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
this.startRevealAnimation(event.detail || {});
});
this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => {
this.prepareRevealBlock(event.detail || {});
});
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') this.fastForwardAnimations();
@@ -121,6 +127,7 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
this.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] };
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
@@ -128,6 +135,7 @@ class BookTextureRendererModule extends BaseModule {
});
this.publishSpread(sidesToDraw);
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
}
@@ -202,7 +210,7 @@ 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);
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';
@@ -301,11 +309,33 @@ class BookTextureRendererModule extends BaseModule {
...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))
}
});
}
startRevealAnimation(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const existing = this.activeAnimations.get(String(blockId));
if (existing && existing.prepared) {
this.startPreparedRevealAnimation(blockId);
return;
}
this.activeAnimations.set(String(blockId), {
blockId,
wordTimings: detail.wordTimings,
@@ -319,21 +349,69 @@ class BookTextureRendererModule extends BaseModule {
this.pendingRevealBlockIds.delete(String(blockId));
this.revealPublishBlockIds = new Set([String(blockId)]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId
}
}));
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.activeAnimations.set(id, {
blockId,
wordTimings,
startedAt: null,
totalDuration: Math.max(
Number(detail.totalDuration || 0),
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
),
completed: false,
prepared: true
});
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
}
startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id);
if (!animation) return false;
animation.startedAt = performance.now();
animation.prepared = false;
animation.completed = false;
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId: animation.blockId
}
}));
this.requestAnimationFrame();
return true;
}
fastForwardAnimations() {
let changed = false;
const blockIds = [];
this.activeAnimations.forEach((animation) => {
if (!animation.completed) {
animation.completed = true;
this.revealedBlockIds.add(String(animation.blockId ?? ''));
blockIds.push(animation.blockId);
changed = true;
}
});
if (changed) {
this.pendingRevealBlockIds.clear();
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true));
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: {
blockIds
}
}));
}
}
@@ -393,6 +471,10 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
if (animation.startedAt == null) {
hasActive = true;
return;
}
const lastTiming = animation.wordTimings.at(-1);
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
if (currentNow - animation.startedAt >= total + 50) {
@@ -426,6 +508,12 @@ class BookTextureRendererModule extends BaseModule {
reveal[side] = {
blockIds,
durationMs,
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,
+1 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260607-webgl-shader-reveal';
const MODULE_CACHE_BUSTER = '20260607-webgl-queued-mask-reveal';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/**
+63 -1
View File
@@ -20,6 +20,8 @@ class PlaybackCoordinatorModule extends BaseModule {
'play',
'calculateWordTimings',
'animateWords',
'isWebGLPlaybackMode',
'scheduleWebGLReveal',
'waitForAudioStart',
'completeSentenceVisual',
'accelerateActiveWordAnimations',
@@ -213,7 +215,7 @@ class PlaybackCoordinatorModule extends BaseModule {
* @returns {Promise<void>} - Resolves when animation completes
*/
async animateWords(sentence) {
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) {
if (!sentence.animation || !sentence.animation.wordTimings) {
console.error('PlaybackCoordinator: Missing animation data');
return Promise.resolve();
}
@@ -224,6 +226,15 @@ class PlaybackCoordinatorModule extends BaseModule {
return Promise.resolve();
}
if (this.isWebGLPlaybackMode()) {
return this.scheduleWebGLReveal(sentence, animQueue);
}
if (!sentence.element) {
console.error('PlaybackCoordinator: Missing DOM element for 2D animation');
return Promise.resolve();
}
const wordElements = sentence.element.querySelectorAll('.word');
let wordTimings = sentence.animation.wordTimings;
let cueTimings = sentence.animation.cueTimings || [];
@@ -302,6 +313,57 @@ class PlaybackCoordinatorModule extends BaseModule {
});
}
isWebGLPlaybackMode() {
return document.body?.dataset?.webglUiMode === '3d'
|| document.body?.classList?.contains('webgl-mode');
}
scheduleWebGLReveal(sentence, animQueue) {
let wordTimings = Array.isArray(sentence.animation?.wordTimings)
? sentence.animation.wordTimings
: [];
let cueTimings = Array.isArray(sentence.animation?.cueTimings)
? sentence.animation.cueTimings
: [];
if (wordTimings.length === 0) {
const words = String(sentence.text || '').match(/\S+/g) || [];
const calculated = this.calculateWordTimings(words, sentence.tts?.duration || sentence.animation?.totalDuration || 0);
wordTimings = calculated.wordTimings;
cueTimings = [];
}
document.dispatchEvent(new CustomEvent('book-texture:reveal-block', {
detail: {
id: sentence.id,
blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null,
wordTimings,
cueTimings,
totalDuration: sentence.animation?.totalDuration || 0
}
}));
return new Promise((resolve) => {
const totalDuration = wordTimings.length > 0
? Math.max(...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
: 0;
cueTimings.forEach(cue => {
animQueue.schedule(() => {
document.dispatchEvent(new CustomEvent('story:media-cue', {
detail: {
sentenceId: sentence.id,
...cue
}
}));
}, cue.delay || 0);
});
animQueue.schedule(() => {
resolve();
}, totalDuration + 100);
});
}
/**
* Calculate word-level timing to match total TTS duration
* This is a utility method that can be called by SentenceQueue during preparation
+50 -3
View File
@@ -68,6 +68,8 @@ class UIDisplayHandlerModule extends BaseModule {
'applyGameConfig',
'applyTranslations',
'renderSentence',
'isWebGLMode',
'prepareWebGLBookReveal',
'renderStoryBlock',
'prepareRenderableBlock',
'prepareTextRenderable',
@@ -986,12 +988,14 @@ class UIDisplayHandlerModule extends BaseModule {
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,
placement: 'append',
token: this.renderWindowToken,
generation
generation,
deferRenderedMark: useWebGLBookReveal
});
if (!element) return null;
if (!isCurrent()) {
@@ -1008,7 +1012,13 @@ class UIDisplayHandlerModule extends BaseModule {
if (sentence.kind === 'image') {
this.revealImageBlock(element);
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
if (useWebGLBookReveal) {
await this.prepareWebGLBookReveal(sentence);
}
await this.playbackCoordinator.play(sentence);
if (useWebGLBookReveal && sentence.blockId != null) {
this.markBlockRendered(sentence.blockId);
}
} else if (sentence.kind === 'music') {
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
}
@@ -1028,6 +1038,42 @@ class UIDisplayHandlerModule extends BaseModule {
}
}
isWebGLMode() {
return document.body?.dataset?.webglUiMode === '3d'
|| document.body?.classList?.contains('webgl-mode');
}
async prepareWebGLBookReveal(sentence) {
const bookPagination = this.getModule('book-pagination');
const bookTextureRenderer = this.getModule('book-texture-renderer');
if (!bookPagination || !bookTextureRenderer || sentence.blockId == null) return;
if (typeof bookPagination.preparePendingBlock === 'function') {
await bookPagination.preparePendingBlock(sentence);
} else {
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
detail: {
block: sentence
}
}));
}
const revealDetail = {
id: sentence.id,
blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0
};
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(revealDetail);
} else {
document.dispatchEvent(new CustomEvent('book-texture:prepare-reveal-block', {
detail: revealDetail
}));
}
}
async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
console.log('UIDisplayHandler: Re-typesetting story after page resize');
@@ -1097,7 +1143,8 @@ class UIDisplayHandlerModule extends BaseModule {
renderedItemsTarget = this.renderedItems,
token = null,
recordMetrics = true,
generation = this.displayGeneration
generation = this.displayGeneration,
deferRenderedMark = false
} = options;
if (!item || !this.paragraphContainer) return null;
const renderable = await this.prepareRenderableBlock(item);
@@ -1144,7 +1191,7 @@ class UIDisplayHandlerModule extends BaseModule {
}
if (item.blockId != null) {
element.dataset.storyBlockId = String(item.blockId);
this.markBlockRendered(item.blockId);
if (!deferRenderedMark) this.markBlockRendered(item.blockId);
}
element.dataset.lineStart = String(renderable.lineStart);
element.dataset.lineCount = String(renderable.lineCount);
+108 -61
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-shader-reveal';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-queued-mask-reveal';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -184,17 +184,15 @@ let pendingPageFlips = 0;
const paperColor = new THREE.Color(0xece4ca);
const inkColor = '#1a1009';
const maxRevealWords = 128;
const completedRevealElapsedMs = 1000000000;
await reportLabStep(48, 'Preparing high-resolution page textures');
const leftCanvas = createPageCanvas('left');
const rightCanvas = createPageCanvas('right');
const leftRevealCanvas = createPageCanvas('left');
const rightRevealCanvas = createPageCanvas('right');
const leftTexture = new THREE.CanvasTexture(leftCanvas);
const rightTexture = new THREE.CanvasTexture(rightCanvas);
const leftRevealTexture = new THREE.CanvasTexture(leftRevealCanvas);
const rightRevealTexture = new THREE.CanvasTexture(rightRevealCanvas);
[leftTexture, rightTexture, leftRevealTexture, rightRevealTexture].forEach((texture) => {
[leftTexture, rightTexture].forEach((texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
@@ -205,14 +203,6 @@ const pageRevealState = {
left: null,
right: null
};
const pageRevealCanvases = {
left: leftRevealCanvas,
right: rightRevealCanvas
};
const pageRevealTextures = {
left: leftRevealTexture,
right: rightRevealTexture
};
await reportLabStep(52, 'Generating leather texture set');
const leatherTextures = createLeatherTextures();
await reportLabStep(56, 'Generating spine cloth texture set');
@@ -357,12 +347,10 @@ const materials = {
})
};
materials.leftPage.userData.bookPageReveal = {
side: 'left',
texture: leftRevealTexture
side: 'left'
};
materials.rightPage.userData.bookPageReveal = {
side: 'right',
texture: rightRevealTexture
side: 'right'
};
materials.spineCloth.userData.isSpineCloth = true;
materials.headband.userData.isHeadband = true;
@@ -475,6 +463,12 @@ window.BookLabDebug = {
window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
document.addEventListener('webgl-book:page-reveal-start', (event) => {
startPageRevealForBlock(event.detail?.blockId);
});
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
fastForwardPageReveals(event.detail?.blockIds || []);
});
installBookControls();
installCameraControls();
resize();
@@ -568,10 +562,12 @@ function configureBookShadowReceiver(material, strength) {
shader.uniforms.bookShadowReceiverStrength = { value: strength };
shader.uniforms.bookTableTopY = { value: tableTopY };
if (pageReveal) {
shader.uniforms.bookRevealMap = { value: pageReveal.texture };
shader.uniforms.bookRevealActive = { value: 0 };
shader.uniforms.bookRevealProgress = { value: 1 };
shader.uniforms.bookRevealBounds = { value: new THREE.Vector4(0, 0, 1, 1) };
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.bookRevealPaperColor = { value: paperColor.clone() };
shader.uniforms.bookRevealSoftness = { value: 0.035 };
material.userData.bookRevealShader = shader;
}
@@ -608,18 +604,29 @@ function configureBookShadowReceiver(material, strength) {
uniform vec2 bookShadowMapTexelSize;
uniform float bookShadowReceiverStrength;
uniform float bookTableTopY;
${pageReveal ? `uniform sampler2D bookRevealMap;
uniform float bookRevealActive;
uniform float bookRevealProgress;
uniform vec4 bookRevealBounds;
${pageReveal ? `uniform float bookRevealActive;
uniform float bookRevealElapsedMs;
uniform int bookRevealWordCount;
uniform vec4 bookRevealWordRects[128];
uniform vec4 bookRevealWordTimings[128];
uniform vec3 bookRevealPaperColor;
uniform float bookRevealSoftness;
float bookRevealMask(vec2 uv) {
vec2 local = (uv - bookRevealBounds.xy) / max(bookRevealBounds.zw, vec2(0.0001));
float bookRevealVisibleMask(vec2 uv) {
float hidden = 0.0;
for (int i = 0; i < 128; i++) {
if (i >= bookRevealWordCount) break;
vec4 rect = bookRevealWordRects[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);
float diagonal = clamp((local.x + (1.0 - local.y)) * 0.5, 0.0, 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 feather = max(0.0001, bookRevealSoftness);
return inside * smoothstep(diagonal - feather, diagonal + feather, bookRevealProgress);
float visible = smoothstep(scan - feather, scan + feather, progress);
hidden = max(hidden, inside * (1.0 - visible));
}
return hidden;
}` : ''}
varying vec3 vBookReceiverWorldPosition;
varying vec3 vBookReceiverWorldNormal;
@@ -764,8 +771,10 @@ function configureBookShadowReceiver(material, strength) {
`#ifdef USE_MAP
vec4 sampledDiffuseColor = texture2D(map, vMapUv);
if (bookRevealActive > 0.5) {
vec4 revealDiffuseColor = texture2D(bookRevealMap, vMapUv);
sampledDiffuseColor = mix(sampledDiffuseColor, revealDiffuseColor, bookRevealMask(vMapUv));
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);
}
diffuseColor *= sampledDiffuseColor;
#endif`
@@ -1640,44 +1649,58 @@ function uploadPageTextureDirect(side, sourceCanvas) {
}
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const revealCanvas = pageRevealCanvases[side];
const revealTexture = pageRevealTextures[side];
if (!revealCanvas || !revealTexture) {
uploadPageTextureDirect(side, sourceCanvas);
return;
}
drawCanvasPageTexture(revealCanvas, sourceCanvas, side);
const canvas = side === 'left' ? leftCanvas : rightCanvas;
const texture = side === 'left' ? leftTexture : rightTexture;
const shader = getPageRevealShader(side);
if (!shader?.uniforms) {
uploadPageTextureDirect(side, sourceCanvas);
return;
}
const bounds = revealDetail.bounds || {};
const x = THREE.MathUtils.clamp(Number(bounds.x || 0), 0, 1);
const y = THREE.MathUtils.clamp(Number(bounds.y || 0), 0, 1);
const width = THREE.MathUtils.clamp(Number(bounds.width || 1), 0.001, 1);
const height = THREE.MathUtils.clamp(Number(bounds.height || 1), 0.001, 1);
shader.uniforms.bookRevealBounds.value.set(
x,
THREE.MathUtils.clamp(1 - y - height, 0, 1),
width,
height
);
shader.uniforms.bookRevealProgress.value = 0;
drawCanvasPageTexture(canvas, sourceCanvas, side);
texture.needsUpdate = true;
applyPageRevealWords(shader, revealDetail.wordRects || []);
shader.uniforms.bookRevealActive.value = 1;
shader.uniforms.bookRevealMap.value = revealTexture;
revealTexture.needsUpdate = true;
shader.uniforms.bookRevealElapsedMs.value = 0;
pageRevealState[side] = {
startedAt: performance.now(),
startedAt: revealDetail.startNow ? performance.now() : null,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
revealCanvas,
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
};
}
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 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);
const height = THREE.MathUtils.clamp(Number(rect.height || 0), 0, 1);
rectUniforms[index].set(
x,
THREE.MathUtils.clamp(1 - y - height, 0, 1),
Math.max(0.0001, width),
Math.max(0.0001, height)
);
timingUniforms[index].set(
Math.max(0, Number(timing.delay || 0)),
Math.max(1, Number(timing.duration || 1)),
0,
0
);
});
for (let index = source.length; index < maxRevealWords; index += 1) {
rectUniforms[index].set(0, 0, 0, 0);
timingUniforms[index].set(0, 1, 0, 0);
}
}
function getPageRevealShader(side) {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
return material?.userData?.bookRevealShader || null;
@@ -1688,10 +1711,34 @@ function clearPageReveal(side) {
const shader = getPageRevealShader(side);
if (shader?.uniforms?.bookRevealActive) {
shader.uniforms.bookRevealActive.value = 0;
shader.uniforms.bookRevealProgress.value = 1;
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
shader.uniforms.bookRevealWordCount.value = 0;
}
}
function startPageRevealForBlock(blockId) {
const id = String(blockId ?? '');
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state || state.startedAt != null) return;
if (!state.blockIds.map(value => String(value)).includes(id)) return;
state.startedAt = performance.now();
const shader = getPageRevealShader(side);
if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0;
});
}
function fastForwardPageReveals(blockIds = []) {
const ids = new Set((Array.isArray(blockIds) ? blockIds : []).map(value => String(value)));
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state) return;
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
if (!matches) return;
clearPageReveal(side);
});
}
function updatePageRevealAnimations(now) {
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
@@ -1701,14 +1748,14 @@ function updatePageRevealAnimations(now) {
clearPageReveal(side);
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.bookRevealProgress.value = progress;
shader.uniforms.bookRevealElapsedMs.value = Math.max(0, now - state.startedAt);
if (progress < 1) return;
const canvas = side === 'left' ? leftCanvas : rightCanvas;
const texture = side === 'left' ? leftTexture : rightTexture;
drawCanvasPageTexture(canvas, state.revealCanvas, side);
texture.needsUpdate = true;
clearPageReveal(side);
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
detail: {
+15 -1
View File
@@ -5,6 +5,14 @@ const sourcePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-lab.js
const source = fs.readFileSync(sourcePath, 'utf8');
const proceduralBookPath = path.join(__dirname, '..', 'public', 'js', 'procedural-book-model.js');
const proceduralBookSource = fs.readFileSync(proceduralBookPath, 'utf8');
const textureRendererPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-renderer-module.js');
const textureRendererSource = fs.readFileSync(textureRendererPath, 'utf8');
const playbackCoordinatorPath = path.join(__dirname, '..', 'public', 'js', 'playback-coordinator-module.js');
const playbackCoordinatorSource = fs.readFileSync(playbackCoordinatorPath, 'utf8');
const uiDisplayHandlerPath = path.join(__dirname, '..', 'public', 'js', 'ui-display-handler-module.js');
const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8');
const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js');
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8');
const checks = [
['scene-level SSAO import', /SSAOPass/.test(source)],
@@ -29,7 +37,13 @@ const checks = [
['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)],
['debug AO remains scene-level', /scene debug: SSAO/.test(source)],
['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)],
['render readiness flag is exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source)]
['render readiness flag is exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source)],
['3D playback bypasses DOM word animation scheduling', /isWebGLPlaybackMode/.test(playbackCoordinatorSource) && /if \(this\.isWebGLPlaybackMode\(\)\)/.test(playbackCoordinatorSource) && /scheduleWebGLReveal/.test(playbackCoordinatorSource)],
['3D UI defers rendered history mark until playback completes', /deferRenderedMark/.test(uiDisplayHandlerSource) && /prepareWebGLBookReveal/.test(uiDisplayHandlerSource) && /markBlockRendered\(sentence\.blockId/.test(uiDisplayHandlerSource)],
['pagination can build a pending unrendered 3D block', /preparePendingBlock/.test(bookPaginationSource) && /book-pagination:prepare-block/.test(bookPaginationSource)],
['texture renderer has separate prepare and start reveal phases', /prepareRevealBlock/.test(textureRendererSource) && /startPreparedRevealAnimation/.test(textureRendererSource) && /webgl-book:page-reveal-start/.test(textureRendererSource)],
['texture renderer publishes per-word reveal coordinates', /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource) && /blockWordStart/.test(textureRendererSource)],
['page reveal shader uses coordinate mask instead of comparing page textures', /bookRevealWordRects/.test(source) && /bookRevealWordTimings/.test(source) && /bookRevealElapsedMs/.test(source) && !/texture2D\(bookRevealMap/.test(source)]
];
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB