Skip to content

feat(scroll): Drive scroll motion from a uniform frame clock - #23987

Open
MartinZikmund wants to merge 8 commits into
dev/mazi/scroll-damagefrom
dev/mazi/scroll-smoothness-2
Open

feat(scroll): Drive scroll motion from a uniform frame clock#23987
MartinZikmund wants to merge 8 commits into
dev/mazi/scroll-damagefrom
dev/mazi/scroll-smoothness-2

Conversation

@MartinZikmund

Copy link
Copy Markdown
Member

GitHub Issue: closes #23985

Important

This is PR 2 of 2 in a stack. It targets dev/mazi/scroll-damage (PR 1), not feature/breakingchanges — review that one first. The diff shown here is only this layer's changes. Once PR 1 merges, this retargets automatically.

PR Type:

✨ Feature

What changed? 🚀

Current behavior. Scroll motion is produced by six independent sources, none of which agree on a clock, and two input paths quantize motion before it reaches the visual:

  • No frame clock. Nothing evaluates motion at presentation time. Ticks are not scheduled on a vsync, so a raw clock read wobbles by milliseconds around a cadence that is otherwise exact. For a driver whose position is a function of time that wobble becomes v·dt of position error — at scroll speeds a visible fraction of a frame step, and worse as refresh rate rises, since the error stays v·dt while the step halves.
  • Touch drag quantized to ≥2 logical px. The per-device manipulation delta threshold bounds the volume of public ManipulationDelta events, but on the scroll path it acts as a motion quantizer: content does not move until 2 px accumulate, then jumps by the whole amount, so a slow drag advances every other frame. Inertia already bypasses it for the same reason.
  • Wheel deltas floored to zero. A precision touchpad reports deltas finer than one 120-unit detent; integer division discarded them.

This PR establishes two invariants:

  • I1 — Single frame timestamp. One timestamp is sampled per frame at Render() entry; every animation and simulation in that frame is evaluated at it. A new FrameClock recovers a phase-locked grid from the median tick interval: whole-frame steps for dropped frames and idle gaps, a gentle pull otherwise so no single frame carries a visible correction, and monotone by construction — a backward step would make a fling's elapsed time negative, which its curve reads as "not started". It lives on the CompositionTarget rather than the process-wide Compositor, because several record loops sharing one clock push near-zero intervals into its window and collapse the estimated period.
  • I2 — Motion is a function of time, not of event arrival. Wheel and touch scrolling are driven from that clock via closed-form simulations (ScrollFlingSimulation, ScrollDecaySimulation, ScrollVelocityTracker). Impulses accumulate into the running simulation rather than restarting it, removing the per-detent hitch.

Also included: frame drivers tick before layout rather than inside the record; a recorded frame is counted under the gate that publishes it (a Draw acquiring the gate in between read a fresh picture against a stale generation and logged a dropped frame that never happened).

Explicitly not adopted (each would be a silent parity deviation or a regression): pointer resampling as the headline fix — Flutter ships it off and it costs ~38 ms of latency; pointer prediction/extrapolation — absent from dxaml, causes overshoot; a CubicBezier on default programmatic scroll — WinUI supplies no easing function.

Design rationale and the full research trail are in specs/scroll-smoothness/spec.md, added here.

PR Checklist ✅

  • 🧪 Added Runtime tests, UI tests, or a manual test sample (for bug fixes / features, if applicable)
    • Runtime tests in Given_Compositor (frame clock) and Given_ScrollViewer (scroll motion), plus a ScrollSmoothnessBenchmark sample for manual frame-pacing comparison.
  • 📚 Docs have been added/updated following the documentation template (for bug fixes / features)
    • specs/scroll-smoothness/spec.md carries the design. No public API surface changed.
  • 🖼️ Validated PR Screenshots Compare Test Run results.
  • ❗ Contains NO breaking changes
  • 👀 Reviewed 2 other open pull requests (optional but appreciated!)

Validation

  • Compile: Uno.UI.Skia, Uno.UI.RuntimeTests.Skia, SamplesApp.Skia.Generic (net10.0) — all 0 warnings, 0 errors.
  • Runtime: the added runtime tests have not yet been executed locally. Because this changes feel rather than API, the ScrollSmoothnessBenchmark sample is the intended reviewer-facing check — it reports frame pacing directly, which a pass/fail test cannot capture.

MartinZikmund and others added 8 commits August 6, 2026 16:20
Damage was accumulated by unioning SKPaths, so a frame that moves a large
subtree paid a path union per visual. A scroll moves everything in the
viewport at once, which is the worst case for that shape.

