From 55bd70e385619d60ce46e5ecef3c4fd83fcd1f3e Mon Sep 17 00:00:00 2001 From: Gaurav Gosain Date: Tue, 1 Sep 2026 09:00:34 +0400 Subject: [PATCH] perf(renderer): read the viewport once per frame CanvasRenderer.render() paints a frame one row at a time, and each row calls buffer.getLine(). getLine() is a compatibility shim: it calls getViewport(), which reads every cell in the grid, and then returns one row of it. A frame that paints R rows therefore reads the whole grid R times. Every read after the first is discarded. Read the viewport once at the top of render() and slice each row out of it. The memo is a local variable inside render(), so it cannot outlive the call. There is no cache to invalidate. Callers outside render(), including SelectionManager, keep taking the original getLine() path. Scrolled rows keep the old path, because they come from the scrollback provider. getViewport() is added to IRenderable as an optional method, so a buffer that does not implement it behaves as before. Measured on a 114x42 grid in Chromium on an NVIDIA RTX 3070, using demo/viewport-repro.html which is included here: grid reads per frame 22.0 -> 1.0 render() mean 66.48ms -> 7.41ms render() p95 131.98ms -> 7.63ms frames in 4 seconds 58 -> 240 The renderer issues more 2D operations per second after the change, not fewer, because it is no longer starved. Drawing was not the bottleneck. --- demo/viewport-repro.html | 145 +++++++++++++++++++++++++++++++++++++++ lib/renderer.ts | 48 +++++++++++-- 2 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 demo/viewport-repro.html diff --git a/demo/viewport-repro.html b/demo/viewport-repro.html new file mode 100644 index 00000000..39f3ba17 --- /dev/null +++ b/demo/viewport-repro.html @@ -0,0 +1,145 @@ + + + + +ghostty-web: how many times a frame reads the grid + + + +

How many times does one frame read the grid?

+

+ CanvasRenderer.render() calls buffer.getLine(y) for every row it paints. + getLine() builds the whole viewport and returns one row of it. This page + counts the grid reads per frame and times render(), with and without a single + viewport read per frame. +

+

+ Run it with bun run dev and open /demo/viewport-repro.html. + Add ?src=/path/to/ghostty-web.js to point it at a built bundle instead. +

+
+
running...
+ + + + diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfdd..c2decafa 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -18,6 +18,13 @@ import { CellFlags } from './types'; // Interface for objects that can be rendered export interface IRenderable { getLine(y: number): GhosttyCell[] | null; + /** + * Read every visible cell in one pass, returning a cols*rows array in row + * order. Optional. When it is present the renderer reads the viewport once + * per frame and slices each row out of it, instead of calling getLine() for + * every row it paints. + */ + getViewport?(): GhosttyCell[]; getCursor(): { x: number; y: number; visible: boolean }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; @@ -301,6 +308,35 @@ export class CanvasRenderer { this.lastViewportY = viewportY; } + // Read the viewport once for this frame. + // + // getLine() is a compatibility shim: it builds the whole viewport and then + // returns one row of it. Calling it per row therefore rebuilds the grid + // once for every row the frame paints, so a frame that paints R rows costs + // R full grid reads when one is enough. + // + // The memo lives for this call only. render() runs to completion before any + // write can land, so it cannot serve stale cells, and no caller outside + // render() is affected. + // + // Scrolled frames still take the old path, because those rows come from the + // scrollback provider rather than from the viewport. + let frameViewport: GhosttyCell[] | null | undefined; + const readLine = (y: number): GhosttyCell[] | null => { + if (viewportY > 0 || typeof buffer.getViewport !== 'function') { + return buffer.getLine(y); + } + if (frameViewport === undefined) { + frameViewport = buffer.getViewport(); + } + if (!frameViewport) return buffer.getLine(y); + if (y < 0 || y >= dims.rows) return null; + const start = y * dims.cols; + // Same copy getLine() makes, so callers still never hold a reference + // into the reused cell pool. + return frameViewport.slice(start, start + dims.cols).map((cell) => ({ ...cell })); + }; + // Check if cursor position changed or if blinking (need to redraw cursor line) const cursorMoved = cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y; @@ -308,7 +344,7 @@ export class CanvasRenderer { // Mark cursor lines as needing redraw if (!forceAll && !buffer.isRowDirty(cursor.y)) { // Need to redraw cursor line - const line = buffer.getLine(cursor.y); + const line = readLine(cursor.y); if (line) { this.renderLine(line, cursor.y, dims.cols); } @@ -316,7 +352,7 @@ export class CanvasRenderer { if (cursorMoved && this.lastCursorPosition.y !== cursor.y) { // Also redraw old cursor line if cursor moved to different line if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) { - const line = buffer.getLine(this.lastCursorPosition.y); + const line = readLine(this.lastCursorPosition.y); if (line) { this.renderLine(line, this.lastCursorPosition.y, dims.cols); } @@ -374,11 +410,11 @@ export class CanvasRenderer { } else { // This row is from visible screen const screenRow = y - Math.floor(viewportY); - line = buffer.getLine(screenRow); + line = readLine(screenRow); } } else { // At bottom - fetch from visible screen - line = buffer.getLine(y); + line = readLine(y); } if (line) { @@ -466,11 +502,11 @@ export class CanvasRenderer { } else { // This row is from visible screen (lower part of viewport) const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y; - line = buffer.getLine(screenRow); + line = readLine(screenRow); } } else { // At bottom - fetch from visible screen - line = buffer.getLine(y); + line = readLine(y); } if (line) {