sparse_strips: CoverageContrast, an opt-in coverage transfer for glyph sharpening - #1790
Open
AdrianEddy wants to merge 2 commits into
Open
sparse_strips: CoverageContrast, an opt-in coverage transfer for glyph sharpening#1790AdrianEddy wants to merge 2 commits into
AdrianEddy wants to merge 2 commits into
Conversation
…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
force-pushed
the
coverage-contrast
branch
2 times, most recently
from
August 2, 2026 13:26
b4682c2 to
f2ba8ec
Compare
…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).
AdrianEddy
force-pushed
the
coverage-contrast
branch
from
August 2, 2026 15:19
b7b9be2 to
20b1464
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
CoverageContrast, an opt-in coverage transfer applied to alpha-mask tints (glyph coverage) before tinting: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 viaRenderContext::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:
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_pathplus glifo'sFontEmbolden(#1628).This PR adds the complementary coverage-space half, and the two compose rather than compete:
FontEmboldenoffsets 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.wterm 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
SkMaskGamma: mask contrasta + k·a(1−a)plus luminance-dependent gamma LUTswterm is exactly Skia's contrast form, minus the LUTs (see below)a(k+1)/(ak+1),kgated by text luminance (visible in Windows Terminal's and Zed's shaders)FontEmboldensmoothstepover a constant 1.2 device-px band → peak slope 1.25 alpha/pxc = 0.5reproduces its output to within one 8-bit level across the core of the edge rampThe 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_colorscales the storedwby 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
t = a − ½the derivative is1 + c/2 − 6c·t² − 2w·t, concave, minimized ata = 1where it equals1 − c − w.from_bitstherefore capswat 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.NONEis a branch, not neutral math; andw = 0leavesc-only output bit-identical because the appended weight term is exactly+0.0. Both are pinned by tests.NONEcontributes zero bits, so paints that don't opt in encode byte-identically. The previously whole-word shader mode compare is masked accordingly.GlyphRenderer::glyph_coverage_contrasthook; 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
Testing
vello_common/tests/coverage_contrast.rs: property tests for bit-exact identity, fixed endpoints, symmetry of thecterm, exhaustive monotonicity/range sweeps (including dense probing at the capped boundary where the derivative vanishes), the headroom cap, the Skia-form equivalence of thewterm, luminance resolution (endpoints, gray = ¼, Rec. 709 ordering, NaN/out-of-range handling), and u8/f32 agreement.pack_tintunit test pinning the wire layout.c-only andc + 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
Tintgained acontrastfield (CoverageContrast::NONEpreserves the previous behavior); changelog entries note it under "Changed" forvello_commonand "Added" forvello_cpu,vello_hybrid, andglifo.This PR was generated by Claude