//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #include "vulkan/vulkan.h" #endif // !CRAFTER_GRAPHICS_WINDOW_DOM export module Crafter.Graphics:DescriptorHeapVulkan; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM import std; import :Device; import :Window; import :Types; import :VulkanBuffer; export namespace Crafter { struct ImageSlotRange { std::uint16_t firstElement; std::uint16_t count; }; struct BufferSlotRange { std::uint16_t firstElement; std::uint16_t count; }; struct SamplerSlotRange { std::uint16_t firstElement; std::uint16_t count; }; struct DescriptorHeapVulkan { VulkanBuffer resourceHeap[Window::numFrames]; VulkanBuffer samplerHeap[Window::numFrames]; std::uint32_t bufferStartOffset; std::uint16_t bufferStartElement; // Where each heap's reserved range starts; it runs to the heap's end. // Heap mode only. Bind passes them on. std::uint32_t resourceReservedRangeOffset = 0; std::uint32_t samplerReservedRangeOffset = 0; std::uint16_t imageCapacity = 0; std::uint16_t bufferCapacity = 0; std::uint16_t samplerCapacity = 0; std::uint16_t imageNext = 0; std::uint16_t bufferNext = 0; std::uint16_t samplerNext = 0; // Per-pool freelists of recyclable slot indices (raw, pre-bufferStartElement // for the buffer pool). Populated by Free*Slots, consumed by Allocate*Slots // before falling through to the bump pointer. Single-slot allocations only — // multi-slot allocations need contiguity and bypass the freelist. std::vector imageFreelist; std::vector bufferFreelist; std::vector samplerFreelist; // ─── Sets mode ────────────────────────────────────────────────── // Used instead of the heap buffers above when the driver has no // VK_EXT_descriptor_heap (Device::descriptorHeapSupported == false). // The same slots then live in one bindless descriptor set per frame, // mirroring the per-frame heap copies, so slot allocation, the slot // handles and every shader-side index keep their meaning: // binding 0 STORAGE_IMAGE[] image slots written as storage images // binding 1 SAMPLED_IMAGE[] image slots written as sampled images // binding 2 SAMPLER[] sampler slots // binding 3 STORAGE_BUFFER[] buffer slots (bufferStartElement is 0) // These numbers are the UI_HEAP_* macros in shaders/ui-shared.glsl. // Bindings 0 and 1 share the image slot space the way the two typed // views of the resource heap do; a slot is valid in whichever of the // two it was written to. // // All bindings are PARTIALLY_BOUND | UPDATE_AFTER_BIND | // UPDATE_UNUSED_WHILE_PENDING: most slots are never written, and a // slot is written into every frame's set at once while earlier frames // may still be executing with their set bound. A swapchain slot too // is written into a set another window's frame in flight is bound // to, and when listed during a frame, into the set that frame has // already bound. static constexpr std::uint32_t storageImageBinding = 0; static constexpr std::uint32_t sampledImageBinding = 1; static constexpr std::uint32_t samplerBinding = 2; static constexpr std::uint32_t storageBufferBinding = 3; VkDescriptorPool setPool = VK_NULL_HANDLE; VkDescriptorSetLayout setLayout = VK_NULL_HANDLE; // Shared by every compute pipeline created against this heap // (ComputeShader::Load), so the set Window::Render binds once per // frame stays bound across all of them. Carries one compute // push-constant range covering the device's whole push-constant space. VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; VkDescriptorSet sets[Window::numFrames] {}; // Descriptor sets reference VkImageView / VkSampler objects, where the // heap takes create-infos, so in sets mode this struct creates and // owns them: setImageViews[slot] is the view every frame's set uses // at that image slot, setSamplers[slot] likewise. The swapchain's // views are its window's (Window::imageViews). std::vector setImageViews; std::vector setSamplers; // ─── Swapchain slots ──────────────────────────────────────────── // Image slots that name a window's swapchain image. A window's // swapchain images are not its frames: it keeps Window::numFrames // frames in flight over however many images the driver made, and // which one a frame draws into is known only once it has been // acquired (#160). So each frame, after the acquire and before // onUpdate, Window::Render writes the image it acquired into the slots // listed here for it, as a storage image in GENERAL, in that frame's // heap copy (or descriptor set), through WriteSwapchainImage. A shader // reads the image it draws into from one fixed slot, and nothing has // to be written again after a resize. Each slot is one window's, so // two windows can share a heap: neither writes the other's slots, // which the other's frames in flight may be reading. UIRenderer lists // its output slot; a pass of your own lists its own with // AddSwapchainSlot. Freeing a slot takes it off the list. struct SwapchainSlot { std::uint16_t slot = 0; // Compared, never dereferenced: a window gone takes nothing down. const Window* window = nullptr; // What each frame's copy holds at the slot, so a frame whose image // hasn't changed writes nothing: the image's index in the // swapchain of that Window::swapchainGeneration, which starts at // 1, so generation 0 means nothing yet. struct Written { std::uint64_t generation = 0; std::uint32_t image = 0; }; std::array written {}; }; std::vector swapchainSlots; // Lists an image slot, raw as AllocateImageSlots returned it, as one // that names `window`'s swapchain image. Every frame's copy gets it // from that frame on; a slot listed during a frame `window` records // with this heap gets that frame's image at once, so a pass set up // mid-frame draws into it the same frame. Listing a slot again moves // it to `window`. Throws std::invalid_argument for a slot past the // heap's image slots, which an empty ImageSlot (0xFFFF) is. void AddSwapchainSlot(std::uint16_t slot, const Window& window) { if (slot >= imageCapacity) { throw std::invalid_argument(std::format( "DescriptorHeapVulkan::AddSwapchainSlot: image slot {} is not one of the heap's {} image slots", slot, imageCapacity)); } std::erase_if(swapchainSlots, [slot](const SwapchainSlot& listed) { return listed.slot == slot; }); swapchainSlots.push_back({ .slot = slot, .window = &window }); if (window.recording && window.descriptorHeap == this) { WriteSwapchainImage(window); } } // Writes window.imageViewInfos[window.currentImage] (in sets mode, // window.imageViews[window.currentImage]) into window's swapchain // slots in the copy of window.currentFrame. Window::Render calls it // once the frame's fence has been waited, so none of window's frames // still reads that copy, and other windows' frames don't read these // slots. void WriteSwapchainImage(const Window& window) { for (SwapchainSlot& listed : swapchainSlots) { if (listed.window != &window) { continue; } SwapchainSlot::Written& written = listed.written[window.currentFrame]; if (written.generation == window.swapchainGeneration && written.image == window.currentImage) { continue; } if (Device::descriptorHeapSupported) { const VkImageDescriptorInfoEXT imageInfo { .sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT, .pView = &window.imageViewInfos[window.currentImage], .layout = VK_IMAGE_LAYOUT_GENERAL, }; const VkResourceDescriptorInfoEXT resource { .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .data = { .pImage = &imageInfo }, }; VulkanBuffer& heap = resourceHeap[window.currentFrame]; const std::uint32_t size = Device::descriptorHeapProperties.imageDescriptorSize; const VkHostAddressRangeEXT destination { .address = heap.value + ImageByteOffset(listed.slot), .size = size, }; Device::vkWriteResourceDescriptorsEXT(Device::device, 1, &resource, &destination); heap.FlushDevice(ImageByteOffset(listed.slot), size); } else { const VkDescriptorImageInfo imageInfo { .imageView = window.imageViews[window.currentImage], .imageLayout = VK_IMAGE_LAYOUT_GENERAL, }; const VkWriteDescriptorSet write { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = sets[window.currentFrame], .dstBinding = storageImageBinding, .dstArrayElement = listed.slot, .descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .pImageInfo = &imageInfo, }; vkUpdateDescriptorSets(Device::device, 1, &write, 0, nullptr); } written = { .generation = window.swapchainGeneration, .image = window.currentImage }; } } DescriptorHeapVulkan() = default; DescriptorHeapVulkan(const DescriptorHeapVulkan&) = delete; DescriptorHeapVulkan& operator=(const DescriptorHeapVulkan&) = delete; // Heap mode owns nothing beyond its VulkanBuffers, which release // themselves. Sets mode owns the objects above. The frame loop keeps // frames in flight and nothing waits for them at shutdown, so wait // here: destroying a view or the pool under a still-executing command // buffer is a use-after-free on the GPU. ~DescriptorHeapVulkan() { if (setLayout == VK_NULL_HANDLE) { return; } vkQueueWaitIdle(Device::queue); DestroySets(); } // Destroys every sets-mode object and returns to the un-initialized // state. The caller guarantees nothing on the GPU still uses them. // Safe on a partly built heap: destroying VK_NULL_HANDLE is a no-op. void DestroySets() noexcept { vkDestroyDescriptorPool(Device::device, setPool, nullptr); for (VkImageView view : setImageViews) { vkDestroyImageView(Device::device, view, nullptr); } setImageViews.clear(); for (VkSampler sampler : setSamplers) { vkDestroySampler(Device::device, sampler, nullptr); } setSamplers.clear(); vkDestroyDescriptorSetLayout(Device::device, setLayout, nullptr); vkDestroyPipelineLayout(Device::device, pipelineLayout, nullptr); setPool = VK_NULL_HANDLE; setLayout = VK_NULL_HANDLE; pipelineLayout = VK_NULL_HANDLE; std::ranges::fill(sets, VkDescriptorSet{VK_NULL_HANDLE}); } // Where Initialize puts things in each frame's two heaps: image // descriptors from byte 0, the buffer descriptors after them, then // the implementation's reserved range, with which the heap ends; // sampler descriptors from byte 0 of the sampler heap, then its // reserved range. struct HeapLayout { std::uint16_t bufferStartElement; std::uint32_t bufferStartOffset; std::uint32_t resourceReservedRangeOffset; std::uint32_t resourceHeapSize; std::uint32_t samplerReservedRangeOffset; std::uint32_t samplerHeapSize; bool operator==(const HeapLayout&) const = default; }; // The layout of a heap with `images`, `buffers` and `samplers` slots // under these descriptor sizes and alignments. Heap mode only: // without the extension the sizes are zero. Pure, so the arithmetic // can be checked against sizes no local driver reports. // // A descriptor_heap array indexes the heap from byte 0 in strides of // its descriptor size (its ArrayStrideIdEXT is the OpConstantSizeOfEXT // of the descriptor type), so buffer slot 0 has to sit on a whole // buffer descriptor: the first one past the image descriptors. The // start used to be rounded down (#163), which put the buffers over the // last image descriptors wherever those were not a whole number of // buffer descriptors, and where they were less than one, sized the // heap as if the buffers began right after them, so the last buffer // descriptors landed in the reserved range, which belongs to the // implementation. Both need image descriptors smaller than buffer // descriptors; NVIDIA's and llvmpipe's are not. Every descriptor size // and alignment is a power of two, each alignment at most its size, // so a whole number of descriptors is aligned for its kind. The // reserved range has to start on a multiple of both resource // alignments (VUID-vkCmdBindResourceHeapEXT-pBindInfo-11435 and // -11436), which for powers of two is a multiple of the larger. The // heap ends with the reserved range. static HeapLayout ComputeLayout(const VkPhysicalDeviceDescriptorHeapPropertiesEXT& properties, std::uint16_t images, std::uint16_t buffers, std::uint16_t samplers) { const std::uint64_t imageEnd = images * properties.imageDescriptorSize; // Without buffer slots there is no buffer region to place, and its // start is 0, as in sets mode. const std::uint64_t bufferStart = buffers == 0 ? 0 : (imageEnd + properties.bufferDescriptorSize - 1) / properties.bufferDescriptorSize; // Shaders and BufferSlot carry absolute buffer indices in 16 bits, // and 0xFFFF is the empty slot. if (bufferStart + buffers > 0xFFFF) { throw std::runtime_error(std::format("DescriptorHeapVulkan: {} image and {} buffer slots do not fit 16-bit slot indices (with {}-byte image and {}-byte buffer descriptors the buffer slots start at index {})", images, buffers, properties.imageDescriptorSize, properties.bufferDescriptorSize, bufferStart)); } const std::uint64_t bufferStartOffset = bufferStart * properties.bufferDescriptorSize; // The reserved range follows whichever descriptors end last. Neither // heap is ever empty, though: a zero-size buffer is invalid // (VUID-VkBufferCreateInfo-size-00912), and on a driver that // reserves nothing (llvmpipe) the common Initialize(1, 1, 0) made // the sampler heap one. Each holds at least one descriptor, as each // binding does in InitializeSets. const std::uint64_t descriptorEnd = std::max({ imageEnd, bufferStartOffset + buffers * properties.bufferDescriptorSize, properties.bufferDescriptorSize }); const std::uint64_t resourceReserved = AlignUp(descriptorEnd, std::max(properties.imageDescriptorAlignment, properties.bufferDescriptorAlignment)); const std::uint64_t samplerReserved = std::max(samplers, 1) * properties.samplerDescriptorSize; return { .bufferStartElement = static_cast(bufferStart), .bufferStartOffset = static_cast(bufferStartOffset), .resourceReservedRangeOffset = static_cast(resourceReserved), .resourceHeapSize = static_cast(resourceReserved + properties.minResourceHeapReservedRange), .samplerReservedRangeOffset = static_cast(samplerReserved), .samplerHeapSize = static_cast(samplerReserved + properties.minSamplerHeapReservedRange), }; } // Call once, before any shader is loaded against this heap. In heap // mode a second call simply re-sizes the heap buffers; in sets mode it // throws, because the pipelines already built against the old layout // could not be used with a new one. void Initialize(std::uint16_t images, std::uint16_t buffers, std::uint16_t samplers) { if (!Device::descriptorHeapSupported) { InitializeSets(images, buffers, samplers); return; } const HeapLayout layout = ComputeLayout(Device::descriptorHeapProperties, images, buffers, samplers); bufferStartElement = layout.bufferStartElement; bufferStartOffset = layout.bufferStartOffset; resourceReservedRangeOffset = layout.resourceReservedRangeOffset; samplerReservedRangeOffset = layout.samplerReservedRangeOffset; imageCapacity = images; bufferCapacity = buffers; samplerCapacity = samplers; imageNext = 0; bufferNext = 0; samplerNext = 0; // Reserve so Free*Slots' push_back is noexcept (slot handle dtors // call Free*Slots and must not throw). At most `capacity` slots // can ever be free at one time. imageFreelist.clear(); imageFreelist.reserve(images); bufferFreelist.clear(); bufferFreelist.reserve(buffers); samplerFreelist.clear(); samplerFreelist.reserve(samplers); // Every slot is unallocated again, and the new heaps hold nothing. swapchainSlots.clear(); // The heaps are written by the CPU every frame (HOST_VISIBLE is // mandatory) and read by the GPU, so DEVICE_LOCAL is a perf // preference, not a requirement: devices without a host-visible // device-local heap (no resizable BAR) get a host-visible-only // allocation instead of a hard allocation failure. for(std::uint8_t i = 0; i < Window::numFrames; i++) { resourceHeap[i].Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_DESCRIPTOR_HEAP_BIT_EXT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, layout.resourceHeapSize, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); samplerHeap[i].Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_DESCRIPTOR_HEAP_BIT_EXT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, layout.samplerHeapSize, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); } } // Sets-mode half of Initialize. Kept apart so the heap sizing above, // which divides by descriptor sizes that are zero without the // extension, never runs here. void InitializeSets(std::uint16_t images, std::uint16_t buffers, std::uint16_t samplers) { // A second Initialize would leak the first pool and layouts and // leave every pipeline created against the old layout // incompatible with the new sets. if (setLayout != VK_NULL_HANDLE) { throw std::runtime_error("DescriptorHeapVulkan: Initialize called twice (descriptor-set mode cannot be re-initialized)"); } // If a step below throws, undo the ones that succeeded. A // half-built heap would otherwise keep setLayout set, so a retry // would report "Initialize called twice" instead of the real // error, and nothing would release what was created. struct Rollback { DescriptorHeapVulkan* heap; ~Rollback() { if (heap != nullptr) heap->DestroySets(); } } rollback{this}; // Buffer slots are plain array indices into binding 3, so // BufferSlot's conversion adds nothing. bufferStartElement = 0; bufferStartOffset = 0; imageCapacity = images; bufferCapacity = buffers; samplerCapacity = samplers; imageNext = 0; bufferNext = 0; samplerNext = 0; imageFreelist.clear(); imageFreelist.reserve(images); bufferFreelist.clear(); bufferFreelist.reserve(buffers); samplerFreelist.clear(); samplerFreelist.reserve(samplers); // Every slot is unallocated again, as in heap mode. swapchainSlots.clear(); // At least one descriptor per binding even when a pool was sized // zero (Initialize(1, 1, 0) is common): a zero-count pool size is // invalid, and the UI shaders statically reference all four arrays. const std::uint32_t imageCount = std::max(images, 1); const std::uint32_t bufferCount = std::max(buffers, 1); const std::uint32_t samplerCount = std::max(samplers, 1); const VkDescriptorSetLayoutBinding bindings[] = { { storageImageBinding, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, imageCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr }, { sampledImageBinding, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, imageCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr }, { samplerBinding, VK_DESCRIPTOR_TYPE_SAMPLER, samplerCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr }, { storageBufferBinding, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, bufferCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr }, }; constexpr VkDescriptorBindingFlags slotFlags = VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT | VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT | VK_DESCRIPTOR_BINDING_UPDATE_UNUSED_WHILE_PENDING_BIT; const VkDescriptorBindingFlags bindingFlags[] = { slotFlags, slotFlags, slotFlags, slotFlags }; const VkDescriptorSetLayoutBindingFlagsCreateInfo bindingFlagsInfo { .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO, .bindingCount = static_cast(std::size(bindingFlags)), .pBindingFlags = bindingFlags, }; const VkDescriptorSetLayoutCreateInfo layoutInfo { .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, .pNext = &bindingFlagsInfo, .flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT, .bindingCount = static_cast(std::size(bindings)), .pBindings = bindings, }; Device::CheckVkResult(vkCreateDescriptorSetLayout(Device::device, &layoutInfo, nullptr, &setLayout)); // One range over the whole push-constant space rather than a // per-shader size: the layout is shared, and each Dispatch pushes // only the bytes its shader declares. const VkPushConstantRange pushRange { .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT, .offset = 0, .size = Device::deviceProperties.limits.maxPushConstantsSize & ~3u, }; const VkPipelineLayoutCreateInfo pipelineLayoutInfo { .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1, .pSetLayouts = &setLayout, .pushConstantRangeCount = 1, .pPushConstantRanges = &pushRange, }; Device::CheckVkResult(vkCreatePipelineLayout(Device::device, &pipelineLayoutInfo, nullptr, &pipelineLayout)); const VkDescriptorPoolSize poolSizes[] = { { VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, imageCount * Window::numFrames }, { VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, imageCount * Window::numFrames }, { VK_DESCRIPTOR_TYPE_SAMPLER, samplerCount * Window::numFrames }, { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, bufferCount * Window::numFrames }, }; const VkDescriptorPoolCreateInfo poolInfo { .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, .flags = VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT, .maxSets = Window::numFrames, .poolSizeCount = static_cast(std::size(poolSizes)), .pPoolSizes = poolSizes, }; Device::CheckVkResult(vkCreateDescriptorPool(Device::device, &poolInfo, nullptr, &setPool)); VkDescriptorSetLayout setLayouts[Window::numFrames]; std::ranges::fill(setLayouts, setLayout); const VkDescriptorSetAllocateInfo allocInfo { .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, .descriptorPool = setPool, .descriptorSetCount = Window::numFrames, .pSetLayouts = setLayouts, }; Device::CheckVkResult(vkAllocateDescriptorSets(Device::device, &allocInfo, sets)); setImageViews.assign(imageCount, VK_NULL_HANDLE); setSamplers.assign(samplerCount, VK_NULL_HANDLE); rollback.heap = nullptr; } // ─── Sets-mode descriptor writes ──────────────────────────────── // The counterparts of vkWriteResourceDescriptorsEXT / // vkWriteSamplerDescriptorsEXT into the heap buffers. Call only when // !Device::descriptorHeapSupported. `slot` is the raw index the // Allocate*Slots call returned. // // Each write replaces whatever view or sampler the slot held and // destroys it immediately. That relies on the same contract as slot // reuse in heap mode: no frame in flight may still reference the slot // (true after the queue drain that precedes Window::onResize, and for // a slot that was freed at a scene-switch barrier). The swapchain // image is written per frame by WriteSwapchainImage instead. // Writes the same image at an image slot of every frame's set, through // one shared view. void WriteImageSets(std::uint16_t slot, VkDescriptorType type, const VkImageViewCreateInfo& viewInfo, VkImageLayout layout) { VkImageView view = VK_NULL_HANDLE; Device::CheckVkResult(vkCreateImageView(Device::device, &viewInfo, nullptr, &view)); vkDestroyImageView(Device::device, setImageViews[slot], nullptr); setImageViews[slot] = view; const VkDescriptorImageInfo imageInfo { .imageView = view, .imageLayout = layout }; VkWriteDescriptorSet writes[Window::numFrames]; for (std::uint8_t f = 0; f < Window::numFrames; ++f) { writes[f] = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = sets[f], .dstBinding = type == VK_DESCRIPTOR_TYPE_STORAGE_IMAGE ? storageImageBinding : sampledImageBinding, .dstArrayElement = slot, .descriptorCount = 1, .descriptorType = type, .pImageInfo = &imageInfo, }; } vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr); } // Writes a storage buffer at a buffer slot of every frame's set. The // whole buffer is bound; the shader's runtime array is bounded by the // item count the dispatch passes, as in heap mode. void WriteBufferSets(std::uint16_t slot, VkBuffer buffer) { const VkDescriptorBufferInfo bufferInfo { .buffer = buffer, .offset = 0, .range = VK_WHOLE_SIZE }; VkWriteDescriptorSet writes[Window::numFrames]; for (std::uint8_t f = 0; f < Window::numFrames; ++f) { writes[f] = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = sets[f], .dstBinding = storageBufferBinding, .dstArrayElement = slot, .descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .pBufferInfo = &bufferInfo, }; } vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr); } // Creates the sampler and writes it at a sampler slot of every // frame's set. void WriteSamplerSets(std::uint16_t slot, const VkSamplerCreateInfo& info) { VkSampler sampler = VK_NULL_HANDLE; Device::CheckVkResult(vkCreateSampler(Device::device, &info, nullptr, &sampler)); vkDestroySampler(Device::device, setSamplers[slot], nullptr); setSamplers[slot] = sampler; const VkDescriptorImageInfo samplerInfo { .sampler = sampler }; VkWriteDescriptorSet writes[Window::numFrames]; for (std::uint8_t f = 0; f < Window::numFrames; ++f) { writes[f] = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = sets[f], .dstBinding = samplerBinding, .dstArrayElement = slot, .descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER, .pImageInfo = &samplerInfo, }; } vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr); } // Takes `count` consecutive slots out of `freelist`, the first of // them returned, if it holds a run that long: freed ranges and freed // single slots alike come back as ranges, rather than only fresh // slots serving a range. The freelists are kept sorted (Free), which // puts neighbours side by side. A freed slot is handed out again at // once, which is why the Free* calls below require that no frame in // flight still uses it. static std::optional TakeRun(std::vector& freelist, std::uint16_t count) { if (count == 0 || freelist.size() < count) return std::nullopt; for (std::size_t i = 0; i + count <= freelist.size(); ++i) { // Sorted and without repeats: the ends `count - 1` apart are a run. if (freelist[i + count - 1] - freelist[i] == count - 1) { const std::uint16_t first = freelist[i]; freelist.erase(freelist.begin() + static_cast(i), freelist.begin() + static_cast(i + count)); return first; } } return std::nullopt; } ImageSlotRange AllocateImageSlots(std::uint16_t count) { if (count == 1 && !imageFreelist.empty()) { std::uint16_t s = imageFreelist.back(); imageFreelist.pop_back(); return {s, 1}; } if (const std::optional first = TakeRun(imageFreelist, count)) { return {*first, count}; } if (imageNext + count > imageCapacity) { std::uint16_t remaining = static_cast((imageCapacity - imageNext) + imageFreelist.size()); throw std::runtime_error(std::format("DescriptorHeapVulkan: out of image slots ({} requested, {} remaining of {})", count, remaining, imageCapacity)); } ImageSlotRange r{imageNext, count}; imageNext += count; return r; } BufferSlotRange AllocateBufferSlots(std::uint16_t count) { if (count == 1 && !bufferFreelist.empty()) { std::uint16_t s = bufferFreelist.back(); bufferFreelist.pop_back(); return {s, 1}; } if (const std::optional first = TakeRun(bufferFreelist, count)) { return {*first, count}; } if (bufferNext + count > bufferCapacity) { std::uint16_t remaining = static_cast((bufferCapacity - bufferNext) + bufferFreelist.size()); throw std::runtime_error(std::format("DescriptorHeapVulkan: out of buffer slots ({} requested, {} remaining of {})", count, remaining, bufferCapacity)); } BufferSlotRange r{bufferNext, count}; bufferNext += count; return r; } SamplerSlotRange AllocateSamplerSlots(std::uint16_t count) { if (count == 1 && !samplerFreelist.empty()) { std::uint16_t s = samplerFreelist.back(); samplerFreelist.pop_back(); return {s, 1}; } if (const std::optional first = TakeRun(samplerFreelist, count)) { return {*first, count}; } if (samplerNext + count > samplerCapacity) { std::uint16_t remaining = static_cast((samplerCapacity - samplerNext) + samplerFreelist.size()); throw std::runtime_error(std::format("DescriptorHeapVulkan: out of sampler slots ({} requested, {} remaining of {})", count, remaining, samplerCapacity)); } SamplerSlotRange r{samplerNext, count}; samplerNext += count; return r; } // Return slots to the per-pool freelist. The descriptors at these slots // are NOT zeroed; the next allocation that reuses a slot overwrites it // via WriteSampledImageDescriptor / WriteBufferDescriptor / sampler write. // Caller MUST ensure no in-flight GPU frame still references the freed // slots — typically vkDeviceWaitIdle (or a scene-switch barrier) before // freeing. Multi-slot ranges are decomposed into their individual slots, // each put in its sorted place (TakeRun looks for runs). // noexcept: capacity is reserved at Initialize so inserting never reallocates. // A freed swapchain slot stops being one, so the window doesn't write // the swapchain image over the slot's next owner. void FreeImageSlots(ImageSlotRange r) noexcept { std::erase_if(swapchainSlots, [r](const SwapchainSlot& listed) { return listed.slot >= r.firstElement && listed.slot - r.firstElement < r.count; }); Release(imageFreelist, r.firstElement, r.count); } void FreeBufferSlots(BufferSlotRange r) noexcept { Release(bufferFreelist, r.firstElement, r.count); } void FreeSamplerSlots(SamplerSlotRange r) noexcept { Release(samplerFreelist, r.firstElement, r.count); } static void Release(std::vector& freelist, std::uint16_t first, std::uint16_t count) noexcept { const auto at = std::ranges::lower_bound(freelist, first); const std::ptrdiff_t index = at - freelist.begin(); for (std::uint16_t i = 0; i < count; ++i) { freelist.insert(freelist.begin() + index + i, static_cast(first + i)); } } std::uint32_t ImageByteOffset(std::uint16_t firstElement) const { return firstElement * Device::descriptorHeapProperties.imageDescriptorSize; } std::uint32_t BufferByteOffset(std::uint16_t firstElement) const { return bufferStartOffset + firstElement * Device::descriptorHeapProperties.bufferDescriptorSize; } std::uint32_t SamplerByteOffset(std::uint16_t firstElement) const { return firstElement * Device::descriptorHeapProperties.samplerDescriptorSize; } // Binds frame `frame`'s heaps into `cmd`, with the reserved ranges // where Initialize put them, or in sets mode the frame's descriptor // set at the compute bind point. Window::Render does it for every // frame it records; a command buffer outside the frame loop that // dispatches against the heap does it itself (examples/RTVolume). void Bind(VkCommandBuffer cmd, std::uint32_t frame) const { if (!Device::descriptorHeapSupported) { // Every compute pipeline loaded against this heap shares // pipelineLayout, so one bind stays valid across all of them, // as the heap bind does. vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipelineLayout, 0, 1, &sets[frame], 0, nullptr); return; } const VkBindHeapInfoEXT resourceInfo { .sType = VK_STRUCTURE_TYPE_BIND_HEAP_INFO_EXT, .heapRange = { .address = resourceHeap[frame].address, .size = resourceHeap[frame].size }, .reservedRangeOffset = resourceReservedRangeOffset, .reservedRangeSize = Device::descriptorHeapProperties.minResourceHeapReservedRange, }; const VkBindHeapInfoEXT samplerInfo { .sType = VK_STRUCTURE_TYPE_BIND_HEAP_INFO_EXT, .heapRange = { .address = samplerHeap[frame].address, .size = samplerHeap[frame].size }, .reservedRangeOffset = samplerReservedRangeOffset, .reservedRangeSize = Device::descriptorHeapProperties.minSamplerHeapReservedRange, }; Device::vkCmdBindResourceHeapEXT(cmd, &resourceInfo); Device::vkCmdBindSamplerHeapEXT(cmd, &samplerInfo); } }; // ─── RAII slot handles ────────────────────────────────────────────── // // Move-only owners of a single bindless slot. Destructor returns the slot // to the heap's freelist (noexcept — Free*Slots can't allocate because // Initialize reserved the freelist). Implicitly convertible to std::uint16_t // for use with FillHeader / WriteFooDescriptor / etc.; for buffer slots // this conversion folds in the heap's bufferStartElement so callers always // get the absolute heap index that GLSL `descriptor_heap` expects. // // Lifetime: the handle MUST be destroyed (or moved-from then destroyed) // before the DescriptorHeapVulkan it references. The handle MUST NOT be // destroyed while a frame in-flight on the GPU still references its slot — // free at scene-switch boundaries (vkDeviceWaitIdle), not mid-frame. // // An empty (default-constructed or moved-from) handle is a no-op on // destruction and converts to the sentinel 0xFFFF. struct ImageSlot { ImageSlot() = default; ImageSlot(DescriptorHeapVulkan* heap, std::uint16_t raw) noexcept : heap_(heap), raw_(raw) {} ImageSlot(const ImageSlot&) = delete; ImageSlot& operator=(const ImageSlot&) = delete; ImageSlot(ImageSlot&& o) noexcept : heap_(o.heap_), raw_(o.raw_) { o.heap_ = nullptr; } ImageSlot& operator=(ImageSlot&& o) noexcept { if (this != &o) { Reset(); heap_ = o.heap_; raw_ = o.raw_; o.heap_ = nullptr; } return *this; } ~ImageSlot() noexcept { Reset(); } void Reset() noexcept { if (heap_) { heap_->FreeImageSlots({raw_, 1}); heap_ = nullptr; } } operator std::uint16_t() const noexcept { return heap_ ? raw_ : std::uint16_t{0xFFFF}; } explicit operator bool() const noexcept { return heap_ != nullptr; } private: DescriptorHeapVulkan* heap_ = nullptr; std::uint16_t raw_ = 0; }; struct BufferSlot { BufferSlot() = default; BufferSlot(DescriptorHeapVulkan* heap, std::uint16_t raw) noexcept : heap_(heap), raw_(raw) {} BufferSlot(const BufferSlot&) = delete; BufferSlot& operator=(const BufferSlot&) = delete; BufferSlot(BufferSlot&& o) noexcept : heap_(o.heap_), raw_(o.raw_) { o.heap_ = nullptr; } BufferSlot& operator=(BufferSlot&& o) noexcept { if (this != &o) { Reset(); heap_ = o.heap_; raw_ = o.raw_; o.heap_ = nullptr; } return *this; } ~BufferSlot() noexcept { Reset(); } void Reset() noexcept { if (heap_) { heap_->FreeBufferSlots({raw_, 1}); heap_ = nullptr; } } // Absolute heap index = bufferStartElement + raw. GLSL descriptor_heap // SSBOs are indexed in buffer-descriptor units from heap byte 0; the // raw bump-allocator index is relative to the buffer region's start. operator std::uint16_t() const noexcept { return heap_ ? static_cast(heap_->bufferStartElement + raw_) : std::uint16_t{0xFFFF}; } explicit operator bool() const noexcept { return heap_ != nullptr; } private: DescriptorHeapVulkan* heap_ = nullptr; std::uint16_t raw_ = 0; }; struct SamplerSlot { SamplerSlot() = default; SamplerSlot(DescriptorHeapVulkan* heap, std::uint16_t raw) noexcept : heap_(heap), raw_(raw) {} SamplerSlot(const SamplerSlot&) = delete; SamplerSlot& operator=(const SamplerSlot&) = delete; SamplerSlot(SamplerSlot&& o) noexcept : heap_(o.heap_), raw_(o.raw_) { o.heap_ = nullptr; } SamplerSlot& operator=(SamplerSlot&& o) noexcept { if (this != &o) { Reset(); heap_ = o.heap_; raw_ = o.raw_; o.heap_ = nullptr; } return *this; } ~SamplerSlot() noexcept { Reset(); } void Reset() noexcept { if (heap_) { heap_->FreeSamplerSlots({raw_, 1}); heap_ = nullptr; } } operator std::uint16_t() const noexcept { return heap_ ? raw_ : std::uint16_t{0xFFFF}; } explicit operator bool() const noexcept { return heap_ != nullptr; } private: DescriptorHeapVulkan* heap_ = nullptr; std::uint16_t raw_ = 0; }; } #endif // !CRAFTER_GRAPHICS_WINDOW_DOM