662 lines
27 KiB
Zig
662 lines
27 KiB
Zig
//! Vulkan graphics API for libghostty's `GenericRenderer`. Active
|
|
//! on `-Drenderer=vulkan` builds; the host (e.g. the Qt frontend)
|
|
//! supplies a VkInstance / VkDevice / VkQueue via the
|
|
//! `ghostty_platform_vulkan_s` C ABI, libghostty drives all
|
|
//! pipeline / image / command-buffer work against those handles,
|
|
//! and rendered frames go back to the host as dmabuf fds for
|
|
//! zero-copy compositing.
|
|
//!
|
|
//! Per-frame model: fence-paced submit-then-wait (one frame in
|
|
//! flight), `Target` is the dmabuf-exportable render image,
|
|
//! `Frame.complete` waits on the fence before handing the fd to
|
|
//! the platform `present` callback.
|
|
//!
|
|
//! 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.
|
|
//! - `buffer_pool.zig` — cross-frame VkBuffer recycle pool
|
|
//! (per-thread pending, shared ready).
|
|
//! - `ThreadState.zig` — per-renderer-thread frame fence /
|
|
//! command buffer / step pool / last-target.
|
|
//! - `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 vulkan = @import("vulkan");
|
|
const vk = vulkan.c;
|
|
|
|
const apprt = @import("../apprt.zig");
|
|
const configpkg = @import("../config.zig");
|
|
const font = @import("../font/main.zig");
|
|
const rendererpkg = @import("../renderer.zig");
|
|
const shadertoy = @import("shadertoy.zig");
|
|
|
|
pub const GraphicsAPI = Vulkan;
|
|
// 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 Pipeline = @import("vulkan/Pipeline.zig");
|
|
pub const RenderPass = @import("vulkan/RenderPass.zig");
|
|
pub const Frame = @import("vulkan/Frame.zig");
|
|
pub const shaders = @import("vulkan/shaders.zig");
|
|
|
|
const bufferpkg = @import("vulkan/buffer.zig");
|
|
pub const Buffer = bufferpkg.Buffer;
|
|
|
|
// ---- comptime contract --------------------------------------------------
|
|
|
|
/// Custom user shaders compile to SPIR-V directly — skip the
|
|
/// GLSL → SPIR-V → GLSL roundtrip that `.glsl` would do. The
|
|
/// roundtrip exists for backends that consume GLSL (OpenGL, Metal
|
|
/// via MSL), but Vulkan ingests SPIR-V natively and we already have
|
|
/// a glslang shim for the renderer's built-in shaders. Bypassing
|
|
/// the roundtrip halves the per-shader compile cost AND avoids the
|
|
/// spirv-cross-emitted main() losing the upstream `gl_FragCoord.xy`
|
|
/// pattern we hook for the Y-flip fix.
|
|
pub const custom_shader_target: shadertoy.Target = .spv;
|
|
|
|
/// Custom shaders ARE now supported on the Vulkan backend.
|
|
/// `shaders.Shaders.init` builds one post pipeline per user shader
|
|
/// (UBO at set 0 binding 1, iChannel0 sampler at set 1 binding 0,
|
|
/// matching `shadertoy_prefix.glsl` after `vulkanizeGlsl` rewrites
|
|
/// the layouts). The renderer's post pass at the end of `drawFrame`
|
|
/// chains them — first pipeline samples `back_texture` and writes
|
|
/// `front_texture`, swap, repeat; the last one writes
|
|
/// `frame.target` instead.
|
|
pub const supports_custom_shaders: bool = true;
|
|
|
|
/// Vulkan's clip-space Y axis points down (unlike OpenGL).
|
|
pub const custom_shader_y_is_down = true;
|
|
|
|
/// Extra `#define` lines `shadertoy.loadFromFile` injects into the
|
|
/// prefix between `#version` and the rest. `GHASTTY_VULKAN`
|
|
/// activates the Vulkan-side `gl_FragCoord` flip + `texture()`
|
|
/// upper-left wrap so `mainImage` sees shadertoy-convention coords
|
|
/// even though Vulkan rasterizes Y-down. OpenGL/MSL backends omit
|
|
/// this decl entirely and pass `&.{}` from `generic.zig`.
|
|
pub const custom_shader_extra_defines: []const []const u8 = &.{"GHASTTY_VULKAN 1"};
|
|
|
|
/// GLSL → GLSL rewriter `shadertoy.loadFromFile` runs after the
|
|
/// prefix splice and before the SPIR-V compile. Plugs the
|
|
/// `vulkanizeGlsl` pass that rewrites `layout(binding = N)` into
|
|
/// `layout(set = S, binding = N)` so the resulting SPIR-V matches
|
|
/// the renderer's multi-set descriptor layout. Without this, the
|
|
/// shader's `iChannel0` lands at set 0 binding 0 while the post
|
|
/// pipeline binds it at set 1 binding 0 → sampler returns garbage.
|
|
pub const rewriteCustomShaderSource = shaders.vulkanizeGlsl;
|
|
|
|
/// Single-buffered for v1; fence-paced submit-then-wait means there's
|
|
/// only ever one frame in flight.
|
|
pub const swap_chain_count = 1;
|
|
|
|
const log = std.log.scoped(.vulkan);
|
|
|
|
// ---- per-surface state --------------------------------------------------
|
|
|
|
alloc: Allocator,
|
|
blending: configpkg.Config.AlphaBlending,
|
|
rt_surface: *apprt.Surface,
|
|
|
|
/// Process-wide Vulkan device. The host owns one VkDevice shared
|
|
/// across every surface, so we mirror that as a single global slot
|
|
/// (not threadlocal — the renderer thread is distinct from the main
|
|
/// thread that constructs the surface, and threadlocal doesn't
|
|
/// survive that boundary).
|
|
///
|
|
/// Initialized in `Vulkan.init` on the surface-construction thread;
|
|
/// read by every other thread via `devicePtr` after that. The renderer
|
|
/// holds `*const Vulkan` from `generic.zig` so we can't mutate fields
|
|
/// on the value — same reason OpenGL uses a `threadlocal var gl_host`
|
|
/// (though OpenGL gets away with threadlocal because the OpenGL
|
|
/// platform callbacks are read on the same thread that set them).
|
|
var device: ?Device = null;
|
|
|
|
/// Refcount of live `Vulkan` renderer instances that share `device`.
|
|
/// Each `init` increments; each `deinit` decrements. The device is
|
|
/// only torn down when the count returns to 0, so closing one tab
|
|
/// (or one split) doesn't yank the VkDevice out from under the
|
|
/// surfaces still running in other tabs. Process-wide (matches
|
|
/// `device`'s scope). Mutated under `device_mutex` because
|
|
/// surfaces' renderer threads run independently and may init/deinit
|
|
/// concurrently.
|
|
var device_refcount: usize = 0;
|
|
var device_mutex: std.Thread.Mutex = .{};
|
|
|
|
/// Cross-frame buffer recycle pool. See `vulkan/buffer_pool.zig`
|
|
/// for the full lifecycle / multi-thread contract. Re-exported so
|
|
/// existing callers (`Vulkan.buffer_pool.cycle` etc.) keep working
|
|
/// unchanged.
|
|
pub const buffer_pool = @import("vulkan/buffer_pool.zig");
|
|
|
|
/// Per-renderer-thread state (frame command buffer, fence, descriptor
|
|
/// pool, last-target pointer). See `vulkan/ThreadState.zig` for the
|
|
/// lifecycle.
|
|
const ThreadState = @import("vulkan/ThreadState.zig");
|
|
|
|
// ---- lifecycle ----------------------------------------------------------
|
|
|
|
pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Vulkan {
|
|
// Vulkan needs the device populated before the renderer's
|
|
// `FrameState.init` starts asking for buffer/texture options.
|
|
// Process-wide (not threadlocal): the renderer thread is
|
|
// distinct from the main thread that constructs the surface.
|
|
device_mutex.lock();
|
|
defer device_mutex.unlock();
|
|
if (device == null) {
|
|
switch (apprt.runtime) {
|
|
// The Vulkan renderer is embedded-only by design: the
|
|
// host owns the VkInstance/Device/Queue and hands them
|
|
// to libghostty via `ghostty_platform_vulkan_s`. There
|
|
// is no Vulkan path through the GTK apprt and never
|
|
// will be from this side. Compile-error any other
|
|
// runtime so a misconfigured `-Drenderer=vulkan
|
|
// -Dapp-runtime=gtk` build fails loudly at compile time
|
|
// instead of crashing at first surface init. Mirrors
|
|
// OpenGL.zig's `@compileError("unsupported app
|
|
// runtime for OpenGL")` pattern.
|
|
else => @compileError("unsupported app runtime for Vulkan (embedded-only)"),
|
|
apprt.embedded => switch (opts.rt_surface.platform) {
|
|
.vulkan => |platform| {
|
|
device = try Device.init(alloc, try bootstrapFromPlatform(platform));
|
|
log.info(
|
|
"Vulkan device ready (api=0x{x})",
|
|
.{device.?.api_version},
|
|
);
|
|
},
|
|
// The Platform union is decided at host-call time
|
|
// (the C ABI lets the host pick), so this arm
|
|
// really is a runtime check — the host plugged us
|
|
// into a non-Vulkan surface.
|
|
.opengl, .macos, .ios => return error.UnsupportedPlatform,
|
|
},
|
|
}
|
|
}
|
|
device_refcount += 1;
|
|
return .{
|
|
.alloc = alloc,
|
|
.blending = opts.config.blending,
|
|
.rt_surface = opts.rt_surface,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Vulkan) void {
|
|
// ThreadState.cleanup is NOT called here — it runs in
|
|
// `threadExit` on the renderer thread, which is where the
|
|
// `threadlocal var` state was populated. Calling it here would
|
|
// read the GUI thread's empty TLS and silently leak everything.
|
|
// See the comment in `threadExit` for the full rationale.
|
|
|
|
// Decrement the shared-device refcount; only the last surface
|
|
// to deinit gets to destroy the VkDevice. Closing one of N tabs
|
|
// must NOT pull the device out from under the others — that
|
|
// crashes (or invisibly silences) every other surface's
|
|
// renderer thread.
|
|
{
|
|
device_mutex.lock();
|
|
defer device_mutex.unlock();
|
|
// Refcount-underflow guard. Was `std.debug.assert(refcount > 0)`,
|
|
// but assertions compile out in ReleaseFast / ReleaseSmall — a
|
|
// double-deinit would silently underflow the unsigned counter
|
|
// to a huge value, blocking the device tear-down forever (the
|
|
// refcount==0 branch below would never trigger). Hard-log
|
|
// even in release: a stale deinit is a contract violation
|
|
// we'd rather surface than mask. We still poison `self` at
|
|
// function exit so the caller sees consistent UB on either
|
|
// path.
|
|
if (device_refcount == 0) {
|
|
log.err("Vulkan.deinit: refcount underflow — double-deinit?", .{});
|
|
} else {
|
|
device_refcount -= 1;
|
|
if (device_refcount == 0) {
|
|
// Last surface: NOW we can safely drain the shared
|
|
// `ready` list of the buffer pool and tear the device
|
|
// down. The waitIdle is needed because non-final
|
|
// deinits skipped it. Each surface's deinit already
|
|
// drained its own per-thread `pending` (via
|
|
// buffer_pool.drainSelf above), so this path only
|
|
// needs to handle the cross-thread `ready`.
|
|
if (device) |*d| {
|
|
d.waitIdle();
|
|
buffer_pool.drainShared(d);
|
|
d.deinit();
|
|
}
|
|
device = null;
|
|
}
|
|
}
|
|
}
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Early per-surface setup hook. No-op for Vulkan: the host
|
|
/// hasn't finished installing the platform callbacks at this
|
|
/// point, so all device wiring waits until `Vulkan.init` (which
|
|
/// runs after the platform is plumbed through `opts`).
|
|
pub fn surfaceInit(surface: *apprt.Surface) !void {
|
|
_ = surface;
|
|
}
|
|
|
|
/// Main-thread setup just before the renderer thread spins up.
|
|
/// No-op: device construction happens in `Vulkan.init` (the
|
|
/// renderer's FrameState init path calls option getters before
|
|
/// `threadEnter`, and those getters need the device — so it has
|
|
/// to be ready earlier than OpenGL needs it to be).
|
|
pub fn finalizeSurfaceInit(self: *const Vulkan, surface: *apprt.Surface) !void {
|
|
_ = self;
|
|
_ = surface;
|
|
}
|
|
|
|
pub fn threadEnter(self: *const Vulkan, surface: *apprt.Surface) !void {
|
|
_ = self;
|
|
_ = surface;
|
|
// No-op: device is brought up in `init` (the renderer's
|
|
// FrameState init path calls option getters before threadEnter
|
|
// and those need the device). Decl kept so
|
|
// `@hasDecl(GraphicsAPI, "threadEnter")` still resolves true in
|
|
// `generic.zig`.
|
|
}
|
|
|
|
pub fn threadExit(self: *const Vulkan) void {
|
|
_ = self;
|
|
if (device) |*d| {
|
|
// ThreadState.cleanup MUST run here, on the renderer thread,
|
|
// not in Vulkan.deinit (which runs on the GUI thread AFTER
|
|
// the renderer thread has joined — see Surface.deinit). Our
|
|
// per-thread Vulkan state lives in `threadlocal var` slots
|
|
// populated on this thread; calling cleanup from the GUI
|
|
// thread reads the GUI thread's empty TLS, the destroys
|
|
// no-op, and the per-tab DescriptorPool / VkCommandBuffer /
|
|
// VkFence + buffer_pool pending list leak forever. heaptrack
|
|
// on a 20-tab open+close session attributed ~6 MB / 42 calls
|
|
// of NVIDIA driver-internal state to exactly this:
|
|
// DescriptorPool.init → ThreadState.ensureInit pages that
|
|
// nothing ever released.
|
|
//
|
|
// Cleanup needs the device alive: refcount stays > 0 until
|
|
// Vulkan.deinit decrements it on the GUI thread, so the
|
|
// shared VkDevice is still valid here.
|
|
ThreadState.cleanup(d);
|
|
// waitIdle was the pre-fix behavior — keep it as belt-and-
|
|
// suspenders for any non-ThreadState in-flight work this
|
|
// thread may have submitted via the shared queue.
|
|
d.waitIdle();
|
|
}
|
|
}
|
|
|
|
pub fn displayRealized(self: *Vulkan) void {
|
|
_ = self;
|
|
}
|
|
|
|
pub fn displayUnrealized(self: *Vulkan) void {
|
|
_ = self;
|
|
}
|
|
|
|
pub fn drawFrameStart(self: *Vulkan) void {
|
|
_ = self;
|
|
}
|
|
|
|
pub fn drawFrameEnd(self: *Vulkan) void {
|
|
_ = self;
|
|
}
|
|
|
|
pub fn initShaders(
|
|
self: *const Vulkan,
|
|
alloc: Allocator,
|
|
/// For Vulkan these are SPIR-V binaries (loaded with
|
|
/// `shadertoy.Target = .spv`), not GLSL strings — see
|
|
/// `custom_shader_target` above.
|
|
custom_shaders: []const []const u8,
|
|
) !shaders.Shaders {
|
|
_ = self;
|
|
return try shaders.Shaders.init(alloc, devicePtr(), custom_shaders);
|
|
}
|
|
|
|
pub fn initTarget(self: *const Vulkan, width: usize, height: usize) !Target {
|
|
// SRGB format so the hardware gamma-encodes the linear premultiplied
|
|
// shader output at framebuffer-write time. The renderer's shaders
|
|
// produce linear premultiplied alpha; without an sRGB format the
|
|
// bytes in memory would be linear and Qt (which expects sRGB
|
|
// premultiplied) would render them as if they were already gamma
|
|
// encoded — colors would look way too dark. The DRM fourcc the
|
|
// host sees is still ARGB8888; SRGB encoding is a Vulkan-side
|
|
// concern only.
|
|
//
|
|
// Per-surface platform: pulled from rt_surface so the `present`
|
|
// 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,
|
|
.width = @intCast(width),
|
|
.height = @intCast(height),
|
|
.platform = platform,
|
|
});
|
|
}
|
|
|
|
/// 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
|
|
/// 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
|
|
// than `apprt.embedded` is impossible. Direct embedded match
|
|
// here keeps the function single-arm.
|
|
if (apprt.runtime != apprt.embedded)
|
|
@compileError("unsupported app runtime for Vulkan (embedded-only)");
|
|
return switch (rt_surface.platform) {
|
|
.vulkan => |p| p,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
pub fn surfaceSize(self: *const Vulkan) !struct { width: u32, height: u32 } {
|
|
const size = self.rt_surface.size;
|
|
return .{ .width = size.width, .height = size.height };
|
|
}
|
|
|
|
pub fn present(self: *Vulkan, target: *Target) !void {
|
|
_ = self;
|
|
// The target is already populated by the time we get here:
|
|
// `Frame.complete` ended the command buffer, submitted with the
|
|
// fence, and waited for the GPU to finish before returning. So
|
|
// the dmabuf fd is safe to hand off.
|
|
target.present();
|
|
// Remember the target's address so `presentLastTarget` can
|
|
// re-present it on no-op frames. We store the pointer — not a
|
|
// value copy — so a subsequent `frame.resize` (which destroys
|
|
// the old Target and overwrites the FrameState's slot with a
|
|
// new one) is transparently followed. A value copy would leave
|
|
// us holding a closed fd and freed VkImage handles.
|
|
ThreadState.last_target = target;
|
|
}
|
|
|
|
pub fn presentLastTarget(self: *Vulkan) !void {
|
|
if (ThreadState.last_target) |t| try self.present(t);
|
|
}
|
|
|
|
pub fn beginFrame(
|
|
self: *const Vulkan,
|
|
renderer: *rendererpkg.Renderer,
|
|
target: *Target,
|
|
) !Frame {
|
|
_ = self;
|
|
const dev = devicePtr();
|
|
|
|
// Lazy per-thread resource init (no-op after the first frame on
|
|
// this thread). Sets up the command pool + buffer + fence +
|
|
// descriptor pool that get reused for every subsequent frame.
|
|
try ThreadState.ensureInit(dev);
|
|
|
|
// Reset this frame's per-frame state. The fence is the load-
|
|
// bearing piece for tear-down correctness: any error path that
|
|
// could leave the fence in an UNSIGNALED-with-no-pending-submit
|
|
// state will hang the next `Vulkan.deinit` on
|
|
// `waitForFences(UINT64_MAX)`.
|
|
//
|
|
// Defense: register the re-signal `errdefer` BEFORE the
|
|
// `beginFrameReset` call (which is the one that calls
|
|
// `vkResetFences`). If any reset fails, the errdefer fires
|
|
// an empty submit with this fence as the signal target,
|
|
// restoring the signaled state.
|
|
errdefer {
|
|
// Empty submit with this fence as the signal target is the
|
|
// simplest portable way to push it back to signaled without
|
|
// recording any commands. The fence in this errdefer can
|
|
// be in any of three states:
|
|
// 1. Reset by `beginFrameReset` (the failing path). The
|
|
// empty submit signals it cleanly.
|
|
// 2. Still in its prior-frame state (the resetFences call
|
|
// failed — spec says the fence is in an undefined
|
|
// state). The empty submit re-signals once any prior
|
|
// pending submit on the queue retires; queueSubmit
|
|
// spec semantics guarantee the fence is signaled
|
|
// after all earlier submits complete.
|
|
// 3. Driver-lost on DEVICE_LOST. queueSubmit returns
|
|
// DEVICE_LOST too; we fall back to deviceWaitIdle.
|
|
// The fallback `vkDeviceWaitIdle` is the actual safety net
|
|
// — without one of those signaling paths succeeding, the
|
|
// next `Vulkan.deinit` hangs on `waitForFences(UINT64_MAX)`.
|
|
const empty: vk.VkSubmitInfo = .{
|
|
.sType = vk.VK_STRUCTURE_TYPE_SUBMIT_INFO,
|
|
.pNext = null,
|
|
.waitSemaphoreCount = 0,
|
|
.pWaitSemaphores = null,
|
|
.pWaitDstStageMask = null,
|
|
.commandBufferCount = 0,
|
|
.pCommandBuffers = null,
|
|
.signalSemaphoreCount = 0,
|
|
.pSignalSemaphores = null,
|
|
};
|
|
const sr = dev.queueSubmit(1, &empty, ThreadState.frame_fence);
|
|
if (sr != vk.VK_SUCCESS) {
|
|
log.warn(
|
|
"beginFrame errdefer: empty queueSubmit failed " ++
|
|
"(result={}); waiting device idle to ensure the fence " ++
|
|
"doesn't hang the next deinit",
|
|
.{sr},
|
|
);
|
|
_ = dev.dispatch.deviceWaitIdle(dev.device);
|
|
}
|
|
}
|
|
try ThreadState.beginFrameReset(dev);
|
|
|
|
return try Frame.begin(
|
|
.{
|
|
.cb = ThreadState.frame_cb,
|
|
.fence = ThreadState.frame_fence,
|
|
.step_pool = if (ThreadState.step_pool) |*p| p else null,
|
|
},
|
|
dev,
|
|
renderer,
|
|
target,
|
|
);
|
|
}
|
|
|
|
// ---- buffer / texture / sampler option getters --------------------------
|
|
//
|
|
// `GenericRenderer` calls these without knowing or caring about Vulkan
|
|
// specifics; the returned `Options` structs are what each backend's
|
|
// resource wrapper expects to be passed back to its `init`. The
|
|
// Vulkan-flavored ones embed a `*const Device` reference plus
|
|
// backend-specific usage flags.
|
|
|
|
inline fn devicePtr() *const Device {
|
|
// Indirected through a getter so future refactors (e.g. allocating
|
|
// `Device` on the heap) don't ripple. Today the device is a
|
|
// process-wide `?Device` populated in `Vulkan.init` BEFORE the
|
|
// renderer's `FrameState.init` calls any of the option getters.
|
|
// A null here means the device construction failed AND someone
|
|
// called an option getter anyway — a programming error, not a
|
|
// runtime condition we can recover from.
|
|
return &(device orelse {
|
|
@panic("Vulkan.devicePtr: device not initialized — option getter called before Vulkan.init succeeded");
|
|
});
|
|
}
|
|
|
|
/// Default buffer options. Vulkan needs an explicit usage bitmask;
|
|
/// callers that want a specific kind override via the per-kind getters
|
|
/// below. (Self is unused — the device comes from the threadlocal.)
|
|
pub fn bufferOptions(_: *const Vulkan) bufferpkg.Options {
|
|
return .{
|
|
.device = devicePtr(),
|
|
.usage = vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
|
|
};
|
|
}
|
|
|
|
pub fn instanceBufferOptions(_: *const Vulkan) bufferpkg.Options {
|
|
return .{
|
|
.device = devicePtr(),
|
|
.usage = vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
|
|
};
|
|
}
|
|
|
|
pub fn uniformBufferOptions(_: *const Vulkan) bufferpkg.Options {
|
|
return .{
|
|
.device = devicePtr(),
|
|
.usage = vk.VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
|
|
};
|
|
}
|
|
|
|
pub fn fgBufferOptions(self: *const Vulkan) bufferpkg.Options {
|
|
return self.instanceBufferOptions();
|
|
}
|
|
|
|
pub fn bgBufferOptions(_: *const Vulkan) bufferpkg.Options {
|
|
// The bg cells buffer is consumed as a STORAGE BUFFER by the
|
|
// cell_bg fragment shader (binding `bg_cells`) and the cell_text
|
|
// vertex shader (same binding). The OpenGL backend doesn't
|
|
// distinguish — every buffer is reusable across roles — but
|
|
// Vulkan validates usage flags at descriptor-write time.
|
|
return .{
|
|
.device = devicePtr(),
|
|
.usage = vk.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT,
|
|
};
|
|
}
|
|
|
|
pub fn imageBufferOptions(self: *const Vulkan) bufferpkg.Options {
|
|
return self.instanceBufferOptions();
|
|
}
|
|
|
|
pub fn bgImageBufferOptions(self: *const Vulkan) bufferpkg.Options {
|
|
return self.instanceBufferOptions();
|
|
}
|
|
|
|
pub fn textureOptions(_: *const Vulkan) Texture.Options {
|
|
// The renderer uses `textureOptions()`-shaped textures both for
|
|
// glyph atlases (sampled-only) AND for the custom-shader
|
|
// back_texture (which is BOTH sampled AND a render target).
|
|
// We hand back the wider usage set so both work. The format
|
|
// matches the renderer's `initTarget` choice
|
|
// (`B8G8R8A8_SRGB`) so a render → sample → render chain
|
|
// through the custom-shader pass keeps the same color format.
|
|
return .{
|
|
.device = devicePtr(),
|
|
.format = vk.VK_FORMAT_B8G8R8A8_SRGB,
|
|
.usage = vk.VK_IMAGE_USAGE_SAMPLED_BIT |
|
|
vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT |
|
|
vk.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
|
|
};
|
|
}
|
|
|
|
pub fn samplerOptions(_: *const Vulkan) Sampler.Options {
|
|
return .{
|
|
.device = devicePtr(),
|
|
.min_filter = .linear,
|
|
.mag_filter = .linear,
|
|
.wrap_s = .clamp_to_edge,
|
|
.wrap_t = .clamp_to_edge,
|
|
};
|
|
}
|
|
|
|
/// Re-export so callers can write `Vulkan.ImageTextureFormat` —
|
|
/// matches the `OpenGL.ImageTextureFormat` shape on the OpenGL side.
|
|
/// Definition lives in `vulkan/Texture.zig` next to `Texture`
|
|
/// itself.
|
|
pub const ImageTextureFormat = Texture.ImageTextureFormat;
|
|
|
|
pub fn imageTextureOptions(
|
|
_: *const Vulkan,
|
|
format: ImageTextureFormat,
|
|
srgb: bool,
|
|
) Texture.Options {
|
|
return .{
|
|
.device = devicePtr(),
|
|
.format = format.toVk(srgb),
|
|
.usage = vk.VK_IMAGE_USAGE_SAMPLED_BIT |
|
|
vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT,
|
|
};
|
|
}
|
|
|
|
pub fn initAtlasTexture(
|
|
_: *const Vulkan,
|
|
atlas: *const font.Atlas,
|
|
) !Texture {
|
|
const fmt: vk.VkFormat = switch (atlas.format) {
|
|
.grayscale => vk.VK_FORMAT_R8_UNORM,
|
|
.bgra => vk.VK_FORMAT_B8G8R8A8_UNORM,
|
|
else => return error.UnsupportedAtlasFormat,
|
|
};
|
|
return try Texture.init(
|
|
.{
|
|
.device = devicePtr(),
|
|
.format = fmt,
|
|
.usage = vk.VK_IMAGE_USAGE_SAMPLED_BIT |
|
|
vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT,
|
|
},
|
|
atlas.size,
|
|
atlas.size,
|
|
null,
|
|
);
|
|
}
|