Metal: buffer-switch crossfades, animations on by default

Buffer switches now cross-fade: just before redisplay paints the new
buffer (pre-redisplay-functions), the driver snapshots the static
texture and the compositor fades it out over the new content
(smoothstep, GPU-composited).  Configurable with mtl-buffer-transitions
(default t) and mtl-buffer-transition-duration (default 0.15s), and
exposed as mtl-transition-start for custom effects.

Cursor animations are enabled by default now that they behave: the
burst modes (sonicboom/ripple/pixiedust) keep the proper static cursor
-- inverted glyph, exact NS parity -- and only draw their effects in
the overlay; a solid overlay body would have hidden the character
under the cursor.  The body-animated modes (spring/torpedo/hollow/
beam) still draw the overlay body.

The animation pump helpers moved above the defcustoms: with the
default flipped to t, custom-initialize-reset runs the :set at load
time, which called mtl--anim-pump-start before it was defined.

README documents the user commands (status, draw-stats, cursor modes,
vsync, video, transitions).
This commit is contained in:
Andros Fenollosa 2026-06-04 21:50:01 +02:00
parent 35b5c6d093
commit 1a7d722f31
5 changed files with 198 additions and 28 deletions

View file

@ -99,8 +99,9 @@ The binary is `src/emacs` (or install the app bundle from `nextstep/`).
## Enabling the GPU backend
Emacs starts with the regular Cocoa backend; switch a frame to Metal
with:
The release app bundle enables it automatically (set the environment
variable `EMACS_GPU_DISABLE=1` to start with the stock Cocoa backend
instead). In a source build, switch a frame to Metal with:
```elisp
(add-to-list 'load-path "/path/to/emacs-gpu/lisp")
@ -108,12 +109,26 @@ with:
(mtl-enable)
```
Extras once enabled:
## Commands and options
| Command | What it does |
|---|---|
| `M-x mtl-status` | Show backend state: GPU device, animations, cursor mode |
| `M-: (mtl-draw-stats)` | Renderer counters; `glyphs-drawn` growing proves the GPU is painting |
| `M-x mtl-toggle-animations` | Toggle GPU cursor effects (on by default) |
| `M-x mtl-set-cursor` | Pick the cursor effect: `sonicboom` (default), `torpedo` (comet trail), `spring`, `ripple`, `pixiedust`, `hollow`, `beam`, `block` |
| `M-: (mtl-vsync nil)` | Uncap presents from the display refresh (lower latency, more power) |
| `M-: (mtl-video-insert "clip.mp4" 480 270 t)` | Play a video inline at point; follows scrolling |
| `M-x mtl-video-stop` | Stop the inline video |
Cursor effects trigger on cursor jumps (`M-<`, `M->`, isearch hits),
not on single-character movement.
Buffer switches cross-fade by default; tune or disable with:
```elisp
(mtl-video-insert "video.mp4" 480 270 t) ; inline video, follows scrolling
(mtl-animations t) ; GPU cursor effects (experimental)
(mtl-draw-stats) ; renderer counters
(setq mtl-buffer-transition-duration 0.15) ; seconds
(setq mtl-buffer-transitions nil) ; turn it off
```
## How it works

View file

