Skip to content

feat!: 60fps default cap + static-field parking - #9

Open
biering wants to merge 2 commits into
mainfrom
feat/maxfps-static-skip
Open

biering wants to merge 2 commits into
mainfrom
feat/maxfps-static-skip

Conversation

@biering

@biering biering commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a maxFps rendering cap (default 60) — excess rAF frames are skipped with the loop kept alive and skipped time folded into the next delta.
  • Park the render loop entirely when the active animation's speed is 0 — one frame is drawn, then the rAF chain stops until the next update()/visibility change.

Why

Benchmarked on a real wallpaper deployment (Apple powermetrics, M4 Pro): the uncapped loop runs at 120Hz on ProMotion displays for a field that moves imperceptibly per frame, and a paused (speed-0) field kept repainting identical frames at full rate — ~46% of one core while visible.

Benefits

  • ProMotion/120Hz embedders halve their render cost with zero visual change for ambient speeds.
  • speed: 0 now costs ~0 CPU instead of a full render loop — hosts can implement "pause" for free.

How

  • Throttle uses a separate lastDrawTime so lastFrameTime keeps accumulating delta across skipped frames (the random animation integrates deltaMs); drift correction keeps average fps ≈ target; update()/resize draws bypass the throttle and reset its window.
  • Static predicate is resolved[animation].speed === 0 (strict — negative speeds animate backwards), verified time-invariant per animation in new field tests (incl. arc breathe / ribbon bloom / random's post-init behavior). It funnels into the existing idleDrawn park machinery via shouldAnimate(), so visibility gates, resize, and update() re-wake paths all behave as before.
  • Companion fix: update() pins lastFrameTime when resuming from a park, so delta-based animations don't integrate the whole parked stretch in one frame.
  • bump-minor-pre-major added to release-please config → this releases as 0.7.0.

BREAKING: rendering is now capped at 60fps by default; pass maxFps: 0 for the old uncapped behavior.

Test plan

  • 6 new loop tests (fps band under cap, uncapped, static park + rAF stop, resume via update, static random init frame, update-inside-throttle-window) on the existing fake-rAF harness.
  • 4 new resolveConfig maxFps tests; 5 new field time-invariance tests.
  • pnpm --filter wavedance test — 68/68 green; typecheck + build green.

🤖 Generated with Claude Code

https://claude.ai/code/session_015oFoSH9ZnH1mheL8R7wNQY

biering and others added 2 commits August 24, 2026 11:50
…ctive speed is 0

Two rendering-cost changes measured on a real wallpaper deployment
(powermetrics, M4 Pro): uncapped rAF on ProMotion displays ran the loop at
120Hz, and a speed-0 (paused) field kept repainting identical frames.

- New `maxFps` config option: excess animation frames are skipped while the
  rAF chain stays alive; skipped time folds into the next frame's delta so
  delta-integrating animations stay correct. `0` disables the cap.
- When the active animation's speed is exactly 0 the computed field is
  time-invariant (verified per-animation), so the loop now draws one frame
  and parks via the existing idle machinery; any update() re-wakes it.
- update() now pins the frame clock when resuming from a park so
  delta-based animations don't integrate the whole parked stretch.
- release-please: bump-minor-pre-major so this releases as 0.7.0.

BREAKING CHANGE: rendering is capped at 60fps by default; pass maxFps: 0
to restore uncapped rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oFoSH9ZnH1mheL8R7wNQY
@biering biering self-assigned this Sep 17, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant