feat: visual parity with the original Ghostty (pixel-diff pipeline)
Adds scripts/visual-parity.sh (`just visual-parity`): launches the installed Ghostty 1.3.1 and the Rust build in an isolated headless nested weston (GL renderer, Mesa forced, weston-screenshooter via --debug), with identical isolated config, a deterministic test page (ASCII, ligatures, all SGR styles and underline kinds, 16/256/truecolor, box/block/braille/powerline/nerd icons, CJK, emoji, wide chars, cursor) and a tabs+splits scenario driven through Xwayland, then diffs the captures (terminal area and chrome separately, heatmaps). Fixes found by comparing against the original: - A pane stayed blank after a nested split: the GTK render path called draw_frame(false) where Ghostty uses drawFrame(true) (a re-realized GLArea has a new swap chain that nothing else redraws). - Sprite glyph antialiasing now ports z2d's default 4x4 multisample rasterizer instead of tiny-skia's analytic coverage (pixels differing from Ghostty's reference sprite sheets: 5.76% -> 1.20%, none off by more than 96 levels); the sheet test tolerance is tightened to 0.1%. Result vs Ghostty 1.3.1: test page 0.020% of pixels differ (196 px at curly-underline ends and diagonal box strokes, max delta 86 levels); tabs+splits and headerbar/tab bar are bit-identical. Details in docs/rewrite/notes/visual-parity-VP.md. Test Plan: - just visual-parity (needs weston, weston-screenshooter, xdotool) - cargo test -p ghostty-font (sprite sheet comparison)
This commit is contained in:
1 parent
19ea627bed
commit
64aa8b6793
9 files changed
+599
-45
No files matched your search
@@ -0,0 +1,62 @@
|
||||
# Visual parity vs original Ghostty 1.3.1 (agent VP)
|
||||
|
||||
## Method (`scripts/visual-parity.sh`, `just visual-parity [scenario...]`)
|
||||
|
||||
- A private headless `weston` (`--backend=headless --renderer=gl`, 1200x800, `desktop-shell`, no
|
||||
panel) per run, on its own socket in `$XDG_RUNTIME_DIR=/tmp/vp-runtime-$UID` (socket paths must be
|
||||
<108 bytes). The user's desktop is never touched. Mesa is forced for weston (`__EGL_VENDOR_LIBRARY_FILENAMES`
|
||||
-> 50_mesa.json); the NVIDIA EGL crashes Xwayland's glamor. `XWAYLAND_NO_GLAMOR=1`.
|
||||
- `weston --debug` is what authorizes `weston-screenshooter` (without it: "Output capture error: unauthorized").
|
||||
- Both apps run with `env -i`, `GSETTINGS_BACKEND=memory`, no D-Bus, isolated `XDG_CONFIG_HOME` with the
|
||||
same explicit config (JetBrains Mono 13, bg/fg, padding 8, `maximize = true` so the window geometry
|
||||
is deterministic, `cursor-style-blink = false`, `title = Ghostty`, no shell integration) and
|
||||
`LANG=C.UTF-8` (otherwise `printf '\U..'` in the test page prints literally).
|
||||
- Scenarios: `page` (block cursor), `page-bar` (bar cursor), `tabs` (new tab + split right + split down,
|
||||
driven with `xdotool` through Xwayland since the original Ghostty has no input replay; Rust is driven
|
||||
the same way so both apps are treated identically).
|
||||
Test page: `scripts/visual-parity/testpage.sh` (ASCII, ligatures, bold/italic/faint/inverse/strike/
|
||||
overline, all underline styles + colored, 16/256/truecolor, box drawing, blocks, braille, powerline,
|
||||
nerd font icons, CJK, emoji, wide chars, math, cursor). Not covered: mouse selection (no input
|
||||
injection into the original on Wayland; could be added through the Xwayland path).
|
||||
- `scripts/visual-parity/compare.py`: per-pixel max-channel delta, % differing, % > 8 / > 32 levels, max,
|
||||
mean, red heat-map PNG; run on the full frame, the terminal area and the headerbar strip.
|
||||
`scripts/visual-parity/zoom.py`: ref | rust | diff crops, upscaled.
|
||||
- Output (not committed): `target/visual-parity/<scenario>/{ref,rust,diff,diff-term,diff-chrome}.png`,
|
||||
`target/visual-parity/stats.txt`. `RUST_BIN` defaults to `target/production/ghostty`.
|
||||
Iteration build: `CARGO_TARGET_DIR=target/vp CARGO_PROFILE_PRODUCTION_LTO=off
|
||||
CARGO_PROFILE_PRODUCTION_CODEGEN_UNITS=16 CARGO_PROFILE_PRODUCTION_INCREMENTAL=true cargo build --profile production`.
|
||||
|
||||
## Results (% of pixels differing at all; max channel delta)
|
||||
|
||||
| scenario | before | after |
|
||||
|---|---|---|
|
||||
| page (full frame) | 0.130% (1250 px, max 93) | 0.020% (196 px, max 86; 0.001% > 32 levels) |
|
||||
| page-bar | same as page | 0.020% |
|
||||
| tabs + splits | 44.6% (a whole pane blank, bug) | 0.000% (bit-identical) |
|
||||
| chrome (headerbar, tab bar) | 0.000% | 0.000% |
|
||||
|
||||
Sprite sheets (`ghostty-font` tests, Zig reference PNGs): differing pixels 5.76% -> 1.20%, pixels off by
|
||||
more than 96 levels 74 -> 0 (test tolerance tightened from 4% to 0.1% big diffs).
|
||||
|
||||
Font metrics, cell size, baseline, glyph rasterization, colors, cursor, underline positions, padding,
|
||||
GTK CSS/headerbar/tab bar were already pixel-identical; no change was needed there.
|
||||
|
||||
## Fixes
|
||||
|
||||
1. **Split rebuild blanked an unchanged pane** (`ghostty-core/src/surface/mod.rs`, `draw_frame`): the GTK
|
||||
`render` handler called `renderer.draw_frame(false)`; Zig's `glRender` uses `drawFrame(true)`. With
|
||||
`sync = false` and nothing dirty the renderer only re-presents the last target, which is cleared when
|
||||
the GLArea is re-realized (nested split = tree rebuild = unrealize/realize of every surface whose size
|
||||
did not change), so that pane stayed empty until something changed. Now `true` like Zig.
|
||||
2. **Sprite antialiasing** (`ghostty-font/src/sprite/canvas.rs`): replaced tiny-skia's coverage with a port of
|
||||
z2d 0.10's default `multisample_4x` rasterizer (4x4 samples, scanline spans with edge x rounded at
|
||||
sample row centers, non-zero filter, alpha = `coverage * 16 - 1`, full = 255, z2d's Bezier
|
||||
flattening with tolerance 0.1). Stroke outlines still come from tiny-skia's stroker.
|
||||
|
||||
## Remaining differences (page: 196 px, all <= 86 levels)
|
||||
|
||||
- Curly underline: first/last pixels and peaks differ slightly. z2d builds its own stroke polygon (pen/joins)
|
||||
for curves; tiny-skia's stroker offsets the Bezier differently.
|
||||
- Diagonal box glyphs (`U+2571..2573`) have a few edge pixels off by up to ~38 levels (same cause).
|
||||
- Porting z2d's stroker (`tess/stroke_plotter`, `Pen`) would remove the rest; judged not worth it
|
||||
(0.02% of pixels, imperceptible).
|
||||
Reference in new issue
Block a user