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
parent
55f4abbc02
commit
44d508fb9b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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` — GLSL→SPIR-V→VkShaderModule.
|
||||
//! - `vulkan/shaders.zig` — GLSL→SPIR-V→VkShaderModule + 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue