diff --git a/public/index.html b/public/index.html index 0fb4978..1f4af9a 100644 --- a/public/index.html +++ b/public/index.html @@ -280,6 +280,6 @@ console.log(message); }; - + diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index acd0d2d..9b5ffd9 100644 --- a/public/js/book-page-format-module.js +++ b/public/js/book-page-format-module.js @@ -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; diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index e06d3f6..18abd80 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -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; diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 81799a3..9388d90 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -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, diff --git a/public/js/loader.js b/public/js/loader.js index 1c84077..78b4fd1 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -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; /** diff --git a/public/js/playback-coordinator-module.js b/public/js/playback-coordinator-module.js index 65553bd..264eb86 100644 --- a/public/js/playback-coordinator-module.js +++ b/public/js/playback-coordinator-module.js @@ -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} - 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 diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 8622dfd..f09f4db 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -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); diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index d71cda0..b92e340 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -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 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); - float feather = max(0.0001, bookRevealSoftness); - return inside * smoothstep(diagonal - feather, diagonal + feather, bookRevealProgress); + 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); + 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); + 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: { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 2a7fdfb..f1adcdd 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -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); diff --git a/tmp-video-frames/contact.png b/tmp-video-frames/contact.png new file mode 100644 index 0000000..2dc381c Binary files /dev/null and b/tmp-video-frames/contact.png differ diff --git a/tmp-video-frames/frame-001.png b/tmp-video-frames/frame-001.png new file mode 100644 index 0000000..c811f7c Binary files /dev/null and b/tmp-video-frames/frame-001.png differ diff --git a/tmp-video-frames/frame-002.png b/tmp-video-frames/frame-002.png new file mode 100644 index 0000000..e1a7c95 Binary files /dev/null and b/tmp-video-frames/frame-002.png differ diff --git a/tmp-video-frames/frame-003.png b/tmp-video-frames/frame-003.png new file mode 100644 index 0000000..68101ce Binary files /dev/null and b/tmp-video-frames/frame-003.png differ diff --git a/tmp-video-frames/frame-004.png b/tmp-video-frames/frame-004.png new file mode 100644 index 0000000..eaeaec5 Binary files /dev/null and b/tmp-video-frames/frame-004.png differ diff --git a/tmp-video-frames/frame-005.png b/tmp-video-frames/frame-005.png new file mode 100644 index 0000000..8c36651 Binary files /dev/null and b/tmp-video-frames/frame-005.png differ diff --git a/tmp-video-frames/frame-006.png b/tmp-video-frames/frame-006.png new file mode 100644 index 0000000..b992dbc Binary files /dev/null and b/tmp-video-frames/frame-006.png differ diff --git a/tmp-video-frames/frame-007.png b/tmp-video-frames/frame-007.png new file mode 100644 index 0000000..9f5db57 Binary files /dev/null and b/tmp-video-frames/frame-007.png differ diff --git a/tmp-video-frames/frame-008.png b/tmp-video-frames/frame-008.png new file mode 100644 index 0000000..04311be Binary files /dev/null and b/tmp-video-frames/frame-008.png differ diff --git a/tmp-video-frames/frame-009.png b/tmp-video-frames/frame-009.png new file mode 100644 index 0000000..2c860f2 Binary files /dev/null and b/tmp-video-frames/frame-009.png differ diff --git a/tmp-video-frames/frame-010.png b/tmp-video-frames/frame-010.png new file mode 100644 index 0000000..97df7d2 Binary files /dev/null and b/tmp-video-frames/frame-010.png differ diff --git a/tmp-video-frames/frame-011.png b/tmp-video-frames/frame-011.png new file mode 100644 index 0000000..c9a8f8f Binary files /dev/null and b/tmp-video-frames/frame-011.png differ diff --git a/tmp-video-frames/frame-012.png b/tmp-video-frames/frame-012.png new file mode 100644 index 0000000..b04d573 Binary files /dev/null and b/tmp-video-frames/frame-012.png differ diff --git a/tmp-video-frames/frame-013.png b/tmp-video-frames/frame-013.png new file mode 100644 index 0000000..d8e0c07 Binary files /dev/null and b/tmp-video-frames/frame-013.png differ diff --git a/tmp-webgl-contact-small.png b/tmp-webgl-contact-small.png new file mode 100644 index 0000000..3aaec15 Binary files /dev/null and b/tmp-webgl-contact-small.png differ diff --git a/tmp-webgl-contact.png b/tmp-webgl-contact.png new file mode 100644 index 0000000..e41e64f Binary files /dev/null and b/tmp-webgl-contact.png differ diff --git a/tmp-webgl-cover-leather.png b/tmp-webgl-cover-leather.png new file mode 100644 index 0000000..54b7bc0 Binary files /dev/null and b/tmp-webgl-cover-leather.png differ diff --git a/tmp-webgl-normal-small.png b/tmp-webgl-normal-small.png new file mode 100644 index 0000000..9d765ce Binary files /dev/null and b/tmp-webgl-normal-small.png differ diff --git a/tmp-webgl-normal.png b/tmp-webgl-normal.png new file mode 100644 index 0000000..e41e64f Binary files /dev/null and b/tmp-webgl-normal.png differ diff --git a/tmp-webgl-ssao-small.png b/tmp-webgl-ssao-small.png new file mode 100644 index 0000000..a637cbf Binary files /dev/null and b/tmp-webgl-ssao-small.png differ diff --git a/tmp-webgl-ssao.png b/tmp-webgl-ssao.png new file mode 100644 index 0000000..e41e64f Binary files /dev/null and b/tmp-webgl-ssao.png differ diff --git a/tmp-webgl-visual-smoke.png b/tmp-webgl-visual-smoke.png new file mode 100644 index 0000000..88616af Binary files /dev/null and b/tmp-webgl-visual-smoke.png differ