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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:55:43 +02:00
1 parent 0c45682136
commit 10f92fde8c
1 file changed
+24
+24
View File
@@ -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');