Skip to content

perf(renderer): fetch the viewport once per frame, add glyph atlas and scroll blit - #29

Open
brianegan wants to merge 5 commits into
diegosouzapw:mainfrom
brianegan:pr/renderer-perf
Open

brianegan wants to merge 5 commits into
diegosouzapw:mainfrom
brianegan:pr/renderer-perf

Conversation

@brianegan

@brianegan brianegan commented Jul 25, 2026

Copy link
Copy Markdown

We embed ghostty-web in a VS Code extension as the terminal for coding CLIs.
Streaming output felt slow enough to be distracting, so we profiled it instead
of guessing. This PR is the renderer half of what came out.

Fetching the viewport once per frame

getLine(y) on the WASM terminal calls getViewport(), which walks every cell
of every row and then throws away all but one. The renderer called it once per
visible row, so a single frame did around forty full viewport walks. That is
O(rows squared) crossings, and it degrades quadratically as the terminal gets
taller:

rows 24    15.6 ms
rows 40    56.3 ms
rows 50   129.6 ms

getViewportLines() walks once and slices, and the renderer caches it per
frame. On a 120x40 streaming frame that single change is about 11 of the 12x in
this PR. The atlas and the blit are the remaining 11%.

Also worth reporting: dirtyRows averaged 0.19 per frame while 36.58 rows were
being painted. Ghostty reports needsFullRedraw() on every scroll, because
relocating every row's content cannot be expressed as a row set, so the per-row
dirty flags were doing nothing during streaming.

Glyph atlas

Rasterises each distinct glyph once, keyed by text, colour, bold and italic.
Slots are sized from actualBoundingBox* ink extents rather than the cell box,
so italics, combining marks and full-height powerline glyphs do not clip.
Engines without those metrics fall back to a padded cell box. Shelf-packed, and
after four repacks it disables itself and reverts to fillText rather than
thrashing.

One thing worth knowing before you review it. A first version built a
${style}|${color}|${text} cache key per cell, allocated thousands of strings a
frame, and was a net loss at 0.96x. Two-level buckets with the outer one
memoised across a run turned it into a win. Chromium's Skia already caches glyph
masks internally, so a userland atlas only pays off if the lookup is cheaper
than fillText, which is a narrower margin than it sounds.

Scroll blit

The shift is derived rather than reported. Row y always shows absolute line
scrollbackLength - viewportY + y, so holding a line fixed across two frames
gives shift = (viewportY - lastViewportY) - (scrollbackLength - lastLength).
Scrolling into history and streaming at the bottom both fall out of that one
formula.

It is treated as a hypothesis, not a fact. Every retained row is checked against
two independent 32-bit hashes of what is actually on the canvas before its
pixels are reused, and mismatches get repainted. Alternate-screen programs and
scrolling regions move content without growing the scrollback, and the hash
catches them. If more than half the viewport fails verification it falls back to
a full repaint.

Two mistakes fixed after measuring, in case they read as odd:

The blit was gated on !forceAll, which meant needsFullRedraw() disabled it
on exactly the frames it existed for. A caller-requested repaint still blocks
it, but a full-dirty buffer does not, because the hashes are better informed
than a viewport-wide flag.

Every blit was staged through a scratch canvas, copying the full bitmap twice
per frame, which made it a net loss at 5.78 ms against 5.30. An opaque
background now moves pixels in one self-copy. The scratch is only used for
translucent themes, where clearing first would destroy the overlapping source.

Options

glyphAtlas and scrollBlit land on ITerminalOptions, defaulting on, with
live toggling. Mainly so the two can be measured independently, which is how the
numbers above were separated.

Verification

tests/e2e/10-renderer-optimisations.spec.ts renders identical content with each
optimisation on and off and diffs the bitmaps.

blit vs full repaint      0 differing pixels
atlas vs fillText         max delta 2/255 on 5.4% of pixels
rows painted, 40 frames   800 -> 80

The atlas delta is antialiasing rounding from rasterising at device scale in an
unscaled context instead of CSS scale in a scaled one. It is spread evenly
across rows. A positioning bug would show max delta 255 in clusters.

438 unit tests, the e2e suite, tsc, biome and prettier all clean.

Stacked on #28.

brianegan and others added 5 commits July 25, 2026 13:19
Three rendering fixes carried on top of the Ghostty 1.3 upgrade, plus a
regression test:

- Scroll boundary: floor viewportY once and use that single integer for
  both the scrollback/screen row comparison and the offset/screen-row math
  (render loop and hyperlink-hover scan). During smooth scroll viewportY is
  fractional; comparing rows against the raw value while indexing with the
  floored value read one row past the scrollback (returning null, leaving
  stale pixels) and dropped the top screen row, duplicating a line near the
  top of the viewport. Covered by renderer-viewport-boundary.test.ts.

- Block elements: snap every partial-block edge to the device-pixel grid
  and add the quadrant blocks (U+2596-U+259F) as device-snapped rectangles.
  Quadrants previously fell through to font-glyph rendering, which doesn't
  fill the cell and left gaps in block/quadrant art (e.g. the Claude Code
  mascot), especially at line-height > 1.

- Selection color: selected text keeps its original foreground instead of
  being forced to theme.selectionForeground, so editor theme colors survive
  selection (helper customization).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reserve a SCROLLBAR_GUTTER (15px) on the right of the canvas so the
