renderer+qt/vulkan: bg_color pipeline draws + dynamicRendering on host

First pipeline actually drawing. Validation passes (modulo NVIDIA's
unrelated `VK_KHR_present_mode_fifo_latest_ready` noise). The window
should now show the rendered output of bg_color rather than a
transparent rectangle — verify visually.

What changed:

Pipeline / Shaders:
  - `Pipeline.zig` gains optional `descriptor_pool` in `Options`,
    plus `descriptor_set` + `descriptor_set_layout` fields. When
    given a pool + layouts, `Pipeline.init` allocates one set so
    `RenderPass.step` can bind it without separate plumbing.
  - `Shaders.init` builds the real bg_color pipeline:
    `full_screen.v.glsl` + `bg_color.f.glsl`, UBO at binding=1
    (matching the GLSL `layout(binding = 1)` on Globals), fragment
    stage only. Pool sized for 5 sets + 5 UBOs + 8 image-samplers
    so adding the remaining pipelines later doesn't need a pool
    rebuild.
  - `PipelineCollection`'s default-init now uses a zeroed
    `empty_pipeline` sentinel (`pipeline == null`) instead of
    `undefined`. Debug-mode 0xAA poison was reaching validation as
    fake VkPipeline / VkDescriptorSet handles.

RenderPass.step body:
  - Skips silently when `pipeline.pipeline == null` (the four
    unbuilt slots in PipelineCollection).
  - For pipelines that do have a descriptor set, updates it with
    the Step's `uniforms` VkBuffer (UBO descriptor type), binds
    the set, then binds the pipeline and emits `vkCmdDraw`.

Target.zig:
  - Adds COLOR_ATTACHMENT + TRANSFER_SRC to the usage flags so
    the target is valid as both a render-pass attachment and a
    debug-readback source. SAMPLED was already there for the
    custom-shader path.

