renderer/vulkan: VkCommandPool wrapper + one-shot helper

Adds `vulkan/CommandPool.zig` — the missing piece between
`Texture.zig` having an image handle and actually being able to
upload pixels to it. Provides the `init` / `deinit` lifecycle plus
a `beginOneShot` → `OneShot.endAndSubmit` helper that runs a
caller-recorded command buffer to completion.

Pool flags: `TRANSIENT_BIT | RESET_COMMAND_BUFFER_BIT`. The
transient hint matches our usage pattern (every CB allocated here
is short-lived); the reset bit lets us free individual buffers
without dropping the whole pool.

One-shot semantics: alloc → begin → caller records → end → submit
→ `vkQueueWaitIdle` → free. The wait is acceptable here because
the only consumer for now is atlas / texture upload, which is rare
and naturally synchronous (the renderer wants the texture populated
before sampling it the next frame). Per-frame command submission
will land separately with fence-based pacing — never `queueWaitIdle`.

Dispatch additions: 10 new entries for the full one-shot path —
`vkCreateCommandPool`, `vkDestroyCommandPool`,
`vkAllocateCommandBuffers`, `vkFreeCommandBuffers`,
`vkBeginCommandBuffer`, `vkEndCommandBuffer`, `vkQueueSubmit`,
`vkQueueWaitIdle`, `vkCmdPipelineBarrier`, `vkCmdCopyBufferToImage`.
The last two are loaded here (rather than in the upcoming texture-
upload commit) because they're command-buffer-recording functions
and naturally belong with the rest of the command-buffer surface.

Verification: temp-switch compile-check; only the expected
downstream `DerivedConfig` error surfaced. Reverted. OpenGL build
still silent / clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-24 09:25:31 -05:00
parent a1a6d45c79
commit fafd928a80
3 changed files with 205 additions and 0 deletions

View File

@ -66,6 +66,7 @@
pub const Device = @import("vulkan/Device.zig");
pub const Sampler = @import("vulkan/Sampler.zig");
pub const Texture = @import("vulkan/Texture.zig");
pub const CommandPool = @import("vulkan/CommandPool.zig");
const bufferpkg = @import("vulkan/buffer.zig");
pub const Buffer = bufferpkg.Buffer;

View File

