diff --git a/src/renderer/Vulkan.zig b/src/renderer/Vulkan.zig index 524aaeaf0..f2fe54f50 100644 --- a/src/renderer/Vulkan.zig +++ b/src/renderer/Vulkan.zig @@ -69,6 +69,7 @@ pub const Texture = @import("vulkan/Texture.zig"); pub const Target = @import("vulkan/Target.zig"); pub const CommandPool = @import("vulkan/CommandPool.zig"); pub const Pipeline = @import("vulkan/Pipeline.zig"); +pub const Frame = @import("vulkan/Frame.zig"); pub const shaders = @import("vulkan/shaders.zig"); const bufferpkg = @import("vulkan/buffer.zig"); diff --git a/src/renderer/vulkan/Device.zig b/src/renderer/vulkan/Device.zig index 019237275..dd0c934fd 100644 --- a/src/renderer/vulkan/Device.zig +++ b/src/renderer/vulkan/Device.zig @@ -147,6 +147,14 @@ pub const Dispatch = struct { // device-level resolution like any other device function. getMemoryFdKHR: std.meta.Child(vk.PFN_vkGetMemoryFdKHR), getImageSubresourceLayout: std.meta.Child(vk.PFN_vkGetImageSubresourceLayout), + + // Per-frame sync (fence + command-buffer reset) — used by + // `vulkan/Frame.zig`. + createFence: std.meta.Child(vk.PFN_vkCreateFence), + destroyFence: std.meta.Child(vk.PFN_vkDestroyFence), + waitForFences: std.meta.Child(vk.PFN_vkWaitForFences), + resetFences: std.meta.Child(vk.PFN_vkResetFences), + resetCommandBuffer: std.meta.Child(vk.PFN_vkResetCommandBuffer), }; // ---- fields --------------------------------------------------------- @@ -379,6 +387,16 @@ pub fn init( try dl.load(vk.PFN_vkGetMemoryFdKHR, "vkGetMemoryFdKHR"); const get_image_subresource_layout = try dl.load(vk.PFN_vkGetImageSubresourceLayout, "vkGetImageSubresourceLayout"); + const create_fence = + try dl.load(vk.PFN_vkCreateFence, "vkCreateFence"); + const destroy_fence = + try dl.load(vk.PFN_vkDestroyFence, "vkDestroyFence"); + const wait_for_fences = + try dl.load(vk.PFN_vkWaitForFences, "vkWaitForFences"); + const reset_fences = + try dl.load(vk.PFN_vkResetFences, "vkResetFences"); + const reset_command_buffer = + try dl.load(vk.PFN_vkResetCommandBuffer, "vkResetCommandBuffer"); return .{ .platform = platform, @@ -431,6 +449,11 @@ pub fn init( .destroyPipeline = destroy_pipeline, .getMemoryFdKHR = get_memory_fd_khr, .getImageSubresourceLayout = get_image_subresource_layout, + .createFence = create_fence, + .destroyFence = destroy_fence, + .waitForFences = wait_for_fences, + .resetFences = reset_fences, + .resetCommandBuffer = reset_command_buffer, }, }; } diff --git a/src/renderer/vulkan/Frame.zig b/src/renderer/vulkan/Frame.zig new file mode 100644 index 000000000..aa9f9334d --- /dev/null +++ b/src/renderer/vulkan/Frame.zig @@ -0,0 +1,152 @@ +//! Per-draw recording context. Lifecycle: `begin` → caller records +//! commands (via the eventual `renderPass()` accessor) → `complete`. +//! +//! Unlike `opengl/Frame.zig` (which is a zero-state wrapper around +//! the implicit GL context), Vulkan's Frame drives the explicit +//! sync model: a fence is signaled when the GPU finishes the +//! frame's submit, and `complete` waits on it before handing the +//! dmabuf fd to the host. That's required for correctness — the +//! host shouldn't sample memory the GPU is still writing — and +//! acceptable for perf because terminal frames cap at ~60Hz. +//! +//! Ownership: the command buffer and fence are owned by the +//! top-level renderer (`Vulkan.zig`, not yet wired) and passed into +//! `begin` via `Options`. Frame just borrows them. The top-level +//! is responsible for creating/destroying them and for resetting +//! the fence to unsignaled state before `begin` (this layer would +//! conflate ownership otherwise). +//! +//! Why not semaphores? With dmabuf export to the host (rather than +//! a `VkSwapchain` we own), we have no acquire/present semaphore +//! pair to sync against. Fence-only is the right model when +//! libghostty hands the host a "GPU is done writing to this fd" +//! guarantee at present time. The host's own compositor handles +//! display sync from there. +//! +//! `renderPass()` will land alongside `vulkan/RenderPass.zig` in a +//! follow-up commit. For now it's not declared — calling code that +//! tries to record into a frame will fail to compile, which is +//! intentional: the recording path isn't ready. +//! +//! Counterpart: `src/renderer/opengl/Frame.zig`. + +const Self = @This(); + +const std = @import("std"); +const vk = @import("vulkan").c; + +const Device = @import("Device.zig"); +const Target = @import("Target.zig"); + +const log = std.log.scoped(.vulkan); + +pub const Options = struct { + /// Command buffer this frame's commands record into. Caller + /// resets it to a fresh state before `begin` is called. + cb: vk.VkCommandBuffer, + + /// Fence that gets signaled when the submit completes. Caller + /// resets it to unsignaled before `begin` is called. + fence: vk.VkFence, +}; + +pub const Error = error{ + /// `vkBeginCommandBuffer` / `vkEndCommandBuffer` / + /// `vkQueueSubmit` / `vkWaitForFences` returned a non-success + /// status. + VulkanFailed, +}; + +device: *const Device, +target: *Target, +cb: vk.VkCommandBuffer, +fence: vk.VkFence, + +/// Begin recording a frame. The command buffer is reset and started +/// with `ONE_TIME_SUBMIT` since we always submit before the next +/// `begin` overwrites it. +pub fn begin( + opts: Options, + device: *const Device, + target: *Target, +) Error!Self { + const begin_info: vk.VkCommandBufferBeginInfo = .{ + .sType = vk.VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .pNext = null, + .flags = vk.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + .pInheritanceInfo = null, + }; + const r = device.dispatch.beginCommandBuffer(opts.cb, &begin_info); + if (r != vk.VK_SUCCESS) { + log.err("vkBeginCommandBuffer (frame) failed: result={}", .{r}); + return error.VulkanFailed; + } + + return .{ + .device = device, + .target = target, + .cb = opts.cb, + .fence = opts.fence, + }; +} + +/// End recording, submit to the queue with `self.fence`, and (if +/// `sync` is true, which it always is for our dmabuf-export model) +/// wait on the fence so the GPU is guaranteed to be done before +/// the host imports the target's dmabuf. +/// +/// `sync == false` is accepted by the interface for parity with +/// `opengl/Frame.zig`, but currently still does the wait — without +/// it, handing the dmabuf fd to the host would race the GPU. The +/// argument may eventually drive multi-frame pipelining once a +/// proper queue of frames is in flight. +pub fn complete(self: *const Self, sync: bool) void { + _ = sync; + const dev = self.device; + + { + const r = dev.dispatch.endCommandBuffer(self.cb); + if (r != vk.VK_SUCCESS) { + log.err("vkEndCommandBuffer (frame) failed: result={}", .{r}); + return; + } + } + + const submit_info: vk.VkSubmitInfo = .{ + .sType = vk.VK_STRUCTURE_TYPE_SUBMIT_INFO, + .pNext = null, + .waitSemaphoreCount = 0, + .pWaitSemaphores = null, + .pWaitDstStageMask = null, + .commandBufferCount = 1, + .pCommandBuffers = &self.cb, + .signalSemaphoreCount = 0, + .pSignalSemaphores = null, + }; + { + const r = dev.dispatch.queueSubmit(dev.queue, 1, &submit_info, self.fence); + if (r != vk.VK_SUCCESS) { + log.err("vkQueueSubmit (frame) failed: result={}", .{r}); + return; + } + } + + // Wait for the GPU to finish writing the target before letting + // the host import the dmabuf. UINT64_MAX = "wait indefinitely". + { + const r = dev.dispatch.waitForFences( + dev.device, + 1, + &self.fence, + vk.VK_TRUE, + std.math.maxInt(u64), + ); + if (r != vk.VK_SUCCESS) { + log.err("vkWaitForFences (frame) failed: result={}", .{r}); + } + } +} + +test { + std.testing.refAllDecls(@This()); +}