Skip to content

sparse_strips: CoverageContrast, an opt-in coverage transfer for glyph sharpening - #1790

Open
AdrianEddy wants to merge 2 commits into
linebender:mainfrom
AdrianEddy:coverage-contrast
Open

sparse_strips: CoverageContrast, an opt-in coverage transfer for glyph sharpening#1790
AdrianEddy wants to merge 2 commits into
linebender:mainfrom
AdrianEddy:coverage-contrast

Conversation

@AdrianEddy

@AdrianEddy AdrianEddy commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds CoverageContrast, an opt-in coverage transfer applied to alpha-mask tints (glyph coverage) before tinting:

a' = a + c·a(1−a)(2a−1)   // steepen: lerp(identity, smoothstep)
       + w·a(1−a)         // weight bias: raises the 0.5 crossing by w/4

The default is bit-exact off: every consumer branches on is_none() before evaluating any new math, so existing output does not change by a single byte unless a renderer opts in via RenderContext::set_glyph_coverage_contrast / Scene::set_glyph_coverage_contrast.

Motivation

Vello's analytic coverage is the exact area of the pixel square covered by the outline — at a straight edge, a linear ramp with slope exactly 1.0 alpha/px. That is the correct answer to "how much of this pixel is ink," but it is not what any mainstream text stack displays: every one of them deliberately steepens (and, in some regimes, thickens) the transition, because the exact ramp reads as slightly soft, most visibly on low-DPI displays.

This gap is called out in Vello's own planning documents:

  • vision.md, "Improving rendering quality": "a theoretically 'correct' approach to gamma often yields text and hairline strokes that appear weak and spindly … it's likely that various heuristics will need to be applied to get good results. (Note that stem darkening is one approach used specifically for text rendering …)"
  • Plan for glyph rendering #204 notes the initial glyph strategy supports neither hinting-adjacent contrast boosts nor stem darkening, with results "suboptimal on low-dpi displays."
  • The theme goes back to A crate I want: 2d graphics (2018): "to match high quality desktop rendering both RGB subpixel rendering and gamma correct blending is desirable."

This PR is a small, opt-in implementation of exactly that predicted heuristic, in the factoring that fits the sparse-strips architecture.

Relation to the stem-darkening roadmap thread

