Upload only changed rows to GPU and hoist glyph bounds checks

paintIncremental's per-row dirty work was negated by present() calling
PixelBuffer.updateBuffer(null), which re-uploads the whole pane texture
every frame. Track the vertical band of buffer rows written since the
last present and hand that to updateBuffer so only changed rows upload.
The border is now drawn without extending the dirty band (its pixels are
unchanged between incremental frames). Also clamp blitGlyph's rectangle
once instead of bounds-checking every glyph pixel in the inner loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:12:45 +02:00
parent bdb33450f1
commit 07585a314c
2 changed files with 75 additions and 19 deletions

View File

@@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon
connection.project.dir= connection.project.dir=
eclipse.preferences.version=1 eclipse.preferences.version=1
gradle.user.home= gradle.user.home=
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1
jvm.arguments= jvm.arguments=
offline.mode=false offline.mode=false
override.workspace.settings=true override.workspace.settings=true

View File

@@ -12,6 +12,7 @@ import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import javafx.geometry.Rectangle2D;
import javafx.scene.SnapshotParameters; import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas; import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
@@ -675,12 +676,42 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private long[] rowHashes = new long[0]; private long[] rowHashes = new long[0];
private CursorState lastCursor = CursorState.none(); private CursorState lastCursor = CursorState.none();
private GlyphCache glyphs; private GlyphCache glyphs;
// Half-open [min, max) vertical span of buffer rows written since the last present, so
// present() can upload only that band to the GPU instead of the whole pane texture.
private int dirtyMinY = Integer.MAX_VALUE;
private int dirtyMaxY = Integer.MIN_VALUE;
private void invalidate() { private void invalidate() {
rowHashes = new long[0]; rowHashes = new long[0];
lastCursor = CursorState.none(); lastCursor = CursorState.none();
} }
// Record that buffer rows [y0, y1) changed; clamped to the buffer in dirtyRegion().
private void markDirtyRows(int y0, int y1) {
if (y0 < dirtyMinY) {
dirtyMinY = y0;
}
if (y1 > dirtyMaxY) {
dirtyMaxY = y1;
}
}
private void resetDirty() {
dirtyMinY = Integer.MAX_VALUE;
dirtyMaxY = Integer.MIN_VALUE;
}
// The region to hand PixelBuffer.updateBuffer: a full-width band covering the rows
// written this frame (clamped to the buffer), or EMPTY when nothing changed.
private Rectangle2D dirtyRegion() {
int y0 = Math.max(0, dirtyMinY);
int y1 = Math.min(height, dirtyMaxY);
if (y0 >= y1) {
return Rectangle2D.EMPTY;
}
return new Rectangle2D(0, y0, width, y1 - y0);
}
private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot,
double px, double py, double paneWidth, double paneHeight, boolean active) { double px, double py, double paneWidth, double paneHeight, boolean active) {
ensure(paneWidth, paneHeight); ensure(paneWidth, paneHeight);
@@ -835,7 +866,11 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
private void present(GraphicsContext gc, double px, double py) { private void present(GraphicsContext gc, double px, double py) {
pixelBuffer.updateBuffer(ignored -> null); // Only re-upload the rows that actually changed; the unchanged remainder of the pane
// texture is already correct on the GPU from the previous frame.
Rectangle2D dirty = dirtyRegion();
resetDirty();
pixelBuffer.updateBuffer(ignored -> dirty);
gc.drawImage(image, px, py); gc.drawImage(image, px, py);
} }
@@ -915,6 +950,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
int dy = rows * lineHeight(); int dy = rows * lineHeight();
int top = contentTop(); int top = contentTop();
int contentHeight = rowHashes.length * lineHeight(); int contentHeight = rowHashes.length * lineHeight();
// The whole content region shifts; the arraycopy below moves pixels that the
// per-strip fillRect calls don't touch, so mark the full content band for upload.
markDirtyRows(top, top + contentHeight);
if (dy == 0 || Math.abs(dy) >= contentHeight) { if (dy == 0 || Math.abs(dy) >= contentHeight) {
fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND)); fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND));
return; return;
@@ -1035,26 +1073,28 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
int red = (rgb >> 16) & 0xff; int red = (rgb >> 16) & 0xff;
int green = (rgb >> 8) & 0xff; int green = (rgb >> 8) & 0xff;
int blue = rgb & 0xff; int blue = rgb & 0xff;
for (int gy = 0; gy < glyph.height; gy++) { // Clamp the glyph rectangle to the buffer once, so the inner loops carry no
int py = y + gy; // per-pixel bounds check (this is the hottest pixel loop on a text repaint).
if (py < 0 || py >= height) { int gyStart = Math.max(0, -y);
continue; int gyEnd = Math.min(glyph.height, height - y);
int gxStart = Math.max(0, -x);
int gxEnd = Math.min(glyph.width, width - x);
if (gyStart >= gyEnd || gxStart >= gxEnd) {
return;
} }
int rowOffset = py * width; for (int gy = gyStart; gy < gyEnd; gy++) {
int rowOffset = (y + gy) * width;
int glyphOffset = gy * glyph.width; int glyphOffset = gy * glyph.width;
for (int gx = 0; gx < glyph.width; gx++) { for (int gx = gxStart; gx < gxEnd; gx++) {
int px = x + gx;
if (px < 0 || px >= width) {
continue;
}
int alpha = glyph.alpha[glyphOffset + gx] & 0xff; int alpha = glyph.alpha[glyphOffset + gx] & 0xff;
if (alpha == 0) { if (alpha == 0) {
continue; continue;
} }
int index = rowOffset + px; int index = rowOffset + x + gx;
pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha);
} }
} }
markDirtyRows(y + gyStart, y + gyEnd);
} }
private int blendOpaque(int dst, int red, int green, int blue, int alpha) { private int blendOpaque(int dst, int red, int green, int blue, int alpha) {
@@ -1093,11 +1133,16 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) { private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) {
// The border is redrawn every frame to restore the side edges over the rows we
// repaint, but its pixels never change between incremental frames. Write it without
// marking the dirty band: the segments inside a repainted row's band are already
// covered by that band (and so re-uploaded), and the segments outside it are
// identical to what is already on the GPU, so they need no upload.
for (int i = 0; i < lineWidth; i++) { for (int i = 0; i < lineWidth; i++) {
fillRect(x + i, y + i, w - (2 * i), 1, color); fillRectRaw(x + i, y + i, w - (2 * i), 1, color);
fillRect(x + i, y + h - 1 - i, w - (2 * i), 1, color); fillRectRaw(x + i, y + h - 1 - i, w - (2 * i), 1, color);
fillRect(x + i, y + i, 1, h - (2 * i), color); fillRectRaw(x + i, y + i, 1, h - (2 * i), color);
fillRect(x + w - 1 - i, y + i, 1, h - (2 * i), color); fillRectRaw(x + w - 1 - i, y + i, 1, h - (2 * i), color);
} }
} }
@@ -1118,19 +1163,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha);
} }
} }
markDirtyRows(y0, y1);
} }
private void fillRect(int x, int y, int w, int h, int color) { private void fillRect(int x, int y, int w, int h, int color) {
int y0 = Math.max(0, y);
int y1 = Math.min(height, y + h);
if (fillRectRaw(x, y, w, h, color)) {
markDirtyRows(y0, y1);
}
}
// Raw fill with no dirty-band tracking; returns whether any pixels were written.
private boolean fillRectRaw(int x, int y, int w, int h, int color) {
int x0 = Math.max(0, x); int x0 = Math.max(0, x);
int y0 = Math.max(0, y); int y0 = Math.max(0, y);
int x1 = Math.min(width, x + w); int x1 = Math.min(width, x + w);
int y1 = Math.min(height, y + h); int y1 = Math.min(height, y + h);
if (x0 >= x1 || y0 >= y1) { if (x0 >= x1 || y0 >= y1) {
return; return false;
} }
for (int py = y0; py < y1; py++) { for (int py = y0; py < y1; py++) {
Arrays.fill(pixels, (py * width) + x0, (py * width) + x1, color); Arrays.fill(pixels, (py * width) + x0, (py * width) + x1, color);
} }
return true;
} }
private int contentLeft() { private int contentLeft() {