Skip to content

proposal: add an advance-only shaping path that skips per-glyph GlyphExtents #259

@hajimehoshi

Description

@hajimehoshi

I found that getting advances of a text takes unnecesasry long, due to HarfBuzz's works (HarfBuzzShaper.Shape).

This is a real world issue in my GUI framework (guigui-gui/guigui#390), when putting a big (10M or so) text data in a text input, as this needs to calculate text advances to show appropriate text glyphs.

Claude Code helped me to make this proposal, and I'll paste the summary as following:


Title: Add an advance-only shaping path that skips per-glyph GlyphExtents

Body:

Summary

shaping.HarfbuzzShaper.Shape unconditionally fills each glyph's Width, Height, XBearing, and YBearing by calling font.Face.GlyphExtents once per glyph. Callers that only need the total advance (text measurement, line-wrapping width queries, etc.) have no way to opt out, even though the advance data is already produced by harfbuzz.Buffer.Shape in buf.Pos[i].XAdvance / YAdvance.

For variable fonts this is expensive: every GlyphExtents miss flows through getExtentsFromGlyfgetGlyfPointsgvar.applyDeltasToPoints. The cost is amplified because Face.SetCoords (called every time SetVariations runs) resets the extentsCache, so back-to-back shape calls usually start cold.

Profile evidence

CPU profile from a text-editor workload (Measure calling Advance per visible line) on a variable font:

github.com/hajimehoshi/ebiten/v2/text/v2.(*GoTextFace).advance     3.75s  65% cum
  shaping.(*HarfbuzzShaper).Shape                                  2.25s  39%
    harfbuzz.(*Buffer).Shape                                       0.40s   7%   ← actual layout
    font.(*Face).GlyphExtents loop                                 1.74s  30%   ← unused by advance
      font.(*Face).getGlyfPoints                                   1.26s  22%
      font.gvar.applyDeltasToPoints                                0.42s   7%

GlyphExtents accounts for ~30% of total CPU on this profile and is dead work for the caller.

Workaround

Drop to harfbuzz directly: cache a *harfbuzz.Font and *harfbuzz.Buffer, replicate the positioning prelude of HarfbuzzShaper.Shape (segmenter run handling, sideways axis switch, font scale, feature array), call Buffer.Shape, then sum Pos[i].XAdvance/YAdvance. This works but duplicates ~50 lines of HarfbuzzShaper.Shape's setup, and the duplication is fragile against upstream changes (e.g. how sideways advances are signed).

Proposed API

A way to ask HarfbuzzShaper to skip per-glyph extent population. A few options, in rough order of preference:

  1. New method: (*HarfbuzzShaper).ShapeAdvance(Input) fixed.Int26_6 — returns just the total advance, with the same direction/sideways semantics Output.Advance would have.
  2. Flag on Input (or on HarfbuzzShaper): e.g. Input.SkipGlyphExtents bool. Shape returns the same Output but with Width/Height/XBearing/YBearing left zero. Output.Advance and Glyph.Advance are still populated. Less invasive but couples the contract to a field.
  3. New method returning a stripped Output: e.g. ShapeNoExtents(Input) Output. Cheaper than (1) for callers that also want per-glyph advances, but more API surface than (2).

Option (1) is the simplest in usage and makes the cost difference explicit at the call site. Option (2) reuses existing types and integrates with Output.RecomputeAdvance callers.

Notes

  • The advance data is already present in buf.Pos[i] after Buffer.Shape, so the implementation is mostly "skip the extents loop, return early."
  • Sideways vertical text needs the same sign convention Output.sideways() applies (per-glyph advance negated), so ShapeAdvance should return Output.Advance semantics, not raw Pos.XAdvance sums.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions