Skip to content

fix(text): position glyphs at their fractional coordinates - #461

Merged
LeadcodeDev merged 1 commit into
mainfrom
fix/text-subpixel-positioning
Sep 30, 2026
Merged

LeadcodeDev merged 1 commit into
mainfrom
fix/text-subpixel-positioning

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Every skia_safe::Font in the engine was built with Skia's defaults, which place glyphs on whole device pixels: subpixel positioning off, baseline snapping on. Everything else on the canvas sits at its exact fractional position, so under any slow motion the box moved by hundredths of a pixel while the glyphs inside it advanced in 1px jumps. What that looks like is a label shivering inside a card that is standing still — a wiggle of a few pixels, a float loop, a camera push-in.

Measured on the scenario from the issue, a text and a 4px bar in one column translated 0 → 3px linearly: the bar's ink centroid tracked the keyframe to the hundredth, the text's held for three frames and then jumped by exactly 1.00px.

renderer::subpixel_font is now the only way to build a Font, and the 107 direct Font::from_typeface calls across 37 files go through it. A single constructor rather than 107 call sites to keep in line: a new component that writes the obvious thing gets the right behaviour, and reintroducing the snapping means deleting the constructor rather than forgetting three setters.

The residue is 0.153px against 0.690px before, not zero, because Skia quantises subpixel positions to quarter-pixels. That is a quarter of the previous error and a fifth of a pixel of the glyph's own antialiasing, rather than a visible step.

centring_keeps_the_gradient_over_the_glyphs had to change what it measures, and it is worth being explicit about why, since a test edited alongside the change it guards deserves the scrutiny. It sampled the rightmost ink column and compared premultiplied channels. With glyph edges no longer snapped, that column is an antialiased fringe rather than a hard edge, so the identical violet read (33, 22, 59) through alpha 61 where the centred case read (139, 92, 246) at full coverage — 33 × 255 / 61 = 138. The gradient was never wrong; the test was reading premultiplied values as if they were colours. It now walks in from the right to a column with real coverage and unpremultiplies before comparing.

Verification — a new integration test renders the issue's scenario frame by frame, separates the text band from the bar band by their ink rows, and asserts the text centroid follows the travel and does not drift against the bar. Red at 0.690px with the constructor's three setters removed, green at 0.153px with them, tolerance 0.25px. Plus cargo fmt --all --check, cargo clippy --workspace --all-targets --features rustmotion/studio -- -D warnings, and cargo test --workspace --features rustmotion/studio: 0 failed. A 40px and a 14px line were also rendered and looked at, since hinting is now off.

Closes #456

Every Font was built with Skia's defaults, which place glyphs on whole
device pixels: subpixel positioning off, baseline snapping on. The box
around a text moved by hundredths of a pixel, the glyphs inside it
advanced in 1px jumps, so a label visibly slid against its own container
under any slow motion — a wiggle of a few pixels, a float loop, a camera
push-in. Measured on the reported scenario, the text centroid strayed
0.690px from a 3px linear travel while the bar sharing its translated
box tracked it to the hundredth.

`subpixel_font` is now the only constructor, and the 107 direct
`Font::from_typeface` calls across 37 files go through it, so a new
component cannot reintroduce the default by writing the obvious thing.
The residue is 0.153px, which is Skia quantising subpixel positions to
quarter-pixels rather than snapping.

One test had to change its measurement. `centring_keeps_the_gradient
_over_the_glyphs` sampled the rightmost ink column and compared
premultiplied channels; that column is now an antialiased fringe rather
than a hard edge, so the same violet read (33, 22, 59) through alpha 61
instead of (139, 92, 246). It now reads a covered column and
unpremultiplies, which is what it meant to compare.

Closes #456
@LeadcodeDev LeadcodeDev self-assigned this Sep 30, 2026
@LeadcodeDev
LeadcodeDev merged commit fc373ba into main Sep 30, 2026
4 checks passed
@LeadcodeDev
LeadcodeDev deleted the fix/text-subpixel-positioning branch September 30, 2026 16:09
@LeadcodeDev LeadcodeDev mentioned this pull request Oct 1, 2026
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.

Text snaps to whole pixels while everything around it moves sub-pixel: fonts are created with Skia's default subpixel/baseline-snap settings

1 participant