scrollback scrollbar draws beside the text instead of painting over the
last columns. Text still lays out across cols*metrics.width; the canvas
is widened by the gutter and the needsResize check is updated to match.

Route the scrollbar thumb and track colors through the theme
(scrollbarThumb / scrollbarTrack), applying fade and idle dim via
globalAlpha. The track defaults to empty so the gutter shows the
terminal background, like VS Code's scrollbars, and is only drawn when a
theme provides a color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roll blit

Streaming output was spending almost all of its time re-reading the same
cells out of WASM. Measured in Chromium against a real terminal:

  grid     before      after     speedup
  80x24    15.6 ms    1.96 ms      8.0x
  120x40   56.3 ms    4.70 ms     12.0x
  200x50  129.6 ms    9.37 ms     13.8x

Three changes, in order of how much they matter.

1. Fetch the viewport once per frame.

getLine() walks every cell of every row to return one row, so calling it
per rendered row cost O(rows^2 * cols) WASM crossings a frame. Profiling a
120x40 grid: 40 getLine calls came to 58.7 ms, one bulk read came to 1.5 ms.
That accounts for most of the speedup above, and for why the old path
degraded quadratically as the terminal grew.

Adds GhosttyTerminal.getViewportLines(), an optional member of IRenderable,
and routes the renderer's row access through a lazy per-frame cache. Rows
served from scrollback already used a direct per-row grid walk and are
unchanged.

2. Glyph atlas (lib/glyph-atlas.ts).

Rasterises each distinct glyph once and composites it with drawImage, keyed
by text, colour and the bold/italic flags. Slots are sized from the glyph's
ink box rather than the cell box so italics, combining marks and full-height
powerline glyphs are not clipped. Shelf packed, repacks when full, and
disables itself after four repacks so a working set that never fits falls
back to fillText instead of thrashing.

The lookup runs per painted cell, so it uses two-level buckets with the
outer one memoised across a run. A single composite string key allocated
thousands of strings a frame and made the atlas a net loss.

3. Scroll blit.

A scroll relocates every row, which is why the buffer reports FULL dirty
and the old path repainted the whole viewport. The pixels are still correct
though, just in the wrong place, so move them with one copy and repaint only
what the copy could not fill: 40 frames of scrolling went from 800 row
paints to 80.

The shift is derived, since nothing reports it. Row y always shows absolute
line scrollbackLength - viewportY + y, so holding a line fixed across two
frames gives:

  shift = (viewportY - lastViewportY) - (scrollbackLength - lastLength)

Scrolling into history and streaming at the bottom both fall out of that.
The result is treated as a hypothesis: each retained row is checked against
two independent 32-bit hashes of what is actually on the canvas before its
pixels are reused, so alternate-screen redraws and scrolling regions, which
move content without growing scrollback, are caught and repainted. More than
half the viewport failing verification falls back to a full repaint.

An opaque background moves pixels in a single self-copy. Translucent themes
stage through a scratch bitmap, because there the moved band has to replace
the destination and clearing first would destroy the overlapping source.

Verified by tests/e2e/10-renderer-optimisations.spec.ts, which diffs bitmaps
rendered with each optimisation on and off. The blit is pixel exact. The
atlas differs by at most 2/255 on 5.4% of pixels, spread evenly across rows,
which is antialiasing rounding from rasterising at device scale in an
unscaled context rather than CSS scale in a scaled one.

Both paths sit behind the glyphAtlas and scrollBlit renderer options,
defaulting on, so either can be switched off to isolate an artefact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to 96698f9, from grepping the remaining getLine() callers. Both
of these walked the whole viewport to read a single row.

renderCursor fetched the cell under a block cursor itself, which cost a full
viewport walk on every frame that drew a cursor. It runs inside render(),
which already has the frame's rows cached, so the row is passed in now.
Viewport walks per frame drop from 2 to 1 and a 120x40 streaming frame goes
from 4.70 ms to 3.18 ms, or 17.7x against the original renderer.

The OSC8 link provider's backward scan called getLine() once per column
while looking for the start of a link, on a row that does not change inside
the loop. Scanning a long link was quadratic as a result. Hoisted out, which
is how the forward scan a few lines below was already written.

One more caller is left: SelectionManager.getSelection() fetches per row
inside its loop over the selected range, so copying a selection costs one
walk per selected screen row. It is a one-shot user action rather than a
per-frame cost, and fixing it means threading a bulk read through the
scrollback and screen split, so it is left for its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the plumbing for the two renderer optimisations added in 96698f9,
which until now could only be reached by constructing a CanvasRenderer
directly. Embedders using Terminal got the defaults with no way to turn
either one off.

Adds both to ITerminalOptions, defaults them to true in TerminalCore, and
forwards them when Terminal builds its renderer. handleOptionChange also
handles them at runtime via the renderer's setGlyphAtlas/setScrollBlit,
forcing one full repaint so the frame that follows starts from a canvas the
blit can reason about.

The setters landed in 72d3ac4 by accident, as they were written in a
concurrent session while that commit was being staged. Committing the rest
here rather than rewriting history, so the two halves are at least adjacent.

Verified in Chromium: defaults reach the renderer, values passed at
construction are respected, and toggling either at runtime leaves the canvas
differing by at most 1/255 per channel, which is the antialiasing rounding
between atlas and fillText rasterisation and nothing more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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