From c39474131ed95e683822ca425b6662b372f2a852 Mon Sep 17 00:00:00 2001 From: Anton Oreskin Date: Mon, 7 Sep 2026 22:12:27 +0200 Subject: [PATCH] Add owned Vulkan synchronization objects --- API_DESIGN.md | 16 +++++++- ergonomic/ergonomic.v | 82 ++++++++++++++++++++++++++++++++++++++ ergonomic/ergonomic_test.v | 36 +++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/API_DESIGN.md b/API_DESIGN.md index a1cc4f5..538cd6e 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -57,6 +57,15 @@ defer { command_buffer.begin(u32(vk.CommandBufferUsageFlagBits.one_time_submit))! // Record commands with command_buffer.handle. command_buffer.end()! +mut fence := device.new_fence(false)! +defer { + fence.destroy() +} +mut image_ready := device.new_semaphore()! +defer { + image_ready.destroy() +} +// Submit using fence.handle and image_ready.handle. println('${physical_device.name()}: queue family ${device.queue.family_index}') ``` @@ -64,6 +73,8 @@ println('${physical_device.name()}: queue family ${device.queue.family_index}') `CommandPool` belongs to its parent `Device` and is fixed to that device's selected queue-family index. `PrimaryCommandBuffer` retains the exact device and pool handles needed by `free()`, while its public raw `handle` remains available for recording and submission. `free()` is idempotent and clears that raw handle. Reset, begin, and end failures are returned as typed `VulkanError` values. Destroying a command pool implicitly frees and invalidates all command buffers still allocated from it; callers may either free buffers explicitly before pool destruction or rely on that Vulkan lifetime rule, but must never use or free a buffer after its pool is destroyed. Every command pool must be destroyed before its parent device. +`Fence` exposes status, timeout-aware waiting, and reset while preserving positive Vulkan statuses such as `VK_NOT_READY` and `VK_TIMEOUT`. `Fence` and `Semaphore` expose their raw handles for submission structures, clear those handles during idempotent destruction, and must be destroyed before their parent device. + Custom allocation callbacks, concurrent-sharing buffers, queue priorities other than 1.0, enabled features, and device extensions deliberately remain in the raw layer for now. A future configurable owning wrapper must retain the allocator used at creation so the same callbacks are supplied during destruction. ## Next slices @@ -71,5 +82,6 @@ Custom allocation callbacks, concurrent-sharing buffers, queue priorities other 1. Instance extension and layer enumeration with owned V strings. 2. Presentation-support selection layered onto the core queue-flag helper. 3. Configurable queue requests, extension validation, and enabled features. -4. Owned images and synchronization objects, each with explicit parent ownership and destruction ordering. -5. Builders only where they eliminate unsafe pointer/count bookkeeping; Vulkan synchronization and memory choices should remain explicit. +4. Owned fences and binary semaphores with explicit parent ownership and destruction ordering. (Implemented.) +5. Owned images with explicit parent ownership and destruction ordering. +6. Builders only where they eliminate unsafe pointer/count bookkeeping; Vulkan synchronization and memory choices should remain explicit. diff --git a/ergonomic/ergonomic.v b/ergonomic/ergonomic.v index 9c28fa7..0752fcc 100644 --- a/ergonomic/ergonomic.v +++ b/ergonomic/ergonomic.v @@ -396,6 +396,88 @@ pub fn (buffer OwnedBuffer) destroy() { vk.free_memory(buffer.device, buffer.memory, unsafe { nil }) } +// Fence owns a VkFence created by one Device. Its parent device must outlive +// it. The raw handle remains public for queue submission. +pub struct Fence { + device vk.Device +pub mut: + handle vk.Fence +} + +// new_fence creates a fence, optionally in the signaled state. +pub fn (device Device) new_fence(signaled bool) !Fence { + flags := if signaled { u32(vk.FenceCreateFlagBits.signaled) } else { vk.FenceCreateFlags(0) } + create_info := vk.FenceCreateInfo{ + flags: flags + } + mut handle := vk.Fence(unsafe { nil }) + require_success(vk.create_fence(device.handle, &create_info, unsafe { nil }, &handle), 'vkCreateFence')! + return Fence{ + device: device.handle + handle: handle + } +} + +// status returns VK_SUCCESS when signaled and VK_NOT_READY otherwise. +pub fn (fence Fence) status() !vk.Result { + return check(vk.get_fence_status(fence.device, fence.handle), 'vkGetFenceStatus') +} + +// is_signaled reports the current fence state. +pub fn (fence Fence) is_signaled() !bool { + return fence.status()! == .success +} + +// wait blocks for at most timeout nanoseconds and returns VK_SUCCESS or +// VK_TIMEOUT so callers can distinguish completion from expiration. +pub fn (fence Fence) wait(timeout u64) !vk.Result { + return check(vk.wait_for_fences(fence.device, 1, &fence.handle, vk.Bool32(1), timeout), 'vkWaitForFences') +} + +// reset returns the fence to the unsignaled state. +pub fn (fence Fence) reset() ! { + require_success(vk.reset_fences(fence.device, 1, &fence.handle), 'vkResetFences')! +} + +// destroy releases the fence and clears its handle. Repeated calls are +// harmless, but the parent Device must still be alive. +pub fn (mut fence Fence) destroy() { + if isnil(fence.handle) { + return + } + vk.destroy_fence(fence.device, fence.handle, unsafe { nil }) + fence.handle = vk.Fence(unsafe { nil }) +} + +// Semaphore owns a binary VkSemaphore created by one Device. Its raw handle +// remains public for submission and presentation structures. +pub struct Semaphore { + device vk.Device +pub mut: + handle vk.Semaphore +} + +// new_semaphore creates a core binary semaphore. +pub fn (device Device) new_semaphore() !Semaphore { + create_info := vk.SemaphoreCreateInfo{} + mut handle := vk.Semaphore(unsafe { nil }) + require_success(vk.create_semaphore(device.handle, &create_info, unsafe { nil }, &handle), 'vkCreateSemaphore')! + return Semaphore{ + device: device.handle + handle: handle + } +} + +// destroy releases the semaphore and clears its handle. Repeated calls are +// harmless, but the parent Device must still be alive. +pub fn (mut semaphore Semaphore) destroy() { + if isnil(semaphore.handle) { + return + } + vk.destroy_semaphore(semaphore.device, semaphore.handle, unsafe { nil }) + semaphore.handle = vk.Semaphore(unsafe { nil }) +} + // physical_devices performs Vulkan's count/fill enumeration pattern and // retries when the available device set changes and VK_INCOMPLETE is returned. pub fn (instance Instance) physical_devices() ![]PhysicalDevice { diff --git a/ergonomic/ergonomic_test.v b/ergonomic/ergonomic_test.v index d2ad76d..75fe25e 100644 --- a/ergonomic/ergonomic_test.v +++ b/ergonomic/ergonomic_test.v @@ -138,6 +138,42 @@ fn test_free_is_idempotent_for_an_already_cleared_command_buffer() { assert isnil(buffer.handle) } +fn test_fence_and_semaphore_retain_parent_device_and_raw_handles() { + device_handle := vk.Device(unsafe { nil }) + fence_handle := vk.Fence(unsafe { nil }) + semaphore_handle := vk.Semaphore(unsafe { nil }) + fence := Fence{ + device: device_handle + handle: fence_handle + } + semaphore := Semaphore{ + device: device_handle + handle: semaphore_handle + } + + assert fence.device == device_handle + assert fence.handle == fence_handle + assert semaphore.device == device_handle + assert semaphore.handle == semaphore_handle +} + +fn test_sync_destroy_is_idempotent_for_cleared_handles() { + mut fence := Fence{ + device: vk.Device(unsafe { nil }) + handle: vk.Fence(unsafe { nil }) + } + mut semaphore := Semaphore{ + device: vk.Device(unsafe { nil }) + handle: vk.Semaphore(unsafe { nil }) + } + fence.destroy() + fence.destroy() + semaphore.destroy() + semaphore.destroy() + assert isnil(fence.handle) + assert isnil(semaphore.handle) +} + fn memory_properties(types []vk.MemoryPropertyFlags) vk.PhysicalDeviceMemoryProperties { mut properties := vk.PhysicalDeviceMemoryProperties{ memoryTypeCount: u32(types.len)