Skip to content

glifo: Make glyph pixel-snapping decisions stable under last-ulp noise - #1785

Open
AdrianEddy wants to merge 1 commit into
linebender:mainfrom
AdrianEddy:stable-glyph-placement
Open

AdrianEddy wants to merge 1 commit into
linebender:mainfrom
AdrianEddy:stable-glyph-placement

Conversation

@AdrianEddy

Copy link
Copy Markdown
Contributor

Glyph placement makes discrete per-pixel decisions from an f64 translation: the floor/fract split behind the atlas quad position and the subpixel-bucket cache key, and the vertical-hinting baseline round. A consumer that produces the same content twice — a live pass and a recorded or cached one — computes that translation through different floating-point op orders, and the two results agree only to ~1e-12. Glyph origins routinely sit exactly on the decision boundary (integer coordinates, or exact halves under fractional scale factors), so that last-ulp noise decides the pixel: the same glyph can render one device pixel apart between the two passes. In a caching compositor this shows up as text popping by a pixel when a cached layer is swapped in for a live render — the same "two producers must agree byte-for-byte" requirement behind the damage-rendering work in #1737.

The fix biases every decision by 1e-6 — far above the noise, far below the 0.25-px subpixel buckets — so the boundaries move off those structured values, and any two evaluations agreeing within 1e-6 decide identically. Details:

  • The floor and fract halves of the split share the biased value; mixing a biased and a raw operation would reopen the 1-px gap.
  • The hinting round becomes half-up, which also makes it shift-invariant across zero (f64::round's half-away-from-zero is not: translating a scene by a whole pixel could flip a baseline the old way).
  • Auditing the decision chain surfaced that the subpixel quantizer could receive exactly 1.0 through the f32 narrowing of the fract and wrapped it to bucket 0 — shifting the glyph in the wrong direction. It now clamps to the last bucket.

Away from the boundaries nothing changes: the full snapshot suite passes untouched. New tests pin decision stability at integers and exact halves under injected noise, plus the end-to-end lockstep of the bucket and the quad pixel.

An alternative would be snapping the translation to fixed point (FreeType-style) before all decisions — strictly stronger, but it changes the placement of every fractional position and guarantees snapshot churn. This is the minimal zero-churn version, and the helper call sites are exactly where a snap would slot in if that direction is preferred later.

This PR was generated by Claude.

Glyph placement makes discrete per-pixel decisions from an f64
translation: the floor/fract split behind the atlas quad position and
the subpixel-bucket cache key, and the hinting baseline round. A
consumer that produces the same content twice - a live pass and a
recorded or cached one - computes that translation through different
floating-point op orders and gets answers agreeing only to ~1e-12,
while glyph origins routinely sit exactly on the decision boundary
(integer coordinates; exact halves under fractional scale factors).
A bare floor/round lets that last-ulp noise decide the pixel, so the
two passes can place the same glyph one device pixel apart.

Bias every decision by 1e-6 - far above the noise, far below the
0.25-px subpixel buckets - so the boundary moves off those structured
values and any two evaluations agreeing within 1e-6 decide identically.
The floor and fract halves of the split share the biased value, and the
hinting round becomes half-up so exact negative halves decide like
positive ones. The subpixel quantizer now clamps an input of exactly
1.0 (reachable through the f32 narrowing of the biased fract) to the
last bucket instead of wrapping to bucket 0, which would shift the
glyph in the wrong direction. No behavior change away from the
boundaries; the full snapshot suite passes untouched.
@AdrianEddy
AdrianEddy force-pushed the stable-glyph-placement branch from 8c47ff7 to b4adf81 Compare August 14, 2026 06:22
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