diff --git a/public/index.html b/public/index.html index d83f957..0fb4978 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 b4948e8..acd0d2d 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-paper-loader-fix'; +import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-shader-reveal'; export const BOOK_TEXTURE_WIDTH = 3072; @@ -20,13 +20,13 @@ class BookPageFormatModule extends BaseModule { margins: Object.freeze({ topIn: 0.46, bottomIn: 0.58, - innerBaseIn: 0.375, - innerMinIn: 0.44, - innerMaxIn: 0.68, - innerThicknessFactor: 0.25, - outerBaseIn: 0.44, - outerThicknessFactor: 0.04, - outerMaxIn: 0.5 + innerBaseIn: 0.42, + innerMinIn: 0.48, + innerMaxIn: 0.74, + innerThicknessFactor: 0.32, + outerBaseIn: 0.36, + outerThicknessFactor: 0.02, + outerMaxIn: 0.42 }), typography: Object.freeze({ fontFamily: '"EB Garamond", "EB Garamond 12", serif', diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 9961b36..81799a3 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -27,6 +27,9 @@ class BookTextureRendererModule extends BaseModule { this.currentSpread = null; this.activeAnimations = new Map(); this.revealedBlockIds = new Set(); + this.pendingRevealBlockIds = new Set(); + this.revealBounds = null; + this.revealPublishBlockIds = null; this.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 30; @@ -40,6 +43,7 @@ class BookTextureRendererModule extends BaseModule { 'drawPageLines', 'drawLine', 'drawWord', + 'recordRevealRect', 'getPageContent', 'buildLineSegments', 'startRevealAnimation', @@ -70,10 +74,18 @@ class BookTextureRendererModule extends BaseModule { this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged); this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady); this.addEventListener(document, 'book-pagination:spread-updated', (event) => { + const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.(); const latestBlockId = event.detail?.latestBlockId; const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); - if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) this.markPendingReveal(latestBlockId); - this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.()); + this.currentSpread = spread || { left: [], right: [] }; + if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { + this.markPendingReveal(latestBlockId); + const pendingSides = this.getBlockSides(latestBlockId); + const immediateSides = ['left', 'right'].filter(side => !pendingSides.includes(side)); + if (immediateSides.length) this.drawSpread(this.currentSpread, immediateSides); + return; + } + this.drawSpread(this.currentSpread); }); this.addEventListener(document, 'book-texture:reveal-block', (event) => { this.startRevealAnimation(event.detail || {}); @@ -108,12 +120,15 @@ class BookTextureRendererModule extends BaseModule { drawSpread(spread = null, sides = null) { this.currentSpread = spread || { left: [], right: [] }; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; + this.revealBounds = { left: null, right: null }; sidesToDraw.forEach((side) => { if (!this.canvases[side]) return; this.drawPageBase(side); this.drawPageLines(side, this.currentSpread?.[side] || []); }); this.publishSpread(sidesToDraw); + this.revealBounds = null; + this.revealPublishBlockIds = null; } drawPageBase(side) { @@ -181,26 +196,20 @@ class BookTextureRendererModule extends BaseModule { ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; if (lineRecord.dropCapText) { ctx.save(); - const alpha = this.getWordAlpha(lineRecord, 0); - if (alpha <= 0) { - ctx.restore(); - } else { - ctx.globalAlpha *= alpha; - ctx.font = `${Math.round(fontPx * 2.68)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; - ctx.textBaseline = 'top'; - ctx.fillText( - String(lineRecord.dropCapText), - content.x, - content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25) - ); - ctx.restore(); - } + const dropCapFontPx = Math.round(fontPx * 2.68); + const dropCapX = content.x; + const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25); + 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); + ctx.restore(); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; } this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => { - this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex); + this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx); }); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px'; @@ -262,33 +271,36 @@ class BookTextureRendererModule extends BaseModule { return segments; } - drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) { - const alpha = this.getWordAlpha(lineRecord, localWordIndex); - if (alpha <= 0) return; - const previousAlpha = ctx.globalAlpha; - ctx.globalAlpha = previousAlpha * alpha; + drawWord(ctx, value, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx) { ctx.fillText(value, x, baseY); - ctx.globalAlpha = previousAlpha; + const width = ctx.measureText(value).width || fontPx; + this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex); } - getWordAlpha(lineRecord, localWordIndex) { - const animation = this.activeAnimations.get(String(lineRecord.blockId ?? '')); - if (!animation) { - return 1; - } - - const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex; - const timing = animation.wordTimings[globalWordIndex]; - if (!timing) { - return animation.completed ? 1 : 0; - } - - const elapsed = animation.completed - ? Number.POSITIVE_INFINITY - : performance.now() - animation.startedAt; - const duration = Math.max(1, Number(timing.duration || 1)); - const progress = Math.max(0, Math.min(1, (elapsed - Number(timing.delay || 0)) / duration)); - return progress; + 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]) + }; } startRevealAnimation(detail = {}) { @@ -298,8 +310,14 @@ class BookTextureRendererModule extends BaseModule { blockId, wordTimings: detail.wordTimings, startedAt: performance.now(), + totalDuration: Math.max( + Number(detail.totalDuration || 0), + ...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)) + ), completed: false }); + this.pendingRevealBlockIds.delete(String(blockId)); + this.revealPublishBlockIds = new Set([String(blockId)]); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); this.requestAnimationFrame(); } @@ -314,12 +332,14 @@ class BookTextureRendererModule extends BaseModule { } }); if (changed) { + this.pendingRevealBlockIds.clear(); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true)); } } stopAnimations() { this.activeAnimations.clear(); + this.pendingRevealBlockIds.clear(); if (this.animationFrameId) { clearTimeout(this.animationFrameId); this.animationFrameId = null; @@ -352,12 +372,7 @@ class BookTextureRendererModule extends BaseModule { markPendingReveal(blockId) { const id = String(blockId ?? ''); if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return; - this.activeAnimations.set(id, { - blockId, - wordTimings: [], - startedAt: performance.now(), - completed: false - }); + this.pendingRevealBlockIds.add(id); } requestAnimationFrame() { @@ -387,7 +402,6 @@ class BookTextureRendererModule extends BaseModule { hasActive = true; } }); - this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true)); if (hasActive) this.requestAnimationFrame(); } @@ -399,6 +413,28 @@ class BookTextureRendererModule extends BaseModule { }; if (sidesToPublish.includes('left')) detail.left = this.canvases.left; if (sidesToPublish.includes('right')) detail.right = this.canvases.right; + 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, + 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) + } + }; + }); + if (Object.keys(reveal).length) detail.reveal = reveal; document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail })); diff --git a/public/js/loader.js b/public/js/loader.js index b164408..1c84077 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-paper-loader-fix'; +const MODULE_CACHE_BUSTER = '20260607-webgl-shader-reveal'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 722a16e..d71cda0 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-paper-loader-fix'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-shader-reveal'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -188,15 +188,31 @@ const inkColor = '#1a1009'; 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); -[leftTexture, rightTexture].forEach((texture) => { +const leftRevealTexture = new THREE.CanvasTexture(leftRevealCanvas); +const rightRevealTexture = new THREE.CanvasTexture(rightRevealCanvas); +[leftTexture, rightTexture, leftRevealTexture, rightRevealTexture].forEach((texture) => { texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = maxTextureAnisotropy; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = true; }); +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'); @@ -340,6 +356,14 @@ const materials = { envMapIntensity: 0 }) }; +materials.leftPage.userData.bookPageReveal = { + side: 'left', + texture: leftRevealTexture +}; +materials.rightPage.userData.bookPageReveal = { + side: 'right', + texture: rightRevealTexture +}; materials.spineCloth.userData.isSpineCloth = true; materials.headband.userData.isHeadband = true; configureHardcoverPaperMaterial(materials.pageBlock); @@ -535,13 +559,22 @@ function configureBookShadowReceiver(material, strength) { const isSpineCloth = material.userData?.isSpineCloth === true; const isHardcoverPaper = material.userData?.isHardcoverPaper === true; const isHeadband = material.userData?.isHeadband === true; - material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`; + const pageReveal = material.userData?.bookPageReveal || null; + material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v1' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`; material.onBeforeCompile = (shader) => { shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) }; 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.bookRevealSoftness = { value: 0.035 }; + material.userData.bookRevealShader = shader; + } shader.vertexShader = shader.vertexShader .replace( @@ -575,6 +608,19 @@ 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; + 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); + }` : ''} varying vec3 vBookReceiverWorldPosition; varying vec3 vBookReceiverWorldNormal; ${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''} @@ -712,6 +758,19 @@ function configureBookShadowReceiver(material, strength) { outgoingLight += bookLocalBounce(vBookReceiverWorldPosition, normalize(vBookReceiverWorldNormal), bookReceiverShadow, diffuseColor.rgb); #include ` ); + if (pageReveal) { + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#ifdef USE_MAP + vec4 sampledDiffuseColor = texture2D(map, vMapUv); + if (bookRevealActive > 0.5) { + vec4 revealDiffuseColor = texture2D(bookRevealMap, vMapUv); + sampledDiffuseColor = mix(sampledDiffuseColor, revealDiffuseColor, bookRevealMask(vMapUv)); + } + diffuseColor *= sampledDiffuseColor; + #endif` + ); + } }; } @@ -1551,12 +1610,18 @@ function syncBookControls() { function handlePageCanvases(event) { const detail = event.detail || {}; if (detail.left) { - drawCanvasPageTexture(leftCanvas, detail.left, 'left'); - leftTexture.needsUpdate = true; + if (detail.reveal?.left) { + beginPageReveal('left', detail.left, detail.reveal.left); + } else { + uploadPageTextureDirect('left', detail.left); + } } if (detail.right) { - drawCanvasPageTexture(rightCanvas, detail.right, 'right'); - rightTexture.needsUpdate = true; + if (detail.reveal?.right) { + beginPageReveal('right', detail.right, detail.reveal.right); + } else { + uploadPageTextureDirect('right', detail.right); + } } markStaticSceneBuffersDirty(); document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ @@ -1566,6 +1631,94 @@ function handlePageCanvases(event) { }); } +function uploadPageTextureDirect(side, sourceCanvas) { + const canvas = side === 'left' ? leftCanvas : rightCanvas; + const texture = side === 'left' ? leftTexture : rightTexture; + clearPageReveal(side); + drawCanvasPageTexture(canvas, sourceCanvas, side); + texture.needsUpdate = true; +} + +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 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; + shader.uniforms.bookRevealActive.value = 1; + shader.uniforms.bookRevealMap.value = revealTexture; + revealTexture.needsUpdate = true; + + pageRevealState[side] = { + startedAt: performance.now(), + durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), + revealCanvas, + blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [] + }; +} + +function getPageRevealShader(side) { + const material = side === 'left' ? materials.leftPage : materials.rightPage; + return material?.userData?.bookRevealShader || null; +} + +function clearPageReveal(side) { + pageRevealState[side] = null; + const shader = getPageRevealShader(side); + if (shader?.uniforms?.bookRevealActive) { + shader.uniforms.bookRevealActive.value = 0; + shader.uniforms.bookRevealProgress.value = 1; + } +} + +function updatePageRevealAnimations(now) { + ['left', 'right'].forEach((side) => { + const state = pageRevealState[side]; + if (!state) return; + const shader = getPageRevealShader(side); + if (!shader?.uniforms) { + clearPageReveal(side); + return; + } + const progress = THREE.MathUtils.clamp((now - state.startedAt) / state.durationMs, 0, 1); + shader.uniforms.bookRevealProgress.value = progress; + 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: { + side, + blockIds: state.blockIds + } + })); + }); +} + function drawCanvasPageTexture(canvas, sourceCanvas, side) { const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f2ead0'; @@ -2998,6 +3151,7 @@ function animate(now = performance.now()) { const hadActiveFlips = activeFlips.length > 0; updateActiveFlips(performance.now()); if (hadActiveFlips) markStaticSceneBuffersDirty(); + updatePageRevealAnimations(now); updateCandleShadowUniforms(); renderedFrameCount += 1; const shadowStartedAt = performance.now();