Metal: implement scroll_run (move pixels in the static texture)

mtl_scroll_run was a stub that only nudged the animator, so Emacs's scroll
optimization left stale/duplicated text: scrolling (C-v, recenter) and any
re-layout (window split, *Completions*/*Warnings* popups) painted new rows over
the old ones because the moved block was never relocated.

Now mtl_scroll_run mirrors ns_scroll_run's geometry (window_box +
WINDOW_TO_FRAME_PIXEL_Y, clamped to avoid the mode line) and moves the block
inside the static texture with a GPU blit. Since a single copy can't overlap
its own source/destination, it bounces the region through a scratch texture.
The render encoder is ended and reopened (LOAD) around the blit so subsequent
draw_glyph_string calls land on the moved pixels; Metal's hazard tracking
orders the copy after the draws. Encoder creation is factored into
openRenderEncoderClear:.

Verified with the comparison harness: scrolling and split/relayout no longer
leave ghost text.
This commit is contained in:
Andros Fenollosa 2026-06-01 16:12:37 +02:00
parent fc71504809
commit 88bdacc133
2 changed files with 104 additions and 23 deletions

View file

@ -153,6 +153,9 @@ typedef struct mtl_spring {
/* Phase 4: intermediate texture — Emacs renders here, animator blits to screen */
@property (nonatomic, strong) id<MTLTexture> staticTexture;
/* Scratch texture for scroll_run: a region can't be blitted onto itself when
source and destination overlap, so we bounce through this. */
@property (nonatomic, strong) id<MTLTexture> scratchTexture;
@property (nonatomic, strong) id<MTLRenderPipelineState> blitPipeline;
/* Phase 4: animator */

View file

@ -987,8 +987,88 @@ easing_apply (MtlScrollEasing mode, float t)
@implementation MtlFrameData
----------------------------------------------------------------------- */
@interface MtlFrameData ()
- (void)openRenderEncoderClear:(BOOL)clear;
- (void)scrollRunFrom:(int)fromY to:(int)toY x:(int)x width:(int)w height:(int)h;
@end
@implementation MtlFrameData
/* Open a render command encoder targeting the static texture. CLEAR wipes it to
the frame background (only for a fresh/resized texture); otherwise LOAD
preserves the previous content (Emacs redraws just the dirty regions).
Factored out so beginFrame and scrollRunFrom: can both reopen the encoder. */
- (void)openRenderEncoderClear:(BOOL)clear
{
struct frame *f = self.emacsFrame;
unsigned long bg = f ? ns_color_to_pixel (FRAME_BACKGROUND_COLOR (f)) : 0x2E3440;
float r, g, b;
unpack_color (bg, &r, &g, &b);
MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor renderPassDescriptor];
rpd.colorAttachments[0].texture = self.staticTexture;
rpd.colorAttachments[0].loadAction = clear ? MTLLoadActionClear : MTLLoadActionLoad;
rpd.colorAttachments[0].clearColor = MTLClearColorMake (r, g, b, 1.0);
rpd.colorAttachments[0].storeAction = MTLStoreActionStore;
self.encoder = [self.cmdBuf renderCommandEncoderWithDescriptor:rpd];
[self.encoder setVertexBuffer:self.uniformBuffer offset:0 atIndex:1];
[self.encoder setFragmentBuffer:self.uniformBuffer offset:0 atIndex:1];
}
/* RIF scroll_run: move a block of already-rendered pixels inside the static
texture (from y -> to y, full width of the run). Coordinates come in as
Emacs logical pixels; the texture is physical, so scale by the backing factor.
A single GPU copy can't have overlapping source/destination, so bounce the
region through scratchTexture. */
- (void)scrollRunFrom:(int)fromY to:(int)toY x:(int)x width:(int)w height:(int)h
{
if (!self.staticTexture || !self.scratchTexture || w <= 0 || h <= 0) return;
CGSize dsz = self.metalLayer.drawableSize;
NSSize lsz = self.metalLayer.frame.size;
double scx = lsz.width > 0 ? dsz.width / lsz.width : 1.0;
double scy = lsz.height > 0 ? dsz.height / lsz.height : 1.0;
long px = lround (x * scx), pw = lround (w * scx);
long pfrom = lround (fromY * scy), pto = lround (toY * scy), ph = lround (h * scy);
long tw = (long) self.staticTexture.width, tht = (long) self.staticTexture.height;
if (px < 0) px = 0;
if (pfrom < 0 || pto < 0) return;
if (px >= tw || pfrom >= tht || pto >= tht) return;
if (px + pw > tw) pw = tw - px;
if (pfrom + ph > tht) ph = tht - pfrom;
if (pto + ph > tht) ph = tht - pto;
if (pw <= 0 || ph <= 0) return;
/* The copy must run after the draws already recorded this frame. End the
render encoder, do the two blits on the same command buffer (Metal's hazard
tracking orders them after the render writes), then reopen the encoder with
LOAD so subsequent draw_glyph_string calls land on top of the moved pixels. */
BOOL hadEncoder = (self.encoder != nil);
if (self.encoder) { [self.encoder endEncoding]; self.encoder = nil; }
if (!self.cmdBuf) self.cmdBuf = [g_queue commandBuffer];
id<MTLBlitCommandEncoder> blit = [self.cmdBuf blitCommandEncoder];
[blit copyFromTexture:self.staticTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pfrom, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.scratchTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake (0, 0, 0)];
[blit copyFromTexture:self.scratchTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake (0, 0, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.staticTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pto, 0)];
[blit endEncoding];
if (hadEncoder)
[self openRenderEncoderClear:NO];
else
{ [self.cmdBuf commit]; self.cmdBuf = nil; }
}
- (void)beginFrame
{
CGSize dsz = self.metalLayer.drawableSize;
@ -1011,6 +1091,7 @@ easing_apply (MtlScrollEasing mode, float t)
td.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead;
td.storageMode = MTLStorageModePrivate;
self.staticTexture = [g_device newTextureWithDescriptor:td];
self.scratchTexture = [g_device newTextureWithDescriptor:td];
needsClear = YES; /* New or resized texture: clear to background color */
}
@ -1019,23 +1100,8 @@ easing_apply (MtlScrollEasing mode, float t)
u->screen_width = (float)sz.width;
u->screen_height = (float)sz.height;
struct frame *f = self.emacsFrame;
unsigned long bg = f ? ns_color_to_pixel(FRAME_BACKGROUND_COLOR(f)) : 0x2E3440;
float r, g, b;
unpack_color (bg, &r, &g, &b);
MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor renderPassDescriptor];
rpd.colorAttachments[0].texture = self.staticTexture;
/* Load previous content (not clear) unless this is a fresh texture.
The NS backend redraws only dirty regions; our Metal backend must do the same. */
rpd.colorAttachments[0].loadAction = needsClear ? MTLLoadActionClear : MTLLoadActionLoad;
rpd.colorAttachments[0].clearColor = MTLClearColorMake(r, g, b, 1.0);
rpd.colorAttachments[0].storeAction = MTLStoreActionStore;
self.cmdBuf = [g_queue commandBuffer];
self.encoder = [self.cmdBuf renderCommandEncoderWithDescriptor:rpd];
[self.encoder setVertexBuffer:self.uniformBuffer offset:0 atIndex:1];
[self.encoder setFragmentBuffer:self.uniformBuffer offset:0 atIndex:1];
self.cmdBuf = [g_queue commandBuffer];
[self openRenderEncoderClear:needsClear];
}
- (void)fillRect:(NSRect)rect color:(unsigned long)color
@ -1843,13 +1909,25 @@ mtl_scroll_run (struct window *w, struct run *run)
{
struct frame *f = WINDOW_XFRAME (w);
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.animator) return;
if (!fd) return;
/* Accumulate scroll pixels for animation */
float pixels = (float)(run->height);
if (run->current_y < run->desired_y) pixels = -pixels;
[fd.animator beginScrollBy:pixels];
(void)run;
/* Move the already-rendered block of pixels inside the static texture, the
same geometry the NS backend uses (ns_scroll_run): the text area box of W
including fringes, clamped so we never copy over the mode line. */
int x, y, width, height, from_y, to_y, bottom_y;
window_box (w, ANY_AREA, &x, &y, &width, &height);
from_y = WINDOW_TO_FRAME_PIXEL_Y (w, run->current_y);
to_y = WINDOW_TO_FRAME_PIXEL_Y (w, run->desired_y);
bottom_y = y + height;
if (to_y < from_y)
height = (from_y + run->height > bottom_y) ? bottom_y - from_y : run->height;
else
height = (to_y + run->height > bottom_y) ? bottom_y - to_y : run->height;
if (height <= 0) return;
[fd scrollRunFrom:from_y to:to_y x:x width:width height:height];
}
static void