Rects are now appended as contours to one builder under the nonzero fill rule,
and collapse to their bounding rect past a small count -- past which the clip
mask costs more than the pixels it saves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w88LWryCBS83AzcTezBUM
Manipulation deltas were held back until they crossed a significance
threshold, so a slow drag advanced the content in 2px steps instead of
following the finger, and wheel deltas were truncated to whole detents by an
int division.

Scrolling asks for every delta as it arrives; the threshold exists to suppress
accidental micro-manipulations, which is not what a scroll presenter wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w88LWryCBS83AzcTezBUM
…vers

Frames present one per vsync, but ticks are not scheduled on one, so a raw
clock read wobbles by milliseconds around a cadence that is otherwise exact. A
driver whose position is a function of time turns that wobble into v*dt of
position error -- at scroll speeds, a visible fraction of a frame step, and
worse as refresh rate rises, since the error is v*dt regardless of rate while
the step itself halves.

Drivers now evaluate against a phase-locked grid recovered from the median
tick interval: whole-frame steps for dropped frames and idle gaps, a gentle
pull otherwise so no single frame carries a visible correction, and monotone
by construction -- a backward step would make a fling's elapsed time negative,
which its curve reads as "not started".

It lives on the CompositionTarget rather than the process-wide Compositor:
several record loops sharing one clock push near-zero intervals into its
window and can collapse the estimated period, silently degrading it to the raw
clock it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w88LWryCBS83AzcTezBUM
…cord

A driver writing from inside the record produces a frame request the render
state machine cannot tell apart from "content changed since the last record",
so it arms a present for a picture that tick will not produce and the previous
one is shown again.

Raising the hook from the tick, before UpdateLayout, makes a driver's write an
ordinary pre-frame invalidation -- the same shape a pointer event produces --
and lets the same tick clean up the layout it dirties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w88LWryCBS83AzcTezBUM
A Draw acquiring the frame gate between the publish and the count read the
fresh picture against the stale generation and recorded a dropped frame that
never happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w88LWryCBS83AzcTezBUM
Both wheel and touch motion restarted an ease-out towards a fixed target on
every input. An ease front-loads its motion, so each restart re-injects a large
first step, and since input arrives asynchronously to the frame clock that step
lands on an arbitrary frame -- the presented displacement then alternates
between very large and very small.

The wheel now uses exponential decay, which is memoryless, so a detent composes
with whatever motion is in flight: velocity stays continuous and only its slope
changes. Touch inertia uses the platform's own curve -- Android's spline
distance, Apple's drag constant -- launched from a least-squares fit over the
recent gesture rather than a two-sample difference, whose divisor is whatever
interval those two samples happened to have.

Both are analytic in absolute time and evaluated once per frame against the
frame clock, so a late or early frame produces the correct position instead of
accumulating error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w88LWryCBS83AzcTezBUM
Copilot AI lite review requested due to automatic review settings August 6, 2026 14:24
@github-actions github-actions Bot added area/skia ✏️ Categorizes an issue or PR as relevant to Skia area/automation Categorizes an issue or PR as relevant to project automation kind/documentation labels Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 10m 50s —— View job


Code Review

PR #23987 — feat(scroll): Drive scroll motion from a uniform frame clock

  • Gather context and understand the PR
  • Review new core files: FrameClock.skia.cs, CompositionTarget.FrameClock.skia.cs
  • Review scroll simulations: ScrollFlingSimulation.cs, ScrollDecaySimulation.cs
  • Review ScrollVelocityTracker.cs
  • Review ScrollContentPresenter.Managed.cs changes
  • Review Compositor.skia.cs, InteractionTracker changes
  • Review GestureRecognizer changes
  • Review runtime tests (Given_Compositor.cs, Given_ScrollViewer.cs)
  • Review sample app (ScrollSmoothnessBenchmark)
  • Post inline comments and summary

Overall verdict

fix-first — 1 medium issue with a targeted fix, 1 low-severity efficiency issue, and 2 pre-existing items worth cleaning up while the file is open.

Issues found: 2 (inline) + 2 pre-existing (not in diff)

Medium — CompositionInertiaProcessorTimer.Stop() re-fetches target (inline comment on GestureRecognizer.Manipulation.InertiaProcessor.cs:378)

Stop() calls CompositionTarget.MainFrameDriverTarget again rather than using the same instance captured in Start(). If a second window opens or the first window closes between start and stop, the handler may be unsubscribed from a different target than the one it was registered on — leaving it subscribed forever and the _handler field nulled so it can never be cleaned up. The ~CompositionInertiaProcessorTimer finalizer has the same flaw. Fix: store the target local as a field in Start() and use that field in Stop().

Low — running condition can keep the fling loop alive unnecessarily (inline comment on ScrollContentPresenter.Managed.cs:601)

