From 3ec5f35bd7b5db86156f54ca0e4c4399cc1d39b5 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Mon, 25 May 2026 18:44:26 -0500 Subject: [PATCH] pkg/vulkan: promote Device/Sampler/CommandPool/DescriptorPool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors how pkg/opengl/ houses the OpenGL Buffer/Program/Texture/etc. typed wrappers consumed by src/renderer/OpenGL.zig. Renderer-policy files (Target, Texture, buffer, Pipeline, RenderPass, Frame, shaders) stay under src/renderer/vulkan/ — same split the OpenGL backend uses. Decoupling Device from the apprt is what makes this move possible: - Device.zig drops `platform: apprt.embedded.Platform.Vulkan`. - Device.init now takes a neutral `HostBootstrap` (raw handles + the root proc-addr resolver), so pkg/vulkan/ stays free of libghostty's apprt types. - Vulkan.zig's `bootstrapFromPlatform` translates the apprt callbacks into HostBootstrap at the libghostty boundary. - Target.Options.platform becomes non-optional. The smoke-test code that justified the optional was deleted in 1427f658a; its removal here closes a dead fallback (`self.device.platform`) that would also have stopped working once Device.platform went away. Verified via Docker (debian:bookworm-slim + zig 0.15.2 linux-arm64): zig build -Drenderer=vulkan -Dapp-runtime=none → clean zig build -Drenderer=opengl -Dapp-runtime=none → clean Step 1 of 6 in the PR-17 review refactor (slim Vulkan.zig, decouple shadertoy, etc., to follow). Co-Authored-By: claude-flow --- {src/renderer => pkg}/vulkan/CommandPool.zig | 2 +- .../vulkan/DescriptorPool.zig | 2 +- {src/renderer => pkg}/vulkan/Device.zig | 84 +++++++------- {src/renderer => pkg}/vulkan/Sampler.zig | 2 +- pkg/vulkan/main.zig | 31 ++++- src/renderer/Vulkan.zig | 106 +++++++++++++----- src/renderer/vulkan/Frame.zig | 7 +- src/renderer/vulkan/Pipeline.zig | 7 +- src/renderer/vulkan/README.md | 69 ++++++------ src/renderer/vulkan/RenderPass.zig | 31 ++--- src/renderer/vulkan/Target.zig | 64 ++++++----- src/renderer/vulkan/Texture.zig | 28 +++-- src/renderer/vulkan/buffer.zig | 5 +- src/renderer/vulkan/shaders.zig | 14 +-- 14 files changed, 271 insertions(+), 181 deletions(-) rename {src/renderer => pkg}/vulkan/CommandPool.zig (99%) rename {src/renderer => pkg}/vulkan/DescriptorPool.zig (99%) rename {src/renderer => pkg}/vulkan/Device.zig (92%) rename {src/renderer => pkg}/vulkan/Sampler.zig (99%) diff --git a/src/renderer/vulkan/CommandPool.zig b/pkg/vulkan/CommandPool.zig similarity index 99% rename from src/renderer/vulkan/CommandPool.zig rename to pkg/vulkan/CommandPool.zig index ada00d963..959dd107a 100644 --- a/src/renderer/vulkan/CommandPool.zig +++ b/pkg/vulkan/CommandPool.zig @@ -14,7 +14,7 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vk = @import("c.zig").c; const Device = @import("Device.zig"); diff --git a/src/renderer/vulkan/DescriptorPool.zig b/pkg/vulkan/DescriptorPool.zig similarity index 99% rename from src/renderer/vulkan/DescriptorPool.zig rename to pkg/vulkan/DescriptorPool.zig index 3fb8510a1..c71d63d73 100644 --- a/src/renderer/vulkan/DescriptorPool.zig +++ b/pkg/vulkan/DescriptorPool.zig @@ -20,7 +20,7 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vk = @import("c.zig").c; const Device = @import("Device.zig"); diff --git a/src/renderer/vulkan/Device.zig b/pkg/vulkan/Device.zig similarity index 92% rename from src/renderer/vulkan/Device.zig rename to pkg/vulkan/Device.zig index 1801dbb06..010c19d7b 100644 --- a/src/renderer/vulkan/Device.zig +++ b/pkg/vulkan/Device.zig @@ -34,8 +34,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const apprt = @import("../../apprt.zig"); -const vk = @import("vulkan").c; +const vk = @import("c.zig").c; const log = std.log.scoped(.vulkan); @@ -203,11 +202,6 @@ pub const Dispatch = struct { // ---- fields --------------------------------------------------------- -/// The callbacks the apprt handed us. Held by value (not pointer) -/// because the apprt's `Platform.Vulkan` is itself stored by value -/// inside the `Surface`. -platform: apprt.embedded.Platform.Vulkan, - instance: vk.VkInstance, physical_device: vk.VkPhysicalDevice, device: vk.VkDevice, @@ -260,14 +254,28 @@ pub fn queueWaitIdle(self: *const Device) vk.VkResult { // ---- API ------------------------------------------------------------ -/// Build a `Device` from the host's platform callbacks. Performs: -/// 1. Pull host handles via the callbacks. Any null returns -> -/// `error.HostHandleMissing`. -/// 2. Load the instance-level dispatch via `vkGetInstanceProcAddr`. -/// 3. Verify `physicalDeviceProperties.apiVersion >= 1.3`. -/// 4. Verify every entry in `REQUIRED_DEVICE_EXTENSIONS` is present +/// Pre-resolved host-Vulkan handles passed into `Device.init`. Keeps +/// `pkg/vulkan` independent of any apprt type — callers (e.g. +/// libghostty's `src/renderer/Vulkan.zig`) translate their own +/// platform-callback struct into this neutral shape. +pub const HostBootstrap = struct { + instance: vk.VkInstance, + physical_device: vk.VkPhysicalDevice, + device: vk.VkDevice, + queue: vk.VkQueue, + queue_family_index: u32, + /// Root proc-addr resolver. `Device.init` uses this to pull + /// `vkGetInstanceProcAddr` itself plus every instance-level + /// function it needs to bootstrap the dispatch table. + get_instance_proc_addr_raw: *const anyopaque, +}; + +/// Build a `Device` from pre-resolved host handles. Performs: +/// 1. Load the instance-level dispatch via `vkGetInstanceProcAddr`. +/// 2. Verify `physicalDeviceProperties.apiVersion >= 1.3`. +/// 3. Verify every entry in `REQUIRED_DEVICE_EXTENSIONS` is present /// on the physical device. -/// 5. Load the device-level dispatch via `vkGetDeviceProcAddr`. +/// 4. Load the device-level dispatch via `vkGetDeviceProcAddr`. /// /// On success the returned `Device` is ready for the renderer to /// build pipelines / images / command buffers against. The host @@ -275,38 +283,23 @@ pub fn queueWaitIdle(self: *const Device) vk.VkResult { /// is a no-op stub for symmetry. pub fn init( alloc: Allocator, - platform: apprt.embedded.Platform.Vulkan, + boot: HostBootstrap, ) (Error || Allocator.Error)!Device { - // ---- 1. resolve host handles --------------------------------- - const instance_handle = platform.instance(platform.userdata) orelse - return error.HostHandleMissing; - const physical_device_handle = platform.physical_device(platform.userdata) orelse - return error.HostHandleMissing; - const device_handle = platform.device(platform.userdata) orelse - return error.HostHandleMissing; - const queue_handle = platform.queue(platform.userdata) orelse - return error.HostHandleMissing; + const instance = boot.instance; + const physical_device = boot.physical_device; + const device = boot.device; + const queue = boot.queue; + const queue_family_index = boot.queue_family_index; - const instance: vk.VkInstance = @ptrCast(instance_handle); - const physical_device: vk.VkPhysicalDevice = @ptrCast(physical_device_handle); - const device: vk.VkDevice = @ptrCast(device_handle); - const queue: vk.VkQueue = @ptrCast(queue_handle); - const queue_family_index = platform.queue_family_index(platform.userdata); - - // ---- 2. instance-level dispatch ------------------------------ - // The host's get_instance_proc_addr is our root entry point. We - // resolve other functions via vkGetInstanceProcAddr (instance, - // name); per the Vulkan spec, passing a non-null instance is - // valid for any function that takes an instance, physical - // device, device, or child object of any of these — i.e. + // ---- instance-level dispatch --------------------------------- + // The caller-provided get_instance_proc_addr is our root entry + // point. We resolve other functions via vkGetInstanceProcAddr + // (instance, name); per the Vulkan spec, passing a non-null + // instance is valid for any function that takes an instance, + // physical device, device, or child object of any of these — i.e. // everything we care about. - const get_instance_proc_addr_raw = - platform.get_instance_proc_addr( - platform.userdata, - "vkGetInstanceProcAddr", - ) orelse return error.HostHandleMissing; const get_instance_proc_addr: std.meta.Child(vk.PFN_vkGetInstanceProcAddr) = - @ptrCast(@alignCast(get_instance_proc_addr_raw)); + @ptrCast(@alignCast(boot.get_instance_proc_addr_raw)); const InstanceLoader = struct { instance: vk.VkInstance, @@ -338,7 +331,7 @@ pub fn init( const get_device_proc_addr = try il.load(vk.PFN_vkGetDeviceProcAddr, "vkGetDeviceProcAddr"); - // ---- 3. version check ---------------------------------------- + // ---- version check ------------------------------------------ var props: vk.VkPhysicalDeviceProperties = std.mem.zeroes(vk.VkPhysicalDeviceProperties); get_physical_device_properties(physical_device, &props); if (props.apiVersion < MIN_API_VERSION) { @@ -356,7 +349,7 @@ pub fn init( return error.UnsupportedVulkanVersion; } - // ---- 4. extension check -------------------------------------- + // ---- extension check ---------------------------------------- var ext_count: u32 = 0; { const r = enumerate_device_extension_properties(physical_device, null, &ext_count, null); @@ -409,7 +402,7 @@ pub fn init( } } - // ---- 5. device-level dispatch -------------------------------- + // ---- device-level dispatch ---------------------------------- const DeviceLoader = struct { device: vk.VkDevice, get_device_proc_addr: std.meta.Child(vk.PFN_vkGetDeviceProcAddr), @@ -555,7 +548,6 @@ pub fn init( get_physical_device_memory_properties(physical_device, &memory_properties); return .{ - .platform = platform, .instance = instance, .physical_device = physical_device, .device = device, diff --git a/src/renderer/vulkan/Sampler.zig b/pkg/vulkan/Sampler.zig similarity index 99% rename from src/renderer/vulkan/Sampler.zig rename to pkg/vulkan/Sampler.zig index 5bb1a354d..ef6e817e6 100644 --- a/src/renderer/vulkan/Sampler.zig +++ b/pkg/vulkan/Sampler.zig @@ -11,7 +11,7 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vk = @import("c.zig").c; const Device = @import("Device.zig"); diff --git a/pkg/vulkan/main.zig b/pkg/vulkan/main.zig index 38a6ca055..dcddb23b4 100644 --- a/pkg/vulkan/main.zig +++ b/pkg/vulkan/main.zig @@ -1,7 +1,30 @@ -//! Vulkan loader bindings. +//! Vulkan bindings. //! -//! Lightweight `@cImport` wrapper around the system Vulkan headers, -//! shaped after `pkg/opengl/`. `c` is the raw C API; higher-level -//! Zig helpers go alongside as the renderer needs them. +//! Shaped after `pkg/opengl/`: `c` is the raw C API (a thin `@cImport` +//! wrapper around the system Vulkan headers); the per-resource files +//! alongside provide opinionated typed wrappers the renderer +//! consumes as primitives. +//! +//! The Vulkan renderer in `src/renderer/vulkan/` builds renderer +//! policy on top of these (Pipeline / RenderPass / Frame / Target +//! etc.); anything that's pure Vulkan-API plumbing belongs here. +//! +//! Vulkan core API + the dmabuf-related extensions the renderer relies +//! on for zero-copy presentation: +//! +//! - VK_KHR_external_memory / VK_KHR_external_memory_fd +//! - VK_EXT_external_memory_dma_buf +//! - VK_EXT_image_drm_format_modifier +//! +//! VK_USE_PLATFORM_* macros are intentionally NOT set in `c.zig` — +//! libghostty talks to its host purely via dmabuf fds (handed back to +//! the apprt's `ghostty_platform_vulkan_s.present` callback), so it +//! never sees a `wl_display` or `xcb_connection`. That keeps the +//! binding portable and lets the host (Qt RHI) do all the +//! platform-specific compositing. pub const c = @import("c.zig").c; +pub const Device = @import("Device.zig"); +pub const Sampler = @import("Sampler.zig"); +pub const CommandPool = @import("CommandPool.zig"); +pub const DescriptorPool = @import("DescriptorPool.zig"); diff --git a/src/renderer/Vulkan.zig b/src/renderer/Vulkan.zig index c598269ff..7220592dd 100644 --- a/src/renderer/Vulkan.zig +++ b/src/renderer/Vulkan.zig @@ -11,26 +11,35 @@ //! `Frame.complete` waits on the fence before handing the fd to //! the platform `present` callback. //! -//! Submodules: -//! - `vulkan/Device.zig` — host-handle wrapper, dispatch table. -//! - `vulkan/Sampler.zig` — VkSampler. -//! - `vulkan/Texture.zig` — VkImage + memory + view + staging upload. -//! - `vulkan/Target.zig` — dmabuf-exportable render target -//! (direct or legacy_copy mode). -//! - `vulkan/buffer.zig` — Buffer(T) host-coherent. -//! - `vulkan/CommandPool.zig` — VkCommandPool + one-shot helper. -//! - `vulkan/Pipeline.zig` — VkPipeline + layout (dynamic rendering). -//! - `vulkan/RenderPass.zig` — dynamic-rendering pass + step recorder. -//! - `vulkan/Frame.zig` — per-draw context (fence-paced). -//! - `vulkan/shaders.zig` — GLSL→SPIR-V→VkShaderModule + the -//! OpenGL-GLSL → Vulkan-GLSL rewriter. +//! Submodules — pure Vulkan-API wrappers live in `pkg/vulkan/` +//! (mirror of `pkg/opengl/`); renderer-policy modules live alongside +//! this file under `vulkan/`. +//! +//! In `pkg/vulkan/` (re-exported from this file as +//! `Vulkan.{Device,Sampler,CommandPool,DescriptorPool}`): +//! - `Device.zig` — host-handle wrapper + dispatch table. +//! - `Sampler.zig` — VkSampler. +//! - `CommandPool.zig` — VkCommandPool + one-shot helper. +//! - `DescriptorPool.zig`— per-frame descriptor pool. +//! +//! In `src/renderer/vulkan/`: +//! - `Texture.zig` — VkImage + memory + view + staging upload. +//! - `Target.zig` — dmabuf-exportable render target +//! (direct or legacy_copy mode). +//! - `buffer.zig` — Buffer(T) host-coherent + recycle pool. +//! - `Pipeline.zig` — VkPipeline + layout (dynamic rendering). +//! - `RenderPass.zig` — dynamic-rendering pass + step recorder. +//! - `Frame.zig` — per-draw context (fence-paced). +//! - `shaders.zig` — GLSL→SPIR-V→VkShaderModule + the +//! OpenGL-GLSL → Vulkan-GLSL rewriter. pub const Vulkan = @This(); const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); @@ -39,15 +48,24 @@ const rendererpkg = @import("../renderer.zig"); const shadertoy = @import("shadertoy.zig"); pub const GraphicsAPI = Vulkan; -pub const Device = @import("vulkan/Device.zig"); -pub const Sampler = @import("vulkan/Sampler.zig"); +// Device-dispatch primitives live in `pkg/vulkan/` so they can be +// reused by anything that needs a typed Vulkan binding (mirrors how +// `pkg/opengl/` houses Buffer/Program/Texture/etc.). The renderer +// re-exports them from this top-level so call sites continue to write +// `Vulkan.Device`, `Vulkan.Sampler`, etc. +pub const Device = vulkan.Device; +pub const Sampler = vulkan.Sampler; +pub const CommandPool = vulkan.CommandPool; +pub const DescriptorPool = vulkan.DescriptorPool; + +// Renderer-policy primitives stay in `src/renderer/vulkan/` (dmabuf +// export, our pipeline + render-pass wiring, frame fence pacing, the +// GLSL→SPIR-V loader). 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 RenderPass = @import("vulkan/RenderPass.zig"); pub const Frame = @import("vulkan/Frame.zig"); -pub const DescriptorPool = @import("vulkan/DescriptorPool.zig"); pub const shaders = @import("vulkan/shaders.zig"); const bufferpkg = @import("vulkan/buffer.zig"); @@ -370,7 +388,7 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Vulkan { else => @compileError("unsupported app runtime for Vulkan (embedded-only)"), apprt.embedded => switch (opts.rt_surface.platform) { .vulkan => |platform| { - device = try Device.init(alloc, platform); + device = try Device.init(alloc, try bootstrapFromPlatform(platform)); log.info( "Vulkan device ready (api=0x{x})", .{device.?.api_version}, @@ -547,11 +565,13 @@ pub fn initTarget(self: *const Vulkan, width: usize, height: usize) !Target { // concern only. // // Per-surface platform: pulled from rt_surface so the `present` - // callback's `userdata` points at THIS surface's window. The - // process-global Device has its own `platform` copy from - // whichever surface first initialized it; splits and tabs would - // otherwise route their dmabuf frames to the wrong window. - const platform = surfacePlatform(self.rt_surface); + // callback's `userdata` points at THIS surface's window. Splits + // and tabs share the process-wide Device but each owns its own + // platform copy — without per-surface routing here, all dmabuf + // frames would funnel through whichever surface initialized the + // device first. + const platform = surfacePlatform(self.rt_surface) orelse + return error.UnsupportedPlatform; return try Target.init(.{ .device = devicePtr(), .format = vk.VK_FORMAT_B8G8R8A8_SRGB, @@ -561,9 +581,44 @@ pub fn initTarget(self: *const Vulkan, width: usize, height: usize) !Target { }); } +/// Translate the apprt's `Platform.Vulkan` callback struct into the +/// neutral `Device.HostBootstrap` the binding expects. Resolves the +/// host's handles + the root proc-addr resolver up-front so the +/// binding stays free of any apprt type. Any null host handle -> +/// `error.HostHandleMissing`. +fn bootstrapFromPlatform( + platform: apprt.embedded.Platform.Vulkan, +) Device.Error!Device.HostBootstrap { + const instance_handle = platform.instance(platform.userdata) orelse + return error.HostHandleMissing; + const physical_device_handle = platform.physical_device(platform.userdata) orelse + return error.HostHandleMissing; + const device_handle = platform.device(platform.userdata) orelse + return error.HostHandleMissing; + const queue_handle = platform.queue(platform.userdata) orelse + return error.HostHandleMissing; + const get_instance_proc_addr_raw = platform.get_instance_proc_addr( + platform.userdata, + "vkGetInstanceProcAddr", + ) orelse return error.HostHandleMissing; + + return .{ + .instance = @ptrCast(instance_handle), + .physical_device = @ptrCast(physical_device_handle), + .device = @ptrCast(device_handle), + .queue = @ptrCast(queue_handle), + .queue_family_index = platform.queue_family_index(platform.userdata), + .get_instance_proc_addr_raw = get_instance_proc_addr_raw, + }; +} + /// Extract the Vulkan platform callbacks from a surface, when the /// surface was created with the Vulkan platform tag. Returns null -/// otherwise (smoke test / OpenGL surfaces). +/// when the surface was tagged with a non-Vulkan platform — the +/// caller is expected to reject the surface with +/// `error.UnsupportedPlatform`. (`Vulkan.init` already does the same +/// reject up-front, so reaching this function with a non-Vulkan +/// platform implies a surface plumbed through after that gate.) fn surfacePlatform(rt_surface: *apprt.Surface) ?apprt.embedded.Platform.Vulkan { // `init()` already gates non-embedded runtimes with a // `@compileError`, so reaching this function on anything other @@ -867,4 +922,3 @@ pub fn initAtlasTexture( null, ); } - diff --git a/src/renderer/vulkan/Frame.zig b/src/renderer/vulkan/Frame.zig index d63dabd6c..d12ba03ee 100644 --- a/src/renderer/vulkan/Frame.zig +++ b/src/renderer/vulkan/Frame.zig @@ -33,11 +33,12 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; -const Device = @import("Device.zig"); +const Device = vulkan.Device; +const DescriptorPool = vulkan.DescriptorPool; const Target = @import("Target.zig"); -const DescriptorPool = @import("DescriptorPool.zig"); const RenderPass = @import("RenderPass.zig"); const Vulkan = @import("../Vulkan.zig"); diff --git a/src/renderer/vulkan/Pipeline.zig b/src/renderer/vulkan/Pipeline.zig index 324b3fdfd..d09746e84 100644 --- a/src/renderer/vulkan/Pipeline.zig +++ b/src/renderer/vulkan/Pipeline.zig @@ -22,10 +22,11 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; -const Device = @import("Device.zig"); -const DescriptorPool = @import("DescriptorPool.zig"); +const Device = vulkan.Device; +const DescriptorPool = vulkan.DescriptorPool; const log = std.log.scoped(.vulkan); diff --git a/src/renderer/vulkan/README.md b/src/renderer/vulkan/README.md index c6b816986..17e031850 100644 --- a/src/renderer/vulkan/README.md +++ b/src/renderer/vulkan/README.md @@ -1,37 +1,39 @@ -# Vulkan renderer backend (fork-only, in progress) +# Vulkan renderer backend -This directory will hold the Vulkan analogues of the per-backend -files that live in `../opengl/` and `../metal/`: +This directory holds the **renderer-policy** Vulkan files for libghostty. +Pure Vulkan-API wrappers (Device dispatch table, Sampler, CommandPool, +DescriptorPool) live in `pkg/vulkan/`, mirroring how `pkg/opengl/` +relates to `src/renderer/opengl/`. -| File | Counterpart in `../opengl/` | Notes | -| -------------- | ----------------------------------- | ------------------------------------------------------------------ | -| `buffer.zig` | `opengl/buffer.zig` | Vertex / uniform buffers backed by `VkBuffer` + `VkDeviceMemory`. | -| `Pipeline.zig` | `opengl/Pipeline.zig` | Graphics pipeline + descriptor set layout creation. | -| `RenderPass.zig` | `opengl/RenderPass.zig` | `VkRenderPass` + framebuffer setup for the cell-bg / text passes. | -| `Sampler.zig` | `opengl/Sampler.zig` | `VkSampler` (linear for atlases, nearest for cells). | -| `Target.zig` | `opengl/Target.zig` | Render target image + view (exportable for dmabuf handoff). | -| `Texture.zig` | `opengl/Texture.zig` | `VkImage` + `VkImageView` + upload helpers for the glyph atlas. | -| `Frame.zig` | `opengl/Frame.zig` | Per-frame command buffer + sync primitives (semaphores / fences). | -| `shaders.zig` | `opengl/shaders.zig` | Loader for the SPIR-V blobs (built at compile time via glslang). | +## File layout -The renderer's top-level lives one directory up at -`../Vulkan.zig` and is the single module imported by -`src/renderer.zig` when `build_config.renderer == .vulkan`. That file -currently fails at comptime with a pointer back to the -`qt-vulkan-renderer` branch — see its header comment for the full -contract `GenericRenderer(Vulkan)` expects this directory's modules -to satisfy. +Renderer policy (this directory): -## Binding +| File | OpenGL counterpart | Notes | +| ------------------- | ------------------------- | ------------------------------------------------------------------ | +| `Target.zig` | `opengl/Target.zig` | Render image + dmabuf export (direct or legacy_copy mode). | +| `Texture.zig` | `opengl/Texture.zig` | `VkImage` + `VkImageView` + upload helpers for the glyph atlas. | +| `buffer.zig` | `opengl/buffer.zig` | `Buffer(T)` host-coherent + per-renderer-thread recycle pool. | +| `Pipeline.zig` | `opengl/Pipeline.zig` | Graphics pipeline + descriptor set layout creation. | +| `RenderPass.zig` | `opengl/RenderPass.zig` | Dynamic-rendering pass + step recorder. | +| `Frame.zig` | `opengl/Frame.zig` | Per-draw command buffer + fence-paced submit-then-wait. | +| `shaders.zig` | `opengl/shaders.zig` | GLSL → SPIR-V via glslang + the OpenGL-GLSL → Vulkan-GLSL rewrite. | -The Vulkan C API ships as the `vulkan` Zig module from `pkg/vulkan/` -(thin `@cImport` of the system `vulkan/vulkan.h`). It is registered -in `build.zig.zon` as a lazy dependency and only pulled in when -`-Drenderer=vulkan` is selected, at which point `libvulkan` is also -linked (see `src/build/SharedDeps.zig`). The system needs -`vulkan-headers` (`/usr/include/vulkan/vulkan.h`) and `libvulkan.so` -present — both are stock on every Linux distro and already required -by the Qt RHI side of the renderer. +Pure Vulkan-API wrappers (in `pkg/vulkan/`): + +| File | OpenGL counterpart | Notes | +| --------------------- | ------------------------ | ------------------------------------------------------------------ | +| `Device.zig` | (no analogue — GL ctx) | Host-provided VkInstance/Device/Queue + function dispatch table. | +| `Sampler.zig` | `pkg/opengl/Sampler.zig` | `VkSampler` (linear for atlases, nearest for cells). | +| `CommandPool.zig` | (none) | `VkCommandPool` + one-shot record/submit helper. | +| `DescriptorPool.zig` | (none) | Per-frame `VkDescriptorPool`. | + +The renderer's top-level lives one directory up at `../Vulkan.zig` +and is the single module imported by `src/renderer.zig` when +`build_config.renderer == .vulkan`. It re-exports the `pkg/vulkan/` +types as `Vulkan.Device`, `Vulkan.Sampler`, etc., so call sites use a +single `Vulkan.*` namespace regardless of where each type physically +lives. ## Why dmabuf, not Vulkan swapchains? @@ -39,8 +41,7 @@ The Qt frontend wants to keep `GhosttySurface` as a `QWidget` so that splits (`QSplitter`), tabs (`QTabWidget`), and translucent composition keep working. That rules out `QVulkanWindow`. Instead libghostty exports the rendered `VkImage` memory as a dmabuf fd -(`VK_KHR_external_memory_fd`); the Qt side imports it as a -`QRhiTexture` in a `QRhiWidget` and composites it like any other -GPU-backed widget. This gives us Vulkan GPU rendering without losing -the widget tree — the path 3 ("zero-copy GPU interop") described in -the session-log on the `qt-vulkan-renderer` branch. +(`VK_KHR_external_memory_fd` + `VK_EXT_image_drm_format_modifier`); the +Qt side imports it via `zwp_linux_dmabuf_v1` and attaches it to a +`wl_subsurface` parented to the top-level `wl_surface`. The compositor +scans the buffer out directly — no readback, no QImage round trip. diff --git a/src/renderer/vulkan/RenderPass.zig b/src/renderer/vulkan/RenderPass.zig index 5c68b1600..7e149cd3e 100644 --- a/src/renderer/vulkan/RenderPass.zig +++ b/src/renderer/vulkan/RenderPass.zig @@ -16,12 +16,13 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; -const DescriptorPool = @import("DescriptorPool.zig"); -const Device = @import("Device.zig"); +const Device = vulkan.Device; +const DescriptorPool = vulkan.DescriptorPool; +const Sampler = vulkan.Sampler; const Pipeline = @import("Pipeline.zig"); -const Sampler = @import("Sampler.zig"); const Target = @import("Target.zig"); const Texture = @import("Texture.zig"); const bufferpkg = @import("buffer.zig"); @@ -175,9 +176,7 @@ pub fn begin(opts: Options) Self { if (opts.attachments.len == 0) return self; const attach = opts.attachments[0]; - const view: vk.VkImageView, const image: vk.VkImage, - const width: u32, const height: u32, - const old_layout: vk.VkImageLayout = switch (attach.target) { + const view: vk.VkImageView, const image: vk.VkImage, const width: u32, const height: u32, const old_layout: vk.VkImageLayout = switch (attach.target) { .texture => |t| .{ t.view, t.image, @intCast(t.width), @intCast(t.height), t.layout }, .target => |t| .{ t.view, t.image, t.width, t.height, t.layout }, }; @@ -256,9 +255,12 @@ pub fn begin(opts: Options) Self { src_stage, vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, - 0, null, - 0, null, - 1, &barrier, + 0, + null, + 0, + null, + 1, + &barrier, ); } @@ -650,9 +652,12 @@ pub fn complete(self: *const Self) void { vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, dst_stage, 0, - 0, null, - 0, null, - 1, &barrier, + 0, + null, + 0, + null, + 1, + &barrier, ); } diff --git a/src/renderer/vulkan/Target.zig b/src/renderer/vulkan/Target.zig index 0a554a6b3..5a379871e 100644 --- a/src/renderer/vulkan/Target.zig +++ b/src/renderer/vulkan/Target.zig @@ -47,7 +47,7 @@ const std = @import("std"); const vk = @import("vulkan").c; const apprt = @import("../../apprt.zig"); -const Device = @import("Device.zig"); +const Device = @import("vulkan").Device; const log = std.log.scoped(.vulkan); @@ -87,14 +87,13 @@ pub const Options = struct { /// TRANSFER_SRC_BIT`). Rarely needed. extra_usage: vk.VkImageUsageFlags = 0, - /// Per-surface platform callbacks. `Device.platform` is also a - /// `Platform.Vulkan`, but it's the singleton's copy — its - /// `userdata` points at whichever surface initialized the - /// device first. Splits/tabs share the device but each gets its - /// own platform with the right `userdata`, so `present()` reaches - /// the right window. Falls back to `device.platform` when - /// null (e.g. smoke test). - platform: ?apprt.embedded.Platform.Vulkan = null, + /// Per-surface platform callbacks. The host's process-wide + /// VkDevice is shared across splits/tabs, but each surface gets + /// its own platform copy with the right `userdata`, so + /// `present()` reaches the right window — and `pickModifier` + /// asks the right host (compositor and host can in principle + /// differ across surfaces, e.g. mixed-DPI multi-screen). + platform: apprt.embedded.Platform.Vulkan, }; pub const Error = error{ @@ -105,9 +104,8 @@ pub const Error = error{ device: *const Device, -/// Per-surface platform — see `Options.platform`. Null means "use -/// `device.platform`" (the singleton's copy from the first surface). -platform: ?apprt.embedded.Platform.Vulkan = null, +/// Per-surface platform — see `Options.platform`. +platform: apprt.embedded.Platform.Vulkan, /// Which present strategy this target uses. Decides whether /// `recordPresentBarrier` emits a copy. @@ -148,7 +146,7 @@ pub fn init(opts: Options) Error!Self { vk.VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | vk.VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; - const picked = try pickModifier(dev, opts.format, drm_format, required_features); + const picked = try pickModifier(dev, opts.platform, opts.format, drm_format, required_features); if (picked) |m| { const tag: []const u8 = if (m == DRM_FORMAT_MOD_LINEAR) "LINEAR" @@ -187,6 +185,7 @@ pub fn init(opts: Options) Error!Self { /// COLOR_ATTACHMENT for every modifier). fn pickModifier( dev: *const Device, + platform: apprt.embedded.Platform.Vulkan, format: vk.VkFormat, drm_format: u32, required_features: vk.VkFormatFeatureFlags, @@ -198,8 +197,8 @@ fn pickModifier( // work for AMD/Intel LINEAR but the compositor attach would // fail, so treat it as "no intersection." var host_mods: [MAX_MODIFIERS]u64 = undefined; - const host_returned = dev.platform.get_supported_modifiers( - dev.platform.userdata, + const host_returned = platform.get_supported_modifiers( + platform.userdata, drm_format, &host_mods, MAX_MODIFIERS, @@ -763,9 +762,12 @@ fn recordDirectBarrier(self: *Self, cb: vk.VkCommandBuffer) void { vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, vk.VK_PIPELINE_STAGE_HOST_BIT, 0, - 0, null, - 0, null, - 1, &img_barrier, + 0, + null, + 0, + null, + 1, + &img_barrier, ); self.layout = vk.VK_IMAGE_LAYOUT_GENERAL; @@ -800,9 +802,12 @@ fn recordCopyToDmabuf(self: *Self, cb: vk.VkCommandBuffer) void { vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, vk.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, - 0, null, - 0, null, - 1, &img_barrier, + 0, + null, + 0, + null, + 1, + &img_barrier, ); // Copy image → buffer. BGRA8, packed (stride = width*4). @@ -849,9 +854,12 @@ fn recordCopyToDmabuf(self: *Self, cb: vk.VkCommandBuffer) void { vk.VK_PIPELINE_STAGE_TRANSFER_BIT, vk.VK_PIPELINE_STAGE_HOST_BIT, 0, - 0, null, - 1, &buf_barrier, - 0, null, + 0, + null, + 1, + &buf_barrier, + 0, + null, ); // Track the new image layout so the next frame's RenderPass.begin @@ -861,11 +869,9 @@ fn recordCopyToDmabuf(self: *Self, cb: vk.VkCommandBuffer) void { } pub fn present(self: *const Self) void { - // Prefer the per-surface platform — its `userdata` points at THIS - // surface's GhosttySurface, so present reaches the right window. - // Fall back to the device's singleton copy when no platform was - // attached (only the smoke test does this). - const platform = if (self.platform) |p| p else self.device.platform; + // Per-surface platform — its `userdata` points at THIS surface's + // GhosttySurface, so present reaches the right window. + const platform = self.platform; // `image_backed` is the host's signal that this fd is importable // by a 2D-image consumer (Wayland linux-dmabuf-v1, Vulkan // external image, etc.). True in `.direct` mode where the fd was diff --git a/src/renderer/vulkan/Texture.zig b/src/renderer/vulkan/Texture.zig index 366e1a963..011fe5786 100644 --- a/src/renderer/vulkan/Texture.zig +++ b/src/renderer/vulkan/Texture.zig @@ -27,10 +27,11 @@ const Self = @This(); const std = @import("std"); -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; -const Device = @import("Device.zig"); -const CommandPool = @import("CommandPool.zig"); +const Device = vulkan.Device; +const CommandPool = vulkan.CommandPool; const bufferpkg = @import("buffer.zig"); const log = std.log.scoped(.vulkan); @@ -278,8 +279,7 @@ pub fn replaceRegion( else => 0, }; const src_stage: vk.VkPipelineStageFlags = switch (old_layout) { - vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL => - vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL => vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, else => vk.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, }; { @@ -306,9 +306,12 @@ pub fn replaceRegion( src_stage, vk.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, // dependencyFlags - 0, null, // memory barriers - 0, null, // buffer memory barriers - 1, &barrier, + 0, + null, // memory barriers + 0, + null, // buffer memory barriers + 1, + &barrier, ); } @@ -370,9 +373,12 @@ pub fn replaceRegion( vk.VK_PIPELINE_STAGE_TRANSFER_BIT, vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, - 0, null, - 0, null, - 1, &barrier, + 0, + null, + 0, + null, + 1, + &barrier, ); } diff --git a/src/renderer/vulkan/buffer.zig b/src/renderer/vulkan/buffer.zig index 0e29da584..233d126d3 100644 --- a/src/renderer/vulkan/buffer.zig +++ b/src/renderer/vulkan/buffer.zig @@ -23,9 +23,10 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; -const Device = @import("Device.zig"); +const Device = vulkan.Device; const log = std.log.scoped(.vulkan); diff --git a/src/renderer/vulkan/shaders.zig b/src/renderer/vulkan/shaders.zig index bab1b9cce..ed2867b73 100644 --- a/src/renderer/vulkan/shaders.zig +++ b/src/renderer/vulkan/shaders.zig @@ -20,13 +20,14 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const vk = @import("vulkan").c; +const vulkan = @import("vulkan"); +const vk = vulkan.c; const glslang = @import("glslang"); -const Device = @import("Device.zig"); +const Device = vulkan.Device; +const Sampler = vulkan.Sampler; +const DescriptorPool = vulkan.DescriptorPool; const Pipeline = @import("Pipeline.zig"); -const Sampler = @import("Sampler.zig"); -const DescriptorPool = @import("DescriptorPool.zig"); const math = @import("../../math.zig"); const log = std.log.scoped(.vulkan); @@ -817,7 +818,6 @@ pub const Shaders = struct { /// linear sampling, clamp-to-edge — the standard 2D mode. image_sampler: ?Sampler = null, - defunct: bool = false, /// The compiled `VkShaderModule`s for the renderer's built-in @@ -838,7 +838,7 @@ pub const Shaders = struct { pub fn init( alloc: Allocator, - device: *const @import("Device.zig"), + device: *const Device, // SPIR-V binaries (4-byte-aligned) from // `shadertoy.loadFromFiles` with `target = .spv`. The Vulkan // backend bypasses the spirv-cross GLSL roundtrip the other @@ -1366,7 +1366,7 @@ pub const Shaders = struct { /// (Globals UBO, bg_cells SSBO, individual sampler) so a helper /// keeps the call sites short. fn createSingleBindingDsl( - device: *const @import("Device.zig"), + device: *const Device, binding: u32, descriptor_type: vk.VkDescriptorType, stage_flags: vk.VkShaderStageFlags,