Skip to content

perf(canvas): smooth zooming - compositor-synced frames, fixed measurement contract (DasherCore v0.2.5) - #36

Merged
willwade merged 2 commits into
mainfrom
fix/choppy-zooming
Aug 25, 2026
Merged

willwade merged 2 commits into
mainfrom
fix/choppy-zooming

Conversation

@willwade

@willwade willwade commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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).

  • Drive the engine from TopLevel.RequestAnimationFrame (compositor frame clock — same approach as GTK's frame clock / Android's Choreographer). After: frames == renders, locked 1:1, zero skips.
  • Feed the engine a clamped monotonic timeline (deltas capped at 50 ms) instead of wall-clock UtcNow sampled at each tick — the engine consumes raw deltas as zoom amount, so pause gaps (settings open) no longer inject multi-second jumps; ResumeTimer drops the wall-clock baseline likewise.

2. Text-measurement contract (v0.1.17 regression from #33)

Our OnTextSize returned 1 on success / 0 on failure — inverted against dasher.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,500 FormattedText constructions 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)

  • CommandRenderer now caches brushes, pens and FormattedText objects (was: one allocation per command, hundreds per frame).
  • Command/string buffers reused across frames (was: fresh int[] + string[] every frame).
  • SP_DASHER_FONT polled 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_values returned 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)

metric before after
engine tick spacing avg 26 ms, p95 32, max 64 locked to compositor cadence, 1:1 with renders
engine step 0.33 ms avg 0.13 ms avg
render 0.38 ms avg 0.34 ms avg
text-size callbacks 2,520/window (never cached) 0/window warm
skipped/doubled updates renders 91-109 per 120 ticks frames == renders exactly

(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

  • This changes a capability that users see on other platforms.
  • This introduces a new UX or hardware interaction.

Manual verification (RFC 0011)

  • Build green 0 warnings; suite 43/43
  • Instrumented before/after runs (numbers above)
  • Smoke run: app launches, canvas renders, engine fault-free
  • User acceptance: the reporter's slow-zoom pass in both standard and Direct Mode (her acceptance test)
  • Font change while zooming still invalidates measurement correctly (250 ms poll)

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.

  • Adds generation-guarded animation-frame scheduling with a bounded monotonic engine timeline.
  • Reuses native command and string buffers and caches rendering and text-measurement objects.
  • Changes settings enumeration to probe and allocate the required result capacity.
  • Updates DasherCore from v0.2.4 to v0.2.5.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/Dasher.Windows/Controls/DasherCanvas.cs Replaces the dispatcher frame timer with generation-guarded compositor callbacks, introduces a bounded engine timeline, fixes the text-measurement callback contract, and reuses frame buffers.
src/Dasher.Windows/Controls/SettingsPanel.cs Replaces the fixed-capacity string-value query with probe-then-fetch enumeration.
src/Dasher.Windows/Engine/CommandRenderer.cs Adds bounded caches for brushes, pens, typefaces, and formatted text while rendering only the populated portions of reusable buffers.
DasherCore Advances the submodule from v0.2.4 to v0.2.5; the referenced submodule commits were unavailable locally for direct source-level analysis.

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 frame
Loading

Reviews (2): Last reviewed commit: "fix(canvas): generation-guard the RAF lo..." | Re-trigger Greptile

…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>
Comment thread src/Dasher.Windows/Controls/DasherCanvas.cs Outdated
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>
@willwade
willwade merged commit 90f6739 into main Aug 25, 2026
3 checks passed
@willwade
willwade deleted the fix/choppy-zooming branch August 25, 2026 17:49
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>
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.

Zooming is choppy/stuttering, worse at low speeds (v0.1.17 report)

1 participant