Skip to content

perf(island): same motion at any refresh rate, less work in motion and out of sight - #43

Closed
klNuno wants to merge 3 commits into
mainfrom
perf/high-refresh
Closed

klNuno wants to merge 3 commits into
mainfrom
perf/high-refresh

Conversation

@klNuno

@klNuno klNuno commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Makes the island move the same way at 60, 144 and 240 Hz, and cuts work during its motion and while nobody can see the window. Adds a re-runnable probe that measures every demo pose.

What changed

  • Island spring on a fixed step (src/lib/island/spring.svelte.ts, Island.svelte). Svelte's Spring adds a whole acceleration to its velocity on every frame, so its curve depends on the refresh rate. Opening the island settled in about 417 ms at 60 Hz, 486 ms at 144 Hz and 504 ms at 240 Hz (closing too, past SHRINK_AFTER = 450 ms). FrameSpring runs Svelte's own step at 60 steps a second and shows faster frames part of the way to the next step. At 60 Hz it matches Svelte frame for frame (tested). At 144 and 240 Hz it follows the same curve and gets more frames of it.
  • Hit areas sent once per shell read (App.svelte). While the spring moves, every frame asked for companion_hit_rects: one IPC call and a getBoundingClientRect sweep per frame, 240 a second on a 240 Hz screen. The shell reads them every 40 ms (WATCH_EVERY), so sends now coalesce to that period, and the trailing send always carries the last state. The 500 ms safety tick stops while the window is out of sight.
  • Endless animations pause out of sight (App.svelte, bots.css). data-dormant on the root while the page is hidden or the shell has hidden the window under a full-screen app (companion://fullscreen, which the page hears without being told it is hidden: the shell uses ShowWindow(SW_HIDE), so WebView2 still thinks it is visible). animation-play-state: paused then stops the robots' blinks, hums and pulses from drawing frames during a game. Svelte transitions use WAAPI and still finish.
  • scripts/perf-probe.ts with scripts/perf.vite.config.ts: plain CDP against headless Chrome, GPU on, SwiftShader refused. For each pose it records 10 s of Performance.getMetrics deltas, then 10 s of rAF frame intervals, plus the running CSS animations. open-close clicks the pill open and closes it with Escape four times. --profile lists the heaviest functions, --unthrottled lifts the frame cap. It measures a production build with the demo poses kept in, because the dev server's Svelte tracing (get_stack) was most of what a dev measurement showed.

No visual change: captures of pose=open&tab=threads and pose=deck&tab=music before and after are identical; only the live music meter differs.

Measurements

Production build, headless Chrome 1280x900, ANGLE on the AMD Radeon (renoir). Before and after ran back to back under similar host load. The frame columns are noisy on this shared host: a shift of 16.7 vs 33 ms in p95 at idle is host scheduling, not the page. Main-thread milliseconds per second are thread time and more stable.

60 Hz, 10 s per pose (before → after)

Pose Task ms/s Script ms/s Layout ms/s Style ms/s Frame p50 / p95 / p99 ms Dropped
closed 16.0 → 14.6 2.3 → 2.1 1.29 → 1.20 3.85 → 3.52 16.7 / 16.7 / 16.8 → 16.7 / 16.8 / 16.8 0 → 0
open-close 154.7 → 144.6 24.4 → 21.8 9.97 → 8.82 44.38 → 42.21 16.7 / 16.8 / 33.3 → 16.7 / 16.7 / 16.8 7 → 3
closed-working 4.5 → 4.7 0.8 → 0.9 0.02 → 0.03 1.12 → 1.12 16.7 / 16.7 / 16.8 → 16.7 / 16.8 / 16.8 0 → 0
peek 11.5 → 12.3 1.6 → 1.6 0.71 → 0.85 2.86 → 2.73 16.7 / 33.3 / 33.4 → 16.7 / 16.8 / 16.8 51 → 0
open-threads 9.0 → 9.0 1.0 → 1.2 0.14 → 0.12 2.52 → 2.40 16.7 / 33.3 / 33.4 → 16.7 / 16.7 / 16.8 35 → 0
open-today 6.9 → 6.7 0.8 → 0.8 0.14 → 0.14 1.64 → 1.39 16.7 / 33.3 / 33.4 → 16.7 / 16.7 / 16.8 58 → 0
open-chat 6.8 → 7.3 0.8 → 0.8 0.14 → 0.14 1.43 → 1.55 16.7 / 16.8 / 33.4 → 16.7 / 16.8 / 16.8 28 → 0
open-stats 6.5 → 6.4 0.7 → 0.7 0.12 → 0.14 1.47 → 1.28 16.7 / 16.8 / 33.4 → 16.7 / 16.8 / 33.4 8 → 6
open-music 5.8 → 6.8 0.7 → 0.8 0.10 → 0.14 1.32 → 1.60 16.7 / 16.8 / 33.4 → 16.7 / 16.8 / 16.8 27 → 0
open-apps 8.5 → 9.7 0.7 → 0.9 0.12 → 0.14 1.50 → 1.74 16.7 / 33.4 / 33.4 → 16.7 / 16.7 / 16.8 165 → 0
open-clipboard 8.3 → 7.7 0.9 → 0.9 0.18 → 0.15 1.90 → 1.77 16.7 / 16.8 / 33.4 → 16.7 / 16.7 / 16.8 20 → 0
open-wallpaper 7.8 → 7.6 0.8 → 0.8 0.13 → 0.14 1.64 → 1.46 16.7 / 16.8 / 33.4 → 16.7 / 16.7 / 16.8 18 → 0
open-system 11.9 → 12.0 1.3 → 1.4 0.40 → 0.43 2.79 → 2.70 16.7 / 33.3 / 33.4 → 16.7 / 16.7 / 16.8 37 → 0
deck-music 34.9 → 34.3 6.9 → 6.8 0.50 → 0.52 7.98 → 7.37 16.7 / 16.8 / 33.3 → 16.7 / 16.8 / 16.8 15 → 0
assistant 6.4 → 6.9 0.8 → 1.0 0.10 → 0.10 1.40 → 1.48 16.7 / 33.4 / 33.4 → 16.7 / 16.7 / 16.8 62 → 0
desktop-idle 5.3 → 5.5 1.0 → 1.1 0.38 → 0.34 1.05 → 0.98 16.7 / 33.3 / 33.4 → 16.7 / 16.7 / 16.8 53 → 0
desktop-working 5.6 → 6.1 1.0 → 1.1 0.41 → 0.44 1.15 → 1.19 16.7 / 33.3 / 33.4 → 16.7 / 16.7 / 16.8 55 → 0

Frame cap lifted (--unthrottled), closest headless proxy for a fast screen (before → after)

Pose Task ms/s Script ms/s Layout ms/s Style ms/s Frame p50 / p95 / p99 ms Dropped
closed 90.8 → 92.1 5.7 → 4.3 6.74 → 5.61 15.06 → 15.88 0.5 / 0.8 / 1.3 → 0.4 / 0.9 / 1.1 1064 → 2259
open-close 742.1 → 669.0 56.8 → 43.0 37.40 → 29.07 248.27 → 202.11 0.6 / 4.5 / 7.2 → 0.5 / 3.6 / 7.1 1663 → 1866
deck-music 83.3 → 85.2 7.9 → 7.9 0.77 → 0.80 12.73 → 13.02 0.5 / 0.9 / 1.6 → 0.4 / 0.9 / 1.4 1258 → 2472
desktop-working 34.1 → 42.0 1.0 → 1.3 0.35 → 0.43 1.09 → 1.23 0.5 / 0.9 / 1.3 → 0.4 / 1.0 / 1.3 1531 → 3839

In the unthrottled table, "Dropped" (an interval over 1.5 times the median) means little when the median is under a millisecond. Reading it: idle poses already cost 5 to 16 ms of main thread per second, and that does not move. The changes show during motion: with the frame cap lifted, open-close spends 24 % less in script and 22 % less in layout, and its p95 frame drops from 4.5 to 3.6 ms.

Tried and dropped

A 70 ms transform transition on the music meter's bars, to glide between its 15 Hz levels: deck-music went from 49 to 131 ms/s (20 transitions restarted 15 times a second, style recalc 10 to 57 ms/s). Reverted.

Left as is, with the reason

  • Rust polling (src-tauri/src): the companion's watch thread ticks every 40 ms for click-through and the cursor (a few native calls), the music meter thread samples every 25 ms only while the page reads it and stops 3 s later, and nothing else polls. Slowing the watch while hidden would delay click-through after the shortcut summons the companion over a game, for a few microseconds of CPU saved. No change.
  • ICoreWebView2Controller::put_IsVisible(false) when hidden would idle the whole renderer, but Chromium then throttles chained timers to once a minute after five minutes: pomodoro and note alarms would ring up to a minute late during a game.
  • The deck's bloom / blurAway transitions animate filter: blur() over the 860 px deck for 340 ms: a GPU cost on every frame of the opening, part of the look.
  • The .open view's bind:offsetHeight and IslandBar's measure spans read layout when they mount (about 10 ms per open in the profile). That layout has to happen anyway for that frame; it is moved, not doubled.

Costs in files other work owns (not edited here)

  • src/components/companion/CompanionCharacter.svelte:408 .blink { animation: blink 5s infinite } runs at all times, yet its keyframes stay still for 93 % of each cycle (:459). An endless animation keeps the compositor drawing at the display's rate, so the idle closed pill draws 240 frames a second on a 240 Hz screen to show a blink every 5 s. The same goes for :382-425 (breathe, hum, scan, slide, beacon, float). A short animation triggered by a timer would draw only during the blink.
  • src/components/island/IslandBar.svelte:509 pulse 1.6s infinite on each working dot: two to four of them in the closed pill, same effect.

What a headless Linux Chrome cannot say about WebView2 on a 240 Hz Windows screen

  • Headless Chrome paces frames at 60 Hz or, unthrottled, as fast as it can; it has no vsync at 144 or 240 Hz, so the frame columns are a proxy, not a measurement of a real 240 Hz screen.
  • It does not show the cost of DirectComposition on a transparent, always-on-top window, of DWM, or of WebView2's own GPU process; GPU time is not in these numbers at all.
  • The fullscreen dormant path relies on the shell's event, which only the Windows build emits; here it was checked by faking document.hidden (9 running animations go to 0 paused and back to 9).
  • Not tried on the Windows build or the user's own screen.

Checks

  • bun run check: 0 errors, 0 warnings (291 files)
  • bun run test: 61 files, 457 tests passed (5 new in spring.svelte.test.ts)
  • bun run test:scripts: 0 fail
  • bun run check:manifest: accepted
  • No Rust touched, so no Windows clippy run.

Captures

Not attached: this machine has no image host for PRs (the R2 helper's credentials are absent). Before and after captures of pose=open&tab=threads and pose=deck&tab=music (1280x600, desktop) were opened and compared; they match. They stay on the build machine under captures/bots-perf-high-refresh-*.png.

@klNuno

klNuno commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Note

Clanker Claude Opus 5.5 is responding on behalf of meetsu

landed in #44 (be3e00f).

@klNuno klNuno closed this Oct 14, 2026
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