shaping: felis's cell is a pixel larger than kitty's at the same pinned size #404

Closed
opened 2026-09-25 01:34:56 +09:00 by natsukium · 1 comment
Owner

At the pinned font and size the harness measured felis's cell as larger than every peer's (docs/reference/benchmarks.md "Limits of this run"):

  • Linux, DejaVu Sans Mono 14 pt: felis 12x23 px, kitty, alacritty and foot 11x23.
  • macOS, Menlo 14 pt: felis 9x17 px, kitty 9x16, alacritty and ghostty 8x16.

At 106x35 cells felis paints 8-21% more pixels, which also puts it at a disadvantage in every throughput and latency suite.

CellMetrics::from_scaled (crates/felis-shaping/src/lib.rs) ceils the width, and docs/explanation/rendering/text-shaping.md justifies it partly with "kitty ceils the same advance". The Linux figures contradict that premise: kitty gets 11 px where felis gets 12. Either the pt-to-px conversion differs, or kitty's rounding is not what the doc says.

Decide:

  1. Find where felis's pixel size and kitty's diverge for the same point size (DPI, the advance measured, rounding).
  2. Either correct the rationale in text-shaping.md and keep the ceil, or change the rounding and record why.

Done when the doc's premise matches what kitty does, and the choice is recorded in the owning explanation doc.

At the pinned font and size the harness measured felis's cell as larger than every peer's (`docs/reference/benchmarks.md` "Limits of this run"): - Linux, DejaVu Sans Mono 14 pt: felis 12x23 px, kitty, alacritty and foot 11x23. - macOS, Menlo 14 pt: felis 9x17 px, kitty 9x16, alacritty and ghostty 8x16. At 106x35 cells felis paints 8-21% more pixels, which also puts it at a disadvantage in every throughput and latency suite. `CellMetrics::from_scaled` (`crates/felis-shaping/src/lib.rs`) ceils the width, and `docs/explanation/rendering/text-shaping.md` justifies it partly with "kitty ceils the same advance". The Linux figures contradict that premise: kitty gets 11 px where felis gets 12. Either the pt-to-px conversion differs, or kitty's rounding is not what the doc says. Decide: 1. Find where felis's pixel size and kitty's diverge for the same point size (DPI, the advance measured, rounding). 2. Either correct the rationale in `text-shaping.md` and keep the ceil, or change the rounding and record why. Done when the doc's premise matches what kitty does, and the choice is recorded in the owning explanation doc.
Author
Owner

Diagnosis: where felis's cell width and kitty's diverge

Short answer: the pt→px conversion is the same in both. The difference is hinting. On Linux, kitty's FreeType path ceils an advance that FreeType has already rounded to a whole pixel, so kitty's ceil does nothing there. The doc's premise holds for kitty's CoreText path and fails for its FreeType path.

Sources: kitty 4498c0028, crossfont master, ghostty 4ae9f1a2d. The numbers come from a FreeType 2.13 probe (FT_Set_Char_Size plus FT_Load_Glyph under each load flag) and a swash probe of Font::cell_metrics in a throwaway worktree. Both ran on DejaVuSansMono.ttf 2.37 from nixpkgs. These are deterministic computations, so host load does not affect them.

1. pt → px: identical

  • kitty: dpi_from_scale (kitty/glfw.c:1491) uses 96 × scale on Linux and 72 × scale on macOS. FT_Set_Char_Size(face, 0, ceil(pt*64), dpi, dpi) (kitty/freetype.c:184,228).
  • felis via the harness: tools/bench/field.py:150 gives pt * 96/72 on Linux and pt on macOS.
  • 14 pt therefore gives 18.667 px on Linux and 14 px (logical) on macOS in both terminals. DejaVu's head.flags bit 3 (integer ppem) is set, so FreeType reports x_ppem = 19. x_scale still corresponds to 18.67 px, and the metrics below use that scale.

2. Which advance, and how it is rounded