@ -0,0 +1,160 @@
//! Wrapper for `VkCommandPool` with a one-shot command-buffer helper.
//!
//! Initially used by `vulkan/Texture.zig` for staging-buffer uploads:
//! allocate a transient command buffer, record an upload + layout
//! barriers, submit, wait for completion, free.
//!
//! Eventually the renderer will grow a separate per-frame command
//! pool for the main draw stream; this pool stays around for
//! infrequent operations like atlas uploads where blocking the
//! caller is fine. The choice keeps the API small and avoids the
//! complication of multi-frame fence tracking for resources that
//! will outlive the upload.
const Self = @This();
const std = @import("std");
const vk = @import("vulkan").c;
const Device = @import("Device.zig");
const log = std.log.scoped(.vulkan);
pub const Error = error{
/// A `vkCreateCommandPool` / `vkAllocateCommandBuffers` /
/// `vkBeginCommandBuffer` / `vkEndCommandBuffer` / `vkQueueSubmit`
/// returned a non-success status. Logged with the raw `VkResult`.
VulkanFailed,
};
device: *const Device,
pool: vk.VkCommandPool,
/// Create a command pool on the device's graphics queue family. The
/// pool is created with `TRANSIENT_BIT | RESET_COMMAND_BUFFER_BIT`
/// because every command buffer we allocate here is short-lived and
/// freed (or reset) immediately after submit.
pub fn init(device: *const Device) Error!Self {
const info: vk.VkCommandPoolCreateInfo = .{
.sType = vk.VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
.pNext = null,
.flags = vk.VK_COMMAND_POOL_CREATE_TRANSIENT_BIT |
vk.VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
.queueFamilyIndex = device.queue_family_index,
};
var pool: vk.VkCommandPool = undefined;
const r = device.dispatch.createCommandPool(device.device, &info, null, &pool);
if (r != vk.VK_SUCCESS) {
log.err("vkCreateCommandPool failed: result={}", .{r});
return error.VulkanFailed;
}
return .{ .device = device, .pool = pool };
}
pub fn deinit(self: *Self) void {
self.device.dispatch.destroyCommandPool(self.device.device, self.pool, null);
self.* = undefined;
}
/// A one-shot recording session. Yielded from `beginOneShot`, drives
/// `endAndSubmit` when the caller is done recording.
pub const OneShot = struct {
pool: *Self,
cb: vk.VkCommandBuffer,
/// Record any commands directly via `cb` and the device dispatch
/// table (e.g. `pool.device.dispatch.cmdPipelineBarrier(cb, )`).
/// Then call `endAndSubmit`. The command buffer is freed by the
/// time this returns.
pub fn endAndSubmit(self: OneShot) Error!void {
const dev = self.pool.device;
{
const r = dev.dispatch.endCommandBuffer(self.cb);
if (r != vk.VK_SUCCESS) {
log.err("vkEndCommandBuffer failed: result={}", .{r});
return error.VulkanFailed;
}
}
const submit_info: vk.VkSubmitInfo = .{
.sType = vk.VK_STRUCTURE_TYPE_SUBMIT_INFO,
.pNext = null,
.waitSemaphoreCount = 0,
.pWaitSemaphores = null,
.pWaitDstStageMask = null,
.commandBufferCount = 1,
.pCommandBuffers = &self.cb,
.signalSemaphoreCount = 0,
.pSignalSemaphores = null,
};
{
const r = dev.dispatch.queueSubmit(dev.queue, 1, &submit_info, null);
if (r != vk.VK_SUCCESS) {
log.err("vkQueueSubmit failed: result={}", .{r});
return error.VulkanFailed;
}
}
// Block until the submit completes. Acceptable for one-shot
// uploads (atlas resizes are rare and the caller is willing
// to stall). Per-frame command submission will use fences
// and never queueWaitIdle.
{
const r = dev.dispatch.queueWaitIdle(dev.queue);
if (r != vk.VK_SUCCESS) {
log.err("vkQueueWaitIdle failed: result={}", .{r});
return error.VulkanFailed;
}
}
// Free the command buffer. The pool itself stays around so
// back-to-back uploads can reuse it without re-allocating
// VkCommandPool.
const cb_local = self.cb;
dev.dispatch.freeCommandBuffers(dev.device, self.pool.pool, 1, &cb_local);
}
};
/// Allocate + begin a transient command buffer for a one-shot
/// upload. Pair with `OneShot.endAndSubmit`.
pub fn beginOneShot(self: *Self) Error!OneShot {
const dev = self.device;
const alloc_info: vk.VkCommandBufferAllocateInfo = .{
.sType = vk.VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
.pNext = null,
.commandPool = self.pool,
.level = vk.VK_COMMAND_BUFFER_LEVEL_PRIMARY,
.commandBufferCount = 1,
};
var cb: vk.VkCommandBuffer = undefined;
{
const r = dev.dispatch.allocateCommandBuffers(dev.device, &alloc_info, &cb);
if (r != vk.VK_SUCCESS) {
log.err("vkAllocateCommandBuffers failed: result={}", .{r});
return error.VulkanFailed;
}
}
errdefer dev.dispatch.freeCommandBuffers(dev.device, self.pool, 1, &cb);
const begin_info: vk.VkCommandBufferBeginInfo = .{
.sType = vk.VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.pNext = null,
.flags = vk.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
.pInheritanceInfo = null,
};
{
const r = dev.dispatch.beginCommandBuffer(cb, &begin_info);
if (r != vk.VK_SUCCESS) {
log.err("vkBeginCommandBuffer failed: result={}", .{r});
return error.VulkanFailed;
}
}
return .{ .pool = self, .cb = cb };
}
test {
std.testing.refAllDecls(@This());
}