roadmap_2023.md and Raph's reflections and wishes for 2023 proposed solving stem darkening geometrically, via a combined flatten + offset operation — "the same mechanism can apply stroke thickening to glyphs while retaining very high quality." The geometric half of that vision has since landed: kurbo::expand_path plus glifo's FontEmbolden (#1628).

This PR adds the complementary coverage-space half, and the two compose rather than compete:

  • FontEmbolden offsets outlines in em space, can add arbitrary weight, and participates in the glyph cache key — correct for a global weight change, but a per-text-color weight (see polarity below) would rasterize and cache every glyph once per color class.
  • The w term here is a sub-pixel, device-space adjustment to sampled coverage at fine/shader time — bounded by a fraction of a pixel, matched to the per-pixel blending artifact it corrects, and free to vary per draw: light and dark text share one cached rasterization at zero atlas churn.

Prior art in other renderers

Renderer Mechanism Relation to this PR
Skia SkMaskGamma: mask contrast a + k·a(1−a) plus luminance-dependent gamma LUTs the w term is exactly Skia's contrast form, minus the LUTs (see below)
DirectWrite enhanced contrast a(k+1)/(ak+1), k gated by text luminance (visible in Windows Terminal's and Zed's shaders) same family to first order; its gate boosts dark text because DWrite's blend model is gamma-corrected — in an sRGB-blending renderer the boost belongs on light text, which is the direction implemented here
FreeType / CoreText stem darkening / font smoothing: geometric outline emboldening the geometric complement; already available via FontEmbolden
Qt Quick (distance-field text) smoothstep over a constant 1.2 device-px band → peak slope 1.25 alpha/px c = 0.5 reproduces its output to within one 8-bit level across the core of the edge ramp

The polarity rationale for w: compositing in sRGB-encoded space (which vello_cpu/vello_hybrid do) decodes a half-covered pixel darker than the physically correct blend — dark-on-light text reads slightly bolder, light-on-dark slightly thinner. resolve_for_color scales the stored w by the text color's approximate relative luminance, so black text keeps the weight-free curve bit-exactly and white text receives the full stored strength.

Compared to the Skia/DWrite implementations, the factoring here is deliberately simpler and colorspace-agnostic: one closed-form curve on coverage before tinting, no baked LUTs coupled to a destination gamma, CPU and GPU evaluating identical 8-bit-quantized parameters.

Design notes

  • Provable invariant instead of per-pixel clamps. With t = a − ½ the derivative is 1 + c/2 − 6c·t² − 2w·t, concave, minimized at a = 1 where it equals 1 − c − w. from_bits therefore caps w at the contrast's headroom (c + w ≤ 1); under that invariant the curve is monotone with range [0, 1], so none of the three kernels needs a clamp.
  • Bit-exactness guarantees. NONE is a branch, not neutral math; and w = 0 leaves c-only output bit-identical because the appended weight term is exactly +0.0. Both are pinned by tests.
  • Wire format. The packed tint mode word carries the strengths in bits 8–23; NONE contributes zero bits, so paints that don't opt in encode byte-identically. The previously whole-word shader mode compare is masked accordingly.
  • glifo integration. A default-implemented GlyphRenderer::glyph_coverage_contrast hook; the atlas outline-glyph path stamps the luminance-resolved value. Bitmap/COLR glyphs (own color, no coverage mask) and atlas-miss outline fills (linear coverage) are unaffected — the latter is a documented limitation shared with hinting and subpixel-offset bucketing.

What this deliberately does not attempt

  • Gamma-correct (linear) blending — vision.md's own caution applies: linear blending makes text spindly without compensation, so it is a much larger, coupled change. This curve is compatible with such a future: it operates on coverage pre-tint, so only the calibration constant would move.
  • RGB subpixel rendering — incompatible with transparent-layer compositing, as the roadmap notes.
  • Backdrop-aware correction — the backdrop is unknowable at paint time in a compositing renderer; the same limitation applies to Skia and DirectWrite.

Testing

  • vello_common/tests/coverage_contrast.rs: property tests for bit-exact identity, fixed endpoints, symmetry of the c term, exhaustive monotonicity/range sweeps (including dense probing at the capped boundary where the derivative vanishes), the headroom cap, the Skia-form equivalence of the w term, luminance resolution (endpoints, gray = ¼, Rec. 709 ordering, NaN/out-of-range handling), and u8/f32 agreement.
  • pack_tint unit test pinning the wire layout.
  • Two new cross-backend snapshot tests (c-only and c + w) through the existing spritesheet-tint harness, so the CPU fine stages and the WESL shader are pinned against the same references.
  • cargo fmt, clippy --all-targets, and rustdoc are clean.

Breaking change

Tint gained a contrast field (CoverageContrast::NONE preserves the previous behavior); changelog entries note it under "Changed" for vello_common and "Added" for vello_cpu, vello_hybrid, and glifo.

This PR was generated by Claude

…h sharpening

Analytic exact-area coverage renders a glyph edge as a linear
1.0 alpha/px ramp, which reads softer than mainstream text rasterizers.
This adds an opt-in, renderer-wide coverage transfer applied to
alpha-mask tints:

  a' = a + c*a*(1-a)*(2a-1) + w*a*(1-a)

The symmetric c term steepens edges at constant stem weight, up to a
full smoothstep. The w term is the mask-contrast (embolden) form; its
strength is resolved per draw against the tint color's luminance to
compensate sRGB-space blending thinning light-on-dark text, and it
never enters glyph atlas keys. from_bits caps w at the contrast's
headroom (c + w <= 1), under which the curve is provably monotone with
range [0, 1], so no kernel needs a per-pixel clamp. The default is
bit-exact off: every consumer branches before any new math.

Wired through vello_cpu's fine stages (f32 and u8), vello_hybrid's
packed tint mode word (strength bytes 8-23) and render.wesl, and
glifo's atlas glyph path via a new GlyphRenderer::glyph_coverage_contrast
hook (default NONE). Property tests in
vello_common/tests/coverage_contrast.rs pin identity bit-exactness,
symmetry, monotonicity at the capped boundary, the luminance
resolution, and u8/f32 agreement.
@AdrianEddy
AdrianEddy force-pushed the coverage-contrast branch 2 times, most recently from b4682c2 to f2ba8ec Compare August 2, 2026 13:26
…yphs

The transfer previously reached atlas-cached outline glyphs only, through
their alpha-mask tint at sample time. Glyphs that bypass the atlas (atlas
full, oversized, rotated/skewed, caching disabled) were filled straight
from their outlines with linear coverage, so a glyph rendered softer
until it entered the atlas and changed appearance once cached.

Route the same curve through strip generation for those fills:
CoverageContrast::apply_to_coverage remaps generated coverage bytes with
apply_u8 (matching what the atlas path applies to sampled coverage), and
StripGenerator::generate_filled_path_with_coverage_transfer applies it
before any clip intersection, so a clip attenuates the transferred
coverage exactly as it attenuates an atlas-sampled glyph. Both backends
inherit the u8 alpha buffer, so vello_hybrid needs no shader change.

glifo gains GlyphRenderer::fill_glyph_path (default: linear fill, for
renderers that never report a transfer); fill_uncached_outline_glyph
resolves the transfer under the same conditions as the atlas path (solid
paints, weight resolved against the text color) and feeds it through.
vello_cpu threads the transfer through the dispatchers into strip
generation; vello_hybrid through Scene::fill_path_with.

Tests: apply_to_coverage pinned against apply_u8, and a cached-vs-direct
consistency test on the u8 pipeline where both paths round through
apply_u8 and must agree (fails with the direct path left linear).
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