In a ScrollViewer that is scrollable on both axes, a purely vertical fling with HorizontalOffset between the edges (0 < h < maxH) will have h > 0 && h < maxH == true throughout the fling. If the vertical motion settles at an edge before _flingV.Duration expires, the || keeps running = true and the loop continues issuing no-op Set calls — wasting frame-clock requests — until the duration cutoff. In the common single-axis case this doesn't arise (maxH = 0 → h is clamped to 0, so h > 0 == false), but it is reachable for mixed-axis content.

Pre-existing issues (not in this diff, worth noting)

  • using static System.Net.Mime.MediaTypeNames; at ScrollContentPresenter.Managed.cs:20 has no purpose in a scroll presenter; it is an orphan import, likely a debug leftover from before this PR.
  • The XML doc comment on ScrollOptions references a <param name="LinearAnimationDuration"> that no longer exists in the record definition (ScrollContentPresenter.Managed.cs:1073–1076).

What's working well

  • FrameClock phase-locking design (median period, monotone-by-construction, idle-gap exclusion) is clean and the six runtime tests cover all the key invariants (jitter smoothing, no-drift, dropped-frame step, re-anchor, no-backward-step, burst-isolation).
  • ScrollFlingSimulation analytic curves (Android OverScroller and iOS UIScrollView) are correctly derived and match their reference sources. ScrollDecaySimulation's memoryless impulse composition eliminates the per-detent restart hitch.
  • ScrollVelocityTracker's QR-based quadratic fit matches Flutter/Avalonia conventions; the AssumeStoppedMs = 40 gap guard prevents a restart mid-gesture from injecting stale samples.
  • The ReportsUnquantizedDeltas opt-in cleanly bypasses the manipulation threshold for the scroll path without touching the public ManipulationDelta volume.
  • Floating-point cast for the precision-touchpad wheel delta ((double)delta / 120) is the right fix and the comment explains why.
  • Frame drivers ticking before layout (not inside the record) is the correct ordering; the _fpsHelper.OnFrameRecorded() move under the gate is a real correctness fix.
  • Given_Compositor tests are straightforward unit tests; Given_ScrollViewer new tests (When_Fling_Then_DistanceMatchesTheGesture, When_SlowTouchDrag_Then_ScrollAdvancesEveryMove) are behavior-testing the right property (proportionate fling, every-move advance) rather than implementation internals.

@mergify

mergify Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a Skia-specific “single frame timestamp” discipline for scroll-related motion, routing wheel/touch inertia through a uniform frame clock so scroll position is evaluated once per frame (pre-layout / pre-record) instead of being driven by event arrival timing.

Changes:

  • Add a Skia FrameClock and a CompositionTarget.FrameStarting pre-layout hook to provide a single per-frame timestamp to motion drivers.
  • Rework ScrollViewer wheel + touch inertia to be time-parameterized (decay/fling simulations, unquantized deltas, fractional wheel detents).
  • Add runtime tests plus a manual benchmark sample and a design spec documenting the rationale and constraints.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Uno.UI/UI/Xaml/Media/CompositionTarget.Rendering.skia.cs Moves frame-record accounting under the publish gate to avoid false dropped-frame reporting.