glyphs measured advance source rounding
felis (crates/felis-shaping/src/lib.rs:182-191, :692) M swash, unhinted, linear scale ceil
kitty FreeType (kitty/freetype.c:546-552) max over U+0020..U+007F FT_Load_Glyph with the fontconfig hint style (get_load_flags, :129) → glyph->metrics.horiAdvance ceilf of an already pixel-rounded value
kitty CoreText (kitty/core_text.m:671) max over U+0020..U+007F CTFontGetAdvancesForGlyphs, unhinted ceilf
alacritty/crossfont FT (src/ft/mod.rs:423) 0 hinted horiAdvance alacritty floor (display/mod.rs:1612)
alacritty/crossfont CoreText (src/darwin/mod.rs:327) 0 unhinted floor
ghostty (src/font/Metrics.zig:239-265) face cell width unhinted @round, with a comment explaining why ceil was rejected

FreeType probe at 18.667 px (this host's fontconfig: hinting=True hintstyle=1 (slight), so kitty passes FT_LOAD_TARGET_LIGHT):

NO_HINTING     M horiAdvance = 11.2344   linearHoriAdvance = 11.2414
TARGET_LIGHT   M horiAdvance = 11.0000
TARGET_NORMAL  M horiAdvance = 11.0000

felis/swash at 18.667 px: advance 11.2383. ceil gives 12, and kitty's ceilf(11.0) gives 11. Even at slight hinting, FreeType rounds the advance to the nearest pixel (11.24 → 11), so kitty's ceil acts on an integer. With hinting=false in fontconfig, kitty would also get 12.

macOS, Menlo 14 px: CoreText does not hint advances. kitty gets ceilf(8.43) = 9, the same as felis's 9, and alacritty (floor) and ghostty (round) get 8. The raw values are swash at 14 px: advance 8.4287, ascent 12.9951, descent 3.3018, leading 0. They were computed from DejaVu Sans Mono, which shares Menlo's metrics, and they reproduce the measured felis 9x17 exactly. Verifying them against Menlo itself on the Mac remains open.

Verdict on docs/explanation/rendering/text-shaping.md:222-225: "kitty ceils the same advance (its CoreText and FreeType backends both ceil per-glyph advances)" describes the code correctly but gets the effect wrong. On FreeType, kitty ceils a hinted advance that is already whole, so its effective rule is round at the fontconfig default. The claim that ceiling "keeps felis and kitty on the same column count" holds on macOS only.

3. Height (Linux 23 = 23, macOS 17 vs 16)

  • Linux: felis ceil(17.327) + ceil(4.402) = 18 + 5 = 23. kitty ceil(height*y_scale) = ceil(21.73) = 22 (freetype.c:167), which the _ bitmap check (:169-176) then bumps to 23. alacritty max(size_metrics.height = 22, asc − desc = 18 + 5) = 23. foot uses fcft's per-metric ceil, also 23. Every terminal agrees.
  • macOS: felis ceil(12.995) + ceil(3.302) = 13 + 4 = 17. alacritty rounds each part: 13 + 3 + 0 = 16 (darwin/mod.rs:329-332). ghostty rounds the sum: round(16.297) = 16. kitty gets 16 from ceilf of the CTFramesetter line pitch (core_text.m:704), so CoreText must lay lines out 16.0 px apart. The extra row comes from ceiling a 3.30 px descent.
  • No single height rule matches the peers on both platforms. Rounding the descent would give 16 on macOS but 18 + 4 = 22 on Linux, one row short of every Linux peer. The separate-ceil rule is the only one that gives 23 on Linux.

4. Proposed change

Width: switch CellMetrics::from_scaled from width.ceil() to width.round() (ghostty's rule). The results would be:

felis now felis with round kitty alacritty ghostty foot
Linux DejaVu 14 pt 12 11 11 11 – 11
macOS Menlo 14 pt 9 8 9 8 8 –
macOS Menlo 14 pt @2x (28 px, 16.86) 17 17 17 16 17 –

Clipping: rounding does not reintroduce the clipping the doc warns about, because felis draws glyph quads at bitmap size and never clips them to the cell. Instances carry per-glyph left/top offsets, and negative bearings already slice back across cells (crates/felis-render-wgpu/src/instances.rs). DejaVu's ASCII ink at 18.667 px spans [0.000 ('W'), 11.234 ('R')]. In an 11 px cell the widest glyphs therefore reach at most 0.23 px of antialiased coverage into the neighbor cell, and no ink is lost. kitty on Linux accepts the same overlap: slight hinting rounds the advance but leaves the outline unhinted horizontally. ghostty accepts it explicitly (Metrics.zig:239-248). The doc's real concern is that "glyphs touch", which is the cost round accepts. The from_scaled doc comment and the 7.35 → 8 test (lib.rs:1233) flip to 7.

A kitty-exact alternative would be "round on Linux, ceil on macOS". felis rasterizes with swash on both platforms, so that split would mirror a rasterizer difference felis does not have. I recommend one rule.

Height: keep the separate ceil, and record in text-shaping.md that macOS peers get 16 for Menlo 14 because CoreText rounds a 3.30 px descent down, which a single cross-platform rule cannot match without losing Linux parity.

Size: about 3 lines of code plus 2 test assertions, a rewrite of the text-shaping.md "Atlas integration" width paragraph (≈8 lines), and a CHANGELOG entry, since the default column count changes for the same font_size. The tools/bench/field.py grid probe re-derives geometry from felis, so the bench field follows automatically.

Confidence: high for Linux (every number measured on this host). For macOS it is medium, because Menlo's metrics are inferred from DejaVu (they reproduce the measured 9x17) and not read from the Mac.

## Diagnosis: where felis's cell width and kitty's diverge Short answer: the pt→px conversion is the same in both. The difference is **hinting**. On Linux, kitty's FreeType path ceils an advance that FreeType has already rounded to a whole pixel, so kitty's ceil does nothing there. The doc's premise holds for kitty's CoreText path and fails for its FreeType path. Sources: kitty `4498c0028`, crossfont `master`, ghostty `4ae9f1a2d`. The numbers come from a FreeType 2.13 probe (`FT_Set_Char_Size` plus `FT_Load_Glyph` under each load flag) and a swash probe of `Font::cell_metrics` in a throwaway worktree. Both ran on `DejaVuSansMono.ttf` 2.37 from nixpkgs. These are deterministic computations, so host load does not affect them. ### 1. pt → px: identical - kitty: `dpi_from_scale` (`kitty/glfw.c:1491`) uses 96 × scale on Linux and 72 × scale on macOS. `FT_Set_Char_Size(face, 0, ceil(pt*64), dpi, dpi)` (`kitty/freetype.c:184,228`). - felis via the harness: `tools/bench/field.py:150` gives `pt * 96/72` on Linux and `pt` on macOS. - 14 pt therefore gives 18.667 px on Linux and 14 px (logical) on macOS in both terminals. DejaVu's `head.flags` bit 3 (integer ppem) is set, so FreeType reports `x_ppem = 19`. `x_scale` still corresponds to 18.67 px, and the metrics below use that scale. ### 2. Which advance, and how it is rounded | | glyphs measured | advance source | rounding | |---|---|---|---| | felis (`crates/felis-shaping/src/lib.rs:182-191`, `:692`) | `M` | swash, unhinted, linear scale | `ceil` | | kitty FreeType (`kitty/freetype.c:546-552`) | max over U+0020..U+007F | `FT_Load_Glyph` **with the fontconfig hint style** (`get_load_flags`, `:129`) → `glyph->metrics.horiAdvance` | `ceilf` of an already pixel-rounded value | | kitty CoreText (`kitty/core_text.m:671`) | max over U+0020..U+007F | `CTFontGetAdvancesForGlyphs`, unhinted | `ceilf` | | alacritty/crossfont FT (`src/ft/mod.rs:423`) | `0` | hinted `horiAdvance` | alacritty `floor` (`display/mod.rs:1612`) | | alacritty/crossfont CoreText (`src/darwin/mod.rs:327`) | `0` | unhinted | `floor` | | ghostty (`src/font/Metrics.zig:239-265`) | face cell width | unhinted | `@round`, with a comment explaining why ceil was rejected | FreeType probe at 18.667 px (this host's fontconfig: `hinting=True hintstyle=1` (slight), so kitty passes `FT_LOAD_TARGET_LIGHT`): ``` NO_HINTING M horiAdvance = 11.2344 linearHoriAdvance = 11.2414 TARGET_LIGHT M horiAdvance = 11.0000 TARGET_NORMAL M horiAdvance = 11.0000 ``` felis/swash at 18.667 px: advance 11.2383. `ceil` gives **12**, and kitty's `ceilf(11.0)` gives **11**. Even at slight hinting, FreeType rounds the advance to the nearest pixel (11.24 → 11), so kitty's ceil acts on an integer. With `hinting=false` in fontconfig, kitty would also get 12. macOS, Menlo 14 px: CoreText does not hint advances. kitty gets `ceilf(8.43) = 9`, the same as felis's 9, and alacritty (`floor`) and ghostty (`round`) get 8. The raw values are swash at 14 px: advance 8.4287, ascent 12.9951, descent 3.3018, leading 0. They were computed from DejaVu Sans Mono, which shares Menlo's metrics, and they reproduce the measured felis 9x17 exactly. Verifying them against Menlo itself on the Mac remains open. **Verdict on `docs/explanation/rendering/text-shaping.md:222-225`:** "kitty ceils the same advance (its CoreText and FreeType backends both `ceil` per-glyph advances)" describes the code correctly but gets the effect wrong. On FreeType, kitty ceils a hinted advance that is already whole, so its effective rule is `round` at the fontconfig default. The claim that ceiling "keeps felis and kitty on the same column count" holds on macOS only. ### 3. Height (Linux 23 = 23, macOS 17 vs 16) - Linux: felis `ceil(17.327) + ceil(4.402) = 18 + 5 = 23`. kitty `ceil(height*y_scale) = ceil(21.73) = 22` (`freetype.c:167`), which the `_` bitmap check (`:169-176`) then bumps to 23. alacritty `max(size_metrics.height = 22, asc − desc = 18 + 5)` = 23. foot uses fcft's per-metric ceil, also 23. Every terminal agrees. - macOS: felis `ceil(12.995) + ceil(3.302) = 13 + 4 = 17`. alacritty rounds each part: `13 + 3 + 0 = 16` (`darwin/mod.rs:329-332`). ghostty rounds the sum: `round(16.297) = 16`. kitty gets 16 from `ceilf` of the CTFramesetter line pitch (`core_text.m:704`), so CoreText must lay lines out 16.0 px apart. The extra row comes from ceiling a 3.30 px descent. - No single height rule matches the peers on both platforms. Rounding the descent would give 16 on macOS but 18 + 4 = 22 on Linux, one row short of every Linux peer. The separate-ceil rule is the only one that gives 23 on Linux. ### 4. Proposed change Width: **switch `CellMetrics::from_scaled` from `width.ceil()` to `width.round()`** (ghostty's rule). The results would be: | | felis now | felis with `round` | kitty | alacritty | ghostty | foot | |---|---|---|---|---|---|---| | Linux DejaVu 14 pt | 12 | **11** | 11 | 11 | – | 11 | | macOS Menlo 14 pt | 9 | **8** | 9 | 8 | 8 | – | | macOS Menlo 14 pt @2x (28 px, 16.86) | 17 | 17 | 17 | 16 | 17 | – | Clipping: rounding does not reintroduce the clipping the doc warns about, because felis draws glyph quads at bitmap size and never clips them to the cell. Instances carry per-glyph `left`/`top` offsets, and negative bearings already slice back across cells (`crates/felis-render-wgpu/src/instances.rs`). DejaVu's ASCII ink at 18.667 px spans [0.000 ('W'), 11.234 ('R')]. In an 11 px cell the widest glyphs therefore reach at most 0.23 px of antialiased coverage into the neighbor cell, and no ink is lost. kitty on Linux accepts the same overlap: slight hinting rounds the advance but leaves the outline unhinted horizontally. ghostty accepts it explicitly (`Metrics.zig:239-248`). The doc's real concern is that "glyphs touch", which is the cost `round` accepts. The `from_scaled` doc comment and the `7.35 → 8` test (`lib.rs:1233`) flip to 7. A kitty-exact alternative would be "round on Linux, ceil on macOS". felis rasterizes with swash on both platforms, so that split would mirror a rasterizer difference felis does not have. I recommend one rule. Height: keep the separate ceil, and record in `text-shaping.md` that macOS peers get 16 for Menlo 14 because CoreText rounds a 3.30 px descent down, which a single cross-platform rule cannot match without losing Linux parity. Size: about 3 lines of code plus 2 test assertions, a rewrite of the `text-shaping.md` "Atlas integration" width paragraph (≈8 lines), and a CHANGELOG entry, since the default column count changes for the same `font_size`. The `tools/bench/field.py` grid probe re-derives geometry from felis, so the bench field follows automatically. Confidence: high for Linux (every number measured on this host). For macOS it is medium, because Menlo's metrics are inferred from DejaVu (they reproduce the measured 9x17) and not read from the Mac.
Commenting is not possible because the repository is archived.
No description provided.