perf(canvas): smooth zooming - compositor-synced frames, fixed measurement contract (DasherCore v0.2.5) - #36
Merged
Merged
Conversation
…ement contract User report on v0.1.17 (#35): zooming choppy/stuttering in both standard and Direct Mode, worse at low speed. Instrumented the frame loop and fixed what the numbers showed; the ABI copy itself was exonerated (engine avg 0.33 ms, marshal avg 0.01 ms per frame). Frame pacing (the choppiness): - drive the engine from TopLevel.RequestAnimationFrame instead of a free-running 16 ms DispatcherTimer: the timer ran at Background priority and drifted against the compositor, measuring ~26 ms average tick spacing with 32-64 ms spikes and skipped/doubled updates (beat frequency judder, most visible at low zoom speeds). Engine steps now lock 1:1 with presentation (measurement: frames==renders, zero skips) - feed the engine a clamped monotonic timeline (deltas capped at 50 ms) instead of wall-clock UTC sampled at each tick, so pauses (settings open) and scheduler hiccups no longer inject multi-second jumps the engine would apply as zoom amount - PauseTimer now drops the wall-clock baseline on resume for the same reason Text measurement (v0.1.17 regression #33 integration): - the text-size callback returned 1 on success / 0 on failure - inverted against the documented contract (dasher.h: return 0 on success, non-zero to fall back). The engine therefore never accepted or cached our measurements and re-called the callback ~21x per frame, ~2,500 FormattedText constructions per second (measurement: textcb 2520 -> 0 per 120-frame window after warmup). Also adds a frontend-side measurement cache for fresh labels - SP_DASHER_FONT polling: per frame -> every 250 ms Allocations: - CommandRenderer caches brushes, pens and FormattedText objects (steady-state frames previously allocated one object per command, hundreds per frame) - command/string buffers are reused across frames instead of reallocated Also bumps the DasherCore submodule v0.2.4 -> v0.2.5 (probe-call fix, DasherCore #58, found via Dasher-GTK) and switches the settings string dropdowns to probe-then-fetch so the full alphabet list (622 entries) is no longer truncated by the old fixed 200-slot buffer. Fixes #35 Signed-off-by: will wade <willwade@gmail.com>
A compositor callback queued before Shutdown() (Settings > Reset destroys and recreates the engine on the same canvas) would observe _frameLoopRunning reset to true by the restart and reschedule itself alongside the new loop's callback - double-stepping the engine every frame. Each loop now carries a generation token; Shutdown and the engine-fault stop invalidate any still-queued callbacks (caught in review of #36). Signed-off-by: will wade <willwade@gmail.com>
This was referenced Aug 25, 2026
willwade
added a commit
to dasher-project/Dasher-Apple
that referenced
this pull request
Aug 26, 2026
…abel metrics (#42) Fixes #41. Ports the [Dasher-Windows #36](dasher-project/Dasher-Windows#36) choppiness diagnosis to every Apple target. ## 1. macOS frame pacing — the Windows bug, verbatim `MacDasherCanvas` drove `draw(_:)` with a free-running `Timer(1/60)` — decoupled from the compositor, beat-frequency judder, skipped/doubled frames, worst at slow zoom. Now **`CVDisplayLink`** locked to vsync (retained self in the callback context, hops to main; stopped + released on leaving the window). iOS/visionOS already used `CADisplayLink` and are untouched. ## 2. Clamped engine timeline — all four targets The engine consumes raw deltas as zoom amount; every canvas passed unclamped wall-clock `Date()`. A pause (settings sheet over canvas, backgrounded app where `CADisplayLink` stops) injected a **multi-second delta = giant zoom jump** on resume. Bridges now convert timestamps via `timelineMs(forWallMs:)`: deltas capped at 50 ms, backwards clocks hold position, own monotonic accumulation (`resetTimeline()` for recreation). Mirrors Windows' `EngineTimelineTests` contract. ## 3. Real label metrics — all four targets `dasher_set_text_size_callback` was never wired here, so the engine measured labels with its code-point × fontSize/2 estimate — the exact cause of Windows' **squashed/jumbled deep-zoom labels**. Each bridge now measures with the same font opcode-5 draws with (`fontName` is now a bridge property shared by both paths), **returning 0 on success** per the dasher.h contract (the inversion Windows suffered). Frontend cache keyed text+font+size, bounded at 4096, invalidated on `SP_DASHER_FONT` change (`dasher_text_metrics_changed`). ## 4. DasherCore v0.2.5 + probe-then-fetch The old 64-slot `getStringValues` buffer silently truncated longer lists — `SP_DASHER_FONT` runs to hundreds. Now probes the count first (needs v0.2.5's [DasherCore #58](dasher-project/DasherCore#58) fix) and fetches into an exact-size buffer. ## Builds All four schemes compile clean: DasherMac (macOS), DasherApp (iOS device), DasherKeyboard, DasherVision. (iOS *Simulator* x86_64 slice fails on a pre-existing `speechmarkdown_rust` artifact gap — unrelated to this PR, present before it.) ## Not done here Windows pinned their invariants in tests (#37); worth a follow-up once we have a test target strategy for the bridges. Signed-off-by: will wade <willwade@gmail.com>
willwade
added a commit
that referenced
this pull request
Aug 30, 2026
Implements governance #36 for Windows. The old startup blocked the UI thread synchronously in OnOpened: first-run data install (827 files, 45MB), dasher_create, then realize (~280ms warm, seconds cold) - the window was dead/blank throughout, and the canvas-only delay remained visible even after DasherCore's lazy alphabet loading because realize + install still ran ahead of the first paint. - StartupOverlay ships visible in the AXAML: themed background, indeterminate spinner, and the shared preparing_dasher string (added to the catalogue with 32 locales, dasher-shared-resources#1) from the very first rendered frame - CopyDataIfNeeded, dasher_create, the v5 scan and realize now run on worker threads (CompleteStartupAsync / StartupAsync); the UI thread stays free so the spinner animates - Overlay retires on FirstEngineFrame - the canvas's first real frame - never earlier; on failure it shows the error instead of going blank - Settings > Reset restart path reuses the overlay (RestartEngine->overlay + async init) Signed-off-by: will wade <willwade@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
v0.1.17 user report (#35): "The zooming is very choppy... stuttering and robotic... happens in both standard mode and Direct Mode... the stuttering actually gets worse when going slow."
Instrumented the frame loop first. The ABI layer is exonerated — engine avg 0.33 ms, command-buffer marshal avg 0.01 ms per frame. What the numbers showed instead, and the fixes:
1. Frame pacing (the choppiness)
The engine was driven by a free-running 16 ms
DispatcherTimer(Background priority) while presentation ran on the compositor's clock. Measured: ~26 ms average tick spacing, p95 32 ms, spikes to 64 ms, ~38 fps, with skipped/doubled updates as the two clocks beat against each other — classic judder, proportionally more visible at slow zoom (small per-frame movement makes timing error dominate).TopLevel.RequestAnimationFrame(compositor frame clock — same approach as GTK's frame clock / Android's Choreographer). After: frames == renders, locked 1:1, zero skips.UtcNowsampled at each tick — the engine consumes raw deltas as zoom amount, so pause gaps (settings open) no longer inject multi-second jumps;ResumeTimerdrops the wall-clock baseline likewise.2. Text-measurement contract (v0.1.17 regression from #33)
Our
OnTextSizereturned 1 on success / 0 on failure — inverted againstdasher.h("fill out_width/out_height and return 0; return non-zero to fall back"). The engine therefore never accepted our measurements (the .17 squashing fix users saw was actually DasherCore v0.2.4's codepoint-count correction) and never cached anything, re-calling the callback ~21×/frame ≈ 2,500FormattedTextconstructions per second. After the fix: textcb 2520 → 0 per 120-frame window once warm. Plus a frontend-side measurement cache for fresh labels.3. Allocations (GC pressure)
CommandRenderernow caches brushes, pens andFormattedTextobjects (was: one allocation per command, hundreds per frame).int[]+string[]every frame).SP_DASHER_FONTpolled every 250 ms instead of every frame (was a P/Invoke + string alloc per frame).4. DasherCore submodule v0.2.4 → v0.2.5
Picks up DasherCore #58 (probe call of
dasher_get_parameter_string_valuesreturned 0 before querying — found via Dasher-GTK). The settings string dropdowns now use probe-then-fetch, so the full alphabet list (622 entries) is no longer silently truncated by the old fixed 200-slot buffer.Measurements (before → after, same machine, 120-frame windows)
(Note: absolute cadence in the capture environment is ~30 fps over RDP; on a 60 Hz desktop RAF tracks 60. The 1:1 lock and jitter removal are the fix, not the absolute rate.)
Issue / RFC: #35 (this issue), #28 (measurement-contract regression), DasherCore #58
Type of change
Cross-platform impact
Manual verification (RFC 0011)
Definition of Done
Fixes #35
Greptile Summary
The PR replaces timer-driven canvas updates with compositor-synchronized frames, corrects text measurement semantics, and reduces steady-state rendering allocations.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Settings participant Canvas participant Compositor participant Engine Settings->>Canvas: Shutdown() Canvas->>Canvas: Stop loop and increment generation Settings->>Canvas: Initialize() and StartEngine() Canvas->>Canvas: Increment and capture new generation Canvas->>Compositor: RequestAnimationFrame(new generation) Compositor-->>Canvas: Old queued callback Canvas->>Canvas: Reject stale generation Compositor-->>Canvas: Current callback Canvas->>Engine: StepFrame() Canvas->>Compositor: Request next frameReviews (2): Last reviewed commit: "fix(canvas): generation-guard the RAF lo..." | Re-trigger Greptile