Window - updateInterval runs Update on the window's own clock while the compositor sends no frame callbacks (hidden, minimized): the Wayland loop is a poll() loop, and the Win32 loop sleeps until the next update. Both read the interval each pass. - SetPresentMode (FIFO, Mailbox, Immediate). A mode the surface lacks, or whose swapchain has other than numFrames images (Mesa's Mailbox has four), falls back to FIFO until the mode is set again. - AddPollFd / RemovePollFd wake the Wayland loop on the app's descriptors. - The swapchain has TRANSFER_DST where the surface allows it. - CaptureNextFrame replaces SaveFrame: it records the copy in the frame, before the present, where SaveFrame read an image already presented. A frame unwound past before the write still frees the readback buffer. - Inter-pass barriers on the swapchain image carry the access masks of the stages the passes use. Deferred deletion (issue #101) - VulkanBuffer, Mesh, VulkanShader, ComputeShader and PipelineRTVulkan hand their handles to the deletion queue from their destructors and moves, as a frame in flight may still use them; a static owner goes after the driver has torn down. VulkanShader and PipelineRTVulkan move and can't be copied; re-Load and re-Init queue the old pipeline. - The queue and its mutex are never destroyed, and the queue takes handles from any thread; Device::frameCounter is atomic. Both the queue and the staging rings count frames as one window renders them (documented). - DrainDeletions also lets every buffer's staging ring start over: without a Window nothing else reclaims either, so an app without one calls it after waiting for its work. Meshes and buffers - A Mesh rebuild queues the old acceleration structure and builds into the same storage after a barrier, so the BLAS address stays put when it fits. The refit and rebuild barriers cover compute ray queries too. - RTBuildOptions::noDuplicateAnyHit. Mesh::geometryFlags replaces opaque, and the refit and rebuild fallbacks keep the flags. - A re-upload into a buffer a frame in flight may read (UploadDeviceLocal of the same size, UploadDeviceLocalRange) copies on the GPU, after those reads, through a per-frame staging ring; on ReBAR it used to write the buffer in place. Empty uploads record nothing. - A compressed Mesh::Build that keeps its buffers decompresses into them after the reads of frames in flight, and the hit shaders and ray queries wait for the decompress as well as the BLAS build. Its vertex buffer takes a ranged refit's copies. - Mesh move-assigns (queueing the BLAS it replaces), so it can be replaced in place or erased from a vector. - VulkanBuffer::Create asks for a device address only for buffers with SHADER_DEVICE_ADDRESS usage, as vkGetBufferDeviceAddress requires; the others have address 0. UIRenderer::RegisterBuffer refuses those. - Build inputs are synchronized as shader reads. Other - RTPass pushes the app's own push data and traces at an explicit size. - DescriptorHeapVulkan hands freed slot ranges out again (they leaked), and keeps its free lists sorted. - ImageVulkan's mip chain clamps each level to 1 texel: a non-square image's shorter side went to 0. - VulkanShader throws on a missing file instead of failing in bad_alloc. - CRAFTER_GRAPHICS_DEVICE picks the GPU by its number among the distinct GPUs Vulkan lists; anything but such a number is an error. - The debug messenger prints info messages only with CRAFTER_GRAPHICS_VALIDATION set. - Windows: the WinRT gamepad code moved to a C file (mingw-w64's WinRT headers don't compile as C++), -lcombase dropped, a reference leak fixed. - project.cpp honours --local for Crafter.Event, Math and Asset, as Asset does for Math, and builds every test through one helper, projectArgs included. - Tests: DebugMessengerSeverity, DeletionQueueThreads, DeviceSelection, HeapSlotReuse, MeshLifetime, MipChainNonSquare, PipelineLifetime, RTPassPushData, SwapchainTransferDst, UploadInFlight, VulkanBufferAddress, VulkanShaderLifetime, WindowUpdateClock; others extended. Breaking: SaveFrame is CaptureNextFrame; Mesh::opaque is geometryFlags; a buffer without SHADER_DEVICE_ADDRESS usage has no address; frameCounter is std::atomic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
13 KiB
C++
299 lines
13 KiB
C++
//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<Mesh> 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 <cstdlib>
|
|
#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<Vector<float, 3, 3>> 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<std::uint32_t> 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<Vector<float, 3, 3>>& verts, std::vector<std::uint32_t>& indices) {
|
|
const std::vector<Vector<float, 3, 3>> cube = CubeVerts(0.5f);
|
|
const std::vector<std::uint32_t> cubeIndices = CubeIndices();
|
|
verts.clear();
|
|
indices.clear();
|
|
for (int i = 0; i < count; ++i) {
|
|
const std::uint32_t base = static_cast<std::uint32_t>(verts.size());
|
|
for (const Vector<float, 3, 3>& v : cube) verts.push_back({v.x + 2.0f * static_cast<float>(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<std::size_t>(it - Device::deletionQueue.begin());
|
|
}
|
|
|
|
std::size_t QueuedCount(VkAccelerationStructureKHR handle) {
|
|
return static_cast<std::size_t>(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<Vector<float, 3, 3>> verts = CubeVerts(1.0f);
|
|
std::vector<std::uint32_t> 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<Vector<float, 3, 3>> manyVerts;
|
|
std::vector<std::uint32_t> 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<Mesh> 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<Mesh> moves its elements without queueing ──────
|
|
{
|
|
std::vector<VkAccelerationStructureKHR> handles;
|
|
{
|
|
std::vector<Mesh> 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;
|
|
}
|