@ -87,6 +87,22 @@
('ease-in-out-cubic 5)
(_ 2)))
(defvar mtl--anim-timer nil
"30fps timer driving cursor animations while they are enabled.")
(defun mtl--anim-pump ()
"Advance GPU cursor animations; cancel the timer when they turn off."
(unless (and (fboundp 'mtl-anim-tick) (mtl-anim-tick))
(when (timerp mtl--anim-timer) (cancel-timer mtl--anim-timer))
(setq mtl--anim-timer nil)))
(defun mtl--anim-pump-start ()
"Start the animation pump timer (idempotent).
Emacs's event loop starves the CADisplayLink while idle, so without
this Lisp timer the cursor effects only animate during user input."
(unless (timerp mtl--anim-timer)
(setq mtl--anim-timer (run-at-time 0 0.033 #'mtl--anim-pump))))
;; ---------------------------------------------------------------------------
;; Customizable variables
@ -136,7 +152,7 @@ Lower values are snappier; higher values are more fluid."
:set #'set-default
:group 'mtl)
(defcustom mtl-animations-enabled nil
(defcustom mtl-animations-enabled t
"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
@ -181,25 +197,10 @@ The NS backend still handles events, menus, and scrollbars."
(mtl-trail-length mtl-trail-length)
(mtl-animations mtl-animations-enabled)
(when mtl-animations-enabled (mtl--anim-pump-start))
(add-hook 'pre-redisplay-functions #'mtl--transition-watch)
(message "Metal GPU enabled on frame: %s (device: %s, animations: %s)"
f (mtl-device-name) (if mtl-animations-enabled "on" "off"))))
(defvar mtl--anim-timer nil
"30fps timer driving cursor animations while they are enabled.")
(defun mtl--anim-pump ()
"Advance GPU cursor animations; cancel the timer when they turn off."
(unless (and (fboundp 'mtl-anim-tick) (mtl-anim-tick))
(when (timerp mtl--anim-timer) (cancel-timer mtl--anim-timer))
(setq mtl--anim-timer nil)))
(defun mtl--anim-pump-start ()
"Start the animation pump timer (idempotent).
Emacs's event loop starves the CADisplayLink while idle, so without
this Lisp timer the cursor effects only animate during user input."
(unless (timerp mtl--anim-timer)
(setq mtl--anim-timer (run-at-time 0 0.033 #'mtl--anim-pump))))
(defun mtl-toggle-animations ()
"Toggle the Metal GPU animation layer on or off."
(interactive)
@ -242,6 +243,43 @@ this Lisp timer the cursor effects only animate during user input."
nil t))))
(setopt mtl-scroll-easing easing))
;; ---------------------------------------------------------------------------
;; Buffer-switch transitions
(defcustom mtl-buffer-transitions t
"When non-nil, cross-fade the old content when a window changes buffer.
The previous frame content fades out over
`mtl-buffer-transition-duration' seconds while the new buffer appears
underneath. Rendered entirely by the GPU compositor."
:type 'boolean
:group 'mtl)
(defcustom mtl-buffer-transition-duration 0.15
"Seconds a buffer-switch cross-fade takes."
:type 'number
:group 'mtl)
(defvar mtl--transition-armed nil
"Non-nil while a snapshot was already taken for the ongoing redisplay.")
(defun mtl--transition-watch (window)
"Start a cross-fade when WINDOW is about to display another buffer.
Runs from `pre-redisplay-functions', before the new content is painted,
so the GPU snapshot still holds the old pixels."
(when (and mtl-buffer-transitions
(not (window-minibuffer-p window))
(fboundp 'mtl-transition-start))
(let ((old (window-parameter window 'mtl--last-buffer))
(new (window-buffer window)))
(when (and old (not (eq old new)) (not mtl--transition-armed))
(setq mtl--transition-armed t)
(run-at-time 0 nil (lambda () (setq mtl--transition-armed nil)))
(ignore-errors
(when (mtl-transition-start (float mtl-buffer-transition-duration)
(window-frame window))
(mtl--anim-pump-start))))
(set-window-parameter window 'mtl--last-buffer new))))
;; ---------------------------------------------------------------------------
;; Inline video

View file

@ -592,9 +592,10 @@ frame. Returns t while animations are enabled, nil otherwise. */)
{
if (NILP (frame)) frame = Fselected_frame ();
if (!FRAME_LIVE_P (XFRAME (frame))) return Qnil;
if (!g_mtl_animations_enabled) return Qnil;
MtlFrameData *fd = mtl_get_frame_data (XFRAME (frame));
if (!fd || !fd.animator) return Qnil;
if (!g_mtl_animations_enabled && !fd.transitionTexture && !fd.videoPlayer)
return Qnil;
float step = 0.033f;
if (NUMBERP (dt))
@ -627,6 +628,25 @@ FRAME defaults to the selected frame. */)
return enable;
}
DEFUN ("mtl-transition-start", Fmtl_transition_start, Smtl_transition_start,
1, 2, 0,
doc: /* Crossfade the current frame content over the next redraw.
Snapshot what FRAME shows now and fade it out over DURATION seconds
while the new content appears underneath. Driven by mtl.el's
buffer-switch hook; callable directly for custom effects. FRAME
defaults to the selected frame. Returns t if the snapshot was taken. */)
(Lisp_Object duration, Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
CHECK_LIVE_FRAME (frame);
CHECK_NUMBER (duration);
bool ok;
block_input ();
ok = mtl_transition_start (XFRAME (frame), (float) XFLOATINT (duration));
unblock_input ();
return ok ? Qt : Qnil;
}
void
syms_of_mtlfns (void)
{
@ -654,6 +674,7 @@ syms_of_mtlfns (void)
defsubr (&Smtl_video_tick);
defsubr (&Smtl_anim_tick);
defsubr (&Smtl_vsync);
defsubr (&Smtl_transition_start);
}
#endif /* HAVE_MTL */

View file

@ -231,6 +231,12 @@ typedef struct mtl_spring {
@property (nonatomic, assign) CFTimeInterval lastPresentTime;
@property (nonatomic, assign) BOOL presentScheduled;
/* Buffer-switch transition: a snapshot of the previous content cross-
fades over the new one (drawn by the compositor while active). */
@property (nonatomic, strong) id<MTLTexture> transitionTexture;
@property (nonatomic, assign) CFTimeInterval transitionStart;
@property (nonatomic, assign) float transitionDuration;
/* Coalesced present: present now, unless one landed very recently (then
schedule a deferred one). Use for "make the frame visible" paths. */
- (void)presentCoalesced;
@ -345,6 +351,10 @@ extern bool mtl_video_set_rect (struct frame *f, int x, int y, int w, int h);
extern bool mtl_video_set_clip (struct frame *f, int x, int y, int w, int h);
extern bool mtl_video_tick (struct frame *f);
/* Buffer-switch crossfade: snapshot current content, fade it out over
DURATION seconds while the new content shows underneath. */
extern bool mtl_transition_start (struct frame *f, float duration);
extern bool mtl_render_offscreen_png (const char *path, int w, int h,
void (^draw)(id<MTLRenderCommandEncoder>));
extern bool mtl_render_text_png (const char *path);

View file

@ -272,7 +272,7 @@ BOOL g_mtl_vsync_enabled = YES;
with the NS backend first. When off, the cursor is drawn directly into the
static texture (like NS) and no compositor overlay is drawn. Toggle from
Lisp with (mtl-animations t). */
BOOL g_mtl_animations_enabled = NO;
BOOL g_mtl_animations_enabled = YES;
/* Phase 4: additional global pipeline state */
static id<MTLRenderPipelineState> g_blit_pipeline = nil;
@ -1087,6 +1087,10 @@ mtl_log_seq_p (void)
if (fd.videoPlayer && [fd.videoPlayer isPlaying])
needsComposite = YES;
/* Same while a buffer-switch crossfade is in flight. */
if (fd.transitionTexture)
needsComposite = YES;
if (needsComposite || self.cursorDirty)
{
self.cursorDirty = NO;
@ -1748,6 +1752,33 @@ mtl_log_seq_p (void)
}
}
/* Buffer-switch crossfade: the old content fades out over the new. */
if (self.transitionTexture && self.transitionDuration > 0)
{
float p = (float) ((CACurrentMediaTime () - self.transitionStart)
/ self.transitionDuration);
if (p >= 1.0f)
self.transitionTexture = nil; /* done */
else
{
float a = 1.0f - p;
a = a * a * (3.0f - 2.0f * a); /* smoothstep del fade-out */
typedef struct { float x, y, u, v, al; } ImgVert;
float x1 = (float) sz.width, y1 = (float) sz.height;
ImgVert verts[6] = {
{0,0, 0,0,a}, {x1,0, 1,0,a}, {0,y1, 0,1,a},
{x1,0, 1,0,a}, {x1,y1, 1,1,a}, {0,y1, 0,1,a},
};
[enc setRenderPipelineState:g_image_pipeline];
[enc setVertexBytes:verts length:sizeof (verts) atIndex:0];
[enc setVertexBuffer:self.uniformBuffer offset:0 atIndex:1];
[enc setFragmentTexture:self.transitionTexture atIndex:0];
[enc setFragmentSamplerState:g_nearest_sampler atIndex:0];
[enc drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0 vertexCount:6];
}
}
/* 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
@ -1804,8 +1835,14 @@ mtl_log_seq_p (void)
}
/* 3. Cursor (spring-interpolated position). Hidden during the
blink-off phase; effects in flight keep animating. */
if (!anim.cursorHidden)
blink-off phase; effects in flight keep animating. Only the
body-animated modes draw it here: the burst modes rely on the
static (inverted glyph) cursor in the texture. */
if (!anim.cursorHidden
&& (anim.cursorMode == MTL_CURSOR_SPRING
|| anim.cursorMode == MTL_CURSOR_TORPEDO
|| anim.cursorMode == MTL_CURSOR_HOLLOW
|| anim.cursorMode == MTL_CURSOR_BEAM))
{
float x0=cx, y0=cy, x1=cx+cw, y1=cy+ch;
float fr=ccr,fg=ccg,fb=ccb;
@ -2663,7 +2700,14 @@ mtl_drv_note_cursor (struct frame *f, int x, int y, int w, int h,
never left painted at its old position. */
if (!fd.encoder)
[fd compositeToScreen];
return true;
/* Only the modes that ANIMATE the cursor body draw it in the overlay;
for the burst modes (sonicboom/ripple/pixiedust) the policy keeps
drawing the proper static cursor (inverted glyph) and the overlay
adds just the effects -- a solid overlay body would hide the
character under the cursor. */
MtlCursorMode mode = fd.animator.cursorMode;
return (mode == MTL_CURSOR_SPRING || mode == MTL_CURSOR_TORPEDO
|| mode == MTL_CURSOR_HOLLOW || mode == MTL_CURSOR_BEAM);
}
static struct gfx_driver mtl_gfx_driver =
@ -2815,6 +2859,48 @@ mtl_video_set_clip (struct frame *f, int x, int y, int w, int h)
return true;
}
/* Buffer-switch crossfade: copy the CURRENT static texture into the
transition snapshot and arm the fade. Called from Lisp just before
redisplay paints the new buffer (pre-redisplay-functions), so the
snapshot still holds the old content. */
bool
mtl_transition_start (struct frame *f, float duration)
{
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !fd.staticTexture || fd.encoder || duration <= 0)
return false;
id<MTLTexture> src = fd.staticTexture;
id<MTLTexture> snap = fd.transitionTexture;
if (!snap || snap.width != src.width || snap.height != src.height)
{
MTLTextureDescriptor *td = [MTLTextureDescriptor
texture2DDescriptorWithPixelFormat:src.pixelFormat
width:src.width
height:src.height
mipmapped:NO];
td.usage = MTLTextureUsageShaderRead;
td.storageMode = MTLStorageModePrivate;
snap = [g_device newTextureWithDescriptor:td];
fd.transitionTexture = snap;
[snap release];
}
id<MTLCommandBuffer> cmd = [g_queue commandBuffer];
id<MTLBlitCommandEncoder> blit = [cmd blitCommandEncoder];
[blit copyFromTexture:src sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake (0, 0, 0)
sourceSize:MTLSizeMake (src.width, src.height, 1)
toTexture:snap destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake (0, 0, 0)];
[blit endEncoding];
[cmd commit];
fd.transitionStart = CACurrentMediaTime ();
fd.transitionDuration = duration;
return true;
}
/* Present a fresh composite if a video is active. Called from a Lisp-level
timer (mtl.el): Emacs's event loop starves the CADisplayLink while idle,
so Lisp timers are what reliably drives playback presents. */