renderer/vulkan: code-review correctness + cleanup pass

Fixes from a senior-engineer PR review of the Vulkan + subsurface
stack. All build-validated against both renderer variants in
Docker (Fedora 42 + Zig 0.15.2).

Correctness:
- buffer_pool.cycle() takes Device and destroys pending entries on
  OOM instead of leaving them in the pending list to grow without
  bound. Frame.complete passes the device through.
- RenderPass.begin reads the attachment's current layout
  (Target.layout / Texture.layout) and emits the matching
  oldLayout + srcAccessMask + srcStage instead of hardcoding
  UNDEFINED. Re-used targets across frames now transition cleanly.
- EglDmabufTarget::create stores each acquired resource on the
  unique_ptr immediately so early-return cleanup happens via the
  destructor only — removes the path that double-freed the GL
  texture and the asymmetric ::close(fd) handling.
- m_loggedFirstFrame is std::atomic with a relaxed compare_exchange
  so concurrent first-frame paths produce exactly one log line.
- m_pendingDmabuf overwrite is documented as intentional 1-deep
  drop; m_droppedFrames atomic counter + sparse logging surface
  sustained backlog.
- Vulkan.zig switches on apprt.runtime via @compileError on the
  non-embedded arm (matches OpenGL.zig); misconfigured
  -Drenderer=vulkan -Dapp-runtime=gtk now fails at compile time.

Tests:
- New glslang integration tests in vulkan/shaders.zig that run
  built-in shaders (bg_color.f, cell_text.v, cell_bg.f,
  full_screen.v) through vulkanizeGlsl + glslang and assert valid
  SPIR-V output. Catches rewriter-vs-glslang seams the textual
  unit tests don't.

Cleanup:
- Drop stale "stub / @panic / fork-only in progress" doc comments
  in Vulkan.zig, RenderPass.zig, Frame.zig, shaders.zig,
  backend.zig, embedded.zig, ghostty.h. The renderer is fully
  implemented and the previous docs misled readers.
- pkg/vulkan/build.zig: drop the dead `_ = module` indirection.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-25 15:04:21 -05:00
parent 55f4abbc02
commit 44d508fb9b
12 changed files with 328 additions and 138 deletions

View File

@ -67,10 +67,10 @@ typedef enum {
GHOSTTY_PLATFORM_MACOS,
GHOSTTY_PLATFORM_IOS,
GHOSTTY_PLATFORM_OPENGL,
// Vulkan is a fork-only addition (in-progress). The platform plumbing
// and callback shape are stable; the renderer itself is currently a
// stub and selecting it at build time fails with a compile error
// pointing at the qt-vulkan-renderer branch.
// Vulkan: fork-only platform tag. The host owns the
// VkInstance/Device/Queue and hands them to libghostty via
// `ghostty_platform_vulkan_s`. Frames come back to the host as
// dmabuf fds for zero-copy compositing.
GHOSTTY_PLATFORM_VULKAN,
} ghostty_platform_e;
@ -486,7 +486,7 @@ typedef struct {
void (*present)(void* userdata);
} ghostty_platform_opengl_s;
// Vulkan host integration (fork-only, in progress). The host owns the
// Vulkan host integration (fork-only). The host owns the
// VkInstance / VkPhysicalDevice / VkDevice / VkQueue (same ownership
// model as the OpenGL host); libghostty creates pipelines, command
// pools, and images against that device. Frames are handed back to the

View File

