Metal: gate animations, draw cursor in texture, fix glyph baseline

- Add g_mtl_animations_enabled (default off): the 60fps compositor overlay
  (cyan cursor, particles, CADisplayLink) is now opt-in via (mtl-animations t)
  / mtl-toggle-animations. With it off, compositeToScreen only blits + presents,
  removing the stray cyan cursor box that tinted the frame.
- Draw the cursor directly into the static texture like the NS backend
  (get_phys_cursor_glyph + get_phys_cursor_geometry, FRAME_CURSOR_COLOR,
  filled/hollow/bar/hbar), and handle DRAW_CURSOR in mtl_draw_glyph_string so
  the glyph under a filled cursor stays readable (inverse).
- Fix glyph vertical baseline: bearing_y stored the descent instead of the
  distance from the cell top to the baseline, dropping every glyph ~one ascent
  too low and overlapping the next line / mode line. Store bh-1-raster_oy.
This commit is contained in:
Andros Fenollosa 2026-06-01 07:57:19 +02:00
parent 9c112c768d
commit 9c7a7a2128
4 changed files with 172 additions and 29 deletions

View file

@ -136,6 +136,19 @@ Lower values are snappier; higher values are more fluid."
:set #'set-default
:group 'mtl)
(defcustom mtl-animations-enabled nil
"If non-nil, enable the Metal GPU animation layer.
When nil (the default), the cursor is drawn directly into the static
texture like the NS backend and no compositor overlay is drawn, which is
the correct, flicker-free baseline. Enable this to turn on the animated
cursor effects, particles and the 60fps compositor."
:type 'boolean
:set (lambda (sym val)
(set-default sym val)
(when (fboundp 'mtl-animations)
(mtl-animations val)))
:group 'mtl)
(defcustom mtl-enable-on-startup nil
"If non-nil, enable Metal GPU rendering on the initial frame at startup."
:type 'boolean
@ -165,8 +178,15 @@ The NS backend still handles events, menus, and scrollbars."
(mtl-scroll-effect (mtl--scroll-easing-number mtl-scroll-easing))
(mtl-scroll-duration mtl-scroll-duration)
(mtl-trail-length mtl-trail-length)
(message "Metal GPU enabled on frame: %s (device: %s)"
f (mtl-device-name))))
(mtl-animations mtl-animations-enabled)
(message "Metal GPU enabled on frame: %s (device: %s, animations: %s)"
f (mtl-device-name) (if mtl-animations-enabled "on" "off"))))
(defun mtl-toggle-animations ()
"Toggle the Metal GPU animation layer on or off."
(interactive)
(setopt mtl-animations-enabled (not mtl-animations-enabled))
(message "Metal animations %s" (if mtl-animations-enabled "enabled" "disabled")))
(defun mtl-status ()
"Display current Metal GPU backend status in the minibuffer."
@ -176,8 +196,9 @@ The NS backend still handles events, menus, and scrollbars."
(if (not (mtl-backend-p))
(message "Metal not available on this system")
(let ((status (mtl-animation-status)))
(message "Metal GPU: %s | Cursor: %s | Scroll: %s (%.2fs)"
(message "Metal GPU: %s | Animations: %s | Cursor: %s | Scroll: %s (%.2fs)"
(mtl-device-name)
(if (cdr (assq 'animations status)) "on" "off")
(nth (cdr (assq 'cursor-mode status))
'(block spring torpedo sonicboom ripple pixiedust hollow beam))
(nth (cdr (assq 'scroll-easing status))
@ -223,6 +244,7 @@ The NS backend still handles events, menus, and scrollbars."
(define-key map (kbd "C-c m s") #'mtl-status)
(define-key map (kbd "C-c m c") #'mtl-set-cursor)
(define-key map (kbd "C-c m S") #'mtl-set-scroll)
(define-key map (kbd "C-c m a") #'mtl-toggle-animations)
map)
"Keymap for `mtl-mode'.")

View file

@ -359,6 +359,37 @@ DEFUN ("mtl-trail-length", Fmtl_trail_length, Smtl_trail_length, 1, 1, 0,
return len;
}
DEFUN ("mtl-animations", Fmtl_animations, Smtl_animations, 0, 1, 0,
doc: /* Enable or disable the Metal GPU animation layer.
With ENABLE non-nil, turn on the animated cursor effects, particles and the
CADisplayLink 60fps compositor overlay. With ENABLE nil (the default state),
the cursor is drawn directly into the static texture like the NS backend and
no overlay is composited, which is the correct, flicker-free baseline.
Returns t when animations are enabled, nil otherwise. */)
(Lisp_Object enable)
{
g_mtl_animations_enabled = !NILP (enable);
/* Start or stop the per-frame animators on live Metal frames so the change
takes effect immediately rather than only on the next (mtl-enable). */
Lisp_Object tail, frame;
FOR_EACH_FRAME (tail, frame)
{
struct frame *f = XFRAME (frame);
if (!FRAME_LIVE_P (f) || !FRAME_NS_P (f))
continue;
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.animator)
continue;
if (g_mtl_animations_enabled)
[fd.animator startAnimating];
else
[fd.animator stopAnimating];
}
return g_mtl_animations_enabled ? Qt : Qnil;
}
DEFUN ("mtl-draw-stats", Fmtl_draw_stats, Smtl_draw_stats, 0, 0, 0,
doc: /* Return diagnostic counters for Metal glyph rendering.
Returns an alist with: total-calls, no-fd (no encoder), no-font, glyphs-drawn. */)
@ -376,7 +407,8 @@ DEFUN ("mtl-animation-status", Fmtl_animation_status, Smtl_animation_status,
doc: /* Return an alist with current Metal animation configuration. */)
(void)
{
return list4 (
return list5 (
Fcons (intern ("animations"), g_mtl_animations_enabled ? Qt : Qnil),
Fcons (intern ("cursor-mode"), make_fixnum ((EMACS_INT)g_mtl_cursor_mode)),
Fcons (intern ("scroll-easing"), make_fixnum ((EMACS_INT)g_mtl_scroll_easing)),
Fcons (intern ("scroll-duration"),make_float (g_mtl_scroll_duration)),
@ -403,6 +435,7 @@ syms_of_mtlfns (void)
defsubr (&Smtl_scroll_duration);
defsubr (&Smtl_trail_length);
defsubr (&Smtl_animation_status);
defsubr (&Smtl_animations);
defsubr (&Smtl_capture_frame);
defsubr (&Smtl_draw_stats);
}

View file

@ -224,6 +224,7 @@ extern MtlCursorMode g_mtl_cursor_mode;
extern MtlScrollEasing g_mtl_scroll_easing;
extern float g_mtl_scroll_duration; /* seconds, default 0.15 */
extern NSUInteger g_mtl_trail_len; /* default 20 */
extern BOOL g_mtl_animations_enabled; /* default NO (Fase A) */
/* -----------------------------------------------------------------------
Global Metal display list

View file

@ -246,6 +246,13 @@ MtlScrollEasing g_mtl_scroll_easing = MTL_EASE_OUT_QUAD;
float g_mtl_scroll_duration = 0.15f;
NSUInteger g_mtl_trail_len = 20;
/* Master switch for the GPU animation layer (cursor effects, particles,
CADisplayLink @60fps). OFF by default: the goal is pixel-correct parity
with the NS backend first. When off, the cursor is drawn directly into the
static texture (like NS) and no compositor overlay is drawn. See TODO.org
Fase A. Toggle from Lisp with (mtl-animations t). */
BOOL g_mtl_animations_enabled = NO;
/* Phase 4: additional global pipeline state */
static id<MTLRenderPipelineState> g_blit_pipeline = nil;
static id<MTLRenderPipelineState> g_particle_pipeline = nil;
@ -637,12 +644,20 @@ mtl_rasterize_glyph_id (CTFontRef font, CGGlyph cgGlyph, uint64_t key)
entry = glyph_cache_insert (key);
if (!entry) return NULL;
/* The raster placed the baseline at raster_oy measured from the BOTTOM of the
bh-tall cell (Core Graphics draws y-up). All draw sites expect bearing_y to
be the distance from the cell's TOP edge down to the baseline, so that
y0 = baseline - bearing_y lands the cell top correctly. That distance is
(bh - 1 - raster_oy). The previous code stored raster_oy itself (the
descent), which dropped every glyph ~one ascent too low. */
int raster_oy = (int)(floor (-bbox.origin.y) + 1);
entry->atlas_x = g_atlas_next_x;
entry->atlas_y = g_atlas_next_y;
entry->width = bw;
entry->height = bh;
entry->bearing_x = (int)(floor (-bbox.origin.x) + 1);
entry->bearing_y = (int)(floor (-bbox.origin.y) + 1);
entry->bearing_y = bh - 1 - raster_oy;
entry->advance_x = (float)adv.width;
g_atlas_next_x += bw + 1;
@ -753,10 +768,12 @@ mtl_setup_frame (struct frame *f)
fd.uniformBuffer = ubuf;
fd.emacsFrame = f;
/* Phase 4: create animator and start 60fps animation loop */
/* Phase 4: create animator. Only start the 60fps loop when the animation
layer is explicitly enabled (Fase A: correctness first, animation opt-in). */
MtlAnimator *anim = [[MtlAnimator alloc] initWithFrame:f];
fd.animator = anim;
[anim startAnimating];
if (g_mtl_animations_enabled)
[anim startAnimating];
objc_setAssociatedObject (view, &mtl_frame_key,
fd, OBJC_ASSOCIATION_RETAIN);
@ -1121,7 +1138,11 @@ easing_apply (MtlScrollEasing mode, float t)
[enc drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6];
}
if (anim)
/* Animation overlay (cursor effects, trail, particles) is opt-in. When off,
the cursor lives in the static texture (drawn by mtl_draw_window_cursor),
so the compositor only blits and presents. This is what kills the stray
cyan cursor box drawn on top of text. */
if (anim && g_mtl_animations_enabled)
{
float cx = anim.cursorMode == MTL_CURSOR_SPRING ? anim.springX.pos : anim.curTargetX;
float cy = anim.cursorMode == MTL_CURSOR_SPRING ? anim.springY.pos : anim.curTargetY;
@ -1634,6 +1655,16 @@ mtl_draw_glyph_string (struct glyph_string *s)
unsigned long fg = face ? face->foreground : 0xECEFF4;
unsigned long bg = face ? face->background : 0x2E3440;
/* When this string is drawn as the cursor (via draw_phys_cursor_glyph),
invert: fill the background with the cursor color and draw the glyph in
the face's background color so the character stays readable. Mirrors the
NS backend (FRAME_CURSOR_COLOR + FRAME_BACKGROUND for text). */
if (s->hl == DRAW_CURSOR)
{
bg = ns_color_to_pixel (FRAME_CURSOR_COLOR (f));
fg = face ? face->background : 0x2E3440;
}
/* Background fill */
if (!s->background_filled_p)
{
@ -1821,36 +1852,92 @@ mtl_draw_window_cursor (struct window *w,
enum text_cursor_kinds cursor_type,
int cursor_width, bool on_p, bool active_p)
{
(void)row; (void)active_p;
if (!on_p) return;
(void)x; (void)y; (void)active_p;
struct frame *f = WINDOW_XFRAME (w);
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd) return;
if (!on_p) return;
int ch = FRAME_COLUMN_WIDTH (f);
int lh = FRAME_LINE_HEIGHT (f);
int cw = cursor_width > 0 ? cursor_width : ch;
w->phys_cursor_type = cursor_type;
w->phys_cursor_on_p = on_p;
/* For static cursor modes, draw directly into the static texture */
if (cursor_type == BAR_CURSOR || cursor_type == HBAR_CURSOR
|| fd.animator.cursorMode == MTL_CURSOR_BLOCK)
if (cursor_type == NO_CURSOR)
{
unsigned long color = ns_color_to_pixel(FRAME_FOREGROUND_COLOR(f));
NSRect r;
switch (cursor_type)
{
case BAR_CURSOR: r = NSMakeRect(x, y, cw>2?2:cw, lh); break;
case HBAR_CURSOR: r = NSMakeRect(x, y+lh-2, ch, 2); break;
default: r = NSMakeRect(x, y, ch, lh); break;
}
[fd fillRect:r color:color];
w->phys_cursor_width = 0;
return;
}
/* For animated cursor modes, just update the animator target.
The animator composites the cursor in compositeToScreen at 60fps. */
if (fd.animator)
[fd.animator setCursorX:x y:y width:ch height:lh];
/* Resolve the glyph and geometry exactly like the NS backend so the cursor
box lines up with the character cell. See ns_draw_window_cursor. */
struct glyph *phys_cursor_glyph = get_phys_cursor_glyph (w);
if (phys_cursor_glyph == NULL)
{
if (row->exact_window_width_line_p
&& w->phys_cursor.hpos >= row->used[TEXT_AREA])
{
row->cursor_in_fringe_p = 1;
draw_fringe_bitmap (w, row, 0);
}
return;
}
int fx, fy, h, cursor_height;
get_phys_cursor_geometry (w, row, phys_cursor_glyph, &fx, &fy, &h);
if (cursor_type == BAR_CURSOR)
{
struct glyph *cursor_glyph;
if (cursor_width < 1)
cursor_width = max (FRAME_CURSOR_WIDTH (f), 1);
if (cursor_width < w->phys_cursor_width)
w->phys_cursor_width = cursor_width;
/* For R2L glyphs draw the bar on the right edge. */
cursor_glyph = get_phys_cursor_glyph (w);
if ((cursor_glyph->resolved_level & 1) != 0)
fx += cursor_glyph->pixel_width - w->phys_cursor_width;
}
else if (cursor_type == HBAR_CURSOR)
{
cursor_height = (cursor_width < 1) ? lrint (0.25 * h) : cursor_width;
if (cursor_height > row->height)
cursor_height = row->height;
if (h > cursor_height)
fy += h - cursor_height;
h = cursor_height;
}
unsigned long cc = ns_color_to_pixel (FRAME_CURSOR_COLOR (f));
int cwidth = w->phys_cursor_width;
switch (cursor_type)
{
case DEFAULT_CURSOR:
case NO_CURSOR:
break;
case FILLED_BOX_CURSOR:
/* Re-draw the glyph with DRAW_CURSOR highlight: fills the cell with the
cursor color and draws the character in the background color, keeping
it readable (mtl_draw_glyph_string handles DRAW_CURSOR). */
draw_phys_cursor_glyph (w, row, DRAW_CURSOR);
break;
case HOLLOW_BOX_CURSOR:
/* Outline only: four 1px edges. */
[fd fillRect:NSMakeRect (fx, fy, cwidth, 1) color:cc];
[fd fillRect:NSMakeRect (fx, fy + h - 1, cwidth, 1) color:cc];
[fd fillRect:NSMakeRect (fx, fy, 1, h) color:cc];
[fd fillRect:NSMakeRect (fx + cwidth - 1, fy, 1, h) color:cc];
break;
case HBAR_CURSOR:
case BAR_CURSOR:
[fd fillRect:NSMakeRect (fx, fy, cwidth, h) color:cc];
break;
}
/* Keep the animator target in sync for when the animation layer is enabled.
With animations off this has no visible effect. */
if (g_mtl_animations_enabled && fd.animator)
[fd.animator setCursorX:fx y:fy width:cwidth height:h];
}
static void