feat: windows on their own clock, deferred deletion for every handle owner, staged re-uploads, and RTPass push data

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>
This commit is contained in:
Jorijn van der Graaf 2026-10-20 19:07:54 +02:00
commit 079b6b0e1f
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
47 changed files with 3572 additions and 1430 deletions

View file

@ -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<RTAabb, 1> 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,

View file

@ -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 <cstdio>
#include <cstdlib>
#include <unistd.h>
#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<char>(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;
}

View file

@ -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<float, false> a;
VulkanBuffer<float, true> 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();

View file

@ -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 <cstdlib>
#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<int> dropping = kThreads;
std::vector<std::thread> makers;
for (int t = 0; t < kThreads; ++t) {
makers.emplace_back([&] {
std::vector<VulkanBuffer<std::uint32_t, false>> buffers(kBuffers);
for (VulkanBuffer<std::uint32_t, false>& 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;
}

View file

@ -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 <cstdlib>
#include <sys/wait.h>
#include <unistd.h>
#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<VkPhysicalDevice> devices(count);
vkEnumeratePhysicalDevices(Device::instance, &count, devices.data());
// The distinct GPUs, by device UUID, in the loader's order.
std::vector<std::array<std::uint8_t, VK_UUID_SIZE>> 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<std::uint8_t, VK_UUID_SIZE> 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;
}

View file

@ -27,7 +27,7 @@
#include <cstdlib>
// 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");

View file

@ -25,7 +25,7 @@
#include <cstdlib>
// 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");

View file

@ -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 <cstdlib>
#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 <typename Allocate, typename Free>
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<std::uint16_t>(again.firstElement + 2), 1 });
fresh.FreeImageSlots({ again.firstElement, 1 });
fresh.FreeImageSlots({ static_cast<std::uint16_t>(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;
}

View file

@ -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,

View file

@ -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<Vector<float, 3, 3>> moved(srcMesh.vertexes.begin(), srcMesh.vertexes.end());
std::vector<std::uint32_t> 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

299
tests/MeshLifetime/main.cpp Normal file
View file

@ -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<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;
}

View file

@ -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 <cstdlib>
#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<std::uint32_t>(1u, static_cast<std::uint32_t>(size) >> level);
}
void CheckChain(std::uint16_t width, std::uint16_t height) {
ImageVulkan<Texel> 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<Texel, true> 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<std::size_t>(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<VkBufferImageCopy, kLevels> regions {};
std::uint32_t offset = 0;
for (std::uint32_t level = 0; level < kLevels; ++level) {
regions[level] = {
.bufferOffset = static_cast<VkDeviceSize>(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<std::uint32_t> 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;
}

View file

@ -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() {
}

View file

@ -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 <cstdlib>
#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::size_t>(std::ranges::count_if(Device::deletionQueue,
[&](const Device::PendingDeletion& e) { return e.pipeline == pipeline; }));
}
std::size_t QueuedCount(VkBuffer buffer) {
return static_cast<std::size_t>(std::ranges::count_if(Device::deletionQueue,
[&](const Device::PendingDeletion& e) { return e.buffer == buffer; }));
}
std::size_t QueuedPipelines() {
return static_cast<std::size_t>(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<void(VkCommandBuffer)>& 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<ComputeShader> && !std::is_copy_assignable_v<ComputeShader>,
"two copies of a ComputeShader would both release its pipeline");
static_assert(!std::is_copy_constructible_v<PipelineRTVulkan> && !std::is_copy_assignable_v<PipelineRTVulkan>,
"two copies of a PipelineRTVulkan would both release its pipeline");
static_assert(std::is_nothrow_move_constructible_v<PipelineRTVulkan> && std::is_move_assignable_v<PipelineRTVulkan>,
"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<VulkanShader, 1> shaders {{ { ExeDir() / "lifetime-raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, nullptr } }};
ShaderBindingTableVulkan table;
table.Init(shaders);
std::array<VkRayTracingShaderGroupCreateInfoKHR, 1> 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;
}

View file

