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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user