Skip to content

Commit 4b217f7

Browse files
committed
fix momentum frame rate dependence
1 parent 6b9823a commit 4b217f7

2 files changed

Lines changed: 41 additions & 49 deletions

File tree

src/editor/panel/sprites.zig

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@ const Sprites = @This();
1010
const fly_anim_duration_us: i64 = 750_000;
1111

1212
/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.Fling`.
13+
/// Mouse/trackpad release velocity is measured over a position/time window
14+
/// (`releaseWindowed`), not a per-frame EMA — the EMA converged per frame, so a quick
15+
/// flick built up too little velocity at 60 Hz (e.g. Safari on a deployed build) even
16+
/// though it worked at 120 Hz. The window is wall-clock based, so it's refresh-independent.
1317
const sprite_fling: fizzy.Fling.Tuning = .{
1418
.decay = 4.0,
1519
.min_start = 1.2,
1620
.stop = 0.6,
1721
.max = 50.0,
18-
.idle_s = 0.08,
22+
.idle_s = 0.12,
1923
};
24+
/// Window the mouse/trackpad release velocity is averaged over (s).
25+
const sprite_fling_window_s: f32 = 0.08;
2026
/// Touch scrub: a finger flick is short and bursty, so start coasting at a lower
2127
/// speed and tolerate the small gap the browser leaves before `touchend`. Velocity is
2228
/// measured over a position/time window (`releaseWindowed`) rather than the last frame.
@@ -56,8 +62,6 @@ wheel_accum: f32 = 0.0,
5662
drag_active: bool = false,
5763
/// Whether the pointer moved between press and release (drag vs. click).
5864
moved_since_press: bool = false,
59-
/// Last frame's scrub delta (index units), kept for release when touch skips a final move.
60-
last_drag_frame_dx: f32 = 0,
6165
/// True when the active scrub began with a touch press (not mouse).
6266
drag_was_touch: bool = false,
6367
/// Release momentum for the scrub: coasts the flow after a flick, then snaps.
@@ -719,7 +723,6 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px
719723
dvui.dragPreStart(me.p, .{ .name = "coverflow_drag", .cursor = .hand });
720724
self.moved_since_press = false;
721725
self.drag_was_touch = me.button.touch();
722-
self.last_drag_frame_dx = 0;
723726
self.wheel_accum = 0.0;
724727
// Grabbing again cancels any in-flight coast and its velocity.
725728
self.fling.begin();
@@ -793,42 +796,24 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px
793796
}
794797

795798
if (!snap_scroll) {
796-
if (self.drag_was_touch) {
797-
// Touch path: record movement into the position/time history and, on
798-
// release, coast from a windowed velocity. Kept fully separate from the
799-
// mouse/trackpad path below so it can't change that proven behavior.
800-
if (self.drag_active) self.fling.sampleTimed(frame_dx);
801-
if (released_moved) {
802-
// A finger flick usually delivers its last `touchmove` and `touchend`
803-
// on the same frame, which leaves `drag_active` set. Clear it (after
804-
// sampling that final move) so draw()'s `drag_active` branch doesn't
805-
// immediately cancel the coast we're about to start — that race was
806-
// eating the momentum on ~half of all flicks.
807-
self.drag_active = false;
808-
if (!self.fling.releaseWindowed(sprite_fling_touch, sprite_fling_touch_window_s)) {
809-
const snapped: i64 = @intFromFloat(@round(self.scroll_pos));
810-
self.goal = @floatFromInt(snapped);
811-
dvui.refresh(null, @src(), id);
812-
}
813-
}
814-
} else {
815-
if (frame_dx != 0) self.last_drag_frame_dx = frame_dx;
816-
// Sample the flick velocity once per frame the drag moved.
817-
if (self.drag_active) self.fling.sample(frame_dx);
818-
819-
// On release, coast with the built-up velocity — unless the pointer had
820-
// paused or barely moved, in which case ease to the nearest sprite.
821-
if (released_moved) {
822-
// Touch often lifts without a move on the same frame; re-sample the
823-
// last delta so `release` sees fresh velocity and near-zero idle time.
824-
const release_dx = if (frame_dx != 0) frame_dx else self.last_drag_frame_dx;
825-
if (release_dx != 0) self.fling.sample(release_dx);
826-
827-
if (!self.fling.release(sprite_fling)) {
828-
const snapped: i64 = @intFromFloat(@round(self.scroll_pos));
829-
self.goal = @floatFromInt(snapped);
830-
dvui.refresh(null, @src(), id);
831-
}
799+
// Touch and mouse/trackpad share one path: record each moved frame into the
800+
// position/time history and, on release, coast from a velocity averaged over a
801+
// wall-clock window. That window is refresh-independent, so momentum is reliable
802+
// at 60 Hz and 120 Hz alike — unlike the old per-frame EMA, which underread short
803+
// flicks at lower refresh rates. Only the feel tuning differs per input type.
804+
if (self.drag_active) self.fling.sampleTimed(frame_dx);
805+
if (released_moved) {
806+
// The last move and the release commonly land on the same frame (more so at
807+
// low refresh), which leaves `drag_active` set. Clear it after sampling that
808+
// final move so draw()'s `drag_active` branch doesn't cancel the coast we
809+
// start here — that race was eating momentum on a large share of flicks.
810+
self.drag_active = false;
811+
const tuning = if (self.drag_was_touch) sprite_fling_touch else sprite_fling;
812+
const window_s = if (self.drag_was_touch) sprite_fling_touch_window_s else sprite_fling_window_s;
813+
if (!self.fling.releaseWindowed(tuning, window_s)) {
814+
const snapped: i64 = @intFromFloat(@round(self.scroll_pos));
815+
self.goal = @floatFromInt(snapped);
816+
dvui.refresh(null, @src(), id);
832817
}
833818
}
834819
} else if (released_moved) {

src/editor/widgets/CanvasWidget.zig

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,17 @@ const touch_eval_duration_ns: i128 = 80 * std.time.ns_per_ms;
186186

187187
/// Drag-pan momentum tuning. Units are viewport (data) pixels per second — the same
188188
/// units `scroll_info.viewport.x/y` move in — so the feel scales naturally with zoom.
189+
/// Release velocity is measured over a wall-clock position/time window
190+
/// (`releaseWindowed`)
189191
const pan_fling: fizzy.Fling.Tuning = .{
190192
.decay = 4.0,
191193
.min_start = 40.0,
192194
.stop = 10.0,
193195
.max = 8000.0,
194196
.idle_s = 0.18,
195197
};
198+
/// Window the pan release velocity is averaged over (s).
199+
const pan_fling_window_s: f32 = 0.08;
196200

197201
/// True while a 2-finger pan/pinch is in progress, or while we're still deciding whether
198202
/// a single touch will become one. Tools should skip their input processing whenever this
@@ -949,8 +953,8 @@ pub fn processEvents(self: *CanvasWidget) void {
949953
self.scroll_info.viewport.x += pdx;
950954
self.scroll_info.viewport.y += pdy;
951955
if (self.touch_eval_pan_active or self.scroll_pan_touch_slot != null) {
952-
self.pan_fling_x.sample(pdx);
953-
self.pan_fling_y.sample(pdy);
956+
self.pan_fling_x.sampleTimed(pdx);
957+
self.pan_fling_y.sampleTimed(pdy);
954958
}
955959
self.pending_touch_pan = .{};
956960
}
@@ -1133,16 +1137,19 @@ pub fn processEvents(self: *CanvasWidget) void {
11331137
}
11341138
}
11351139

1136-
// ---- Drag-pan momentum. Sample the flick velocity once per frame, decide on
1137-
// release whether to coast, and advance an in-flight coast — each axis is
1138-
// independent so a mostly-horizontal flick doesn't drift vertically. ----
1140+
// ---- Drag-pan momentum. Record each moved frame into the per-axis position/time
1141+
// history, decide on release whether to coast from a velocity averaged over a
1142+
// wall-clock window, and advance an in-flight coast — each axis is independent so a
1143+
// mostly-horizontal flick doesn't drift vertically. Sampling, release, and the step
1144+
// happen in sequence here (not split across a draw pass), so a coincident
1145+
// move+release frame samples the final move before the coast starts — no race. ----
11391146
if (pan_motion) {
1140-
self.pan_fling_x.sample(pan_dx);
1141-
self.pan_fling_y.sample(pan_dy);
1147+
self.pan_fling_x.sampleTimed(pan_dx);
1148+
self.pan_fling_y.sampleTimed(pan_dy);
11421149
}
11431150
if (pan_released) {
1144-
_ = self.pan_fling_x.release(pan_fling);
1145-
_ = self.pan_fling_y.release(pan_fling);
1151+
_ = self.pan_fling_x.releaseWindowed(pan_fling, pan_fling_window_s);
1152+
_ = self.pan_fling_y.releaseWindowed(pan_fling, pan_fling_window_s);
11461153
}
11471154
if (self.pan_fling_x.coasting or self.pan_fling_y.coasting) {
11481155
if (self.pan_fling_x.step(pan_fling)) |dx| self.scroll_info.viewport.x += dx;

0 commit comments

Comments
 (0)