diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 30cf402..04fc92c 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -76,6 +76,14 @@ of a pane out of view while there is room under the cap, and gives idle ones up recently seen first only when a visible pane needs the slot. Same four round trips: 0 attaches after the first sight of each pane. Freezing is unchanged — an idle renderer sits on a frozen pane doing nothing — so the two axes stay separate as before. +**Thin strokes shimmered while the canvas glided.** The track moves by `translateX`, and +the offset was rounded to whole CSS pixels. At this machine's 1.67× scale a whole CSS +pixel is one and two-thirds device pixels, so two frames in three landed the composited +layers between device pixels and the compositor resampled them — box-drawing lines and +underscores swam, and a glide that stopped on such a frame stayed soft at rest. The offset +is now rounded in device pixels (`snapToDevicePixels`), which at 1× and 2× is what it was. +Screencast of the same six-notch fling, variance of the Laplacian over the pane area: +moving frames averaged 128 before and 143 after; the frame at rest went from 127 to 148. **Then every switch stuttered.** Handing the contexts back on the way out is a rule about the *cap*, and it was paying for the cap on every switch whether or not the page was diff --git a/src/renderer/canvas-view.ts b/src/renderer/canvas-view.ts index c395bad..f7bd193 100644 --- a/src/renderer/canvas-view.ts +++ b/src/renderer/canvas-view.ts @@ -12,6 +12,7 @@ import { maxScrollX, paneRects, scrollToReveal, + snapToDevicePixels, type PaneRect, type Viewport, } from './layout-geometry' @@ -164,7 +165,7 @@ export function createCanvasView(host: HTMLElement, hooks: CanvasHooks): CanvasV function applyScroll(value: number): void { scrollX = value - track.style.transform = `translateX(${-Math.round(value)}px)` + track.style.transform = `translateX(${-snapToDevicePixels(value, window.devicePixelRatio)}px)` syncIndicator() hooks.onScroll?.() } diff --git a/src/renderer/layout-geometry.test.ts b/src/renderer/layout-geometry.test.ts index 24b93cb..339ab6e 100644 --- a/src/renderer/layout-geometry.test.ts +++ b/src/renderer/layout-geometry.test.ts @@ -8,6 +8,7 @@ import { maxScrollX, paneRects, scrollToReveal, + snapToDevicePixels, visiblePaneIds, } from './layout-geometry' @@ -139,3 +140,21 @@ describe('visiblePaneIds', () => { expect(ids).not.toContain('a1') }) }) + +describe('snapToDevicePixels', () => { + it('rounds to whole CSS pixels at 1× and 2×', () => { + expect(snapToDevicePixels(10.4, 1)).toBe(10) + expect(snapToDevicePixels(10.26, 2)).toBe(10.5) + }) + + it('lands on a device pixel at fractional scales', () => { + const dpr = 5 / 3 + const snapped = snapToDevicePixels(184, dpr) + expect(Math.abs(snapped * dpr - Math.round(snapped * dpr))).toBeLessThan(1e-9) + expect(Math.abs(snapped - 184)).toBeLessThan(1 / dpr) + }) + + it('treats a missing ratio as 1×', () => { + expect(snapToDevicePixels(3.7, 0)).toBe(4) + }) +}) diff --git a/src/renderer/layout-geometry.ts b/src/renderer/layout-geometry.ts index 63f2055..8aa43d8 100644 --- a/src/renderer/layout-geometry.ts +++ b/src/renderer/layout-geometry.ts @@ -113,6 +113,19 @@ export function scrollToReveal( * Panes overlapping the active region: the viewport widened by one screen each * way, so renderers are attached before they scroll into view. */ +/** + * A CSS length that lands on a whole device pixel. + * + * The track slides by a transform, and a translation that stops between device + * pixels is resampled by the compositor: at a 1.67× or 1.5× scale most integer + * CSS offsets do, so thin strokes shimmer while the canvas glides. Rounding in + * device pixels keeps every frame crisp; at 1× and 2× it is plain rounding. + */ +export function snapToDevicePixels(cssPx: number, devicePixelRatio: number): number { + const dpr = devicePixelRatio > 0 ? devicePixelRatio : 1 + return Math.round(cssPx * dpr) / dpr +} + export function visiblePaneIds(rects: readonly PaneRect[], viewport: Viewport): string[] { const from = viewport.scrollX - viewport.width const to = viewport.scrollX + viewport.width * 2 diff --git a/src/renderer/self-check/harness.ts b/src/renderer/self-check/harness.ts index fe828f3..8610dca 100644 --- a/src/renderer/self-check/harness.ts +++ b/src/renderer/self-check/harness.ts @@ -220,7 +220,8 @@ export function wheel( export function trackOffset(): number { const track = document.querySelector('.session-host:not([hidden]) .canvas-track') - const match = /translateX\((-?\d+)px\)/.exec(track?.style.transform ?? '') + // Snapped to device pixels, so at fractional scales the CSS value has decimals. + const match = /translateX\((-?\d+(?:\.\d+)?)px\)/.exec(track?.style.transform ?? '') return match === null ? 0 : Number(match[1]) }