Skip to content

fix(mio): interpolate the desk between samples, so a dragged window pushes smoothly - #481

Merged
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix/mio-obstacle-interpolation
Aug 4, 2026
Merged

fix(mio): interpolate the desk between samples, so a dragged window pushes smoothly#481
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix/mio-obstacle-interpolation

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Mio jumped rather than slid when a window was dragged into it: resting against a window edge, it hopped, held for three frames, and hopped again as the window swept past.

What was actually wrong

Not the window hooks. src/window/pointer.ts writes the window's left / top on every pointermove, and WINDOW_BOUNDS_CHANGED is rAF-coalesced — both are ~60 Hz. Mio doesn't listen to that hook at all; it polls wp.os.getWallpaperSurfaces() behind SURFACE_REFRESH_MS = 50. So its obstacle set was up to three frames stale, then advanced by a whole interval of pointer travel at once — and contact resolution is a positional push, so that entire delta landed in one step.

The fix

Not a faster sample. Stop treating a sample as the truth right now and start treating it as a keyframe: hold the previous sample beside the current one and hand the solver a rect lerped between the two, so a 20 Hz measurement drives a 60 Hz push. Sizes interpolate too, which makes a resizing window sweep against Mio instead of stepping.

New module src/mio/obstacle-track.ts; readSurfaces() in src/mio/mio.ts now samples on the throttle and presents every frame.

The lerp spans the throttle, not the measured gap

This is the part that took a second pass. The gap between samples is irregular by construction — the ticker decides which frame readSurfaces() runs on, so a 50 ms throttle fires at 50 ms and at 66.7 ms in whatever order the frames fall. Spread the lerp over the previous gap and it is left unfinished whenever a short gap follows a long one, so the next keyframe pair starts from the position the last one was still travelling toward — a jump, the smaller cousin of the one being removed.

Spread over the throttle, it has always arrived before the next sample can land, because the throttle is a floor on the gap. The worst case degrades to a hold of a frame or two at the end of an interval. mio-obstacle-track.test.ts pins that case specifically.

Interpolation, not extrapolation

Costs one interval of latency — Mio is pushed by where the window was 50 ms ago, invisible on a decorative blob — and buys never overshooting and snapping back when the drag stops.

Three cases deliberately not interpolated

None of them are motion:

  • No previous keyframe — a window that just opened is solid where it is. Sliding it in from nowhere would read as a window materialising mid-desk and then sweeping across it.
  • A gap longer than 250 ms — the tab was in the background; the two samples are before-and-after, not two moments of one movement.
  • After a layer resize — a new origin re-bases every obstacle coordinate at once, so the track drops its history rather than lerp the whole desk across the rebase.

A still desk, the overwhelmingly common case, short-circuits and hands back the measured array itself — no allocation, byte-identical behaviour to before.

Scope

Contact still treats obstacles as static: a moving window displaces Mio without imparting momentum, exactly as today. The same keyframe pair would give obstacle velocity for free, but making contact reflect about relative velocity changes how contact feels everywhere, not just during drags — deliberately left out.

Internal only: no hook, no wp.os surface, no payload change.

Verification

  • npm run typecheck, npm run lint, npm run build — clean
  • npm run test:js — 299 files, 3646 tests green (12 new)
  • Manual: drag a window into Mio while it rests against a desk edge; the push is continuous instead of stepping.

🤖 Generated with Claude Code

Open WordPress Playground Preview

…ushes smoothly

Mio measures the desk on a 50 ms throttle — a dozen
`getBoundingClientRect()` reads is not a per-frame cost — but it was
handing those samples straight to a per-frame solver. A window drag
writes the window's `left` / `top` on every `pointermove`, so by the
time the next sample landed the rect had travelled a whole interval,
and the contact pass, which is a positional push, applied that entire
delta in one step. Mio resting against a window edge hopped, held for
three frames, and hopped again as the window was dragged past it.

The window hooks were never the bottleneck: `WINDOW_BOUNDS_CHANGED` is
rAF-coalesced and Mio does not listen to it anyway. Nor is the answer a
faster sample. It is to stop treating a sample as the truth right now
and start treating it as a keyframe: hold the previous sample beside
the current one and hand the solver a rect lerped between the two, so
a 20 Hz measurement drives a 60 Hz push. Sizes interpolate too, which
makes a resizing window sweep against Mio instead of stepping.

The lerp spans the throttle rather than the measured gap between the
last two samples, and that distinction is what makes the hand-off
seamless. The gap is irregular by construction — the ticker decides
which frame `readSurfaces()` runs on, so a 50 ms throttle fires at
50 ms and at 66.7 ms in whatever order the frames fall. Spread over the
previous gap, the interpolation is left unfinished whenever a short gap
follows a long one, and the next keyframe pair starts from the position
the last one was still travelling toward: a jump, the smaller cousin of
the one being removed. Spread over the throttle, it has always arrived
before the next sample can land, because the throttle is a floor on the
gap. The worst case degrades to a hold of a frame or two.

Interpolation rather than extrapolation costs one interval of latency —
Mio is pushed by where the window was 50 ms ago, invisible on a
decorative blob — and buys never overshooting and snapping back when
the drag stops.

Three cases are deliberately not interpolated, because none of them are
motion: an obstacle with no previous keyframe (a window that just
opened is solid where it is, not slid in from nowhere), a gap longer
than 250 ms (the tab was in the background), and anything following a
layer resize, where a new origin re-bases every obstacle coordinate at
once. A still desk — the overwhelmingly common case — short-circuits
and hands back the measured array itself.

Contact still treats obstacles as static: a moving window displaces Mio
without imparting momentum, exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 4f5b6f6 into trunk Aug 4, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the fix/mio-obstacle-interpolation branch August 4, 2026 07:49
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