diff --git a/examples/RTVolume/main.cpp b/examples/RTVolume/main.cpp index d6242a6..5fdfeb7 100644 --- a/examples/RTVolume/main.cpp +++ b/examples/RTVolume/main.cpp @@ -130,11 +130,12 @@ int main() { aabbDevice.address, kBoxCount, 0.0f }; aabbWriter.Dispatch(cmd, &push, sizeof(push), (kBoxCount + 63) / 64); - // Order the producing writes before the BLAS build reads the buffer. + // Order the producing writes before the BLAS build reads the buffer. A + // build reads its geometry as a shader read. VkMemoryBarrier aabbBarrier { .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, - .dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR, + .dstAccessMask = VK_ACCESS_SHADER_READ_BIT, }; vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, diff --git a/implementations/Crafter.Graphics-ComputeShader.cpp b/implementations/Crafter.Graphics-ComputeShader.cpp index f1daa93..ad71903 100644 --- a/implementations/Crafter.Graphics-ComputeShader.cpp +++ b/implementations/Crafter.Graphics-ComputeShader.cpp @@ -22,9 +22,8 @@ ComputeShader::ComputeShader(ComputeShader&& other) noexcept ComputeShader& ComputeShader::operator=(ComputeShader&& other) noexcept { if (this != &other) { - if (pipeline != VK_NULL_HANDLE) { - vkDestroyPipeline(Device::device, pipeline, nullptr); - } + // A frame in flight may still dispatch the pipeline this replaces. + Device::EnqueueDeletion(pipeline); pipeline = other.pipeline; layout = other.layout; workaroundNeedsTlas = other.workaroundNeedsTlas; @@ -35,10 +34,9 @@ ComputeShader& ComputeShader::operator=(ComputeShader&& other) noexcept { } ComputeShader::~ComputeShader() { - if (pipeline != VK_NULL_HANDLE) { - vkDestroyPipeline(Device::device, pipeline, nullptr); - pipeline = VK_NULL_HANDLE; - } + // Deferred, like the other owners of Vulkan handles: a frame in flight + // may still dispatch it, and a static one goes after the driver. + Device::EnqueueDeletion(pipeline); } void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVulkan& heap) { @@ -48,10 +46,9 @@ void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVul // descriptor-set build compiled from the same source instead. std::filesystem::path setsPath = spvPath; setsPath.replace_extension("sets.spv"); - // VulkanShader only prints when a file is missing and then creates an - // empty module, which surfaces as an unrelated driver error. A missing - // variant is the likely mistake when a bundle is copied to a device - // without the heap, so name it. + // VulkanShader would throw naming only the file. A missing variant is + // the likely mistake when a bundle is copied to a device without the + // heap, so say how to make it. if (!std::filesystem::exists(setsPath)) { throw std::runtime_error(std::format( "ComputeShader::Load: this device has no VK_EXT_descriptor_heap, so the descriptor-set build " @@ -74,12 +71,12 @@ void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVul }, .layout = layout, }; - const VkResult created = vkCreateComputePipelines( - Device::device, Device::pipelineCache, 1, &info, nullptr, &pipeline); - // The module is only an input to pipeline creation; VulkanShader does - // not destroy it, so it is released here either way. - vkDestroyShaderModule(Device::device, shader.shader, nullptr); - Device::CheckVkResult(created); + VkPipeline created = VK_NULL_HANDLE; + Device::CheckVkResult(vkCreateComputePipelines( + Device::device, Device::pipelineCache, 1, &info, nullptr, &created)); + // Loading again replaces the pipeline, which a frame in flight may + // still dispatch. + Device::EnqueueDeletion(std::exchange(pipeline, created)); return; } @@ -110,10 +107,10 @@ void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVul }, .layout = VK_NULL_HANDLE, }; - const VkResult created = vkCreateComputePipelines( - Device::device, Device::pipelineCache, 1, &info, nullptr, &pipeline); - vkDestroyShaderModule(Device::device, shader.shader, nullptr); - Device::CheckVkResult(created); + VkPipeline created = VK_NULL_HANDLE; + Device::CheckVkResult(vkCreateComputePipelines( + Device::device, Device::pipelineCache, 1, &info, nullptr, &created)); + Device::EnqueueDeletion(std::exchange(pipeline, created)); } void ComputeShader::Dispatch(VkCommandBuffer cmd, diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index 613c4ce..31a26a6 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -871,7 +871,11 @@ void Device::Initialize() { VkDebugUtilsMessengerCreateInfoEXT debugUtilsMessengerCreateInfo = {}; debugUtilsMessengerCreateInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT; - debugUtilsMessengerCreateInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT; + // Info messages (the loader reports its device sorting, the layers their + // settings) are for whoever is debugging with validation on; without it + // they would start every application's output. + debugUtilsMessengerCreateInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT + | (enableValidation ? VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT : 0); debugUtilsMessengerCreateInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT; debugUtilsMessengerCreateInfo.pfnUserCallback = onError; CheckVkResult(GET_EXTENSION_FUNCTION(vkCreateDebugUtilsMessengerEXT)(instance, &debugUtilsMessengerCreateInfo, NULL, &debugMessenger)); @@ -884,7 +888,37 @@ void Device::Initialize() { uint32_t bestScore = 0; - for (uint32_t i = 0; i < physDeviceCount; i++) + // CRAFTER_GRAPHICS_DEVICE picks a GPU by its number among the distinct + // ones in this list: with two of the same GPU nothing else tells them + // apart, and the first may be too busy to use. A GPU two driver manifests + // both list (a Flatpak sees the runtime's and the GL extension's) counts + // once, so the number means the same GPU inside a sandbox and out. + const char* chosenDevice = std::getenv("CRAFTER_GRAPHICS_DEVICE"); + if (chosenDevice != nullptr) { + std::vector distinct; + std::vector> seen; + for (VkPhysicalDevice device : physDevices) { + VkPhysicalDeviceIDProperties id { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES }; + VkPhysicalDeviceProperties2 properties { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &id }; + vkGetPhysicalDeviceProperties2(device, &properties); + std::array uuid; + std::ranges::copy(id.deviceUUID, uuid.begin()); + if (std::ranges::find(seen, uuid) == seen.end()) { + seen.push_back(uuid); + distinct.push_back(device); + } + } + const std::string_view text = chosenDevice; + std::size_t index = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), index); + if (error != std::errc() || end != text.data() + text.size() || index >= distinct.size()) { + throw std::runtime_error(std::format("CRAFTER_GRAPHICS_DEVICE={} isn't the number of a GPU: Vulkan lists {} GPU(s), 0 to {}", + chosenDevice, distinct.size(), distinct.size() - 1)); + } + physDevice = distinct[index]; + } + + for (uint32_t i = 0; chosenDevice == nullptr && i < physDeviceCount; i++) { VkPhysicalDevice device = physDevices[i]; @@ -1408,10 +1442,45 @@ void Device::EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory) { // Nothing to free for an already-null handle — avoids parking dead // entries that ReclaimDeletions would have to skip over. if (buffer == VK_NULL_HANDLE) return; - deletionQueue.push_back({ frameCounter, buffer, memory }); + std::lock_guard lock(deletionMutex); + deletionQueue.push_back({ .retireAfter = frameCounter, .buffer = buffer, .memory = memory }); +} + +void Device::EnqueueDeletion(VkAccelerationStructureKHR accelerationStructure) { + if (accelerationStructure == VK_NULL_HANDLE) return; + std::lock_guard lock(deletionMutex); + deletionQueue.push_back({ .retireAfter = frameCounter, .accelerationStructure = accelerationStructure }); +} + +void Device::EnqueueDeletion(VkShaderModule shaderModule) { + if (shaderModule == VK_NULL_HANDLE) return; + std::lock_guard lock(deletionMutex); + deletionQueue.push_back({ .retireAfter = frameCounter, .shaderModule = shaderModule }); +} + +void Device::EnqueueDeletion(VkPipeline pipeline) { + if (pipeline == VK_NULL_HANDLE) return; + std::lock_guard lock(deletionMutex); + deletionQueue.push_back({ .retireAfter = frameCounter, .pipeline = pipeline }); +} + +namespace { + // Free whatever one queued entry holds. The destroy and free calls accept + // VK_NULL_HANDLE for the kinds it doesn't hold; the acceleration-structure + // entry point is only loaded with ray tracing, so that one is guarded. + void DestroyPending(const Device::PendingDeletion& entry) { + if (entry.accelerationStructure != VK_NULL_HANDLE) { + Device::vkDestroyAccelerationStructureKHR(Device::device, entry.accelerationStructure, nullptr); + } + vkDestroyShaderModule(Device::device, entry.shaderModule, nullptr); + vkDestroyPipeline(Device::device, entry.pipeline, nullptr); + vkDestroyBuffer(Device::device, entry.buffer, nullptr); + vkFreeMemory(Device::device, entry.memory, nullptr); + } } void Device::ReclaimDeletions() { + std::lock_guard lock(deletionMutex); // Free every entry whose retire frame has been reached. An entry tagged at // frame F retires at F + framesInFlight: by the time the CPU has begun that // many later frames (each gated by a per-image fence wait), single-queue @@ -1423,19 +1492,18 @@ void Device::ReclaimDeletions() { // newer, hence also in flight). O(ready), not O(queue). while (!deletionQueue.empty() && deletionQueue.front().retireAfter + framesInFlight <= frameCounter) { - const PendingDeletion& entry = deletionQueue.front(); - vkDestroyBuffer(device, entry.buffer, nullptr); - vkFreeMemory(device, entry.memory, nullptr); + DestroyPending(deletionQueue.front()); deletionQueue.pop_front(); } } void Device::DrainDeletions() { + std::lock_guard lock(deletionMutex); // Unconditional: the caller has just wait-idled, so no in-flight GPU work // can still reference any queued resource. - for (PendingDeletion& entry : deletionQueue) { - vkDestroyBuffer(device, entry.buffer, nullptr); - vkFreeMemory(device, entry.memory, nullptr); + for (const PendingDeletion& entry : deletionQueue) { + DestroyPending(entry); } deletionQueue.clear(); + ++drains; } \ No newline at end of file diff --git a/implementations/Crafter.Graphics-Gamepad.cpp b/implementations/Crafter.Graphics-Gamepad.cpp index 451371a..efb62d4 100644 --- a/implementations/Crafter.Graphics-Gamepad.cpp +++ b/implementations/Crafter.Graphics-Gamepad.cpp @@ -14,13 +14,7 @@ module; #include #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 -#define COBJMACROS -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#include -#include -#include +#include "Crafter.Graphics-GamepadWin32.h" #endif module Crafter.Graphics; import std; @@ -407,37 +401,15 @@ void Crafter::Gamepad::Rumble(Device& dev, float low, float high, #endif // ──────────────────────────────────────────────────────────────────── -// Win32 backend (Windows.Gaming.Input via C ABI) +// Win32 backend (Windows.Gaming.Input, through Crafter.Graphics-GamepadWin32.c) // ──────────────────────────────────────────────────────────────────── #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 namespace { - using IGamepad = __x_ABI_CWindows_CGaming_CInput_CIGamepad; - using IGamepadStatics = __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics; - using GamepadAddedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad; - using GamepadRemovedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad; - using GamepadReading = __x_ABI_CWindows_CGaming_CInput_CGamepadReading; - using GamepadVibration = __x_ABI_CWindows_CGaming_CInput_CGamepadVibration; - using GamepadButtons = __x_ABI_CWindows_CGaming_CInput_CGamepadButtons; + // Maps a connected gamepad to its Crafter::Gamepad::Device. The pointer + // is stable for as long as the gamepad stays connected. + std::unordered_map g_byGamepad; - IGamepadStatics* g_statics = nullptr; - EventRegistrationToken g_addedToken {}; - EventRegistrationToken g_removedToken {}; - bool g_initialized = false; - - // Cross-thread queue. WGI fires Added/Removed on a thread pool; the - // event loop drains this on the main thread inside Tick(). - struct QueueEntry { - bool added; - IGamepad* gamepad; // AddRef'd - }; - std::mutex g_queueMutex; - std::vector g_queue; - - // Maps a live IGamepad* to its Crafter::Gamepad::Device. Pointer is - // stable from WGI's perspective for the lifetime of the connection. - std::unordered_map g_byGamepad; - - std::uint32_t PointerToId(IGamepad* g) { + std::uint32_t PointerToId(CrafterWgiGamepad* g) { // Hash the pointer for a stable-while-connected id. (NonRoamableId // would be more durable across reconnects but adds another COM // round-trip; pointer hash is fine for v1.) @@ -449,86 +421,29 @@ namespace { return out == 0 ? 1 : out; } - // ── Delegate vtable for GamepadAdded / GamepadRemoved ─────────── - struct AddedDelegate { - // The first member matches the C ABI vtable layout. Cast yields - // a valid GamepadAddedHandler*. - void* vtbl; - LONG refCount; - bool isAdded; - }; - - extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl; - extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl; - - HRESULT STDMETHODCALLTYPE DelegateQI(GamepadAddedHandler* This, REFIID riid, void** out) { - if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IAgileObject) || - IsEqualIID(riid, &IID___FIEventHandler_1_Windows__CGaming__CInput__CGamepad)) - { - *out = This; - This->lpVtbl->AddRef(This); - return S_OK; - } - *out = nullptr; - return E_NOINTERFACE; - } - ULONG STDMETHODCALLTYPE DelegateAddRef(GamepadAddedHandler* This) { - AddedDelegate* d = reinterpret_cast(This); - return (ULONG)InterlockedIncrement(&d->refCount); - } - ULONG STDMETHODCALLTYPE DelegateRelease(GamepadAddedHandler* This) { - AddedDelegate* d = reinterpret_cast(This); - LONG r = InterlockedDecrement(&d->refCount); - return (ULONG)r; - } - HRESULT STDMETHODCALLTYPE DelegateInvoke(GamepadAddedHandler* This, IInspectable* sender, IGamepad* gamepad) { - AddedDelegate* d = reinterpret_cast(This); - if (gamepad) IGamepad_AddRef(gamepad); - { - std::lock_guard lock(g_queueMutex); - g_queue.push_back({ d->isAdded, gamepad }); - } - return S_OK; - } - - GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl = { - DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke - }; - GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl = { - DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke - }; - - AddedDelegate g_addedDelegate { &g_addedVtbl, 1, true }; - AddedDelegate g_removedDelegate { &g_removedVtbl, 1, false }; - - // Translate GamepadReading.Buttons bitfield to our Button enum. - bool ReadingHas(const GamepadReading& r, GamepadButtons b) { - return (r.Buttons & b) != 0; - } - - void ApplyReading(Crafter::Gamepad::Device* dev, const GamepadReading& r) { + void ApplyReading(Crafter::Gamepad::Device* dev, const CrafterWgiReading& r) { using B = Crafter::Gamepad::Button; using A = Crafter::Gamepad::Axis; - struct Map { B which; GamepadButtons mask; }; + struct Map { B which; std::uint32_t mask; }; static const Map buttonMap[] = { - { B::DPadUp, GamepadButtons_DPadUp }, - { B::DPadDown, GamepadButtons_DPadDown }, - { B::DPadLeft, GamepadButtons_DPadLeft }, - { B::DPadRight, GamepadButtons_DPadRight }, - { B::Start, GamepadButtons_Menu }, - { B::Select, GamepadButtons_View }, - { B::LeftStickClick, GamepadButtons_LeftThumbstick }, - { B::RightStickClick,GamepadButtons_RightThumbstick }, - { B::LeftBumper, GamepadButtons_LeftShoulder }, - { B::RightBumper, GamepadButtons_RightShoulder }, - { B::South, GamepadButtons_A }, - { B::East, GamepadButtons_B }, - { B::West, GamepadButtons_X }, - { B::North, GamepadButtons_Y }, + { B::DPadUp, CRAFTER_WGI_BUTTON_DPAD_UP }, + { B::DPadDown, CRAFTER_WGI_BUTTON_DPAD_DOWN }, + { B::DPadLeft, CRAFTER_WGI_BUTTON_DPAD_LEFT }, + { B::DPadRight, CRAFTER_WGI_BUTTON_DPAD_RIGHT }, + { B::Start, CRAFTER_WGI_BUTTON_MENU }, + { B::Select, CRAFTER_WGI_BUTTON_VIEW }, + { B::LeftStickClick, CRAFTER_WGI_BUTTON_LEFT_THUMBSTICK }, + { B::RightStickClick,CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK }, + { B::LeftBumper, CRAFTER_WGI_BUTTON_LEFT_SHOULDER }, + { B::RightBumper, CRAFTER_WGI_BUTTON_RIGHT_SHOULDER }, + { B::South, CRAFTER_WGI_BUTTON_A }, + { B::East, CRAFTER_WGI_BUTTON_B }, + { B::West, CRAFTER_WGI_BUTTON_X }, + { B::North, CRAFTER_WGI_BUTTON_Y }, }; for (const Map& m : buttonMap) { - bool now = ReadingHas(r, m.mask); + bool now = (r.buttons & m.mask) != 0; bool& cur = dev->buttons[(std::size_t)m.which]; if (cur == now) continue; cur = now; @@ -545,12 +460,12 @@ namespace { cur = v; dev->onAxisChanged.Invoke(which); }; - axis(A::LeftStickX, (float)r.LeftThumbstickX); - axis(A::LeftStickY, (float)r.LeftThumbstickY); - axis(A::RightStickX, (float)r.RightThumbstickX); - axis(A::RightStickY, (float)r.RightThumbstickY); - axis(A::LeftTrigger, (float)r.LeftTrigger); - axis(A::RightTrigger, (float)r.RightTrigger); + axis(A::LeftStickX, (float)r.leftStickX); + axis(A::LeftStickY, (float)r.leftStickY); + axis(A::RightStickX, (float)r.rightStickX); + axis(A::RightStickY, (float)r.rightStickY); + axis(A::LeftTrigger, (float)r.leftTrigger); + axis(A::RightTrigger, (float)r.rightTrigger); // Triggers also expose digital "pressed" buttons at a threshold. constexpr float kTriggerThreshold = 0.1f; @@ -562,81 +477,52 @@ namespace { if (now) dev->onButtonDown.Invoke(which); else dev->onButtonUp .Invoke(which); }; - trigBtn(B::LeftTrigger, r.LeftTrigger); - trigBtn(B::RightTrigger, r.RightTrigger); - } - - void EnsureInit() { - if (g_initialized) return; - g_initialized = true; - // RoInitialize is required once per thread; ignore RPC_E_CHANGED_MODE - // (some hosts initialize as STA, that's fine for WGI). - RoInitialize(RO_INIT_MULTITHREADED); - - HSTRING_HEADER header; - HSTRING className; - const wchar_t* name = RuntimeClass_Windows_Gaming_Input_Gamepad; - if (FAILED(WindowsCreateStringReference(name, - (UINT32)wcslen(name), &header, &className))) return; - - if (FAILED(RoGetActivationFactory(className, - &IID___x_ABI_CWindows_CGaming_CInput_CIGamepadStatics, - (void**)&g_statics))) return; - - __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadAdded( - g_statics, - reinterpret_cast(&g_addedDelegate), - &g_addedToken); - __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadRemoved( - g_statics, - reinterpret_cast(&g_removedDelegate), - &g_removedToken); + trigBtn(B::LeftTrigger, r.leftTrigger); + trigBtn(B::RightTrigger, r.rightTrigger); } } void Crafter::Gamepad::Tick() { - EnsureInit(); - if (!g_statics) return; + if (!CrafterWgiStart()) return; - // Drain the cross-thread connect/disconnect queue. - std::vector pending; - { - std::lock_guard lock(g_queueMutex); - pending.swap(g_queue); - } - for (QueueEntry& e : pending) { - if (!e.gamepad) continue; - if (e.added) { - if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) { - IGamepad_Release(e.gamepad); - continue; - } - auto dev = std::make_unique(); - dev->id = PointerToId(e.gamepad); - dev->name = "Gamepad"; - auto* raw = dev.get(); - g_byGamepad[e.gamepad] = raw; - connected.push_back(std::move(dev)); - onConnected.Invoke(raw); - // keep AddRef'd; released on disconnect - } else { - auto it = g_byGamepad.find(e.gamepad); - if (it != g_byGamepad.end()) { - Crafter::Gamepad::Device* raw = it->second; - onDisconnected.Invoke(raw); - for (auto vi = connected.begin(); vi != connected.end(); ++vi) { - if (vi->get() == raw) { connected.erase(vi); break; } + // The connects and disconnects since the last tick. + std::array events; + for (std::size_t count; (count = CrafterWgiTakeEvents(events.data(), events.size())) != 0;) { + for (const CrafterWgiEvent& e : std::span(events.data(), count)) { + if (e.added) { + if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) { + CrafterWgiRelease(e.gamepad); + continue; } - g_byGamepad.erase(it); + auto dev = std::make_unique(); + dev->id = PointerToId(e.gamepad); + dev->name = "Gamepad"; + auto* raw = dev.get(); + g_byGamepad[e.gamepad] = raw; + connected.push_back(std::move(dev)); + onConnected.Invoke(raw); + // The event's reference is kept until the disconnect. + } else { + auto it = g_byGamepad.find(e.gamepad); + if (it != g_byGamepad.end()) { + Crafter::Gamepad::Device* raw = it->second; + onDisconnected.Invoke(raw); + for (auto vi = connected.begin(); vi != connected.end(); ++vi) { + if (vi->get() == raw) { connected.erase(vi); break; } + } + // The connect's reference, then the disconnect's. + CrafterWgiRelease(it->first); + g_byGamepad.erase(it); + } + CrafterWgiRelease(e.gamepad); } - IGamepad_Release(e.gamepad); } } // Poll each connected gamepad. for (auto& [gp, dev] : g_byGamepad) { - GamepadReading reading{}; - if (FAILED(__x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading(gp, &reading))) continue; + CrafterWgiReading reading {}; + if (!CrafterWgiRead(gp, &reading)) continue; ApplyReading(dev, reading); } } @@ -646,16 +532,13 @@ void Crafter::Gamepad::Rumble(Device& dev, float low, float high, { // WGI vibration is "set and forget" — no duration. Caller can stop // by re-calling with zero magnitudes. We honor 0 duration as stop. - IGamepad* gp = nullptr; + CrafterWgiGamepad* gp = nullptr; for (auto& [g, d] : g_byGamepad) { if (d == &dev) { gp = g; break; } } if (!gp) return; auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; }; - GamepadVibration vib{}; - vib.LeftMotor = clamp01(low); - vib.RightMotor = clamp01(high); - __x_ABI_CWindows_CGaming_CInput_CIGamepad_put_Vibration(gp, vib); + CrafterWgiVibrate(gp, clamp01(low), clamp01(high)); } #endif diff --git a/implementations/Crafter.Graphics-GamepadWin32.c b/implementations/Crafter.Graphics-GamepadWin32.c new file mode 100644 index 0000000..b9c54b4 --- /dev/null +++ b/implementations/Crafter.Graphics-GamepadWin32.c @@ -0,0 +1,153 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Windows.Gaming.Input for the Win32 gamepad backend; see +// Crafter.Graphics-GamepadWin32.h for why this part is C. + +#define COBJMACROS +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include +#include +// Defines the interface IDs the headers below declare, in this file. +#include +#include +#include "Crafter.Graphics-GamepadWin32.h" + +typedef __x_ABI_CWindows_CGaming_CInput_CIGamepad IGamepad; +typedef __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics IGamepadStatics; +typedef __FIEventHandler_1_Windows__CGaming__CInput__CGamepad GamepadHandler; +typedef __FIEventHandler_1_Windows__CGaming__CInput__CGamepadVtbl GamepadHandlerVtbl; + +_Static_assert(CRAFTER_WGI_BUTTON_MENU == GamepadButtons_Menu && CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK == GamepadButtons_RightThumbstick + && CRAFTER_WGI_BUTTON_DPAD_UP == GamepadButtons_DPadUp && CRAFTER_WGI_BUTTON_A == GamepadButtons_A, + "the CRAFTER_WGI_BUTTON_ values are GamepadButtons'"); + +static IGamepadStatics* statics = NULL; +static EventRegistrationToken addedToken; +static EventRegistrationToken removedToken; +static int started = 0; + +// The connects and disconnects, which Windows.Gaming.Input reports on its +// own threads, until the event loop takes them. +static SRWLOCK queueLock = SRWLOCK_INIT; +static CrafterWgiEvent* queue = NULL; +static size_t queueCount = 0; +static size_t queueCapacity = 0; + +// The GamepadAdded and GamepadRemoved handlers. Static objects, so their +// reference counts don't decide their lifetime. +typedef struct Handler { + const GamepadHandlerVtbl* lpVtbl; + int added; +} Handler; + +static HRESULT STDMETHODCALLTYPE HandlerQueryInterface(GamepadHandler* This, REFIID riid, void** out) { + if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IAgileObject) + || IsEqualIID(riid, &IID___FIEventHandler_1_Windows__CGaming__CInput__CGamepad)) { + *out = This; + return S_OK; + } + *out = NULL; + return E_NOINTERFACE; +} + +static ULONG STDMETHODCALLTYPE HandlerAddRef(GamepadHandler* This) { + (void)This; + return 2; +} + +static ULONG STDMETHODCALLTYPE HandlerRelease(GamepadHandler* This) { + (void)This; + return 1; +} + +static HRESULT STDMETHODCALLTYPE HandlerInvoke(GamepadHandler* This, IInspectable* sender, IGamepad* gamepad) { + (void)sender; + if (gamepad == NULL) return S_OK; + const Handler* handler = (const Handler*)This; + __x_ABI_CWindows_CGaming_CInput_CIGamepad_AddRef(gamepad); + AcquireSRWLockExclusive(&queueLock); + if (queueCount == queueCapacity) { + size_t capacity = queueCapacity == 0 ? 8 : queueCapacity * 2; + CrafterWgiEvent* grown = (CrafterWgiEvent*)realloc(queue, capacity * sizeof(CrafterWgiEvent)); + if (grown == NULL) { + ReleaseSRWLockExclusive(&queueLock); + __x_ABI_CWindows_CGaming_CInput_CIGamepad_Release(gamepad); + return E_OUTOFMEMORY; + } + queue = grown; + queueCapacity = capacity; + } + queue[queueCount].added = handler->added; + queue[queueCount].gamepad = (CrafterWgiGamepad*)gamepad; + ++queueCount; + ReleaseSRWLockExclusive(&queueLock); + return S_OK; +} + +static const GamepadHandlerVtbl handlerVtbl = { + HandlerQueryInterface, + HandlerAddRef, + HandlerRelease, + HandlerInvoke, +}; +static Handler addedHandler = { &handlerVtbl, 1 }; +static Handler removedHandler = { &handlerVtbl, 0 }; + +int CrafterWgiStart(void) { + if (started) return statics != NULL; + started = 1; + // Once per thread. RPC_E_CHANGED_MODE (the host made it a single-threaded + // apartment) is fine for Windows.Gaming.Input. + RoInitialize(RO_INIT_MULTITHREADED); + + HSTRING_HEADER header; + HSTRING className; + const wchar_t* name = RuntimeClass_Windows_Gaming_Input_Gamepad; + if (FAILED(WindowsCreateStringReference(name, (UINT32)wcslen(name), &header, &className))) return 0; + if (FAILED(RoGetActivationFactory(className, &IID___x_ABI_CWindows_CGaming_CInput_CIGamepadStatics, (void**)&statics))) { + statics = NULL; + return 0; + } + __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadAdded(statics, (GamepadHandler*)&addedHandler, &addedToken); + __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadRemoved(statics, (GamepadHandler*)&removedHandler, &removedToken); + return 1; +} + +size_t CrafterWgiTakeEvents(CrafterWgiEvent* out, size_t capacity) { + AcquireSRWLockExclusive(&queueLock); + size_t taken = queueCount < capacity ? queueCount : capacity; + for (size_t i = 0; i < taken; ++i) out[i] = queue[i]; + for (size_t i = taken; i < queueCount; ++i) queue[i - taken] = queue[i]; + queueCount -= taken; + ReleaseSRWLockExclusive(&queueLock); + return taken; +} + +int CrafterWgiRead(CrafterWgiGamepad* gamepad, CrafterWgiReading* out) { + __x_ABI_CWindows_CGaming_CInput_CGamepadReading reading; + if (FAILED(__x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading((IGamepad*)gamepad, &reading))) return 0; + out->buttons = (uint32_t)reading.Buttons; + out->leftTrigger = reading.LeftTrigger; + out->rightTrigger = reading.RightTrigger; + out->leftStickX = reading.LeftThumbstickX; + out->leftStickY = reading.LeftThumbstickY; + out->rightStickX = reading.RightThumbstickX; + out->rightStickY = reading.RightThumbstickY; + return 1; +} + +void CrafterWgiVibrate(CrafterWgiGamepad* gamepad, double low, double high) { + __x_ABI_CWindows_CGaming_CInput_CGamepadVibration vibration = { 0 }; + vibration.LeftMotor = low; + vibration.RightMotor = high; + __x_ABI_CWindows_CGaming_CInput_CIGamepad_put_Vibration((IGamepad*)gamepad, vibration); +} + +void CrafterWgiRelease(CrafterWgiGamepad* gamepad) { + __x_ABI_CWindows_CGaming_CInput_CIGamepad_Release((IGamepad*)gamepad); +} diff --git a/implementations/Crafter.Graphics-GamepadWin32.h b/implementations/Crafter.Graphics-GamepadWin32.h new file mode 100644 index 0000000..6ff8d56 --- /dev/null +++ b/implementations/Crafter.Graphics-GamepadWin32.h @@ -0,0 +1,74 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The C side of the Win32 gamepad backend (Crafter.Graphics-GamepadWin32.c): +// Windows.Gaming.Input, through its C interface. mingw-w64's generated WinRT +// headers declare that interface only to C; in C++ they declare the ABI +// templates instead, and mingw-w64 14's windows.foundation.h doesn't compile +// as C++ (IReference and IReference are one type). Gamepad.cpp +// drives it from the event loop's thread; only the connect and disconnect +// notifications arrive on another, and they just queue. +#ifndef CRAFTER_GRAPHICS_GAMEPAD_WIN32_H +#define CRAFTER_GRAPHICS_GAMEPAD_WIN32_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// A connected gamepad: Windows.Gaming.Input's IGamepad. +typedef struct CrafterWgiGamepad CrafterWgiGamepad; + +typedef struct CrafterWgiEvent { + int added; // nonzero for a connect, zero for a disconnect + CrafterWgiGamepad* gamepad; // holds a reference, which CrafterWgiRelease drops +} CrafterWgiEvent; + +// A GamepadReading. `buttons` holds GamepadButtons bits, named below. +typedef struct CrafterWgiReading { + uint32_t buttons; + double leftTrigger; + double rightTrigger; + double leftStickX; + double leftStickY; + double rightStickX; + double rightStickY; +} CrafterWgiReading; + +enum { + CRAFTER_WGI_BUTTON_MENU = 0x1, + CRAFTER_WGI_BUTTON_VIEW = 0x2, + CRAFTER_WGI_BUTTON_A = 0x4, + CRAFTER_WGI_BUTTON_B = 0x8, + CRAFTER_WGI_BUTTON_X = 0x10, + CRAFTER_WGI_BUTTON_Y = 0x20, + CRAFTER_WGI_BUTTON_DPAD_UP = 0x40, + CRAFTER_WGI_BUTTON_DPAD_DOWN = 0x80, + CRAFTER_WGI_BUTTON_DPAD_LEFT = 0x100, + CRAFTER_WGI_BUTTON_DPAD_RIGHT = 0x200, + CRAFTER_WGI_BUTTON_LEFT_SHOULDER = 0x400, + CRAFTER_WGI_BUTTON_RIGHT_SHOULDER = 0x800, + CRAFTER_WGI_BUTTON_LEFT_THUMBSTICK = 0x1000, + CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK = 0x2000, +}; + +// Starts listening for gamepads the first time it is called. Nonzero when +// Windows.Gaming.Input is available, every time. +int CrafterWgiStart(void); +// Moves up to `capacity` of the connects and disconnects that came in since +// the last call into `out`, oldest first, and returns how many it moved. +size_t CrafterWgiTakeEvents(CrafterWgiEvent* out, size_t capacity); +// The gamepad's current state; nonzero on success. +int CrafterWgiRead(CrafterWgiGamepad* gamepad, CrafterWgiReading* out); +// Sets the two rumble motors, each 0..1, until set again. +void CrafterWgiVibrate(CrafterWgiGamepad* gamepad, double low, double high); +// Drops the reference an event handed over. +void CrafterWgiRelease(CrafterWgiGamepad* gamepad); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/implementations/Crafter.Graphics-Mesh.cpp b/implementations/Crafter.Graphics-Mesh.cpp index c171e4b..1780fc7 100644 --- a/implementations/Crafter.Graphics-Mesh.cpp +++ b/implementations/Crafter.Graphics-Mesh.cpp @@ -29,6 +29,20 @@ namespace { constexpr VkBufferUsageFlags2 kIndexUsageBase = kVertexUsageBase | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + // Where the geometry buffers are read: the BLAS build, and hit shaders or + // compute ray queries fetching vertices by address. An upload is ordered + // before these reads, and a re-upload after the earlier ones, which frames + // still in flight may be doing. A build reads its inputs as a shader read. + constexpr VkPipelineStageFlags kGeometryReaders = + VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR + | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR + | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + // The same, for synchronization2's barriers. + constexpr VkPipelineStageFlags2 kGeometryReaders2 = + VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR + | VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR + | VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + // Translate the portable RTBuildOptions to Vulkan build flags. The two // preferences are mutually exclusive; ALLOW_UPDATE is layered on top so // a later in-place refit (UPDATE mode) is permitted. @@ -42,6 +56,15 @@ namespace { return flags; } + // The geometry's flags for a build: opaque or not, and whether any-hit + // may run more than once per primitive. + VkGeometryFlagsKHR GeometryFlags(bool opaque, const RTBuildOptions& options) { + VkGeometryFlagsKHR flags = opaque ? static_cast(VK_GEOMETRY_OPAQUE_BIT_KHR) : VkGeometryFlagsKHR{}; + if (options.noDuplicateAnyHit) + flags |= VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR; + return flags; + } + // Shared BLAS sizing / creation / build-record tail — geometry-type // agnostic; both the triangle and the AABB (procedural) paths feed // their single geometry through here. `update` selects an in-place @@ -61,11 +84,38 @@ namespace { .pGeometries = &blasGeometry, }; + // A refit writes the BLAS in place, and a rebuild writes the same + // storage and scratch whenever the new BLAS fits. Earlier work, in + // this command buffer or in a frame still in flight, may read the BLAS + // (a TLAS build, a trace, a compute ray query) or write the scratch (a + // build), so order that before this build. A first build touches + // nothing earlier. + if (self.accelerationStructure != VK_NULL_HANDLE) { + VkMemoryBarrier asBarrier { + .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR, + .dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR + }; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, + 0, 1, &asBarrier, 0, nullptr, 0, nullptr); + } + if (!update) { // Fresh build: query sizes, (re)allocate scratch + AS storage, // create a new AS handle. The scratch buffer is sized for the // build (always >= the update scratch requirement), so it also // covers later in-place refits without resizing. + // + // On a rebuild the old handle may still be referenced by this + // command buffer and by frames in flight, so it goes to the + // deletion queue instead of being destroyed here. It is queued + // before the Resizes below, which queue the old storage when the + // new BLAS doesn't fit, so it is freed before the buffer it lives in. + Device::EnqueueDeletion(self.accelerationStructure); + self.accelerationStructure = VK_NULL_HANDLE; + VkAccelerationStructureBuildSizesInfoKHR blasBuildSizes = { .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR }; @@ -89,13 +139,6 @@ namespace { VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, blasBuildSizes.accelerationStructureSize); - // Re-Build of an existing Mesh: destroy the stale handle first - // so the previous AS storage isn't leaked. - if (self.accelerationStructure != VK_NULL_HANDLE) { - Device::vkDestroyAccelerationStructureKHR(Device::device, self.accelerationStructure, nullptr); - self.accelerationStructure = VK_NULL_HANDLE; - } - VkAccelerationStructureCreateInfoKHR blasCreateInfo{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR, .buffer = self.blasBuffer.buffer, @@ -110,19 +153,6 @@ namespace { .accelerationStructure = self.accelerationStructure }; self.blasAddr = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo); - } else { - // In-place refit: the AS may have been read by a prior TLAS - // build / trace, so order that read before the build overwrites - // it. Scratch reuse needs the same write-after-read guard. - VkMemoryBarrier asBarrier { - .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, - .srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR, - .dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR - }; - vkCmdPipelineBarrier(cmd, - VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, - VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, - 0, 1, &asBarrier, 0, nullptr, 0, nullptr); } blasBuildGeometryInfo.scratchData.deviceAddress = self.scratchBuffer.address; @@ -183,13 +213,56 @@ namespace { .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR, .geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR, .geometry = geometryData, - .flags = VK_GEOMETRY_OPAQUE_BIT_KHR + .flags = self.geometryFlags }; RecordBLASBuildFromGeometry(self, blasGeometry, indexCount / 3, flags, update, cmd); } } +Mesh::Mesh(Mesh&& other) + : scratchBuffer(std::move(other.scratchBuffer)) + , blasBuffer(std::move(other.blasBuffer)) + , vertexBuffer(std::move(other.vertexBuffer)) + , indexBuffer(std::move(other.indexBuffer)) + , aabbBuffer(std::move(other.aabbBuffer)) + , compressedStaging(std::move(other.compressedStaging)) + , accelerationStructure(std::exchange(other.accelerationStructure, VK_NULL_HANDLE)) + , blasAddr(std::exchange(other.blasAddr, 0)) + , geometryFlags(other.geometryFlags) + , buildFlags(other.buildFlags) + , builtPrimitiveCount(other.builtPrimitiveCount) + , builtInputCount(other.builtInputCount) + , allowUpdate(other.allowUpdate) { +} + +Mesh& Mesh::operator=(Mesh&& other) { + if (this == &other) return *this; + // Ahead of blasBuffer, which it lives in and which queues its own as it + // takes other's over. + Device::EnqueueDeletion(accelerationStructure); + scratchBuffer = std::move(other.scratchBuffer); + blasBuffer = std::move(other.blasBuffer); + vertexBuffer = std::move(other.vertexBuffer); + indexBuffer = std::move(other.indexBuffer); + aabbBuffer = std::move(other.aabbBuffer); + compressedStaging = std::move(other.compressedStaging); + accelerationStructure = std::exchange(other.accelerationStructure, VK_NULL_HANDLE); + blasAddr = std::exchange(other.blasAddr, 0); + geometryFlags = other.geometryFlags; + buildFlags = other.buildFlags; + builtPrimitiveCount = other.builtPrimitiveCount; + builtInputCount = other.builtInputCount; + allowUpdate = other.allowUpdate; + return *this; +} + +Mesh::~Mesh() { + // The body runs before the members' destructors, so the handle is queued + // ahead of blasBuffer, which it lives in. + Device::EnqueueDeletion(accelerationStructure); +} + void Mesh::Build(std::span> verticies, std::span indicies, VkCommandBuffer cmd, RTBuildOptions options) { // Every Build* records an acceleration-structure build; without ray // tracing those entry points are not loaded. Fail with the reason. @@ -199,10 +272,11 @@ void Mesh::Build(std::span> verticies, std::span(verticies.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); - indexBuffer.UploadDeviceLocal(kIndexUsageBase, indicies.data(), static_cast(indicies.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); + vertexBuffer.UploadDeviceLocal(kVertexUsageBase, verticies.data(), static_cast(verticies.size()), cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders); + indexBuffer.UploadDeviceLocal(kIndexUsageBase, indicies.data(), static_cast(indicies.size()), cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders); allowUpdate = options.allowUpdate; + geometryFlags = GeometryFlags(/*opaque*/ true, options); builtInputCount = static_cast(verticies.size()); RecordBLASBuild(*this, static_cast(verticies.size()), static_cast(indicies.size()), BlasFlags(options), /*update*/ false, cmd); } @@ -231,9 +305,12 @@ void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildO // The GPU decompressor writes vertex/index directly (the build input is // never host-written on this path), so they want pure DEVICE_LOCAL — no // host visibility, no map. This is where the BLAS build and any hit-shader - // fetch then read them from (#73). + // fetch then read them from (#73). A ranged Refit copies the moved + // vertices in, hence TRANSFER_DST. + const VkBuffer previousVertices = vertexBuffer.buffer; + const VkBuffer previousIndices = indexBuffer.buffer; vertexBuffer.Resize( - kVertexUsageBase | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, + kVertexUsageBase | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, asset.vertexCount); indexBuffer.Resize( @@ -269,11 +346,33 @@ void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildO regions); } + // A rebuild that keeps its buffers (Resize keeps an allocation that fits) + // decompresses into what frames in flight may still be reading: the + // decompress waits for those reads. Fresh buffers nothing reads yet. + if ((vertexBuffer.buffer != VK_NULL_HANDLE && vertexBuffer.buffer == previousVertices) + || (indexBuffer.buffer != VK_NULL_HANDLE && indexBuffer.buffer == previousIndices)) { + const VkMemoryBarrier2 readsDone { + .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2, + .srcStageMask = kGeometryReaders2, + .srcAccessMask = 0, + .dstStageMask = VK_PIPELINE_STAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, + .dstAccessMask = VK_ACCESS_2_MEMORY_DECOMPRESSION_WRITE_BIT_EXT, + }; + const VkDependencyInfo beforeDecompress { + .sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, + .memoryBarrierCount = 1, + .pMemoryBarriers = &readsDone, + }; + vkCmdPipelineBarrier2(cmd, &beforeDecompress); + } + + // Then the BLAS build reads the decompressed geometry as a shader read, + // as do the hit shaders and ray queries that fetch it by address. Decompress::DecompressOnGPU( cmd, regions, - VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, - VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR); + kGeometryReaders2, + VK_ACCESS_2_SHADER_READ_BIT); // The compressed staging is only read by the decompress recorded above; the // subsequent BLAS build reads the decompressed vertex/index buffers, never @@ -286,6 +385,7 @@ void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildO compressedStaging.DeferredClear(); allowUpdate = options.allowUpdate; + geometryFlags = GeometryFlags(/*opaque*/ true, options); builtInputCount = asset.vertexCount; RecordBLASBuild(*this, asset.vertexCount, asset.indexCount, BlasFlags(options), /*update*/ false, cmd); } @@ -296,7 +396,7 @@ namespace { // Geometry-source agnostic: the host-upload path (RecordProceduralBuild) // points this at self.aabbBuffer, while the device-buffer overloads // point it straight at the producer's buffer — no copy involved either - // way. The geometry's opaque bit is read from self.opaque so an UPDATE + // way. The geometry's flags are read from self.geometryFlags so an UPDATE // keeps the exact geometry description of the original build. void RecordProceduralBuildFromAddress(Mesh& self, VkDeviceAddress aabbAddress, std::uint32_t count, std::uint32_t stride, VkBuildAccelerationStructureFlagsKHR flags, bool update, VkCommandBuffer cmd) { VkAccelerationStructureGeometryAabbsDataKHR aabbsData { @@ -312,7 +412,7 @@ namespace { .geometry = geometryData, // Non-opaque by default (mirrors the WebGPU path) so any-hit // shaders run for procedural geometry unless the caller opts out. - .flags = self.opaque ? static_cast(VK_GEOMETRY_OPAQUE_BIT_KHR) : VkGeometryFlagsKHR{} + .flags = self.geometryFlags }; RecordBLASBuildFromGeometry(self, blasGeometry, count, flags, update, cmd); @@ -330,7 +430,7 @@ namespace { // re-uploads into the same same-sized buffer: Resize reuses the existing // allocation (count unchanged), so the device address — and the geometry // it feeds — stays stable for an in-place UPDATE. - self.aabbBuffer.UploadDeviceLocal(kVertexUsageBase, aabbs.data(), static_cast(aabbs.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); + self.aabbBuffer.UploadDeviceLocal(kVertexUsageBase, aabbs.data(), static_cast(aabbs.size()), cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders); RecordProceduralBuildFromAddress(self, self.aabbBuffer.address, static_cast(aabbs.size()), sizeof(RTAabb), flags, update, cmd); } @@ -338,7 +438,7 @@ namespace { void Mesh::BuildProcedural(std::span aabbs, bool opaque, VkCommandBuffer cmd, RTBuildOptions options) { Device::RequireRayTracing(); - this->opaque = opaque; + geometryFlags = GeometryFlags(opaque, options); allowUpdate = options.allowUpdate; builtInputCount = static_cast(aabbs.size()); @@ -353,7 +453,7 @@ void Mesh::BuildProcedural(VkDeviceAddress aabbAddress, std::uint32_t count, boo // The caller owns that buffer's lifetime + usage flags // (ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY + SHADER_DEVICE_ADDRESS) // and must barrier the producing writes before this build reads them. - this->opaque = opaque; + geometryFlags = GeometryFlags(opaque, options); allowUpdate = options.allowUpdate; builtInputCount = count; @@ -385,6 +485,7 @@ void Mesh::Refit(std::span> verticies, std::span> verticies, std::span(verticies.size())); std::uint32_t count = std::min(dirtyVertexCount, static_cast(verticies.size()) - offset); if (count != 0) { - vertexBuffer.UploadDeviceLocalRange(verticies.data() + offset, offset, count, cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); + vertexBuffer.UploadDeviceLocalRange(verticies.data() + offset, offset, count, cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders); } RecordBLASBuild(*this, static_cast(verticies.size()), static_cast(indicies.size()), buildFlags, /*update*/ true, cmd); diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index e26022c..bbcab7d 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -25,6 +25,7 @@ module; #include #include #include +#include #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #include @@ -615,6 +616,19 @@ void Window::RecreateSwapchainAndImages() { CreateSwapchain(); } +void Window::SetPresentMode(PresentMode mode) { + presentMode = mode; + presentModeUnfit = false; + // A window without a swapchain yet picks the mode up when it makes one. + if (swapChain == VK_NULL_HANDLE) return; + // The mode is fixed when a swapchain is made, so make a new one, as + // Resize does: nothing in flight may still use the old images. + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + Device::DrainDeletions(); + RecreateSwapchainAndImages(); + onResize.Invoke(); +} + void Window::SetTitle(const std::string_view title) { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND xdg_toplevel_set_title(xdgToplevel, title.data()); @@ -750,11 +764,100 @@ void Window::SetDefaultCursor() { #endif } +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND +namespace { + // Milliseconds until the first window that updates on its own clock is + // due, rounded up so the wait doesn't end just short of it; -1 when no + // window does, so the loop waits for events alone. + int MillisecondsToNextClockedUpdate() { + const auto now = std::chrono::high_resolution_clock::now(); + std::optional soonest; + for (const Window* window : Device::windows) { + if (!window->updating || window->updateInterval <= std::chrono::nanoseconds::zero()) continue; + const auto wait = window->lastFrameBegin + window->updateInterval - now; + if (!soonest || wait < *soonest) soonest = wait; + } + if (!soonest) return -1; + if (*soonest <= std::chrono::high_resolution_clock::duration::zero()) return 0; + return static_cast(std::chrono::ceil(*soonest).count()); + } +} +#endif + void Window::StartSync() { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND - while (open && wl_display_dispatch(Device::display) != -1) { + // libwayland's read sequence, as wl_display_dispatch runs it, but the + // poll also watches the descriptors windows registered with AddPollFd, + // and times out when a window that updates on its own clock is due. + std::vector fds; + std::vector ready; + bool flushPending = false; + while (open) { + while (wl_display_prepare_read(Device::display) != 0) { + if (wl_display_dispatch_pending(Device::display) == -1) return; + } + // A full socket (EAGAIN) is flushed again once poll says it is + // writable. EPIPE goes on to the read, which reports the + // compositor's error. + flushPending = false; + if (wl_display_flush(Device::display) == -1) { + if (errno == EAGAIN) { + flushPending = true; + } else if (errno != EPIPE) { + wl_display_cancel_read(Device::display); + return; + } + } + + fds.clear(); + fds.push_back({ .fd = wl_display_get_fd(Device::display), .events = static_cast(POLLIN | (flushPending ? POLLOUT : 0)), .revents = 0 }); + for (const Window* window : Device::windows) { + for (const PollFd& entry : window->pollFds) { + fds.push_back({ .fd = entry.fd, .events = POLLIN, .revents = 0 }); + } + } + if (poll(fds.data(), fds.size(), MillisecondsToNextClockedUpdate()) == -1) { + wl_display_cancel_read(Device::display); + if (errno == EINTR) continue; + return; + } + if (fds[0].revents & (POLLIN | POLLERR | POLLHUP)) { + if (wl_display_read_events(Device::display) == -1) return; + } else { + wl_display_cancel_read(Device::display); + } + // Frame callbacks run Update here for windows on the compositor's clock. + if (wl_display_dispatch_pending(Device::display) == -1) return; + + // A callback may add or remove descriptors, so each ready one is + // looked up again, and its callback copied, before it is called. + ready.clear(); + for (std::size_t i = 1; i < fds.size(); ++i) { + if (fds[i].revents != 0) ready.push_back(fds[i].fd); + } + for (int fd : ready) { + for (const Window* window : Device::windows) { + auto entry = std::ranges::find(window->pollFds, fd, &PollFd::fd); + if (entry != window->pollFds.end()) { + std::function onReadable = entry->onReadable; + onReadable(); + break; + } + } + } + Gamepad::Tick(); onBeforeUpdate.Invoke(); + + // By index: an Update may open or close a window. + const auto now = std::chrono::high_resolution_clock::now(); + for (std::size_t i = 0; i < Device::windows.size(); ++i) { + Window* window = Device::windows[i]; + if (window->updating && window->updateInterval > std::chrono::nanoseconds::zero() + && now >= window->lastFrameBegin + window->updateInterval) { + window->Update(); + } + } } #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 @@ -766,11 +869,23 @@ void Window::StartSync() { // iteration. Block on the message queue instead, waking on input or after a // short timeout so gamepad polling / onBeforeUpdate still tick at a sane // rate (e.g. to detect a controller button asking to resume). + // + // A window on its own clock (updateInterval) sleeps the same way until + // its next update is due, instead of updating every pass. constexpr DWORD kPausedPollIntervalMs = 16; while(open) { + // Read each pass, as the Wayland loop does: the clock may be set or + // changed from inside the loop. + const bool clocked = updateInterval > std::chrono::nanoseconds::zero(); if (!updating) { MsgWaitForMultipleObjectsEx(0, nullptr, kPausedPollIntervalMs, QS_ALLINPUT, MWMO_INPUTAVAILABLE); + } else if (clocked) { + const auto wait = lastFrameBegin + updateInterval - std::chrono::high_resolution_clock::now(); + if (wait > std::chrono::high_resolution_clock::duration::zero()) { + MsgWaitForMultipleObjectsEx(0, nullptr, static_cast(std::chrono::ceil(wait).count()), + QS_ALLINPUT, MWMO_INPUTAVAILABLE); + } } MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { @@ -779,7 +894,7 @@ void Window::StartSync() { } Gamepad::Tick(); onBeforeUpdate.Invoke(); - if(updating) { + if(updating && (!clocked || std::chrono::high_resolution_clock::now() >= lastFrameBegin + updateInterval)) { Update(); } } @@ -801,8 +916,11 @@ void Window::StartUpdate() { lastFrameBegin = std::chrono::high_resolution_clock::now(); updating = true; #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND - cb = wl_surface_frame(surface); - wl_callback_add_listener(cb, &wl_callback_listener, this); + // A window on its own clock is updated by StartSync's loop instead. + if (updateInterval == std::chrono::nanoseconds::zero()) { + cb = wl_surface_frame(surface); + wl_callback_add_listener(cb, &wl_callback_listener, this); + } #endif } @@ -810,6 +928,16 @@ void Window::StopUpdate() { updating = false; } +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND +void Window::AddPollFd(int fd, std::function onReadable) { + pollFds.push_back({ .fd = fd, .onReadable = std::move(onReadable) }); +} + +void Window::RemovePollFd(int fd) { + std::erase_if(pollFds, [fd](const PollFd& entry) { return entry.fd == fd; }); +} +#endif + std::chrono::time_point startTime; void Window::Update() { @@ -846,6 +974,133 @@ void Window::Update() { lastFrameBegin = startTime; } +namespace { + // A frame's readback for CaptureNextFrame: the buffer the swapchain image + // is copied into, recorded into the frame's own command buffer. + // It owns the buffer and its memory, which go through the deletion queue + // however the capture ends: written, or unwound past (a failed + // allocation, a lost device), as the frame may be copying into it. + struct FrameCapture { + VkBuffer buffer = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkMemoryPropertyFlags memoryFlags = 0; + std::uint32_t width = 0; + std::uint32_t height = 0; + // Where it goes: taken from Window::pendingCapture as the copy is + // recorded, so a capture asked for meanwhile (from onResize, say) is + // the next frame's. + std::filesystem::path path; + + FrameCapture(std::uint32_t width, std::uint32_t height) : width(width), height(height) {} + FrameCapture(FrameCapture&& other) noexcept + : buffer(std::exchange(other.buffer, VK_NULL_HANDLE)), memory(std::exchange(other.memory, VK_NULL_HANDLE)), + memoryFlags(other.memoryFlags), width(other.width), height(other.height), path(std::move(other.path)) {} + FrameCapture& operator=(FrameCapture&&) = delete; + ~FrameCapture() { + Device::EnqueueDeletion(buffer, memory); + } + }; + + // Records the copy of `image`, in GENERAL and last written at `writers` + // with `writerAccess`, into a new host-visible buffer. + FrameCapture RecordFrameCapture(VkCommandBuffer cmd, VkImage image, std::uint32_t width, std::uint32_t height, VkPipelineStageFlags writers, VkAccessFlags writerAccess) { + FrameCapture capture(width, height); + const VkBufferCreateInfo bufferInfo { + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = static_cast(width) * height * 4, + .usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + }; + // Into a local first: a call that fails leaves its output undefined. + VkBuffer buffer = VK_NULL_HANDLE; + Device::CheckVkResult(vkCreateBuffer(Device::device, &bufferInfo, nullptr, &buffer)); + capture.buffer = buffer; + VkMemoryRequirements requirements; + vkGetBufferMemoryRequirements(Device::device, capture.buffer, &requirements); + // The CPU reads all of it: cached memory where there is some, as + // write-combined memory is very slow to read (#59, #89). + std::uint32_t memoryType; + try { + memoryType = Device::GetMemoryType(requirements.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT, VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + } catch (const std::runtime_error&) { + memoryType = Device::GetMemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); + } + capture.memoryFlags = Device::memoryProperties.memoryTypes[memoryType].propertyFlags; + const VkMemoryAllocateInfo allocation { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = requirements.size, + .memoryTypeIndex = memoryType, + }; + VkDeviceMemory memory = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateMemory(Device::device, &allocation, nullptr, &memory)); + capture.memory = memory; + Device::CheckVkResult(vkBindBufferMemory(Device::device, capture.buffer, capture.memory, 0)); + + // The passes' writes, before the copy reads them. + const VkImageMemoryBarrier written { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = writerAccess, + .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_GENERAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image, + .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }, + }; + vkCmdPipelineBarrier(cmd, writers, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &written); + const VkBufferImageCopy region { + .imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }, + .imageExtent = { width, height, 1 }, + }; + vkCmdCopyImageToBuffer(cmd, image, VK_IMAGE_LAYOUT_GENERAL, capture.buffer, 1, ®ion); + const VkBufferMemoryBarrier copied { + .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_HOST_READ_BIT, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .buffer = capture.buffer, + .offset = 0, + .size = VK_WHOLE_SIZE, + }; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &copied, 0, nullptr); + return capture; + } + + // Once the frame has finished: the readback as an RGBA PNG at `path`. + void WriteFrameCapture(const FrameCapture& capture, VkFormat format) { + const std::filesystem::path& path = capture.path; + void* mapped = nullptr; + Device::CheckVkResult(vkMapMemory(Device::device, capture.memory, 0, VK_WHOLE_SIZE, 0, &mapped)); + // Cached but not coherent: the copy's writes have to be pulled in. + if ((capture.memoryFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) { + const VkMappedMemoryRange range { + .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, + .memory = capture.memory, + .offset = 0, + .size = VK_WHOLE_SIZE, + }; + Device::CheckVkResult(vkInvalidateMappedMemoryRanges(Device::device, 1, &range)); + } + const std::uint8_t* src = static_cast(mapped); + const std::size_t texels = static_cast(capture.width) * capture.height; + std::vector rgba(texels * 4); + const bool bgr = format == VK_FORMAT_B8G8R8A8_UNORM; + for (std::size_t i = 0; i < texels; ++i) { + rgba[i * 4 + 0] = src[i * 4 + (bgr ? 2 : 0)]; + rgba[i * 4 + 1] = src[i * 4 + 1]; + rgba[i * 4 + 2] = src[i * 4 + (bgr ? 0 : 2)]; + rgba[i * 4 + 3] = src[i * 4 + 3]; + } + vkUnmapMemory(Device::device, capture.memory); + if (stbi_write_png(path.string().c_str(), static_cast(capture.width), static_cast(capture.height), 4, rgba.data(), static_cast(capture.width) * 4) == 0) { + throw std::runtime_error(std::format("CaptureNextFrame: could not write {}", path.string())); + } + } +} + void Window::Render() { // Acquire the next image from the swap chain. If the surface has // changed size out from under us (compositor/Win32 resize delivered @@ -981,10 +1236,10 @@ void Window::Render() { } #endif - // Note: vkCmdClearColorImage is unavailable here — the swapchain is - // created with VK_IMAGE_USAGE_STORAGE_BIT only (no TRANSFER_DST_BIT). - // Passes that need a background should write one explicitly (UIScene - // exposes a `background()` setter for this purpose). + // The window doesn't clear the image: not every surface allows the + // swapchain TRANSFER_DST (swapchainUsage says whether this one does), so + // passes that need a background write one themselves, by a clear where + // the usage allows it or from a shader (UIScene's `background()`). (void)clearColor; for (std::size_t i = 0; i < passes.size(); ++i) { @@ -1000,12 +1255,12 @@ void Window::Render() { // each pass's own SwapchainStage(), so a compute->compute edge only // serialises COMPUTE_SHADER while an RT pass on either side pulls in // RAY_TRACING_SHADER — never the ALL_COMMANDS full-pipeline stall. - VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer]); // Masked like swapWriterStages above: a pass that keeps the // default stage reports a union that names RAY_TRACING_SHADER. - vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], - passes[i]->SwapchainStage() & Device::stageMask, - passes[i + 1]->SwapchainStage() & Device::stageMask, + const VkPipelineStageFlags srcStages = passes[i]->SwapchainStage() & Device::stageMask; + const VkPipelineStageFlags dstStages = passes[i + 1]->SwapchainStage() & Device::stageMask; + VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer], srcStages, dstStages); + vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], srcStages, dstStages, 0, 0, nullptr, 0, nullptr, 1, &ib); } } @@ -1018,10 +1273,21 @@ void Window::Render() { // TRANSFER_WRITE here too — derive both from the same union for symmetry. presentBarrier.srcAccessMask = SwapchainWriterAccess(swapWriterStages); + // CaptureNextFrame: the copy goes here, while the image is still this + // frame's, and the transition to present waits for it. + std::optional capture; + VkPipelineStageFlags presentWaitStages = swapWriterStages; + if (pendingCapture) { + capture.emplace(RecordFrameCapture(drawCmdBuffers[currentBuffer], images[currentBuffer], width, height, swapWriterStages, presentBarrier.srcAccessMask)); + presentWaitStages |= VK_PIPELINE_STAGE_TRANSFER_BIT; + capture->path = std::move(*pendingCapture); + pendingCapture.reset(); + } + // The ->PRESENT_SRC transition only needs to wait on the stages that // actually wrote the image (the per-pass union), not ALL_COMMANDS; present // itself is gated by the render-finished semaphore, so dst stays BOTTOM. - vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], swapWriterStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier); + vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], presentWaitStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier); Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer])); @@ -1062,6 +1328,13 @@ void Window::Render() { // separate from the per-window frameCounter above (which also keys the // acquire-semaphore slot) so non-window code can enqueue against it. Device::frameCounter++; + + if (capture) { + // A capture is for tests and screenshots: waiting for the frame here + // is fine. + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + WriteFrameCapture(*capture, colorFormat); + } } #ifdef CRAFTER_TIMING @@ -1148,30 +1421,37 @@ void Window::CreateSwapchain() swapchainCI.imageFormat = colorFormat; swapchainCI.imageColorSpace = colorSpace; swapchainCI.imageExtent = { swapchainExtent.width, swapchainExtent.height }; - swapchainCI.imageUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + swapchainUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT + | (surfCaps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT); + swapchainCI.imageUsage = swapchainUsage; swapchainCI.preTransform = (VkSurfaceTransformFlagBitsKHR)preTransform; swapchainCI.imageArrayLayers = 1; swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; swapchainCI.queueFamilyIndexCount = 0; // VK_PRESENT_MODE_FIFO_KHR ("v-sync") is guaranteed available per spec, so - // it is the default. For benchmarking we want frame pacing uncapped from - // the display refresh, so when CRAFTER_PRESENT_IMMEDIATE is set in the - // environment we enumerate the surface's present modes and prefer - // IMMEDIATE (then MAILBOX) when offered, falling back to FIFO otherwise. - swapchainCI.presentMode = VK_PRESENT_MODE_FIFO_KHR; - if (std::getenv("CRAFTER_PRESENT_IMMEDIATE")) { + // it is the fallback for a mode the surface lacks. For benchmarking we + // want frame pacing uncapped from the display refresh, so when + // CRAFTER_PRESENT_IMMEDIATE is set in the environment we prefer IMMEDIATE + // (then MAILBOX) whatever presentMode asks for. + { std::uint32_t presentModeCount = 0; vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, nullptr); std::vector presentModes(presentModeCount); vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, presentModes.data()); - bool haveImmediate = false, haveMailbox = false; - for (VkPresentModeKHR m : presentModes) { - if (m == VK_PRESENT_MODE_IMMEDIATE_KHR) haveImmediate = true; - if (m == VK_PRESENT_MODE_MAILBOX_KHR) haveMailbox = true; + auto offered = [&](VkPresentModeKHR mode) { return std::ranges::find(presentModes, mode) != presentModes.end(); }; + swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR; + if (presentModeUnfit) { + // Stays FIFO; see below. + } else if (std::getenv("CRAFTER_PRESENT_IMMEDIATE")) { + if (offered(VK_PRESENT_MODE_IMMEDIATE_KHR)) swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR; + else if (offered(VK_PRESENT_MODE_MAILBOX_KHR)) swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR; + } else if (presentMode == PresentMode::Mailbox && offered(VK_PRESENT_MODE_MAILBOX_KHR)) { + swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR; + } else if (presentMode == PresentMode::Immediate && offered(VK_PRESENT_MODE_IMMEDIATE_KHR)) { + swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR; } - if (haveImmediate) swapchainCI.presentMode = VK_PRESENT_MODE_IMMEDIATE_KHR; - else if (haveMailbox) swapchainCI.presentMode = VK_PRESENT_MODE_MAILBOX_KHR; } + swapchainCI.presentMode = swapchainPresentMode; // Setting oldSwapChain to the saved handle of the previous swapchain aids in resource reuse and makes sure that we can still present already acquired images swapchainCI.oldSwapchain = oldSwapchain; // Setting clipped to VK_TRUE allows the implementation to discard rendering outside of the surface area @@ -1188,8 +1468,21 @@ void Window::CreateSwapchain() Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr)); // images[] and every other per-frame array hold exactly numFrames // entries. A driver may create more images than minImageCount asked for - // (a surface whose minimum is above numFrames must); writing them into - // images[] would run off its end, so refuse instead. + // (a surface whose minimum is above numFrames must, and Mesa gives + // MAILBOX at least four). MAILBOX and IMMEDIATE are wishes: FIFO, with + // numFrames, is the frame loop's own. If that has other than numFrames + // too, writing them into images[] would run off its end, so refuse. + if (imageCount != numFrames && swapchainPresentMode != VK_PRESENT_MODE_FIFO_KHR) { + std::println(std::cerr, "Window: the {} swapchain has {} images, not the frame loop's {}; using FIFO", + swapchainPresentMode == VK_PRESENT_MODE_MAILBOX_KHR ? "MAILBOX" : "IMMEDIATE", imageCount, numFrames); + vkDestroySwapchainKHR(Device::device, swapChain, nullptr); + presentModeUnfit = true; + swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR; + swapchainCI.presentMode = swapchainPresentMode; + swapchainCI.oldSwapchain = VK_NULL_HANDLE; + Device::CheckVkResult(vkCreateSwapchainKHR(Device::device, &swapchainCI, nullptr, &swapChain)); + Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr)); + } if (imageCount != numFrames) { throw std::runtime_error(std::format("Window: the swapchain has {} images, but the frame loop is built for exactly {}", imageCount, numFrames)); } @@ -1301,7 +1594,8 @@ void Window::wl_surface_frame_done(void* data, struct wl_callback *cb, uint32_t Window* window = reinterpret_cast(data); // The member names the live callback, so the destructor can cancel it. - if(window->updating) { + // A window that has since moved to its own clock lets the chain end. + if(window->updating && window->updateInterval == std::chrono::nanoseconds::zero()) { window->cb = wl_surface_frame(window->surface); wl_callback_add_listener(window->cb, &Window::wl_callback_listener, window); window->Update(); @@ -1359,171 +1653,8 @@ void Window::xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_ #endif -void Window::SaveFrame(const std::filesystem::path& path) { - // Staging buffer big enough for one RGBA frame. - VkDeviceSize bufSize = static_cast(width) * height * 4; - - VkBufferCreateInfo bci{ - .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, - .size = bufSize, - .usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT, - .sharingMode = VK_SHARING_MODE_EXCLUSIVE, - }; - VkBuffer stagingBuf = VK_NULL_HANDLE; - Device::CheckVkResult(vkCreateBuffer(Device::device, &bci, nullptr, &stagingBuf)); - - VkMemoryRequirements memReqs; - vkGetBufferMemoryRequirements(Device::device, stagingBuf, &memReqs); - - // Readback path: the CPU *reads* this whole buffer back. The first - // HOST_VISIBLE|HOST_COHERENT type is usually write-combined (uncached) on - // discrete GPUs, where CPU reads are pathologically slow. Ask for - // HOST_CACHED so reads hit the CPU cache, preferring a cached-coherent type - // when one exists. HOST_CACHED is near-universal but not spec-guaranteed, - // so fall back to any HOST_VISIBLE type (#59) if absent — that path then - // relies on the coherency-gated invalidate below. (Deliberately NOT routed - // through the upload-strategy helper, which steers toward write-combined - // DEVICE_LOCAL|HOST_VISIBLE — the worst type for readback. See issue #89.) - std::uint32_t memTypeIndex; - try { - memTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT, - VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); - } catch (const std::runtime_error&) { - memTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); - } - VkMemoryAllocateInfo mai{ - .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, - .allocationSize = memReqs.size, - .memoryTypeIndex = memTypeIndex, - }; - VkDeviceMemory stagingMem = VK_NULL_HANDLE; - Device::CheckVkResult(vkAllocateMemory(Device::device, &mai, nullptr, &stagingMem)); - Device::CheckVkResult(vkBindBufferMemory(Device::device, stagingBuf, stagingMem, 0)); - - // One-shot command buffer so we don't trash the per-frame ones. - VkCommandBufferAllocateInfo cba{ - .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, - .commandPool = Device::commandPool, - .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, - .commandBufferCount = 1, - }; - VkCommandBuffer cmd = VK_NULL_HANDLE; - Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cba, &cmd)); - - VkCommandBufferBeginInfo cbi{ - .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, - .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, - }; - Device::CheckVkResult(vkBeginCommandBuffer(cmd, &cbi)); - - VkImageSubresourceRange range{ - .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, - .baseMipLevel = 0, - .levelCount = 1, - .baseArrayLayer = 0, - .layerCount = 1, - }; - - // Render() leaves the image in PRESENT_SRC_KHR. - VkImageMemoryBarrier toSrc{ - .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, - .srcAccessMask = 0, - .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT, - .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, - .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .image = images[currentBuffer], - .subresourceRange = range, - }; - vkCmdPipelineBarrier(cmd, - VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, - 0, 0, nullptr, 0, nullptr, 1, &toSrc); - - VkBufferImageCopy region{ - .bufferOffset = 0, - .bufferRowLength = 0, - .bufferImageHeight = 0, - .imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }, - .imageOffset = { 0, 0, 0 }, - .imageExtent = { width, height, 1 }, - }; - vkCmdCopyImageToBuffer(cmd, images[currentBuffer], - VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, stagingBuf, 1, ®ion); - - VkImageMemoryBarrier back{ - .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, - .srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT, - .dstAccessMask = 0, - .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, - .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .image = images[currentBuffer], - .subresourceRange = range, - }; - vkCmdPipelineBarrier(cmd, - VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, - 0, 0, nullptr, 0, nullptr, 1, &back); - - Device::CheckVkResult(vkEndCommandBuffer(cmd)); - - VkSubmitInfo si{ - .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, - .commandBufferCount = 1, - .pCommandBuffers = &cmd, - }; - Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &si, VK_NULL_HANDLE)); - Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); - - // Read back, swizzle BGRA → RGBA if needed, write PNG. - void* mapped = nullptr; - Device::CheckVkResult(vkMapMemory(Device::device, stagingMem, 0, VK_WHOLE_SIZE, 0, &mapped)); - - // If the chosen type is cached-but-not-coherent, the transfer's writes may - // not yet be visible to the CPU read; invalidate to pull them in. Gate on - // the chosen type's actual coherency (not the requested flags) — a coherent - // type needs no invalidate. Whole-range invalidate sidesteps - // nonCoherentAtomSize. This is the read-path counterpart of FlushDevice (#60). - if (!(Device::memoryProperties.memoryTypes[memTypeIndex].propertyFlags & - VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) { - VkMappedMemoryRange invalidateRange{ - .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, - .memory = stagingMem, - .offset = 0, - .size = VK_WHOLE_SIZE, - }; - Device::CheckVkResult( - vkInvalidateMappedMemoryRanges(Device::device, 1, &invalidateRange)); - } - - const std::uint8_t* src = static_cast(mapped); - - std::vector rgba(static_cast(width) * height * 4); - bool bgr = (colorFormat == VK_FORMAT_B8G8R8A8_UNORM); - for (std::uint32_t i = 0; i < width * height; ++i) { - if (bgr) { - rgba[i*4+0] = src[i*4+2]; - rgba[i*4+1] = src[i*4+1]; - rgba[i*4+2] = src[i*4+0]; - rgba[i*4+3] = src[i*4+3]; - } else { - rgba[i*4+0] = src[i*4+0]; - rgba[i*4+1] = src[i*4+1]; - rgba[i*4+2] = src[i*4+2]; - rgba[i*4+3] = src[i*4+3]; - } - } - vkUnmapMemory(Device::device, stagingMem); - - stbi_write_png(path.string().c_str(), static_cast(width), static_cast(height), - 4, rgba.data(), static_cast(width) * 4); - - vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); - vkDestroyBuffer(Device::device, stagingBuf, nullptr); - vkFreeMemory(Device::device, stagingMem, nullptr); +void Window::CaptureNextFrame(std::filesystem::path path) { + pendingCapture = std::move(path); } #endif // !CRAFTER_GRAPHICS_WINDOW_DOM @@ -1707,6 +1838,11 @@ void Window::StopUpdate() { Crafter::DomEnv::domStopFrameLoop(); } +void Window::SetPresentMode(PresentMode mode) { + // The page presents the canvas; there is no mode to pick. + presentMode = mode; +} + void Window::Update() { auto now = std::chrono::high_resolution_clock::now(); mouseDelta = {currentMousePos.x - lastMousePos.x, diff --git a/interfaces/Crafter.Graphics-ComputeShader.cppm b/interfaces/Crafter.Graphics-ComputeShader.cppm index 8900aa6..6f23094 100644 --- a/interfaces/Crafter.Graphics-ComputeShader.cppm +++ b/interfaces/Crafter.Graphics-ComputeShader.cppm @@ -51,6 +51,9 @@ export namespace Crafter { ~ComputeShader(); // Loads a SPIR-V compute shader from disk and creates its pipeline. + // Loading again replaces the pipeline. The old one, like the one a + // ComputeShader holds when it's destroyed or moved over, goes through + // the deferred-deletion queue: a frame in flight may still dispatch it. // // `spvPath` always names the descriptor-heap build (.spv). On a // device without the heap, the .sets.spv build next to it is diff --git a/interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm b/interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm index 3163a72..2802581 100644 --- a/interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm +++ b/interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm @@ -391,12 +391,35 @@ export namespace Crafter { 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)); @@ -412,6 +435,9 @@ export namespace Crafter { 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)); @@ -427,6 +453,9 @@ export namespace Crafter { 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)); @@ -441,23 +470,26 @@ export namespace Crafter { // 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. - // noexcept: capacity is reserved at Initialize so push_back never reallocates. + // 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. void FreeImageSlots(ImageSlotRange r) noexcept { - for (std::uint16_t i = 0; i < r.count; ++i) { - imageFreelist.push_back(static_cast(r.firstElement + i)); - } + Release(imageFreelist, r.firstElement, r.count); } void FreeBufferSlots(BufferSlotRange r) noexcept { - for (std::uint16_t i = 0; i < r.count; ++i) { - bufferFreelist.push_back(static_cast(r.firstElement + i)); - } + Release(bufferFreelist, r.firstElement, r.count); } void FreeSamplerSlots(SamplerSlotRange r) noexcept { - for (std::uint16_t i = 0; i < r.count; ++i) { - samplerFreelist.push_back(static_cast(r.firstElement + i)); + 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)); } } diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index ffca74e..3f00b53 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -378,17 +378,30 @@ export namespace Crafter { // instead, tagged with the current frameCounter; ReclaimDeletions frees // an entry only once framesInFlight frames have elapsed, by which point // single-queue submission order guarantees all GPU work that could - // reference it has completed. + // reference it has completed. Destructors that own Vulkan handles + // (VulkanBuffer, Mesh, VulkanShader, ComputeShader, PipelineRTVulkan) + // queue them too: they can't know + // whether a frame in flight still uses the handle, and a static owner + // is destroyed after the driver and its layers have torn down, when no + // Vulkan call may run. An entry holds one kind of handle; the others + // stay VK_NULL_HANDLE. struct PendingDeletion { std::uint64_t retireAfter; // frameCounter value at enqueue time - VkBuffer buffer; - VkDeviceMemory memory; + VkBuffer buffer = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkAccelerationStructureKHR accelerationStructure = VK_NULL_HANDLE; + VkShaderModule shaderModule = VK_NULL_HANDLE; + VkPipeline pipeline = VK_NULL_HANDLE; }; // Monotonic recorded-frame index, bumped once per Window::Render. Not // the same as Window::frameCounter (which is per-window and also drives // the acquire-semaphore slot) — this one is global so Mesh/Device code - // can enqueue too, not just the window loop. - inline static std::uint64_t frameCounter = 0; + // can enqueue too, not just the window loop. Atomic: a destructor on + // another thread reads it as it queues. It counts frames as one + // window renders them: with several windows rendering, framesInFlight + // of its counts pass sooner than each window's frames in flight, so + // the deletion queue and the staging rings assume one window. + inline static std::atomic frameCounter = 0; // = Window::numFrames; set at window init. The number of frames that // must elapse after an enqueue before the resource is safe to free. // Zero until set, which only makes ReclaimDeletions more eager — and it @@ -397,18 +410,36 @@ export namespace Crafter { // A deque, not a vector: frameCounter is monotonic, so retireAfter is // non-decreasing down the queue and the entries ready to reclaim are // always a prefix. ReclaimDeletions pops that prefix off the front in - // O(ready) instead of walking and compacting the whole queue. - inline static std::deque deletionQueue; + // O(ready) instead of walking and compacting the whole queue. Never + // destroyed: static owners queue their handles from their destructors + // at exit, and those may run after this queue's own destructor would. + inline static std::deque& deletionQueue = *new std::deque(); + // Held by the calls below: handle owners go on whichever thread drops + // them (a loader's, say) while the render loop reclaims. Never + // destroyed, for the same reason as the queue. + inline static std::mutex& deletionMutex = *new std::mutex(); // Tag {buffer, memory} for deletion after framesInFlight more frames. // A VK_NULL_HANDLE buffer is ignored (nothing to free). static void EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory); + // The same for an acceleration structure, a shader module and a + // pipeline. Queue an acceleration structure before the buffer it lives + // in: entries are freed in queue order. A VK_NULL_HANDLE is ignored. + static void EnqueueDeletion(VkAccelerationStructureKHR accelerationStructure); + static void EnqueueDeletion(VkShaderModule shaderModule); + static void EnqueueDeletion(VkPipeline pipeline); // Free every entry whose retire frame has been reached // (retireAfter + framesInFlight <= frameCounter). Call once per frame // after waiting that frame's fence. static void ReclaimDeletions(); - // Free every queued entry unconditionally. Call only after a wait-idle - // (resize / teardown), when no in-flight GPU work can reference them. + // Free every queued entry unconditionally, and let every buffer's + // staging ring start over. Call only after a wait-idle (resize / + // teardown), when no in-flight GPU work can reference them. Without a + // Window nothing else reclaims them: an app that has none calls this + // after waiting for its work, or both only grow. static void DrainDeletions(); + // How many DrainDeletions have run: the staging rings start over + // when it changes. + inline static std::atomic drains = 0; // ─── Wayland key repeat ──────────────────────────────────────── // TickKeyRepeats fires onRawKeyDown / onRawKeyHold / onTextInput on diff --git a/interfaces/Crafter.Graphics-ImageVulkan.cppm b/interfaces/Crafter.Graphics-ImageVulkan.cppm index 8ae4df8..362b393 100644 --- a/interfaces/Crafter.Graphics-ImageVulkan.cppm +++ b/interfaces/Crafter.Graphics-ImageVulkan.cppm @@ -177,46 +177,7 @@ export namespace Crafter { ®ion ); - if(mipLevels > 1) { - TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1); - - for (std::uint16_t i = 1; i < mipLevels; ++i) { - std::uint16_t mipWidth = width >> i; - std::uint16_t mipHeight = height >> i; - - std::uint16_t previousMipWidth = width >> (i - std::uint16_t(1)); - std::uint16_t previousMipHeight = height >> (i - std::uint16_t(1)); - - VkImageBlit blit = {}; - blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - blit.srcSubresource.mipLevel = i - 1; - blit.srcSubresource.baseArrayLayer = 0; - blit.srcSubresource.layerCount = 1; - blit.srcOffsets[0] = { 0, 0, 0 }; - blit.srcOffsets[1] = { (int32_t)previousMipWidth, (int32_t)previousMipHeight, 1 }; - - blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - blit.dstSubresource.mipLevel = i; - blit.dstSubresource.baseArrayLayer = 0; - blit.dstSubresource.layerCount = 1; - blit.dstOffsets[0] = { 0, 0, 0 }; - blit.dstOffsets[1] = { (int32_t)mipWidth, (int32_t)mipHeight, 1 }; - - vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR); - // The final blit's destination is never read again, so it - // skips the DST->SRC barrier and is taken straight to the - // consumer layout by the batched final transition below. - if (i + 1 < mipLevels) { - TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1); - } - } - - std::array finalBarriers; - std::uint32_t finalCount = BuildMipChainFinalBarriers(image, layout, mipLevels, finalBarriers); - vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, 0, 0, nullptr, 0, nullptr, finalCount, finalBarriers.data()); - } else { - TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels); - } + BuildMipChain(cmd, layout); ReleaseStaging(); } @@ -336,44 +297,51 @@ export namespace Crafter { region.imageExtent = { width, height, 1 }; vkCmdCopyBufferToImage(cmd, buffer.buffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); - if (mipLevels > 1) { - TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1); - for (std::uint16_t i = 1; i < mipLevels; ++i) { - std::uint16_t mipWidth = width >> i; - std::uint16_t mipHeight = height >> i; - std::uint16_t previousMipWidth = width >> (i - std::uint16_t(1)); - std::uint16_t previousMipHeight = height >> (i - std::uint16_t(1)); - - VkImageBlit blit = {}; - blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - blit.srcSubresource.mipLevel = i - 1; - blit.srcSubresource.baseArrayLayer = 0; - blit.srcSubresource.layerCount = 1; - blit.srcOffsets[0] = { 0, 0, 0 }; - blit.srcOffsets[1] = { (int32_t)previousMipWidth, (int32_t)previousMipHeight, 1 }; - blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - blit.dstSubresource.mipLevel = i; - blit.dstSubresource.baseArrayLayer = 0; - blit.dstSubresource.layerCount = 1; - blit.dstOffsets[0] = { 0, 0, 0 }; - blit.dstOffsets[1] = { (int32_t)mipWidth, (int32_t)mipHeight, 1 }; - vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR); - // The final blit's destination is never read again, so it - // skips the DST->SRC barrier and is taken straight to the - // consumer layout by the batched final transition below. - if (i + 1 < mipLevels) { - TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1); - } - } - std::array finalBarriers; - std::uint32_t finalCount = BuildMipChainFinalBarriers(image, layout, mipLevels, finalBarriers); - vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, 0, 0, nullptr, 0, nullptr, finalCount, finalBarriers.data()); - } else { - TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels); - } + BuildMipChain(cmd, layout); ReleaseStaging(); } + // After level 0 was copied in (in TRANSFER_DST): blits each level from + // the one above it, then takes the whole image to `layout` for + // consumerStage. A level is max(1, size >> level) on each side, as + // Vulkan sizes it: a non-square image's shorter side reaches 1 before + // the chain ends, and a blit to 0 would write nothing. + void BuildMipChain(VkCommandBuffer cmd, VkImageLayout layout) { + if (mipLevels <= 1) { + TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels); + return; + } + auto levelSize = [](std::uint16_t size, std::uint32_t level) { + return std::max(1, static_cast(size >> level)); + }; + TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1); + for (std::uint32_t i = 1; i < mipLevels; ++i) { + VkImageBlit blit = {}; + blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.srcSubresource.mipLevel = i - 1; + blit.srcSubresource.baseArrayLayer = 0; + blit.srcSubresource.layerCount = 1; + blit.srcOffsets[0] = { 0, 0, 0 }; + blit.srcOffsets[1] = { levelSize(width, i - 1), levelSize(height, i - 1), 1 }; + blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.dstSubresource.mipLevel = i; + blit.dstSubresource.baseArrayLayer = 0; + blit.dstSubresource.layerCount = 1; + blit.dstOffsets[0] = { 0, 0, 0 }; + blit.dstOffsets[1] = { levelSize(width, i), levelSize(height, i), 1 }; + vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR); + // The final blit's destination is never read again, so it + // skips the DST->SRC barrier and is taken straight to the + // consumer layout by the batched final transition below. + if (i + 1 < mipLevels) { + TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1); + } + } + std::array finalBarriers; + std::uint32_t finalCount = BuildMipChainFinalBarriers(image, layout, mipLevels, finalBarriers); + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, 0, 0, nullptr, 0, nullptr, finalCount, finalBarriers.data()); + } + void Destroy() { vkDestroyImageView(Device::device, imageView, nullptr); vkDestroyImage(Device::device, image, nullptr); diff --git a/interfaces/Crafter.Graphics-Mesh.cppm b/interfaces/Crafter.Graphics-Mesh.cppm index 5fd8875..b9ee081 100644 --- a/interfaces/Crafter.Graphics-Mesh.cppm +++ b/interfaces/Crafter.Graphics-Mesh.cppm @@ -49,6 +49,12 @@ export namespace Crafter { // PREFER_FAST_BUILD are mutually exclusive, so `preference` selects // exactly one — allowUpdate is layered on top of whichever is set. bool allowUpdate = false; + // Set VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR: an any-hit + // shader then runs at most once per primitive along a ray, which a + // shader that accumulates along it (transmittance through tinted + // glass) relies on. Without it the implementation may call it twice + // for one primitive. Can cost some trace speed. + bool noDuplicateAnyHit = false; }; class Mesh { @@ -75,12 +81,14 @@ export namespace Crafter { // has cleared — no longer pinned for the mesh's life. Between Builds the // handle is null; the next Build's Resize re-creates it. VulkanBuffer compressedStaging; - VkAccelerationStructureGeometryTrianglesDataKHR blasData; - VkAccelerationStructureGeometryKHR blas; VkAccelerationStructureKHR accelerationStructure = VK_NULL_HANDLE; - VkDeviceAddress blasAddr; - bool opaque; + VkDeviceAddress blasAddr = 0; // ─── Build-option / refit state ────────────────────────────────── + // The geometry's flags at the last (re)build: OPAQUE for triangles + // and for procedural geometry built opaque, plus + // NO_DUPLICATE_ANY_HIT_INVOCATION when asked for. Kept because an + // UPDATE must describe its geometry exactly as the build did. + VkGeometryFlagsKHR geometryFlags = VK_GEOMETRY_OPAQUE_BIT_KHR; // Flags the BLAS was last (re)built with — carried so Refit can // re-issue an identical-flags UPDATE build (the FAST_TRACE/FAST_BUILD // preference plus, when opted in, ALLOW_UPDATE). @@ -95,6 +103,23 @@ export namespace Crafter { // Whether ALLOW_UPDATE was set on the last build, i.e. whether an // in-place refit is possible at all. bool allowUpdate = false; + + Mesh() = default; + // Moving hands the BLAS over: the moved-from Mesh keeps no handle, so + // only the last owner releases it. Assigning queues the BLAS this one + // held, as the destructor would. + Mesh(Mesh&& other); + Mesh& operator=(Mesh&& other); + // Queues the BLAS for deferred deletion, ahead of the buffers it lives + // in (they queue themselves): a frame in flight may still trace it. + ~Mesh(); + + // A rebuild (any Build on a Mesh that already has a BLAS) queues the + // old handle for deferred deletion, since this command buffer or a + // frame in flight may still use it, and builds into the same storage + // after a barrier when the new BLAS fits, so blasAddr normally stays + // the same. A BLAS that outgrows its storage moves, so read blasAddr + // again after a rebuild. void Build(std::span> verticies, std::span indicies, VkCommandBuffer cmd, RTBuildOptions options = {}); // GPU path: decompresses vertex (region 0) and index (region 1) streams // from asset.blob into vertexBuffer / indexBuffer using @@ -126,7 +151,11 @@ export namespace Crafter { // and VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, its boxes laid out as // VkAabbPositionsKHR (`stride`, default sizeof(RTAabb) == 24), and the // caller must pipeline-barrier the producing writes (e.g. a compute - // dispatch) before the build on `cmd` reads them. The buffer's + // dispatch) before the build on `cmd` reads them, with + // VK_ACCESS_SHADER_READ_BIT at the acceleration-structure build stage: + // a build reads its geometry as a shader read, and the + // ACCELERATION_STRUCTURE access bits cover only the scratch and the + // acceleration structures themselves. The buffer's // lifetime is the caller's responsibility — it is never copied, so it // must outlive the build submitted on `cmd`. Everything else (opaque // bit, hit-group contract, instance wiring) matches the host-span @@ -151,8 +180,8 @@ export namespace Crafter { // Dirty-range refit: same as Refit above, but only the contiguous block // of vertices in [dirtyVertexOffset, dirtyVertexOffset + dirtyVertexCount) // actually moved, so on the in-place UPDATE path only that sub-range is - // re-uploaded — host-write + flush + barrier (direct) or re-stage + copy - // (staged) scale with the moved vertices, not the whole array (#119). Use + // re-uploaded — the staged copy scales with the moved vertices, not the + // whole array (#119). Use // this for a deforming mesh that nudges a small, known window each frame. // `verticies` / `indicies` are still the *full* arrays (same contract and // topology rule as the full-span Refit — the index buffer is read only @@ -229,6 +258,8 @@ export namespace Crafter { struct RTBuildOptions { RTBuildPreference preference = RTBuildPreference::FastTrace; bool allowUpdate = false; + // No effect here either; for API symmetry like the rest. + bool noDuplicateAnyHit = false; }; class Mesh { diff --git a/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm b/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm index 245ca47..12c0c62 100644 --- a/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm +++ b/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm @@ -14,14 +14,19 @@ import :ShaderBindingTableVulkan; import :Types; export namespace Crafter { + // Owns a ray-tracing pipeline and its shader binding table. Both go to + // the deferred-deletion queue when it is destroyed, moved over or + // initialised again: a frame in flight may still trace with them, and a + // static one is destroyed after the driver. It moves, and can't be copied: + // two copies would both release the pipeline. struct PipelineRTVulkan { - VkPipeline pipeline; - std::vector shaderHandles; + VkPipeline pipeline = VK_NULL_HANDLE; + std::vector shaderHandles; VulkanBuffer sbtBuffer; - VkStridedDeviceAddressRegionKHR raygenRegion; - VkStridedDeviceAddressRegionKHR missRegion; - VkStridedDeviceAddressRegionKHR hitRegion; - VkStridedDeviceAddressRegionKHR callableRegion; + VkStridedDeviceAddressRegionKHR raygenRegion {}; + VkStridedDeviceAddressRegionKHR missRegion {}; + VkStridedDeviceAddressRegionKHR hitRegion {}; + VkStridedDeviceAddressRegionKHR callableRegion {}; // NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): copied // from the shader table at Init so RTPass can push the active TLAS @@ -30,6 +35,35 @@ export namespace Crafter { bool workaroundNeedsTlas = false; std::uint32_t workaroundTlasPushOffset = 0; + PipelineRTVulkan() = default; + PipelineRTVulkan(const PipelineRTVulkan&) = delete; + PipelineRTVulkan& operator=(const PipelineRTVulkan&) = delete; + + PipelineRTVulkan(PipelineRTVulkan&& other) noexcept + : pipeline(std::exchange(other.pipeline, VK_NULL_HANDLE)), + shaderHandles(std::move(other.shaderHandles)), + sbtBuffer(std::move(other.sbtBuffer)), + raygenRegion(other.raygenRegion), + missRegion(other.missRegion), + hitRegion(other.hitRegion), + callableRegion(other.callableRegion), + workaroundNeedsTlas(other.workaroundNeedsTlas), + workaroundTlasPushOffset(other.workaroundTlasPushOffset) {} + + PipelineRTVulkan& operator=(PipelineRTVulkan&& other) noexcept { + if (this == &other) return *this; + Device::EnqueueDeletion(std::exchange(pipeline, std::exchange(other.pipeline, VK_NULL_HANDLE))); + shaderHandles = std::move(other.shaderHandles); + sbtBuffer = std::move(other.sbtBuffer); + raygenRegion = other.raygenRegion; + missRegion = other.missRegion; + hitRegion = other.hitRegion; + callableRegion = other.callableRegion; + workaroundNeedsTlas = other.workaroundNeedsTlas; + workaroundTlasPushOffset = other.workaroundTlasPushOffset; + return *this; + } + // maxRecursionDepth: the maximum ray-recursion depth the pipeline must // support — i.e. the deepest chain of nested traceRayEXT calls. The // raygen counts as depth 1, so a closest-hit shader that traces a shadow @@ -67,7 +101,11 @@ export namespace Crafter { .layout = VK_NULL_HANDLE }; - Device::CheckVkResult(Device::vkCreateRayTracingPipelinesKHR(Device::device, {}, Device::pipelineCache, 1, &rtPipelineInfo, nullptr, &pipeline)); + VkPipeline created = VK_NULL_HANDLE; + Device::CheckVkResult(Device::vkCreateRayTracingPipelinesKHR(Device::device, {}, Device::pipelineCache, 1, &rtPipelineInfo, nullptr, &created)); + // Initialising again replaces the pipeline (and below, the binding + // table), which a frame in flight may still trace with. + Device::EnqueueDeletion(std::exchange(pipeline, created)); std::size_t dataSize = Device::rayTracingProperties.shaderGroupHandleSize * rtPipelineInfo.groupCount; shaderHandles.resize(dataSize); @@ -111,6 +149,7 @@ export namespace Crafter { // direct-write type lacking HOST_COHERENT is still flushed correctly. VkMemoryPropertyFlags sbtPreferred = Device::PreferDirectDeviceWrite(bufferSize) ? VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT : 0; + sbtBuffer.DeferredClear(); sbtBuffer.Create(VK_BUFFER_USAGE_2_SHADER_BINDING_TABLE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, bufferSize, sbtPreferred); std::uint8_t* offset = sbtBuffer.value; @@ -138,7 +177,7 @@ export namespace Crafter { } ~PipelineRTVulkan() { - vkDestroyPipeline(Device::device, pipeline, nullptr); + Device::EnqueueDeletion(pipeline); } }; } diff --git a/interfaces/Crafter.Graphics-RTPass.cppm b/interfaces/Crafter.Graphics-RTPass.cppm index 8f9ea3c..2b9fa54 100644 --- a/interfaces/Crafter.Graphics-RTPass.cppm +++ b/interfaces/Crafter.Graphics-RTPass.cppm @@ -17,6 +17,17 @@ import :RenderingElement3D; export namespace Crafter { struct RTPass : RenderPass { PipelineRTVulkan* pipeline; + // The application's push data, pushed at offset 0 before every trace + // (e.g. a camera), as the DOM backend's RTPass takes it: it starts the + // shaders' push block. The pointed-to bytes are read at Record time, + // so updating them before Render() is enough. Null means none. + const void* pushPtr = nullptr; + std::uint32_t pushBytes = 0; + // The trace size; 0 means the window's current size, as before. Set + // it to trace into an image of its own size rather than the + // swapchain's. + std::uint32_t width = 0; + std::uint32_t height = 0; RTPass(PipelineRTVulkan* p) : pipeline(p) {} @@ -29,6 +40,14 @@ export namespace Crafter { void Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override { vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, pipeline->pipeline); + if (pushBytes != 0) { + VkPushDataInfoEXT userPush { + .sType = VK_STRUCTURE_TYPE_PUSH_DATA_INFO_EXT, + .offset = 0, + .data = { .address = pushPtr, .size = pushBytes }, + }; + Device::vkCmdPushDataEXT(cmd, &userPush); + } // NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): feed // the active frame's TLAS device address into the push-constant // block that VulkanShader synthesizes, so the rewritten raygen can @@ -53,7 +72,7 @@ export namespace Crafter { &pipeline->missRegion, &pipeline->hitRegion, &pipeline->callableRegion, - window.width, window.height, 1); + width != 0 ? width : window.width, height != 0 ? height : window.height, 1); } }; } diff --git a/interfaces/Crafter.Graphics-RenderPass.cppm b/interfaces/Crafter.Graphics-RenderPass.cppm index 4c44d6c..bbc621a 100644 --- a/interfaces/Crafter.Graphics-RenderPass.cppm +++ b/interfaces/Crafter.Graphics-RenderPass.cppm @@ -74,19 +74,35 @@ export namespace Crafter { return access; } + // Read and write access for the stages a pass touches the swapchain image + // in: the next pass after an inter-pass barrier may read the image as well + // as write it, through shaders or through transfers. + inline VkAccessFlags SwapchainPassAccess(VkPipelineStageFlags stages) { + VkAccessFlags access = 0; + if (stages & (VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT + | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR)) + access |= VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; + if (stages & VK_PIPELINE_STAGE_TRANSFER_BIT) + access |= VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; + return access; + } + // The inter-pass swapchain barrier (replacing the old queue-wide // VkMemoryBarrier at the #115 location). Scoped to the swapchain image's // single colour subresource so only that image's shader caches round-trip — // unrelated buffers/images stay resident — exactly as the intra-pass UI // barrier already does. The image is bound as a storage image and stays in // VK_IMAGE_LAYOUT_GENERAL, so this is a pure memory dependency (no layout - // transition). The execution scope (stage masks) is supplied per pass pair - // by the caller via vkCmdPipelineBarrier. - inline VkImageMemoryBarrier BuildSwapchainInterPassBarrier(VkImage image) { + // transition). The stages are the two passes' SwapchainStage(), which the + // caller also hands vkCmdPipelineBarrier, and the access masks follow + // them: a transfer pass (a clear, a blit) writes with TRANSFER_WRITE, which + // SHADER_WRITE would not cover, and an access a stage can't perform is + // invalid in its barrier (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02818/02820). + inline VkImageMemoryBarrier BuildSwapchainInterPassBarrier(VkImage image, VkPipelineStageFlags srcStages, VkPipelineStageFlags dstStages) { return { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, - .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, - .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, + .srcAccessMask = SwapchainWriterAccess(srcStages), + .dstAccessMask = SwapchainPassAccess(dstStages), .oldLayout = VK_IMAGE_LAYOUT_GENERAL, .newLayout = VK_IMAGE_LAYOUT_GENERAL, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, diff --git a/interfaces/Crafter.Graphics-ShaderVulkan.cppm b/interfaces/Crafter.Graphics-ShaderVulkan.cppm index 4ea2d19..de67f7e 100644 --- a/interfaces/Crafter.Graphics-ShaderVulkan.cppm +++ b/interfaces/Crafter.Graphics-ShaderVulkan.cppm @@ -336,11 +336,10 @@ export namespace WorkaroundNvidiaAS { export namespace Crafter { class VulkanShader { public: - std::vector specilizations; VkSpecializationInfo* specilizationInfo; VkShaderStageFlagBits stage; std::string entrypoint; - VkShaderModule shader; + VkShaderModule shader = VK_NULL_HANDLE; // NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): set when // this module read an acceleration structure and was rewritten to fetch // the TLAS device address from a push constant. `tlasPushOffset` is the @@ -362,23 +361,29 @@ export namespace Crafter { if ((stage & rayTracingStages) != 0) { Device::RequireRayTracing(); } + // A file that can't be read throws, naming it: carrying on would + // size the code from a failed tellg() (-1) and die in the + // allocation, or hand the driver no code at all. std::ifstream file(path, std::ios::binary); if (!file) { - std::cerr << "Error: Could not open file " << path << std::endl; + throw std::runtime_error(std::format("VulkanShader: could not open {}", path.string())); } - + // Move to the end of the file to determine its size file.seekg(0, std::ios::end); std::streamsize size = file.tellg(); file.seekg(0, std::ios::beg); - + if (size <= 0 || size % sizeof(std::uint32_t) != 0) { + throw std::runtime_error(std::format("VulkanShader: {} is not SPIR-V ({} bytes)", path.string(), size)); + } + std::vector spirv(size / sizeof(std::uint32_t)); - + // Read the data into the vector if (!file.read(reinterpret_cast(spirv.data()), size)) { - std::cerr << "Error: Could not read data from file" << std::endl; + throw std::runtime_error(std::format("VulkanShader: could not read {}", path.string())); } - + file.close(); // NVIDIA descriptor-heap AS-read workaround (issue #15 / #7). @@ -397,6 +402,35 @@ export namespace Crafter { Device::CheckVkResult(vkCreateShaderModule(Device::device, &module_info, nullptr, &shader)); } + + // The module is released through the deferred-deletion queue, so a + // static VulkanShader makes no Vulkan call at exit. Copies would both + // release it; a move hands it over. + VulkanShader(const VulkanShader&) = delete; + VulkanShader& operator=(const VulkanShader&) = delete; + VulkanShader(VulkanShader&& other) noexcept + : specilizationInfo(other.specilizationInfo) + , stage(other.stage) + , entrypoint(std::move(other.entrypoint)) + , shader(std::exchange(other.shader, VK_NULL_HANDLE)) + , patchedAS(other.patchedAS) + , tlasPushOffset(other.tlasPushOffset) { + } + VulkanShader& operator=(VulkanShader&& other) noexcept { + if (this != &other) { + Device::EnqueueDeletion(shader); + specilizationInfo = other.specilizationInfo; + stage = other.stage; + entrypoint = std::move(other.entrypoint); + shader = std::exchange(other.shader, VK_NULL_HANDLE); + patchedAS = other.patchedAS; + tlasPushOffset = other.tlasPushOffset; + } + return *this; + } + ~VulkanShader() { + Device::EnqueueDeletion(shader); + } }; } #endif // !CRAFTER_GRAPHICS_WINDOW_DOM diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index 64be449..91d964a 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -273,7 +273,10 @@ export namespace Crafter { std::uint32_t gx, std::uint32_t gy = 1, std::uint32_t gz = 1); // Allocates a heap slot for the buffer and registers the GPU handle. - // Returns a move-only BufferSlot RAII handle. + // Returns a move-only BufferSlot RAII handle. The slot points at the + // buffer by its device address, so it needs one: a buffer made without + // VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT is refused + // (std::invalid_argument). template BufferSlot RegisterBuffer(GraphicsBuffer& buffer); @@ -441,6 +444,11 @@ export namespace Crafter { // ─── template-method implementations ──────────────────────────────── template BufferSlot UIRenderer::RegisterBuffer(GraphicsBuffer& buffer) { +#ifndef CRAFTER_GRAPHICS_WINDOW_DOM + if (buffer.address == 0) { + throw std::invalid_argument("UIRenderer::RegisterBuffer: the buffer has no device address (create it with VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT)"); + } +#endif auto range = heap_->AllocateBufferSlots(1); #ifndef CRAFTER_GRAPHICS_WINDOW_DOM WriteBufferDescriptor(range.firstElement, buffer.buffer, buffer.address, buffer.size); diff --git a/interfaces/Crafter.Graphics-VulkanBuffer.cppm b/interfaces/Crafter.Graphics-VulkanBuffer.cppm index a200efb..55ae294 100644 --- a/interfaces/Crafter.Graphics-VulkanBuffer.cppm +++ b/interfaces/Crafter.Graphics-VulkanBuffer.cppm @@ -109,11 +109,17 @@ namespace Crafter { .memoryTypeIndex = memoryTypeIndex }; + // Only a buffer created for SHADER_DEVICE_ADDRESS may have its + // address queried (VUID-VkBufferDeviceAddressInfo-buffer-02601); + // the others keep address 0. + const bool addressable = (usageFlags & VK_BUFFER_USAGE_2_SHADER_DEVICE_ADDRESS_BIT) != 0; VkMemoryAllocateFlagsInfoKHR allocFlagsInfo { .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO_KHR, .flags = VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT_KHR, }; - memAlloc.pNext = &allocFlagsInfo; + if (addressable) { + memAlloc.pNext = &allocFlagsInfo; + } Device::CheckVkResult(vkAllocateMemory(Device::device, &memAlloc, nullptr, &memory)); Device::CheckVkResult(vkBindBufferMemory(Device::device, buffer, memory, 0)); @@ -122,14 +128,12 @@ namespace Crafter { .sType = VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO, .buffer = buffer }; - address = vkGetBufferDeviceAddress(Device::device, &addressInfo); + address = addressable ? vkGetBufferDeviceAddress(Device::device, &addressInfo) : 0; - // Record the allocation's byte size (≥ `size`) for every buffer, - // not just mapped ones: UploadDeviceLocalRange's direct path flushes - // only the dirty sub-range and clamps its rounded-up upper bound to - // this (the nonCoherentAtomSize-aligned-end exception, see - // AlignMappedFlushRange). A non-mapped buffer never maps persistently, - // but its memory is still the allocation a transient map writes into. + // Record the allocation's byte size (≥ `size`): a ranged + // FlushDevice flushes only the dirty sub-range and clamps its + // rounded-up upper bound to this (the nonCoherentAtomSize-aligned-end + // exception, see AlignMappedFlushRange). mappedSize = memReqs.size; if constexpr(Mapped) { Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, memReqs.size, 0, reinterpret_cast(&(VulkanBufferMappedConditional::value)))); @@ -227,97 +231,62 @@ namespace Crafter { // Upload `count` host elements from `src` into this buffer as // device-local, GPU-read geometry, choosing placement at runtime via - // the #89 upload strategy (Device::PreferDirectDeviceWrite) and then - // recording a barrier from the upload to (dstStageMask, dstAccessMask) - // on `cmd`: + // the #89 upload strategy (Device::PreferDirectDeviceWrite), and order + // the upload before (dstStageMask, dstAccessMask) on `cmd`: // ReBAR / UMA (direct) — allocate HOST_VISIBLE with DEVICE_LOCAL as a // best-effort preference (GetMemoryType lands the host-visible // device-local type that the strategy already proved exists), map // transiently, memcpy, flush if the chosen type isn't coherent, // unmap. No staging buffer: it would be pure overhead on a bar where // the device-local heap is itself host-writable. - // No / small BAR (staged) — allocate pure DEVICE_LOCAL (+ TRANSFER_DST), - // fill a transient HOST_VISIBLE staging buffer, vkCmdCopyBuffer into - // this buffer, then hand the staging buffer to the fence-keyed - // deferred-deletion queue (#101/#102) so it outlives the copy submit. - // Requires !Mapped: the destination must be free to be device-local-only - // (a persistent map would force HOST_VISIBLE), so the direct path maps - // just long enough to write. A same-size re-upload reuses the allocation - // (Resize), so the device address stays stable across an in-place AS - // UPDATE refit that re-calls this. + // No / small BAR (staged) — allocate pure DEVICE_LOCAL and copy from + // the staging ring on the GPU (CopyThroughStaging). + // The direct write is only for a fresh allocation, which nothing can be + // reading yet. A same-size re-upload reuses the allocation (Resize), so + // the device address stays stable across an in-place AS UPDATE refit + // that re-calls this, but a frame in flight may still read it: that + // upload always copies, on the GPU timeline, after those reads. + // dstStageMask must name every stage that reads the buffer, since the + // next upload waits for them. Requires !Mapped: the destination must be + // free to be device-local-only (a persistent map would force + // HOST_VISIBLE), so the direct path maps just long enough to write. void UploadDeviceLocal(VkBufferUsageFlags2 usageFlags, const T* src, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) { - VkDeviceSize bytes = static_cast(count) * sizeof(T); - VkAccessFlags srcAccessMask; - VkPipelineStageFlags srcStageMask; - if (Device::PreferDirectDeviceWrite(bytes)) { + // Nothing to put anywhere: no buffer of no bytes, no copy of none. + if (count == 0) return; + const VkDeviceSize bytes = static_cast(count) * sizeof(T); + // Any upload may be followed by one that copies in. + usageFlags |= VK_BUFFER_USAGE_TRANSFER_DST_BIT; + const bool direct = Device::PreferDirectDeviceWrite(bytes); + const VkBuffer previous = buffer; + if (direct) { Resize(usageFlags, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); - void* mapped = nullptr; - Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped)); - std::memcpy(mapped, src, bytes); - // Match FlushDevice()'s gate: a non-coherent type needs an - // explicit flush before the device reads the written range. - if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) { - VkMappedMemoryRange range { - .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, - .memory = memory, - .offset = 0, - .size = VK_WHOLE_SIZE - }; - vkFlushMappedMemoryRanges(Device::device, 1, &range); - } - vkUnmapMemory(Device::device, memory); - srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; - srcStageMask = VK_PIPELINE_STAGE_HOST_BIT; } else { - Resize(usageFlags | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count); - // Persistent staging instead of a Create+DeferredClear per call - // (issue #120): on no-/small-BAR hardware Mesh::Refit / - // RecordProceduralBuild hit this branch every frame for deforming - // meshes, so a fresh alloc+map+free cycle per upload was pure - // churn on exactly the hardware this path targets. Keep a small - // ring of staging buffers, each grown to a high-water mark and - // reused — reallocated (by Resize, which defers the outgrown - // allocation) only when a larger upload arrives. - // - // The ring is grown lazily, on first entry into this staged - // branch, so ReBAR/UMA hardware (which always takes the direct - // `if` branch above) never constructs a staging allocation it - // does not use. - // - // Why a ring and not one shared buffer: the vkCmdCopyBuffer below - // still reads the staging buffer after this call returns — the - // reason the old code used DeferredClear. With frames pipelined - // framesInFlight deep, overwriting one shared staging buffer next - // frame would clobber data the previous frame's copy is still - // reading. Indexing by frameCounter % framesInFlight gives each - // in-flight frame its own slot; a slot is rewritten only after - // framesInFlight frames have elapsed — the exact window after - // which single-queue submission order (the same guarantee the - // #101 deletion queue relies on) ensures that copy has completed. - const std::uint32_t ringSize = - Device::framesInFlight ? Device::framesInFlight : 1; - if (stagingRing.size() < ringSize) { - stagingRing.resize(ringSize); - } - VulkanBuffer& staging = - stagingRing[Device::frameCounter % ringSize]; - // SHADER_DEVICE_ADDRESS: Create always queries the buffer device - // address (and allocates with the device-address bit); the - // staging buffer's own address is otherwise unused. Resize reuses - // the existing allocation (and its mapped pointer) when the - // upload still fits the slot's high-water capacity. - staging.Resize(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count); - std::memcpy(staging.value, src, bytes); - staging.FlushDevice(); - VkBufferCopy region { .srcOffset = 0, .dstOffset = 0, .size = bytes }; - vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, ®ion); - srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT; + Resize(usageFlags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count); } + if (!direct || buffer == previous) { + CopyThroughStaging(src, 0, count, cmd, dstAccessMask, dstStageMask); + return; + } + + void* mapped = nullptr; + Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped)); + std::memcpy(mapped, src, bytes); + // Match FlushDevice()'s gate: a non-coherent type needs an + // explicit flush before the device reads the written range. + if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) { + VkMappedMemoryRange range { + .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, + .memory = memory, + .offset = 0, + .size = VK_WHOLE_SIZE + }; + vkFlushMappedMemoryRanges(Device::device, 1, &range); + } + vkUnmapMemory(Device::device, memory); VkBufferMemoryBarrier barrier = { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, - .srcAccessMask = srcAccessMask, + .srcAccessMask = VK_ACCESS_HOST_WRITE_BIT, .dstAccessMask = dstAccessMask, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, @@ -325,79 +294,55 @@ namespace Crafter { .offset = 0, .size = VK_WHOLE_SIZE }; - vkCmdPipelineBarrier(cmd, srcStageMask, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL); + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_HOST_BIT, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL); } - // Re-upload only the half-open sub-range [offset, offset+count) of an - // already-allocated device-local buffer — the dirty-range counterpart of + // Re-upload only the half-open sub-range [offset, offset+count) of a + // buffer UploadDeviceLocal made — the dirty-range counterpart of // UploadDeviceLocal. `src` points at the first changed element (not the // start of the whole array); `offset` is that element's index in this - // buffer. The buffer must already exist at its full size (a prior - // UploadDeviceLocal / Build sized it): this never Resizes, so the device - // address stays stable and the untouched elements keep their last - // contents. Only the dirty bytes are written + flushed (direct path) or - // staged + copied (staged path), and the post-upload barrier covers only - // that sub-range — so a deforming-mesh refit that nudges a few vertices - // pays for those vertices, not a full-array re-upload + flush + copy (#119). - // - // The direct-vs-staged choice is read from the memory type the buffer was - // actually allocated with (HOST_VISIBLE → map + write in place; - // device-local-only → stage + GPU copy), NOT from the sub-range size: the - // allocation is fixed, so a small dirty range must not be mis-routed to a - // map of a non-host-visible buffer the way UploadDeviceLocal's size-based - // PreferDirectDeviceWrite check would. The staged path's GPU copy needs - // VK_BUFFER_USAGE_TRANSFER_DST_BIT on the destination — already present, - // since UploadDeviceLocal only allocates device-local-only memory on the - // staged path, where it adds that bit. + // buffer. This never Resizes, so the device address stays stable and the + // untouched elements keep their last contents. Only the dirty elements + // are staged and copied, and the barriers cover only that sub-range — so + // a deforming-mesh refit that nudges a few vertices pays for those + // vertices, not a full-array re-upload (#119). The buffer is one a frame + // in flight may still read, so the copy waits for its readers + // (dstStageMask, as in UploadDeviceLocal) on the GPU, whatever memory + // type it lives in. void UploadDeviceLocalRange(const T* src, std::uint32_t offset, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) { - VkDeviceSize byteOffset = static_cast(offset) * sizeof(T); - VkDeviceSize bytes = static_cast(count) * sizeof(T); - VkAccessFlags srcAccessMask; - VkPipelineStageFlags srcStageMask; - if (memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) { - void* mapped = nullptr; - Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped)); - std::memcpy(static_cast(mapped) + byteOffset, src, bytes); - // Non-coherent memory needs an explicit flush — but only of the - // sub-range actually written, rounded outward to - // nonCoherentAtomSize (and clamped to the allocation size). - if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) { - MappedFlushRange r = AlignMappedFlushRange( - byteOffset, bytes, Device::nonCoherentAtomSize, mappedSize); - VkMappedMemoryRange range { - .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, - .memory = memory, - .offset = r.offset, - .size = r.size - }; - vkFlushMappedMemoryRanges(Device::device, 1, &range); - } - vkUnmapMemory(Device::device, memory); - srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; - srcStageMask = VK_PIPELINE_STAGE_HOST_BIT; - } else { - // Device-local-only: stage just the dirty elements and copy them - // into place at byteOffset. The staging buffer outlives the queued - // copy via the fence-keyed deletion queue (#101/#102), exactly as - // the full-buffer staged path does. - VulkanBuffer staging; - staging.Create(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count); - std::memcpy(staging.value, src, bytes); - staging.FlushDevice(); - VkBufferCopy region { .srcOffset = 0, .dstOffset = byteOffset, .size = bytes }; - vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, ®ion); - staging.DeferredClear(); - srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT; - } + CopyThroughStaging(src, offset, count, cmd, dstAccessMask, dstStageMask); + } - // Order only the written sub-range before the consumer reads it. The - // untouched bytes were made visible by their own prior upload's - // barrier and are not written in this submit, so they need no fresh - // dependency even though the build reads the whole buffer. - VkBufferMemoryBarrier barrier = { + // Copy `count` elements from `src` to element `offset` of this buffer on + // the GPU timeline: after the reads by `dstStageMask` recorded earlier + // or still in flight, and before the ones that follow. The bytes go + // through this frame's slot of the staging ring first. + void CopyThroughStaging(const T* src, std::uint32_t offset, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) { + // A copy or barrier of no bytes is invalid usage. + if (count == 0) return; + const VkDeviceSize byteOffset = static_cast(offset) * sizeof(T); + const VkDeviceSize bytes = static_cast(count) * sizeof(T); + + // Write after read: an execution dependency on the earlier readers. + VkBufferMemoryBarrier readsDone = { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, - .srcAccessMask = srcAccessMask, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .buffer = buffer, + .offset = byteOffset, + .size = bytes + }; + vkCmdPipelineBarrier(cmd, dstStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, NULL, 1, &readsDone, 0, NULL); + + const auto [staging, stagingOffset] = Stage(src, count); + VkBufferCopy region { .srcOffset = stagingOffset, .dstOffset = byteOffset, .size = bytes }; + vkCmdCopyBuffer(cmd, staging, buffer, 1, ®ion); + + VkBufferMemoryBarrier copied = { + .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .dstAccessMask = dstAccessMask, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, @@ -405,7 +350,47 @@ namespace Crafter { .offset = byteOffset, .size = bytes }; - vkCmdPipelineBarrier(cmd, srcStageMask, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL); + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask, 0, 0, NULL, 1, &copied, 0, NULL); + } + + // Put `count` elements in this frame's slot of the staging ring, after + // whatever this frame staged before, and say where: the slot's buffer + // and the byte offset. A copy still in flight never reads the slot, as + // a slot is used again only framesInFlight frames later — the window + // after which single-queue submission order (the same guarantee the + // #101 deletion queue relies on) ensures that copy has completed. Each + // slot keeps its high-water size (issue #120); outgrowing it mid-frame + // hands the old allocation, with this frame's earlier bytes, to the + // deletion queue. Without a window no frame passes; after a + // Device::DrainDeletions (which follows a wait for the queue) nothing + // reads any slot, so they all start over. + std::pair Stage(const T* src, std::uint32_t count) requires(!Mapped) { + const std::uint32_t ringSize = Device::framesInFlight ? Device::framesInFlight : 1; + if (stagingRing.size() < ringSize) { + stagingRing.resize(ringSize); + stagingFrame.resize(ringSize, std::numeric_limits::max()); + stagingUsed.resize(ringSize, 0); + } + if (stagingDrains != Device::drains) { + stagingDrains = Device::drains; + std::ranges::fill(stagingUsed, 0); + } + const std::size_t slot = Device::frameCounter % ringSize; + if (stagingFrame[slot] != Device::frameCounter) { + stagingFrame[slot] = Device::frameCounter; + stagingUsed[slot] = 0; + } + VulkanBuffer& staging = stagingRing[slot]; + const std::uint32_t needed = stagingUsed[slot] + count; + if (staging.buffer == VK_NULL_HANDLE || static_cast(needed) * sizeof(T) > staging.capacity) { + staging.Resize(VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, needed); + stagingUsed[slot] = 0; + } + const std::uint32_t first = stagingUsed[slot]; + std::memcpy(staging.value + first, src, static_cast(count) * sizeof(T)); + staging.FlushDevice(static_cast(first) * sizeof(T), static_cast(count) * sizeof(T)); + stagingUsed[slot] = first + count; + return { staging.buffer, static_cast(first) * sizeof(T) }; } void FlushDevice() requires(Mapped) { @@ -516,18 +501,31 @@ namespace Crafter { vkInvalidateMappedMemoryRanges(Device::device, 1, &range); } - // Persistent per-frame-in-flight staging ring for the staged - // UploadDeviceLocal path (issue #120). Empty — and so holding zero - // host-visible allocations — until the first upload that actually - // stages, which only happens on no-/small-BAR hardware (ReBAR/UMA always - // takes the direct-write branch and never touches this, so the lazy ring - // keeps the change a no-op there). Sized to Device::framesInFlight so - // each in-flight frame copies from its own slot; each slot grows to a - // high-water mark via Resize and is reused across frames rather than - // reallocated every call — mirroring the TLAS instance/metadata reuse. + // Persistent per-frame-in-flight staging ring for the copies Stage + // records (issue #120). Empty — and so holding zero host-visible + // allocations — until the first upload that stages: on ReBAR/UMA only + // re-uploads do, as a first upload writes directly. Sized to + // Device::framesInFlight so each in-flight frame copies from its own + // slot; each slot grows to a high-water mark via Resize and is reused + // across frames rather than reallocated every call — mirroring the TLAS + // instance/metadata reuse. stagingFrame and stagingUsed say, per slot, + // which frame last staged into it and how many elements it has staged; + // stagingDrains, the Device::drains it last saw. std::vector> stagingRing; + std::vector stagingFrame; + std::vector stagingUsed; + std::uint64_t stagingDrains = 0; - VulkanBuffer(VulkanBuffer&& other) { + VulkanBuffer(VulkanBuffer&& other) noexcept { + *this = std::move(other); + } + + // Takes `other`'s buffer over. The one this held goes to the deletion + // queue, as the destructor would send it: a frame in flight may still + // read it. + VulkanBuffer& operator=(VulkanBuffer&& other) noexcept { + if (this == &other) return *this; + DeferredClear(); buffer = other.buffer; memory = other.memory; size = other.size; @@ -538,15 +536,21 @@ namespace Crafter { other.buffer = VK_NULL_HANDLE; address = other.address; stagingRing = std::move(other.stagingRing); + stagingFrame = std::move(other.stagingFrame); + stagingUsed = std::move(other.stagingUsed); + stagingDrains = other.stagingDrains; if constexpr(Mapped) { VulkanBufferMappedConditional::value = other.VulkanBufferMappedConditional::value; } - }; + return *this; + } + // Deferred, like DeferredClear: the owner may go while a frame in + // flight still reads the buffer, and a static owner goes after the + // driver has torn down. Call Clear() first to free at once when the + // GPU is known to be idle. The staging ring's buffers defer the same way. ~VulkanBuffer() { - if(buffer != VK_NULL_HANDLE) { - Clear(); - } + DeferredClear(); } VulkanBuffer(VulkanBuffer&) = delete; diff --git a/interfaces/Crafter.Graphics-Window.cppm b/interfaces/Crafter.Graphics-Window.cppm index 1426cd0..45dcb0a 100644 --- a/interfaces/Crafter.Graphics-Window.cppm +++ b/interfaces/Crafter.Graphics-Window.cppm @@ -56,6 +56,21 @@ export namespace Crafter { struct DescriptorHeapWebGPU; #endif + // How a window hands finished frames to the display. Fifo waits for the + // display's refresh; it is the default and the one mode every surface + // has. Mailbox replaces a frame still waiting to be shown, so presenting + // never waits, which suits a window that updates on its own clock + // (Window::updateInterval). Immediate shows each frame at once and may + // tear. A mode the surface lacks falls back to Fifo, as does one whose + // swapchain the driver makes with more images than the frame loop's + // Window::numFrames (Mesa's Mailbox has at least four). In the browser + // the page presents the canvas and the mode has no effect. + enum class PresentMode { + Fifo, + Mailbox, + Immediate, + }; + struct Window { FrameTime currentFrameTime; std::uint32_t width; @@ -71,6 +86,17 @@ export namespace Crafter { Event onResize; bool open = true; bool updating = false; + // Zero (the default): Update runs when the compositor's frame + // callback says the window is about to show a new frame, so it + // follows the display and stops while the window is hidden or + // minimized. Non-zero: Update runs on this clock instead, visible or + // not, for an app whose output goes somewhere besides the screen. + // Pair it with PresentMode::Mailbox so a present never waits on the + // display. Set it before StartUpdate. Native only: in the browser, + // requestAnimationFrame drives Update. + std::chrono::nanoseconds updateInterval{0}; + // The mode SetPresentMode last asked for. + PresentMode presentMode = PresentMode::Fifo; // Currently-pressed raw key codes. The runtime stores raw platform // codes only (Win32 PS/2 scancode + extended bit; Wayland kernel // keycode). Cross-platform default bindings use `Key(CrafterKeys::X)` @@ -156,6 +182,11 @@ export namespace Crafter { void StartUpdate(); void StopUpdate(); + // Makes the swapchain again with the new mode and fires onResize, as + // a resize does, so call it outside StartInit/FinishInit. The + // environment variable CRAFTER_PRESENT_IMMEDIATE overrides the mode, + // for benchmarks. + void SetPresentMode(PresentMode mode); void SetTitle(const std::string_view title); // The xdg app id, which the compositor matches to a .desktop file // for the icon, the task switcher and activation. @@ -208,6 +239,21 @@ export namespace Crafter { #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND + // Wakes the event loop when `fd` becomes readable and calls + // `onReadable` from it, which must consume what made the descriptor + // readable, or the loop wakes again at once. For a socket, or an + // eventfd another thread signals, so new data can drive an Update + // right away (call Update from the callback) instead of waiting for + // the next one. StartSync's loop serves the descriptors of every + // window. Remove a descriptor before closing it. + void AddPollFd(int fd, std::function onReadable); + void RemovePollFd(int fd); + struct PollFd { + int fd; + std::function onReadable; + }; + std::vector pollFds; + float scale = 1.0f; bool configured = false; // Pending size from the most recent xdg_toplevel.configure, in @@ -273,14 +319,28 @@ export namespace Crafter { // stay valid. Does NOT fire onResize — callers do that. void RecreateSwapchainAndImages(); - // Save the current swapchain image (state after Render() returns) to - // a PNG file. Allocates a one-shot staging buffer + command buffer, - // copies image-to-buffer, waits idle, then writes PNG via stb. Useful - // for visual regression tests and screenshotting from headless code. - void SaveFrame(const std::filesystem::path& path); + // Saves the frame the next Render() draws to a PNG at `path`, for + // visual regression tests and screenshots: that Render copies the + // swapchain image into a readback buffer before presenting it, waits + // for the frame, and writes the file. The copy can't come later: a + // presented image belongs to the presentation engine until it is + // acquired again. + void CaptureNextFrame(std::filesystem::path path); + std::optional pendingCapture; static constexpr std::uint8_t numFrames = 3; VkSurfaceKHR vulkanSurface = VK_NULL_HANDLE; VkSwapchainKHR swapChain = VK_NULL_HANDLE; + // What the swapchain images were created for: STORAGE | TRANSFER_SRC, + // plus TRANSFER_DST where the surface allows it, so a pass can blit or + // clear into the image instead of only writing it from a shader. + VkImageUsageFlags swapchainUsage = 0; + // The mode the swapchain was made with: presentMode, unless the + // surface lacks it or CRAFTER_PRESENT_IMMEDIATE is set. + VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR; + // The mode presentMode asks for made a swapchain with more images than + // numFrames once, so swapchains are FIFO until SetPresentMode is + // called again, rather than being made twice at every resize. + bool presentModeUnfit = false; VkFormat colorFormat; VkColorSpaceKHR colorSpace; // Supported composite-alpha mode for vulkanSurface. A surface property diff --git a/project.cpp b/project.cpp index c445d25..fd12733 100644 --- a/project.cpp +++ b/project.cpp @@ -9,27 +9,30 @@ using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { std::vector depArgs(args.begin(), args.end()); - // Crafter.Event/Math/Asset are always resolved from forgejo (GitProject); - // there is no local-sibling branch for them here. A consumer building - // THIS project with --local (to pick up local Crafter.Graphics edits) - // forwards --local down through depArgs, but these git deps can't honour - // it — the flag makes their resolver look for an unsuffixed local - // checkout that doesn't exist. Strip it so the sub-deps resolve normally - // while Crafter.Graphics itself is still built from the local tree. - std::erase(depArgs, "--local"); - - Configuration* event = GitProject({ - .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Event.git" }, - .args = depArgs, - }); - Configuration* math = GitProject({ - .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, - .args = depArgs, - }); - Configuration* asset = GitProject({ - .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" }, - .args = depArgs, - }); + // Crafter.Event/Math/Asset come from forgejo, or with --local from the + // sibling checkouts next to this one (../Crafter.Event, ...), the way + // Crafter.Asset resolves Crafter.Math. --local stays in depArgs so + // Crafter.Asset picks its local Crafter.Math too: a consumer building + // against local checkouts has to see the same sibling projects + // everywhere, because Crafter.Build tells projects apart by path and + // arguments and two Crafter.Math projects would put two copies of the + // module on the compile path. + const bool useLocal = std::ranges::find(args, std::string_view("--local")) != args.end(); + auto sibling = [&](std::string_view name) -> Configuration* { + if (useLocal) { + return LocalProject({ + .projectFile = std::format("../{}/project.cpp", name), + .args = depArgs, + }); + } + return GitProject({ + .source = { .url = std::format("https://forgejo.catcrafts.net/Catcrafts/{}.git", name) }, + .args = depArgs, + }); + }; + Configuration* event = sibling("Crafter.Event"); + Configuration* math = sibling("Crafter.Math"); + Configuration* asset = sibling("Crafter.Asset"); Configuration cfg; cfg.path = "./"; @@ -71,10 +74,11 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.linkFlags.push_back("-lkernel32"); cfg.linkFlags.push_back("-luser32"); cfg.linkFlags.push_back("-lgdi32"); - // Windows.Gaming.Input (WGI) needs the WinRT activation runtime - // and combase for HSTRING / RoGetActivationFactory. + // Windows.Gaming.Input (WGI) needs the WinRT activation runtime for + // HSTRING and RoGetActivationFactory. Its gamepad code is C (see + // Crafter.Graphics-GamepadWin32.h). cfg.linkFlags.push_back("-lruntimeobject"); - cfg.linkFlags.push_back("-lcombase"); + cfg.cFiles.push_back("implementations/Crafter.Graphics-GamepadWin32"); } else { cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_WAYLAND", ""}); cfg.linkFlags.push_back("-lwayland-client"); @@ -104,6 +108,7 @@ extern "C" Configuration CrafterBuildProject(std::span a libevdev.mtune = cfg.mtune; libevdev.debug = cfg.debug; libevdev.sysroot = cfg.sysroot; + libevdev.projectArgs = cfg.projectArgs; libevdev.cFiles = { fs::absolute("lib/libevdev/libevdev/libevdev"), fs::absolute("lib/libevdev/libevdev/libevdev-names"), @@ -281,35 +286,43 @@ extern "C" Configuration CrafterBuildProject(std::span a } cfg.buildFiles.emplace_back(fs::path("shaders/ui-shared.glsl")); + // A test executable built the way the library is (its target, flags, + // dependencies and C files) from the library's sources plus + // tests//main.cpp. Configuration isn't copyable (it owns the + // parsed module list), so the shared settings are copied field by + // field, here once for every test. Callers add what else a test needs. + auto LibraryTest = [&](std::string_view name) { + Test test; + Configuration& c = test.config; + c.path = cfg.path; + c.name = name; + c.outputName = name; + c.type = ConfigurationType::Executable; + c.target = cfg.target; + c.march = cfg.march; + c.mtune = cfg.mtune; + c.debug = cfg.debug; + c.sysroot = cfg.sysroot; + c.projectArgs = cfg.projectArgs; + c.dependencies = cfg.dependencies; + c.externalDependencies = cfg.externalDependencies; + c.compileFlags = cfg.compileFlags; + c.linkFlags = cfg.linkFlags; + c.defines = cfg.defines; + c.cFiles = cfg.cFiles; + std::vector sources(impls.begin(), impls.end()); + sources.emplace_back(std::format("tests/{}/main", name)); + c.GetInterfacesAndImplementations(ifaces, sources); + return test; + }; + // Regression test for issue #18: drive the NVIDIA descriptor-heap // AS-read workaround's SPIR-V rewrite over real compiled shaders and // check the result with spirv-val (one push-constant block, correct - // TLAS offset). The test executable recompiles the whole module plus - // tests/PushConstantRewrite/main.cpp; Configuration isn't copyable - // (it owns the parsed module list), so the shared build settings are - // mirrored field by field. glslang and spirv-val are invoked at - // runtime, so the test declares them as required tools. Remove with - // the rest of the workaround. - Test pcTest; - Configuration& tc = pcTest.config; - tc.path = cfg.path; - tc.name = "PushConstantRewrite"; - tc.outputName = "PushConstantRewrite"; - tc.type = ConfigurationType::Executable; - tc.target = cfg.target; - tc.march = cfg.march; - tc.mtune = cfg.mtune; - tc.debug = cfg.debug; - tc.sysroot = cfg.sysroot; - tc.dependencies = cfg.dependencies; - tc.externalDependencies = cfg.externalDependencies; - tc.compileFlags = cfg.compileFlags; - tc.linkFlags = cfg.linkFlags; - tc.defines = cfg.defines; - tc.cFiles = cfg.cFiles; - std::vector testImpls(impls.begin(), impls.end()); - testImpls.emplace_back("tests/PushConstantRewrite/main"); - tc.GetInterfacesAndImplementations(ifaces, testImpls); + // TLAS offset). glslang and spirv-val are invoked at runtime, so the + // test declares them as required tools. Remove with the rest of the + // workaround. + Test pcTest = LibraryTest("PushConstantRewrite"); pcTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(pcTest)); @@ -318,27 +331,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // callback directly, so it needs no compositor — but it does need // the Wayland backend compiled in, hence Linux-only. if (!windows) { - Test scrollTest; - Configuration& sc = scrollTest.config; - sc.path = cfg.path; - sc.name = "MouseScroll"; - sc.outputName = "MouseScroll"; - sc.type = ConfigurationType::Executable; - sc.target = cfg.target; - sc.march = cfg.march; - sc.mtune = cfg.mtune; - sc.debug = cfg.debug; - sc.sysroot = cfg.sysroot; - sc.dependencies = cfg.dependencies; - sc.externalDependencies = cfg.externalDependencies; - sc.compileFlags = cfg.compileFlags; - sc.linkFlags = cfg.linkFlags; - sc.defines = cfg.defines; - sc.cFiles = cfg.cFiles; - std::vector scrollImpls(impls.begin(), impls.end()); - scrollImpls.emplace_back("tests/MouseScroll/main"); - sc.GetInterfacesAndImplementations(ifaces, scrollImpls); - cfg.tests.push_back(std::move(scrollTest)); + cfg.tests.push_back(LibraryTest("MouseScroll")); // Issue #40: multi-frame-in-flight frame pacing (per-image fences // + per-frame semaphores, no steady-state wait-idle). Drives the @@ -349,27 +342,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // a clean multi-frame run is the regression guard. Needs the // Wayland backend + a real compositor, hence inside the !windows // block alongside MouseScroll. - Test frameLoopTest; - Configuration& fl = frameLoopTest.config; - fl.path = cfg.path; - fl.name = "FrameLoopSync"; - fl.outputName = "FrameLoopSync"; - fl.type = ConfigurationType::Executable; - fl.target = cfg.target; - fl.march = cfg.march; - fl.mtune = cfg.mtune; - fl.debug = cfg.debug; - fl.sysroot = cfg.sysroot; - fl.dependencies = cfg.dependencies; - fl.externalDependencies = cfg.externalDependencies; - fl.compileFlags = cfg.compileFlags; - fl.linkFlags = cfg.linkFlags; - fl.defines = cfg.defines; - fl.cFiles = cfg.cFiles; - std::vector frameLoopImpls(impls.begin(), impls.end()); - frameLoopImpls.emplace_back("tests/FrameLoopSync/main"); - fl.GetInterfacesAndImplementations(ifaces, frameLoopImpls); - cfg.tests.push_back(std::move(frameLoopTest)); + cfg.tests.push_back(LibraryTest("FrameLoopSync")); // Issue #153: two frame-loop validation errors. (1) The acquire // barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but @@ -384,27 +357,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // compute pass through the loop plus interleaved StartInit rounds // and asserts the layer stays silent. Needs the Wayland backend + a // real compositor, so it lives in the !windows block. - Test setupReuseTest; - Configuration& sr = setupReuseTest.config; - sr.path = cfg.path; - sr.name = "SetupCmdBufferReuse"; - sr.outputName = "SetupCmdBufferReuse"; - sr.type = ConfigurationType::Executable; - sr.target = cfg.target; - sr.march = cfg.march; - sr.mtune = cfg.mtune; - sr.debug = cfg.debug; - sr.sysroot = cfg.sysroot; - sr.dependencies = cfg.dependencies; - sr.externalDependencies = cfg.externalDependencies; - sr.compileFlags = cfg.compileFlags; - sr.linkFlags = cfg.linkFlags; - sr.defines = cfg.defines; - sr.cFiles = cfg.cFiles; - std::vector setupReuseImpls(impls.begin(), impls.end()); - setupReuseImpls.emplace_back("tests/SetupCmdBufferReuse/main"); - sr.GetInterfacesAndImplementations(ifaces, setupReuseImpls); - cfg.tests.push_back(std::move(setupReuseTest)); + cfg.tests.push_back(LibraryTest("SetupCmdBufferReuse")); // The UI path has two binding models: VK_EXT_descriptor_heap, and // a bindless descriptor set for drivers without it (Mesa Turnip). @@ -415,29 +368,10 @@ extern "C" Configuration CrafterBuildProject(std::span a // other tests it loads the real UI kernels, so it carries the // library's shaders (both variants) and a font into its own bin // dir. Needs the Wayland backend + a real compositor. - Test parityTest; - Configuration& up = parityTest.config; - up.path = cfg.path; - up.name = "UIFallbackParity"; - up.outputName = "UIFallbackParity"; - up.type = ConfigurationType::Executable; - up.target = cfg.target; - up.march = cfg.march; - up.mtune = cfg.mtune; - up.debug = cfg.debug; - up.sysroot = cfg.sysroot; - up.dependencies = cfg.dependencies; - up.externalDependencies = cfg.externalDependencies; - up.compileFlags = cfg.compileFlags; - up.linkFlags = cfg.linkFlags; - up.defines = cfg.defines; - up.cFiles = cfg.cFiles; - up.shaders = cfg.shaders; - up.buildFiles = cfg.buildFiles; - up.files.emplace_back(fs::path("examples/HelloUI/font.ttf")); - std::vector parityImpls(impls.begin(), impls.end()); - parityImpls.emplace_back("tests/UIFallbackParity/main"); - up.GetInterfacesAndImplementations(ifaces, parityImpls); + Test parityTest = LibraryTest("UIFallbackParity"); + parityTest.config.shaders = cfg.shaders; + parityTest.config.buildFiles = cfg.buildFiles; + parityTest.config.files.emplace_back(fs::path("examples/HelloUI/font.ttf")); cfg.tests.push_back(std::move(parityTest)); // Reading the TLAS from the descriptor heap (#7/#15): renders a @@ -447,29 +381,10 @@ extern "C" Configuration CrafterBuildProject(std::span a // is the manual probe for whether a driver still has the fault. // Needs the Wayland backend, a real compositor and an RT device // (skips without RT). - Test heapAsTest; - Configuration& ha = heapAsTest.config; - ha.path = cfg.path; - ha.name = "HeapASRead"; - ha.outputName = "HeapASRead"; - ha.type = ConfigurationType::Executable; - ha.target = cfg.target; - ha.march = cfg.march; - ha.mtune = cfg.mtune; - ha.debug = cfg.debug; - ha.sysroot = cfg.sysroot; - ha.dependencies = cfg.dependencies; - ha.externalDependencies = cfg.externalDependencies; - ha.compileFlags = cfg.compileFlags; - ha.linkFlags = cfg.linkFlags; - ha.defines = cfg.defines; - ha.cFiles = cfg.cFiles; - ha.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-raygen.glsl"), std::string("main"), ShaderType::RayGen); - ha.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); - ha.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-miss.glsl"), std::string("main"), ShaderType::Miss); - std::vector heapAsImpls(impls.begin(), impls.end()); - heapAsImpls.emplace_back("tests/HeapASRead/main"); - ha.GetInterfacesAndImplementations(ifaces, heapAsImpls); + Test heapAsTest = LibraryTest("HeapASRead"); + heapAsTest.config.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-raygen.glsl"), std::string("main"), ShaderType::RayGen); + heapAsTest.config.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); + heapAsTest.config.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-miss.glsl"), std::string("main"), ShaderType::Miss); cfg.tests.push_back(std::move(heapAsTest)); // Dynamic descriptor-heap indexing in a hit shader: a closest-hit @@ -478,30 +393,31 @@ extern "C" Configuration CrafterBuildProject(std::span a // neighbouring lanes on the other), which lost the device on // NVIDIA 610.43.02; skips on NVIDIA drivers older than 615.78.08. // Same requirements as HeapASRead. - Test dynIndexTest; - Configuration& di = dynIndexTest.config; - di.path = cfg.path; - di.name = "HeapDynamicIndexHit"; - di.outputName = "HeapDynamicIndexHit"; - di.type = ConfigurationType::Executable; - di.target = cfg.target; - di.march = cfg.march; - di.mtune = cfg.mtune; - di.debug = cfg.debug; - di.sysroot = cfg.sysroot; - di.dependencies = cfg.dependencies; - di.externalDependencies = cfg.externalDependencies; - di.compileFlags = cfg.compileFlags; - di.linkFlags = cfg.linkFlags; - di.defines = cfg.defines; - di.cFiles = cfg.cFiles; - di.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-raygen.glsl"), std::string("main"), ShaderType::RayGen); - di.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); - di.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-miss.glsl"), std::string("main"), ShaderType::Miss); - std::vector dynIndexImpls(impls.begin(), impls.end()); - dynIndexImpls.emplace_back("tests/HeapDynamicIndexHit/main"); - di.GetInterfacesAndImplementations(ifaces, dynIndexImpls); + Test dynIndexTest = LibraryTest("HeapDynamicIndexHit"); + dynIndexTest.config.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-raygen.glsl"), std::string("main"), ShaderType::RayGen); + dynIndexTest.config.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); + dynIndexTest.config.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-miss.glsl"), std::string("main"), ShaderType::Miss); cfg.tests.push_back(std::move(dynIndexTest)); + + // The swapchain is created with TRANSFER_DST where the surface + // allows it: clears the image from a pass and reads it back. + // Needs the Wayland backend and a real compositor. + cfg.tests.push_back(LibraryTest("SwapchainTransferDst")); + + // A window on its own update clock keeps updating while + // minimized, and the loop wakes for a registered eventfd. Needs + // the Wayland backend and a real compositor. + cfg.tests.push_back(LibraryTest("WindowUpdateClock")); + + // RTPass pushes the application's push data and traces at an + // explicit size: clears the frame, traces a quadrant with a hit + // colour that changes every frame, and checks the read-back. + // Same requirements as HeapASRead. + Test rtPushTest = LibraryTest("RTPassPushData"); + rtPushTest.config.shaders.emplace_back(fs::path("tests/RTPassPushData/rtpass-raygen.glsl"), std::string("main"), ShaderType::RayGen); + rtPushTest.config.shaders.emplace_back(fs::path("tests/RTPassPushData/rtpass-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); + rtPushTest.config.shaders.emplace_back(fs::path("tests/RTPassPushData/rtpass-miss.glsl"), std::string("main"), ShaderType::Miss); + cfg.tests.push_back(std::move(rtPushTest)); } // Issue #36: BLAS build options. Drives the real hardware AS-build @@ -512,27 +428,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // handle (in-place UPDATE), and that the validation layer reports no // errors. Needs a Vulkan RT device at runtime (same as the RT // examples), so it shares the native build settings. - Test blasTest; - Configuration& bc = blasTest.config; - bc.path = cfg.path; - bc.name = "BLASBuildOptions"; - bc.outputName = "BLASBuildOptions"; - bc.type = ConfigurationType::Executable; - bc.target = cfg.target; - bc.march = cfg.march; - bc.mtune = cfg.mtune; - bc.debug = cfg.debug; - bc.sysroot = cfg.sysroot; - bc.dependencies = cfg.dependencies; - bc.externalDependencies = cfg.externalDependencies; - bc.compileFlags = cfg.compileFlags; - bc.linkFlags = cfg.linkFlags; - bc.defines = cfg.defines; - bc.cFiles = cfg.cFiles; - std::vector blasImpls(impls.begin(), impls.end()); - blasImpls.emplace_back("tests/BLASBuildOptions/main"); - bc.GetInterfacesAndImplementations(ifaces, blasImpls); - cfg.tests.push_back(std::move(blasTest)); + cfg.tests.push_back(LibraryTest("BLASBuildOptions")); // Issue #64: TLAS host-input buffers (instanceBuffer / metadataBuffer) // grow on a high-water mark instead of reallocating to the exact count @@ -543,79 +439,19 @@ extern "C" Configuration CrafterBuildProject(std::span a // growth past it reallocates, with the validation layer reporting no // errors when an oversized instance buffer is fed to the build. Needs a // Vulkan RT device at runtime, so it shares the native build settings. - Test tlasTest; - Configuration& tlc = tlasTest.config; - tlc.path = cfg.path; - tlc.name = "TLASHighWaterMark"; - tlc.outputName = "TLASHighWaterMark"; - tlc.type = ConfigurationType::Executable; - tlc.target = cfg.target; - tlc.march = cfg.march; - tlc.mtune = cfg.mtune; - tlc.debug = cfg.debug; - tlc.sysroot = cfg.sysroot; - tlc.dependencies = cfg.dependencies; - tlc.externalDependencies = cfg.externalDependencies; - tlc.compileFlags = cfg.compileFlags; - tlc.linkFlags = cfg.linkFlags; - tlc.defines = cfg.defines; - tlc.cFiles = cfg.cFiles; - std::vector tlasImpls(impls.begin(), impls.end()); - tlasImpls.emplace_back("tests/TLASHighWaterMark/main"); - tlc.GetInterfacesAndImplementations(ifaces, tlasImpls); - cfg.tests.push_back(std::move(tlasTest)); + cfg.tests.push_back(LibraryTest("TLASHighWaterMark")); // The barrier BuildTLAS records after the build carries the // acceleration-structure memory dependency to the shaders that read // the TLAS, not just execution order. BuildTlasReadBarrier is pure // CPU logic, so no GPU device is needed at runtime. - Test tlasBarrierTest; - Configuration& tbc = tlasBarrierTest.config; - tbc.path = cfg.path; - tbc.name = "TLASBuildBarrier"; - tbc.outputName = "TLASBuildBarrier"; - tbc.type = ConfigurationType::Executable; - tbc.target = cfg.target; - tbc.march = cfg.march; - tbc.mtune = cfg.mtune; - tbc.debug = cfg.debug; - tbc.sysroot = cfg.sysroot; - tbc.dependencies = cfg.dependencies; - tbc.externalDependencies = cfg.externalDependencies; - tbc.compileFlags = cfg.compileFlags; - tbc.linkFlags = cfg.linkFlags; - tbc.defines = cfg.defines; - tbc.cFiles = cfg.cFiles; - std::vector tlasBarrierImpls(impls.begin(), impls.end()); - tlasBarrierImpls.emplace_back("tests/TLASBuildBarrier/main"); - tbc.GetInterfacesAndImplementations(ifaces, tlasBarrierImpls); - cfg.tests.push_back(std::move(tlasBarrierTest)); + cfg.tests.push_back(LibraryTest("TLASBuildBarrier")); // BuildTLAS on an empty scene (no elements registered yet) builds a // valid empty TLAS instead of creating zero-size buffers, and adding // and removing instances afterwards still rebuilds it. Needs a Vulkan // RT device at runtime, like TLASHighWaterMark. - Test tlasEmptyTest; - Configuration& tec = tlasEmptyTest.config; - tec.path = cfg.path; - tec.name = "TLASEmptyBuild"; - tec.outputName = "TLASEmptyBuild"; - tec.type = ConfigurationType::Executable; - tec.target = cfg.target; - tec.march = cfg.march; - tec.mtune = cfg.mtune; - tec.debug = cfg.debug; - tec.sysroot = cfg.sysroot; - tec.dependencies = cfg.dependencies; - tec.externalDependencies = cfg.externalDependencies; - tec.compileFlags = cfg.compileFlags; - tec.linkFlags = cfg.linkFlags; - tec.defines = cfg.defines; - tec.cFiles = cfg.cFiles; - std::vector tlasEmptyImpls(impls.begin(), impls.end()); - tlasEmptyImpls.emplace_back("tests/TLASEmptyBuild/main"); - tec.GetInterfacesAndImplementations(ifaces, tlasEmptyImpls); - cfg.tests.push_back(std::move(tlasEmptyTest)); + cfg.tests.push_back(LibraryTest("TLASEmptyBuild")); // Issue #118: the per-frame TLAS instance+metadata host rebuild copies // (and flushes) only the slots whose element's host-authored data @@ -628,54 +464,14 @@ extern "C" Configuration CrafterBuildProject(std::span a // the moved slots, with the validation layer reporting no errors over // the ranged FlushDevice the dirty span feeds. Needs a Vulkan RT device // at runtime, so it shares the native build settings. - Test tlasDirtyTest; - Configuration& tld = tlasDirtyTest.config; - tld.path = cfg.path; - tld.name = "TLASInstanceDirtyTracking"; - tld.outputName = "TLASInstanceDirtyTracking"; - tld.type = ConfigurationType::Executable; - tld.target = cfg.target; - tld.march = cfg.march; - tld.mtune = cfg.mtune; - tld.debug = cfg.debug; - tld.sysroot = cfg.sysroot; - tld.dependencies = cfg.dependencies; - tld.externalDependencies = cfg.externalDependencies; - tld.compileFlags = cfg.compileFlags; - tld.linkFlags = cfg.linkFlags; - tld.defines = cfg.defines; - tld.cFiles = cfg.cFiles; - std::vector tlasDirtyImpls(impls.begin(), impls.end()); - tlasDirtyImpls.emplace_back("tests/TLASInstanceDirtyTracking/main"); - tld.GetInterfacesAndImplementations(ifaces, tlasDirtyImpls); - cfg.tests.push_back(std::move(tlasDirtyTest)); + cfg.tests.push_back(LibraryTest("TLASInstanceDirtyTracking")); // Issue #51: FontAtlas only re-uploads the dirty sub-rect now, // tracked via FontAtlas::DirtyRect. The accumulation/clamp math is // pure CPU, so this test drives it directly — no GPU device needed // at runtime (the real copy params are covered by HelloUI rendering // text on both backends). - Test atlasTest; - Configuration& ac = atlasTest.config; - ac.path = cfg.path; - ac.name = "FontAtlasDirtyRect"; - ac.outputName = "FontAtlasDirtyRect"; - ac.type = ConfigurationType::Executable; - ac.target = cfg.target; - ac.march = cfg.march; - ac.mtune = cfg.mtune; - ac.debug = cfg.debug; - ac.sysroot = cfg.sysroot; - ac.dependencies = cfg.dependencies; - ac.externalDependencies = cfg.externalDependencies; - ac.compileFlags = cfg.compileFlags; - ac.linkFlags = cfg.linkFlags; - ac.defines = cfg.defines; - ac.cFiles = cfg.cFiles; - std::vector atlasImpls(impls.begin(), impls.end()); - atlasImpls.emplace_back("tests/FontAtlasDirtyRect/main"); - ac.GetInterfacesAndImplementations(ifaces, atlasImpls); - cfg.tests.push_back(std::move(atlasTest)); + cfg.tests.push_back(LibraryTest("FontAtlasDirtyRect")); // Issue #52: shaped-run cache for UIRenderer::ShapeText. Shapes a // string against a real CPU-side FontAtlas and asserts that a cache @@ -685,27 +481,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // atlas image is a real GPU image) but no swapchain — same native // build settings as the others. The font file is copied next to the // binary; the test also probes the project-root path. - Test textTest; - Configuration& xc = textTest.config; - xc.path = cfg.path; - xc.name = "ShapeTextCache"; - xc.outputName = "ShapeTextCache"; - xc.type = ConfigurationType::Executable; - xc.target = cfg.target; - xc.march = cfg.march; - xc.mtune = cfg.mtune; - xc.debug = cfg.debug; - xc.sysroot = cfg.sysroot; - xc.dependencies = cfg.dependencies; - xc.externalDependencies = cfg.externalDependencies; - xc.compileFlags = cfg.compileFlags; - xc.linkFlags = cfg.linkFlags; - xc.defines = cfg.defines; - xc.cFiles = cfg.cFiles; - xc.files = { fs::path("tests/ShapeTextCache/font.ttf") }; - std::vector textImpls(impls.begin(), impls.end()); - textImpls.emplace_back("tests/ShapeTextCache/main"); - xc.GetInterfacesAndImplementations(ifaces, textImpls); + Test textTest = LibraryTest("ShapeTextCache"); + textTest.config.files = { fs::path("tests/ShapeTextCache/font.ttf") }; cfg.tests.push_back(std::move(textTest)); // Issue #59: Device::GetMemoryType gained a `preferred` mask and a @@ -715,54 +492,14 @@ extern "C" Configuration CrafterBuildProject(std::span a // The selection is pure CPU logic over Device::memoryProperties, so // this test installs synthetic memory layouts and drives it directly — // no GPU device needed at runtime. - Test memTest; - Configuration& mc = memTest.config; - mc.path = cfg.path; - mc.name = "MemoryTypeFallback"; - mc.outputName = "MemoryTypeFallback"; - mc.type = ConfigurationType::Executable; - mc.target = cfg.target; - mc.march = cfg.march; - mc.mtune = cfg.mtune; - mc.debug = cfg.debug; - mc.sysroot = cfg.sysroot; - mc.dependencies = cfg.dependencies; - mc.externalDependencies = cfg.externalDependencies; - mc.compileFlags = cfg.compileFlags; - mc.linkFlags = cfg.linkFlags; - mc.defines = cfg.defines; - mc.cFiles = cfg.cFiles; - std::vector memImpls(impls.begin(), impls.end()); - memImpls.emplace_back("tests/MemoryTypeFallback/main"); - mc.GetInterfacesAndImplementations(ifaces, memImpls); - cfg.tests.push_back(std::move(memTest)); + cfg.tests.push_back(LibraryTest("MemoryTypeFallback")); // Issue #70: ImageVulkan's mip-chain upload folds the never-read final // blit destination into a single batched final-transition barrier // instead of giving it a dedicated DST->SRC barrier. The barrier set is // built by BuildMipChainFinalBarriers, pure CPU logic over mip count and // layout, so this test drives it directly — no GPU device at runtime. - Test mipBarrierTest; - Configuration& mbc = mipBarrierTest.config; - mbc.path = cfg.path; - mbc.name = "MipChainBarrierBatch"; - mbc.outputName = "MipChainBarrierBatch"; - mbc.type = ConfigurationType::Executable; - mbc.target = cfg.target; - mbc.march = cfg.march; - mbc.mtune = cfg.mtune; - mbc.debug = cfg.debug; - mbc.sysroot = cfg.sysroot; - mbc.dependencies = cfg.dependencies; - mbc.externalDependencies = cfg.externalDependencies; - mbc.compileFlags = cfg.compileFlags; - mbc.linkFlags = cfg.linkFlags; - mbc.defines = cfg.defines; - mbc.cFiles = cfg.cFiles; - std::vector mipBarrierImpls(impls.begin(), impls.end()); - mipBarrierImpls.emplace_back("tests/MipChainBarrierBatch/main"); - mbc.GetInterfacesAndImplementations(ifaces, mipBarrierImpls); - cfg.tests.push_back(std::move(mipBarrierTest)); + cfg.tests.push_back(LibraryTest("MipChainBarrierBatch")); // Issue #115: the frame loop's inter-pass and acquire/present barriers // no longer use ALL_COMMANDS / a queue-wide VkMemoryBarrier. The stage @@ -771,27 +508,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // and the inter-pass dependency is scoped to the swapchain image by // BuildSwapchainInterPassBarrier — all pure CPU logic over the pass // list, so this test drives them directly with no GPU at runtime. - Test swapBarrierTest; - Configuration& sbc = swapBarrierTest.config; - sbc.path = cfg.path; - sbc.name = "SwapchainBarrierScope"; - sbc.outputName = "SwapchainBarrierScope"; - sbc.type = ConfigurationType::Executable; - sbc.target = cfg.target; - sbc.march = cfg.march; - sbc.mtune = cfg.mtune; - sbc.debug = cfg.debug; - sbc.sysroot = cfg.sysroot; - sbc.dependencies = cfg.dependencies; - sbc.externalDependencies = cfg.externalDependencies; - sbc.compileFlags = cfg.compileFlags; - sbc.linkFlags = cfg.linkFlags; - sbc.defines = cfg.defines; - sbc.cFiles = cfg.cFiles; - std::vector swapBarrierImpls(impls.begin(), impls.end()); - swapBarrierImpls.emplace_back("tests/SwapchainBarrierScope/main"); - sbc.GetInterfacesAndImplementations(ifaces, swapBarrierImpls); - cfg.tests.push_back(std::move(swapBarrierTest)); + cfg.tests.push_back(LibraryTest("SwapchainBarrierScope")); // Issue #47: the fused UI uber-kernel (shaders/ui-fused.comp.glsl) and // its C++ push-constant mirror UIFusedHeader. Compiles the real shader @@ -799,26 +516,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // member offsets to UIFusedHeader's layout so a GLSL/C++ drift can't // slip through (the C++ static_assert only guards the C++ side). No GPU // device at runtime, but glslang + spirv-val are required tools. - Test fusedTest; - Configuration& ftc = fusedTest.config; - ftc.path = cfg.path; - ftc.name = "UIFusedShader"; - ftc.outputName = "UIFusedShader"; - ftc.type = ConfigurationType::Executable; - ftc.target = cfg.target; - ftc.march = cfg.march; - ftc.mtune = cfg.mtune; - ftc.debug = cfg.debug; - ftc.sysroot = cfg.sysroot; - ftc.dependencies = cfg.dependencies; - ftc.externalDependencies = cfg.externalDependencies; - ftc.compileFlags = cfg.compileFlags; - ftc.linkFlags = cfg.linkFlags; - ftc.defines = cfg.defines; - ftc.cFiles = cfg.cFiles; - std::vector fusedImpls(impls.begin(), impls.end()); - fusedImpls.emplace_back("tests/UIFusedShader/main"); - ftc.GetInterfacesAndImplementations(ifaces, fusedImpls); + Test fusedTest = LibraryTest("UIFusedShader"); fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(fusedTest)); @@ -828,54 +526,19 @@ extern "C" Configuration CrafterBuildProject(std::span a // logic over the recorded flags, so this test stamps them directly and // verifies the coherent path issues no Vulkan call — no GPU device // needed at runtime. - Test flushTest; - Configuration& fc = flushTest.config; - fc.path = cfg.path; - fc.name = "VulkanBufferFlushGate"; - fc.outputName = "VulkanBufferFlushGate"; - fc.type = ConfigurationType::Executable; - fc.target = cfg.target; - fc.march = cfg.march; - fc.mtune = cfg.mtune; - fc.debug = cfg.debug; - fc.sysroot = cfg.sysroot; - fc.dependencies = cfg.dependencies; - fc.externalDependencies = cfg.externalDependencies; - fc.compileFlags = cfg.compileFlags; - fc.linkFlags = cfg.linkFlags; - fc.defines = cfg.defines; - fc.cFiles = cfg.cFiles; - std::vector flushImpls(impls.begin(), impls.end()); - flushImpls.emplace_back("tests/VulkanBufferFlushGate/main"); - fc.GetInterfacesAndImplementations(ifaces, flushImpls); - cfg.tests.push_back(std::move(flushTest)); + cfg.tests.push_back(LibraryTest("VulkanBufferFlushGate")); + + // VulkanBuffer::Create requests and queries a device address only + // when the usage includes SHADER_DEVICE_ADDRESS; other buffers report + // address 0. Needs a Vulkan device at runtime. + cfg.tests.push_back(LibraryTest("VulkanBufferAddress")); // Ranged FlushDevice: UI descriptor registration now flushes only the // written descriptor byte range instead of the whole heap, rounding the // range outward to nonCoherentAtomSize via AlignMappedFlushRange. The // rounding is pure math and the coherent gate is pure logic, so this // test drives both directly with no GPU device. - Test rangedFlushTest; - Configuration& rfc = rangedFlushTest.config; - rfc.path = cfg.path; - rfc.name = "VulkanBufferRangedFlush"; - rfc.outputName = "VulkanBufferRangedFlush"; - rfc.type = ConfigurationType::Executable; - rfc.target = cfg.target; - rfc.march = cfg.march; - rfc.mtune = cfg.mtune; - rfc.debug = cfg.debug; - rfc.sysroot = cfg.sysroot; - rfc.dependencies = cfg.dependencies; - rfc.externalDependencies = cfg.externalDependencies; - rfc.compileFlags = cfg.compileFlags; - rfc.linkFlags = cfg.linkFlags; - rfc.defines = cfg.defines; - rfc.cFiles = cfg.cFiles; - std::vector rangedFlushImpls(impls.begin(), impls.end()); - rangedFlushImpls.emplace_back("tests/VulkanBufferRangedFlush/main"); - rfc.GetInterfacesAndImplementations(ifaces, rangedFlushImpls); - cfg.tests.push_back(std::move(rangedFlushTest)); + cfg.tests.push_back(LibraryTest("VulkanBufferRangedFlush")); // Issue #63: VulkanBuffer::Resize now reuses the existing allocation in // place when a new request still fits the created capacity and the @@ -884,27 +547,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // always destroying + reallocating. The reuse guard is pure logic over // recorded fields, so this test stamps a fake handle + capacity/flags and // verifies the in-place path issues no Vulkan call — no GPU device needed. - Test resizeTest; - Configuration& rc = resizeTest.config; - rc.path = cfg.path; - rc.name = "VulkanBufferResizeReuse"; - rc.outputName = "VulkanBufferResizeReuse"; - rc.type = ConfigurationType::Executable; - rc.target = cfg.target; - rc.march = cfg.march; - rc.mtune = cfg.mtune; - rc.debug = cfg.debug; - rc.sysroot = cfg.sysroot; - rc.dependencies = cfg.dependencies; - rc.externalDependencies = cfg.externalDependencies; - rc.compileFlags = cfg.compileFlags; - rc.linkFlags = cfg.linkFlags; - rc.defines = cfg.defines; - rc.cFiles = cfg.cFiles; - std::vector resizeImpls(impls.begin(), impls.end()); - resizeImpls.emplace_back("tests/VulkanBufferResizeReuse/main"); - rc.GetInterfacesAndImplementations(ifaces, resizeImpls); - cfg.tests.push_back(std::move(resizeTest)); + cfg.tests.push_back(LibraryTest("VulkanBufferResizeReuse")); // Issue #101: fence-keyed deferred resource-deletion queue. Since #40 // dropped the per-frame wait-idle, destroying a buffer the GPU may @@ -915,27 +558,11 @@ extern "C" Configuration CrafterBuildProject(std::span a // on a real headless device (real buffers so the frees execute and the // validation layer can object) by stepping Device::frameCounter — no // swapchain/window needed, so it shares the native build settings. - Test deferredTest; - Configuration& dc = deferredTest.config; - dc.path = cfg.path; - dc.name = "DeferredDeletion"; - dc.outputName = "DeferredDeletion"; - dc.type = ConfigurationType::Executable; - dc.target = cfg.target; - dc.march = cfg.march; - dc.mtune = cfg.mtune; - dc.debug = cfg.debug; - dc.sysroot = cfg.sysroot; - dc.dependencies = cfg.dependencies; - dc.externalDependencies = cfg.externalDependencies; - dc.compileFlags = cfg.compileFlags; - dc.linkFlags = cfg.linkFlags; - dc.defines = cfg.defines; - dc.cFiles = cfg.cFiles; - std::vector deferredImpls(impls.begin(), impls.end()); - deferredImpls.emplace_back("tests/DeferredDeletion/main"); - dc.GetInterfacesAndImplementations(ifaces, deferredImpls); - cfg.tests.push_back(std::move(deferredTest)); + cfg.tests.push_back(LibraryTest("DeferredDeletion")); + + // The deletion queue takes the handles that destructors on other + // threads queue while the render loop reclaims it. + cfg.tests.push_back(LibraryTest("DeletionQueueThreads")); // Issue #67: the compressed Mesh::Build path no longer pins its // host-visible `compressedStaging` for the mesh's life — it releases it @@ -949,27 +576,43 @@ extern "C" Configuration CrafterBuildProject(std::span a // framesInFlight frames. Shares the native build settings; needs the // asset pipeline for SaveCompressed/LoadCompressedMesh (cfg.dependencies // already carries Crafter.Asset). - Test meshStagingTest; - Configuration& msc = meshStagingTest.config; - msc.path = cfg.path; - msc.name = "MeshDecompressStagingRelease"; - msc.outputName = "MeshDecompressStagingRelease"; - msc.type = ConfigurationType::Executable; - msc.target = cfg.target; - msc.march = cfg.march; - msc.mtune = cfg.mtune; - msc.debug = cfg.debug; - msc.sysroot = cfg.sysroot; - msc.dependencies = cfg.dependencies; - msc.externalDependencies = cfg.externalDependencies; - msc.compileFlags = cfg.compileFlags; - msc.linkFlags = cfg.linkFlags; - msc.defines = cfg.defines; - msc.cFiles = cfg.cFiles; - std::vector meshStagingImpls(impls.begin(), impls.end()); - meshStagingImpls.emplace_back("tests/MeshDecompressStagingRelease/main"); - msc.GetInterfacesAndImplementations(ifaces, meshStagingImpls); - cfg.tests.push_back(std::move(meshStagingTest)); + cfg.tests.push_back(LibraryTest("MeshDecompressStagingRelease")); + + // A Mesh queues its acceleration structure for deferred deletion when + // it is rebuilt, destroyed or moved from, and a rebuild builds into + // fresh storage. Headless like the test above; needs a Vulkan RT + // device at runtime. + cfg.tests.push_back(LibraryTest("MeshLifetime")); + + // A VulkanShader queues its shader module for deferred deletion when + // it is destroyed or move-assigned over, and can only be moved. + // Headless; needs any Vulkan device. + Test shaderLifetimeTest = LibraryTest("VulkanShaderLifetime"); + shaderLifetimeTest.config.shaders.emplace_back(fs::path("tests/VulkanShaderLifetime/noop.comp.glsl"), std::string("main"), ShaderType::Compute); + cfg.tests.push_back(std::move(shaderLifetimeTest)); + + // ComputeShader and PipelineRTVulkan queue their pipelines for deferred + // deletion when destroyed or move-assigned over, and only move. + // Headless; needs any Vulkan device, and ray tracing for half of it. + Test pipelineLifetimeTest = LibraryTest("PipelineLifetime"); + pipelineLifetimeTest.config.shaders.emplace_back(fs::path("tests/VulkanShaderLifetime/noop.comp.glsl"), std::string("main"), ShaderType::Compute); + pipelineLifetimeTest.config.shaders.emplace_back(fs::path("tests/PipelineLifetime/lifetime-raygen.glsl"), std::string("main"), ShaderType::RayGen); + cfg.tests.push_back(std::move(pipelineLifetimeTest)); + + // DescriptorHeapVulkan hands freed slots out again, ranges included. + // Headless; needs any Vulkan device. + cfg.tests.push_back(LibraryTest("HeapSlotReuse")); + + // The debug messenger prints info messages only with validation on. + // Headless; needs any Vulkan device. + cfg.tests.push_back(LibraryTest("DebugMessengerSeverity")); + + // A re-upload into a buffer the GPU may still read lands after the + // reads already submitted. Headless; needs any Vulkan device. + cfg.tests.push_back(LibraryTest("UploadInFlight")); + + // CRAFTER_GRAPHICS_DEVICE picks the GPU. Needs two Vulkan devices. + cfg.tests.push_back(LibraryTest("DeviceSelection")); // Issue #114: a static ImageVulkan no longer pins its host-visible // staging `buffer` for the image's life — Update releases it via @@ -983,27 +626,11 @@ extern "C" Configuration CrafterBuildProject(std::span a // the entry retires only after framesInFlight frames, and a streamed // image keeps + then frees its staging on Destroy. Shares the native // build settings. - Test imageStagingTest; - Configuration& isc = imageStagingTest.config; - isc.path = cfg.path; - isc.name = "ImageStagingRelease"; - isc.outputName = "ImageStagingRelease"; - isc.type = ConfigurationType::Executable; - isc.target = cfg.target; - isc.march = cfg.march; - isc.mtune = cfg.mtune; - isc.debug = cfg.debug; - isc.sysroot = cfg.sysroot; - isc.dependencies = cfg.dependencies; - isc.externalDependencies = cfg.externalDependencies; - isc.compileFlags = cfg.compileFlags; - isc.linkFlags = cfg.linkFlags; - isc.defines = cfg.defines; - isc.cFiles = cfg.cFiles; - std::vector imageStagingImpls(impls.begin(), impls.end()); - imageStagingImpls.emplace_back("tests/ImageStagingRelease/main"); - isc.GetInterfacesAndImplementations(ifaces, imageStagingImpls); - cfg.tests.push_back(std::move(imageStagingTest)); + cfg.tests.push_back(LibraryTest("ImageStagingRelease")); + + // A non-square image gets every level of its mip chain, each at least + // one texel on a side. Headless; needs any Vulkan device. + cfg.tests.push_back(LibraryTest("MipChainNonSquare")); // Issue #89: Device::PreferDirectDeviceWrite chooses the upload strategy // for a CPU-written, GPU-read buffer — direct HOST_VISIBLE|DEVICE_LOCAL @@ -1012,27 +639,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // Device::memoryProperties (types + heaps), so this test installs // synthetic ReBAR / UMA / small-window / no-BAR layouts and drives it // directly — no GPU device needed at runtime. - Test uploadTest; - Configuration& uc = uploadTest.config; - uc.path = cfg.path; - uc.name = "UploadStrategy"; - uc.outputName = "UploadStrategy"; - uc.type = ConfigurationType::Executable; - uc.target = cfg.target; - uc.march = cfg.march; - uc.mtune = cfg.mtune; - uc.debug = cfg.debug; - uc.sysroot = cfg.sysroot; - uc.dependencies = cfg.dependencies; - uc.externalDependencies = cfg.externalDependencies; - uc.compileFlags = cfg.compileFlags; - uc.linkFlags = cfg.linkFlags; - uc.defines = cfg.defines; - uc.cFiles = cfg.cFiles; - std::vector uploadImpls(impls.begin(), impls.end()); - uploadImpls.emplace_back("tests/UploadStrategy/main"); - uc.GetInterfacesAndImplementations(ifaces, uploadImpls); - cfg.tests.push_back(std::move(uploadTest)); + cfg.tests.push_back(LibraryTest("UploadStrategy")); // Issue #57: Font::GetLineWidth memoises per-codepoint advances in // font units (Font::AdvanceUnits) and rescales per call, instead of @@ -1040,27 +647,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // query. Pure CPU — Font only touches stb_truetype — so this drives // the public API directly with no Vulkan device. The font file is // copied next to the binary; the test also probes the project root. - Test advTest; - Configuration& vc = advTest.config; - vc.path = cfg.path; - vc.name = "FontAdvanceCache"; - vc.outputName = "FontAdvanceCache"; - vc.type = ConfigurationType::Executable; - vc.target = cfg.target; - vc.march = cfg.march; - vc.mtune = cfg.mtune; - vc.debug = cfg.debug; - vc.sysroot = cfg.sysroot; - vc.dependencies = cfg.dependencies; - vc.externalDependencies = cfg.externalDependencies; - vc.compileFlags = cfg.compileFlags; - vc.linkFlags = cfg.linkFlags; - vc.defines = cfg.defines; - vc.cFiles = cfg.cFiles; - vc.files = { fs::path("tests/FontAdvanceCache/font.ttf") }; - std::vector advImpls(impls.begin(), impls.end()); - advImpls.emplace_back("tests/FontAdvanceCache/main"); - vc.GetInterfacesAndImplementations(ifaces, advImpls); + Test advTest = LibraryTest("FontAdvanceCache"); + advTest.config.files = { fs::path("tests/FontAdvanceCache/font.ttf") }; cfg.tests.push_back(std::move(advTest)); // Issue #50: uiResolveScreenPixel now gates its per-pixel clip-rect @@ -1069,27 +657,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // in UIRenderer::ClipFlags — pure CPU logic over the clip rect and the // surface size — so this test drives it directly with synthetic // dimensions, no Window or GPU device needed at runtime. - Test clipTest; - Configuration& clc = clipTest.config; - clc.path = cfg.path; - clc.name = "UIClipFlag"; - clc.outputName = "UIClipFlag"; - clc.type = ConfigurationType::Executable; - clc.target = cfg.target; - clc.march = cfg.march; - clc.mtune = cfg.mtune; - clc.debug = cfg.debug; - clc.sysroot = cfg.sysroot; - clc.dependencies = cfg.dependencies; - clc.externalDependencies = cfg.externalDependencies; - clc.compileFlags = cfg.compileFlags; - clc.linkFlags = cfg.linkFlags; - clc.defines = cfg.defines; - clc.cFiles = cfg.cFiles; - std::vector clipImpls(impls.begin(), impls.end()); - clipImpls.emplace_back("tests/UIClipFlag/main"); - clc.GetInterfacesAndImplementations(ifaces, clipImpls); - cfg.tests.push_back(std::move(clipTest)); + cfg.tests.push_back(LibraryTest("UIClipFlag")); // Issue #56: InputField_HitTestCursor mapped a click x to a cursor byte // offset by re-walking the prefix for every boundary (O(n^2) glyph @@ -1098,27 +666,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // boundaries. The mapping is pure CPU over a TrueType file, so this test // drives it directly: no Vulkan device or window. The font is copied // next to the binary; the test also probes the project-root path. - Test hitTest; - Configuration& hc = hitTest.config; - hc.path = cfg.path; - hc.name = "InputFieldHitTest"; - hc.outputName = "InputFieldHitTest"; - hc.type = ConfigurationType::Executable; - hc.target = cfg.target; - hc.march = cfg.march; - hc.mtune = cfg.mtune; - hc.debug = cfg.debug; - hc.sysroot = cfg.sysroot; - hc.dependencies = cfg.dependencies; - hc.externalDependencies = cfg.externalDependencies; - hc.compileFlags = cfg.compileFlags; - hc.linkFlags = cfg.linkFlags; - hc.defines = cfg.defines; - hc.cFiles = cfg.cFiles; - hc.files = { fs::path("tests/InputFieldHitTest/font.ttf") }; - std::vector hitImpls(impls.begin(), impls.end()); - hitImpls.emplace_back("tests/InputFieldHitTest/main"); - hc.GetInterfacesAndImplementations(ifaces, hitImpls); + Test hitTest = LibraryTest("InputFieldHitTest"); + hitTest.config.files = { fs::path("tests/InputFieldHitTest/font.ttf") }; cfg.tests.push_back(std::move(hitTest)); // Issue #128: DrawInputField re-measured the cursor prefix via @@ -1130,27 +679,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // DrawText no-ops with a null atlas/renderer — so this test drives // DrawInputField directly with no Vulkan device or window. The font is // copied next to the binary; the test also probes the project root. - Test caretTest; - Configuration& crc = caretTest.config; - crc.path = cfg.path; - crc.name = "InputFieldCaretCache"; - crc.outputName = "InputFieldCaretCache"; - crc.type = ConfigurationType::Executable; - crc.target = cfg.target; - crc.march = cfg.march; - crc.mtune = cfg.mtune; - crc.debug = cfg.debug; - crc.sysroot = cfg.sysroot; - crc.dependencies = cfg.dependencies; - crc.externalDependencies = cfg.externalDependencies; - crc.compileFlags = cfg.compileFlags; - crc.linkFlags = cfg.linkFlags; - crc.defines = cfg.defines; - crc.cFiles = cfg.cFiles; - crc.files = { fs::path("tests/InputFieldCaretCache/font.ttf") }; - std::vector caretImpls(impls.begin(), impls.end()); - caretImpls.emplace_back("tests/InputFieldCaretCache/main"); - crc.GetInterfacesAndImplementations(ifaces, caretImpls); + Test caretTest = LibraryTest("InputFieldCaretCache"); + caretTest.config.files = { fs::path("tests/InputFieldCaretCache/font.ttf") }; cfg.tests.push_back(std::move(caretTest)); // Issue #69: the engine feeds one shared Device::pipelineCache to every @@ -1160,27 +690,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // VkPipelineCache header and Device::deviceProperties, so this test // stamps synthetic device identities + headers and drives it directly, // no GPU device needed at runtime. - Test cacheTest; - Configuration& cc = cacheTest.config; - cc.path = cfg.path; - cc.name = "PipelineCacheValidation"; - cc.outputName = "PipelineCacheValidation"; - cc.type = ConfigurationType::Executable; - cc.target = cfg.target; - cc.march = cfg.march; - cc.mtune = cfg.mtune; - cc.debug = cfg.debug; - cc.sysroot = cfg.sysroot; - cc.dependencies = cfg.dependencies; - cc.externalDependencies = cfg.externalDependencies; - cc.compileFlags = cfg.compileFlags; - cc.linkFlags = cfg.linkFlags; - cc.defines = cfg.defines; - cc.cFiles = cfg.cFiles; - std::vector cacheImpls(impls.begin(), impls.end()); - cacheImpls.emplace_back("tests/PipelineCacheValidation/main"); - cc.GetInterfacesAndImplementations(ifaces, cacheImpls); - cfg.tests.push_back(std::move(cacheTest)); + cfg.tests.push_back(LibraryTest("PipelineCacheValidation")); } return cfg; diff --git a/tests/BLASBuildOptions/main.cpp b/tests/BLASBuildOptions/main.cpp index d8d70ae..5826701 100644 --- a/tests/BLASBuildOptions/main.cpp +++ b/tests/BLASBuildOptions/main.cpp @@ -211,6 +211,55 @@ int main() { Check(triFast.scratchBuffer.buffer == VK_NULL_HANDLE, "static-mesh refit rebuild re-released its scratch (issue #66)"); + // ── One any-hit call per triangle. A shader that accumulates along the + // ray (transmittance through tinted glass) must see each triangle once, + // which Vulkan only promises for geometry built with + // NO_DUPLICATE_ANY_HIT_INVOCATION. The geometry stays opaque (an + // instance forces any-hit on where it wants it), and refits keep the + // flags: an UPDATE's geometry must match its build's, which the + // validation check at the end would report. ───────────────────────── + Check(tri.geometryFlags == VK_GEOMETRY_OPAQUE_BIT_KHR, "triangle geometry is opaque, duplicate any-hit calls allowed, by default"); + Mesh once; + { + auto verts = CubeVerts(1.0f); + auto idx = CubeIndices(); + VkCommandBuffer cmd = BeginCmd(); + once.Build(verts, idx, cmd, RTBuildOptions{ .allowUpdate = true, .noDuplicateAnyHit = true }); + SubmitWait(cmd); + } + Check(once.geometryFlags == (VK_GEOMETRY_OPAQUE_BIT_KHR | VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR), + "noDuplicateAnyHit → NO_DUPLICATE_ANY_HIT_INVOCATION on the opaque geometry"); + const VkAccelerationStructureKHR onceHandle = once.accelerationStructure; + { + auto verts = CubeVerts(1.5f); + auto idx = CubeIndices(); + VkCommandBuffer cmd = BeginCmd(); + once.Refit(verts, idx, cmd); + SubmitWait(cmd); + } + Check(once.accelerationStructure == onceHandle && (once.geometryFlags & VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR) != 0, + "an in-place refit keeps the geometry's flags"); + { + auto verts = CubeVerts(1.0f); + verts.push_back({2.0f, 2.0f, 2.0f}); + auto idx = CubeIndices(); + idx.insert(idx.end(), {0u, 1u, 8u}); + VkCommandBuffer cmd = BeginCmd(); + once.Refit(verts, idx, cmd); + SubmitWait(cmd); + } + Check(once.builtPrimitiveCount == 13 && (once.geometryFlags & VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR) != 0, + "so does the rebuild a refit with new topology falls back to"); + Mesh onceProc; + { + std::array boxes {{ { .min = {-1,-1,-1}, .max = {1,1,1} } }}; + VkCommandBuffer cmd = BeginCmd(); + onceProc.BuildProcedural(boxes, /*opaque*/ false, cmd, RTBuildOptions{ .noDuplicateAnyHit = true }); + SubmitWait(cmd); + } + Check(onceProc.geometryFlags == VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR, + "procedural geometry takes the option too, and stays non-opaque"); + // ── Procedural (AABB) BLAS: build with options, then refit. ───────── Mesh proc; { @@ -275,7 +324,10 @@ int main() { VkBufferMemoryBarrier barrier { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, - .dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR, + // A build reads its geometry as a shader read; the + // ACCELERATION_STRUCTURE access bits are for the scratch and the + // acceleration structures themselves. + .dstAccessMask = VK_ACCESS_SHADER_READ_BIT, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .buffer = devAabb.buffer, .offset = 0, .size = VK_WHOLE_SIZE, diff --git a/tests/DebugMessengerSeverity/main.cpp b/tests/DebugMessengerSeverity/main.cpp new file mode 100644 index 0000000..0e3da6c --- /dev/null +++ b/tests/DebugMessengerSeverity/main.cpp @@ -0,0 +1,49 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The debug messenger prints the loader's and layers' info messages only when +// CRAFTER_GRAPHICS_VALIDATION is set. It used to print them on every run (the +// loader alone reports its device sorting a few times over), so every +// application's output started with lines that were not its own. Warnings and +// errors still print either way. +// +// Captures what Device::Initialize writes to stdout without validation, so it +// skips when CRAFTER_GRAPHICS_VALIDATION is set. Needs a Vulkan device. + +#include +#include +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; + +int main() { + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + std::println("SKIP checks a run without CRAFTER_GRAPHICS_VALIDATION"); + return 77; + } + + // Send stdout to a file while the device comes up. + std::FILE* capture = std::tmpfile(); + std::fflush(stdout); + const int saved = dup(STDOUT_FILENO); + dup2(fileno(capture), STDOUT_FILENO); + Device::Initialize(); + std::fflush(stdout); + dup2(saved, STDOUT_FILENO); + close(saved); + + std::string output; + std::rewind(capture); + for (int c = std::fgetc(capture); c != EOF; c = std::fgetc(capture)) output.push_back(static_cast(c)); + std::fclose(capture); + + const bool quiet = output.find("(info)") == std::string::npos; + std::println("{} Device::Initialize prints no info messages without validation", quiet ? "PASS" : "FAIL"); + if (!quiet) { + std::println(" it printed:\n{}", output.substr(0, 400)); + } + return quiet ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/DeferredDeletion/main.cpp b/tests/DeferredDeletion/main.cpp index 3d91374..de98c4b 100644 --- a/tests/DeferredDeletion/main.cpp +++ b/tests/DeferredDeletion/main.cpp @@ -5,7 +5,8 @@ // the per-frame vkQueueWaitIdle, a buffer the CPU is done with may still be // read by the GPU for up to framesInFlight-1 more frames — so destroying it // immediately (VulkanBuffer::Clear / the old Resize path) is a use-after-free. -// VulkanBuffer::DeferredClear() / Resize() now hand the handles to Device's +// VulkanBuffer::DeferredClear() / Resize() and the VulkanBuffer destructor now +// hand the handles to Device's // deletion queue, tagged with the current frameCounter; ReclaimDeletions frees // an entry only once framesInFlight frames have elapsed, and DrainDeletions // (called after a wait-idle) frees everything unconditionally. @@ -134,6 +135,35 @@ int main() { "DrainDeletions frees every queued entry unconditionally"); } + // ── The destructor defers too: an owner can be destroyed while a frame in + // flight still reads the buffer, so it can't free it at once ────────── + { + Device::frameCounter = 0; + Device::deletionQueue.clear(); + + VkBuffer plain = VK_NULL_HANDLE; + VkBuffer mapped = VK_NULL_HANDLE; + { + VulkanBuffer a; + VulkanBuffer b; + a.Create(USAGE, DEVICE_LOCAL, 8); + b.Create(USAGE, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, 8); + plain = a.buffer; + mapped = b.buffer; + } + const bool bothQueued = Device::deletionQueue.size() == 2 + && std::ranges::any_of(Device::deletionQueue, [&](const Device::PendingDeletion& e) { return e.buffer == plain; }) + && std::ranges::any_of(Device::deletionQueue, [&](const Device::PendingDeletion& e) { return e.buffer == mapped; }); + Check(bothQueued, "destroying a VulkanBuffer that owns an allocation queues it, mapped or not"); + + Device::frameCounter = Window::numFrames - 1; + Device::ReclaimDeletions(); + Check(Device::deletionQueue.size() == 2, "a destroyed buffer is not freed before its retire frame"); + Device::frameCounter = Window::numFrames; + Device::ReclaimDeletions(); + Check(Device::deletionQueue.empty(), "a destroyed buffer is freed on its retire frame"); + } + // ── EnqueueDeletion ignores a null handle (nothing to free) ────────────── { Device::deletionQueue.clear(); diff --git a/tests/DeletionQueueThreads/main.cpp b/tests/DeletionQueueThreads/main.cpp new file mode 100644 index 0000000..5e314b1 --- /dev/null +++ b/tests/DeletionQueueThreads/main.cpp @@ -0,0 +1,60 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The deletion queue takes handles from any thread. Every Vulkan handle +// owner's destructor queues its handles, and owners go where they're made: a +// loader thread drops the buffers of a model it built while the render loop +// reclaims the queue each frame. The queue was a plain deque, so those +// pushes and pops raced and corrupted it. +// +// Four threads make and drop buffers while this one steps frames and +// reclaims, as Window::Render does; then everything has to drain cleanly. +// +// Needs a Vulkan device and no window. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; + +using namespace Crafter; + +int main() { + Device::Initialize(); + Device::framesInFlight = 2; + Device::frameCounter = 0; + + // Each round, every thread makes its buffers, then all drop theirs at + // once, so the queue's pushes overlap each other and the reclaims. + constexpr int kThreads = 4; + constexpr int kBuffers = 400; + constexpr int kRounds = 5; + for (int round = 0; round < kRounds; ++round) { + std::latch made(kThreads + 1); + std::atomic dropping = kThreads; + std::vector makers; + for (int t = 0; t < kThreads; ++t) { + makers.emplace_back([&] { + std::vector> buffers(kBuffers); + for (VulkanBuffer& buffer : buffers) { + buffer.Create(VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 16); + } + made.arrive_and_wait(); + buffers.clear(); + --dropping; + }); + } + made.arrive_and_wait(); + while (dropping > 0) { + ++Device::frameCounter; + Device::ReclaimDeletions(); + } + for (std::thread& maker : makers) maker.join(); + } + Device::DrainDeletions(); + + const bool empty = Device::deletionQueue.empty(); + std::println("{} {} buffers dropped at once on {} threads while frames were reclaimed, {} times, and drained", empty ? "PASS" : "FAIL", kThreads * kBuffers, kThreads, kRounds); + return empty ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/DeviceSelection/main.cpp b/tests/DeviceSelection/main.cpp new file mode 100644 index 0000000..9de0f8a --- /dev/null +++ b/tests/DeviceSelection/main.cpp @@ -0,0 +1,73 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// CRAFTER_GRAPHICS_DEVICE picks the GPU by its number among the distinct +// GPUs in the Vulkan loader's list. Without it the first discrete GPU is +// used, as before. A machine with two of the same GPU had no way to render on +// the second, which matters when the first is full: a job filling its memory +// leaves no room to even create a device there. A GPU listed twice, as a +// Flatpak with two driver manifests lists them, counts once. A value that +// isn't a number (a PCI address, say) is an error, as one out of range is: it +// used to pick the first GPU without a word. +// +// Needs at least two distinct GPUs; skips with fewer. + +#include +#include +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; + +int main() { + // In a child, before this process has a Vulkan instance. + const pid_t child = fork(); + if (child == 0) { + setenv("CRAFTER_GRAPHICS_DEVICE", "CA:00.0", 1); + try { + Device::Initialize(); + } catch (const std::runtime_error& error) { + _exit(std::string_view(error.what()).find("CRAFTER_GRAPHICS_DEVICE") != std::string_view::npos ? 0 : 2); + } + _exit(1); + } + int status = 0; + waitpid(child, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + std::println("FAIL CRAFTER_GRAPHICS_DEVICE=CA:00.0 is an error naming the variable (child exit {})", WIFEXITED(status) ? WEXITSTATUS(status) : -1); + return EXIT_FAILURE; + } + std::println("PASS CRAFTER_GRAPHICS_DEVICE=CA:00.0 is an error naming the variable"); + + setenv("CRAFTER_GRAPHICS_DEVICE", "1", 1); + Device::Initialize(); + + std::uint32_t count = 0; + vkEnumeratePhysicalDevices(Device::instance, &count, nullptr); + std::vector devices(count); + vkEnumeratePhysicalDevices(Device::instance, &count, devices.data()); + // The distinct GPUs, by device UUID, in the loader's order. + std::vector> distinct; + auto uuidOf = [](VkPhysicalDevice device) { + VkPhysicalDeviceIDProperties id { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES }; + VkPhysicalDeviceProperties2 properties { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &id }; + vkGetPhysicalDeviceProperties2(device, &properties); + std::array uuid; + std::ranges::copy(id.deviceUUID, uuid.begin()); + return uuid; + }; + for (VkPhysicalDevice device : devices) { + if (std::ranges::find(distinct, uuidOf(device)) == distinct.end()) distinct.push_back(uuidOf(device)); + } + if (distinct.size() < 2) { + std::println("SKIP one GPU; nothing to choose between"); + return 77; + } + + const bool picked = uuidOf(Device::physDevice) == distinct[1]; + std::println("{} CRAFTER_GRAPHICS_DEVICE=1 selects the second distinct GPU ({}, {} listed)", + picked ? "PASS" : "FAIL", Device::deviceProperties.deviceName, count); + return picked ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/HeapASRead/main.cpp b/tests/HeapASRead/main.cpp index 68ab572..ca178b0 100644 --- a/tests/HeapASRead/main.cpp +++ b/tests/HeapASRead/main.cpp @@ -27,7 +27,7 @@ #include // PNG decode for the read-back frame. The library only compiles the writer -// (Window::SaveFrame); STB_IMAGE_STATIC keeps the reader private to this file. +// (Window::CaptureNextFrame); STB_IMAGE_STATIC keeps the reader private to this file. #define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_STATIC #define STBI_ONLY_PNG @@ -253,15 +253,12 @@ int Render(Path path, const fs::path& out) { for (int i = 0; i < Window::numFrames * 2; ++i) window.Render(); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); - // Counted before the capture: SaveFrame reads back an image that has - // already been handed to the presentation engine, which the validation - // layer reports on its own. + window.CaptureNextFrame(out); + window.Render(); if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { Check(Device::validationErrorCount == 0, - std::format("no Vulkan validation errors while rendering ({} seen)", Device::validationErrorCount)); + std::format("no Vulkan validation errors while rendering and capturing ({} seen)", Device::validationErrorCount)); } - - window.SaveFrame(out); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); const Frame frame = LoadFrame(out); Check(!frame.rgba.empty(), "the frame was read back"); diff --git a/tests/HeapDynamicIndexHit/main.cpp b/tests/HeapDynamicIndexHit/main.cpp index 057d289..653810a 100644 --- a/tests/HeapDynamicIndexHit/main.cpp +++ b/tests/HeapDynamicIndexHit/main.cpp @@ -25,7 +25,7 @@ #include // PNG decode for the read-back frame. The library only compiles the writer -// (Window::SaveFrame); STB_IMAGE_STATIC keeps the reader private to this file. +// (Window::CaptureNextFrame); STB_IMAGE_STATIC keeps the reader private to this file. #define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_STATIC #define STBI_ONLY_PNG @@ -341,15 +341,12 @@ int RenderScene(const fs::path& out) { for (int i = 0; i < Window::numFrames * 2; ++i) window.Render(); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); - // Counted before the capture: SaveFrame reads back an image that has - // already been handed to the presentation engine, which the validation - // layer reports on its own. + window.CaptureNextFrame(out); + window.Render(); if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { Check(Device::validationErrorCount == 0, - std::format("no Vulkan validation errors while rendering ({} seen)", Device::validationErrorCount)); + std::format("no Vulkan validation errors while rendering and capturing ({} seen)", Device::validationErrorCount)); } - - window.SaveFrame(out); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); const Frame frame = LoadFrame(out); Check(!frame.rgba.empty(), "the frame was read back"); diff --git a/tests/HeapSlotReuse/main.cpp b/tests/HeapSlotReuse/main.cpp new file mode 100644 index 0000000..24b039b --- /dev/null +++ b/tests/HeapSlotReuse/main.cpp @@ -0,0 +1,102 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// DescriptorHeapVulkan hands freed slots out again, ranges included. Freed +// slots went to a free list that only a request for one slot looked at, so a +// range always took slots never used before: a model that allocates its +// textures and samplers as ranges, switched for another and back, used up +// the heap (32 samplers last a handful of switches) and then threw "out of +// slots". The test frees and allocates ranges of each kind many times over, +// more slots in all than the heap has; a range reuses a freed run of slots +// when one is long enough, and a long enough request after small frees +// still takes fresh slots rather than failing. +// +// Needs a Vulkan device and no window. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; + +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// Allocates a range, frees it, `rounds` times over, with a single slot held +// throughout so the range isn't simply the next free one at the top. +template +std::string Churn(std::uint16_t count, std::uint32_t rounds, Allocate allocate, Free free) { + try { + for (std::uint32_t i = 0; i < rounds; ++i) { + auto range = allocate(count); + if (range.count != count) return std::format("round {}: {} slots instead of {}", i, range.count, count); + free(range); + } + } catch (const std::exception& e) { + return e.what(); + } + return ""; +} + +} // namespace + +int main() { + Device::Initialize(); + DescriptorHeapVulkan heap; + constexpr std::uint16_t kImages = 16; + constexpr std::uint16_t kBuffers = 16; + constexpr std::uint16_t kSamplers = 8; + heap.Initialize(kImages, kBuffers, kSamplers); + + // ── Ranges freed and allocated again, far more than fit ───────────────── + const std::string images = Churn(3, 100, [&](std::uint16_t n) { return heap.AllocateImageSlots(n); }, + [&](ImageSlotRange r) { heap.FreeImageSlots(r); }); + Check(images.empty(), std::format("100 image ranges of 3, each freed, fit a heap of 16 ({})", images.empty() ? "they did" : images)); + const std::string buffers = Churn(4, 100, [&](std::uint16_t n) { return heap.AllocateBufferSlots(n); }, + [&](BufferSlotRange r) { heap.FreeBufferSlots(r); }); + Check(buffers.empty(), std::format("and buffer ranges of 4 ({})", buffers.empty() ? "they did" : buffers)); + const std::string samplers = Churn(2, 100, [&](std::uint16_t n) { return heap.AllocateSamplerSlots(n); }, + [&](SamplerSlotRange r) { heap.FreeSamplerSlots(r); }); + Check(samplers.empty(), std::format("and sampler ranges of 2, in a heap of 8 ({})", samplers.empty() ? "they did" : samplers)); + + // ── A freed run comes back as the range ───────────────────────────────── + { + DescriptorHeapVulkan fresh; + fresh.Initialize(kImages, kBuffers, kSamplers); + const ImageSlotRange first = fresh.AllocateImageSlots(3); + const ImageSlotRange held = fresh.AllocateImageSlots(1); + fresh.FreeImageSlots(first); + const ImageSlotRange again = fresh.AllocateImageSlots(3); + Check(again.firstElement == first.firstElement && again.count == 3, + std::format("a range of 3 reuses the 3 slots freed before it ({} then {})", first.firstElement, again.firstElement)); + // Freed one by one and out of order, they still make a run. + fresh.FreeImageSlots({ static_cast(again.firstElement + 2), 1 }); + fresh.FreeImageSlots({ again.firstElement, 1 }); + fresh.FreeImageSlots({ static_cast(again.firstElement + 1), 1 }); + const ImageSlotRange pieced = fresh.AllocateImageSlots(3); + Check(pieced.firstElement == first.firstElement, std::format("as do 3 freed one by one, out of order ({})", pieced.firstElement)); + // Two freed slots apart from each other make no run of two: fresh ones. + const ImageSlotRange a = fresh.AllocateImageSlots(1); + const ImageSlotRange b = fresh.AllocateImageSlots(1); + const ImageSlotRange c = fresh.AllocateImageSlots(1); + fresh.FreeImageSlots(a); + fresh.FreeImageSlots(c); + const ImageSlotRange pair = fresh.AllocateImageSlots(2); + Check(pair.firstElement != a.firstElement && pair.firstElement != c.firstElement && pair.firstElement + 1 != a.firstElement + && pair.firstElement + 1 != c.firstElement, + std::format("two freed slots apart make no range of 2; it takes others ({}, {} freed; got {})", a.firstElement, c.firstElement, pair.firstElement)); + (void)held; + (void)b; + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/ImageStagingRelease/main.cpp b/tests/ImageStagingRelease/main.cpp index 7ce4501..b8e6018 100644 --- a/tests/ImageStagingRelease/main.cpp +++ b/tests/ImageStagingRelease/main.cpp @@ -161,6 +161,9 @@ int main() { Check(match, "image reads back byte-equal to the staged source " "(released staging outlived the submit)"); + // The submit has finished, so free at once rather than leave the + // destructor to queue it next to the staging counted below. + readback.Clear(); } // ── Retire timing: the staging frees once framesInFlight frames elapse, diff --git a/tests/MeshDecompressStagingRelease/main.cpp b/tests/MeshDecompressStagingRelease/main.cpp index d18fcc7..73c3ce2 100644 --- a/tests/MeshDecompressStagingRelease/main.cpp +++ b/tests/MeshDecompressStagingRelease/main.cpp @@ -208,6 +208,22 @@ int main() { Check(Device::deletionQueue.empty(), "staging is freed once its retire frame is reached"); + // A ranged refit copies the moved vertices into the vertex buffer the + // decompressor wrote, so that buffer has to take a transfer: it was made + // without TRANSFER_DST, an invalid copy only validation sees. + { + std::vector> moved(srcMesh.vertexes.begin(), srcMesh.vertexes.end()); + std::vector indices(srcMesh.indexes.begin(), srcMesh.indexes.end()); + for (std::size_t v = 4; v < 8; ++v) moved[v].z += 0.5f; + const std::uint32_t errorsBefore = Device::validationErrorCount; + VkCommandBuffer refit = BeginCmd(); + mesh.Refit(moved, indices, 4, 4, refit); + SubmitWait(refit); + Check(Device::validationErrorCount == errorsBefore, + "a ranged refit of the compressed mesh copies into its vertex buffer without validation errors"); + Device::DrainDeletions(); + } + // ── Regression guard for issue #110 ────────────────────────────────────── // The "exactly one allocation" assertion above holds ONLY because the // refit-capable build (allowUpdate=true) RETAINS its per-mesh BLAS scratch diff --git a/tests/MeshLifetime/main.cpp b/tests/MeshLifetime/main.cpp new file mode 100644 index 0000000..9657d88 --- /dev/null +++ b/tests/MeshLifetime/main.cpp @@ -0,0 +1,299 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A Mesh hands its acceleration structure to the deferred-deletion queue +// (issue #101) whenever it lets go of it, because frames in flight may still +// trace it: +// - Building a Mesh that already has a BLAS used to destroy the old handle at +// once, although the command buffer being recorded or a frame in flight +// could still use it, and then built into the old storage with no barrier. +// The old handle is now queued, and a barrier orders earlier reads of the +// storage and writes of the scratch before the build. The storage is still +// reused when the new BLAS fits, which keeps blasAddr stable; storage the +// new BLAS outgrows is queued behind the handle. Two builds of one Mesh in +// one command buffer is the case the validation layer sees: the command +// buffer became invalid because its first build's destination was +// destroyed, and synchronization validation +// (VK_KHRONOS_VALIDATION_VALIDATE_SYNC=1) sees the two builds' writes race. +// - Destroying a Mesh used to leak the handle. The handle and the Mesh's +// buffers are now queued, the handle first so it goes before its storage. +// - Moving a Mesh hands the handle over, so only the last owner queues it, +// including when a std::vector grows. +// +// Needs a Vulkan ray-tracing device (exits 77 without one) and no window: the +// test steps Device::frameCounter itself. The validation checks only mean +// something with CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import Crafter.Math; +import std; + +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +VkCommandBuffer BeginCmd() { + VkCommandBufferAllocateInfo allocInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd)); + VkCommandBufferBeginInfo beginInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo)); + return cmd; +} + +void SubmitWait(VkCommandBuffer cmd) { + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + VkSubmitInfo submitInfo { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &cmd, + }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE)); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); +} + +std::vector> CubeVerts(float s) { + return { + {-s,-s,-s}, { s,-s,-s}, { s, s,-s}, {-s, s,-s}, + {-s,-s, s}, { s,-s, s}, { s, s, s}, {-s, s, s}, + }; +} + +std::vector CubeIndices() { + return { + 0,1,2, 0,2,3, 4,6,5, 4,7,6, + 0,4,5, 0,5,1, 3,2,6, 3,6,7, + 1,5,6, 1,6,2, 0,3,7, 0,7,4, + }; +} + +// `count` unit cubes in a row: enough geometry to outgrow one cube's BLAS. +void Cubes(int count, std::vector>& verts, std::vector& indices) { + const std::vector> cube = CubeVerts(0.5f); + const std::vector cubeIndices = CubeIndices(); + verts.clear(); + indices.clear(); + for (int i = 0; i < count; ++i) { + const std::uint32_t base = static_cast(verts.size()); + for (const Vector& v : cube) verts.push_back({v.x + 2.0f * static_cast(i), v.y, v.z}); + for (std::uint32_t index : cubeIndices) indices.push_back(base + index); + } +} + +// Where an entry sits in the queue, or the queue size when it isn't there. +std::size_t QueuePosition(auto matches) { + const auto it = std::ranges::find_if(Device::deletionQueue, matches); + return static_cast(it - Device::deletionQueue.begin()); +} + +std::size_t QueuedCount(VkAccelerationStructureKHR handle) { + return static_cast(std::ranges::count_if(Device::deletionQueue, + [&](const Device::PendingDeletion& e) { return e.accelerationStructure == handle; })); +} + +bool Queued(VkBuffer buffer) { + return std::ranges::any_of(Device::deletionQueue, + [&](const Device::PendingDeletion& e) { return e.buffer == buffer; }); +} + +} // namespace + +int main() { + Device::Initialize(); + if (!Device::rayTracingSupported) { + std::println("SKIP no Vulkan ray-tracing device"); + return 77; + } + Device::validationErrorCount = 0; + Device::framesInFlight = Window::numFrames; + Device::frameCounter = 0; + Device::DrainDeletions(); + + std::vector> verts = CubeVerts(1.0f); + std::vector indices = CubeIndices(); + const RTBuildOptions refittable { .allowUpdate = true }; + + // ── A rebuild queues the old handle and reuses storage that fits ───────── + { + Mesh mesh; + VkCommandBuffer cmd = BeginCmd(); + mesh.Build(verts, indices, cmd, refittable); + const VkAccelerationStructureKHR first = mesh.accelerationStructure; + const VkBuffer firstStorage = mesh.blasBuffer.buffer; + + // Same geometry, so the new BLAS fits the old storage. + mesh.Build(verts, indices, cmd, refittable); + Check(QueuedCount(first) == 1, "a rebuild queues the old acceleration structure instead of destroying it while recorded"); + Check(mesh.accelerationStructure != VK_NULL_HANDLE && mesh.accelerationStructure != first, + "and creates a new one"); + Check(mesh.blasBuffer.buffer == firstStorage && !Queued(firstStorage), "in the same storage, which still fits"); + SubmitWait(cmd); + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("two builds of one Mesh in one command buffer are validation-clean ({} errors)", Device::validationErrorCount)); + } + + // A BLAS that outgrows the storage moves to new storage; the old + // storage is queued behind the handle that lives in it. + std::vector> manyVerts; + std::vector manyIndices; + Cubes(64, manyVerts, manyIndices); + const VkAccelerationStructureKHR second = mesh.accelerationStructure; + VkCommandBuffer growCmd = BeginCmd(); + mesh.Build(manyVerts, manyIndices, growCmd, refittable); + const std::size_t handleAt = QueuePosition([&](const Device::PendingDeletion& e) { return e.accelerationStructure == second; }); + const std::size_t storageAt = QueuePosition([&](const Device::PendingDeletion& e) { return e.buffer == firstStorage; }); + Check(mesh.blasBuffer.buffer != firstStorage && storageAt < Device::deletionQueue.size(), + "a rebuild that outgrows the storage queues it and gets new storage"); + Check(handleAt < storageAt, "the old handle is queued before the storage it lives in"); + SubmitWait(growCmd); + + // A refit after the rebuilds still updates the latest BLAS in place. + VkCommandBuffer refitCmd = BeginCmd(); + const VkAccelerationStructureKHR rebuilt = mesh.accelerationStructure; + manyVerts[0] = {-1.5f, -1.5f, -1.5f}; + mesh.Refit(manyVerts, manyIndices, refitCmd); + SubmitWait(refitCmd); + Check(mesh.accelerationStructure == rebuilt, "a refit after the rebuilds keeps the handle"); + } + Device::DrainDeletions(); + + // ── Destroying a Mesh queues its BLAS ahead of its buffers ─────────────── + { + VkAccelerationStructureKHR handle = VK_NULL_HANDLE; + VkBuffer storage = VK_NULL_HANDLE; + VkBuffer vertices = VK_NULL_HANDLE; + VkBuffer scratch = VK_NULL_HANDLE; + { + Mesh mesh; + VkCommandBuffer cmd = BeginCmd(); + mesh.Build(verts, indices, cmd, refittable); + SubmitWait(cmd); + Device::DrainDeletions(); + handle = mesh.accelerationStructure; + storage = mesh.blasBuffer.buffer; + vertices = mesh.vertexBuffer.buffer; + scratch = mesh.scratchBuffer.buffer; + } + Check(QueuedCount(handle) == 1, "destroying a Mesh queues its acceleration structure"); + Check(Queued(storage) && Queued(vertices) && Queued(scratch), + "and its BLAS storage, vertex and scratch buffers"); + Check(!Device::deletionQueue.empty() && Device::deletionQueue.front().accelerationStructure == handle, + "the acceleration structure is queued before the storage it lives in"); + + Device::frameCounter += Window::numFrames; + Device::ReclaimDeletions(); + Check(Device::deletionQueue.empty(), "everything a destroyed Mesh queued is freed on its retire frame"); + } + + // ── A never-built Mesh has nothing to queue ────────────────────────────── + { + { Mesh empty; } + Check(Device::deletionQueue.empty(), "destroying a Mesh that was never built queues nothing"); + } + + // ── Moving hands the BLAS over; only the last owner queues it ──────────── + { + VkAccelerationStructureKHR handle = VK_NULL_HANDLE; + { + Mesh source; + VkCommandBuffer cmd = BeginCmd(); + source.Build(verts, indices, cmd); + SubmitWait(cmd); + Device::DrainDeletions(); + handle = source.accelerationStructure; + const VkDeviceAddress address = source.blasAddr; + + Mesh moved(std::move(source)); + Check(source.accelerationStructure == VK_NULL_HANDLE && source.blasAddr == 0, + "a moved-from Mesh no longer holds the BLAS"); + Check(moved.accelerationStructure == handle && moved.blasAddr == address, + "the Mesh it moved to does"); + } + Check(QueuedCount(handle) == 1, "the BLAS is queued once, by the Mesh it moved to"); + Device::DrainDeletions(); + } + + // ── Move assignment hands the BLAS over and queues the one it replaces ─── + { + VkAccelerationStructureKHR kept = VK_NULL_HANDLE; + VkAccelerationStructureKHR replaced = VK_NULL_HANDLE; + { + Mesh source; + Mesh target; + VkCommandBuffer cmd = BeginCmd(); + source.Build(verts, indices, cmd); + target.Build(verts, indices, cmd); + SubmitWait(cmd); + Device::DrainDeletions(); + kept = source.accelerationStructure; + replaced = target.accelerationStructure; + + target = std::move(source); + Check(source.accelerationStructure == VK_NULL_HANDLE && target.accelerationStructure == kept, + "move assignment hands the BLAS over"); + Check(QueuedCount(replaced) == 1 && QueuedCount(kept) == 0, "and queues the BLAS it replaces, once"); + } + Check(QueuedCount(kept) == 1, "the BLAS moved in is queued once, when its new Mesh goes"); + Device::DrainDeletions(); + + // Erasing from a vector moves the later Meshes down over it. + std::vector meshes(3); + VkCommandBuffer cmd = BeginCmd(); + for (Mesh& mesh : meshes) mesh.Build(verts, indices, cmd); + SubmitWait(cmd); + const VkAccelerationStructureKHR erased = meshes[0].accelerationStructure; + const VkAccelerationStructureKHR last = meshes[2].accelerationStructure; + meshes.erase(meshes.begin()); + Check(QueuedCount(erased) == 1 && QueuedCount(last) == 0 && meshes[1].accelerationStructure == last, + "erasing a Mesh from a vector queues its BLAS and keeps the others"); + meshes.clear(); + Device::DrainDeletions(); + } + + // ── A growing std::vector moves its elements without queueing ────── + { + std::vector handles; + { + std::vector meshes; + VkCommandBuffer cmd = BeginCmd(); + for (int i = 0; i < 5; ++i) { + meshes.emplace_back().Build(verts, indices, cmd); + } + SubmitWait(cmd); + for (const Mesh& mesh : meshes) handles.push_back(mesh.accelerationStructure); + Check(std::ranges::none_of(handles, [](VkAccelerationStructureKHR h) { return QueuedCount(h) != 0; }), + "moving the meshes as the vector grew queued none of their BLASes"); + Device::DrainDeletions(); + } + Check(std::ranges::all_of(handles, [](VkAccelerationStructureKHR h) { return QueuedCount(h) == 1; }), + "each BLAS in the vector is queued exactly once when the vector goes"); + Device::DrainDeletions(); + } + + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors across the run ({} seen)", Device::validationErrorCount)); + } + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/MipChainNonSquare/main.cpp b/tests/MipChainNonSquare/main.cpp new file mode 100644 index 0000000..4193906 --- /dev/null +++ b/tests/MipChainNonSquare/main.cpp @@ -0,0 +1,157 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// An ImageVulkan that isn't square gets every level of its mip chain. The +// chain was blitted with each level `size >> level` wide and high, which is +// 0 for the shorter side once the level passes its log2: a 256×4 texture's +// levels 3 to 8 were blitted with no height, so nothing wrote them and they +// held whatever memory they got. A level is max(1, size >> level). +// +// A 256×4 and a 4×256 texture of one colour each get their full chain (9 +// levels, as the longer side needs); every texel of every level must come +// back that colour. Needs a Vulkan device and no window. The validation +// check only means something with CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; + +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +VkCommandBuffer BeginCmd() { + VkCommandBufferAllocateInfo allocInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd)); + VkCommandBufferBeginInfo beginInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo)); + return cmd; +} + +void SubmitWait(VkCommandBuffer cmd) { + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + VkSubmitInfo submitInfo { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &cmd, + }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE)); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); +} + +// RGBA8 texel as a 4-byte word, matching VK_FORMAT_R8G8B8A8_UNORM. +using Texel = std::uint32_t; +constexpr Texel kColour = 0xFF3366CCu; +constexpr std::uint8_t kLevels = 9; + +std::uint32_t LevelSize(std::uint16_t size, std::uint32_t level) { + return std::max(1u, static_cast(size) >> level); +} + +void CheckChain(std::uint16_t width, std::uint16_t height) { + ImageVulkan image; + // Room for every level, back to back. + std::uint32_t total = 0; + for (std::uint32_t level = 0; level < kLevels; ++level) total += LevelSize(width, level) * LevelSize(height, level); + VulkanBuffer readback; + readback.Create(VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, total, VK_MEMORY_PROPERTY_HOST_CACHED_BIT); + std::fill_n(readback.value, total, 0u); + readback.FlushDevice(); + + VkCommandBuffer cmd = BeginCmd(); + image.Create(width, height, kLevels, cmd, VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + std::fill_n(image.buffer.value, static_cast(width) * height, kColour); + image.Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + const VkImageMemoryBarrier toSource { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_SHADER_READ_BIT, + .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image.image, + .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, kLevels, 0, 1 }, + }; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toSource); + std::array regions {}; + std::uint32_t offset = 0; + for (std::uint32_t level = 0; level < kLevels; ++level) { + regions[level] = { + .bufferOffset = static_cast(offset) * sizeof(Texel), + .imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, level, 0, 1 }, + .imageExtent = { LevelSize(width, level), LevelSize(height, level), 1 }, + }; + offset += LevelSize(width, level) * LevelSize(height, level); + } + vkCmdCopyImageToBuffer(cmd, image.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.buffer, kLevels, regions.data()); + const VkBufferMemoryBarrier toHost { + .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_HOST_READ_BIT, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .buffer = readback.buffer, + .offset = 0, + .size = VK_WHOLE_SIZE, + }; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &toHost, 0, nullptr); + SubmitWait(cmd); + readback.FlushHost(); + + std::vector wrong; + offset = 0; + for (std::uint32_t level = 0; level < kLevels; ++level) { + const std::uint32_t count = LevelSize(width, level) * LevelSize(height, level); + if (!std::all_of(readback.value + offset, readback.value + offset + count, [](Texel t) { return t == kColour; })) wrong.push_back(level); + offset += count; + } + std::string list; + for (std::uint32_t level : wrong) list += std::format(" {}", level); + Check(wrong.empty(), std::format("every level of a {}×{} texture's chain has its colour (wrong:{})", width, height, wrong.empty() ? " none" : list)); + image.Destroy(); +} + +} // namespace + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + Device::framesInFlight = 2; + Device::frameCounter = 0; + + CheckChain(256, 4); + CheckChain(4, 256); + // Square, as before. + CheckChain(256, 256); + + Device::DrainDeletions(); + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors across the run ({} seen)", Device::validationErrorCount)); + } + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/PipelineLifetime/lifetime-raygen.glsl b/tests/PipelineLifetime/lifetime-raygen.glsl new file mode 100644 index 0000000..e6f53b7 --- /dev/null +++ b/tests/PipelineLifetime/lifetime-raygen.glsl @@ -0,0 +1,9 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : require + +// A ray-tracing pipeline to create and release; it never runs. +void main() { +} diff --git a/tests/PipelineLifetime/main.cpp b/tests/PipelineLifetime/main.cpp new file mode 100644 index 0000000..392886d --- /dev/null +++ b/tests/PipelineLifetime/main.cpp @@ -0,0 +1,206 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// ComputeShader and PipelineRTVulkan release their pipelines through the +// deferred-deletion queue (issue #101), like the other owners of Vulkan +// handles. Both used to destroy the pipeline on the spot, while a frame in +// flight could still be running it, and a static one made a Vulkan call at +// exit. Neither can be copied, as two copies would both release the pipeline; +// moves hand it over, and a move assignment queues what it replaces, a ray- +// tracing pipeline's binding table too. A PipelineRTVulkan that was never +// initialised releases nothing: its handle used to start out uninitialised and +// be destroyed regardless. Loading or initialising one again queues the +// pipeline (and binding table) it had, which used to leak. +// +// Needs a Vulkan device and no window: the test steps Device::frameCounter +// itself. The ray-tracing half skips without ray tracing. The validation check +// only means something with CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; + +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +fs::path ExeDir() { + return fs::read_symlink("/proc/self/exe").parent_path(); +} + +std::size_t QueuedCount(VkPipeline pipeline) { + return static_cast(std::ranges::count_if(Device::deletionQueue, + [&](const Device::PendingDeletion& e) { return e.pipeline == pipeline; })); +} + +std::size_t QueuedCount(VkBuffer buffer) { + return static_cast(std::ranges::count_if(Device::deletionQueue, + [&](const Device::PendingDeletion& e) { return e.buffer == buffer; })); +} + +std::size_t QueuedPipelines() { + return static_cast(std::ranges::count_if(Device::deletionQueue, + [](const Device::PendingDeletion& e) { return e.pipeline != VK_NULL_HANDLE; })); +} + +// Records into a one-off command buffer and runs it to completion. +void Run(const std::function& record) { + const VkCommandBufferAllocateInfo allocation { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocation, &cmd)); + const VkCommandBufferBeginInfo begin { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &begin)); + record(cmd); + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + const VkSubmitInfo submit { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &cmd }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submit, VK_NULL_HANDLE)); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); +} + +} // namespace + +static_assert(!std::is_copy_constructible_v && !std::is_copy_assignable_v, + "two copies of a ComputeShader would both release its pipeline"); +static_assert(!std::is_copy_constructible_v && !std::is_copy_assignable_v, + "two copies of a PipelineRTVulkan would both release its pipeline"); +static_assert(std::is_nothrow_move_constructible_v && std::is_move_assignable_v, + "a PipelineRTVulkan moves, so it can live in a container"); + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + Device::framesInFlight = Window::numFrames; + Device::frameCounter = 0; + Device::DrainDeletions(); + + // What Load needs in descriptor-set mode; the heap build reads nothing from it. + DescriptorHeapVulkan heap; + heap.Initialize(1, 1, 0); + const fs::path compute = ExeDir() / "noop.comp.spv"; + + // ── ComputeShader ──────────────────────────────────────────────────────── + VkPipeline pipeline = VK_NULL_HANDLE; + { + ComputeShader never; + } + Check(Device::deletionQueue.empty(), "a ComputeShader never loaded releases nothing"); + { + ComputeShader shader; + shader.Load(compute, heap); + pipeline = shader.pipeline; + Check(pipeline != VK_NULL_HANDLE, "the compute pipeline was created"); + // Load's VulkanShader has queued the shader module, nothing more. + Check(QueuedPipelines() == 0, "a live ComputeShader has queued no pipeline"); + } + Check(QueuedCount(pipeline) == 1, "destroying a ComputeShader queues its pipeline"); + Device::frameCounter = Window::numFrames; + Device::ReclaimDeletions(); + Check(Device::deletionQueue.empty(), "the pipeline is freed on its retire frame"); + { + ComputeShader source; + source.Load(compute, heap); + pipeline = source.pipeline; + ComputeShader moved(std::move(source)); + Check(source.pipeline == VK_NULL_HANDLE && moved.pipeline == pipeline, "a move constructor hands the compute pipeline over"); + + ComputeShader target; + target.Load(compute, heap); + const VkPipeline replaced = target.pipeline; + target = std::move(moved); + Check(moved.pipeline == VK_NULL_HANDLE && target.pipeline == pipeline, "a move assignment hands it over"); + Check(QueuedCount(replaced) == 1, "and queues the compute pipeline it replaced"); + Check(QueuedCount(pipeline) == 0, "the pipeline moved around is not queued while owned"); + } + Check(QueuedCount(pipeline) == 1 && QueuedPipelines() == 2, "the compute pipeline is queued once, by its last owner"); + Device::DrainDeletions(); + { + ComputeShader shader; + shader.Load(compute, heap); + pipeline = shader.pipeline; + shader.Load(compute, heap); + Check(shader.pipeline != pipeline && QueuedCount(pipeline) == 1 && QueuedPipelines() == 1, "loading one again queues the pipeline it had"); + } + Device::DrainDeletions(); + + // ── PipelineRTVulkan ───────────────────────────────────────────────────── + if (!Device::rayTracingSupported) { + std::println("SKIP no Vulkan ray-tracing device: PipelineRTVulkan not checked"); + } else { + { + PipelineRTVulkan never; + } + Check(Device::deletionQueue.empty(), "a PipelineRTVulkan never initialised releases nothing"); + + std::array shaders {{ { ExeDir() / "lifetime-raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, nullptr } }}; + ShaderBindingTableVulkan table; + table.Init(shaders); + std::array raygen {{ { + .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR, + .generalShader = 0, .closestHitShader = VK_SHADER_UNUSED_KHR, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, + } }}; + auto build = [&](PipelineRTVulkan& rt) { + Run([&](VkCommandBuffer cmd) { rt.Init(cmd, raygen, {}, {}, table); }); + }; + + { + PipelineRTVulkan rt; + build(rt); + pipeline = rt.pipeline; + Check(pipeline != VK_NULL_HANDLE && QueuedPipelines() == 0, "the ray-tracing pipeline was created, and nothing queued while it lives"); + } + Check(QueuedCount(pipeline) == 1, "destroying a PipelineRTVulkan queues its pipeline"); + Device::DrainDeletions(); + { + PipelineRTVulkan source; + build(source); + pipeline = source.pipeline; + const VkBuffer sourceTable = source.sbtBuffer.buffer; + const VkDeviceAddress raygenAddress = source.raygenRegion.deviceAddress; + PipelineRTVulkan moved(std::move(source)); + Check(source.pipeline == VK_NULL_HANDLE && moved.pipeline == pipeline && moved.sbtBuffer.buffer == sourceTable + && moved.raygenRegion.deviceAddress == raygenAddress, + "a move constructor hands the ray-tracing pipeline and its binding table over"); + + PipelineRTVulkan target; + build(target); + const VkPipeline replaced = target.pipeline; + const VkBuffer replacedTable = target.sbtBuffer.buffer; + target = std::move(moved); + Check(moved.pipeline == VK_NULL_HANDLE && target.pipeline == pipeline && target.sbtBuffer.buffer == sourceTable, + "a move assignment hands them over"); + Check(QueuedCount(replaced) == 1 && QueuedCount(replacedTable) == 1, "and queues the pipeline and binding table it replaced"); + Check(QueuedCount(pipeline) == 0 && QueuedCount(sourceTable) == 0, "what was moved around is not queued while owned"); + + build(target); + Check(target.pipeline != pipeline && QueuedCount(pipeline) == 1 && QueuedCount(sourceTable) == 1, + "initialising one again queues the pipeline and binding table it had"); + } + Device::DrainDeletions(); + } + + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors across the run ({} seen)", Device::validationErrorCount)); + } + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/RTPassPushData/main.cpp b/tests/RTPassPushData/main.cpp new file mode 100644 index 0000000..08c5449 --- /dev/null +++ b/tests/RTPassPushData/main.cpp @@ -0,0 +1,201 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// RTPass pushes the application's own push data (pushPtr/pushBytes, as the +// DOM backend's RTPass takes it) and traces at an explicit size instead of +// always the window's. Before, an app with a camera to push had to copy +// RTPass's Record, NVIDIA TLAS push included, into its own pass. The frame +// is cleared to magenta, then the pass traces only the top-left quadrant, +// colouring hits from a push block that changes every frame; the read-back +// frame shows the last frame's colour on the triangle, the miss colour +// around it inside the quadrant, and the clear colour outside it. On NVIDIA +// the TLAS address is pushed too, after the app's block, so this also covers +// the two pushes together. +// +// Needs a live Wayland compositor and a Vulkan RT device; without RT it +// skips. The validation check only means something with +// CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +// PNG decode for the read-back frame. The library only compiles the writer +// (Window::CaptureNextFrame); STB_IMAGE_STATIC keeps the reader private to this file. +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_STATIC +#define STBI_ONLY_PNG +#include "../../lib/stb_image.h" + +import Crafter.Graphics; +import Crafter.Event; +import Crafter.Math; +import std; + +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +fs::path ExeDir() { + return fs::read_symlink("/proc/self/exe").parent_path(); +} + +struct ClearPass : RenderPass { + void Record(VkCommandBuffer cmd, std::uint32_t, Window& window) override { + const VkClearColorValue magenta = {{1.0f, 0.0f, 1.0f, 1.0f}}; + const VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdClearColorImage(cmd, window.images[window.currentBuffer], VK_IMAGE_LAYOUT_GENERAL, &magenta, 1, &range); + } + VkPipelineStageFlags SwapchainStage() const override { return VK_PIPELINE_STAGE_TRANSFER_BIT; } +}; + +// Matches the raygen's push block. +struct Push { + float hitColour[4]; +}; + +} // namespace + +int main() { + Device::Initialize(); + if (!Device::rayTracingSupported) { + std::println("SKIP no Vulkan ray-tracing device"); + return 77; + } + Device::validationErrorCount = 0; + + Window window(640, 360, "RTPassPushData test"); + if ((window.swapchainUsage & VK_IMAGE_USAGE_TRANSFER_DST_BIT) == 0) { + std::println("SKIP the surface can't clear the swapchain image"); + return 77; + } + VkCommandBuffer cmd = window.StartInit(); + + DescriptorHeapVulkan descriptorHeap; + descriptorHeap.Initialize(1, 1, 0); + VkSpecializationMapEntry entry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) }; + VkSpecializationInfo spec = { .mapEntryCount = 1, .pMapEntries = &entry, .dataSize = sizeof(std::uint16_t), + .pData = &descriptorHeap.bufferStartElement }; + const fs::path dir = ExeDir(); + std::array shaders{{ + {dir / "rtpass-raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, &spec}, + {dir / "rtpass-miss.spv", "main", VK_SHADER_STAGE_MISS_BIT_KHR, nullptr}, + {dir / "rtpass-closesthit.spv", "main", VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR, nullptr}, + }}; + ShaderBindingTableVulkan shaderTable; + shaderTable.Init(shaders); + std::array raygenGroups {{ { + .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR, + .generalShader = 0, .closestHitShader = VK_SHADER_UNUSED_KHR, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, + } }}; + std::array missGroups {{ { + .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR, + .generalShader = 1, .closestHitShader = VK_SHADER_UNUSED_KHR, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, + } }}; + std::array hitGroups {{ { + .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_TRIANGLES_HIT_GROUP_KHR, + .generalShader = VK_SHADER_UNUSED_KHR, .closestHitShader = 2, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, + } }}; + PipelineRTVulkan pipeline; + pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, shaderTable); + + Mesh triangleMesh; + std::array, 3> verts {{{-150, -150, 100}, {0, 150, 100}, {150, -150, 100}}}; + std::array index {{2, 1, 0}}; + triangleMesh.Build(verts, index, cmd); + RenderingElement3D renderer; + renderer.instance = { + .instanceCustomIndex = 0, .mask = 0xFF, .instanceShaderBindingTableRecordOffset = 0, + .flags = VK_GEOMETRY_INSTANCE_FORCE_OPAQUE_BIT_KHR, .accelerationStructureReference = triangleMesh.blasAddr, + }; + MatrixRowMajor::Identity().Store(reinterpret_cast(renderer.instance.transform.matrix)); + RenderingElement3D::Add(&renderer); + for (std::uint32_t f = 0; f < Window::numFrames; ++f) RenderingElement3D::BuildTLAS(cmd, f); + window.FinishInit(); + + auto imgSlots = descriptorHeap.AllocateImageSlots(1); + auto bufSlots = descriptorHeap.AllocateBufferSlots(1); + auto writeDescriptors = [&] { + std::array tlasRanges{}; + std::array imageInfos{}; + std::vector resources; + std::vector destinations; + for (std::uint32_t f = 0; f < Window::numFrames; ++f) { + tlasRanges[f] = { .address = RenderingElement3D::tlases[f].address }; + imageInfos[f] = { .sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT, .pView = &window.imageViews[f], .layout = VK_IMAGE_LAYOUT_GENERAL }; + resources.push_back({ .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR, + .data = { .pAddressRange = &tlasRanges[f] } }); + destinations.push_back({ .address = descriptorHeap.resourceHeap[f].value + descriptorHeap.BufferByteOffset(bufSlots.firstElement), + .size = Device::descriptorHeapProperties.bufferDescriptorSize }); + resources.push_back({ .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + .data = { .pImage = &imageInfos[f] } }); + destinations.push_back({ .address = descriptorHeap.resourceHeap[f].value + descriptorHeap.ImageByteOffset(imgSlots.firstElement), + .size = Device::descriptorHeapProperties.imageDescriptorSize }); + } + Device::vkWriteResourceDescriptorsEXT(Device::device, static_cast(resources.size()), resources.data(), destinations.data()); + for (auto& heap : descriptorHeap.resourceHeap) heap.FlushDevice(); + }; + writeDescriptors(); + EventListener resizeListener(&window.onResize, writeDescriptors); + window.descriptorHeap = &descriptorHeap; + + ClearPass clear; + Push push{}; + RTPass rtPass(&pipeline); + rtPass.pushPtr = &push; + rtPass.pushBytes = sizeof(push); + window.passes.push_back(&clear); + window.passes.push_back(&rtPass); + + // A different colour every frame; the frame read back has the last one. + const int frames = Window::numFrames * 2; + for (int i = 0; i < frames; ++i) { + rtPass.width = window.width / 2; + rtPass.height = window.height / 2; + push = { { 0.0f, static_cast(i) / static_cast(frames - 1), 1.0f, 1.0f } }; + window.Render(); + } + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + const fs::path out = fs::temp_directory_path() / "crafter-rtpass-push-data.png"; + std::error_code ec; + fs::remove(out, ec); + window.CaptureNextFrame(out); + window.Render(); + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors while rendering and capturing ({} seen)", Device::validationErrorCount)); + } + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + int width = 0, height = 0, channels = 0; + stbi_uc* pixels = stbi_load(out.string().c_str(), &width, &height, &channels, STBI_rgb_alpha); + Check(pixels != nullptr, "the frame was read back"); + if (pixels != nullptr) { + auto at = [&](int x, int y) { + const stbi_uc* p = pixels + (static_cast(y) * width + x) * 4; + return std::array{p[0], p[1], p[2]}; + }; + auto near = [](std::array got, std::array want) { + for (int c = 0; c < 3; ++c) if (std::abs(got[c] - want[c]) > 3) return false; + return true; + }; + const auto inside = at(width / 4, height / 4); + const auto quadrantCorner = at(width / 40, height / 40); + const auto outside = at(3 * width / 4, 3 * height / 4); + Check(near(inside, {0, 255, 255}), std::format("the triangle has the last frame's pushed colour (got {}, {}, {})", inside[0], inside[1], inside[2])); + Check(near(quadrantCorner, {0, 0, 64}), std::format("misses inside the traced quadrant (got {}, {}, {})", quadrantCorner[0], quadrantCorner[1], quadrantCorner[2])); + Check(near(outside, {255, 0, 255}), std::format("outside the trace size the clear stays (got {}, {}, {})", outside[0], outside[1], outside[2])); + stbi_image_free(pixels); + } + + RenderingElement3D::Remove(&renderer); + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/RTPassPushData/rtpass-closesthit.glsl b/tests/RTPassPushData/rtpass-closesthit.glsl new file mode 100644 index 0000000..f0438ab --- /dev/null +++ b/tests/RTPassPushData/rtpass-closesthit.glsl @@ -0,0 +1,11 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable + +layout(location = 0) rayPayloadInEXT float hit; + +void main() { + hit = 1.0; +} diff --git a/tests/RTPassPushData/rtpass-miss.glsl b/tests/RTPassPushData/rtpass-miss.glsl new file mode 100644 index 0000000..eb39521 --- /dev/null +++ b/tests/RTPassPushData/rtpass-miss.glsl @@ -0,0 +1,11 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable + +layout(location = 0) rayPayloadInEXT float hit; + +void main() { + hit = 0.0; +} diff --git a/tests/RTPassPushData/rtpass-raygen.glsl b/tests/RTPassPushData/rtpass-raygen.glsl new file mode 100644 index 0000000..1639298 --- /dev/null +++ b/tests/RTPassPushData/rtpass-raygen.glsl @@ -0,0 +1,31 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable +#extension GL_EXT_shader_image_load_formatted : enable +#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable +#extension GL_EXT_descriptor_heap : enable +#extension GL_EXT_nonuniform_qualifier : enable + +// Hits take their colour from the pass's push data; the TLAS comes from the +// heap, so on NVIDIA the pass also pushes the TLAS address, after this block. +layout(constant_id = 0) const uint16_t bufferStart = 0us; +layout(descriptor_heap) uniform accelerationStructureEXT topLevelAS[]; +layout(descriptor_heap) uniform writeonly image2D image[]; +layout(push_constant) uniform Push { vec4 hitColour; } push; + +layout(location = 0) rayPayloadEXT float hit; + +void main() { + uvec2 pixel = gl_LaunchIDEXT.xy; + uvec2 resolution = gl_LaunchSizeEXT.xy; + vec2 ndc = (vec2(pixel) + 0.5) / vec2(resolution) * 2.0 - 1.0; + vec3 origin = vec3(0.0, 0.0, -300.0); + float aspect = float(resolution.x) / float(resolution.y); + float tanHalfFov = tan(radians(60.0) * 0.5); + vec3 direction = normalize(vec3(ndc.x * aspect * tanHalfFov, -ndc.y * tanHalfFov, 1.0)); + + traceRayEXT(topLevelAS[bufferStart], gl_RayFlagsNoneEXT, 0xff, 0, 0, 0, origin, 0.001, direction, 10000.0, 0); + imageStore(image[0], ivec2(pixel), hit > 0.5 ? push.hitColour : vec4(0.0, 0.0, 0.25, 1.0)); +} diff --git a/tests/SwapchainBarrierScope/main.cpp b/tests/SwapchainBarrierScope/main.cpp index d200b49..f165f9a 100644 --- a/tests/SwapchainBarrierScope/main.cpp +++ b/tests/SwapchainBarrierScope/main.cpp @@ -132,7 +132,8 @@ int main() { // layout change — the storage image stays bound), matching the intra-pass // UI barrier's access scope. { - VkImageMemoryBarrier b = BuildSwapchainInterPassBarrier(SentinelImage()); + VkImageMemoryBarrier b = BuildSwapchainInterPassBarrier(SentinelImage(), + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR); Check(b.sType == VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, "inter-pass barrier sType is IMAGE_MEMORY_BARRIER"); Check(b.image == SentinelImage(), "inter-pass barrier carries the swapchain image handle"); Check(b.oldLayout == VK_IMAGE_LAYOUT_GENERAL && b.newLayout == VK_IMAGE_LAYOUT_GENERAL, @@ -149,6 +150,25 @@ int main() { "inter-pass barrier is scoped to the single swapchain subresource"); } + // The access masks follow the two passes' stages: a transfer pass (a + // clear or blit) writes with TRANSFER_WRITE, and SHADER_WRITE in a + // barrier whose source stage is TRANSFER is invalid + // (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02818). + { + const VkImageMemoryBarrier clearThenTrace = BuildSwapchainInterPassBarrier(SentinelImage(), + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR); + Check(clearThenTrace.srcAccessMask == VK_ACCESS_TRANSFER_WRITE_BIT, + "after a transfer pass the src access is transfer write"); + Check(clearThenTrace.dstAccessMask == (VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT), + "before a ray-tracing pass the dst access is shader read|write"); + const VkImageMemoryBarrier traceThenBlit = BuildSwapchainInterPassBarrier(SentinelImage(), + VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_PIPELINE_STAGE_TRANSFER_BIT); + Check(traceThenBlit.srcAccessMask == VK_ACCESS_SHADER_WRITE_BIT, + "after a ray-tracing pass the src access is shader write"); + Check(traceThenBlit.dstAccessMask == (VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT), + "before a transfer pass the dst access is transfer read|write"); + } + // ─── SwapchainWriterAccess (issue #153) ───────────────────────────────── // The frame-edge (acquire dst / present src) barriers must set an access // mask supported by their accompanying stage mask diff --git a/tests/SwapchainTransferDst/main.cpp b/tests/SwapchainTransferDst/main.cpp new file mode 100644 index 0000000..ced31da --- /dev/null +++ b/tests/SwapchainTransferDst/main.cpp @@ -0,0 +1,104 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The swapchain images are created with TRANSFER_DST where the surface +// allows it, so a pass can blit or clear into them: the swapchain used to be +// STORAGE | TRANSFER_SRC only, which left a compute shader as the only way to +// put an offscreen image on screen. This checks the usage the Window records, +// then clears the swapchain image from a pass, reads the frame back and +// checks the colour. +// +// Needs a live Wayland compositor and a Vulkan device. The validation check +// only means something with CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +// PNG decode for the read-back frame. The library only compiles the writer +// (Window::CaptureNextFrame); STB_IMAGE_STATIC keeps the reader private to this file. +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_STATIC +#define STBI_ONLY_PNG +#include "../../lib/stb_image.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// Clears the frame's swapchain image with vkCmdClearColorImage, a transfer +// write. +struct ClearPass : RenderPass { + VkClearColorValue colour = {{0.2f, 0.4f, 0.8f, 1.0f}}; + + void Record(VkCommandBuffer cmd, std::uint32_t, Window& window) override { + const VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdClearColorImage(cmd, window.images[window.currentBuffer], VK_IMAGE_LAYOUT_GENERAL, &colour, 1, &range); + } + + VkPipelineStageFlags SwapchainStage() const override { + return VK_PIPELINE_STAGE_TRANSFER_BIT; + } +}; + +} // namespace + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + + Window window(320, 240, "SwapchainTransferDst test"); + VkSurfaceCapabilitiesKHR caps; + Device::CheckVkResult(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(Device::physDevice, window.vulkanSurface, &caps)); + const bool surfaceAllows = (caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT) != 0; + + Check((window.swapchainUsage & (VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) + == (VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT), + "the swapchain keeps STORAGE and TRANSFER_SRC"); + Check(((window.swapchainUsage & VK_IMAGE_USAGE_TRANSFER_DST_BIT) != 0) == surfaceAllows, + std::format("TRANSFER_DST is used exactly when the surface allows it ({})", surfaceAllows ? "it does" : "it doesn't")); + if (!surfaceAllows) { + std::println("SKIP the clear-and-read-back check (this surface has no TRANSFER_DST)"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; + } + + window.StartInit(); + window.FinishInit(); + ClearPass clear; + window.passes.push_back(&clear); + for (int i = 0; i < Window::numFrames * 2; ++i) window.Render(); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + const fs::path out = fs::temp_directory_path() / "crafter-swapchain-transfer-dst.png"; + std::error_code ec; + fs::remove(out, ec); + window.CaptureNextFrame(out); + window.Render(); + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors while clearing and capturing ({} seen)", Device::validationErrorCount)); + } + int width = 0, height = 0, channels = 0; + stbi_uc* pixels = stbi_load(out.string().c_str(), &width, &height, &channels, STBI_rgb_alpha); + Check(pixels != nullptr, "the frame was read back"); + if (pixels != nullptr) { + const stbi_uc* p = pixels + (static_cast(height / 2) * width + width / 2) * 4; + // 0.2, 0.4, 0.8 in a UNORM image. + const bool cleared = std::abs(p[0] - 51) <= 2 && std::abs(p[1] - 102) <= 2 && std::abs(p[2] - 204) <= 2; + Check(cleared, std::format("the cleared colour reached the frame (got {}, {}, {})", p[0], p[1], p[2])); + stbi_image_free(pixels); + } + + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/UIFallbackParity/main.cpp b/tests/UIFallbackParity/main.cpp index 0f03bdc..b2d342e 100644 --- a/tests/UIFallbackParity/main.cpp +++ b/tests/UIFallbackParity/main.cpp @@ -46,7 +46,7 @@ #include // PNG decode for the comparison. The library only compiles the writer -// (Window::SaveFrame); STB_IMAGE_STATIC keeps the reader private to this file. +// (Window::CaptureNextFrame); STB_IMAGE_STATIC keeps the reader private to this file. #define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_STATIC #define STBI_ONLY_PNG @@ -301,15 +301,11 @@ int RenderScene(const fs::path& out) { for (int i = 0; i < Window::numFrames * 3; ++i) window.Render(); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); - // Counted before the capture: SaveFrame reads back an image that has - // already been handed to the presentation engine, which the validation - // layer reports in either binding model. What is under test here is the - // rendering above. + window.CaptureNextFrame(out); + window.Render(); const std::uint32_t renderErrors = Device::validationErrorCount; Check(renderErrors == 0, - std::format("no Vulkan validation errors while rendering ({} seen)", renderErrors)); - - window.SaveFrame(out); + std::format("no Vulkan validation errors while rendering and capturing ({} seen)", renderErrors)); std::ofstream(fs::path(out).concat(".mode")) << (Device::descriptorHeapSupported ? "heap" : "sets"); Check(fs::exists(out), "the frame was written"); diff --git a/tests/UploadInFlight/main.cpp b/tests/UploadInFlight/main.cpp new file mode 100644 index 0000000..7f909fa --- /dev/null +++ b/tests/UploadInFlight/main.cpp @@ -0,0 +1,213 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A re-upload into a buffer the GPU may still be reading lands on the GPU +// timeline, after the reads already submitted, instead of being written from +// the host at once. On ReBAR hardware UploadDeviceLocal (same size) and +// UploadDeviceLocalRange mapped the buffer and wrote it in place while a frame +// in flight could still read it: a mesh refitted every frame had the previous +// frame's BLAS update read this frame's vertices, or half of them. +// +// The test records a re-upload, then, before submitting it, submits and waits +// for a copy of the buffer: a frame in flight that runs after the upload was +// recorded. That copy must still see the old contents, and a copy recorded +// after the upload the new ones. Both upload calls are checked, and two +// ranged uploads to one buffer in one frame, which share that frame's staging. +// +// An empty re-upload records nothing (a copy or barrier of no bytes is +// invalid usage). And without a window no frame passes, so nothing ever +// reclaimed the staging: each re-upload added to it. DrainDeletions, after +// waiting for the queue, now lets the staging start over. +// +// Needs a Vulkan device and no window: the test steps Device::frameCounter +// itself. Run with CRAFTER_GRAPHICS_VALIDATION=1 and +// VK_KHRONOS_VALIDATION_VALIDATE_SYNC=1 to check the uploads' barriers too. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +constexpr std::uint32_t kCount = 256; +constexpr VkBufferUsageFlags2 kUsage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT + | VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + +VkCommandBuffer BeginCmd() { + const VkCommandBufferAllocateInfo allocation { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocation, &cmd)); + const VkCommandBufferBeginInfo begin { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &begin)); + return cmd; +} + +void Submit(VkCommandBuffer cmd) { + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + const VkSubmitInfo submit { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &cmd }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submit, VK_NULL_HANDLE)); +} + +// Copies `source` into the host-visible `out`, after the uploads' consumers +// (the transfer stage here) and readable by the host once the queue is idle. +void CopyOut(VkCommandBuffer cmd, VulkanBuffer& source, VulkanBuffer& out) { + const VkBufferCopy region { .srcOffset = 0, .dstOffset = 0, .size = kCount * sizeof(std::uint32_t) }; + vkCmdCopyBuffer(cmd, source.buffer, out.buffer, 1, ®ion); + const VkBufferMemoryBarrier toHost { + .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_HOST_READ_BIT, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .buffer = out.buffer, + .offset = 0, + .size = VK_WHOLE_SIZE, + }; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &toHost, 0, nullptr); +} + +std::vector Contents(VulkanBuffer& out) { + out.FlushHost(); + return std::vector(out.value, out.value + kCount); +} + +std::vector Filled(std::uint32_t base) { + std::vector values(kCount); + for (std::uint32_t i = 0; i < kCount; ++i) values[i] = base + i; + return values; +} + +// One round: upload `before`, record a re-upload with `reupload`, and run a +// copy of the buffer before that re-upload's submission. +void Round(std::string_view name, const std::function&, VkCommandBuffer)>& reupload, + const std::vector& after) { + const std::vector before = Filled(1000); + VulkanBuffer data; + VulkanBuffer seenInFlight; + VulkanBuffer seenAfter; + seenInFlight.Create(VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, kCount); + seenAfter.Create(VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, kCount); + + VkCommandBuffer setup = BeginCmd(); + data.UploadDeviceLocal(kUsage, before.data(), kCount, setup, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + Submit(setup); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + ++Device::frameCounter; + + // The next frame records its re-upload... + VkCommandBuffer next = BeginCmd(); + reupload(data, next); + CopyOut(next, data, seenAfter); + + // ...while a frame submitted before it still reads the buffer. + VkCommandBuffer inFlight = BeginCmd(); + CopyOut(inFlight, data, seenInFlight); + Submit(inFlight); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + Submit(next); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + Check(Contents(seenInFlight) == before, std::format("{}: a frame submitted before the upload still reads the old contents", name)); + Check(Contents(seenAfter) == after, std::format("{}: a read recorded after the upload sees the new contents", name)); + + data.Clear(); + seenInFlight.Clear(); + seenAfter.Clear(); + Device::DrainDeletions(); +} + +} // namespace + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + Device::framesInFlight = Window::numFrames; + Device::frameCounter = 0; + + const std::vector fresh = Filled(5000); + Round("UploadDeviceLocal of the same size", [&](VulkanBuffer& data, VkCommandBuffer cmd) { + data.UploadDeviceLocal(kUsage, fresh.data(), kCount, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + }, fresh); + + Round("UploadDeviceLocalRange", [&](VulkanBuffer& data, VkCommandBuffer cmd) { + data.UploadDeviceLocalRange(fresh.data(), 0, kCount, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + }, fresh); + + // Two ranged uploads in one frame: the second must not overwrite the + // first's staged bytes before the first copy has run. + std::vector halves = Filled(1000); + for (std::uint32_t i = 0; i < kCount / 2; ++i) halves[i] = 7000 + i; + for (std::uint32_t i = kCount / 2; i < kCount; ++i) halves[i] = 9000 + i; + Round("two UploadDeviceLocalRange calls in one frame", [&](VulkanBuffer& data, VkCommandBuffer cmd) { + data.UploadDeviceLocalRange(halves.data(), 0, kCount / 2, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + data.UploadDeviceLocalRange(halves.data() + kCount / 2, kCount / 2, kCount / 2, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + }, halves); + + { + VulkanBuffer data; + VkCommandBuffer cmd = BeginCmd(); + data.UploadDeviceLocal(kUsage, fresh.data(), kCount, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + Submit(cmd); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + ++Device::frameCounter; + const std::uint32_t errorsBefore = Device::validationErrorCount; + cmd = BeginCmd(); + data.UploadDeviceLocal(kUsage, fresh.data(), 0, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + data.UploadDeviceLocalRange(fresh.data(), 0, 0, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + Submit(cmd); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + Check(Device::validationErrorCount == errorsBefore, "an empty re-upload records no copy or barrier (validation stays quiet)"); + data.Clear(); + Device::DrainDeletions(); + } + + { + // As before any window: no frames in flight, and the frame count + // stands still. + Device::framesInFlight = 0; + VulkanBuffer data; + VkCommandBuffer cmd = BeginCmd(); + data.UploadDeviceLocal(kUsage, fresh.data(), kCount, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + Submit(cmd); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + for (int round = 0; round < 8; ++round) { + cmd = BeginCmd(); + data.UploadDeviceLocalRange(fresh.data(), 0, kCount, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + Submit(cmd); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + Device::DrainDeletions(); + } + const VkDeviceSize staged = data.stagingRing.empty() ? 0 : data.stagingRing[0].capacity; + Check(staged <= kCount * sizeof(std::uint32_t), + std::format("without a window, re-uploads between drains hold one upload's staging ({} bytes for {})", staged, kCount * sizeof(std::uint32_t))); + data.Clear(); + Device::DrainDeletions(); + Device::framesInFlight = Window::numFrames; + } + + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors across the run ({} seen)", Device::validationErrorCount)); + } + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/VulkanBufferAddress/main.cpp b/tests/VulkanBufferAddress/main.cpp new file mode 100644 index 0000000..c27b863 --- /dev/null +++ b/tests/VulkanBufferAddress/main.cpp @@ -0,0 +1,73 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// VulkanBuffer::Create asks for a device address only for buffers whose +// usage includes SHADER_DEVICE_ADDRESS. It used to request the +// DEVICE_ADDRESS allocation flag and call vkGetBufferDeviceAddress for every +// buffer, which is invalid usage for the others +// (VUID-VkBufferDeviceAddressInfo-buffer-02601), so every caller had to add +// the usage bit whether it wanted an address or not. A buffer without the bit +// now reports address 0; one with it still gets a real address. A UI +// buffer slot points at the buffer by its address, so RegisterBuffer refuses +// a buffer without one rather than registering address 0. +// +// Needs a Vulkan device; the validation check only means something with +// CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +} // namespace + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + + VulkanBuffer staging; + staging.Create(VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, 16); + Check(staging.address == 0, std::format("a buffer without SHADER_DEVICE_ADDRESS has no address (got {:#x})", staging.address)); + + VulkanBuffer resized; + resized.Resize(VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 16); + resized.Resize(VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 64); + Check(resized.address == 0, "nor after growing through Resize"); + + VulkanBuffer addressable; + addressable.Create(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 16); + Check(addressable.address != 0, "a buffer with SHADER_DEVICE_ADDRESS has one"); + + // Refused before it touches the renderer, so an uninitialised one does. + UIRenderer ui; + bool refused = false; + try { + (void)ui.RegisterBuffer(staging); + } catch (const std::invalid_argument&) { + refused = true; + } + Check(refused, "RegisterBuffer refuses a buffer without a device address"); + + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors ({} seen)", Device::validationErrorCount)); + } + + staging.Clear(); + resized.Clear(); + addressable.Clear(); + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/VulkanShaderLifetime/main.cpp b/tests/VulkanShaderLifetime/main.cpp new file mode 100644 index 0000000..c3c80f2 --- /dev/null +++ b/tests/VulkanShaderLifetime/main.cpp @@ -0,0 +1,121 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A VulkanShader releases its shader module. It used to have no destructor, +// so every module leaked, and ComputeShader::Load destroyed the module by +// hand. The destructor hands the module to the deferred-deletion queue (issue +// #101), like the other owners of Vulkan handles, so a static VulkanShader +// makes no Vulkan call at exit. A VulkanShader can't be copied any more, since +// two copies would both release the module; moves hand it over, which is what +// a growing std::vector needs. A file that can't be read throws, +// naming it: it used to print a line and create a module from no code, which +// surfaced later as an unrelated driver error. +// +// Needs a Vulkan device and no window: the test steps Device::frameCounter +// itself. The validation check only means something with +// CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; + +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +fs::path ShaderPath() { + return fs::read_symlink("/proc/self/exe").parent_path() / "noop.comp.spv"; +} + +std::size_t QueuedCount(VkShaderModule module) { + return static_cast(std::ranges::count_if(Device::deletionQueue, + [&](const Device::PendingDeletion& e) { return e.shaderModule == module; })); +} + +} // namespace + +static_assert(!std::is_copy_constructible_v && !std::is_copy_assignable_v, + "two copies of a VulkanShader would both release its module"); + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + Device::framesInFlight = Window::numFrames; + Device::frameCounter = 0; + Device::DrainDeletions(); + + // ── Destroying a VulkanShader queues its module ────────────────────────── + VkShaderModule module = VK_NULL_HANDLE; + { + VulkanShader shader(ShaderPath(), "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr); + module = shader.shader; + Check(module != VK_NULL_HANDLE, "the shader module was created"); + Check(Device::deletionQueue.empty(), "a live VulkanShader has queued nothing"); + } + Check(QueuedCount(module) == 1, "destroying a VulkanShader queues its module"); + Device::frameCounter = Window::numFrames; + Device::ReclaimDeletions(); + Check(Device::deletionQueue.empty(), "the module is freed on its retire frame"); + + // ── Moving hands the module over ───────────────────────────────────────── + { + VulkanShader source(ShaderPath(), "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr); + module = source.shader; + VulkanShader moved(std::move(source)); + Check(source.shader == VK_NULL_HANDLE && moved.shader == module, "a move constructor hands the module over"); + + VulkanShader target(ShaderPath(), "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr); + const VkShaderModule replaced = target.shader; + target = std::move(moved); + Check(moved.shader == VK_NULL_HANDLE && target.shader == module, "a move assignment hands the module over"); + Check(QueuedCount(replaced) == 1, "and queues the module it replaced"); + Check(QueuedCount(module) == 0, "the module moved around is not queued while owned"); + } + Check(QueuedCount(module) == 1, "the module is queued once, by its last owner"); + Device::DrainDeletions(); + + // ── A growing std::vector moves its elements ─────────────── + { + std::vector modules; + { + std::vector shaders; + for (int i = 0; i < 5; ++i) { + shaders.emplace_back(ShaderPath(), "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr); + } + for (const VulkanShader& shader : shaders) modules.push_back(shader.shader); + Check(Device::deletionQueue.empty(), "growing the vector queued nothing"); + } + Check(std::ranges::all_of(modules, [](VkShaderModule m) { return QueuedCount(m) == 1; }), + "each module in the vector is queued exactly once when the vector goes"); + Device::DrainDeletions(); + } + + // ── A file that isn't there throws, naming it ───────────────────────────── + { + std::string message; + try { + VulkanShader missing(ShaderPath().parent_path() / "missing.spv", "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr); + } catch (const std::runtime_error& e) { + message = e.what(); + } + Check(message.find("missing.spv") != std::string::npos, + std::format("a shader file that isn't there throws, naming it ({})", message.empty() ? "no exception" : message)); + } + + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors across the run ({} seen)", Device::validationErrorCount)); + } + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/VulkanShaderLifetime/noop.comp.glsl b/tests/VulkanShaderLifetime/noop.comp.glsl new file mode 100644 index 0000000..fbe74f1 --- /dev/null +++ b/tests/VulkanShaderLifetime/noop.comp.glsl @@ -0,0 +1,7 @@ +#version 460 + +// A module to load and release; it never runs. +layout(local_size_x = 1) in; + +void main() { +} diff --git a/tests/WindowUpdateClock/main.cpp b/tests/WindowUpdateClock/main.cpp new file mode 100644 index 0000000..e427ab8 --- /dev/null +++ b/tests/WindowUpdateClock/main.cpp @@ -0,0 +1,137 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A window can update on its own clock (Window::updateInterval) instead of +// the compositor's frame callbacks, which stop while the window is hidden or +// minimized, so an app whose output goes somewhere other than the screen +// keeps rendering. The event loop also wakes for file descriptors the app +// registers (Window::AddPollFd), such as an eventfd another thread signals, +// and SetPresentMode picks MAILBOX so presenting never waits on the display. +// +// The test presents with MAILBOX, updates every 20 ms, and has a thread +// signal an eventfd every 50 ms. Half a second in, it minimizes its own +// window, and then counts the updates and wakes for a second. Before, the +// Wayland loop only updated from frame callbacks, which KWin stops for a +// minimized window, and blocked in wl_display_dispatch, so it never saw +// another descriptor. +// +// Needs a live Wayland compositor that honours set_minimized, and a Vulkan +// device. The validation check only means something with +// CRAFTER_GRAPHICS_VALIDATION set. + +#include +#include +#include +#include +#include "vulkan/vulkan.h" +#include "../../lib/xdg-shell-client-protocol.h" + +import Crafter.Graphics; +import Crafter.Event; +import std; + +using namespace Crafter; +using namespace std::chrono_literals; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +} // namespace + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + + Window window(320, 240, "WindowUpdateClock test"); + window.SetPresentMode(PresentMode::Mailbox); + std::uint32_t modeCount = 0; + Device::CheckVkResult(vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, window.vulkanSurface, &modeCount, nullptr)); + std::vector modes(modeCount); + Device::CheckVkResult(vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, window.vulkanSurface, &modeCount, modes.data())); + const bool mailboxOffered = std::ranges::find(modes, VK_PRESENT_MODE_MAILBOX_KHR) != modes.end(); + Check(window.swapchainPresentMode == (mailboxOffered ? VK_PRESENT_MODE_MAILBOX_KHR : VK_PRESENT_MODE_FIFO_KHR), + std::format("SetPresentMode(Mailbox) gives MAILBOX where the surface offers it, else FIFO ({})", mailboxOffered ? "offered" : "not offered")); + window.StartInit(); + window.FinishInit(); + + // A thread signals an eventfd every 50 ms; the loop must wake for it. + const int wake = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + std::atomic stopSignalling = false; + std::thread signaller([&] { + while (!stopSignalling) { + const std::uint64_t one = 1; + (void)write(wake, &one, sizeof(one)); + std::this_thread::sleep_for(50ms); + } + }); + std::uint32_t wakes = 0; + window.AddPollFd(wake, [&] { + std::uint64_t count = 0; + (void)read(wake, &count, sizeof(count)); + ++wakes; + }); + + // If an update ever blocks (a present waiting on a hidden surface), fail + // instead of hanging until the runner's timeout. + std::atomic finished = false; + std::thread watchdog([&] { + const auto deadline = std::chrono::steady_clock::now() + 15s; + while (!finished) { + if (std::chrono::steady_clock::now() > deadline) { + std::println("FAIL the event loop stopped making progress"); + // _Exit skips the flush that would print the line. + std::fflush(stdout); + std::_Exit(EXIT_FAILURE); + } + std::this_thread::sleep_for(50ms); + } + }); + + const auto start = std::chrono::steady_clock::now(); + bool minimized = false; + std::uint32_t updatesVisible = 0; + std::uint32_t updatesMinimized = 0; + std::uint32_t wakesAtMinimize = 0; + EventListener onUpdate(&window.onUpdate, [&](FrameTime) { + const auto elapsed = std::chrono::steady_clock::now() - start; + if (!minimized && elapsed >= 500ms) { + xdg_toplevel_set_minimized(window.xdgToplevel); + minimized = true; + wakesAtMinimize = wakes; + } else if (minimized && elapsed >= 1500ms) { + window.open = false; + } + (minimized ? updatesMinimized : updatesVisible)++; + }); + + window.updateInterval = 20ms; + window.StartUpdate(); + window.StartSync(); + finished = true; + watchdog.join(); + stopSignalling = true; + signaller.join(); + window.RemovePollFd(wake); + close(wake); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + // 20 ms apart: 25 updates in the visible half second, 50 in the minimized + // second. Leave room for a loaded machine, not for a stopped clock. + std::println(" {} updates visible, {} minimized, {} wakes ({} while minimized)", updatesVisible, updatesMinimized, wakes, wakes - wakesAtMinimize); + Check(updatesVisible >= 18 && updatesVisible <= 27, "the clock updates every 20 ms while the window is visible"); + Check(updatesMinimized >= 40 && updatesMinimized <= 52, "and keeps updating every 20 ms while it is minimized"); + Check(wakes - wakesAtMinimize >= 14, "the eventfd wakes the loop every 50 ms, minimized too"); + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors ({} seen)", Device::validationErrorCount)); + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}