src/Uno.UI/UI/Xaml/Media/CompositionTarget.FrameClock.skia.cs Introduces FrameStarting event + per-target frame clock integration for Skia.
src/Uno.UI/UI/Xaml/Internal/InputManager.Pointers.Managed.cs Sends fractional wheel “detents” (double) to trackers to avoid precision touchpad dead-zone.
src/Uno.UI/UI/Xaml/Internal/DirectManipulation.cs Enables unquantized deltas for direct manipulation (scrolling) gesture recognizer.
src/Uno.UI/UI/Xaml/Internal/CoreServices.cs Raises FrameStarting pre-layout (Skia) to tick motion drivers before recording.
src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollFlingSimulation.cs Adds closed-form fling simulation (Android/iOS parity) for touch inertia.
src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollDecaySimulation.cs Adds exponential decay simulation for wheel motion with impulse accumulation.
src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.Managed.cs Wires wheel decay + touch fling to FrameStarting, adds velocity fitting, and updates animation frame handling.
src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.cs Switches wheel path to feed the accumulating decay via AddWheelImpulse.
src/Uno.UI/UI/Input/WinRT/ScrollVelocityTracker.cs Adds least-squares velocity estimation over recent samples for stable inertia launch velocity.
src/Uno.UI/UI/Input/WinRT/GestureRecognizer.Manipulation.InertiaProcessor.cs Uses FrameStarting (Skia) to tick inertia pre-record instead of CompositionTarget.Rendering.
src/Uno.UI/UI/Input/WinRT/GestureRecognizer.Manipulation.cs Ensures manipulation deltas can be committed even when below thresholds for scrolling.
src/Uno.UI/UI/Input/WinRT/GestureRecognizer.cs Adds ReportsUnquantizedDeltas init flag to control delta threshold behavior for scroll.
src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_ScrollViewer.cs Updates/adds scroll behavior tests for wheel monotonicity, fling proportionality, and unquantized slow-drag.
src/Uno.UI.RuntimeTests/Tests/Windows_UI_Composition/Given_Compositor.cs Adds frame clock behavior tests (jitter smoothing, drift, drops, reanchor, monotonicity).
src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerState.cs Updates wheel delta API to accept double ticks.
src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInteractingState.cs Signature update for wheel delta (double).
src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaState.cs Uses double wheel delta and casts to float vectors for inertia handling.
src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerIdleState.cs Uses double wheel delta and adjusts velocity computation accordingly.
src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerCustomAnimationState.cs Signature update for wheel delta (double).
src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTracker.cs Documents and implements fractional wheel detents end-to-end (double).
src/Uno.UI.Composition/Composition/FrameClock.skia.cs New frame clock implementation based on median tick intervals and monotone correction.
src/Uno.UI.Composition/Composition/Compositor.skia.cs Counts “frame drivers” as animating work so WaitForIdle(...waitForCompositionAnimations:true) covers them.
src/SamplesApp/SamplesApp.Samples/Windows_UI_Xaml_Controls/ScrollViewerTests/ScrollSmoothnessBenchmark.xaml.cs Adds a manual benchmark to report per-frame offset-delta jitter statistics while scrolling.
src/SamplesApp/SamplesApp.Samples/Windows_UI_Xaml_Controls/ScrollViewerTests/ScrollSmoothnessBenchmark.xaml UI for the scroll smoothness benchmark sample.
specs/scroll-smoothness/spec.md Adds design spec capturing goals, invariants, research references, and staged plan.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +631 to +640
var compositor = Visual.Compositor;
var now = compositor.TimestampInTicks;

if (!_isWheelDecayRunning)
{
_wheelDecayH.Start(HorizontalOffset, now);
_wheelDecayV.Start(VerticalOffset, now);
_isWheelDecayRunning = true;
FrameDriverTarget!.FrameStarting += OnWheelDecayFrame;
}
if (_handler is not null)
{
CompositionTarget.Rendering -= _handler;
if (Microsoft.UI.Xaml.Media.CompositionTarget.MainFrameDriverTarget is { } target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop() re-fetches MainFrameDriverTarget instead of using the same target instance captured in Start(). In a multi-window scenario, or if the window closes between Start and Stop, MainFrameDriverTarget could return null or a different CompositionTarget than the one the handler was subscribed to — leaving the subscription alive and _handler set to null so it can never be unsubscribed again. The finalizer (~CompositionInertiaProcessorTimer()) inherits the same flaw.

The fix is to store the target captured during Start as a field and use it in Stop:

private Microsoft.UI.Xaml.Media.CompositionTarget? _target;

public void Start()
{
    Stop();
    if (Microsoft.UI.Xaml.Media.CompositionTarget.MainFrameDriverTarget is not { } target)
        return;

    _target = target;
    _startTimestamp = 0;
    _handler = (_, timestamp) => {};
    target.FrameStarting += _handler;
}

public void Stop()
{
    if (_handler is not null)
    {
        _target?.FrameStarting -= _handler;   // same instance that was subscribed
        _target = null;
        _handler = null;
    }
}


// Done once both curves are spent, or the content has run into an edge in the direction of travel.
var running = elapsed < Math.Max(_flingH.Duration, _flingV.Duration)
&& (h > 0 && h < maxH || v > 0 && v < maxV);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The running condition can keep the fling active for extra no-op frames in a bidirectional-scrollable ScrollViewer. If the fling is purely vertical but HorizontalOffset is mid-range (0 < h < maxH), then h > 0 && h < maxH remains true even after the vertical motion settles at an edge. The fling loop continues issuing Set calls that change nothing, until elapsed >= _flingV.Duration.

The frame-clock requests and resulting wakeups waste CPU while the content is stationary. The simplest fix is to also stop when neither axis changed this frame:

var previousH = HorizontalOffset;
var previousV = VerticalOffset;

// … Set call …

var running = elapsed < Math.Max(_flingH.Duration, _flingV.Duration)
    && (HorizontalOffset != previousH || VerticalOffset != previousV
        || (h > 0 && h < maxH) || (v > 0 && v < maxV));

Or conservatively: check that at least one axis is still between its limits and hasn't been pinned there for two consecutive frames.

@MartinZikmund
MartinZikmund force-pushed the dev/mazi/scroll-damage branch 2 times, most recently from 80cfa2b to 68659d3 Compare August 14, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/automation Categorizes an issue or PR as relevant to project automation area/skia ✏️ Categorizes an issue or PR as relevant to Skia kind/documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants