From 4c047513420391e0cbfb9ff77512a88c1fae83b7 Mon Sep 17 00:00:00 2001 From: Anton Oreskin Date: Wed, 9 Sep 2026 10:45:24 +0200 Subject: [PATCH] Add Vulkan rendering conveniences and TinyCC loader support --- .github/workflows/generated-bindings-ci.yml | 9 + API_DESIGN.md | 23 ++- CHANGELOG.md | 17 ++ OWNERSHIP.md | 12 +- README.md | 6 +- c/loader_bridge.h | 26 +++ ergonomic/configuration.v | 2 +- ergonomic/ergonomic.v | 5 +- ergonomic/presentation.v | 175 ++++++++++++++++++++ ergonomic/presentation_test.v | 92 ++++++++++ ergonomic/resources.v | 163 ++++++++++++++++++ ergonomic/resources_test.v | 92 ++++++++++ examples/ergonomic_lifecycle/main.v | 11 ++ 13 files changed, 626 insertions(+), 7 deletions(-) create mode 100644 ergonomic/presentation.v create mode 100644 ergonomic/presentation_test.v create mode 100644 ergonomic/resources.v create mode 100644 ergonomic/resources_test.v diff --git a/.github/workflows/generated-bindings-ci.yml b/.github/workflows/generated-bindings-ci.yml index b4afc70..279ea5a 100644 --- a/.github/workflows/generated-bindings-ci.yml +++ b/.github/workflows/generated-bindings-ci.yml @@ -160,6 +160,7 @@ jobs: cp vulkan.v vulkan_video.v loader.v v.mod VERSION REGISTRY_COMMIT VOLK_COMMIT "$module_root/" cp c/loader_bridge.h c/volk.c.v "$module_root/c/" cp ergonomic/ergonomic.v ergonomic/discovery.v ergonomic/configuration.v \ + ergonomic/presentation.v ergonomic/resources.v \ "$module_root/ergonomic/" echo "VMODULES=$RUNNER_TEMP/vmodules" >> "$GITHUB_ENV" - name: Compile smoke test and ergonomic unit tests @@ -175,6 +176,14 @@ jobs: v -cc "${{ matrix.compiler }}" run generator/test fi v -cc "${{ matrix.compiler }}" test ergonomic + - name: Validate TinyCC loader and ergonomic API + if: runner.os == 'Linux' && matrix.v-channel == 'stable' + shell: bash + run: | + set -euo pipefail + v -cc tcc test ergonomic + VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation \ + v -cc tcc run examples/ergonomic_lifecycle - name: Report V master compatibility warning if: always() && matrix.v-channel == 'master' && (steps.checkout_v_master.outcome == 'failure' || steps.setup_v_master.outcome == 'failure' || steps.compile.outcome == 'failure') run: echo "::warning::The live V master compatibility lane failed; stable V 0.5.2 remains authoritative." diff --git a/API_DESIGN.md b/API_DESIGN.md index 1f7b2d4..cc67cf4 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -112,6 +112,25 @@ println('${physical_device.name()}: queue family ${device.queue.family_index}') `OwnedImageView` creates an identity-swizzled 2D view using the image's format and explicit aspect mask. It exposes the raw view and parent-image handles, view type, format, and complete subresource range. Every view must be destroyed before its image. +`PhysicalDevice.surface_support()` owns the three presentation queries needed +for swapchain negotiation. The accompanying selection helpers keep policy +explicit through ordered preference arrays while correctly handling Vulkan's +undefined-format sentinel, FIFO fallback, fixed versus variable extents, +bounded image counts, and composite-alpha masks. + +`OwnedBuffer.map()` validates the range and host-visible property before +returning a borrowed `MappedBufferMemory`. Its `write_bytes()` helper is +restricted to host-coherent memory so a successful return means the bytes are +visible without a separate flush. Use the public pointer plus raw flush and +invalidate commands for non-coherent memory. `upload_bytes()` provides the +common one-shot map, copy, and unmap sequence. + +`OwnedShaderModule` validates SPIR-V size, alignment, and magic before calling +Vulkan. Byte input is copied to aligned words for the duration of creation. +Shader modules and mappings must be destroyed or unmapped before their parent +resource or device. Device and queue `wait_idle()` helpers preserve typed +Vulkan errors. + `ImageLayoutTransition` keeps the synchronization-1 source/destination stage masks, access masks, old/new layouts, dependency flags, and aspect mask explicit. `PrimaryCommandBuffer.transition_image_layout()` records one image-only `vkCmdPipelineBarrier` over the owned image's single mip level and array layer. It does not infer synchronization, track layout state, or perform queue-family ownership transfers; use the raw API for broader ranges, ownership transfers, or synchronization-2 barriers. `InstanceOptions` validates and owns instance layer/extension name pointers through creation. `DeviceOptions` accepts either its compatible single-queue fields or `DeviceQueueRequest` values for multiple queues and families, plus device extensions, core features, and an application-owned feature `pNext` chain. `PhysicalDevice.find_present_queue_family()` layers surface support over the existing queue-flag selection. Custom allocation callbacks and concurrent-sharing resources remain in the raw layer. A future allocator-aware owning wrapper must retain the allocator used at creation so the same callbacks are supplied during destruction. @@ -125,4 +144,6 @@ println('${physical_device.name()}: queue family ${device.queue.family_index}') 5. Owned 2D images with explicit parent ownership and destruction ordering. (Implemented.) 6. Checked primary command-buffer queue submission with explicit synchronization. (Implemented.) 7. Owned 2D image views and focused synchronization-1 layout-transition recording. (Implemented.) -8. Builders only where they eliminate unsafe pointer/count bookkeeping; Vulkan synchronization and memory choices should remain explicit. +8. Surface-support snapshots and explicit swapchain-choice helpers. (Implemented.) +9. Checked host-visible buffer mappings, coherent byte uploads, and owned SPIR-V shader modules. (Implemented.) +10. Builders only where they eliminate unsafe pointer/count bookkeeping; Vulkan synchronization and memory choices should remain explicit. diff --git a/CHANGELOG.md b/CHANGELOG.md index cbec844..418c58d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ This changelog tracks the semantic version of the V module in `v.mod`. Generated binding snapshots continue to use the Vulkan registry version stored in `VERSION`. +## Unreleased + +### Added + +- Owned SPIR-V shader modules with byte-count, alignment, and magic validation. +- Checked host-visible buffer mappings, persistent coherent byte writes, and + one-shot map/copy/unmap uploads. +- Owned surface-support discovery plus reusable format, present-mode, extent, + image-count, and composite-alpha selection helpers. +- Typed device and queue idle waits. +- TinyCC loader and lifecycle coverage on Linux. + +### Fixed + +- Use a deep-bound Vulkan loader handle under TinyCC so Volk's exported global + dispatch variables cannot shadow loader entry points. + ## 1.6.0 - 2026-09-09 ### Added diff --git a/OWNERSHIP.md b/OWNERSHIP.md index 0604952..7c052da 100644 --- a/OWNERSHIP.md +++ b/OWNERSHIP.md @@ -7,9 +7,15 @@ in the reverse order of creation. V structs are values and can be copied. A copied `Instance`, `Device`, `CommandPool`, `OwnedBuffer`, `OwnedImage`, or `OwnedImageView` refers to the same native allocation; destroying more than one copy is invalid. `Fence`, -`Semaphore`, and `PrimaryCommandBuffer` clear their handle when a mutable value -is destroyed or freed, but a previously made copy is still independent and can -retain the old handle. +`Semaphore`, `PrimaryCommandBuffer`, `MappedBufferMemory`, and +`OwnedShaderModule` clear their handle or pointer when a mutable value is +destroyed, freed, or unmapped, but a previously made copy is still independent +and can retain the old value. + +A mapped range borrows its `OwnedBuffer`; unmap it before destroying the buffer. +An `OwnedShaderModule` must be destroyed before its parent device. A successful +`new_shader_module*` call copies or consumes SPIR-V only during creation, so the +input slice need not outlive the call. Until a breaking ownership redesign, follow these rules: diff --git a/README.md b/README.md index d725275..2c09cd4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,11 @@ multi-queue logical-device ownership. It also provides explicit memory-type selection and owned buffer/device-memory allocation, owned command pools and primary command-buffer lifecycle helpers, synchronization objects, checked queue submission, owned 2D images and views, and explicit image-layout transition -recording without modifying generated files. +recording without modifying generated files. Presentation helpers collect and +select surface formats, present modes, extents, image counts, and composite +alpha modes. Host-visible buffers support checked persistent mappings and +coherent uploads, while owned shader modules accept validated SPIR-V words or +bytes. See [the ergonomic API design](API_DESIGN.md). Instance and device configuration can validate requested names before Vulkan is diff --git a/c/loader_bridge.h b/c/loader_bridge.h index 99d5919..94b92d6 100644 --- a/c/loader_bridge.h +++ b/c/loader_bridge.h @@ -3,7 +3,32 @@ // Keep initialization and validation in the translation unit that owns Volk's // global dispatch table. Some Linux loader/SDK combinations report success from // volkInitialize() while leaving vkCreateInstance unresolved. +#if defined(__TINYC__) && defined(__linux__) && !defined(RTLD_DEEPBIND) +#define RTLD_DEEPBIND 0x00008 +#endif + static VkResult v_vulkan_initialize_loader(void) { +#if defined(__TINYC__) && defined(__linux__) && defined(RTLD_DEEPBIND) + // TinyCC exports Volk's global function-pointer variables from the main + // executable. Without deep binding, the Vulkan loader can resolve names + // such as vkCreateInstance back to those variables instead of its own + // entry points. Load the ICD-facing loader in its own symbol scope and give + // Volk the unambiguous vkGetInstanceProcAddr address. + void *loader = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND); + if (loader == NULL) { + loader = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND); + } + if (loader == NULL) { + return VK_ERROR_INITIALIZATION_FAILED; + } + PFN_vkGetInstanceProcAddr get_instance_proc_addr = + (PFN_vkGetInstanceProcAddr)dlsym(loader, "vkGetInstanceProcAddr"); + if (get_instance_proc_addr == NULL) { + return VK_ERROR_INITIALIZATION_FAILED; + } + volkInitializeCustom(get_instance_proc_addr); + return vkCreateInstance != NULL ? VK_SUCCESS : VK_ERROR_INITIALIZATION_FAILED; +#else VkResult result = volkInitialize(); if (result != VK_SUCCESS || vkCreateInstance != NULL) { return result; @@ -20,4 +45,5 @@ static VkResult v_vulkan_initialize_loader(void) { #endif return vkCreateInstance != NULL ? VK_SUCCESS : VK_ERROR_INITIALIZATION_FAILED; +#endif } diff --git a/ergonomic/configuration.v b/ergonomic/configuration.v index f84237f..8c7d997 100644 --- a/ergonomic/configuration.v +++ b/ergonomic/configuration.v @@ -34,7 +34,7 @@ pub fn new_instance_with_options(options InstanceOptions) !Instance { for extension in options.extensions { extension_pointers << extension.str } - application := vk.ApplicationInfo{ + mut application := vk.ApplicationInfo{ pApplicationName: options.application_name.str applicationVersion: options.application_version pEngineName: options.engine_name.str diff --git a/ergonomic/ergonomic.v b/ergonomic/ergonomic.v index ccd5707..0e2738a 100644 --- a/ergonomic/ergonomic.v +++ b/ergonomic/ergonomic.v @@ -324,6 +324,7 @@ pub: size vk.DeviceSize allocation_size vk.DeviceSize memory_type_index u32 + memory_properties vk.MemoryPropertyFlags } // new_buffer creates an exclusive-sharing buffer, selects a compatible memory @@ -344,7 +345,8 @@ pub fn (device Device) new_buffer(size vk.DeviceSize, usage vk.BufferUsageFlags, mut requirements := vk.MemoryRequirements{} vk.get_buffer_memory_requirements(device.handle, handle, mut requirements) - memory_type_index := device.physical_device.find_memory_type(requirements.memoryTypeBits, required_memory_properties) or { + physical_memory := device.physical_device.memory_properties() + memory_type_index := select_memory_type(physical_memory, requirements.memoryTypeBits, required_memory_properties) or { vk.destroy_buffer(device.handle, handle, unsafe { nil }) return error('no compatible memory type for buffer') } @@ -372,6 +374,7 @@ pub fn (device Device) new_buffer(size vk.DeviceSize, usage vk.BufferUsageFlags, size: size allocation_size: requirements.size memory_type_index: memory_type_index + memory_properties: physical_memory.memoryTypes[memory_type_index].propertyFlags } } diff --git a/ergonomic/presentation.v b/ergonomic/presentation.v new file mode 100644 index 0000000..7b2fe50 --- /dev/null +++ b/ergonomic/presentation.v @@ -0,0 +1,175 @@ +module ergonomic + +import antono2.vulkan as vk + +// SurfaceSupport is an owned snapshot of the presentation capabilities used +// when choosing a swapchain configuration. +pub struct SurfaceSupport { +pub: + capabilities vk.SurfaceCapabilitiesKHR + formats []vk.SurfaceFormatKHR + present_modes []vk.PresentModeKHR +} + +// surface_capabilities queries the fixed and variable limits for surface. +pub fn (device PhysicalDevice) surface_capabilities(surface vk.SurfaceKHR) !vk.SurfaceCapabilitiesKHR { + if isnil(surface) { + return error('presentation surface must not be null') + } + mut capabilities := vk.SurfaceCapabilitiesKHR{} + require_success(vk.get_physical_device_surface_capabilities_khr(device.handle, surface, mut capabilities), 'vkGetPhysicalDeviceSurfaceCapabilitiesKHR')! + return capabilities +} + +// surface_formats performs Vulkan's count/fill enumeration and retries if the +// surface changes while it is being queried. +pub fn (device PhysicalDevice) surface_formats(surface vk.SurfaceKHR) ![]vk.SurfaceFormatKHR { + if isnil(surface) { + return error('presentation surface must not be null') + } + for { + mut count := u32(0) + mut no_formats := unsafe { nil } + result := vk.get_physical_device_surface_formats_khr(device.handle, surface, &count, mut no_formats) + if result == .incomplete { + continue + } + require_success(result, 'vkGetPhysicalDeviceSurfaceFormatsKHR(count)')! + if count == 0 { + return []vk.SurfaceFormatKHR{} + } + mut formats := []vk.SurfaceFormatKHR{len: int(count)} + fill_result := vk.get_physical_device_surface_formats_khr(device.handle, surface, &count, mut formats[0]) + if fill_result == .incomplete { + continue + } + require_success(fill_result, 'vkGetPhysicalDeviceSurfaceFormatsKHR(values)')! + return formats[..int(count)].clone() + } + return []vk.SurfaceFormatKHR{} +} + +// surface_present_modes performs Vulkan's count/fill enumeration and retries +// if the surface changes while it is being queried. +pub fn (device PhysicalDevice) surface_present_modes(surface vk.SurfaceKHR) ![]vk.PresentModeKHR { + if isnil(surface) { + return error('presentation surface must not be null') + } + for { + mut count := u32(0) + result := vk.get_physical_device_surface_present_modes_khr(device.handle, surface, &count, unsafe { nil }) + if result == .incomplete { + continue + } + require_success(result, 'vkGetPhysicalDeviceSurfacePresentModesKHR(count)')! + if count == 0 { + return []vk.PresentModeKHR{} + } + mut modes := []vk.PresentModeKHR{len: int(count)} + fill_result := vk.get_physical_device_surface_present_modes_khr(device.handle, surface, &count, modes.data) + if fill_result == .incomplete { + continue + } + require_success(fill_result, 'vkGetPhysicalDeviceSurfacePresentModesKHR(values)')! + return modes[..int(count)].clone() + } + return []vk.PresentModeKHR{} +} + +// surface_support obtains the capabilities, formats, and present modes needed +// to configure or recreate a swapchain. +pub fn (device PhysicalDevice) surface_support(surface vk.SurfaceKHR) !SurfaceSupport { + return SurfaceSupport{ + capabilities: device.surface_capabilities(surface)! + formats: device.surface_formats(surface)! + present_modes: device.surface_present_modes(surface)! + } +} + +// select_surface_format picks the first available exact match in preferred +// order, falling back to the first advertised format. A lone undefined format +// means the surface accepts the caller's first preference. +pub fn select_surface_format(available []vk.SurfaceFormatKHR, + preferred []vk.SurfaceFormatKHR) !vk.SurfaceFormatKHR { + if available.len == 0 { + return error('surface exposes no formats') + } + if available.len == 1 && available[0].format == .undefined && preferred.len > 0 { + return preferred[0] + } + for wanted in preferred { + for candidate in available { + if candidate.format == wanted.format && candidate.colorSpace == wanted.colorSpace { + return candidate + } + } + } + return available[0] +} + +// select_present_mode picks the first available mode in preferred order. FIFO +// is the portable fallback; malformed empty input is rejected. +pub fn select_present_mode(available []vk.PresentModeKHR, + preferred []vk.PresentModeKHR) !vk.PresentModeKHR { + if available.len == 0 { + return error('surface exposes no present modes') + } + for wanted in preferred { + if wanted in available { + return wanted + } + } + if vk.PresentModeKHR.fifo in available { + return .fifo + } + return available[0] +} + +// select_surface_extent returns the surface's fixed extent when one is set, +// otherwise clamps the requested framebuffer extent to the advertised range. +pub fn select_surface_extent(capabilities vk.SurfaceCapabilitiesKHR, + requested vk.Extent2D) vk.Extent2D { + if capabilities.currentExtent.width != max_u32 { + return capabilities.currentExtent + } + return vk.Extent2D{ + width: clamp_u32(requested.width, capabilities.minImageExtent.width, capabilities.maxImageExtent.width) + height: clamp_u32(requested.height, capabilities.minImageExtent.height, capabilities.maxImageExtent.height) + } +} + +fn clamp_u32(value u32, minimum u32, maximum u32) u32 { + return if value < minimum { + minimum + } else if value > maximum { + maximum + } else { + value + } +} + +// select_surface_image_count requests minImageCount plus additional_images and +// clamps the result to maxImageCount when the surface defines a maximum. +pub fn select_surface_image_count(capabilities vk.SurfaceCapabilitiesKHR, + additional_images u32) u32 { + mut count := if additional_images > max_u32 - capabilities.minImageCount { + max_u32 + } else { + capabilities.minImageCount + additional_images + } + if capabilities.maxImageCount > 0 && count > capabilities.maxImageCount { + count = capabilities.maxImageCount + } + return count +} + +// select_composite_alpha picks the first supported mode in preferred order. +pub fn select_composite_alpha(supported vk.CompositeAlphaFlagsKHR, + preferred []vk.CompositeAlphaFlagBitsKHR) !vk.CompositeAlphaFlagBitsKHR { + for wanted in preferred { + if supported & u32(wanted) != 0 { + return wanted + } + } + return error('surface exposes none of the preferred composite-alpha modes') +} diff --git a/ergonomic/presentation_test.v b/ergonomic/presentation_test.v new file mode 100644 index 0000000..2d65f7f --- /dev/null +++ b/ergonomic/presentation_test.v @@ -0,0 +1,92 @@ +module ergonomic + +import antono2.vulkan as vk + +fn surface_format(format vk.Format, color_space vk.ColorSpaceKHR) vk.SurfaceFormatKHR { + return vk.SurfaceFormatKHR{ + format: format + colorSpace: color_space + } +} + +fn test_select_surface_format_uses_preference_order() ! { + linear := surface_format(.r8g8b8a8_unorm, .srgb_nonlinear) + srgb := surface_format(.b8g8r8a8_srgb, .srgb_nonlinear) + selected := select_surface_format([linear, srgb], [srgb, linear])! + assert selected.format == .b8g8r8a8_srgb + assert selected.colorSpace == .srgb_nonlinear +} + +fn test_select_surface_format_accepts_preference_for_undefined_surface() ! { + undefined := surface_format(.undefined, .srgb_nonlinear) + preferred := surface_format(.b8g8r8a8_srgb, .srgb_nonlinear) + assert select_surface_format([undefined], [preferred])! == preferred +} + +fn test_select_surface_format_rejects_empty_input() { + select_surface_format([], []) or { + assert err.msg() == 'surface exposes no formats' + return + } + assert false +} + +fn test_select_present_mode_uses_preference_then_fifo_fallback() ! { + available := [vk.PresentModeKHR.fifo, .mailbox] + assert select_present_mode(available, [.immediate, .mailbox])! == .mailbox + assert select_present_mode(available, [.immediate])! == .fifo +} + +fn test_select_present_mode_rejects_empty_input() { + select_present_mode([], [.fifo]) or { + assert err.msg() == 'surface exposes no present modes' + return + } + assert false +} + +fn test_select_surface_extent_uses_fixed_extent() { + capabilities := vk.SurfaceCapabilitiesKHR{ + currentExtent: vk.Extent2D{ width: 800, height: 600 } + } + assert select_surface_extent(capabilities, vk.Extent2D{ width: 1920, height: 1080 }) == vk.Extent2D{ width: 800, height: 600 } +} + +fn test_select_surface_extent_clamps_variable_extent() { + capabilities := vk.SurfaceCapabilitiesKHR{ + currentExtent: vk.Extent2D{ width: max_u32, height: max_u32 } + minImageExtent: vk.Extent2D{ width: 320, height: 240 } + maxImageExtent: vk.Extent2D{ width: 1920, height: 1080 } + } + assert select_surface_extent(capabilities, vk.Extent2D{ width: 200, height: 1440 }) == vk.Extent2D{ width: 320, height: 1080 } +} + +fn test_select_surface_image_count_clamps_and_avoids_overflow() { + bounded := vk.SurfaceCapabilitiesKHR{ minImageCount: 2, maxImageCount: 3 } + assert select_surface_image_count(bounded, 1) == 3 + assert select_surface_image_count(bounded, 10) == 3 + unbounded := vk.SurfaceCapabilitiesKHR{ minImageCount: max_u32 - 1 } + assert select_surface_image_count(unbounded, 10) == max_u32 +} + +fn test_select_composite_alpha_uses_supported_preference() ! { + supported := u32(vk.CompositeAlphaFlagBitsKHR.opaque) | u32(vk.CompositeAlphaFlagBitsKHR.inherit) + assert select_composite_alpha(supported, [.pre_multiplied, .inherit, .opaque])! == .inherit +} + +fn test_select_composite_alpha_rejects_missing_preference() { + select_composite_alpha(u32(vk.CompositeAlphaFlagBitsKHR.opaque), [.inherit]) or { + assert err.msg() == 'surface exposes none of the preferred composite-alpha modes' + return + } + assert false +} + +fn test_surface_queries_reject_null_before_vulkan_call() { + device := PhysicalDevice{} + device.surface_support(unsafe { nil }) or { + assert err.msg() == 'presentation surface must not be null' + return + } + assert false +} diff --git a/ergonomic/resources.v b/ergonomic/resources.v new file mode 100644 index 0000000..8d76c04 --- /dev/null +++ b/ergonomic/resources.v @@ -0,0 +1,163 @@ +module ergonomic + +import antono2.vulkan as vk + +// MappedBufferMemory is a borrowed mapping of one OwnedBuffer range. The +// buffer and its parent Device must outlive the mapping. +pub struct MappedBufferMemory { + device vk.Device + memory vk.DeviceMemory + memory_properties vk.MemoryPropertyFlags +pub: + offset vk.DeviceSize + size vk.DeviceSize +pub mut: + data voidptr +} + +fn validate_buffer_range(buffer_size vk.DeviceSize, offset vk.DeviceSize, + size vk.DeviceSize) ! { + if size == 0 { + return error('mapped buffer size must be greater than zero') + } + if offset > buffer_size || size > buffer_size - offset { + return error('mapped buffer range exceeds buffer size') + } +} + +// map maps a checked range of host-visible buffer memory. For non-coherent +// memory, callers using data directly remain responsible for flush/invalidate. +pub fn (buffer OwnedBuffer) map(offset vk.DeviceSize, size vk.DeviceSize, + flags vk.MemoryMapFlags) !MappedBufferMemory { + validate_buffer_range(buffer.size, offset, size)! + if buffer.memory_properties & u32(vk.MemoryPropertyFlagBits.host_visible) == 0 { + return error('buffer memory is not host visible') + } + mut data := voidptr(unsafe { nil }) + require_success(vk.map_memory(buffer.device, buffer.memory, offset, size, flags, &data), 'vkMapMemory')! + return MappedBufferMemory{ + device: buffer.device + memory: buffer.memory + memory_properties: buffer.memory_properties + offset: offset + size: size + data: data + } +} + +// write_bytes copies bytes into coherent mapped memory after validating the +// relative range. Empty writes are harmless. +pub fn (mapping MappedBufferMemory) write_bytes(relative_offset vk.DeviceSize, + bytes []u8) ! { + if isnil(mapping.data) { + return error('buffer memory is not mapped') + } + if mapping.memory_properties & u32(vk.MemoryPropertyFlagBits.host_coherent) == 0 { + return error('write_bytes requires host-coherent buffer memory') + } + byte_count := vk.DeviceSize(bytes.len) + if relative_offset > mapping.size || byte_count > mapping.size - relative_offset { + return error('mapped buffer write exceeds mapped range') + } + if bytes.len == 0 { + return + } + unsafe { + destination := voidptr(usize(mapping.data) + usize(relative_offset)) + vmemcpy(destination, bytes.data, bytes.len) + } +} + +// unmap ends this mapping and clears its pointer. Repeated calls are harmless. +pub fn (mut mapping MappedBufferMemory) unmap() { + if isnil(mapping.data) { + return + } + vk.unmap_memory(mapping.device, mapping.memory) + mapping.data = unsafe { nil } +} + +// upload_bytes performs one checked map/copy/unmap operation. The buffer must +// use host-visible, host-coherent memory. +pub fn (buffer OwnedBuffer) upload_bytes(offset vk.DeviceSize, bytes []u8) ! { + if bytes.len == 0 { + if offset > buffer.size { + return error('mapped buffer range exceeds buffer size') + } + return + } + mut mapping := buffer.map(offset, vk.DeviceSize(bytes.len), 0)! + defer { + mapping.unmap() + } + mapping.write_bytes(0, bytes)! +} + +// OwnedShaderModule owns a VkShaderModule created from validated SPIR-V words. +// Its parent Device must outlive it. +pub struct OwnedShaderModule { + device vk.Device +pub mut: + handle vk.ShaderModule +} + +fn validate_spirv_words(words []u32) ! { + if words.len == 0 { + return error('SPIR-V code must not be empty') + } + if words[0] != u32(0x07230203) { + return error('SPIR-V code has an invalid magic word') + } +} + +// new_shader_module creates an owned module from aligned SPIR-V words. +pub fn (device Device) new_shader_module(words []u32) !OwnedShaderModule { + validate_spirv_words(words)! + info := vk.ShaderModuleCreateInfo{ + codeSize: usize(words.len) * sizeof(u32) + pCode: words.data + } + mut handle := vk.ShaderModule(unsafe { nil }) + require_success(vk.create_shader_module(device.handle, &info, unsafe { nil }, &handle), 'vkCreateShaderModule')! + return OwnedShaderModule{ + device: device.handle + handle: handle + } +} + +// new_shader_module_bytes validates the byte count and copies bytes into an +// aligned word slice before creating an owned shader module. +pub fn (device Device) new_shader_module_bytes(code []u8) !OwnedShaderModule { + if code.len == 0 { + return error('SPIR-V code must not be empty') + } + word_size := int(sizeof(u32)) + if code.len % word_size != 0 { + return error('SPIR-V byte count must be a multiple of four') + } + mut words := []u32{len: code.len / word_size} + unsafe { + vmemcpy(words.data, code.data, code.len) + } + return device.new_shader_module(words) +} + +// destroy releases the shader module and clears its handle. Repeated calls +// are harmless while the parent Device remains alive. +pub fn (mut shader OwnedShaderModule) destroy() { + if isnil(shader.handle) { + return + } + vk.destroy_shader_module(shader.device, shader.handle, unsafe { nil }) + shader.handle = vk.ShaderModule(unsafe { nil }) +} + +// wait_idle waits for all work submitted to this device to complete. +pub fn (device Device) wait_idle() ! { + require_success(vk.device_wait_idle(device.handle), 'vkDeviceWaitIdle')! +} + +// wait_idle waits for all work submitted to this queue to complete. +pub fn (queue Queue) wait_idle() ! { + require_success(vk.queue_wait_idle(queue.handle), 'vkQueueWaitIdle')! +} diff --git a/ergonomic/resources_test.v b/ergonomic/resources_test.v new file mode 100644 index 0000000..473aea2 --- /dev/null +++ b/ergonomic/resources_test.v @@ -0,0 +1,92 @@ +module ergonomic + +import antono2.vulkan as vk + +fn test_validate_buffer_range_rejects_empty_and_overflowing_ranges() { + validate_buffer_range(64, 0, 0) or { + assert err.msg() == 'mapped buffer size must be greater than zero' + validate_buffer_range(64, 60, 8) or { + assert err.msg() == 'mapped buffer range exceeds buffer size' + return + } + } + assert false +} + +fn test_map_rejects_non_host_visible_memory_before_vulkan_call() { + buffer := OwnedBuffer{ + size: 64 + memory_properties: u32(vk.MemoryPropertyFlagBits.device_local) + } + buffer.map(0, 64, 0) or { + assert err.msg() == 'buffer memory is not host visible' + return + } + assert false +} + +fn test_write_bytes_checks_range_and_copies_to_coherent_mapping() ! { + mut storage := []u8{len: 8} + mapping := MappedBufferMemory{ + memory_properties: u32(vk.MemoryPropertyFlagBits.host_visible) | u32(vk.MemoryPropertyFlagBits.host_coherent) + size: 8 + data: storage.data + } + mapping.write_bytes(2, [u8(7), 8, 9])! + assert storage == [u8(0), 0, 7, 8, 9, 0, 0, 0] + mapping.write_bytes(7, [u8(1), 2]) or { + assert err.msg() == 'mapped buffer write exceeds mapped range' + return + } + assert false +} + +fn test_write_bytes_rejects_noncoherent_mapping() { + mut storage := []u8{len: 4} + mapping := MappedBufferMemory{ + memory_properties: u32(vk.MemoryPropertyFlagBits.host_visible) + size: 4 + data: storage.data + } + mapping.write_bytes(0, [u8(1)]) or { + assert err.msg() == 'write_bytes requires host-coherent buffer memory' + return + } + assert false +} + +fn test_upload_bytes_validates_empty_write_offset_without_vulkan_call() ! { + buffer := OwnedBuffer{ size: 4 } + buffer.upload_bytes(4, [])! + buffer.upload_bytes(5, []) or { + assert err.msg() == 'mapped buffer range exceeds buffer size' + return + } + assert false +} + +fn test_shader_validation_rejects_invalid_input_before_vulkan_call() { + device := Device{} + device.new_shader_module([]) or { + assert err.msg() == 'SPIR-V code must not be empty' + device.new_shader_module([u32(1)]) or { + assert err.msg() == 'SPIR-V code has an invalid magic word' + device.new_shader_module_bytes([u8(3), 2, 35]) or { + assert err.msg() == 'SPIR-V byte count must be a multiple of four' + return + } + } + } + assert false +} + +fn test_mapping_and_shader_cleanup_are_idempotent_when_cleared() { + mut mapping := MappedBufferMemory{} + mapping.unmap() + mapping.unmap() + assert isnil(mapping.data) + mut shader := OwnedShaderModule{} + shader.destroy() + shader.destroy() + assert isnil(shader.handle) +} diff --git a/examples/ergonomic_lifecycle/main.v b/examples/ergonomic_lifecycle/main.v index 533030d..3a0e4c8 100644 --- a/examples/ergonomic_lifecycle/main.v +++ b/examples/ergonomic_lifecycle/main.v @@ -56,6 +56,15 @@ fn main() { defer { buffer.destroy() } + host_memory_properties := u32(vk.MemoryPropertyFlagBits.host_visible) | u32(vk.MemoryPropertyFlagBits.host_coherent) + host_buffer := device.new_buffer(64, u32(vk.BufferUsageFlagBits.transfer_src), host_memory_properties) or { panic(err) } + defer { + host_buffer.destroy() + } + host_buffer.upload_bytes(0, [u8(1), 2, 3, 4]) or { panic(err) } + mut mapped := host_buffer.map(4, 4, 0) or { panic(err) } + mapped.write_bytes(0, [u8(5), 6, 7, 8]) or { panic(err) } + mapped.unmap() image_usage := u32(vk.ImageUsageFlagBits.sampled) | u32(vk.ImageUsageFlagBits.transfer_dst) image := device.new_image_2d(64, 64, .r8g8b8a8_unorm, .optimal, image_usage, memory_properties) or { @@ -104,6 +113,8 @@ fn main() { if fence.wait(5_000_000_000) or { panic(err) } != .success { panic('queue submission did not complete before the smoke-test timeout') } + work_queue.wait_idle() or { panic(err) } + device.wait_idle() or { panic(err) } println('Vulkan ergonomic lifecycle passed on ${physical_device.name()} with ${device.queues.len} queue(s)') }