Vulkan.textureOptions:
  - Bumps to `B8G8R8A8_UNORM` (matching `initTarget`) and adds
    COLOR_ATTACHMENT_BIT. The renderer's custom-shader
    `back_texture` is BOTH a render target AND a sampled source,
    so the usage union covers both roles. Without this, the
    custom-shader path (which the user's config triggers) tried
    to use a SAMPLED-only image as a color attachment and validation
    rejected it.

shaders.zig:
  - For v1, only compile `full_screen.v.glsl` + `bg_color.f.glsl`.
    The other 7 shaders use `sampler2DRect`, which is an OpenGL-only
    construct that produces SPIR-V with the `SampledRect` capability
    Vulkan 1.3 doesn't allow. Source-level rewrite to `sampler2D`
    is a separate follow-up. Unused module slots stay null-handle
    sentinels; `deinit` skips them.

Host (Qt side):
  - Enables `VkPhysicalDeviceVulkan13Features.dynamicRendering` +
    `synchronization2` when creating the VkDevice. libghostty's
    renderer uses Vulkan 1.3 dynamic rendering
    (`vkCmdBeginRendering` / `vkCmdEndRendering`, no
    `VkRenderPass`); the feature must be explicitly enabled at
    device creation or the renderer errors when it tries to begin
    a rendering scope.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-24 12:42:22 -05:00
parent f1c4fa60b9
commit e8ad547dda
6 changed files with 291 additions and 49 deletions

View File

@ -176,8 +176,18 @@ bool Host::init() {
qci.queueCount = 1;
qci.pQueuePriorities = &queuePriority;
// libghostty's Vulkan renderer uses Vulkan 1.3 dynamic rendering
// (vkCmdBeginRendering / vkCmdEndRendering, no VkRenderPass).
// That feature has to be explicitly enabled at device creation
// time via VkPhysicalDeviceVulkan13Features.
VkPhysicalDeviceVulkan13Features vk13features{};
vk13features.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
vk13features.dynamicRendering = VK_TRUE;
vk13features.synchronization2 = VK_TRUE;
VkDeviceCreateInfo dci{};
dci.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
dci.pNext = &vk13features;
dci.queueCreateInfoCount = 1;
dci.pQueueCreateInfos = &qci;
dci.enabledExtensionCount =

View File

@ -385,11 +385,19 @@ pub fn bgImageBufferOptions(self: *const Vulkan) bufferpkg.Options {
}
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_UNORM`) so a render sample render chain
// through the custom-shader pass keeps the same color format.
return .{
.device = devicePtr(),
.format = vk.VK_FORMAT_R8G8B8A8_UNORM,
.format = vk.VK_FORMAT_B8G8R8A8_UNORM,
.usage = vk.VK_IMAGE_USAGE_SAMPLED_BIT |
vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT,
vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT |
vk.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
};
}

View File

@ -25,6 +25,7 @@ const std = @import("std");
const vk = @import("vulkan").c;
const Device = @import("Device.zig");
const DescriptorPool = @import("DescriptorPool.zig");
const log = std.log.scoped(.vulkan);
@ -56,6 +57,13 @@ pub const VertexInput = struct {
pub const Options = struct {
device: *const Device,
/// Optional descriptor pool. If provided alongside a non-empty
/// `descriptor_set_layouts` slice, `Pipeline.init` allocates one
/// descriptor set against the first layout and stores it on
/// `Pipeline.descriptor_set` so `RenderPass.step` can bind it
/// without a separate plumbing step.
descriptor_pool: ?*DescriptorPool = null,
/// Shader modules. The caller owns these Pipeline does not
/// destroy them on deinit (they're typically reused across
/// multiple pipelines and outlive any one of them).
@ -95,6 +103,25 @@ device: *const Device,
pipeline: vk.VkPipeline,
layout: vk.VkPipelineLayout,
/// Cached copy of the single `VkDescriptorSetLayout` this pipeline
/// was built with (when one was provided). `Shaders.init` owns the
/// layout's lifetime; storing the handle here lets `RenderPass.step`
/// allocate descriptor sets matching this pipeline without threading
/// the layout separately.
descriptor_set_layout: vk.VkDescriptorSetLayout = null,
/// Optional descriptor set bundled with this pipeline. When set,
/// `RenderPass.step` updates it with the Step's `uniforms`/textures
/// and binds it before drawing. Allocated from a pool at
/// `Pipeline.init` time when `opts.descriptor_pool` is provided.
/// Null for pipelines that take no descriptor inputs (e.g. the
/// smoke-test's solid-color pipeline).
descriptor_set: vk.VkDescriptorSet = null,
/// Binding number that `uniforms` writes to. Defaults to 1 to match
/// the GLSL `layout(binding = 1)` on the Globals UBO. Override per
/// pipeline if/when glslang's auto-map picks a different slot.
uniforms_binding: u32 = 1,
pub fn init(opts: Options) Error!Self {
const dev = opts.device;
@ -312,10 +339,25 @@ pub fn init(opts: Options) Error!Self {
}
}
const dsl_first: vk.VkDescriptorSetLayout =
if (opts.descriptor_set_layouts.len > 0) opts.descriptor_set_layouts[0] else null;
var dset: vk.VkDescriptorSet = null;
if (opts.descriptor_pool) |pool_ptr| {
if (dsl_first != null) {
dset = pool_ptr.allocate(dsl_first) catch |err| {
log.err("Pipeline.init: descriptor set allocation failed: {}", .{err});
return error.VulkanFailed;
};
}
}
return .{
.device = dev,
.pipeline = pipeline,
.layout = layout,
.descriptor_set_layout = dsl_first,
.descriptor_set = dset,
};
}

View File

@ -213,19 +213,70 @@ pub fn begin(opts: Options) Self {
/// Record one step of the pass.
///
/// **Body is a stub.** The full implementation will bind the
/// pipeline, allocate + populate the descriptor set, bind vertex
/// buffers, and emit `vkCmdDraw`. Until that lands, step records
/// nothing the frame loop runs end-to-end without drawing real
/// terminal content but doesn't crash either, so the rest of the
/// Vulkan integration (Qt-side QRhiWidget + dmabuf import) can
/// proceed in parallel against a known-color clear frame.
/// Skips silently when the pipeline isn't yet real (`VkPipeline ==
/// null`) `Shaders.init` only constructs bg_color so far; the
/// other 4 pipeline slots are default-undefined and we filter them
/// out here rather than crashing on a null handle.
pub fn step(self: *Self, s: Step) void {
_ = self;
_ = s;
// No-op stub. Replace with `cmdBindPipeline` + descriptor set
// wiring + `cmdDraw` once Shaders.init + DescriptorPool
// integration lands.
// Skip pipelines that haven't been constructed yet only
// `bg_color` is real today; the other 4 slots in
// `PipelineCollection` are default-initialized (VkPipeline ==
// null) and we filter them out instead of crashing on a null
// handle.
if (s.pipeline.pipeline == null) return;
if (s.draw.vertex_count == 0) return;
const dev = self.device;
// Update + bind the pipeline's descriptor set if it has one
// AND the step is passing a uniforms buffer. Today this only
// fires for the bg_color path.
if (s.pipeline.descriptor_set != null) if (s.uniforms) |ubo_buffer| {
const buffer_info: vk.VkDescriptorBufferInfo = .{
.buffer = ubo_buffer,
.offset = 0,
.range = vk.VK_WHOLE_SIZE,
};
const write: vk.VkWriteDescriptorSet = .{
.sType = vk.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.pNext = null,
.dstSet = s.pipeline.descriptor_set,
.dstBinding = s.pipeline.uniforms_binding,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
.pImageInfo = null,
.pBufferInfo = &buffer_info,
.pTexelBufferView = null,
};
dev.dispatch.updateDescriptorSets(dev.device, 1, &write, 0, null);
var sets = [_]vk.VkDescriptorSet{s.pipeline.descriptor_set};
dev.dispatch.cmdBindDescriptorSets(
self.cb,
vk.VK_PIPELINE_BIND_POINT_GRAPHICS,
s.pipeline.layout,
0, // first set
1, // set count
&sets,
0, // dynamic offset count
null,
);
};
dev.dispatch.cmdBindPipeline(
self.cb,
vk.VK_PIPELINE_BIND_POINT_GRAPHICS,
s.pipeline.pipeline,
);
dev.dispatch.cmdDraw(
self.cb,
@intCast(s.draw.vertex_count),
@intCast(s.draw.instance_count),
0,
0,
);
self.step_number += 1;
}
/// Close the rendering scope and leave the attachment in a layout

View File

@ -106,8 +106,12 @@ pub fn init(opts: Options) Error!Self {
const dev = opts.device;
const drm_format = try vkFormatToDrmFourcc(opts.format);
// COLOR_ATTACHMENT we render into this via dynamic rendering.
// SAMPLED the renderer's custom-shader path samples the target.
// TRANSFER_SRC readback for debug / screenshot tooling.
const usage = @as(vk.VkImageUsageFlags, vk.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT) |
vk.VK_IMAGE_USAGE_SAMPLED_BIT |
vk.VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
opts.extra_usage;
// ---- 1. VkImage (with external-memory chain) ----------------

View File

@ -25,6 +25,7 @@ const glslang = @import("glslang");
const Device = @import("Device.zig");
const Pipeline = @import("Pipeline.zig");
const DescriptorPool = @import("DescriptorPool.zig");
const math = @import("../../math.zig");
const log = std.log.scoped(.vulkan);
@ -377,12 +378,24 @@ pub const BgImage = extern struct {
/// Pipeline collection shape (matches `opengl/shaders.zig`). Each
/// field is the Vulkan `Pipeline` instance for that named shader.
///
/// Default-init to all-null handles: pipelines that haven't been
/// constructed yet have `pipeline == null`, which `RenderPass.step`
/// detects and silently skips. Using `Pipeline = undefined` instead
/// would leak Debug-mode 0xAA poison bytes into VkPipeline / VkDevice
/// handles, which validation rightly flags as invalid.
pub const PipelineCollection = struct {
bg_color: Pipeline = undefined,
cell_bg: Pipeline = undefined,
cell_text: Pipeline = undefined,
image: Pipeline = undefined,
bg_image: Pipeline = undefined,
bg_color: Pipeline = empty_pipeline,
cell_bg: Pipeline = empty_pipeline,
cell_text: Pipeline = empty_pipeline,
image: Pipeline = empty_pipeline,
bg_image: Pipeline = empty_pipeline,
};
const empty_pipeline: Pipeline = .{
.device = undefined, // unused gated behind pipeline-handle null checks
.pipeline = null,
.layout = null,
};
/// Top-level renderer shader state. Same shape as
@ -406,6 +419,21 @@ pub const Shaders = struct {
pipelines: PipelineCollection,
post_pipelines: []const Pipeline,
modules: Modules,
/// Process-wide descriptor pool. Sized for one set per pipeline
/// at startup; `RenderPass.step` updates the sets in place each
/// frame (we wait on the fence in `Frame.complete`, so reuse is
/// safe no command buffer using these sets is in flight when
/// the next frame begins).
descriptor_pool: ?DescriptorPool = null,
/// One descriptor set + layout per pipeline. The layout is also
/// stored on `Pipeline.descriptor_set_layout` so `RenderPass.step`
/// can re-fetch from `step.pipeline`; the set lives here because
/// it's allocated once and updated per-frame.
bg_color_set_layout: vk.VkDescriptorSetLayout = null,
bg_color_set: vk.VkDescriptorSet = null,
defunct: bool = false,
/// The compiled `VkShaderModule`s for the renderer's built-in
@ -436,29 +464,108 @@ pub const Shaders = struct {
// tears down any successfully-compiled modules if a later
// one fails so we don't leak `VkShaderModule` handles on
// partial failure.
var modules: Modules = undefined;
modules.bg_color_frag = try Module.init(alloc, device, source.bg_color_frag, .fragment);
errdefer modules.bg_color_frag.deinit();
modules.bg_image_frag = try Module.init(alloc, device, source.bg_image_frag, .fragment);
errdefer modules.bg_image_frag.deinit();
modules.bg_image_vert = try Module.init(alloc, device, source.bg_image_vert, .vertex);
errdefer modules.bg_image_vert.deinit();
modules.cell_bg_frag = try Module.init(alloc, device, source.cell_bg_frag, .fragment);
errdefer modules.cell_bg_frag.deinit();
modules.cell_text_frag = try Module.init(alloc, device, source.cell_text_frag, .fragment);
errdefer modules.cell_text_frag.deinit();
modules.cell_text_vert = try Module.init(alloc, device, source.cell_text_vert, .vertex);
errdefer modules.cell_text_vert.deinit();
// For v1 we only compile the modules needed by the bg_color
// pipeline (`full_screen.v.glsl` + `bg_color.f.glsl`). The
// other shaders use OpenGL-only constructs (`sampler2DRect`)
// that aren't valid SPIR-V capabilities in Vulkan 1.3 they
// need source-level conversion to `sampler2D` before we can
// compile them. The unused modules stay null-handle
// sentinels and `Shaders.deinit` skips them.
const empty_module: Module = .{
.handle = null,
.stage = vk.VK_SHADER_STAGE_VERTEX_BIT,
.device = device,
};
var modules: Modules = .{
.bg_color_frag = empty_module,
.bg_image_frag = empty_module,
.bg_image_vert = empty_module,
.cell_bg_frag = empty_module,
.cell_text_frag = empty_module,
.cell_text_vert = empty_module,
.full_screen_vert = empty_module,
.image_frag = empty_module,
.image_vert = empty_module,
};
modules.full_screen_vert = try Module.init(alloc, device, source.full_screen_vert, .vertex);
errdefer modules.full_screen_vert.deinit();
modules.image_frag = try Module.init(alloc, device, source.image_frag, .fragment);
errdefer modules.image_frag.deinit();
modules.image_vert = try Module.init(alloc, device, source.image_vert, .vertex);
modules.bg_color_frag = try Module.init(alloc, device, source.bg_color_frag, .fragment);
errdefer modules.bg_color_frag.deinit();
// Build a descriptor pool sized for one descriptor set per
// pipeline (we currently only construct bg_color; size for the
// full set so adding new pipelines doesn't require pool
// resizing).
var pool = try DescriptorPool.init(.{
.device = device,
.max_sets = 5,
.uniform_buffers = 5,
.combined_image_samplers = 8,
});
errdefer pool.deinit();
// ---- bg_color pipeline -----------------------------------
//
// Full-screen fragment shader that reads the bg color out of
// the Globals UBO. The vertex shader (`full_screen.v.glsl`)
// synthesizes a covering triangle from `gl_VertexIndex`, so
// there's no vertex input.
//
// Descriptor set layout: one UBO binding for Globals. The
// existing OpenGL shader declares it at `binding = 1`; with
// glslang's `setAutoMapBindings(true)` (in our shim) the
// binding may be remapped, but for v1 we declare it at
// binding 1 to match. Layout fragment-stage only the
// vertex shader for bg_color doesn't use the UBO.
const bg_color_bindings = [_]vk.VkDescriptorSetLayoutBinding{.{
.binding = 1,
.descriptorType = vk.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
.descriptorCount = 1,
.stageFlags = vk.VK_SHADER_STAGE_FRAGMENT_BIT,
.pImmutableSamplers = null,
}};
const bg_color_dsl_info: vk.VkDescriptorSetLayoutCreateInfo = .{
.sType = vk.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.pNext = null,
.flags = 0,
.bindingCount = bg_color_bindings.len,
.pBindings = &bg_color_bindings,
};
var bg_color_dsl: vk.VkDescriptorSetLayout = undefined;
if (device.dispatch.createDescriptorSetLayout(
device.device,
&bg_color_dsl_info,
null,
&bg_color_dsl,
) != vk.VK_SUCCESS) {
return error.VulkanFailed;
}
errdefer device.dispatch.destroyDescriptorSetLayout(device.device, bg_color_dsl, null);
const bg_color_dsls = [_]vk.VkDescriptorSetLayout{bg_color_dsl};
const bg_color_pipeline = try Pipeline.init(.{
.device = device,
.descriptor_pool = &pool,
.vertex_module = modules.full_screen_vert.handle,
.fragment_module = modules.bg_color_frag.handle,
.vertex_input = null,
.descriptor_set_layouts = &bg_color_dsls,
.color_format = vk.VK_FORMAT_B8G8R8A8_UNORM,
.blending_enabled = false,
.topology = vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST,
});
errdefer bg_color_pipeline.deinit();
var pipelines: PipelineCollection = .{};
pipelines.bg_color = bg_color_pipeline;
return .{
.pipelines = .{},
.pipelines = pipelines,
.post_pipelines = &.{},
.modules = modules,
.descriptor_pool = pool,
.bg_color_set_layout = bg_color_dsl,
.bg_color_set = bg_color_pipeline.descriptor_set,
};
}
@ -467,20 +574,40 @@ pub const Shaders = struct {
if (self.defunct) return;
self.defunct = true;
// Destroy every compiled module.
self.modules.bg_color_frag.deinit();
self.modules.bg_image_frag.deinit();
self.modules.bg_image_vert.deinit();
self.modules.cell_bg_frag.deinit();
self.modules.cell_text_frag.deinit();
self.modules.cell_text_vert.deinit();
self.modules.full_screen_vert.deinit();
self.modules.image_frag.deinit();
self.modules.image_vert.deinit();
// Real pipeline (bg_color) destroy first since it
// references the descriptor set layout.
const bg_color_real = self.pipelines.bg_color.pipeline != null;
if (bg_color_real) self.pipelines.bg_color.deinit();
// No pipeline destruction yet `init` doesn't construct
// real pipelines. Real `deinit` will iterate `inline for`
// over PipelineCollection's fields once those exist.
// The descriptor pool reclaims all sets allocated from it,
// including `bg_color_set`. Destroy the standalone layout
// separately.
if (self.descriptor_pool) |*p| p.deinit();
if (self.bg_color_set_layout != null) {
self.modules.bg_color_frag.device.dispatch.destroyDescriptorSetLayout(
self.modules.bg_color_frag.device.device,
self.bg_color_set_layout,
null,
);
}
// Destroy every compiled module. Modules whose handle is
// null (not compiled in v1) skip destruction vkDestroy*
// is null-safe per the Vulkan spec but we check explicitly
// so we don't even pass null through the dispatch.
inline for (.{
&self.modules.bg_color_frag,
&self.modules.bg_image_frag,
&self.modules.bg_image_vert,
&self.modules.cell_bg_frag,
&self.modules.cell_text_frag,
&self.modules.cell_text_vert,
&self.modules.full_screen_vert,
&self.modules.image_frag,
&self.modules.image_vert,
}) |m_ptr| {
if (m_ptr.handle != null) m_ptr.deinit();
}
}
};