From 1a7d722f31827435d19d036bc372478f91be5bdf Mon Sep 17 00:00:00 2001 From: Andros Fenollosa Date: Thu, 4 Jun 2026 21:50:01 +0200 Subject: [PATCH] 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). --- README.md | 27 +++++++++++---- lisp/mtl.el | 72 +++++++++++++++++++++++++++++---------- src/mtlfns.m | 23 ++++++++++++- src/mtlterm.h | 10 ++++++ src/mtlterm.m | 94 ++++++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 198 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 546d08ebd6e..0d2e76e7e34 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lisp/mtl.el b/lisp/mtl.el index 4733a01ebf2..ac6a39d5602 100644 --- a/lisp/mtl.el +++ b/lisp/mtl.el @@ -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 diff --git a/src/mtlfns.m b/src/mtlfns.m index 4fcfea6b86e..7896dff33ad 100644 --- a/src/mtlfns.m +++ b/src/mtlfns.m @@ -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 */ diff --git a/src/mtlterm.h b/src/mtlterm.h index 1a99482ccf8..3f5427f4bec 100644 --- a/src/mtlterm.h +++ b/src/mtlterm.h @@ -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 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)); extern bool mtl_render_text_png (const char *path); diff --git a/src/mtlterm.m b/src/mtlterm.m index c710902337b..a455d635e9c 100644 --- a/src/mtlterm.m +++ b/src/mtlterm.m @@ -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 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 src = fd.staticTexture; + id 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 cmd = [g_queue commandBuffer]; + id 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. */