From 10f92fde8c283a1cd2ffc40c3430f2f00e5d7889 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 09:55:43 +0200 Subject: [PATCH] perf(story-mode): repaint panels by need, not at display rate Every visible panel was repainted on every animation frame: five panels cost ~81% of one core (main thread, 2x screen, measured with Chrome's own task counters). Panels now repaint at 30 fps while their entrance plays, 15 fps for the newest panel of a running turn, and 6 fps once settled; a settled panel holds still after 20 s until the pointer passes over it. Clocks still run in real time. Measured: 11.6% in the first 10 s, 2.4% once the page has settled, 5.1% with the pointer on a panel. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/story-mode.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 68cc8a5d..b017ffb2 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -36,6 +36,17 @@ const STORY_POLL_MS = 4000; * art together in a centered stage instead of spreading it to both ends; the background still fills it. */ const STORY_STAGE_MAX = 680; +/** + * Redraw rates (frames per second). The clocks run in real time whatever the rate; only how often a + * panel is repainted changes. An entrance plays smoothly, the newest panel of a running turn stays + * lively, and a settled panel keeps its blinks and bobs at a fraction of the cost (measured: every + * panel at display rate cost ~80% of a core with five panels on a 2x screen). + */ +const STORY_FPS_INTRO = 30; +const STORY_FPS_LIVE = 15; +const STORY_FPS_SETTLED = 6; +/** A settled panel animates this long, then holds still until the pointer wakes it again. */ +const STORY_SETTLED_AWAKE_MS = 20000; /** * The comic engine: drawing kit, the agent character and one scene per panel kind. Pure drawing; @@ -1700,6 +1711,14 @@ Object.assign(CodemanApp.prototype, { for (const rt of st.panels) { if (!rt.visible) continue; rt.clock += (dt / 1000) * StoryArt.FPS; + const intro = rt.clock < StoryArt.HERO; + const fps = intro ? STORY_FPS_INTRO : rt.panel?.live ? STORY_FPS_LIVE : STORY_FPS_SETTLED; + if (!intro && !rt.panel?.live) { + rt.awakeUntil ??= t + STORY_SETTLED_AWAKE_MS; + if (t > rt.awakeUntil) continue; // holding still; the pointer wakes it + } + if (t < (rt.nextDrawAt || 0)) continue; + rt.nextDrawAt = t + 1000 / fps - 4; this._storyDrawPanel(rt); } } @@ -1903,6 +1922,11 @@ Object.assign(CodemanApp.prototype, { liveTag.textContent = 'LIVE'; liveTag.hidden = true; fig.append(canvas, cap, balloon, liveTag); + // A settled panel holds still after a while; pointing at it brings it back to life. + fig.addEventListener('pointerenter', () => { + const rt = this._story?.panels.find((p) => p.el === fig); + if (rt) rt.awakeUntil = performance.now() + STORY_SETTLED_AWAKE_MS; + }); let splash = null; if (kind === 'complete') { splash = document.createElement('div');