From 431e305df92ce08c80c4de1c3dfe549cae616a55 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 6 Jun 2026 15:37:44 +0200 Subject: [PATCH] Add WebGL FPS cap and texture word reveal --- public/js/book-pagination-module.js | 13 ++- public/js/book-texture-renderer-module.js | 124 +++++++++++++++++++++- public/js/loader.js | 2 +- public/js/playback-coordinator-module.js | 15 +++ public/js/webgl-book-lab.js | 83 ++++++++++++--- 5 files changed, 219 insertions(+), 18 deletions(-) diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 1a5e304..1298dea 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -77,9 +77,12 @@ class BookPaginationModule extends BaseModule { const layout = this.layoutTextBlock(block, type); if (!layout?.lines?.length) return; + let blockWordCursor = 0; + cursorLine += layout.topSpaceLines; layout.lines.forEach((line) => { const geometry = this.getLineGeometry(cursorLine); + const lineWordCount = line.nodes.filter(node => node?.type === 'box' && node.value).length; if (!spreads[geometry.spreadIndex]) { spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] }; } @@ -93,10 +96,13 @@ class BookPaginationModule extends BaseModule { pageLine: geometry.pageLine, fontPx: layout.fontPx, lineHeightPx: layout.lineHeightPx, - fontStyle: layout.fontStyle + fontStyle: layout.fontStyle, + blockWordStart: blockWordCursor }); + blockWordCursor += lineWordCount; cursorLine += 1; }); + cursorLine += layout.bottomSpaceLines; }); return spreads.filter(Boolean); @@ -109,6 +115,8 @@ class BookPaginationModule extends BaseModule { const typography = this.metrics.typography; const role = block.role || block.metadata?.role || (type === 'heading' ? 'chapter-heading' : 'body'); const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading'; + const topSpaceLines = role === 'chapter-heading' ? 2 : role === 'section-heading' || block.addTopSpace || block.metadata?.addTopSpace ? 1 : 0; + const bottomSpaceLines = role === 'chapter-heading' || role === 'section-heading' ? 1 : 0; const lineHeightPx = Math.max(1, Number(this.metrics.typographyLineHeightPx || 1)); const fontPx = Math.max(1, Number(this.metrics.bodyFontSizePx || lineHeightPx / 1.5)); const indent = (isHeading || block.isFirstParagraphInChapter || block.metadata?.isFirstParagraphInChapter || block.addTopSpace) @@ -123,6 +131,7 @@ class BookPaginationModule extends BaseModule { measures, fontSize: `${fontPx}px`, fontFamily: typography.fontFamily, + fontFeatureSettings: '"kern" on, "liga" on, "onum" on, "pnum" on, "dlig" on, "clig" on, "calt" on', lineHeightPx, lineHeight: lineHeightPx / fontPx }); @@ -133,6 +142,8 @@ class BookPaginationModule extends BaseModule { fontPx, lineHeightPx, fontStyle: isHeading ? 'italic' : 'normal', + topSpaceLines, + bottomSpaceLines, lines: this.extractLines(layout, { measures, lineOffsets, diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 9bec7d0..c46b864 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -24,6 +24,11 @@ class BookTextureRendererModule extends BaseModule { left: [], right: [] }; + this.currentSpread = null; + this.activeAnimations = new Map(); + this.animationFrameId = null; + this.lastAnimationFrameAt = 0; + this.targetFrameDurationMs = 1000 / 30; this.bindMethods([ 'initialize', @@ -33,6 +38,12 @@ class BookTextureRendererModule extends BaseModule { 'drawPageBase', 'drawPageLines', 'drawLine', + 'drawWord', + 'startRevealAnimation', + 'fastForwardAnimations', + 'stopAnimations', + 'requestAnimationFrame', + 'tickAnimations', 'publishSpread', 'getPageCanvas', 'getHitMap', @@ -51,6 +62,15 @@ class BookTextureRendererModule extends BaseModule { this.addEventListener(document, 'book-pagination:spread-updated', (event) => { this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.()); }); + this.addEventListener(document, 'book-texture:reveal-block', (event) => { + this.startRevealAnimation(event.detail || {}); + }); + this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); + this.addEventListener(document, 'ui:command', (event) => { + if (event.detail?.type === 'continue') this.fastForwardAnimations(); + }); + this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations); + this.addEventListener(document, 'story:history-restoring', this.stopAnimations); this.reportProgress(100, 'Book texture renderer ready'); return true; } @@ -73,10 +93,11 @@ class BookTextureRendererModule extends BaseModule { } drawSpread(spread = null) { + this.currentSpread = spread || { left: [], right: [] }; this.drawPageBase('left'); this.drawPageBase('right'); - this.drawPageLines('left', spread?.left || []); - this.drawPageLines('right', spread?.right || []); + this.drawPageLines('left', this.currentSpread?.left || []); + this.drawPageLines('right', this.currentSpread?.right || []); this.publishSpread(); } @@ -112,6 +133,8 @@ class BookTextureRendererModule extends BaseModule { ctx.save(); ctx.fillStyle = 'rgba(31, 19, 10, 0.86)'; ctx.textBaseline = 'alphabetic'; + if ('fontKerning' in ctx) ctx.fontKerning = 'normal'; + if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal'; lines.forEach(line => this.drawLine(ctx, line)); ctx.restore(); } @@ -133,6 +156,7 @@ class BookTextureRendererModule extends BaseModule { ? Math.max(0, (metrics.content.width - naturalWidth) / 2) : Number(line.offset || 0); let x = metrics.content.x + centerOffset; + let wordIndex = 0; ctx.font = `${fontStyle}${fontPx}px ${metrics.typography.fontFamily}`; nodes.forEach((node, index) => { @@ -140,8 +164,9 @@ class BookTextureRendererModule extends BaseModule { if (node.type === 'box' && node.value) { const nextNode = nodes[index + 1]; const value = `${node.value}${nextNode?.type === 'penalty' && nextNode.penalty === 100 ? '-' : ''}`; - ctx.fillText(value, x, baseY); + this.drawWord(ctx, value, x, baseY, lineRecord, wordIndex); x += Number(node.width || ctx.measureText(value).width || 0); + wordIndex += 1; } else if (node.type === 'glue' && node.width !== 0) { let width = Number(node.width || 0); if (ratio > 0) width += Number(node.stretch || 0) * ratio; @@ -151,6 +176,99 @@ class BookTextureRendererModule extends BaseModule { }); } + drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) { + const animation = this.activeAnimations.get(String(lineRecord.blockId ?? '')); + if (!animation) { + ctx.globalAlpha = 1; + ctx.fillText(value, x, baseY); + return; + } + + const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex; + const timing = animation.wordTimings[globalWordIndex]; + if (!timing) { + ctx.globalAlpha = animation.completed ? 1 : 0; + ctx.fillText(value, x, baseY); + ctx.globalAlpha = 1; + return; + } + + 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)); + if (progress <= 0) return; + + const previousAlpha = ctx.globalAlpha; + ctx.globalAlpha = previousAlpha * progress; + ctx.fillText(value, x, baseY); + ctx.globalAlpha = previousAlpha; + } + + startRevealAnimation(detail = {}) { + const blockId = detail.blockId ?? detail.id ?? null; + if (blockId == null || !Array.isArray(detail.wordTimings)) return; + this.activeAnimations.set(String(blockId), { + blockId, + wordTimings: detail.wordTimings, + startedAt: performance.now(), + completed: false + }); + this.requestAnimationFrame(); + } + + fastForwardAnimations() { + let changed = false; + this.activeAnimations.forEach((animation) => { + if (!animation.completed) { + animation.completed = true; + changed = true; + } + }); + if (changed) { + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); + } + } + + stopAnimations() { + this.activeAnimations.clear(); + if (this.animationFrameId) { + clearTimeout(this.animationFrameId); + this.animationFrameId = null; + } + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); + } + + requestAnimationFrame() { + if (this.animationFrameId) return; + this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs); + } + + tickAnimations(now) { + this.animationFrameId = null; + if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) { + this.requestAnimationFrame(); + return; + } + this.lastAnimationFrameAt = now; + + let hasActive = false; + const currentNow = performance.now(); + this.activeAnimations.forEach((animation) => { + if (animation.completed) return; + const lastTiming = animation.wordTimings.at(-1); + const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0); + if (currentNow - animation.startedAt >= total + 50) { + animation.completed = true; + } else { + hasActive = true; + } + }); + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); + if (hasActive) this.requestAnimationFrame(); + } + publishSpread() { document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail: { diff --git a/public/js/loader.js b/public/js/loader.js index b63cc65..5211b60 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260606-webgl-overlay-page-layout'; +const MODULE_CACHE_BUSTER = '20260606-webgl-fps-texture-animation'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/playback-coordinator-module.js b/public/js/playback-coordinator-module.js index 9645fd1..65553bd 100644 --- a/public/js/playback-coordinator-module.js +++ b/public/js/playback-coordinator-module.js @@ -241,6 +241,15 @@ class PlaybackCoordinatorModule extends BaseModule { }; }); } + 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 @@ -350,6 +359,12 @@ class PlaybackCoordinatorModule extends BaseModule { console.log('PlaybackCoordinator: Fast forwarding'); this.accelerateActiveWordAnimations(this.currentSentence); + document.dispatchEvent(new CustomEvent('book-texture:fast-forward', { + detail: { + id: this.currentSentence?.id, + blockId: this.currentSentence?.blockId ?? this.currentSentence?.metadata?.blockId ?? null + } + })); const animQueue = this.getModule('animation-queue'); if (animQueue) { diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 59c5349..ba38f2c 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=20260606-webgl-overlay-page-layout'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-fps-texture-animation'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -25,12 +25,13 @@ const appInitialState = window.WebGLBookInitialState || {}; const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; const isAppIntegrationMode = appInitialState.appMode === true; +const appRenderPixelRatio = isAppIntegrationMode ? 0.5 : Math.min(window.devicePixelRatio || 1, 2); const labStatus = document.getElementById('lab_status'); if (labStatus && tableDebugMode !== tableDebugModes.none) { labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`; } const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); -renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); +renderer.setPixelRatio(appRenderPixelRatio); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.12; @@ -39,7 +40,7 @@ renderer.shadowMap.type = THREE.VSMShadowMap; const generatedTextureCanvases = {}; const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); -const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2); +const reflectionPixelRatio = isAppIntegrationMode ? 0.28 : Math.min(window.devicePixelRatio || 1, 2); const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200; const reflectionTargetSize = new THREE.Vector2(); const pageRaycaster = new THREE.Raycaster(); @@ -64,13 +65,13 @@ let tableDustTexture = null; let tableGreaseTexture = null; const tableTopY = -0.02; const bookTableContactClearance = 0.002; -const tableReflectionBaseWidth = isAppIntegrationMode ? 1280 : 4096; -const tableReflectionBaseHeight = isAppIntegrationMode ? 720 : 2304; +const tableReflectionBaseWidth = isAppIntegrationMode ? 480 : 4096; +const tableReflectionBaseHeight = isAppIntegrationMode ? 270 : 2304; const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, - samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 2 : 8) : 0 + samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 0 : 8) : 0 }); tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace; tableReflectionTarget.texture.minFilter = THREE.LinearFilter; @@ -89,7 +90,7 @@ const reflectionUp = new THREE.Vector3(); const candleShadowSources = []; const candleWorldPosition = new THREE.Vector3(); const flameWorldPosition = new THREE.Vector3(); -const bookShadowMapSize = isAppIntegrationMode ? 512 : 1536; +const bookShadowMapSize = isAppIntegrationMode ? 128 : 1536; const bookShadowTargets = Array.from({ length: 3 }, () => { const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, { colorSpace: THREE.NoColorSpace, @@ -141,6 +142,12 @@ updateCameraRig(0); configureScenePostprocessing(); const clock = new THREE.Clock(); +const targetFrameDurationMs = 1000 / 30; +let lastRenderFrameAt = 0; +let fpsDisplay = null; +let fpsWindowStartedAt = performance.now(); +let fpsWindowFrames = 0; +const lastFrameTiming = {}; const book = new THREE.Group(); scene.add(book); const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1); @@ -421,8 +428,33 @@ installBookControls(); installCameraControls(); resize(); document.dispatchEvent(new CustomEvent('webgl-book:scene-ready')); +ensureFpsDisplay(); animate(); +function ensureFpsDisplay() { + if (fpsDisplay) return fpsDisplay; + fpsDisplay = document.createElement('div'); + fpsDisplay.id = 'webgl_fps_display'; + Object.assign(fpsDisplay.style, { + position: 'fixed', + top: '0.65rem', + right: '0.75rem', + zIndex: '80', + minWidth: '4.2rem', + padding: '0.22rem 0.42rem', + border: '1px solid rgba(246, 231, 201, 0.28)', + background: 'rgba(10, 7, 4, 0.62)', + color: 'rgba(255, 238, 202, 0.94)', + font: '12px ui-monospace, SFMono-Regular, Consolas, monospace', + lineHeight: '1.2', + textAlign: 'right', + pointerEvents: 'none' + }); + fpsDisplay.textContent = '0 fps'; + document.body.appendChild(fpsDisplay); + return fpsDisplay; +} + function buildTable() { const tableTexture = new THREE.TextureLoader().load('/assets/webgl/wood_table_diff_1k.jpg'); tableTexture.colorSpace = THREE.SRGBColorSpace; @@ -661,14 +693,14 @@ function configureScenePostprocessing() { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, - samples: renderer.capabilities.isWebGL2 ? 8 : 0 + samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 0 : 8) : 0 }); sceneComposerTarget.texture.colorSpace = THREE.SRGBColorSpace; sceneComposerTarget.texture.minFilter = THREE.LinearFilter; sceneComposerTarget.texture.magFilter = THREE.LinearFilter; composer = new EffectComposer(renderer, sceneComposerTarget); - composer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + composer.setPixelRatio(appRenderPixelRatio); sceneRenderPass = new RenderPass(scene, camera); composer.addPass(sceneRenderPass); @@ -2543,7 +2575,7 @@ function resize() { camera.aspect = width / height; camera.updateProjectionMatrix(); const desiredReflectionScale = reflectionPixelRatio * 1.5; - const reflectionScale = Math.max(1, Math.min( + const reflectionScale = Math.max(isAppIntegrationMode ? 0.35 : 1, Math.min( desiredReflectionScale, 4096 / width, 2304 / height @@ -2812,9 +2844,17 @@ function renderMirrorDebugView() { }); } -function animate() { - requestAnimationFrame(animate); - const delta = clock.getDelta(); +function animate(now = performance.now()) { + const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs; + if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) { + setTimeout(animate, Math.max(1, targetFrameDurationMs - elapsedSinceLastFrame)); + return; + } + const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs; + lastRenderFrameAt = now; + setTimeout(animate, targetFrameDurationMs); + const delta = Math.min(0.1, frameElapsedMs / 1000); + clock.getDelta(); const t = clock.elapsedTime; updateCameraRig(delta); scene.traverse((object) => { @@ -2847,12 +2887,17 @@ function animate() { updateActiveFlips(performance.now()); updateCandleShadowUniforms(); renderedFrameCount += 1; + const shadowStartedAt = performance.now(); if (!isAppIntegrationMode || renderedFrameCount % 6 === 1 || activeFlips.length > 0) { updateBookShadowMaps(); } + lastFrameTiming.shadows = performance.now() - shadowStartedAt; + const reflectionStartedAt = performance.now(); if (!isAppIntegrationMode || renderedFrameCount % 4 === 1 || cameraRig.navigationActive || activeFlips.length > 0) { updateTableReflection(); } + lastFrameTiming.reflection = performance.now() - reflectionStartedAt; + const renderStartedAt = performance.now(); if (tableDebugMode === tableDebugModes.mirror) { renderer.setRenderTarget(null); renderer.clear(); @@ -2862,6 +2907,18 @@ function animate() { } else { renderer.render(scene, camera); } + lastFrameTiming.render = performance.now() - renderStartedAt; + lastFrameTiming.total = lastFrameTiming.shadows + lastFrameTiming.reflection + lastFrameTiming.render; window.BookLabDebug.renderedFrames += 1; window.BookLabDebug.ready = true; + fpsWindowFrames += 1; + if (now - fpsWindowStartedAt >= 500) { + const fps = Math.round((fpsWindowFrames * 1000) / Math.max(1, now - fpsWindowStartedAt)); + ensureFpsDisplay().textContent = `${fps} fps`; + document.documentElement.dataset.webglFps = String(fps); + fpsWindowFrames = 0; + fpsWindowStartedAt = now; + } + document.documentElement.dataset.webglRenderedFrames = String(window.BookLabDebug.renderedFrames); + document.documentElement.dataset.webglFrameTiming = JSON.stringify(lastFrameTiming); }