renderer: drive kitty graphics animation

This commit is contained in:
Mitchell Hashimoto
2026-08-20 21:41:50 -07:00
parent 73903f76aa
commit aee7bf3475
3 changed files with 277 additions and 104 deletions

View File

@@ -18,7 +18,6 @@ const App = @import("../App.zig");
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.renderer_thread);
const DRAW_INTERVAL = 8; // 120 FPS
const CURSOR_BLINK_INTERVAL = 600;
/// Whether calls to `drawFrame` must be done from the app thread.
@@ -53,16 +52,15 @@ wakeup_c: xev.Completion = .{},
stop: xev.Async,
stop_c: xev.Completion = .{},
/// The timer used for rendering
/// The timer used for animations (custom shaders, Kitty graphics).
/// Normal rendering is driven by wakeup messages instead.
render_h: xev.Timer,
render_c: xev.Completion = .{},
render_c_cancel: xev.Completion = .{},
/// The timer used for draw calls. Draw calls don't update from the
/// terminal state so they're much cheaper. They're used for animation
/// and are paused when the terminal is not focused.
draw_h: xev.Timer,
draw_c: xev.Completion = .{},
draw_active: bool = false,
/// The kind of work the currently scheduled animation wake needs,
/// stored when the timer is armed.
animation_wake: rendererpkg.Renderer.AnimationWake.Kind = .draw,
/// This async is used to force a draw immediately. This does not
/// coalesce like the wakeup does.
@@ -115,12 +113,10 @@ flags: packed struct {
} = .{},
pub const DerivedConfig = struct {
custom_shader_animation: configpkg.CustomShaderAnimation,
scrollback_compression: bool,
pub fn init(config: *const configpkg.Config) DerivedConfig {
return .{
.custom_shader_animation = config.@"custom-shader-animation",
.scrollback_compression = config.@"scrollback-compression",
};
}
@@ -153,10 +149,6 @@ pub fn init(
var render_h = try xev.Timer.init();
errdefer render_h.deinit();
// Draw timer, see comments.
var draw_h = try xev.Timer.init();
errdefer draw_h.deinit();
// Draw now async, see comments.
var draw_now = try xev.Async.init();
errdefer draw_now.deinit();
@@ -176,7 +168,6 @@ pub fn init(
.wakeup = wakeup_h,
.stop = stop_h,
.render_h = render_h,
.draw_h = draw_h,
.draw_now = draw_now,
.cursor_h = cursor_timer,
.surface = surface,
@@ -201,7 +192,6 @@ pub fn deinit(self: *Thread) void {
self.stop.deinit();
self.wakeup.deinit();
self.render_h.deinit();
self.draw_h.deinit();
self.draw_now.deinit();
self.cursor_h.deinit();
if (comptime terminalpkg.compression_enabled)
@@ -270,8 +260,9 @@ fn threadMain_(self: *Thread) !void {
cursorTimerCallback,
);
// Start the draw timer
self.syncDrawTimer();
// Arm the animation timer in case the renderer already needs
// animation wakes (e.g. custom shaders loaded at startup).
self.armAnimationTimer();
// Run
log.debug("starting renderer thread", .{});
@@ -309,47 +300,6 @@ fn setQosClass(self: *const Thread) void {
}
}
fn syncDrawTimer(self: *Thread) void {
skip: {
// If our renderer supports animations and has them, then we
// can apply draw timer based on custom shader animation configuration.
if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and
self.renderer.hasAnimations())
{
// If our config says to always animate, we do so.
switch (self.config.custom_shader_animation) {
// Always animate
.always => break :skip,
// Only when focused
.true => if (self.flags.focused) break :skip,
// Never animate
.false => {},
}
}
// We're skipping the draw timer. Stop it on the next iteration.
self.draw_active = false;
return;
}
// Set our active state so it knows we're running. We set this before
// even checking the active state in case we have a pending shutdown.
self.draw_active = true;
// If our draw timer is already active, then we don't have to do anything.
if (self.draw_c.state() == .active) return;
// Start the timer which loops
self.draw_h.run(
&self.loop,
&self.draw_c,
DRAW_INTERVAL,
Thread,
self,
drawCallback,
);
}
/// Drain the mailbox.
fn drainMailbox(self: *Thread) !void {
// There's probably a more elegant way to do this...
@@ -378,15 +328,11 @@ fn drainMailbox(self: *Thread) !void {
self.setQosClass();
// If we became visible then we immediately rebuild cells
// (renderCallback skips updateFrame while invisible) and draw.
if (v) {
self.renderer.updateFrame(
self.state,
self.flags.cursor_blink_visible,
) catch |err|
log.warn("error rendering on visibility regain err={}", .{err});
self.drawFrame(false);
}
// (renderCallback skips updateFrame while invisible) and
// draw. Going through renderCallback also reschedules
// any Kitty graphics animation wakeup that lapsed
// while we were invisible.
if (v) _ = renderCallback(self, undefined, undefined, {});
// Notify the renderer so it can update any state.
self.renderer.setVisible(v);
@@ -412,8 +358,9 @@ fn drainMailbox(self: *Thread) !void {
// Set it on the renderer
try self.renderer.setFocus(v);
// We always resync our draw timer (may disable it)
self.syncDrawTimer();
// Focus gates custom shader animation, so re-arm
// the animation timer for the new state.
self.armAnimationTimer();
if (!v) {
// If we're not focused, then we stop the cursor blink
@@ -474,9 +421,9 @@ fn drainMailbox(self: *Thread) !void {
try self.changeConfig(config.thread);
try self.renderer.changeConfig(config.impl);
// Stop and start the draw timer to capture the new
// hasAnimations value.
self.syncDrawTimer();
// The config affects what animation wakes the
// renderer needs (custom shaders, animation mode).
self.armAnimationTimer();
},
.search_viewport_matches => |v| {
@@ -606,37 +553,19 @@ fn drawNowCallback(
return .rearm;
}
fn drawCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Timer.RunError!void,
) xev.CallbackAction {
_ = r catch unreachable;
const t: *Thread = self_ orelse {
// This shouldn't happen so we log it.
log.warn("render callback fired without data set", .{});
return .disarm;
};
// Draw
t.drawFrame(false);
// Only continue if we're still active
if (t.draw_active) {
t.draw_h.run(&t.loop, &t.draw_c, DRAW_INTERVAL, Thread, t, drawCallback);
}
return .disarm;
}
fn renderCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Timer.RunError!void,
) xev.CallbackAction {
_ = r catch unreachable;
_ = r catch |err| switch (err) {
// Sent when a scheduled animation wakeup is superseded by a
// newer one (Timer.reset cancels the pending run). Nothing to
// do; the replacement timer carries on.
error.Canceled => return .disarm,
else => unreachable,
};
const t: *Thread = self_ orelse {
// This shouldn't happen so we log it.
log.warn("render callback fired without data set", .{});
@@ -645,6 +574,7 @@ fn renderCallback(
// If we're not visible there's no point spending CPU rebuilding cells —
// we'll catch up when the .visible mailbox message flips us back on.
// Kitty graphics animations pause with us and resume on visibility.
if (!t.flags.visible) return .disarm;
// Update our frame data
@@ -657,9 +587,78 @@ fn renderCallback(
// Draw
t.drawFrame(false);
// Schedule the next animation wake, if the renderer needs one.
t.armAnimationTimer();
return .disarm;
}
/// Schedule the animation timer for the renderer's next animation
/// wake, if it needs one.
///
/// This is called after every frame update or animation draw and
/// whenever the wake inputs change (focus, config, visibility
/// regain). Resetting a pending timer is always safe: every call
/// recomputes the wake, so the deadline only ever moves toward the
/// actual next wake.
fn armAnimationTimer(self: *Thread) void {
const wake = self.renderer.animationWake() orelse return;
self.animation_wake = wake.kind;
self.render_h.reset(
&self.loop,
&self.render_c,
&self.render_c_cancel,
wake.delay_ms,
Thread,
self,
animationTimerCallback,
);
}
fn animationTimerCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Timer.RunError!void,
) xev.CallbackAction {
_ = r catch |err| switch (err) {
// Sent when a scheduled animation wake is superseded by a
// newer one (Timer.reset cancels the pending run). Nothing to
// do; the replacement timer carries on.
error.Canceled => return .disarm,
else => unreachable,
};
const t: *Thread = self_ orelse {
// This shouldn't happen so we log it.
log.warn("animation callback fired without data set", .{});
return .disarm;
};
// Animations pause entirely while we're invisible; the .visible
// mailbox message re-arms us when we can be seen again.
if (!t.flags.visible) return .disarm;
switch (t.animation_wake) {
// Frame data must be updated (a Kitty animation frame is
// due). renderCallback updates, draws, and re-arms us.
.update => return renderCallback(
t,
undefined,
undefined,
{},
),
// A redraw alone suffices (custom shader time uniform).
// Draw calls don't update from the terminal state so they
// are much cheaper than a frame update.
.draw => {
t.drawFrame(false);
t.armAnimationTimer();
return .disarm;
},
}
}
fn cursorTimerCallback(
self_: ?*Thread,
_: *xev.Loop,

View File

@@ -236,6 +236,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Our overlay state, if any.
overlay: ?Overlay = null,
/// The base timestamp for the Kitty graphics animation clock.
/// Animation frame timing is expressed as milliseconds since
/// this instant. Set on the first frame update that observes
/// Kitty images.
kitty_animation_clock: ?std.Io.Timestamp = null,
/// When the next Kitty animation frame is due, in
/// milliseconds on the animation clock, from the most recent
/// frame update. Null when no running animation needs a
/// wakeup.
kitty_animation_next_ms: ?u64 = null,
const HighlightTag = enum(u8) {
search_match,
search_match_selected,
@@ -577,6 +589,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
scroll_to_bottom_on_output: bool,
custom_shader_animation: configpkg.CustomShaderAnimation,
pub fn init(
alloc_gpa: Allocator,
@@ -651,6 +664,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.scroll_to_bottom_on_output = config.@"scroll-to-bottom".output,
.custom_shader_animation = config.@"custom-shader-animation",
.arena = arena,
};
}
@@ -997,10 +1011,74 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.syncDisplayLink(id, draw_now);
}
/// True if our renderer has animations so that a higher frequency
/// timer is used.
pub fn hasAnimations(self: *const Self) bool {
return self.has_custom_shaders;
/// The cadence of continuous (draw-only) animation wakes,
/// i.e. 120fps, and the floor for any animation wake delay.
pub const draw_interval_ms: u64 = 8;
/// A point in the future when the renderer needs to be driven
/// again to keep animating, and what kind of drive it needs.
pub const AnimationWake = struct {
/// Delay in milliseconds until the wake is due.
delay_ms: u64,
kind: Kind,
pub const Kind = enum {
/// A redraw alone suffices, no updateFrame. Much cheaper
/// than `update`.
draw,
/// Frame data must be updated first: updateFrame, then draw.
update,
};
};
/// The soonest animation wake this renderer needs, if any:
/// custom shader animation wants continuous draw-only wakes
/// at draw_interval_ms while active, and a running Kitty
/// graphics animation wants an update wake when its next
/// frame is due. The renderer thread drives its animation
/// timer off this, re-querying after every wake.
///
/// Must be called on the render thread.
pub fn animationWake(self: *const Self) ?AnimationWake {
// Custom shaders animate by redrawing on a fixed cadence,
// gated by configuration and focus.
const shader_delay: ?u64 = shader: {
if (!self.has_custom_shaders) break :shader null;
break :shader switch (self.config.custom_shader_animation) {
.false => null,
.always => draw_interval_ms,
.true => if (self.focused) draw_interval_ms else null,
};
};
// Kitty animations tick during updateFrame; between
// updates the deadline is absolute on the animation
// clock, so a stream of draw wakes recomputing this
// cannot starve it into the future.
const kitty_delay: ?u64 = kitty: {
const next = self.kitty_animation_next_ms orelse break :kitty null;
const base = self.kitty_animation_clock orelse break :kitty null;
const now: std.Io.Timestamp = .now(global.io(), .awake);
const now_ms: u64 = @intCast(@divTrunc(
base.durationTo(now).nanoseconds,
std.time.ns_per_ms,
));
// Never wake faster than the draw interval; an
// overdue frame is picked up on the next wake.
break :kitty @max(next -| now_ms, draw_interval_ms);
};
// An update wake includes a draw, so it wins ties.
if (kitty_delay) |k| {
if (shader_delay == null or k <= shader_delay.?) {
return .{ .delay_ms = k, .kind = .update };
}
}
if (shader_delay) |s| return .{ .delay_ms = s, .kind = .draw };
return null;
}
/// True if our renderer is using vsync. If true, the renderer or apprt
@@ -1248,6 +1326,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
break :preedit try p.clone(arena_alloc);
};
// Advance any running Kitty graphics animations to the
// frame due now, and remember when the next frame is
// due (as an absolute deadline, see animationWake) so
// the renderer thread can schedule a wakeup for it.
// This must happen before the dirty check below:
// advancing a frame marks the image state dirty.
self.kitty_animation_next_ms = next: {
// Likely case: we have no kitty images, so do nothing.
const storage = &state.terminal.screens.active.kitty_images;
if (storage.images.count() == 0) break :next null;
const now: std.Io.Timestamp = .now(global.io(), .awake);
const base = self.kitty_animation_clock orelse base: {
self.kitty_animation_clock = now;
break :base now;
};
const now_ms: u64 = @intCast(@divTrunc(
base.durationTo(now).nanoseconds,
std.time.ns_per_ms,
));
const delay = storage.animationTick(
global.io(),
now_ms,
) orelse break :next null;
break :next now_ms + delay;
};
// If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
// We only do this if the Kitty image state is dirty meaning only if
// it changes.
@@ -1492,10 +1597,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Conditions under which we need to draw the frame, otherwise we
// don't need to since the previous frame should be identical.
//
// While any animation is in progress (a pending animation wake)
// every draw must actually render.
const needs_redraw =
size_changed or
self.cells_rebuilt or
self.hasAnimations() or
self.animationWake() != null or
sync;
if (!needs_redraw) {

View File

@@ -769,7 +769,10 @@ pub const State = struct {
alloc: Allocator,
image: *const terminal.kitty.graphics.Image,
) PrepImageError!void {
const data = image.data.bytes() orelse unreachable;
// For animated images this is the current animation frame;
// the image generation changes whenever the current frame
// does, so the upload cache stays coherent.
const data = image.renderData().bytes() orelse unreachable;
try self.prepImage(
alloc,
.{ .kitty = image.id },
@@ -1439,3 +1442,66 @@ test "kitty renderer positions relative placements from virtual parent placehold
try testing.expectEqual(@as(i32, 2), child.x);
try testing.expectEqual(@as(i32, 3), child.y);
}
test "kitty renderer uploads the current animation frame" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 });
defer t.deinit(alloc);
t.width_px = 30;
t.height_px = 30;
var state: State = .empty;
defer state.deinit(alloc);
const storage = &t.screens.active.kitty_images;
try storage.addImage(io, alloc, t.screens.active, .{
.id = 1,
.width = 1,
.height = 1,
.format = .rgba,
.data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) },
});
const pin = try t.screens.active.pages.trackPin(
t.screens.active.cursor.page_pin.*,
);
try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
.location = .{ .pin = pin },
.columns = 1,
.rows = 1,
});
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
const gen1 = state.images.get(.{ .kitty = 1 }).?.generation;
try testing.expectEqualSlices(
u8,
&.{ 255, 0, 0, 255 },
state.images.get(.{ .kitty = 1 }).?.image.pending.dataSlice(),
);
// Attach an animation and make its extra frame current, the way
// an animation tick would.
const img = storage.images.getPtr(1).?;
const anim = try alloc.create(terminal.kitty.graphics.Animation);
anim.* = .{};
img.animation = anim;
try anim.frames.append(alloc, .{
.data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }),
.gap_ms = 40,
});
anim.current_index = 1;
storage.markImageContentChanged(io, img);
// The renderer must pick up the frame's pixels under a fresh
// generation.
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
const entry = state.images.get(.{ .kitty = 1 }).?;
try testing.expect(entry.generation > gen1);
try testing.expectEqualSlices(
u8,
&.{ 0, 0, 255, 255 },
entry.image.pending.dataSlice(),
);
}