@ -1,14 +1,14 @@
const std = @import("std");
pub fn build(b: *std.Build) !void {
const module = b.addModule("vulkan", .{
// `addModule` registers "vulkan" on `b`'s module table; consumers
// (`src/build/SharedDeps.zig`) reach it via
// `b.lazyDependency("vulkan", ...).module("vulkan")`. No return
// value or further wiring is needed here Vulkan headers
// (`vulkan-headers` package) sit on the default system include
// path and libvulkan is link-system'd by the top-level build.
// Same pattern as `pkg/opengl/build.zig`.
_ = b.addModule("vulkan", .{
.root_source_file = b.path("main.zig"),
});
// The Vulkan headers (`vulkan-headers` package on every standard
// Linux distro) live on the default system include path. Consumers
// link libvulkan from the top-level build (see
// `src/build/SharedDeps.zig`) this package only owns the binding
// surface, mirroring `pkg/opengl/`.
_ = module;
}

View File

@ -53,11 +53,11 @@ find_package(LayerShellQt REQUIRED)
# QPA native-handle accessors.
find_package(PkgConfig REQUIRED)
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
# libEGL for the OpenGL present path's dmabuf export
# (EGL_MESA_image_dma_buf_export). Resolved at runtime via
# eglGetProcAddress, so we only need the link for the base entry
# points (eglQueryString, eglGetCurrentDisplay, eglGetError).
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
# libEGL is only needed by the OpenGL variant `EglDmabufTarget`
# uses EGL_MESA_image_dma_buf_export to export an FBO-backed
# texture as a dmabuf. The Vulkan variant gets dmabufs straight
# from `VK_KHR_external_memory_fd` and never calls into EGL, so
# the EGL pkg-config + IMPORTED_TARGET is gated below.
# libxkbcommon: derive the unshifted Unicode codepoint for a key event
# from its XKB keycode, so libghostty's kitty encoder finds an entry for
# punctuation keys (Qt's ev->key() reports the SHIFTED symbol, e.g.
@ -161,6 +161,13 @@ if(GHASTTY_VARIANT STREQUAL "vulkan")
add_compile_definitions(GHASTTY_USE_VULKAN)
endif()
# libEGL: needed by EglDmabufTarget.cpp for the OpenGL variant's
# zero-copy present path. Linked on both variants because the source
# file compiles into both (the Vulkan variant just never instantiates
# an `EglDmabufTarget`); skipping the link would leave undefined
# references to its destructor / static methods at link time.
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
if(NOT EXISTS "${GHOSTTY_SO}")
message(FATAL_ERROR
"libghostty not found at ${GHOSTTY_SO}\n"

View File

@ -1605,9 +1605,14 @@ void GhosttySurface::presentVulkanDmabuf(
// Per-surface one-shot breadcrumb so logs confirm the dmabuf
// hand-off is wired for each pane/split independently. Subsequent
// frames are silent so we don't spam stderr.
if (!m_loggedFirstFrame) {
m_loggedFirstFrame = true;
// frames are silent so we don't spam stderr. The compare_exchange
// ensures exactly one thread wins the right to emit the log even
// if two renderer-thread frames race the first present — relaxed
// ordering is fine since the only state we publish is the bool
// itself.
bool expected = false;
if (m_loggedFirstFrame.compare_exchange_strong(
expected, true, std::memory_order_relaxed)) {
std::fprintf(stderr,
"[ghastty] first dmabuf for surface=%p: fd=%d %ux%u "
"stride=%u fourcc=0x%08x mod=0x%lx image_backed=%d path=%s\n",
@ -1628,12 +1633,39 @@ void GhosttySurface::presentVulkanDmabuf(
// Subsurface path. Park the descriptor under the mutex (so
// a concurrent drainVulkan sees a consistent snapshot) and
// wake the GUI thread.
//
// Frame-drop semantics: at most one frame is parked. If
// drainVulkan hasn't consumed the previous one before the
// renderer thread arrives with a new one, the older frame is
// overwritten — its fd is libghostty's to close at next
// Target.deinit, so the descriptor doesn't leak; the user just
// sees a missed frame. That's the right call for a 60Hz
// terminal: the alternative (block the renderer thread on the
// GUI thread) would stall every present. We bump a counter so
// a sustained backlog is visible in logs/metrics; spurious
// drops happen on the first few frames before the GUI thread
// pump is hot, hence the >0 threshold.
bool overwrote = false;
{
QMutexLocker lock(&m_pendingMutex);
overwrote = m_pendingDmabuf.fd >= 0;
m_pendingDmabuf = PendingDmabuf{
dmabuf_fd, drm_format, drm_modifier, width, height, stride,
};
}
if (overwrote) {
const auto count = m_droppedFrames.fetch_add(
1, std::memory_order_relaxed) + 1;
// Log the first 3 drops + every 60th thereafter — silent in
// the steady state, audible on sustained backlog.
if (count <= 3 || count % 60 == 0) {
std::fprintf(stderr,
"[ghastty] surface=%p dropped frame "
"(parked one not yet drained, total=%llu)\n",
static_cast<void *>(this),
static_cast<unsigned long long>(count));
}
}
QMetaObject::invokeMethod(this, "drainVulkan", Qt::QueuedConnection);
return;
}

View File

@ -1,6 +1,7 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <QImage>
@ -382,8 +383,20 @@ private:
// the legacy mmap+memcpy+QImage+QPainter path renders pixels.
std::unique_ptr<wayland::SubsurfacePresenter> m_subsurfacePresenter;
// Per-surface latch for the first-dmabuf log breadcrumb so each
// pane / split prints its own line on first frame.
bool m_loggedFirstFrame = false;
// pane / split prints its own line on first frame. Atomic because
// the renderer thread is what hits `presentVulkanDmabuf` and the
// first-frame check would otherwise race a sibling renderer
// thread on the same widget — relaxed CAS means at most one log
// line per surface, even under concurrent first frames.
std::atomic<bool> m_loggedFirstFrame{false};
// Count of frames overwritten in `m_pendingDmabuf` before the GUI
// thread drained them. Each overwrite is a missed compositor
// present — fd lifetime is unaffected (libghostty owns the
// dmabuf), but a sustained nonzero rate means the GUI thread is
// falling behind the renderer. Logged sparsely from
// `presentVulkanDmabuf`.
std::atomic<std::uint64_t> m_droppedFrames{0};
// Set true on QEvent::Hide, false on QEvent::Show. Guards the
// present path against a race where libghostty's renderer thread
// fires one more frame after we've detached the subsurface

View File

@ -118,6 +118,12 @@ std::unique_ptr<EglDmabufTarget> EglDmabufTarget::create(QOpenGLContext *ctx,
auto *gl = ctx->functions();
if (!gl) return nullptr;
// We populate `target->m_*` AS we acquire each resource; on any
// failure we just `return nullptr` and let the unique_ptr's
// destructor unwind everything that's been stored so far. This is
// the only cleanup path — no manual gl->glDeleteTextures /
// ::close(fd) on early returns, which previously double-freed the
// texture and made the cleanup logic asymmetric per branch.
auto target = std::unique_ptr<EglDmabufTarget>(new EglDmabufTarget());
target->m_eglDisplay = dpy;
target->m_width = width_px;
@ -127,13 +133,13 @@ std::unique_ptr<EglDmabufTarget> EglDmabufTarget::create(QOpenGLContext *ctx,
unsigned int tex = 0;
gl->glGenTextures(1, &tex);
if (tex == 0) return nullptr;
target->m_texture = tex;
gl->glBindTexture(GL_TEXTURE_2D, tex);
gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width_px, height_px, 0, GL_RGBA,
GL_UNSIGNED_BYTE, nullptr);
gl->glBindTexture(GL_TEXTURE_2D, 0);
target->m_texture = tex;
// 2. Wrap as an EGLImage targeting the GL texture.
EGLImageKHR img = fns.createImage(
@ -148,7 +154,6 @@ std::unique_ptr<EglDmabufTarget> EglDmabufTarget::create(QOpenGLContext *ctx,
std::fprintf(stderr,
"[ghastty] EglDmabufTarget: eglCreateImageKHR failed (0x%x)\n",
eglGetError());
gl->glDeleteTextures(1, &tex);
return nullptr;
}
target->m_eglImage = img;
@ -196,11 +201,7 @@ std::unique_ptr<EglDmabufTarget> EglDmabufTarget::create(QOpenGLContext *ctx,
// 5. Attach to a framebuffer so libghostty can render into it.
unsigned int fbo = 0;
gl->glGenFramebuffers(1, &fbo);
if (fbo == 0) {
::close(fd);
target->m_fd = -1;
return nullptr;
}
if (fbo == 0) return nullptr;
target->m_framebuffer = fbo;
gl->glBindFramebuffer(GL_FRAMEBUFFER, fbo);
gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,

View File

@ -397,8 +397,8 @@ pub const Platform = union(PlatformTag) {
};
/// Configuration for a host that owns a Vulkan device libghostty
/// should render against (fork-only, in progress). The host owns
/// the VkInstance / VkPhysicalDevice / VkDevice / VkQueue same
/// should render against (fork-only). The host owns the
/// VkInstance / VkPhysicalDevice / VkDevice / VkQueue same
/// ownership model as `OpenGL` above. Frames are handed back to
/// the host as dmabuf file descriptors so the host can sample
/// them without a CPU readback.
@ -578,9 +578,7 @@ pub const PlatformTag = enum(c_int) {
macos = 1,
ios = 2,
opengl = 3,
// Fork-only, in progress: the platform plumbing is here so the C
// ABI is stable, but the renderer is currently a stub. Selecting
// `-Drenderer=vulkan` fails at comptime in `src/renderer.zig`.
// Fork-only platform tag for hosts that drive `src/renderer/Vulkan.zig`.
vulkan = 4,
};

View File

@ -1,51 +1,29 @@
//! Vulkan graphics API for libghostty's `GenericRenderer`.
//! 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.
//!
//! Status: this is the **build-unblocking** version. The comptime
//! contract `GenericRenderer(Vulkan)` requires is fully wired so
//! `-Drenderer=vulkan` compiles cleanly; the per-frame rendering
//! bodies (`beginFrame`, `present`, `presentLastTarget`, and the
//! `RenderPass.step` body recording draws) are `@panic` stubs that
//! land in follow-up commits alongside the integration smoke test
//! on real hardware.
//!
//! What does work today:
//! - Module type contract resolves at comptime.
//! - The `Renderer = GenericRenderer(Vulkan)` switch arm in
//! `src/renderer.zig:42` goes live.
//! - `init` / `deinit` succeed, all option getters return sensible
//! defaults.
//! - The submodule resource wrappers (`Device`, `Texture`, `Buffer`,
//! `Sampler`, `Target`, `Pipeline`, `CommandPool`, `Frame`,
//! `shaders.Module`) all work in isolation.
//!
//! What doesn't work yet:
//! - The per-frame draw loop. The renderer's actual `beginFrame`
//! `complete` sequence + `RenderPass.step` body don't record
//! real commands yet. Calling them at runtime hits an explicit
//! `@panic` with a pointer to the follow-up.
//! - Frame target presentation: `Vulkan.initTarget` exists but
//! the device handoff between `init` (per-surface) and
//! `initTarget` (per-frame) isn't wired up.
//!
//! Approach for the follow-up: a runtime smoke test that
//! bootstraps Vulkan through the standard loader, constructs each
//! resource wrapper in turn against real hardware, validates the
//! dmabuf fd from `Target` is importable as an external `VkImage`
//! by a second test consumer. Once that passes, we know the bottom
//! half of the renderer is correct end-to-end and we can wire the
//! actual draw path through `Vulkan.zig` without flying blind.
//! 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:
//! - `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.
//! - `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` pass + step recording (currently stub).
//! - `vulkan/RenderPass.zig` dynamic-rendering pass + step recorder.
//! - `vulkan/Frame.zig` per-draw context (fence-paced).
//! - `vulkan/shaders.zig` GLSLSPIR-VVkShaderModule.
//! - `vulkan/shaders.zig` GLSLSPIR-VVkShaderModule + the
//! OpenGL-GLSL Vulkan-GLSL rewriter.
pub const Vulkan = @This();
@ -206,8 +184,25 @@ pub const buffer_pool = struct {
/// Move all `pending` entries to `ready` the fence has
/// signaled, so the GPU is done with them. Call from
/// `Frame.complete` after `vkWaitForFences`.
pub fn cycle() void {
ready.appendSlice(std.heap.smp_allocator, pending.items) catch return;
///
/// `dev` is needed only on the OOM fallback path: if `ready`
/// can't grow to absorb `pending`, we destroy the pending
/// VkBuffers / VkDeviceMemory directly instead of leaking them
/// (the alternative would be to leave them in `pending` forever,
/// where each successive frame's `cycle` would try the same
/// failing append on an ever-growing list guaranteed VkDevice
/// memory exhaustion).
pub fn cycle(dev: *const Device) void {
ready.appendSlice(std.heap.smp_allocator, pending.items) catch {
// Couldn't grow `ready` destroy the GPU resources now
// (the GPU is provably done with them, the fence wait
// already returned) so the next frame doesn't double up
// on a pending list that can never drain.
for (pending.items) |e| {
dev.dispatch.destroyBuffer(dev.device, e.buffer, null);
dev.dispatch.freeMemory(dev.device, e.memory, null);
}
};
pending.clearRetainingCapacity();
}
@ -264,7 +259,17 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Vulkan {
defer device_mutex.unlock();
if (device == null) {
switch (apprt.runtime) {
else => return error.UnsupportedRuntime,
// 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, platform);
@ -273,6 +278,10 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Vulkan {
.{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,
},
}
@ -329,20 +338,20 @@ pub fn deinit(self: *Vulkan) void {
self.* = undefined;
}
/// Early per-surface setup. Stub Vulkan needs nothing here because
/// the host hasn't finished installing the platform callbacks yet.
/// 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. This is
/// where we have valid platform callbacks, so this is where the
/// `Device` lives.
/// 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 {
// The renderer holds a `*const Vulkan`, so we can't actually
// mutate self here. The renderer threads its own pointer to us
// via opts, so this is a no-op for now the device construction
// moves into `threadEnter` where `self: *Vulkan`.
_ = self;
_ = surface;
}
@ -350,11 +359,10 @@ pub fn finalizeSurfaceInit(self: *const Vulkan, surface: *apprt.Surface) !void {
pub fn threadEnter(self: *const Vulkan, surface: *apprt.Surface) !void {
_ = self;
_ = surface;
// Device is brought up in `init` (the renderer's FrameState init
// path calls options getters before threadEnter, and our options
// need the device so it has to be ready earlier than OpenGL
// wants). Nothing to do here; left in place so
// `@hasDecl(GraphicsAPI, "threadEnter")` keeps returning true in
// 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`.
}
@ -422,12 +430,15 @@ pub fn initTarget(self: *const Vulkan, width: usize, height: usize) !Target {
/// surface was created with the Vulkan platform tag. Returns null
/// otherwise (smoke test / OpenGL surfaces).
fn surfacePlatform(rt_surface: *apprt.Surface) ?apprt.embedded.Platform.Vulkan {
return switch (apprt.runtime) {
// `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,
apprt.embedded => switch (rt_surface.platform) {
.vulkan => |p| p,
else => null,
},
};
}

View File

@ -6,11 +6,10 @@ pub const Backend = enum {
opengl,
metal,
webgl,
/// Vulkan is on this fork only and is a work in progress: selecting
/// `-Drenderer=vulkan` currently fails at comptime in `renderer.zig`.
/// The scaffolding (apprt platform callbacks, public C API) is in
/// place; the renderer itself lands in follow-up commits on
/// `qt-vulkan-renderer`.
/// Vulkan is on this fork only. Embedded-only the host owns
/// the VkInstance/Device/Queue and hands them in via
/// `ghostty_platform_vulkan_s`; libghostty renders against
/// those handles and exports the result as a dmabuf fd.
vulkan,
pub fn default(

View File

@ -167,7 +167,7 @@ pub fn complete(self: *const Self, sync: bool) void {
// recording is provably no longer in use by the GPU and is
// safe to hand to the next `Buffer.create` call. See
// `Vulkan.buffer_pool` for the lifecycle.
Vulkan.buffer_pool.cycle();
Vulkan.buffer_pool.cycle(dev);
// Hand the rendered target off to the host via `Vulkan.present`,
// which both calls the platform's present callback AND records
@ -186,11 +186,6 @@ pub fn complete(self: *const Self, sync: bool) void {
/// Begin a render pass recording into this frame's command buffer.
/// The returned `RenderPass` accepts `step()` calls for the
/// per-pipeline draw work, and is finalized with `complete()`.
///
/// Currently delegates straight to `RenderPass.begin` which is itself
/// a stub for the recording layer actual command-recording lives
/// in a follow-up commit on `qt-vulkan-renderer`. The plumbing is
/// here so `GenericRenderer(Vulkan)` resolves at comptime.
pub inline fn renderPass(
self: *const Self,
attachments: []const RenderPass.Options.Attachment,

View File

@ -3,12 +3,13 @@
//! `VkRenderPass` object needed) plus the per-`step` resource
//! binding + draw-call emission.
//!
//! **Stub.** The TYPES are wired so `GenericRenderer(Vulkan)` can
//! resolve at comptime and `-Drenderer=vulkan` builds. The bodies of
//! `step` and `complete` @panic the actual command-recording layer
//! (descriptor sets, pipeline binding, vertex buffer binding, draw
//! calls) lands in a follow-up commit once the integration is
//! validated end-to-end.
//! `begin` transitions the attachment from its current layout to
//! `COLOR_ATTACHMENT_OPTIMAL` and opens a rendering scope with the
//! caller's clear color. `step` updates the pipeline's descriptor
//! sets from the Step's resources and records a draw call;
//! `complete` closes the rendering scope and transitions the
//! attachment to its consumer-facing layout (SHADER_READ_ONLY for
//! intermediate textures, GENERAL for the dmabuf-backed target).
//!
//! Counterpart: `src/renderer/opengl/RenderPass.zig`.
@ -61,6 +62,20 @@ pub const Options = struct {
attachments: []const Attachment,
pub const Attachment = struct {
// Held by value to match the OpenGL backend's Attachment
// shape (so `generic.zig`'s call sites remain identical).
// Vulkan's `Texture` and `Target` carry a `layout` field
// that mutates across passes `RenderPass.begin` reads it
// to emit the right source-layout barrier, and
// `RenderPass.complete` updates the value-copy here. Because
// the value is a copy, that update doesn't propagate back
// to the caller; the call sites in `generic.zig` are
// intentionally fine with that they always pass the
// CURRENT `frame.target` / `state.{front,back}_texture`
// (whose `layout` was last updated by the previous pass's
// `recordPresentBarrier` / pipeline-end barrier in
// `Target.recordPresentBarrier` / `Texture.replaceRegion`)
// when constructing a new pass.
target: union(enum) {
texture: Texture,
target: Target,
@ -88,9 +103,11 @@ pub const Step = struct {
};
pub const Error = error{
/// Reserved for actual command-recording failures once `step` is
/// implemented. Currently unused the panic stub bypasses any
/// error path.
/// Reserved for command-recording failures. Currently unused
/// the recorder relies on Vulkan's silent-failure model
/// (record bad input validation flags it / next submit
/// returns DEVICE_LOST), but the slot stays open in case a
/// future step wants to fail-fast at record time.
VulkanFailed,
};
@ -127,9 +144,10 @@ pub fn begin(opts: Options) Self {
const attach = opts.attachments[0];
const view: vk.VkImageView, const image: vk.VkImage,
const width: u32, const height: u32 = switch (attach.target) {
.texture => |t| .{ t.view, t.image, @intCast(t.width), @intCast(t.height) },
.target => |t| .{ t.view, t.image, t.width, t.height },
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 },
};
// Always Y-flip the viewport regardless of attachment kind.
//
@ -149,17 +167,46 @@ pub fn begin(opts: Options) Self {
// `uv = fragCoord/iResolution` + `texture(iChannel0, uv)`
// expects in Vulkan-native convention.
// Transition to COLOR_ATTACHMENT_OPTIMAL. Sources from
// UNDEFINED (fresh target) or whatever we always discard
// prior contents (loadOp = CLEAR / LOAD covered below; here we
// just need write access).
// Transition to COLOR_ATTACHMENT_OPTIMAL. The attachment's
// current layout drives the source-side of the barrier so a
// re-used target (e.g. `Target` in `.direct` mode after the
// previous frame's `recordDirectBarrier` left it in GENERAL,
// or `.legacy_copy` after `recordCopyToDmabuf` left it in
// TRANSFER_SRC_OPTIMAL, or a `Texture` after the previous
// pass's `complete` left it in SHADER_READ_ONLY_OPTIMAL) is
// transitioned correctly. UNDEFINED is the implicit-discard
// initial layout for a fresh image; we'd also accept it for
// an image whose contents we don't care about, but `loadOp =
// CLEAR` covers that case explicitly so we always pass a
// truthful old layout to validation.
{
// Source access depends on what the previous owner of the
// layout could have left in flight. For COLOR_ATTACHMENT_*
// it's the color-write access; for TRANSFER_SRC the read
// already retired but we conservatively name it; for
// SHADER_READ_ONLY the prior fragment-stage read; UNDEFINED
// and GENERAL want a no-op source mask (GENERAL was last
// written by the present-barrier and `recordDirectBarrier`
// has already chained that visibility into HOST the next
// frame doesn't need to re-flush it).
const src_access: vk.VkAccessFlags = switch (old_layout) {
vk.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL => vk.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL => vk.VK_ACCESS_TRANSFER_READ_BIT,
vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL => vk.VK_ACCESS_SHADER_READ_BIT,
else => 0,
};
const src_stage: vk.VkPipelineStageFlags = switch (old_layout) {
vk.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL => vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL => vk.VK_PIPELINE_STAGE_TRANSFER_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,
};
const barrier: vk.VkImageMemoryBarrier = .{
.sType = vk.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.pNext = null,
.srcAccessMask = 0,
.srcAccessMask = src_access,
.dstAccessMask = vk.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.oldLayout = vk.VK_IMAGE_LAYOUT_UNDEFINED,
.oldLayout = old_layout,
.newLayout = vk.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.srcQueueFamilyIndex = vk.VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = vk.VK_QUEUE_FAMILY_IGNORED,
@ -174,7 +221,7 @@ pub fn begin(opts: Options) Self {
};
opts.device.dispatch.cmdPipelineBarrier(
opts.cb,
vk.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
src_stage,
vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
0,
0, null,

View File

@ -660,19 +660,16 @@ const empty_pipeline: Pipeline = .{
/// `opengl/shaders.zig`'s `Shaders` so the generic renderer's call
/// sites work without per-backend branching.
///
/// What's wired:
/// - Compiles all 9 built-in GLSL sources at init time via
/// `Module.init` (which runs the glslang shim same code path
/// user shaders go through). The compiled `VkShaderModule`
/// handles are held in `modules` for the lifetime of the
/// `Shaders` struct.
///
/// What's stubbed:
/// - `pipelines` is still `undefined`. Building real pipelines
/// needs the per-pipeline descriptor-set layout (which depends
/// on what `setAutoMapBindings` picked) and the vertex input
/// description for the instanced pipelines. Constructed in a
/// follow-up commit once the rest of the integration is wired.
/// `Shaders.init`:
/// - Compiles all 9 built-in GLSL sources via `Module.init` (the
/// glslang shim same code path user shaders go through).
/// - Creates per-pipeline descriptor set layouts + a single
/// descriptor pool sized for the static pipeline set.
/// - Builds one `Pipeline` per renderer shader (`bg_color`,
/// `cell_bg`, `cell_text`, `image`, `bg_image`) plus one per
/// user-supplied post-shader.
/// `Shaders.deinit` walks the same set in reverse to destroy
/// pipelines, layouts, samplers, the descriptor pool, and modules.
pub const Shaders = struct {
pipelines: PipelineCollection,
/// One per user-supplied custom shader. Built by `Shaders.init`
@ -1478,3 +1475,93 @@ test "vulkanizeGlsl: layout with pre-existing set qualifier is unchanged" {
// error than to silently rewrite.
try std.testing.expect(std.mem.indexOf(u8, out, "set = 3") != null);
}
// ---- glslang integration tests --------------------------------------
//
// `vulkanizeGlsl` unit tests above exercise the textual rewrite in
// isolation. The integration tests below feed the rewriter's output
// through glslang via `ghastty_glslang_compile_vulkan` and assert
// the result is a valid SPIR-V binary. That covers the seam where
// a syntactically-fine rewrite still produces something glslang
// rejects (e.g. a `set = N` on a declaration glslang's
// `--auto-map-bindings` is also trying to assign).
fn compileToSpv(
alloc: std.mem.Allocator,
src: [:0]const u8,
stage: Stage,
) ![]const u32 {
glslang.testing.ensureInit() catch return error.GlslangFailed;
const translated = try vulkanizeGlsl(alloc, src);
defer alloc.free(translated);
var spv_ptr: [*c]u32 = undefined;
var spv_len: usize = 0;
var err_ptr: [*c]u8 = undefined;
const c_stage: glslang.c.ghastty_glslang_stage_t = switch (stage) {
.vertex => glslang.c.GHASTTY_GLSLANG_STAGE_VERTEX,
.fragment => glslang.c.GHASTTY_GLSLANG_STAGE_FRAGMENT,
};
const rc = glslang.c.ghastty_glslang_compile_vulkan(
translated.ptr,
c_stage,
&spv_ptr,
&spv_len,
&err_ptr,
);
if (rc != 0) {
if (err_ptr != null) {
std.log.err("compileToSpv: {s}", .{
std.mem.span(@as([*:0]const u8, @ptrCast(err_ptr))),
});
glslang.c.ghastty_glslang_free_error(err_ptr);
}
return error.GlslangFailed;
}
// Caller owns; copy out of glslang's malloc into the test allocator
// so cleanup is symmetric (the caller `defer alloc.free(out)`s).
const spv_words = spv_ptr[0..spv_len];
const owned = try alloc.alloc(u32, spv_len);
@memcpy(owned, spv_words);
glslang.c.ghastty_glslang_free_spirv(spv_ptr);
return owned;
}
test "glslang integration: built-in bg_color fragment compiles" {
const alloc = std.testing.allocator;
const spv = try compileToSpv(alloc, source.bg_color_frag, .fragment);
defer alloc.free(spv);
// SPIR-V magic word first 4 bytes are 0x07230203.
try std.testing.expect(spv.len > 0);
try std.testing.expectEqual(@as(u32, 0x07230203), spv[0]);
}
test "glslang integration: built-in cell_text vertex compiles" {
const alloc = std.testing.allocator;
const spv = try compileToSpv(alloc, source.cell_text_vert, .vertex);
defer alloc.free(spv);
try std.testing.expect(spv.len > 0);
try std.testing.expectEqual(@as(u32, 0x07230203), spv[0]);
}
test "glslang integration: cell_bg fragment compiles (non-contiguous sets)" {
// cell_bg uses set 0 (UBO) and set 2 (storage) set 1 is the
// empty placeholder DSL. The rewriter has to produce something
// glslang can compile despite the gap; this test catches a
// regression where the rewrite emits set=1 for the storage
// buffer and breaks the pipeline layout assumption.
const alloc = std.testing.allocator;
const spv = try compileToSpv(alloc, source.cell_bg_frag, .fragment);
defer alloc.free(spv);
try std.testing.expect(spv.len > 0);
try std.testing.expectEqual(@as(u32, 0x07230203), spv[0]);
}
test "glslang integration: full_screen vertex compiles" {
const alloc = std.testing.allocator;
const spv = try compileToSpv(alloc, source.full_screen_vert, .vertex);
defer alloc.free(spv);
try std.testing.expect(spv.len > 0);
try std.testing.expectEqual(@as(u32, 0x07230203), spv[0]);
}