@ -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 <cstdlib>
#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<VulkanShader, 3> 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<VkRayTracingShaderGroupCreateInfoKHR, 1> 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<VkRayTracingShaderGroupCreateInfoKHR, 1> 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<VkRayTracingShaderGroupCreateInfoKHR, 1> 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<Vector<float, 3, 3>, 3> verts {{{-150, -150, 100}, {0, 150, 100}, {150, -150, 100}}};
std::array<std::uint32_t, 3> 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<float, 4, 3, 1>::Identity().Store(reinterpret_cast<float*>(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<VkDeviceAddressRangeKHR, Window::numFrames> tlasRanges{};
std::array<VkImageDescriptorInfoEXT, Window::numFrames> imageInfos{};
std::vector<VkResourceDescriptorInfoEXT> resources;
std::vector<VkHostAddressRangeEXT> 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<std::uint32_t>(resources.size()), resources.data(), destinations.data());
for (auto& heap : descriptorHeap.resourceHeap) heap.FlushDevice();
};
writeDescriptors();
EventListener<void> 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<float>(i) / static_cast<float>(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<std::size_t>(y) * width + x) * 4;
return std::array<int, 3>{p[0], p[1], p[2]};
};
auto near = [](std::array<int, 3> got, std::array<int, 3> 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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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));
}

View file

@ -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

View file

@ -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 <cstdlib>
#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<std::size_t>(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;
}

View file

@ -46,7 +46,7 @@
#include <cstdlib>
// 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");

View file

@ -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 <cstdlib>
#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<std::uint32_t, false>& source, VulkanBuffer<std::uint32_t, true>& out) {
const VkBufferCopy region { .srcOffset = 0, .dstOffset = 0, .size = kCount * sizeof(std::uint32_t) };
vkCmdCopyBuffer(cmd, source.buffer, out.buffer, 1, &region);
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<std::uint32_t> Contents(VulkanBuffer<std::uint32_t, true>& out) {
out.FlushHost();
return std::vector<std::uint32_t>(out.value, out.value + kCount);
}
std::vector<std::uint32_t> Filled(std::uint32_t base) {
std::vector<std::uint32_t> 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<void(VulkanBuffer<std::uint32_t, false>&, VkCommandBuffer)>& reupload,
const std::vector<std::uint32_t>& after) {
const std::vector<std::uint32_t> before = Filled(1000);
VulkanBuffer<std::uint32_t, false> data;
VulkanBuffer<std::uint32_t, true> seenInFlight;
VulkanBuffer<std::uint32_t, true> 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<std::uint32_t> fresh = Filled(5000);
Round("UploadDeviceLocal of the same size", [&](VulkanBuffer<std::uint32_t, false>& data, VkCommandBuffer cmd) {
data.UploadDeviceLocal(kUsage, fresh.data(), kCount, cmd, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
}, fresh);
Round("UploadDeviceLocalRange", [&](VulkanBuffer<std::uint32_t, false>& 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<std::uint32_t> 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<std::uint32_t, false>& 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<std::uint32_t, false> 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<std::uint32_t, false> 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;
}

View file

@ -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 <cstdlib>
#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<std::uint32_t, true> 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<std::uint32_t, false> 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<std::uint32_t, false> 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;
}

View file

@ -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<VulkanShader> 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 <cstdlib>
#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::size_t>(std::ranges::count_if(Device::deletionQueue,
[&](const Device::PendingDeletion& e) { return e.shaderModule == module; }));
}
} // namespace
static_assert(!std::is_copy_constructible_v<VulkanShader> && !std::is_copy_assignable_v<VulkanShader>,
"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<VulkanShader> moves its elements ───────────────
{
std::vector<VkShaderModule> modules;
{
std::vector<VulkanShader> 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;
}

View file

@ -0,0 +1,7 @@
#version 460
// A module to load and release; it never runs.
layout(local_size_x = 1) in;
void main() {
}

View file

@ -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 <cstdio>
#include <cstdlib>
#include <sys/eventfd.h>
#include <unistd.h>
#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<VkPresentModeKHR> 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<bool> 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<bool> 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<FrameTime> 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;
}