diff --git a/src/apprt.zig b/src/apprt.zig index ef788c9bb..22256cc97 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -52,6 +52,15 @@ pub const runtime = switch (build_config.artifact) { pub const App = runtime.App; pub const Surface = runtime.Surface; +/// True if all GPU operations (drawing, resource creation and +/// destruction) must happen on the app thread. This is the case +/// when the graphics context is owned by the app thread (GTK's +/// GLArea). When false, the render thread performs GPU operations +/// itself. +pub const must_draw_from_app_thread = + @hasDecl(App, "must_draw_from_app_thread") and + App.must_draw_from_app_thread; + test { _ = Runtime; _ = runtime; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 4b01da0c5..40c11f606 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -335,6 +335,12 @@ pub fn presentLastTarget(self: *OpenGL) !void { if (self.last_target) |target| try self.present(target); } +/// Called when the renderer released its GPU resources; the last +/// presented target is deinited with them so we must drop our copy. +pub fn gpuResourcesReleased(self: *OpenGL) void { + self.last_target = null; +} + /// Returns the options to use when constructing buffers. pub inline fn bufferOptions(self: OpenGL) bufferpkg.Options { _ = self; diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 8fd746375..f66fd3842 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -22,14 +22,6 @@ const CURSOR_BLINK_INTERVAL = 600; /// Whether calls to `drawFrame` must be done from the app thread. /// -/// If this is `true` then we send a `redraw_surface` message to the apprt -/// whenever we need to draw instead of calling `drawFrame` directly. -const must_draw_from_app_thread = - if (@hasDecl(apprt.App, "must_draw_from_app_thread")) - apprt.App.must_draw_from_app_thread - else - false; - /// The type used for sending messages to the IO thread. For now this is /// hardcoded with a capacity. We can make this a comptime parameter in /// the future if we want it configurable. @@ -479,7 +471,7 @@ fn drawFrame(self: *Thread, now: bool) void { // when we're forced to via `now`. if (!now and self.renderer.hasVsync()) return; - if (must_draw_from_app_thread) { + if (apprt.must_draw_from_app_thread) { _ = self.app_mailbox.push( .{ .redraw_surface = self.surface }, .{ .instant = {} }, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index c09f2cb4d..26ce0d55e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -207,8 +207,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Health of the most recently completed frame. health: std.atomic.Value(Health) = .{ .raw = .healthy }, - /// Our swap chain (multiple buffering) - swap_chain: SwapChain, + /// True when we have a graphics context that can create GPU + /// resources. Creating any GPU resource while this is false is invalid. + display_realized: bool = true, + + /// Our swap chain (multiple buffering). Null when it has + /// been released, either because the surface is hidden + /// (`releaseGpuResources`) or because the display is + /// unrealized. Rebuilt on the next `drawFrame`. + swap_chain: ?SwapChain, /// This value is used to force-update swap chain targets in the /// event of a config change that requires it (such as blending mode). @@ -271,14 +278,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// frame state struct so we can start working on a new frame. frame_sema: std.Io.Semaphore = .{ .permits = buf_count }, - /// Set to true when deinited, if you try to deinit a defunct - /// swap chain it will just be ignored, to prevent double-free. - /// - /// This is required because of `displayUnrealized`, since it - /// `deinits` the swapchain, which leads to a double-free if - /// the renderer is deinited after that. - defunct: bool = false, - pub fn init(api: GraphicsAPI, custom_shaders: bool) !SwapChain { var result: SwapChain = .{ .frames = undefined }; @@ -291,9 +290,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *SwapChain) void { - if (self.defunct) return; - self.defunct = true; - // Wait for all of our inflight draws to complete // so that we can cleanly deinit our GPU state. for (0..buf_count) |_| self.frame_sema.waitUncancelable( @@ -305,11 +301,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Get the next frame state to draw to. This will wait on the /// semaphore to ensure that the frame is available. This must /// always be paired with a call to releaseFrame. - pub fn nextFrame(self: *SwapChain) error{Defunct}!*FrameState { - if (self.defunct) return error.Defunct; - + pub fn nextFrame(self: *SwapChain) *FrameState { self.frame_sema.waitUncancelable(global.io()); - errdefer self.frame_sema.post(); self.frame_index = (self.frame_index + 1) % buf_count; return &self.frames[self.frame_index]; } @@ -814,7 +807,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.deinit(self.alloc); if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); - self.swap_chain.deinit(); + if (self.swap_chain) |*sc| sc.deinit(); if (DisplayLink != void) { if (self.display_link) |display_link| { @@ -950,9 +943,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { defer self.draw_mutex.unlock(global.io()); // We assume that the swap chain was deinited in - // `displayUnrealized`, in which case it should be - // marked defunct. If not, we have a problem. - assert(self.swap_chain.defunct); + // `displayUnrealized`. If not, we have a problem. + assert(self.swap_chain == null); + assert(!self.display_realized); // We reinitialize our shaders and our swap chain. try self.initShaders(); @@ -960,6 +953,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.api, self.has_custom_shaders, ); + self.display_realized = true; self.reinitialize_shaders = false; self.target_config_modified = 1; } @@ -978,11 +972,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lockUncancelable(global.io()); defer self.draw_mutex.unlock(global.io()); - // We deinit our swap chain and shaders. - // - // This will mark them as defunct so that they - // can't be double-freed or used in draw calls. - self.swap_chain.deinit(); + // We deinit our swap chain and shaders. Clearing + // `display_realized` ensures drawFrame doesn't attempt + // to rebuild the swap chain (we have no GPU context); + // displayRealized will. + if (self.swap_chain) |*sc| sc.deinit(); + self.swap_chain = null; + self.display_realized = false; self.shaders.deinit(self.alloc); } @@ -1110,6 +1106,48 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn setVisible(self: *Self, visible: bool) void { self.visible = visible; self.syncDisplayLink(null, null); + + // When we're hidden, release our GPU resources if GPU + // operations are allowed from this thread. Apprts where + // they aren't (GTK owns the OpenGL context on the app + // thread) call `releaseGpuResources` at the appropriate + // time instead. + if (comptime !apprt.must_draw_from_app_thread) { + if (!visible) self.releaseGpuResources(); + } + } + + /// Release the GPU resources we hold while the surface is not + /// visible. Today this is the swap chain (render targets, font + /// atlas texture copies, cell buffers, custom shader textures), + /// which makes up nearly all of a surface's GPU memory usage; + /// a hidden surface doesn't draw, so it doesn't need it. The + /// swap chain is rebuilt on the next `drawFrame`. + /// + /// This is safe to call in any state; resources that are + /// already released are skipped. + /// + /// For OpenGL this must be called on the app thread with the + /// GL context current (the same requirement as + /// `displayUnrealized`). Other APIs may call this from the + /// render thread; see `apprt.must_draw_from_app_thread`. + pub fn releaseGpuResources(self: *Self) void { + self.draw_mutex.lockUncancelable(global.io()); + defer self.draw_mutex.unlock(global.io()); + + if (self.swap_chain) |*sc| { + // Waits for any in-flight frames to complete, then + // frees all GPU resources. + sc.deinit(); + self.swap_chain = null; + + // Let the API drop any references it holds to swap + // chain resources (e.g. OpenGL's last presented + // target). + if (comptime @hasDecl(GraphicsAPI, "gpuResourcesReleased")) { + self.api.gpuResourcesReleased(); + } + } } /// Create or update the display link and match it to the current @@ -1175,11 +1213,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update all our textures so that they sync on the next frame. // We can modify this without a lock because the GPU does not - // touch this data. - for (&self.swap_chain.frames) |*frame| { + // touch this data. A released swap chain is rebuilt with + // fresh frames that sync all textures on first use. + if (self.swap_chain) |*sc| for (&sc.frames) |*frame| { frame.grayscale_modified = 0; frame.color_modified = 0; - } + }; // Get our metrics from the grid. This doesn't require a lock because // the metrics are never recalculated. @@ -1591,6 +1630,25 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // then drawing is absurd, so we just return. if (surface_size.width == 0 or surface_size.height == 0) return; + // If we have no graphics context we can't draw. This is + // only the case while unrealized (GTK); displayRealized + // rebuilds the swap chain. + if (!self.display_realized) return; + + // Get our swap chain, rebuilding it if it was released + // while we were hidden. Rebuilding is deferred to draw + // time because resource creation must happen somewhere + // our graphics API allows it (OpenGL requires a current + // context, which drawFrame guarantees). + const swap_chain: *SwapChain, const swap_chain_rebuilt: bool = + if (self.swap_chain) |*sc| .{ sc, false } else rebuild: { + self.swap_chain = try SwapChain.init( + self.api, + self.has_custom_shaders, + ); + break :rebuild .{ &self.swap_chain.?, true }; + }; + const size_changed = self.size.screen.width != surface_size.width or self.size.screen.height != surface_size.height; @@ -1602,6 +1660,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // every draw must actually render. const needs_redraw = size_changed or + swap_chain_rebuilt or self.cells_rebuilt or self.animationWake() != null or sync; @@ -1616,9 +1675,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells_rebuilt = false; // Wait for a frame to be available. - const frame = try self.swap_chain.nextFrame(); - errdefer self.swap_chain.releaseFrame(); - // log.debug("drawing frame index={}", .{self.swap_chain.frame_index}); + const frame = swap_chain.nextFrame(); + errdefer swap_chain.releaseFrame(); + // log.debug("drawing frame index={}", .{swap_chain.frame_index}); // If we need to reinitialize our shaders, do so. if (self.reinitialize_shaders) { @@ -1863,8 +1922,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }, .{ .forever = {} }); } - // Always release our semaphore - self.swap_chain.releaseFrame(); + // Always release our semaphore. The swap chain is + // guaranteed to exist here: it is only torn down after + // waiting for all in-flight frames to complete, and this + // callback is what signals that completion. + self.swap_chain.?.releaseFrame(); } /// Call this any time the background image path changes.