View File

@ -109,6 +109,20 @@ pub const Dispatch = struct {
bindBufferMemory: std.meta.Child(vk.PFN_vkBindBufferMemory),
mapMemory: std.meta.Child(vk.PFN_vkMapMemory),
unmapMemory: std.meta.Child(vk.PFN_vkUnmapMemory),
// Command pool / buffer + queue submit + recording
// used by `vulkan/CommandPool.zig` and (later) per-frame command
// recording in `vulkan/Frame.zig`.
createCommandPool: std.meta.Child(vk.PFN_vkCreateCommandPool),
destroyCommandPool: std.meta.Child(vk.PFN_vkDestroyCommandPool),
allocateCommandBuffers: std.meta.Child(vk.PFN_vkAllocateCommandBuffers),
freeCommandBuffers: std.meta.Child(vk.PFN_vkFreeCommandBuffers),
beginCommandBuffer: std.meta.Child(vk.PFN_vkBeginCommandBuffer),
endCommandBuffer: std.meta.Child(vk.PFN_vkEndCommandBuffer),
queueSubmit: std.meta.Child(vk.PFN_vkQueueSubmit),
queueWaitIdle: std.meta.Child(vk.PFN_vkQueueWaitIdle),
cmdPipelineBarrier: std.meta.Child(vk.PFN_vkCmdPipelineBarrier),
cmdCopyBufferToImage: std.meta.Child(vk.PFN_vkCmdCopyBufferToImage),
};
// ---- fields ---------------------------------------------------------
@ -301,6 +315,26 @@ pub fn init(
try dl.load(vk.PFN_vkMapMemory, "vkMapMemory");
const unmap_memory =
try dl.load(vk.PFN_vkUnmapMemory, "vkUnmapMemory");
const create_command_pool =
try dl.load(vk.PFN_vkCreateCommandPool, "vkCreateCommandPool");
const destroy_command_pool =
try dl.load(vk.PFN_vkDestroyCommandPool, "vkDestroyCommandPool");
const allocate_command_buffers =
try dl.load(vk.PFN_vkAllocateCommandBuffers, "vkAllocateCommandBuffers");
const free_command_buffers =
try dl.load(vk.PFN_vkFreeCommandBuffers, "vkFreeCommandBuffers");
const begin_command_buffer =
try dl.load(vk.PFN_vkBeginCommandBuffer, "vkBeginCommandBuffer");
const end_command_buffer =
try dl.load(vk.PFN_vkEndCommandBuffer, "vkEndCommandBuffer");
const queue_submit =
try dl.load(vk.PFN_vkQueueSubmit, "vkQueueSubmit");
const queue_wait_idle =
try dl.load(vk.PFN_vkQueueWaitIdle, "vkQueueWaitIdle");
const cmd_pipeline_barrier =
try dl.load(vk.PFN_vkCmdPipelineBarrier, "vkCmdPipelineBarrier");
const cmd_copy_buffer_to_image =
try dl.load(vk.PFN_vkCmdCopyBufferToImage, "vkCmdCopyBufferToImage");
return .{
.platform = platform,
@ -333,6 +367,16 @@ pub fn init(
.bindBufferMemory = bind_buffer_memory,
.mapMemory = map_memory,
.unmapMemory = unmap_memory,
.createCommandPool = create_command_pool,
.destroyCommandPool = destroy_command_pool,
.allocateCommandBuffers = allocate_command_buffers,
.freeCommandBuffers = free_command_buffers,
.beginCommandBuffer = begin_command_buffer,
.endCommandBuffer = end_command_buffer,
.queueSubmit = queue_submit,
.queueWaitIdle = queue_wait_idle,
.cmdPipelineBarrier = cmd_pipeline_barrier,
.cmdCopyBufferToImage = cmd_copy_buffer_to_image,
},
};
}