Hold 60fps: throttle shadow/reflection passes when book geometry is static

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 09:39:29 +02:00
parent 1e8defbb55
commit b180637ea7
+16 -3
View File
@@ -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 && (