From 93aa0e5d32becc11a0b5d64004e56282097546d5 Mon Sep 17 00:00:00 2001 From: Andros Fenollosa Date: Wed, 3 Jun 2026 22:30:04 +0200 Subject: [PATCH] Metal: defer presenting clear-only update cycles (first tab-switch flash) Switching to a tab whose buffer faces are not realized yet garbages the frame: redisplay first runs an update cycle that only calls clear_frame, and Metal presented that blank frame, flashing white for the ~30 ms the follow-up cycle needs to realize faces and repaint. NS does not flash because AppKit coalesces backing-store flushes within the event cycle. Track per-cycle whether clear_frame ran and whether any glyph string was drawn; a clear-only cycle commits to the static texture but defers the present (needsPresent), which the follow-up content cycle or flush_display then picks up. Also adds MTL_LOG_SEQ=1 tracing of the begin/clear/draw/end/present sequence, which is what exposed the blank present. --- src/mtlterm.h | 8 ++++++++ src/mtlterm.m | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/mtlterm.h b/src/mtlterm.h index 63732df5b55..41432623a1d 100644 --- a/src/mtlterm.h +++ b/src/mtlterm.h @@ -179,6 +179,14 @@ typedef struct mtl_spring { drawRect: expose path for the uncovered pixels; Metal has none). */ @property (nonatomic, assign) int lastMiniHeight; +/* Within the current update cycle: clear_frame ran / real content was drawn. + A cycle that only cleared (a garbaged frame, e.g. the first switch to a tab + whose faces are not realized yet) must NOT present, or the user sees a blank + flash before the follow-up cycle paints the actual content. NS does not + flash there because AppKit coalesces the backing-store flushes. */ +@property (nonatomic, assign) BOOL cycleSawClear; +@property (nonatomic, assign) BOOL cycleSawDraw; + /* Main Emacs render cycle (renders to staticTexture) */ - (void)beginFrame; - (void)endFrame; diff --git a/src/mtlterm.m b/src/mtlterm.m index 2714ca286d7..4f2fc2b1557 100644 --- a/src/mtlterm.m +++ b/src/mtlterm.m @@ -1400,6 +1400,20 @@ easing_apply (MtlScrollEasing mode, float t) self.needsPresent = YES; } +/* Sequence tracing for present-flow debugging (MTL_LOG_SEQ=1). */ +static BOOL +mtl_log_seq_p (void) +{ + static int on = -1; + if (on < 0) on = getenv ("MTL_LOG_SEQ") != NULL; + return on > 0; +} + +#define MTL_SEQ(fmt, ...) \ + do { if (mtl_log_seq_p ()) \ + fprintf (stderr, "[mtlseq %.3f] " fmt "\n", \ + CACurrentMediaTime (), ##__VA_ARGS__); } while (0) + - (void)compositeToScreen { if (!self.staticTexture || !g_blit_pipeline) return; @@ -1407,6 +1421,8 @@ easing_apply (MtlScrollEasing mode, float t) id drawable = [self.metalLayer nextDrawable]; if (!drawable) return; + MTL_SEQ ("PRESENT"); + self.needsPresent = NO; /* about to present whatever is in the static texture */ NSSize sz = self.metalLayer.frame.size; @@ -2709,12 +2725,14 @@ mtl_draw_glyph_string (struct glyph_string *s) if (fd.encoder) { + fd.cycleSawDraw = YES; [fd applyClipRect:clip]; mtl_draw_glyph_string_impl (s); [fd clearClipRect]; } else { + MTL_SEQ ("immediate glyph draw x=%d y=%d w=%d", s->x, s->y, s->width); [fd beginFrame]; [fd applyClipRect:clip]; mtl_draw_glyph_string_impl (s); @@ -2737,6 +2755,8 @@ mtl_clear_frame (struct frame *f) committed immediate frame with a deferred present, like mtl_draw_glyph_string. */ BOOL immediate = (fd.encoder == nil); + MTL_SEQ ("clear_frame (immediate=%d)", immediate); + fd.cycleSawClear = YES; if (immediate) [fd beginFrame]; NSRect bounds = fd.metalLayer ? CGRectMake (0, 0, @@ -2828,6 +2848,7 @@ mtl_flush_display (struct frame *f) encoder that never got endEncoding, causing an assertion failure. */ MtlFrameData *fd = mtl_get_frame_data (f); if (!fd) return; + MTL_SEQ ("flush_display (encoder=%d needsPresent=%d)", fd.encoder != nil, (int) fd.needsPresent); if (fd.encoder) [fd endFrame]; else if (fd.needsPresent) @@ -2844,6 +2865,9 @@ mtl_update_begin (struct frame *f) /* Guard: if encoder already active (e.g. from a re-entrant redisplay), end it cleanly before starting a new frame. */ if (fd.encoder) [fd endFrame]; + MTL_SEQ ("update_begin"); + fd.cycleSawClear = NO; + fd.cycleSawDraw = NO; [fd beginFrame]; /* Expose substitute: when the minibuffer (echo area) changes height, the @@ -2874,7 +2898,15 @@ static void mtl_update_end (struct frame *f) { MtlFrameData *fd = mtl_get_frame_data (f); - if (fd) [fd endFrame]; + if (!fd) return; + /* A cycle that only cleared the garbaged frame (no content drawn) commits + to the static texture but defers the present: presenting it would flash + a blank frame for the tens of ms the follow-up cycle needs to realize + faces/fonts and repaint (seen on the first switch to a new tab). The + deferred present is picked up by the next cycle or by flush_display. */ + BOOL clearOnly = fd.cycleSawClear && !fd.cycleSawDraw; + MTL_SEQ ("update_end%s", clearOnly ? " (clear-only, present deferred)" : ""); + [fd endFramePresent:!clearOnly]; } static void