diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index e3e2ceccf..8fd746375 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -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, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index cd62a7bbd..c09f2cb4d 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -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) { diff --git a/src/renderer/image.zig b/src/renderer/image.zig index bf8a606d5..d5b470332 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -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(), + ); +}