From b180637ea7f2ace97999f7144a588f841edc3aa9 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 19 Jun 2026 09:39:29 +0200 Subject: [PATCH] Hold 60fps: throttle shadow/reflection passes when book geometry is static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table reflection (full scene re-render) and the book shadow maps each cost ~11ms/frame and were refreshing at 30Hz even when nothing moved, so every idle/reveal frame paid for one heavy pass on top of the ~12ms scene render — ~45-52fps. These passes only need full-rate updates while the book geometry is actually moving (a page flip). At idle, or during a text reveal where only the page texture mask animates, they now refresh at 8Hz (candle flicker is the only thing changing them then, captured imperceptibly). Most non-flip frames are then just the scene render. pixelRatio is deliberately left at 2x: the book is tilted, so page glyphs are minified along the tilt and the supersampling is the scene's antialiasing (the composer MSAA is disabled in app-integration mode). Reducing it blurs text and exposes edge aliasing, so 60fps is bought from the geometry-independent passes instead. Expressed pixelRatio via devicePixelRatio so it stays native on HiDPI. Verified live at WQHD/2x (screenshot-checked crisp text + clean edges): idle ~64fps median (was 52), reveal ~66fps median (was ~33). Remaining single-digit dips are main-thread page rasterization during background prepare — addressed by the worker migration. Co-Authored-By: Claude Opus 4.8 --- public/js/webgl-book-lab.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index ae2b53f..0e2d33a 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -25,7 +25,12 @@ const appInitialState = window.WebGLBookInitialState || {}; const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; const isAppIntegrationMode = appInitialState.appMode === true; -const appRenderPixelRatio = 2; +// Render scale. The book is tilted, so page glyphs are minified along the tilt and need +// supersampling to stay crisp — 2× is the established sharp baseline. This is deliberately +// NOT reduced for performance (that blurs text); 60fps comes from the reflection/shadow +// passes instead, whose cost is independent of this scale. +const renderSupersample = 2; +const appRenderPixelRatio = Math.min((window.devicePixelRatio || 1) * renderSupersample, 2); const labStatus = document.getElementById('lab_status'); if (labStatus && tableDebugMode !== tableDebugModes.none) { labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`; @@ -127,6 +132,12 @@ const bookShadowBiasMatrix = new THREE.Matrix4().set( 0, 0, 0, 1 ); const dynamicBufferRefreshIntervalMs = 1000 / 30; +// Shadows and the table reflection only need full-rate updates while the book geometry is +// moving (a page flip). When the geometry is static — idle, or a text reveal where only the +// page texture mask animates — the soft shadows/reflection refresh far less often, so those +// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing +// changing them then, which 8Hz captures imperceptibly. +const staticGeometryBufferRefreshIntervalMs = 1000 / 8; const flipDynamicBufferGraceMs = 180; let lastBookShadowRefreshAt = -Infinity; let lastTableReflectionRefreshAt = -Infinity; @@ -4686,11 +4697,13 @@ function animate(now = performance.now()) { ? Math.min(...activeFlips.map(flip => Math.max(0, now - Number(flip.startTime || now)))) : Infinity; const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs; + const geometryAnimating = activeFlips.length > 0; + const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs; const shadowRefreshDue = !deferDynamicBuffersForFlipStart && ( - forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= dynamicBufferRefreshIntervalMs + forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs ); const reflectionRefreshDue = !deferDynamicBuffersForFlipStart && ( - forceDynamicBufferRefresh || now - lastTableReflectionRefreshAt >= dynamicBufferRefreshIntervalMs + forceDynamicBufferRefresh || now - lastTableReflectionRefreshAt >= bufferRefreshIntervalMs ); const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue && !forceDynamicBufferRefresh; const refreshShadowsThisFrame = shadowRefreshDue && (