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

@ -130,11 +130,12 @@ int main() {
aabbDevice.address, kBoxCount, 0.0f };
aabbWriter.Dispatch(cmd, &push, sizeof(push), (kBoxCount + 63) / 64);
// Order the producing writes before the BLAS build reads the buffer.
// Order the producing writes before the BLAS build reads the buffer. A
// build reads its geometry as a shader read.
VkMemoryBarrier aabbBarrier {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,

View file

@ -22,9 +22,8 @@ ComputeShader::ComputeShader(ComputeShader&& other) noexcept
ComputeShader& ComputeShader::operator=(ComputeShader&& other) noexcept {
if (this != &other) {
if (pipeline != VK_NULL_HANDLE) {
vkDestroyPipeline(Device::device, pipeline, nullptr);
}
// A frame in flight may still dispatch the pipeline this replaces.
Device::EnqueueDeletion(pipeline);
pipeline = other.pipeline;
layout = other.layout;
workaroundNeedsTlas = other.workaroundNeedsTlas;
@ -35,10 +34,9 @@ ComputeShader& ComputeShader::operator=(ComputeShader&& other) noexcept {
}
ComputeShader::~ComputeShader() {
if (pipeline != VK_NULL_HANDLE) {
vkDestroyPipeline(Device::device, pipeline, nullptr);
pipeline = VK_NULL_HANDLE;
}
// Deferred, like the other owners of Vulkan handles: a frame in flight
// may still dispatch it, and a static one goes after the driver.
Device::EnqueueDeletion(pipeline);
}
void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVulkan& heap) {
@ -48,10 +46,9 @@ void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVul
// descriptor-set build compiled from the same source instead.
std::filesystem::path setsPath = spvPath;
setsPath.replace_extension("sets.spv");
// VulkanShader only prints when a file is missing and then creates an
// empty module, which surfaces as an unrelated driver error. A missing
// variant is the likely mistake when a bundle is copied to a device
// without the heap, so name it.
// VulkanShader would throw naming only the file. A missing variant is
// the likely mistake when a bundle is copied to a device without the
// heap, so say how to make it.
if (!std::filesystem::exists(setsPath)) {
throw std::runtime_error(std::format(
"ComputeShader::Load: this device has no VK_EXT_descriptor_heap, so the descriptor-set build "
@ -74,12 +71,12 @@ void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVul
},
.layout = layout,
};
const VkResult created = vkCreateComputePipelines(
Device::device, Device::pipelineCache, 1, &info, nullptr, &pipeline);
// The module is only an input to pipeline creation; VulkanShader does
// not destroy it, so it is released here either way.
vkDestroyShaderModule(Device::device, shader.shader, nullptr);
Device::CheckVkResult(created);
VkPipeline created = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateComputePipelines(
Device::device, Device::pipelineCache, 1, &info, nullptr, &created));
// Loading again replaces the pipeline, which a frame in flight may
// still dispatch.
Device::EnqueueDeletion(std::exchange(pipeline, created));
return;
}
@ -110,10 +107,10 @@ void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVul
},
.layout = VK_NULL_HANDLE,
};
const VkResult created = vkCreateComputePipelines(
Device::device, Device::pipelineCache, 1, &info, nullptr, &pipeline);
vkDestroyShaderModule(Device::device, shader.shader, nullptr);
Device::CheckVkResult(created);
VkPipeline created = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateComputePipelines(
Device::device, Device::pipelineCache, 1, &info, nullptr, &created));
Device::EnqueueDeletion(std::exchange(pipeline, created));
}
void ComputeShader::Dispatch(VkCommandBuffer cmd,

View file

@ -871,7 +871,11 @@ void Device::Initialize() {
VkDebugUtilsMessengerCreateInfoEXT debugUtilsMessengerCreateInfo = {};
debugUtilsMessengerCreateInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
debugUtilsMessengerCreateInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
// Info messages (the loader reports its device sorting, the layers their
// settings) are for whoever is debugging with validation on; without it
// they would start every application's output.
debugUtilsMessengerCreateInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT
| (enableValidation ? VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT : 0);
debugUtilsMessengerCreateInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
debugUtilsMessengerCreateInfo.pfnUserCallback = onError;
CheckVkResult(GET_EXTENSION_FUNCTION(vkCreateDebugUtilsMessengerEXT)(instance, &debugUtilsMessengerCreateInfo, NULL, &debugMessenger));
@ -884,7 +888,37 @@ void Device::Initialize() {
uint32_t bestScore = 0;
for (uint32_t i = 0; i < physDeviceCount; i++)
// CRAFTER_GRAPHICS_DEVICE picks a GPU by its number among the distinct
// ones in this list: with two of the same GPU nothing else tells them
// apart, and the first may be too busy to use. A GPU two driver manifests
// both list (a Flatpak sees the runtime's and the GL extension's) counts
// once, so the number means the same GPU inside a sandbox and out.
const char* chosenDevice = std::getenv("CRAFTER_GRAPHICS_DEVICE");
if (chosenDevice != nullptr) {
std::vector<VkPhysicalDevice> distinct;
std::vector<std::array<std::uint8_t, VK_UUID_SIZE>> seen;
for (VkPhysicalDevice device : physDevices) {
VkPhysicalDeviceIDProperties id { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES };
VkPhysicalDeviceProperties2 properties { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &id };
vkGetPhysicalDeviceProperties2(device, &properties);
std::array<std::uint8_t, VK_UUID_SIZE> uuid;
std::ranges::copy(id.deviceUUID, uuid.begin());
if (std::ranges::find(seen, uuid) == seen.end()) {
seen.push_back(uuid);
distinct.push_back(device);
}
}
const std::string_view text = chosenDevice;
std::size_t index = 0;
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), index);
if (error != std::errc() || end != text.data() + text.size() || index >= distinct.size()) {
throw std::runtime_error(std::format("CRAFTER_GRAPHICS_DEVICE={} isn't the number of a GPU: Vulkan lists {} GPU(s), 0 to {}",
chosenDevice, distinct.size(), distinct.size() - 1));
}
physDevice = distinct[index];
}
for (uint32_t i = 0; chosenDevice == nullptr && i < physDeviceCount; i++)
{
VkPhysicalDevice device = physDevices[i];
@ -1408,10 +1442,45 @@ void Device::EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory) {
// Nothing to free for an already-null handle — avoids parking dead
// entries that ReclaimDeletions would have to skip over.
if (buffer == VK_NULL_HANDLE) return;
deletionQueue.push_back({ frameCounter, buffer, memory });
std::lock_guard lock(deletionMutex);
deletionQueue.push_back({ .retireAfter = frameCounter, .buffer = buffer, .memory = memory });
}
void Device::EnqueueDeletion(VkAccelerationStructureKHR accelerationStructure) {
if (accelerationStructure == VK_NULL_HANDLE) return;
std::lock_guard lock(deletionMutex);
deletionQueue.push_back({ .retireAfter = frameCounter, .accelerationStructure = accelerationStructure });
}
void Device::EnqueueDeletion(VkShaderModule shaderModule) {
if (shaderModule == VK_NULL_HANDLE) return;
std::lock_guard lock(deletionMutex);
deletionQueue.push_back({ .retireAfter = frameCounter, .shaderModule = shaderModule });
}
void Device::EnqueueDeletion(VkPipeline pipeline) {
if (pipeline == VK_NULL_HANDLE) return;
std::lock_guard lock(deletionMutex);
deletionQueue.push_back({ .retireAfter = frameCounter, .pipeline = pipeline });
}
namespace {
// Free whatever one queued entry holds. The destroy and free calls accept
// VK_NULL_HANDLE for the kinds it doesn't hold; the acceleration-structure
// entry point is only loaded with ray tracing, so that one is guarded.
void DestroyPending(const Device::PendingDeletion& entry) {
if (entry.accelerationStructure != VK_NULL_HANDLE) {
Device::vkDestroyAccelerationStructureKHR(Device::device, entry.accelerationStructure, nullptr);
}
vkDestroyShaderModule(Device::device, entry.shaderModule, nullptr);
vkDestroyPipeline(Device::device, entry.pipeline, nullptr);
vkDestroyBuffer(Device::device, entry.buffer, nullptr);
vkFreeMemory(Device::device, entry.memory, nullptr);
}
}
void Device::ReclaimDeletions() {
std::lock_guard lock(deletionMutex);
// Free every entry whose retire frame has been reached. An entry tagged at
// frame F retires at F + framesInFlight: by the time the CPU has begun that
// many later frames (each gated by a per-image fence wait), single-queue
@ -1423,19 +1492,18 @@ void Device::ReclaimDeletions() {
// newer, hence also in flight). O(ready), not O(queue).
while (!deletionQueue.empty() &&
deletionQueue.front().retireAfter + framesInFlight <= frameCounter) {
const PendingDeletion& entry = deletionQueue.front();
vkDestroyBuffer(device, entry.buffer, nullptr);
vkFreeMemory(device, entry.memory, nullptr);
DestroyPending(deletionQueue.front());
deletionQueue.pop_front();
}
}
void Device::DrainDeletions() {
std::lock_guard lock(deletionMutex);
// Unconditional: the caller has just wait-idled, so no in-flight GPU work
// can still reference any queued resource.
for (PendingDeletion& entry : deletionQueue) {
vkDestroyBuffer(device, entry.buffer, nullptr);
vkFreeMemory(device, entry.memory, nullptr);
for (const PendingDeletion& entry : deletionQueue) {
DestroyPending(entry);
}
deletionQueue.clear();
++drains;
}

View file

@ -14,13 +14,7 @@ module;
#include <cstring>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#define COBJMACROS
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <windows.gaming.input.h>
#include <roapi.h>
#include <initguid.h>
#include "Crafter.Graphics-GamepadWin32.h"
#endif
module Crafter.Graphics;
import std;
@ -407,37 +401,15 @@ void Crafter::Gamepad::Rumble(Device& dev, float low, float high,
#endif
// ────────────────────────────────────────────────────────────────────
// Win32 backend (Windows.Gaming.Input via C ABI)
// Win32 backend (Windows.Gaming.Input, through Crafter.Graphics-GamepadWin32.c)
// ────────────────────────────────────────────────────────────────────
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
namespace {
using IGamepad = __x_ABI_CWindows_CGaming_CInput_CIGamepad;
using IGamepadStatics = __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics;
using GamepadAddedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad;
using GamepadRemovedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad;
using GamepadReading = __x_ABI_CWindows_CGaming_CInput_CGamepadReading;
using GamepadVibration = __x_ABI_CWindows_CGaming_CInput_CGamepadVibration;
using GamepadButtons = __x_ABI_CWindows_CGaming_CInput_CGamepadButtons;
// Maps a connected gamepad to its Crafter::Gamepad::Device. The pointer
// is stable for as long as the gamepad stays connected.
std::unordered_map<CrafterWgiGamepad*, Crafter::Gamepad::Device*> g_byGamepad;
IGamepadStatics* g_statics = nullptr;
EventRegistrationToken g_addedToken {};
EventRegistrationToken g_removedToken {};
bool g_initialized = false;
// Cross-thread queue. WGI fires Added/Removed on a thread pool; the
// event loop drains this on the main thread inside Tick().
struct QueueEntry {
bool added;
IGamepad* gamepad; // AddRef'd
};
std::mutex g_queueMutex;
std::vector<QueueEntry> g_queue;
// Maps a live IGamepad* to its Crafter::Gamepad::Device. Pointer is
// stable from WGI's perspective for the lifetime of the connection.
std::unordered_map<IGamepad*, Crafter::Gamepad::Device*> g_byGamepad;
std::uint32_t PointerToId(IGamepad* g) {
std::uint32_t PointerToId(CrafterWgiGamepad* g) {
// Hash the pointer for a stable-while-connected id. (NonRoamableId
// would be more durable across reconnects but adds another COM
// round-trip; pointer hash is fine for v1.)
@ -449,86 +421,29 @@ namespace {
return out == 0 ? 1 : out;
}
// ── Delegate vtable for GamepadAdded / GamepadRemoved ───────────
struct AddedDelegate {
// The first member matches the C ABI vtable layout. Cast yields
// a valid GamepadAddedHandler*.
void* vtbl;
LONG refCount;
bool isAdded;
};
extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl;
extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl;
HRESULT STDMETHODCALLTYPE DelegateQI(GamepadAddedHandler* This, REFIID riid, void** out) {
if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IAgileObject) ||
IsEqualIID(riid, &IID___FIEventHandler_1_Windows__CGaming__CInput__CGamepad))
{
*out = This;
This->lpVtbl->AddRef(This);
return S_OK;
}
*out = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE DelegateAddRef(GamepadAddedHandler* This) {
AddedDelegate* d = reinterpret_cast<AddedDelegate*>(This);
return (ULONG)InterlockedIncrement(&d->refCount);
}
ULONG STDMETHODCALLTYPE DelegateRelease(GamepadAddedHandler* This) {
AddedDelegate* d = reinterpret_cast<AddedDelegate*>(This);
LONG r = InterlockedDecrement(&d->refCount);
return (ULONG)r;
}
HRESULT STDMETHODCALLTYPE DelegateInvoke(GamepadAddedHandler* This, IInspectable* sender, IGamepad* gamepad) {
AddedDelegate* d = reinterpret_cast<AddedDelegate*>(This);
if (gamepad) IGamepad_AddRef(gamepad);
{
std::lock_guard<std::mutex> lock(g_queueMutex);
g_queue.push_back({ d->isAdded, gamepad });
}
return S_OK;
}
GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl = {
DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke
};
GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl = {
DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke
};
AddedDelegate g_addedDelegate { &g_addedVtbl, 1, true };
AddedDelegate g_removedDelegate { &g_removedVtbl, 1, false };
// Translate GamepadReading.Buttons bitfield to our Button enum.
bool ReadingHas(const GamepadReading& r, GamepadButtons b) {
return (r.Buttons & b) != 0;
}
void ApplyReading(Crafter::Gamepad::Device* dev, const GamepadReading& r) {
void ApplyReading(Crafter::Gamepad::Device* dev, const CrafterWgiReading& r) {
using B = Crafter::Gamepad::Button;
using A = Crafter::Gamepad::Axis;
struct Map { B which; GamepadButtons mask; };
struct Map { B which; std::uint32_t mask; };
static const Map buttonMap[] = {
{ B::DPadUp, GamepadButtons_DPadUp },
{ B::DPadDown, GamepadButtons_DPadDown },
{ B::DPadLeft, GamepadButtons_DPadLeft },
{ B::DPadRight, GamepadButtons_DPadRight },
{ B::Start, GamepadButtons_Menu },
{ B::Select, GamepadButtons_View },
{ B::LeftStickClick, GamepadButtons_LeftThumbstick },
{ B::RightStickClick,GamepadButtons_RightThumbstick },
{ B::LeftBumper, GamepadButtons_LeftShoulder },
{ B::RightBumper, GamepadButtons_RightShoulder },
{ B::South, GamepadButtons_A },
{ B::East, GamepadButtons_B },
{ B::West, GamepadButtons_X },
{ B::North, GamepadButtons_Y },
{ B::DPadUp, CRAFTER_WGI_BUTTON_DPAD_UP },
{ B::DPadDown, CRAFTER_WGI_BUTTON_DPAD_DOWN },
{ B::DPadLeft, CRAFTER_WGI_BUTTON_DPAD_LEFT },
{ B::DPadRight, CRAFTER_WGI_BUTTON_DPAD_RIGHT },
{ B::Start, CRAFTER_WGI_BUTTON_MENU },
{ B::Select, CRAFTER_WGI_BUTTON_VIEW },
{ B::LeftStickClick, CRAFTER_WGI_BUTTON_LEFT_THUMBSTICK },
{ B::RightStickClick,CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK },
{ B::LeftBumper, CRAFTER_WGI_BUTTON_LEFT_SHOULDER },
{ B::RightBumper, CRAFTER_WGI_BUTTON_RIGHT_SHOULDER },
{ B::South, CRAFTER_WGI_BUTTON_A },
{ B::East, CRAFTER_WGI_BUTTON_B },
{ B::West, CRAFTER_WGI_BUTTON_X },
{ B::North, CRAFTER_WGI_BUTTON_Y },
};
for (const Map& m : buttonMap) {
bool now = ReadingHas(r, m.mask);
bool now = (r.buttons & m.mask) != 0;
bool& cur = dev->buttons[(std::size_t)m.which];
if (cur == now) continue;
cur = now;
@ -545,12 +460,12 @@ namespace {
cur = v;
dev->onAxisChanged.Invoke(which);
};
axis(A::LeftStickX, (float)r.LeftThumbstickX);
axis(A::LeftStickY, (float)r.LeftThumbstickY);
axis(A::RightStickX, (float)r.RightThumbstickX);
axis(A::RightStickY, (float)r.RightThumbstickY);
axis(A::LeftTrigger, (float)r.LeftTrigger);
axis(A::RightTrigger, (float)r.RightTrigger);
axis(A::LeftStickX, (float)r.leftStickX);
axis(A::LeftStickY, (float)r.leftStickY);
axis(A::RightStickX, (float)r.rightStickX);
axis(A::RightStickY, (float)r.rightStickY);
axis(A::LeftTrigger, (float)r.leftTrigger);
axis(A::RightTrigger, (float)r.rightTrigger);
// Triggers also expose digital "pressed" buttons at a threshold.
constexpr float kTriggerThreshold = 0.1f;
@ -562,81 +477,52 @@ namespace {
if (now) dev->onButtonDown.Invoke(which);
else dev->onButtonUp .Invoke(which);
};
trigBtn(B::LeftTrigger, r.LeftTrigger);
trigBtn(B::RightTrigger, r.RightTrigger);
}
void EnsureInit() {
if (g_initialized) return;
g_initialized = true;
// RoInitialize is required once per thread; ignore RPC_E_CHANGED_MODE
// (some hosts initialize as STA, that's fine for WGI).
RoInitialize(RO_INIT_MULTITHREADED);
HSTRING_HEADER header;
HSTRING className;
const wchar_t* name = RuntimeClass_Windows_Gaming_Input_Gamepad;
if (FAILED(WindowsCreateStringReference(name,
(UINT32)wcslen(name), &header, &className))) return;
if (FAILED(RoGetActivationFactory(className,
&IID___x_ABI_CWindows_CGaming_CInput_CIGamepadStatics,
(void**)&g_statics))) return;
__x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadAdded(
g_statics,
reinterpret_cast<GamepadAddedHandler*>(&g_addedDelegate),
&g_addedToken);
__x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadRemoved(
g_statics,
reinterpret_cast<GamepadAddedHandler*>(&g_removedDelegate),
&g_removedToken);
trigBtn(B::LeftTrigger, r.leftTrigger);
trigBtn(B::RightTrigger, r.rightTrigger);
}
}
void Crafter::Gamepad::Tick() {
EnsureInit();
if (!g_statics) return;
if (!CrafterWgiStart()) return;
// Drain the cross-thread connect/disconnect queue.
std::vector<QueueEntry> pending;
{
std::lock_guard<std::mutex> lock(g_queueMutex);
pending.swap(g_queue);
}
for (QueueEntry& e : pending) {
if (!e.gamepad) continue;
if (e.added) {
if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) {
IGamepad_Release(e.gamepad);
continue;
}
auto dev = std::make_unique<Crafter::Gamepad::Device>();
dev->id = PointerToId(e.gamepad);
dev->name = "Gamepad";
auto* raw = dev.get();
g_byGamepad[e.gamepad] = raw;
connected.push_back(std::move(dev));
onConnected.Invoke(raw);
// keep AddRef'd; released on disconnect
} else {
auto it = g_byGamepad.find(e.gamepad);
if (it != g_byGamepad.end()) {
Crafter::Gamepad::Device* raw = it->second;
onDisconnected.Invoke(raw);
for (auto vi = connected.begin(); vi != connected.end(); ++vi) {
if (vi->get() == raw) { connected.erase(vi); break; }
// The connects and disconnects since the last tick.
std::array<CrafterWgiEvent, 16> events;
for (std::size_t count; (count = CrafterWgiTakeEvents(events.data(), events.size())) != 0;) {
for (const CrafterWgiEvent& e : std::span(events.data(), count)) {
if (e.added) {
if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) {
CrafterWgiRelease(e.gamepad);
continue;
}
g_byGamepad.erase(it);
auto dev = std::make_unique<Crafter::Gamepad::Device>();
dev->id = PointerToId(e.gamepad);
dev->name = "Gamepad";
auto* raw = dev.get();
g_byGamepad[e.gamepad] = raw;
connected.push_back(std::move(dev));
onConnected.Invoke(raw);
// The event's reference is kept until the disconnect.
} else {
auto it = g_byGamepad.find(e.gamepad);
if (it != g_byGamepad.end()) {
Crafter::Gamepad::Device* raw = it->second;
onDisconnected.Invoke(raw);
for (auto vi = connected.begin(); vi != connected.end(); ++vi) {
if (vi->get() == raw) { connected.erase(vi); break; }
}
// The connect's reference, then the disconnect's.
CrafterWgiRelease(it->first);
g_byGamepad.erase(it);
}
CrafterWgiRelease(e.gamepad);
}
IGamepad_Release(e.gamepad);
}
}
// Poll each connected gamepad.
for (auto& [gp, dev] : g_byGamepad) {
GamepadReading reading{};
if (FAILED(__x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading(gp, &reading))) continue;
CrafterWgiReading reading {};
if (!CrafterWgiRead(gp, &reading)) continue;
ApplyReading(dev, reading);
}
}
@ -646,16 +532,13 @@ void Crafter::Gamepad::Rumble(Device& dev, float low, float high,
{
// WGI vibration is "set and forget" — no duration. Caller can stop
// by re-calling with zero magnitudes. We honor 0 duration as stop.
IGamepad* gp = nullptr;
CrafterWgiGamepad* gp = nullptr;
for (auto& [g, d] : g_byGamepad) {
if (d == &dev) { gp = g; break; }
}
if (!gp) return;
auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; };
GamepadVibration vib{};
vib.LeftMotor = clamp01(low);
vib.RightMotor = clamp01(high);
__x_ABI_CWindows_CGaming_CInput_CIGamepad_put_Vibration(gp, vib);
CrafterWgiVibrate(gp, clamp01(low), clamp01(high));
}
#endif

View file

@ -0,0 +1,153 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Windows.Gaming.Input for the Win32 gamepad backend; see
// Crafter.Graphics-GamepadWin32.h for why this part is C.
#define COBJMACROS
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <roapi.h>
#include <winstring.h>
#include <wchar.h>
#include <stdlib.h>
// Defines the interface IDs the headers below declare, in this file.
#include <initguid.h>
#include <windows.gaming.input.h>
#include "Crafter.Graphics-GamepadWin32.h"
typedef __x_ABI_CWindows_CGaming_CInput_CIGamepad IGamepad;
typedef __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics IGamepadStatics;
typedef __FIEventHandler_1_Windows__CGaming__CInput__CGamepad GamepadHandler;
typedef __FIEventHandler_1_Windows__CGaming__CInput__CGamepadVtbl GamepadHandlerVtbl;
_Static_assert(CRAFTER_WGI_BUTTON_MENU == GamepadButtons_Menu && CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK == GamepadButtons_RightThumbstick
&& CRAFTER_WGI_BUTTON_DPAD_UP == GamepadButtons_DPadUp && CRAFTER_WGI_BUTTON_A == GamepadButtons_A,
"the CRAFTER_WGI_BUTTON_ values are GamepadButtons'");
static IGamepadStatics* statics = NULL;
static EventRegistrationToken addedToken;
static EventRegistrationToken removedToken;
static int started = 0;
// The connects and disconnects, which Windows.Gaming.Input reports on its
// own threads, until the event loop takes them.
static SRWLOCK queueLock = SRWLOCK_INIT;
static CrafterWgiEvent* queue = NULL;
static size_t queueCount = 0;
static size_t queueCapacity = 0;
// The GamepadAdded and GamepadRemoved handlers. Static objects, so their
// reference counts don't decide their lifetime.
typedef struct Handler {
const GamepadHandlerVtbl* lpVtbl;
int added;
} Handler;
static HRESULT STDMETHODCALLTYPE HandlerQueryInterface(GamepadHandler* This, REFIID riid, void** out) {
if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IAgileObject)
|| IsEqualIID(riid, &IID___FIEventHandler_1_Windows__CGaming__CInput__CGamepad)) {
*out = This;
return S_OK;
}
*out = NULL;
return E_NOINTERFACE;
}
static ULONG STDMETHODCALLTYPE HandlerAddRef(GamepadHandler* This) {
(void)This;
return 2;
}
static ULONG STDMETHODCALLTYPE HandlerRelease(GamepadHandler* This) {
(void)This;
return 1;
}
static HRESULT STDMETHODCALLTYPE HandlerInvoke(GamepadHandler* This, IInspectable* sender, IGamepad* gamepad) {
(void)sender;
if (gamepad == NULL) return S_OK;
const Handler* handler = (const Handler*)This;
__x_ABI_CWindows_CGaming_CInput_CIGamepad_AddRef(gamepad);
AcquireSRWLockExclusive(&queueLock);
if (queueCount == queueCapacity) {
size_t capacity = queueCapacity == 0 ? 8 : queueCapacity * 2;
CrafterWgiEvent* grown = (CrafterWgiEvent*)realloc(queue, capacity * sizeof(CrafterWgiEvent));
if (grown == NULL) {
ReleaseSRWLockExclusive(&queueLock);
__x_ABI_CWindows_CGaming_CInput_CIGamepad_Release(gamepad);
return E_OUTOFMEMORY;
}
queue = grown;
queueCapacity = capacity;
}
queue[queueCount].added = handler->added;
queue[queueCount].gamepad = (CrafterWgiGamepad*)gamepad;
++queueCount;
ReleaseSRWLockExclusive(&queueLock);
return S_OK;
}
static const GamepadHandlerVtbl handlerVtbl = {
HandlerQueryInterface,
HandlerAddRef,
HandlerRelease,
HandlerInvoke,
};
static Handler addedHandler = { &handlerVtbl, 1 };
static Handler removedHandler = { &handlerVtbl, 0 };
int CrafterWgiStart(void) {
if (started) return statics != NULL;
started = 1;
// Once per thread. RPC_E_CHANGED_MODE (the host made it a single-threaded
// apartment) is fine for Windows.Gaming.Input.
RoInitialize(RO_INIT_MULTITHREADED);
HSTRING_HEADER header;
HSTRING className;
const wchar_t* name = RuntimeClass_Windows_Gaming_Input_Gamepad;
if (FAILED(WindowsCreateStringReference(name, (UINT32)wcslen(name), &header, &className))) return 0;
if (FAILED(RoGetActivationFactory(className, &IID___x_ABI_CWindows_CGaming_CInput_CIGamepadStatics, (void**)&statics))) {
statics = NULL;
return 0;
}
__x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadAdded(statics, (GamepadHandler*)&addedHandler, &addedToken);
__x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadRemoved(statics, (GamepadHandler*)&removedHandler, &removedToken);
return 1;
}
size_t CrafterWgiTakeEvents(CrafterWgiEvent* out, size_t capacity) {
AcquireSRWLockExclusive(&queueLock);
size_t taken = queueCount < capacity ? queueCount : capacity;
for (size_t i = 0; i < taken; ++i) out[i] = queue[i];
for (size_t i = taken; i < queueCount; ++i) queue[i - taken] = queue[i];
queueCount -= taken;
ReleaseSRWLockExclusive(&queueLock);
return taken;
}
int CrafterWgiRead(CrafterWgiGamepad* gamepad, CrafterWgiReading* out) {
__x_ABI_CWindows_CGaming_CInput_CGamepadReading reading;
if (FAILED(__x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading((IGamepad*)gamepad, &reading))) return 0;
out->buttons = (uint32_t)reading.Buttons;
out->leftTrigger = reading.LeftTrigger;
out->rightTrigger = reading.RightTrigger;
out->leftStickX = reading.LeftThumbstickX;
out->leftStickY = reading.LeftThumbstickY;
out->rightStickX = reading.RightThumbstickX;
out->rightStickY = reading.RightThumbstickY;
return 1;
}
void CrafterWgiVibrate(CrafterWgiGamepad* gamepad, double low, double high) {
__x_ABI_CWindows_CGaming_CInput_CGamepadVibration vibration = { 0 };
vibration.LeftMotor = low;
vibration.RightMotor = high;
__x_ABI_CWindows_CGaming_CInput_CIGamepad_put_Vibration((IGamepad*)gamepad, vibration);
}
void CrafterWgiRelease(CrafterWgiGamepad* gamepad) {
__x_ABI_CWindows_CGaming_CInput_CIGamepad_Release((IGamepad*)gamepad);
}

View file

@ -0,0 +1,74 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The C side of the Win32 gamepad backend (Crafter.Graphics-GamepadWin32.c):
// Windows.Gaming.Input, through its C interface. mingw-w64's generated WinRT
// headers declare that interface only to C; in C++ they declare the ABI
// templates instead, and mingw-w64 14's windows.foundation.h doesn't compile
// as C++ (IReference<boolean> and IReference<BYTE> are one type). Gamepad.cpp
// drives it from the event loop's thread; only the connect and disconnect
// notifications arrive on another, and they just queue.
#ifndef CRAFTER_GRAPHICS_GAMEPAD_WIN32_H
#define CRAFTER_GRAPHICS_GAMEPAD_WIN32_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// A connected gamepad: Windows.Gaming.Input's IGamepad.
typedef struct CrafterWgiGamepad CrafterWgiGamepad;
typedef struct CrafterWgiEvent {
int added; // nonzero for a connect, zero for a disconnect
CrafterWgiGamepad* gamepad; // holds a reference, which CrafterWgiRelease drops
} CrafterWgiEvent;
// A GamepadReading. `buttons` holds GamepadButtons bits, named below.
typedef struct CrafterWgiReading {
uint32_t buttons;
double leftTrigger;
double rightTrigger;
double leftStickX;
double leftStickY;
double rightStickX;
double rightStickY;
} CrafterWgiReading;
enum {
CRAFTER_WGI_BUTTON_MENU = 0x1,
CRAFTER_WGI_BUTTON_VIEW = 0x2,
CRAFTER_WGI_BUTTON_A = 0x4,
CRAFTER_WGI_BUTTON_B = 0x8,
CRAFTER_WGI_BUTTON_X = 0x10,
CRAFTER_WGI_BUTTON_Y = 0x20,
CRAFTER_WGI_BUTTON_DPAD_UP = 0x40,
CRAFTER_WGI_BUTTON_DPAD_DOWN = 0x80,
CRAFTER_WGI_BUTTON_DPAD_LEFT = 0x100,
CRAFTER_WGI_BUTTON_DPAD_RIGHT = 0x200,
CRAFTER_WGI_BUTTON_LEFT_SHOULDER = 0x400,
CRAFTER_WGI_BUTTON_RIGHT_SHOULDER = 0x800,
CRAFTER_WGI_BUTTON_LEFT_THUMBSTICK = 0x1000,
CRAFTER_WGI_BUTTON_RIGHT_THUMBSTICK = 0x2000,
};
// Starts listening for gamepads the first time it is called. Nonzero when
// Windows.Gaming.Input is available, every time.
int CrafterWgiStart(void);
// Moves up to `capacity` of the connects and disconnects that came in since
// the last call into `out`, oldest first, and returns how many it moved.
size_t CrafterWgiTakeEvents(CrafterWgiEvent* out, size_t capacity);
// The gamepad's current state; nonzero on success.
int CrafterWgiRead(CrafterWgiGamepad* gamepad, CrafterWgiReading* out);
// Sets the two rumble motors, each 0..1, until set again.
void CrafterWgiVibrate(CrafterWgiGamepad* gamepad, double low, double high);
// Drops the reference an event handed over.
void CrafterWgiRelease(CrafterWgiGamepad* gamepad);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -29,6 +29,20 @@ namespace {
constexpr VkBufferUsageFlags2 kIndexUsageBase =
kVertexUsageBase | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
// Where the geometry buffers are read: the BLAS build, and hit shaders or
// compute ray queries fetching vertices by address. An upload is ordered
// before these reads, and a re-upload after the earlier ones, which frames
// still in flight may be doing. A build reads its inputs as a shader read.
constexpr VkPipelineStageFlags kGeometryReaders =
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR
| VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR
| VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
// The same, for synchronization2's barriers.
constexpr VkPipelineStageFlags2 kGeometryReaders2 =
VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR
| VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR
| VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT;
// Translate the portable RTBuildOptions to Vulkan build flags. The two
// preferences are mutually exclusive; ALLOW_UPDATE is layered on top so
// a later in-place refit (UPDATE mode) is permitted.
@ -42,6 +56,15 @@ namespace {
return flags;
}
// The geometry's flags for a build: opaque or not, and whether any-hit
// may run more than once per primitive.
VkGeometryFlagsKHR GeometryFlags(bool opaque, const RTBuildOptions& options) {
VkGeometryFlagsKHR flags = opaque ? static_cast<VkGeometryFlagsKHR>(VK_GEOMETRY_OPAQUE_BIT_KHR) : VkGeometryFlagsKHR{};
if (options.noDuplicateAnyHit)
flags |= VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR;
return flags;
}
// Shared BLAS sizing / creation / build-record tail — geometry-type
// agnostic; both the triangle and the AABB (procedural) paths feed
// their single geometry through here. `update` selects an in-place
@ -61,11 +84,38 @@ namespace {
.pGeometries = &blasGeometry,
};
// A refit writes the BLAS in place, and a rebuild writes the same
// storage and scratch whenever the new BLAS fits. Earlier work, in
// this command buffer or in a frame still in flight, may read the BLAS
// (a TLAS build, a trace, a compute ray query) or write the scratch (a
// build), so order that before this build. A first build touches
// nothing earlier.
if (self.accelerationStructure != VK_NULL_HANDLE) {
VkMemoryBarrier asBarrier {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR,
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
0, 1, &asBarrier, 0, nullptr, 0, nullptr);
}
if (!update) {
// Fresh build: query sizes, (re)allocate scratch + AS storage,
// create a new AS handle. The scratch buffer is sized for the
// build (always >= the update scratch requirement), so it also
// covers later in-place refits without resizing.
//
// On a rebuild the old handle may still be referenced by this
// command buffer and by frames in flight, so it goes to the
// deletion queue instead of being destroyed here. It is queued
// before the Resizes below, which queue the old storage when the
// new BLAS doesn't fit, so it is freed before the buffer it lives in.
Device::EnqueueDeletion(self.accelerationStructure);
self.accelerationStructure = VK_NULL_HANDLE;
VkAccelerationStructureBuildSizesInfoKHR blasBuildSizes = {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR
};
@ -89,13 +139,6 @@ namespace {
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
blasBuildSizes.accelerationStructureSize);
// Re-Build of an existing Mesh: destroy the stale handle first
// so the previous AS storage isn't leaked.
if (self.accelerationStructure != VK_NULL_HANDLE) {
Device::vkDestroyAccelerationStructureKHR(Device::device, self.accelerationStructure, nullptr);
self.accelerationStructure = VK_NULL_HANDLE;
}
VkAccelerationStructureCreateInfoKHR blasCreateInfo{
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR,
.buffer = self.blasBuffer.buffer,
@ -110,19 +153,6 @@ namespace {
.accelerationStructure = self.accelerationStructure
};
self.blasAddr = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo);
} else {
// In-place refit: the AS may have been read by a prior TLAS
// build / trace, so order that read before the build overwrites
// it. Scratch reuse needs the same write-after-read guard.
VkMemoryBarrier asBarrier {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR,
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
0, 1, &asBarrier, 0, nullptr, 0, nullptr);
}
blasBuildGeometryInfo.scratchData.deviceAddress = self.scratchBuffer.address;
@ -183,13 +213,56 @@ namespace {
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR,
.geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR,
.geometry = geometryData,
.flags = VK_GEOMETRY_OPAQUE_BIT_KHR
.flags = self.geometryFlags
};
RecordBLASBuildFromGeometry(self, blasGeometry, indexCount / 3, flags, update, cmd);
}
}
Mesh::Mesh(Mesh&& other)
: scratchBuffer(std::move(other.scratchBuffer))
, blasBuffer(std::move(other.blasBuffer))
, vertexBuffer(std::move(other.vertexBuffer))
, indexBuffer(std::move(other.indexBuffer))
, aabbBuffer(std::move(other.aabbBuffer))
, compressedStaging(std::move(other.compressedStaging))
, accelerationStructure(std::exchange(other.accelerationStructure, VK_NULL_HANDLE))
, blasAddr(std::exchange(other.blasAddr, 0))
, geometryFlags(other.geometryFlags)
, buildFlags(other.buildFlags)
, builtPrimitiveCount(other.builtPrimitiveCount)
, builtInputCount(other.builtInputCount)
, allowUpdate(other.allowUpdate) {
}
Mesh& Mesh::operator=(Mesh&& other) {
if (this == &other) return *this;
// Ahead of blasBuffer, which it lives in and which queues its own as it
// takes other's over.
Device::EnqueueDeletion(accelerationStructure);
scratchBuffer = std::move(other.scratchBuffer);
blasBuffer = std::move(other.blasBuffer);
vertexBuffer = std::move(other.vertexBuffer);
indexBuffer = std::move(other.indexBuffer);
aabbBuffer = std::move(other.aabbBuffer);
compressedStaging = std::move(other.compressedStaging);
accelerationStructure = std::exchange(other.accelerationStructure, VK_NULL_HANDLE);
blasAddr = std::exchange(other.blasAddr, 0);
geometryFlags = other.geometryFlags;
buildFlags = other.buildFlags;
builtPrimitiveCount = other.builtPrimitiveCount;
builtInputCount = other.builtInputCount;
allowUpdate = other.allowUpdate;
return *this;
}
Mesh::~Mesh() {
// The body runs before the members' destructors, so the handle is queued
// ahead of blasBuffer, which it lives in.
Device::EnqueueDeletion(accelerationStructure);
}
void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd, RTBuildOptions options) {
// Every Build* records an acceleration-structure build; without ray
// tracing those entry points are not loaded. Fail with the reason.
@ -199,10 +272,11 @@ void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
// upload before the BLAS build reads them. Replaces the previous
// HOST_VISIBLE allocation that the build (and any hit-shader fetch) would
// read over PCIe every frame (#73).
vertexBuffer.UploadDeviceLocal(kVertexUsageBase, verticies.data(), static_cast<std::uint32_t>(verticies.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
indexBuffer.UploadDeviceLocal(kIndexUsageBase, indicies.data(), static_cast<std::uint32_t>(indicies.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
vertexBuffer.UploadDeviceLocal(kVertexUsageBase, verticies.data(), static_cast<std::uint32_t>(verticies.size()), cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders);
indexBuffer.UploadDeviceLocal(kIndexUsageBase, indicies.data(), static_cast<std::uint32_t>(indicies.size()), cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders);
allowUpdate = options.allowUpdate;
geometryFlags = GeometryFlags(/*opaque*/ true, options);
builtInputCount = static_cast<std::uint32_t>(verticies.size());
RecordBLASBuild(*this, static_cast<std::uint32_t>(verticies.size()), static_cast<std::uint32_t>(indicies.size()), BlasFlags(options), /*update*/ false, cmd);
}
@ -231,9 +305,12 @@ void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildO
// The GPU decompressor writes vertex/index directly (the build input is
// never host-written on this path), so they want pure DEVICE_LOCAL — no
// host visibility, no map. This is where the BLAS build and any hit-shader
// fetch then read them from (#73).
// fetch then read them from (#73). A ranged Refit copies the moved
// vertices in, hence TRANSFER_DST.
const VkBuffer previousVertices = vertexBuffer.buffer;
const VkBuffer previousIndices = indexBuffer.buffer;
vertexBuffer.Resize(
kVertexUsageBase | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
kVertexUsageBase | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
asset.vertexCount);
indexBuffer.Resize(
@ -269,11 +346,33 @@ void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildO
regions);
}
// A rebuild that keeps its buffers (Resize keeps an allocation that fits)
// decompresses into what frames in flight may still be reading: the
// decompress waits for those reads. Fresh buffers nothing reads yet.
if ((vertexBuffer.buffer != VK_NULL_HANDLE && vertexBuffer.buffer == previousVertices)
|| (indexBuffer.buffer != VK_NULL_HANDLE && indexBuffer.buffer == previousIndices)) {
const VkMemoryBarrier2 readsDone {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2,
.srcStageMask = kGeometryReaders2,
.srcAccessMask = 0,
.dstStageMask = VK_PIPELINE_STAGE_2_MEMORY_DECOMPRESSION_BIT_EXT,
.dstAccessMask = VK_ACCESS_2_MEMORY_DECOMPRESSION_WRITE_BIT_EXT,
};
const VkDependencyInfo beforeDecompress {
.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
.memoryBarrierCount = 1,
.pMemoryBarriers = &readsDone,
};
vkCmdPipelineBarrier2(cmd, &beforeDecompress);
}
// Then the BLAS build reads the decompressed geometry as a shader read,
// as do the hit shaders and ray queries that fetch it by address.
Decompress::DecompressOnGPU(
cmd,
regions,
VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR);
kGeometryReaders2,
VK_ACCESS_2_SHADER_READ_BIT);
// The compressed staging is only read by the decompress recorded above; the
// subsequent BLAS build reads the decompressed vertex/index buffers, never
@ -286,6 +385,7 @@ void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildO
compressedStaging.DeferredClear();
allowUpdate = options.allowUpdate;
geometryFlags = GeometryFlags(/*opaque*/ true, options);
builtInputCount = asset.vertexCount;
RecordBLASBuild(*this, asset.vertexCount, asset.indexCount, BlasFlags(options), /*update*/ false, cmd);
}
@ -296,7 +396,7 @@ namespace {
// Geometry-source agnostic: the host-upload path (RecordProceduralBuild)
// points this at self.aabbBuffer, while the device-buffer overloads
// point it straight at the producer's buffer — no copy involved either
// way. The geometry's opaque bit is read from self.opaque so an UPDATE
// way. The geometry's flags are read from self.geometryFlags so an UPDATE
// keeps the exact geometry description of the original build.
void RecordProceduralBuildFromAddress(Mesh& self, VkDeviceAddress aabbAddress, std::uint32_t count, std::uint32_t stride, VkBuildAccelerationStructureFlagsKHR flags, bool update, VkCommandBuffer cmd) {
VkAccelerationStructureGeometryAabbsDataKHR aabbsData {
@ -312,7 +412,7 @@ namespace {
.geometry = geometryData,
// Non-opaque by default (mirrors the WebGPU path) so any-hit
// shaders run for procedural geometry unless the caller opts out.
.flags = self.opaque ? static_cast<VkGeometryFlagsKHR>(VK_GEOMETRY_OPAQUE_BIT_KHR) : VkGeometryFlagsKHR{}
.flags = self.geometryFlags
};
RecordBLASBuildFromGeometry(self, blasGeometry, count, flags, update, cmd);
@ -330,7 +430,7 @@ namespace {
// re-uploads into the same same-sized buffer: Resize reuses the existing
// allocation (count unchanged), so the device address — and the geometry
// it feeds — stays stable for an in-place UPDATE.
self.aabbBuffer.UploadDeviceLocal(kVertexUsageBase, aabbs.data(), static_cast<std::uint32_t>(aabbs.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
self.aabbBuffer.UploadDeviceLocal(kVertexUsageBase, aabbs.data(), static_cast<std::uint32_t>(aabbs.size()), cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders);
RecordProceduralBuildFromAddress(self, self.aabbBuffer.address, static_cast<std::uint32_t>(aabbs.size()), sizeof(RTAabb), flags, update, cmd);
}
@ -338,7 +438,7 @@ namespace {
void Mesh::BuildProcedural(std::span<const RTAabb> aabbs, bool opaque, VkCommandBuffer cmd, RTBuildOptions options) {
Device::RequireRayTracing();
this->opaque = opaque;
geometryFlags = GeometryFlags(opaque, options);
allowUpdate = options.allowUpdate;
builtInputCount = static_cast<std::uint32_t>(aabbs.size());
@ -353,7 +453,7 @@ void Mesh::BuildProcedural(VkDeviceAddress aabbAddress, std::uint32_t count, boo
// The caller owns that buffer's lifetime + usage flags
// (ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY + SHADER_DEVICE_ADDRESS)
// and must barrier the producing writes before this build reads them.
this->opaque = opaque;
geometryFlags = GeometryFlags(opaque, options);
allowUpdate = options.allowUpdate;
builtInputCount = count;
@ -385,6 +485,7 @@ void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
.preference = (buildFlags & VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR)
? RTBuildPreference::FastBuild : RTBuildPreference::FastTrace,
.allowUpdate = allowUpdate,
.noDuplicateAnyHit = (geometryFlags & VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR) != 0,
});
return;
}
@ -402,15 +503,14 @@ void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
// the rest of the vertex buffer already holds last refit's positions, so a
// ranged upload patches just the vertices that moved (#119). The window is
// clamped to the array — a caller window past the end would otherwise read
// out of bounds / overflow the copy. UploadDeviceLocalRange writes into the
// out of bounds / overflow the copy. UploadDeviceLocalRange copies into the
// existing same-sized allocation (no Resize), so the address the UPDATE
// reads stays stable, then barriers just that sub-range before the build. On
// the staged path this re-stages only the dirty slice (the deforming-mesh
// fallback, #73).
// reads stays stable, after the reads of the frames still in flight and
// before the build, staging only the dirty slice.
std::uint32_t offset = std::min(dirtyVertexOffset, static_cast<std::uint32_t>(verticies.size()));
std::uint32_t count = std::min(dirtyVertexCount, static_cast<std::uint32_t>(verticies.size()) - offset);
if (count != 0) {
vertexBuffer.UploadDeviceLocalRange(verticies.data() + offset, offset, count, cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
vertexBuffer.UploadDeviceLocalRange(verticies.data() + offset, offset, count, cmd, VK_ACCESS_SHADER_READ_BIT, kGeometryReaders);
}
RecordBLASBuild(*this, static_cast<std::uint32_t>(verticies.size()), static_cast<std::uint32_t>(indicies.size()), buildFlags, /*update*/ true, cmd);

View file

@ -25,6 +25,7 @@ module;
#include <wayland-client-protocol.h>
#include <sys/stat.h>
#include <time.h>
#include <poll.h>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#include <windows.h>
@ -615,6 +616,19 @@ void Window::RecreateSwapchainAndImages() {
CreateSwapchain();
}
void Window::SetPresentMode(PresentMode mode) {
presentMode = mode;
presentModeUnfit = false;
// A window without a swapchain yet picks the mode up when it makes one.
if (swapChain == VK_NULL_HANDLE) return;
// The mode is fixed when a swapchain is made, so make a new one, as
// Resize does: nothing in flight may still use the old images.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
Device::DrainDeletions();
RecreateSwapchainAndImages();
onResize.Invoke();
}
void Window::SetTitle(const std::string_view title) {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
xdg_toplevel_set_title(xdgToplevel, title.data());
@ -750,11 +764,100 @@ void Window::SetDefaultCursor() {
#endif
}
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
namespace {
// Milliseconds until the first window that updates on its own clock is
// due, rounded up so the wait doesn't end just short of it; -1 when no
// window does, so the loop waits for events alone.
int MillisecondsToNextClockedUpdate() {
const auto now = std::chrono::high_resolution_clock::now();
std::optional<std::chrono::high_resolution_clock::duration> soonest;
for (const Window* window : Device::windows) {
if (!window->updating || window->updateInterval <= std::chrono::nanoseconds::zero()) continue;
const auto wait = window->lastFrameBegin + window->updateInterval - now;
if (!soonest || wait < *soonest) soonest = wait;
}
if (!soonest) return -1;
if (*soonest <= std::chrono::high_resolution_clock::duration::zero()) return 0;
return static_cast<int>(std::chrono::ceil<std::chrono::milliseconds>(*soonest).count());
}
}
#endif
void Window::StartSync() {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
while (open && wl_display_dispatch(Device::display) != -1) {
// libwayland's read sequence, as wl_display_dispatch runs it, but the
// poll also watches the descriptors windows registered with AddPollFd,
// and times out when a window that updates on its own clock is due.
std::vector<pollfd> fds;
std::vector<int> ready;
bool flushPending = false;
while (open) {
while (wl_display_prepare_read(Device::display) != 0) {
if (wl_display_dispatch_pending(Device::display) == -1) return;
}
// A full socket (EAGAIN) is flushed again once poll says it is
// writable. EPIPE goes on to the read, which reports the
// compositor's error.
flushPending = false;
if (wl_display_flush(Device::display) == -1) {
if (errno == EAGAIN) {
flushPending = true;
} else if (errno != EPIPE) {
wl_display_cancel_read(Device::display);
return;
}
}
fds.clear();
fds.push_back({ .fd = wl_display_get_fd(Device::display), .events = static_cast<short>(POLLIN | (flushPending ? POLLOUT : 0)), .revents = 0 });
for (const Window* window : Device::windows) {
for (const PollFd& entry : window->pollFds) {
fds.push_back({ .fd = entry.fd, .events = POLLIN, .revents = 0 });
}
}
if (poll(fds.data(), fds.size(), MillisecondsToNextClockedUpdate()) == -1) {
wl_display_cancel_read(Device::display);
if (errno == EINTR) continue;
return;
}
if (fds[0].revents & (POLLIN | POLLERR | POLLHUP)) {
if (wl_display_read_events(Device::display) == -1) return;
} else {
wl_display_cancel_read(Device::display);
}
// Frame callbacks run Update here for windows on the compositor's clock.
if (wl_display_dispatch_pending(Device::display) == -1) return;
// A callback may add or remove descriptors, so each ready one is
// looked up again, and its callback copied, before it is called.
ready.clear();
for (std::size_t i = 1; i < fds.size(); ++i) {
if (fds[i].revents != 0) ready.push_back(fds[i].fd);
}
for (int fd : ready) {
for (const Window* window : Device::windows) {
auto entry = std::ranges::find(window->pollFds, fd, &PollFd::fd);
if (entry != window->pollFds.end()) {
std::function<void()> onReadable = entry->onReadable;
onReadable();
break;
}
}
}
Gamepad::Tick();
onBeforeUpdate.Invoke();
// By index: an Update may open or close a window.
const auto now = std::chrono::high_resolution_clock::now();
for (std::size_t i = 0; i < Device::windows.size(); ++i) {
Window* window = Device::windows[i];
if (window->updating && window->updateInterval > std::chrono::nanoseconds::zero()
&& now >= window->lastFrameBegin + window->updateInterval) {
window->Update();
}
}
}
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
@ -766,11 +869,23 @@ void Window::StartSync() {
// iteration. Block on the message queue instead, waking on input or after a
// short timeout so gamepad polling / onBeforeUpdate still tick at a sane
// rate (e.g. to detect a controller button asking to resume).
//
// A window on its own clock (updateInterval) sleeps the same way until
// its next update is due, instead of updating every pass.
constexpr DWORD kPausedPollIntervalMs = 16;
while(open) {
// Read each pass, as the Wayland loop does: the clock may be set or
// changed from inside the loop.
const bool clocked = updateInterval > std::chrono::nanoseconds::zero();
if (!updating) {
MsgWaitForMultipleObjectsEx(0, nullptr, kPausedPollIntervalMs,
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
} else if (clocked) {
const auto wait = lastFrameBegin + updateInterval - std::chrono::high_resolution_clock::now();
if (wait > std::chrono::high_resolution_clock::duration::zero()) {
MsgWaitForMultipleObjectsEx(0, nullptr, static_cast<DWORD>(std::chrono::ceil<std::chrono::milliseconds>(wait).count()),
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
}
}
MSG msg;
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
@ -779,7 +894,7 @@ void Window::StartSync() {
}
Gamepad::Tick();
onBeforeUpdate.Invoke();
if(updating) {
if(updating && (!clocked || std::chrono::high_resolution_clock::now() >= lastFrameBegin + updateInterval)) {
Update();
}
}
@ -801,8 +916,11 @@ void Window::StartUpdate() {
lastFrameBegin = std::chrono::high_resolution_clock::now();
updating = true;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
cb = wl_surface_frame(surface);
wl_callback_add_listener(cb, &wl_callback_listener, this);
// A window on its own clock is updated by StartSync's loop instead.
if (updateInterval == std::chrono::nanoseconds::zero()) {
cb = wl_surface_frame(surface);
wl_callback_add_listener(cb, &wl_callback_listener, this);
}
#endif
}
@ -810,6 +928,16 @@ void Window::StopUpdate() {
updating = false;
}
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
void Window::AddPollFd(int fd, std::function<void()> onReadable) {
pollFds.push_back({ .fd = fd, .onReadable = std::move(onReadable) });
}
void Window::RemovePollFd(int fd) {
std::erase_if(pollFds, [fd](const PollFd& entry) { return entry.fd == fd; });
}
#endif
std::chrono::time_point<std::chrono::high_resolution_clock> startTime;
void Window::Update() {
@ -846,6 +974,133 @@ void Window::Update() {
lastFrameBegin = startTime;
}
namespace {
// A frame's readback for CaptureNextFrame: the buffer the swapchain image
// is copied into, recorded into the frame's own command buffer.
// It owns the buffer and its memory, which go through the deletion queue
// however the capture ends: written, or unwound past (a failed
// allocation, a lost device), as the frame may be copying into it.
struct FrameCapture {
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
VkMemoryPropertyFlags memoryFlags = 0;
std::uint32_t width = 0;
std::uint32_t height = 0;
// Where it goes: taken from Window::pendingCapture as the copy is
// recorded, so a capture asked for meanwhile (from onResize, say) is
// the next frame's.
std::filesystem::path path;
FrameCapture(std::uint32_t width, std::uint32_t height) : width(width), height(height) {}
FrameCapture(FrameCapture&& other) noexcept
: buffer(std::exchange(other.buffer, VK_NULL_HANDLE)), memory(std::exchange(other.memory, VK_NULL_HANDLE)),
memoryFlags(other.memoryFlags), width(other.width), height(other.height), path(std::move(other.path)) {}
FrameCapture& operator=(FrameCapture&&) = delete;
~FrameCapture() {
Device::EnqueueDeletion(buffer, memory);
}
};
// Records the copy of `image`, in GENERAL and last written at `writers`
// with `writerAccess`, into a new host-visible buffer.
FrameCapture RecordFrameCapture(VkCommandBuffer cmd, VkImage image, std::uint32_t width, std::uint32_t height, VkPipelineStageFlags writers, VkAccessFlags writerAccess) {
FrameCapture capture(width, height);
const VkBufferCreateInfo bufferInfo {
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = static_cast<VkDeviceSize>(width) * height * 4,
.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
};
// Into a local first: a call that fails leaves its output undefined.
VkBuffer buffer = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateBuffer(Device::device, &bufferInfo, nullptr, &buffer));
capture.buffer = buffer;
VkMemoryRequirements requirements;
vkGetBufferMemoryRequirements(Device::device, capture.buffer, &requirements);
// The CPU reads all of it: cached memory where there is some, as
// write-combined memory is very slow to read (#59, #89).
std::uint32_t memoryType;
try {
memoryType = Device::GetMemoryType(requirements.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT, VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
} catch (const std::runtime_error&) {
memoryType = Device::GetMemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT);
}
capture.memoryFlags = Device::memoryProperties.memoryTypes[memoryType].propertyFlags;
const VkMemoryAllocateInfo allocation {
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = requirements.size,
.memoryTypeIndex = memoryType,
};
VkDeviceMemory memory = VK_NULL_HANDLE;
Device::CheckVkResult(vkAllocateMemory(Device::device, &allocation, nullptr, &memory));
capture.memory = memory;
Device::CheckVkResult(vkBindBufferMemory(Device::device, capture.buffer, capture.memory, 0));
// The passes' writes, before the copy reads them.
const VkImageMemoryBarrier written {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = writerAccess,
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_GENERAL,
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = image,
.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 },
};
vkCmdPipelineBarrier(cmd, writers, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &written);
const VkBufferImageCopy region {
.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 },
.imageExtent = { width, height, 1 },
};
vkCmdCopyImageToBuffer(cmd, image, VK_IMAGE_LAYOUT_GENERAL, capture.buffer, 1, &region);
const VkBufferMemoryBarrier copied {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_HOST_READ_BIT,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.buffer = capture.buffer,
.offset = 0,
.size = VK_WHOLE_SIZE,
};
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &copied, 0, nullptr);
return capture;
}
// Once the frame has finished: the readback as an RGBA PNG at `path`.
void WriteFrameCapture(const FrameCapture& capture, VkFormat format) {
const std::filesystem::path& path = capture.path;
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, capture.memory, 0, VK_WHOLE_SIZE, 0, &mapped));
// Cached but not coherent: the copy's writes have to be pulled in.
if ((capture.memoryFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) {
const VkMappedMemoryRange range {
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = capture.memory,
.offset = 0,
.size = VK_WHOLE_SIZE,
};
Device::CheckVkResult(vkInvalidateMappedMemoryRanges(Device::device, 1, &range));
}
const std::uint8_t* src = static_cast<const std::uint8_t*>(mapped);
const std::size_t texels = static_cast<std::size_t>(capture.width) * capture.height;
std::vector<std::uint8_t> rgba(texels * 4);
const bool bgr = format == VK_FORMAT_B8G8R8A8_UNORM;
for (std::size_t i = 0; i < texels; ++i) {
rgba[i * 4 + 0] = src[i * 4 + (bgr ? 2 : 0)];
rgba[i * 4 + 1] = src[i * 4 + 1];
rgba[i * 4 + 2] = src[i * 4 + (bgr ? 0 : 2)];
rgba[i * 4 + 3] = src[i * 4 + 3];
}
vkUnmapMemory(Device::device, capture.memory);
if (stbi_write_png(path.string().c_str(), static_cast<int>(capture.width), static_cast<int>(capture.height), 4, rgba.data(), static_cast<int>(capture.width) * 4) == 0) {
throw std::runtime_error(std::format("CaptureNextFrame: could not write {}", path.string()));
}
}
}
void Window::Render() {
// Acquire the next image from the swap chain. If the surface has
// changed size out from under us (compositor/Win32 resize delivered
@ -981,10 +1236,10 @@ void Window::Render() {
}
#endif
// Note: vkCmdClearColorImage is unavailable here — the swapchain is
// created with VK_IMAGE_USAGE_STORAGE_BIT only (no TRANSFER_DST_BIT).
// Passes that need a background should write one explicitly (UIScene
// exposes a `background()` setter for this purpose).
// The window doesn't clear the image: not every surface allows the
// swapchain TRANSFER_DST (swapchainUsage says whether this one does), so
// passes that need a background write one themselves, by a clear where
// the usage allows it or from a shader (UIScene's `background()`).
(void)clearColor;
for (std::size_t i = 0; i < passes.size(); ++i) {
@ -1000,12 +1255,12 @@ void Window::Render() {
// each pass's own SwapchainStage(), so a compute->compute edge only
// serialises COMPUTE_SHADER while an RT pass on either side pulls in
// RAY_TRACING_SHADER — never the ALL_COMMANDS full-pipeline stall.
VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer]);
// Masked like swapWriterStages above: a pass that keeps the
// default stage reports a union that names RAY_TRACING_SHADER.
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer],
passes[i]->SwapchainStage() & Device::stageMask,
passes[i + 1]->SwapchainStage() & Device::stageMask,
const VkPipelineStageFlags srcStages = passes[i]->SwapchainStage() & Device::stageMask;
const VkPipelineStageFlags dstStages = passes[i + 1]->SwapchainStage() & Device::stageMask;
VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer], srcStages, dstStages);
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], srcStages, dstStages,
0, 0, nullptr, 0, nullptr, 1, &ib);
}
}
@ -1018,10 +1273,21 @@ void Window::Render() {
// TRANSFER_WRITE here too — derive both from the same union for symmetry.
presentBarrier.srcAccessMask = SwapchainWriterAccess(swapWriterStages);
// CaptureNextFrame: the copy goes here, while the image is still this
// frame's, and the transition to present waits for it.
std::optional<FrameCapture> capture;
VkPipelineStageFlags presentWaitStages = swapWriterStages;
if (pendingCapture) {
capture.emplace(RecordFrameCapture(drawCmdBuffers[currentBuffer], images[currentBuffer], width, height, swapWriterStages, presentBarrier.srcAccessMask));
presentWaitStages |= VK_PIPELINE_STAGE_TRANSFER_BIT;
capture->path = std::move(*pendingCapture);
pendingCapture.reset();
}
// The ->PRESENT_SRC transition only needs to wait on the stages that
// actually wrote the image (the per-pass union), not ALL_COMMANDS; present
// itself is gated by the render-finished semaphore, so dst stays BOTTOM.
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], swapWriterStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier);
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], presentWaitStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier);
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
@ -1062,6 +1328,13 @@ void Window::Render() {
// separate from the per-window frameCounter above (which also keys the
// acquire-semaphore slot) so non-window code can enqueue against it.
Device::frameCounter++;
if (capture) {
// A capture is for tests and screenshots: waiting for the frame here
// is fine.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
WriteFrameCapture(*capture, colorFormat);
}
}
#ifdef CRAFTER_TIMING
@ -1148,30 +1421,37 @@ void Window::CreateSwapchain()
swapchainCI.imageFormat = colorFormat;
swapchainCI.imageColorSpace = colorSpace;
swapchainCI.imageExtent = { swapchainExtent.width, swapchainExtent.height };
swapchainCI.imageUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
swapchainUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT
| (surfCaps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT);
swapchainCI.imageUsage = swapchainUsage;
swapchainCI.preTransform = (VkSurfaceTransformFlagBitsKHR)preTransform;
swapchainCI.imageArrayLayers = 1;
swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
swapchainCI.queueFamilyIndexCount = 0;
// VK_PRESENT_MODE_FIFO_KHR ("v-sync") is guaranteed available per spec, so
// it is the default. For benchmarking we want frame pacing uncapped from
// the display refresh, so when CRAFTER_PRESENT_IMMEDIATE is set in the
// environment we enumerate the surface's present modes and prefer
// IMMEDIATE (then MAILBOX) when offered, falling back to FIFO otherwise.
swapchainCI.presentMode = VK_PRESENT_MODE_FIFO_KHR;
if (std::getenv("CRAFTER_PRESENT_IMMEDIATE")) {
// it is the fallback for a mode the surface lacks. For benchmarking we
// want frame pacing uncapped from the display refresh, so when
// CRAFTER_PRESENT_IMMEDIATE is set in the environment we prefer IMMEDIATE
// (then MAILBOX) whatever presentMode asks for.
{
std::uint32_t presentModeCount = 0;
vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, nullptr);
std::vector<VkPresentModeKHR> presentModes(presentModeCount);
vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, presentModes.data());
bool haveImmediate = false, haveMailbox = false;
for (VkPresentModeKHR m : presentModes) {
if (m == VK_PRESENT_MODE_IMMEDIATE_KHR) haveImmediate = true;
if (m == VK_PRESENT_MODE_MAILBOX_KHR) haveMailbox = true;
auto offered = [&](VkPresentModeKHR mode) { return std::ranges::find(presentModes, mode) != presentModes.end(); };
swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
if (presentModeUnfit) {
// Stays FIFO; see below.
} else if (std::getenv("CRAFTER_PRESENT_IMMEDIATE")) {
if (offered(VK_PRESENT_MODE_IMMEDIATE_KHR)) swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
else if (offered(VK_PRESENT_MODE_MAILBOX_KHR)) swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR;
} else if (presentMode == PresentMode::Mailbox && offered(VK_PRESENT_MODE_MAILBOX_KHR)) {
swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR;
} else if (presentMode == PresentMode::Immediate && offered(VK_PRESENT_MODE_IMMEDIATE_KHR)) {
swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
}
if (haveImmediate) swapchainCI.presentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
else if (haveMailbox) swapchainCI.presentMode = VK_PRESENT_MODE_MAILBOX_KHR;
}
swapchainCI.presentMode = swapchainPresentMode;
// Setting oldSwapChain to the saved handle of the previous swapchain aids in resource reuse and makes sure that we can still present already acquired images
swapchainCI.oldSwapchain = oldSwapchain;
// Setting clipped to VK_TRUE allows the implementation to discard rendering outside of the surface area
@ -1188,8 +1468,21 @@ void Window::CreateSwapchain()
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr));
// images[] and every other per-frame array hold exactly numFrames
// entries. A driver may create more images than minImageCount asked for
// (a surface whose minimum is above numFrames must); writing them into
// images[] would run off its end, so refuse instead.
// (a surface whose minimum is above numFrames must, and Mesa gives
// MAILBOX at least four). MAILBOX and IMMEDIATE are wishes: FIFO, with
// numFrames, is the frame loop's own. If that has other than numFrames
// too, writing them into images[] would run off its end, so refuse.
if (imageCount != numFrames && swapchainPresentMode != VK_PRESENT_MODE_FIFO_KHR) {
std::println(std::cerr, "Window: the {} swapchain has {} images, not the frame loop's {}; using FIFO",
swapchainPresentMode == VK_PRESENT_MODE_MAILBOX_KHR ? "MAILBOX" : "IMMEDIATE", imageCount, numFrames);
vkDestroySwapchainKHR(Device::device, swapChain, nullptr);
presentModeUnfit = true;
swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
swapchainCI.presentMode = swapchainPresentMode;
swapchainCI.oldSwapchain = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateSwapchainKHR(Device::device, &swapchainCI, nullptr, &swapChain));
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr));
}
if (imageCount != numFrames) {
throw std::runtime_error(std::format("Window: the swapchain has {} images, but the frame loop is built for exactly {}", imageCount, numFrames));
}
@ -1301,7 +1594,8 @@ void Window::wl_surface_frame_done(void* data, struct wl_callback *cb, uint32_t
Window* window = reinterpret_cast<Window*>(data);
// The member names the live callback, so the destructor can cancel it.
if(window->updating) {
// A window that has since moved to its own clock lets the chain end.
if(window->updating && window->updateInterval == std::chrono::nanoseconds::zero()) {
window->cb = wl_surface_frame(window->surface);
wl_callback_add_listener(window->cb, &Window::wl_callback_listener, window);
window->Update();
@ -1359,171 +1653,8 @@ void Window::xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_
#endif
void Window::SaveFrame(const std::filesystem::path& path) {
// Staging buffer big enough for one RGBA frame.
VkDeviceSize bufSize = static_cast<VkDeviceSize>(width) * height * 4;
VkBufferCreateInfo bci{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = bufSize,
.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
};
VkBuffer stagingBuf = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateBuffer(Device::device, &bci, nullptr, &stagingBuf));
VkMemoryRequirements memReqs;
vkGetBufferMemoryRequirements(Device::device, stagingBuf, &memReqs);
// Readback path: the CPU *reads* this whole buffer back. The first
// HOST_VISIBLE|HOST_COHERENT type is usually write-combined (uncached) on
// discrete GPUs, where CPU reads are pathologically slow. Ask for
// HOST_CACHED so reads hit the CPU cache, preferring a cached-coherent type
// when one exists. HOST_CACHED is near-universal but not spec-guaranteed,
// so fall back to any HOST_VISIBLE type (#59) if absent — that path then
// relies on the coherency-gated invalidate below. (Deliberately NOT routed
// through the upload-strategy helper, which steers toward write-combined
// DEVICE_LOCAL|HOST_VISIBLE — the worst type for readback. See issue #89.)
std::uint32_t memTypeIndex;
try {
memTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT,
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
} catch (const std::runtime_error&) {
memTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT);
}
VkMemoryAllocateInfo mai{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = memReqs.size,
.memoryTypeIndex = memTypeIndex,
};
VkDeviceMemory stagingMem = VK_NULL_HANDLE;
Device::CheckVkResult(vkAllocateMemory(Device::device, &mai, nullptr, &stagingMem));
Device::CheckVkResult(vkBindBufferMemory(Device::device, stagingBuf, stagingMem, 0));
// One-shot command buffer so we don't trash the per-frame ones.
VkCommandBufferAllocateInfo cba{
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
.commandPool = Device::commandPool,
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
.commandBufferCount = 1,
};
VkCommandBuffer cmd = VK_NULL_HANDLE;
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cba, &cmd));
VkCommandBufferBeginInfo cbi{
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
};
Device::CheckVkResult(vkBeginCommandBuffer(cmd, &cbi));
VkImageSubresourceRange range{
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1,
};
// Render() leaves the image in PRESENT_SRC_KHR.
VkImageMemoryBarrier toSrc{
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = images[currentBuffer],
.subresourceRange = range,
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 1, &toSrc);
VkBufferImageCopy region{
.bufferOffset = 0,
.bufferRowLength = 0,
.bufferImageHeight = 0,
.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 },
.imageOffset = { 0, 0, 0 },
.imageExtent = { width, height, 1 },
};
vkCmdCopyImageToBuffer(cmd, images[currentBuffer],
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, stagingBuf, 1, &region);
VkImageMemoryBarrier back{
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT,
.dstAccessMask = 0,
.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = images[currentBuffer],
.subresourceRange = range,
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
0, 0, nullptr, 0, nullptr, 1, &back);
Device::CheckVkResult(vkEndCommandBuffer(cmd));
VkSubmitInfo si{
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &cmd,
};
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &si, VK_NULL_HANDLE));
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
// Read back, swizzle BGRA → RGBA if needed, write PNG.
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, stagingMem, 0, VK_WHOLE_SIZE, 0, &mapped));
// If the chosen type is cached-but-not-coherent, the transfer's writes may
// not yet be visible to the CPU read; invalidate to pull them in. Gate on
// the chosen type's actual coherency (not the requested flags) — a coherent
// type needs no invalidate. Whole-range invalidate sidesteps
// nonCoherentAtomSize. This is the read-path counterpart of FlushDevice (#60).
if (!(Device::memoryProperties.memoryTypes[memTypeIndex].propertyFlags &
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
VkMappedMemoryRange invalidateRange{
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = stagingMem,
.offset = 0,
.size = VK_WHOLE_SIZE,
};
Device::CheckVkResult(
vkInvalidateMappedMemoryRanges(Device::device, 1, &invalidateRange));
}
const std::uint8_t* src = static_cast<const std::uint8_t*>(mapped);
std::vector<std::uint8_t> rgba(static_cast<std::size_t>(width) * height * 4);
bool bgr = (colorFormat == VK_FORMAT_B8G8R8A8_UNORM);
for (std::uint32_t i = 0; i < width * height; ++i) {
if (bgr) {
rgba[i*4+0] = src[i*4+2];
rgba[i*4+1] = src[i*4+1];
rgba[i*4+2] = src[i*4+0];
rgba[i*4+3] = src[i*4+3];
} else {
rgba[i*4+0] = src[i*4+0];
rgba[i*4+1] = src[i*4+1];
rgba[i*4+2] = src[i*4+2];
rgba[i*4+3] = src[i*4+3];
}
}
vkUnmapMemory(Device::device, stagingMem);
stbi_write_png(path.string().c_str(), static_cast<int>(width), static_cast<int>(height),
4, rgba.data(), static_cast<int>(width) * 4);
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
vkDestroyBuffer(Device::device, stagingBuf, nullptr);
vkFreeMemory(Device::device, stagingMem, nullptr);
void Window::CaptureNextFrame(std::filesystem::path path) {
pendingCapture = std::move(path);
}
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
@ -1707,6 +1838,11 @@ void Window::StopUpdate() {
Crafter::DomEnv::domStopFrameLoop();
}
void Window::SetPresentMode(PresentMode mode) {
// The page presents the canvas; there is no mode to pick.
presentMode = mode;
}
void Window::Update() {
auto now = std::chrono::high_resolution_clock::now();
mouseDelta = {currentMousePos.x - lastMousePos.x,

View file

@ -51,6 +51,9 @@ export namespace Crafter {
~ComputeShader();
// Loads a SPIR-V compute shader from disk and creates its pipeline.
// Loading again replaces the pipeline. The old one, like the one a
// ComputeShader holds when it's destroyed or moved over, goes through
// the deferred-deletion queue: a frame in flight may still dispatch it.
//
// `spvPath` always names the descriptor-heap build (<name>.spv). On a
// device without the heap, the <name>.sets.spv build next to it is

View file

@ -391,12 +391,35 @@ export namespace Crafter {
vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr);
}
// Takes `count` consecutive slots out of `freelist`, the first of
// them returned, if it holds a run that long: freed ranges and freed
// single slots alike come back as ranges, rather than only fresh
// slots serving a range. The freelists are kept sorted (Free), which
// puts neighbours side by side. A freed slot is handed out again at
// once, which is why the Free* calls below require that no frame in
// flight still uses it.
static std::optional<std::uint16_t> TakeRun(std::vector<std::uint16_t>& freelist, std::uint16_t count) {
if (count == 0 || freelist.size() < count) return std::nullopt;
for (std::size_t i = 0; i + count <= freelist.size(); ++i) {
// Sorted and without repeats: the ends `count - 1` apart are a run.
if (freelist[i + count - 1] - freelist[i] == count - 1) {
const std::uint16_t first = freelist[i];
freelist.erase(freelist.begin() + static_cast<std::ptrdiff_t>(i), freelist.begin() + static_cast<std::ptrdiff_t>(i + count));
return first;
}
}
return std::nullopt;
}
ImageSlotRange AllocateImageSlots(std::uint16_t count) {
if (count == 1 && !imageFreelist.empty()) {
std::uint16_t s = imageFreelist.back();
imageFreelist.pop_back();
return {s, 1};
}
if (const std::optional<std::uint16_t> first = TakeRun(imageFreelist, count)) {
return {*first, count};
}
if (imageNext + count > imageCapacity) {
std::uint16_t remaining = static_cast<std::uint16_t>((imageCapacity - imageNext) + imageFreelist.size());
throw std::runtime_error(std::format("DescriptorHeapVulkan: out of image slots ({} requested, {} remaining of {})", count, remaining, imageCapacity));
@ -412,6 +435,9 @@ export namespace Crafter {
bufferFreelist.pop_back();
return {s, 1};
}
if (const std::optional<std::uint16_t> first = TakeRun(bufferFreelist, count)) {
return {*first, count};
}
if (bufferNext + count > bufferCapacity) {
std::uint16_t remaining = static_cast<std::uint16_t>((bufferCapacity - bufferNext) + bufferFreelist.size());
throw std::runtime_error(std::format("DescriptorHeapVulkan: out of buffer slots ({} requested, {} remaining of {})", count, remaining, bufferCapacity));
@ -427,6 +453,9 @@ export namespace Crafter {
samplerFreelist.pop_back();
return {s, 1};
}
if (const std::optional<std::uint16_t> first = TakeRun(samplerFreelist, count)) {
return {*first, count};
}
if (samplerNext + count > samplerCapacity) {
std::uint16_t remaining = static_cast<std::uint16_t>((samplerCapacity - samplerNext) + samplerFreelist.size());
throw std::runtime_error(std::format("DescriptorHeapVulkan: out of sampler slots ({} requested, {} remaining of {})", count, remaining, samplerCapacity));
@ -441,23 +470,26 @@ export namespace Crafter {
// via WriteSampledImageDescriptor / WriteBufferDescriptor / sampler write.
// Caller MUST ensure no in-flight GPU frame still references the freed
// slots — typically vkDeviceWaitIdle (or a scene-switch barrier) before
// freeing. Multi-slot ranges are decomposed into their individual slots.
// noexcept: capacity is reserved at Initialize so push_back never reallocates.
// freeing. Multi-slot ranges are decomposed into their individual slots,
// each put in its sorted place (TakeRun looks for runs).
// noexcept: capacity is reserved at Initialize so inserting never reallocates.
void FreeImageSlots(ImageSlotRange r) noexcept {
for (std::uint16_t i = 0; i < r.count; ++i) {
imageFreelist.push_back(static_cast<std::uint16_t>(r.firstElement + i));
}
Release(imageFreelist, r.firstElement, r.count);
}
void FreeBufferSlots(BufferSlotRange r) noexcept {
for (std::uint16_t i = 0; i < r.count; ++i) {
bufferFreelist.push_back(static_cast<std::uint16_t>(r.firstElement + i));
}
Release(bufferFreelist, r.firstElement, r.count);
}
void FreeSamplerSlots(SamplerSlotRange r) noexcept {
for (std::uint16_t i = 0; i < r.count; ++i) {
samplerFreelist.push_back(static_cast<std::uint16_t>(r.firstElement + i));
Release(samplerFreelist, r.firstElement, r.count);
}
static void Release(std::vector<std::uint16_t>& freelist, std::uint16_t first, std::uint16_t count) noexcept {
const auto at = std::ranges::lower_bound(freelist, first);
const std::ptrdiff_t index = at - freelist.begin();
for (std::uint16_t i = 0; i < count; ++i) {
freelist.insert(freelist.begin() + index + i, static_cast<std::uint16_t>(first + i));
}
}

View file

@ -378,17 +378,30 @@ export namespace Crafter {
// instead, tagged with the current frameCounter; ReclaimDeletions frees
// an entry only once framesInFlight frames have elapsed, by which point
// single-queue submission order guarantees all GPU work that could
// reference it has completed.
// reference it has completed. Destructors that own Vulkan handles
// (VulkanBuffer, Mesh, VulkanShader, ComputeShader, PipelineRTVulkan)
// queue them too: they can't know
// whether a frame in flight still uses the handle, and a static owner
// is destroyed after the driver and its layers have torn down, when no
// Vulkan call may run. An entry holds one kind of handle; the others
// stay VK_NULL_HANDLE.
struct PendingDeletion {
std::uint64_t retireAfter; // frameCounter value at enqueue time
VkBuffer buffer;
VkDeviceMemory memory;
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
VkAccelerationStructureKHR accelerationStructure = VK_NULL_HANDLE;
VkShaderModule shaderModule = VK_NULL_HANDLE;
VkPipeline pipeline = VK_NULL_HANDLE;
};
// Monotonic recorded-frame index, bumped once per Window::Render. Not
// the same as Window::frameCounter (which is per-window and also drives
// the acquire-semaphore slot) — this one is global so Mesh/Device code
// can enqueue too, not just the window loop.
inline static std::uint64_t frameCounter = 0;
// can enqueue too, not just the window loop. Atomic: a destructor on
// another thread reads it as it queues. It counts frames as one
// window renders them: with several windows rendering, framesInFlight
// of its counts pass sooner than each window's frames in flight, so
// the deletion queue and the staging rings assume one window.
inline static std::atomic<std::uint64_t> frameCounter = 0;
// = Window::numFrames; set at window init. The number of frames that
// must elapse after an enqueue before the resource is safe to free.
// Zero until set, which only makes ReclaimDeletions more eager — and it
@ -397,18 +410,36 @@ export namespace Crafter {
// A deque, not a vector: frameCounter is monotonic, so retireAfter is
// non-decreasing down the queue and the entries ready to reclaim are
// always a prefix. ReclaimDeletions pops that prefix off the front in
// O(ready) instead of walking and compacting the whole queue.
inline static std::deque<PendingDeletion> deletionQueue;
// O(ready) instead of walking and compacting the whole queue. Never
// destroyed: static owners queue their handles from their destructors
// at exit, and those may run after this queue's own destructor would.
inline static std::deque<PendingDeletion>& deletionQueue = *new std::deque<PendingDeletion>();
// Held by the calls below: handle owners go on whichever thread drops
// them (a loader's, say) while the render loop reclaims. Never
// destroyed, for the same reason as the queue.
inline static std::mutex& deletionMutex = *new std::mutex();
// Tag {buffer, memory} for deletion after framesInFlight more frames.
// A VK_NULL_HANDLE buffer is ignored (nothing to free).
static void EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory);
// The same for an acceleration structure, a shader module and a
// pipeline. Queue an acceleration structure before the buffer it lives
// in: entries are freed in queue order. A VK_NULL_HANDLE is ignored.
static void EnqueueDeletion(VkAccelerationStructureKHR accelerationStructure);
static void EnqueueDeletion(VkShaderModule shaderModule);
static void EnqueueDeletion(VkPipeline pipeline);
// Free every entry whose retire frame has been reached
// (retireAfter + framesInFlight <= frameCounter). Call once per frame
// after waiting that frame's fence.
static void ReclaimDeletions();
// Free every queued entry unconditionally. Call only after a wait-idle
// (resize / teardown), when no in-flight GPU work can reference them.
// Free every queued entry unconditionally, and let every buffer's
// staging ring start over. Call only after a wait-idle (resize /
// teardown), when no in-flight GPU work can reference them. Without a
// Window nothing else reclaims them: an app that has none calls this
// after waiting for its work, or both only grow.
static void DrainDeletions();
// How many DrainDeletions have run: the staging rings start over
// when it changes.
inline static std::atomic<std::uint64_t> drains = 0;
// ─── Wayland key repeat ────────────────────────────────────────
// TickKeyRepeats fires onRawKeyDown / onRawKeyHold / onTextInput on

View file

@ -177,46 +177,7 @@ export namespace Crafter {
&region
);
if(mipLevels > 1) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1);
for (std::uint16_t i = 1; i < mipLevels; ++i) {
std::uint16_t mipWidth = width >> i;
std::uint16_t mipHeight = height >> i;
std::uint16_t previousMipWidth = width >> (i - std::uint16_t(1));
std::uint16_t previousMipHeight = height >> (i - std::uint16_t(1));
VkImageBlit blit = {};
blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.srcSubresource.mipLevel = i - 1;
blit.srcSubresource.baseArrayLayer = 0;
blit.srcSubresource.layerCount = 1;
blit.srcOffsets[0] = { 0, 0, 0 };
blit.srcOffsets[1] = { (int32_t)previousMipWidth, (int32_t)previousMipHeight, 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;
blit.dstOffsets[0] = { 0, 0, 0 };
blit.dstOffsets[1] = { (int32_t)mipWidth, (int32_t)mipHeight, 1 };
vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
// The final blit's destination is never read again, so it
// skips the DST->SRC barrier and is taken straight to the
// consumer layout by the batched final transition below.
if (i + 1 < mipLevels) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1);
}
}
std::array<VkImageMemoryBarrier, 2> finalBarriers;
std::uint32_t finalCount = BuildMipChainFinalBarriers(image, layout, mipLevels, finalBarriers);
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, 0, 0, nullptr, 0, nullptr, finalCount, finalBarriers.data());
} else {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
}
BuildMipChain(cmd, layout);
ReleaseStaging();
}
@ -336,44 +297,51 @@ export namespace Crafter {
region.imageExtent = { width, height, 1 };
vkCmdCopyBufferToImage(cmd, buffer.buffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);
if (mipLevels > 1) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1);
for (std::uint16_t i = 1; i < mipLevels; ++i) {
std::uint16_t mipWidth = width >> i;
std::uint16_t mipHeight = height >> i;
std::uint16_t previousMipWidth = width >> (i - std::uint16_t(1));
std::uint16_t previousMipHeight = height >> (i - std::uint16_t(1));
VkImageBlit blit = {};
blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.srcSubresource.mipLevel = i - 1;
blit.srcSubresource.baseArrayLayer = 0;
blit.srcSubresource.layerCount = 1;
blit.srcOffsets[0] = { 0, 0, 0 };
blit.srcOffsets[1] = { (int32_t)previousMipWidth, (int32_t)previousMipHeight, 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;
blit.dstOffsets[0] = { 0, 0, 0 };
blit.dstOffsets[1] = { (int32_t)mipWidth, (int32_t)mipHeight, 1 };
vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
// The final blit's destination is never read again, so it
// skips the DST->SRC barrier and is taken straight to the
// consumer layout by the batched final transition below.
if (i + 1 < mipLevels) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1);
}
}
std::array<VkImageMemoryBarrier, 2> finalBarriers;
std::uint32_t finalCount = BuildMipChainFinalBarriers(image, layout, mipLevels, finalBarriers);
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, 0, 0, nullptr, 0, nullptr, finalCount, finalBarriers.data());
} else {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
}
BuildMipChain(cmd, layout);
ReleaseStaging();
}
// After level 0 was copied in (in TRANSFER_DST): blits each level from
// the one above it, then takes the whole image to `layout` for
// consumerStage. A level is max(1, size >> level) on each side, as
// Vulkan sizes it: a non-square image's shorter side reaches 1 before
// the chain ends, and a blit to 0 would write nothing.
void BuildMipChain(VkCommandBuffer cmd, VkImageLayout layout) {
if (mipLevels <= 1) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
return;
}
auto levelSize = [](std::uint16_t size, std::uint32_t level) {
return std::max<std::int32_t>(1, static_cast<std::int32_t>(size >> level));
};
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, 0, 1);
for (std::uint32_t i = 1; i < mipLevels; ++i) {
VkImageBlit blit = {};
blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.srcSubresource.mipLevel = i - 1;
blit.srcSubresource.baseArrayLayer = 0;
blit.srcSubresource.layerCount = 1;
blit.srcOffsets[0] = { 0, 0, 0 };
blit.srcOffsets[1] = { levelSize(width, i - 1), levelSize(height, i - 1), 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;
blit.dstOffsets[0] = { 0, 0, 0 };
blit.dstOffsets[1] = { levelSize(width, i), levelSize(height, i), 1 };
vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
// The final blit's destination is never read again, so it
// skips the DST->SRC barrier and is taken straight to the
// consumer layout by the batched final transition below.
if (i + 1 < mipLevels) {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, i, 1);
}
}
std::array<VkImageMemoryBarrier, 2> finalBarriers;
std::uint32_t finalCount = BuildMipChainFinalBarriers(image, layout, mipLevels, finalBarriers);
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, 0, 0, nullptr, 0, nullptr, finalCount, finalBarriers.data());
}
void Destroy() {
vkDestroyImageView(Device::device, imageView, nullptr);
vkDestroyImage(Device::device, image, nullptr);

View file

@ -49,6 +49,12 @@ export namespace Crafter {
// PREFER_FAST_BUILD are mutually exclusive, so `preference` selects
// exactly one — allowUpdate is layered on top of whichever is set.
bool allowUpdate = false;
// Set VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR: an any-hit
// shader then runs at most once per primitive along a ray, which a
// shader that accumulates along it (transmittance through tinted
// glass) relies on. Without it the implementation may call it twice
// for one primitive. Can cost some trace speed.
bool noDuplicateAnyHit = false;
};
class Mesh {
@ -75,12 +81,14 @@ export namespace Crafter {
// has cleared — no longer pinned for the mesh's life. Between Builds the
// handle is null; the next Build's Resize re-creates it.
VulkanBuffer<std::byte, true> compressedStaging;
VkAccelerationStructureGeometryTrianglesDataKHR blasData;
VkAccelerationStructureGeometryKHR blas;
VkAccelerationStructureKHR accelerationStructure = VK_NULL_HANDLE;
VkDeviceAddress blasAddr;
bool opaque;
VkDeviceAddress blasAddr = 0;
// ─── Build-option / refit state ──────────────────────────────────
// The geometry's flags at the last (re)build: OPAQUE for triangles
// and for procedural geometry built opaque, plus
// NO_DUPLICATE_ANY_HIT_INVOCATION when asked for. Kept because an
// UPDATE must describe its geometry exactly as the build did.
VkGeometryFlagsKHR geometryFlags = VK_GEOMETRY_OPAQUE_BIT_KHR;
// Flags the BLAS was last (re)built with — carried so Refit can
// re-issue an identical-flags UPDATE build (the FAST_TRACE/FAST_BUILD
// preference plus, when opted in, ALLOW_UPDATE).
@ -95,6 +103,23 @@ export namespace Crafter {
// Whether ALLOW_UPDATE was set on the last build, i.e. whether an
// in-place refit is possible at all.
bool allowUpdate = false;
Mesh() = default;
// Moving hands the BLAS over: the moved-from Mesh keeps no handle, so
// only the last owner releases it. Assigning queues the BLAS this one
// held, as the destructor would.
Mesh(Mesh&& other);
Mesh& operator=(Mesh&& other);
// Queues the BLAS for deferred deletion, ahead of the buffers it lives
// in (they queue themselves): a frame in flight may still trace it.
~Mesh();
// A rebuild (any Build on a Mesh that already has a BLAS) queues the
// old handle for deferred deletion, since this command buffer or a
// frame in flight may still use it, and builds into the same storage
// after a barrier when the new BLAS fits, so blasAddr normally stays
// the same. A BLAS that outgrows its storage moves, so read blasAddr
// again after a rebuild.
void Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd, RTBuildOptions options = {});
// GPU path: decompresses vertex (region 0) and index (region 1) streams
// from asset.blob into vertexBuffer / indexBuffer using
@ -126,7 +151,11 @@ export namespace Crafter {
// and VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, its boxes laid out as
// VkAabbPositionsKHR (`stride`, default sizeof(RTAabb) == 24), and the
// caller must pipeline-barrier the producing writes (e.g. a compute
// dispatch) before the build on `cmd` reads them. The buffer's
// dispatch) before the build on `cmd` reads them, with
// VK_ACCESS_SHADER_READ_BIT at the acceleration-structure build stage:
// a build reads its geometry as a shader read, and the
// ACCELERATION_STRUCTURE access bits cover only the scratch and the
// acceleration structures themselves. The buffer's
// lifetime is the caller's responsibility — it is never copied, so it
// must outlive the build submitted on `cmd`. Everything else (opaque
// bit, hit-group contract, instance wiring) matches the host-span
@ -151,8 +180,8 @@ export namespace Crafter {
// Dirty-range refit: same as Refit above, but only the contiguous block
// of vertices in [dirtyVertexOffset, dirtyVertexOffset + dirtyVertexCount)
// actually moved, so on the in-place UPDATE path only that sub-range is
// re-uploaded — host-write + flush + barrier (direct) or re-stage + copy
// (staged) scale with the moved vertices, not the whole array (#119). Use
// re-uploaded — the staged copy scales with the moved vertices, not the
// whole array (#119). Use
// this for a deforming mesh that nudges a small, known window each frame.
// `verticies` / `indicies` are still the *full* arrays (same contract and
// topology rule as the full-span Refit — the index buffer is read only
@ -229,6 +258,8 @@ export namespace Crafter {
struct RTBuildOptions {
RTBuildPreference preference = RTBuildPreference::FastTrace;
bool allowUpdate = false;
// No effect here either; for API symmetry like the rest.
bool noDuplicateAnyHit = false;
};
class Mesh {

View file

@ -14,14 +14,19 @@ import :ShaderBindingTableVulkan;
import :Types;
export namespace Crafter {
// Owns a ray-tracing pipeline and its shader binding table. Both go to
// the deferred-deletion queue when it is destroyed, moved over or
// initialised again: a frame in flight may still trace with them, and a
// static one is destroyed after the driver. It moves, and can't be copied:
// two copies would both release the pipeline.
struct PipelineRTVulkan {
VkPipeline pipeline;
std::vector<std::uint8_t> shaderHandles;
VkPipeline pipeline = VK_NULL_HANDLE;
std::vector<std::uint8_t> shaderHandles;
VulkanBuffer<std::uint8_t, true> sbtBuffer;
VkStridedDeviceAddressRegionKHR raygenRegion;
VkStridedDeviceAddressRegionKHR missRegion;
VkStridedDeviceAddressRegionKHR hitRegion;
VkStridedDeviceAddressRegionKHR callableRegion;
VkStridedDeviceAddressRegionKHR raygenRegion {};
VkStridedDeviceAddressRegionKHR missRegion {};
VkStridedDeviceAddressRegionKHR hitRegion {};
VkStridedDeviceAddressRegionKHR callableRegion {};
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): copied
// from the shader table at Init so RTPass can push the active TLAS
@ -30,6 +35,35 @@ export namespace Crafter {
bool workaroundNeedsTlas = false;
std::uint32_t workaroundTlasPushOffset = 0;
PipelineRTVulkan() = default;
PipelineRTVulkan(const PipelineRTVulkan&) = delete;
PipelineRTVulkan& operator=(const PipelineRTVulkan&) = delete;
PipelineRTVulkan(PipelineRTVulkan&& other) noexcept
: pipeline(std::exchange(other.pipeline, VK_NULL_HANDLE)),
shaderHandles(std::move(other.shaderHandles)),
sbtBuffer(std::move(other.sbtBuffer)),
raygenRegion(other.raygenRegion),
missRegion(other.missRegion),
hitRegion(other.hitRegion),
callableRegion(other.callableRegion),
workaroundNeedsTlas(other.workaroundNeedsTlas),
workaroundTlasPushOffset(other.workaroundTlasPushOffset) {}
PipelineRTVulkan& operator=(PipelineRTVulkan&& other) noexcept {
if (this == &other) return *this;
Device::EnqueueDeletion(std::exchange(pipeline, std::exchange(other.pipeline, VK_NULL_HANDLE)));
shaderHandles = std::move(other.shaderHandles);
sbtBuffer = std::move(other.sbtBuffer);
raygenRegion = other.raygenRegion;
missRegion = other.missRegion;
hitRegion = other.hitRegion;
callableRegion = other.callableRegion;
workaroundNeedsTlas = other.workaroundNeedsTlas;
workaroundTlasPushOffset = other.workaroundTlasPushOffset;
return *this;
}
// maxRecursionDepth: the maximum ray-recursion depth the pipeline must
// support — i.e. the deepest chain of nested traceRayEXT calls. The
// raygen counts as depth 1, so a closest-hit shader that traces a shadow
@ -67,7 +101,11 @@ export namespace Crafter {
.layout = VK_NULL_HANDLE
};
Device::CheckVkResult(Device::vkCreateRayTracingPipelinesKHR(Device::device, {}, Device::pipelineCache, 1, &rtPipelineInfo, nullptr, &pipeline));
VkPipeline created = VK_NULL_HANDLE;
Device::CheckVkResult(Device::vkCreateRayTracingPipelinesKHR(Device::device, {}, Device::pipelineCache, 1, &rtPipelineInfo, nullptr, &created));
// Initialising again replaces the pipeline (and below, the binding
// table), which a frame in flight may still trace with.
Device::EnqueueDeletion(std::exchange(pipeline, created));
std::size_t dataSize = Device::rayTracingProperties.shaderGroupHandleSize * rtPipelineInfo.groupCount;
shaderHandles.resize(dataSize);
@ -111,6 +149,7 @@ export namespace Crafter {
// direct-write type lacking HOST_COHERENT is still flushed correctly.
VkMemoryPropertyFlags sbtPreferred = Device::PreferDirectDeviceWrite(bufferSize)
? VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT : 0;
sbtBuffer.DeferredClear();
sbtBuffer.Create(VK_BUFFER_USAGE_2_SHADER_BINDING_TABLE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, bufferSize, sbtPreferred);
std::uint8_t* offset = sbtBuffer.value;
@ -138,7 +177,7 @@ export namespace Crafter {
}
~PipelineRTVulkan() {
vkDestroyPipeline(Device::device, pipeline, nullptr);
Device::EnqueueDeletion(pipeline);
}
};
}

View file

@ -17,6 +17,17 @@ import :RenderingElement3D;
export namespace Crafter {
struct RTPass : RenderPass {
PipelineRTVulkan* pipeline;
// The application's push data, pushed at offset 0 before every trace
// (e.g. a camera), as the DOM backend's RTPass takes it: it starts the
// shaders' push block. The pointed-to bytes are read at Record time,
// so updating them before Render() is enough. Null means none.
const void* pushPtr = nullptr;
std::uint32_t pushBytes = 0;
// The trace size; 0 means the window's current size, as before. Set
// it to trace into an image of its own size rather than the
// swapchain's.
std::uint32_t width = 0;
std::uint32_t height = 0;
RTPass(PipelineRTVulkan* p) : pipeline(p) {}
@ -29,6 +40,14 @@ export namespace Crafter {
void Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, pipeline->pipeline);
if (pushBytes != 0) {
VkPushDataInfoEXT userPush {
.sType = VK_STRUCTURE_TYPE_PUSH_DATA_INFO_EXT,
.offset = 0,
.data = { .address = pushPtr, .size = pushBytes },
};
Device::vkCmdPushDataEXT(cmd, &userPush);
}
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): feed
// the active frame's TLAS device address into the push-constant
// block that VulkanShader synthesizes, so the rewritten raygen can
@ -53,7 +72,7 @@ export namespace Crafter {
&pipeline->missRegion,
&pipeline->hitRegion,
&pipeline->callableRegion,
window.width, window.height, 1);
width != 0 ? width : window.width, height != 0 ? height : window.height, 1);
}
};
}

View file

@ -74,19 +74,35 @@ export namespace Crafter {
return access;
}
// Read and write access for the stages a pass touches the swapchain image
// in: the next pass after an inter-pass barrier may read the image as well
// as write it, through shaders or through transfers.
inline VkAccessFlags SwapchainPassAccess(VkPipelineStageFlags stages) {
VkAccessFlags access = 0;
if (stages & (VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT
| VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR))
access |= VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
if (stages & VK_PIPELINE_STAGE_TRANSFER_BIT)
access |= VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
return access;
}
// The inter-pass swapchain barrier (replacing the old queue-wide
// VkMemoryBarrier at the #115 location). Scoped to the swapchain image's
// single colour subresource so only that image's shader caches round-trip —
// unrelated buffers/images stay resident — exactly as the intra-pass UI
// barrier already does. The image is bound as a storage image and stays in
// VK_IMAGE_LAYOUT_GENERAL, so this is a pure memory dependency (no layout
// transition). The execution scope (stage masks) is supplied per pass pair
// by the caller via vkCmdPipelineBarrier.
inline VkImageMemoryBarrier BuildSwapchainInterPassBarrier(VkImage image) {
// transition). The stages are the two passes' SwapchainStage(), which the
// caller also hands vkCmdPipelineBarrier, and the access masks follow
// them: a transfer pass (a clear, a blit) writes with TRANSFER_WRITE, which
// SHADER_WRITE would not cover, and an access a stage can't perform is
// invalid in its barrier (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02818/02820).
inline VkImageMemoryBarrier BuildSwapchainInterPassBarrier(VkImage image, VkPipelineStageFlags srcStages, VkPipelineStageFlags dstStages) {
return {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
.srcAccessMask = SwapchainWriterAccess(srcStages),
.dstAccessMask = SwapchainPassAccess(dstStages),
.oldLayout = VK_IMAGE_LAYOUT_GENERAL,
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,

View file

@ -336,11 +336,10 @@ export namespace WorkaroundNvidiaAS {
export namespace Crafter {
class VulkanShader {
public:
std::vector<VkSpecializationMapEntry> specilizations;
VkSpecializationInfo* specilizationInfo;
VkShaderStageFlagBits stage;
std::string entrypoint;
VkShaderModule shader;
VkShaderModule shader = VK_NULL_HANDLE;
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): set when
// this module read an acceleration structure and was rewritten to fetch
// the TLAS device address from a push constant. `tlasPushOffset` is the
@ -362,23 +361,29 @@ export namespace Crafter {
if ((stage & rayTracingStages) != 0) {
Device::RequireRayTracing();
}
// A file that can't be read throws, naming it: carrying on would
// size the code from a failed tellg() (-1) and die in the
// allocation, or hand the driver no code at all.
std::ifstream file(path, std::ios::binary);
if (!file) {
std::cerr << "Error: Could not open file " << path << std::endl;
throw std::runtime_error(std::format("VulkanShader: could not open {}", path.string()));
}
// Move to the end of the file to determine its size
file.seekg(0, std::ios::end);
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
if (size <= 0 || size % sizeof(std::uint32_t) != 0) {
throw std::runtime_error(std::format("VulkanShader: {} is not SPIR-V ({} bytes)", path.string(), size));
}
std::vector<std::uint32_t> spirv(size / sizeof(std::uint32_t));
// Read the data into the vector
if (!file.read(reinterpret_cast<char*>(spirv.data()), size)) {
std::cerr << "Error: Could not read data from file" << std::endl;
throw std::runtime_error(std::format("VulkanShader: could not read {}", path.string()));
}
file.close();
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7).
@ -397,6 +402,35 @@ export namespace Crafter {
Device::CheckVkResult(vkCreateShaderModule(Device::device, &module_info, nullptr, &shader));
}
// The module is released through the deferred-deletion queue, so a
// static VulkanShader makes no Vulkan call at exit. Copies would both
// release it; a move hands it over.
VulkanShader(const VulkanShader&) = delete;
VulkanShader& operator=(const VulkanShader&) = delete;
VulkanShader(VulkanShader&& other) noexcept
: specilizationInfo(other.specilizationInfo)
, stage(other.stage)
, entrypoint(std::move(other.entrypoint))
, shader(std::exchange(other.shader, VK_NULL_HANDLE))
, patchedAS(other.patchedAS)
, tlasPushOffset(other.tlasPushOffset) {
}
VulkanShader& operator=(VulkanShader&& other) noexcept {
if (this != &other) {
Device::EnqueueDeletion(shader);
specilizationInfo = other.specilizationInfo;
stage = other.stage;
entrypoint = std::move(other.entrypoint);
shader = std::exchange(other.shader, VK_NULL_HANDLE);
patchedAS = other.patchedAS;
tlasPushOffset = other.tlasPushOffset;
}
return *this;
}
~VulkanShader() {
Device::EnqueueDeletion(shader);
}
};
}
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -273,7 +273,10 @@ export namespace Crafter {
std::uint32_t gx, std::uint32_t gy = 1, std::uint32_t gz = 1);
// Allocates a heap slot for the buffer and registers the GPU handle.
// Returns a move-only BufferSlot RAII handle.
// Returns a move-only BufferSlot RAII handle. The slot points at the
// buffer by its device address, so it needs one: a buffer made without
// VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT is refused
// (std::invalid_argument).
template<typename T, bool Mapped>
BufferSlot RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer);
@ -441,6 +444,11 @@ export namespace Crafter {
// ─── template-method implementations ────────────────────────────────
template<typename T, bool Mapped>
BufferSlot UIRenderer::RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
if (buffer.address == 0) {
throw std::invalid_argument("UIRenderer::RegisterBuffer: the buffer has no device address (create it with VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT)");
}
#endif
auto range = heap_->AllocateBufferSlots(1);
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
WriteBufferDescriptor(range.firstElement, buffer.buffer, buffer.address, buffer.size);

View file

@ -109,11 +109,17 @@ namespace Crafter {
.memoryTypeIndex = memoryTypeIndex
};
// Only a buffer created for SHADER_DEVICE_ADDRESS may have its
// address queried (VUID-VkBufferDeviceAddressInfo-buffer-02601);
// the others keep address 0.
const bool addressable = (usageFlags & VK_BUFFER_USAGE_2_SHADER_DEVICE_ADDRESS_BIT) != 0;
VkMemoryAllocateFlagsInfoKHR allocFlagsInfo {
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO_KHR,
.flags = VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT_KHR,
};
memAlloc.pNext = &allocFlagsInfo;
if (addressable) {
memAlloc.pNext = &allocFlagsInfo;
}
Device::CheckVkResult(vkAllocateMemory(Device::device, &memAlloc, nullptr, &memory));
Device::CheckVkResult(vkBindBufferMemory(Device::device, buffer, memory, 0));
@ -122,14 +128,12 @@ namespace Crafter {
.sType = VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO,
.buffer = buffer
};
address = vkGetBufferDeviceAddress(Device::device, &addressInfo);
address = addressable ? vkGetBufferDeviceAddress(Device::device, &addressInfo) : 0;
// Record the allocation's byte size (≥ `size`) for every buffer,
// not just mapped ones: UploadDeviceLocalRange's direct path flushes
// only the dirty sub-range and clamps its rounded-up upper bound to
// this (the nonCoherentAtomSize-aligned-end exception, see
// AlignMappedFlushRange). A non-mapped buffer never maps persistently,
// but its memory is still the allocation a transient map writes into.
// Record the allocation's byte size (≥ `size`): a ranged
// FlushDevice flushes only the dirty sub-range and clamps its
// rounded-up upper bound to this (the nonCoherentAtomSize-aligned-end
// exception, see AlignMappedFlushRange).
mappedSize = memReqs.size;
if constexpr(Mapped) {
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, memReqs.size, 0, reinterpret_cast<void**>(&(VulkanBufferMappedConditional<T, true>::value))));
@ -227,97 +231,62 @@ namespace Crafter {
// Upload `count` host elements from `src` into this buffer as
// device-local, GPU-read geometry, choosing placement at runtime via
// the #89 upload strategy (Device::PreferDirectDeviceWrite) and then
// recording a barrier from the upload to (dstStageMask, dstAccessMask)
// on `cmd`:
// the #89 upload strategy (Device::PreferDirectDeviceWrite), and order
// the upload before (dstStageMask, dstAccessMask) on `cmd`:
// ReBAR / UMA (direct) — allocate HOST_VISIBLE with DEVICE_LOCAL as a
// best-effort preference (GetMemoryType lands the host-visible
// device-local type that the strategy already proved exists), map
// transiently, memcpy, flush if the chosen type isn't coherent,
// unmap. No staging buffer: it would be pure overhead on a bar where
// the device-local heap is itself host-writable.
// No / small BAR (staged) — allocate pure DEVICE_LOCAL (+ TRANSFER_DST),
// fill a transient HOST_VISIBLE staging buffer, vkCmdCopyBuffer into
// this buffer, then hand the staging buffer to the fence-keyed
// deferred-deletion queue (#101/#102) so it outlives the copy submit.
// Requires !Mapped: the destination must be free to be device-local-only
// (a persistent map would force HOST_VISIBLE), so the direct path maps
// just long enough to write. A same-size re-upload reuses the allocation
// (Resize), so the device address stays stable across an in-place AS
// UPDATE refit that re-calls this.
// No / small BAR (staged) — allocate pure DEVICE_LOCAL and copy from
// the staging ring on the GPU (CopyThroughStaging).
// The direct write is only for a fresh allocation, which nothing can be
// reading yet. A same-size re-upload reuses the allocation (Resize), so
// the device address stays stable across an in-place AS UPDATE refit
// that re-calls this, but a frame in flight may still read it: that
// upload always copies, on the GPU timeline, after those reads.
// dstStageMask must name every stage that reads the buffer, since the
// next upload waits for them. Requires !Mapped: the destination must be
// free to be device-local-only (a persistent map would force
// HOST_VISIBLE), so the direct path maps just long enough to write.
void UploadDeviceLocal(VkBufferUsageFlags2 usageFlags, const T* src, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) {
VkDeviceSize bytes = static_cast<VkDeviceSize>(count) * sizeof(T);
VkAccessFlags srcAccessMask;
VkPipelineStageFlags srcStageMask;
if (Device::PreferDirectDeviceWrite(bytes)) {
// Nothing to put anywhere: no buffer of no bytes, no copy of none.
if (count == 0) return;
const VkDeviceSize bytes = static_cast<VkDeviceSize>(count) * sizeof(T);
// Any upload may be followed by one that copies in.
usageFlags |= VK_BUFFER_USAGE_TRANSFER_DST_BIT;
const bool direct = Device::PreferDirectDeviceWrite(bytes);
const VkBuffer previous = buffer;
if (direct) {
Resize(usageFlags, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped));
std::memcpy(mapped, src, bytes);
// Match FlushDevice()'s gate: a non-coherent type needs an
// explicit flush before the device reads the written range.
if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
VkMappedMemoryRange range {
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = 0,
.size = VK_WHOLE_SIZE
};
vkFlushMappedMemoryRanges(Device::device, 1, &range);
}
vkUnmapMemory(Device::device, memory);
srcAccessMask = VK_ACCESS_HOST_WRITE_BIT;
srcStageMask = VK_PIPELINE_STAGE_HOST_BIT;
} else {
Resize(usageFlags | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count);
// Persistent staging instead of a Create+DeferredClear per call
// (issue #120): on no-/small-BAR hardware Mesh::Refit /
// RecordProceduralBuild hit this branch every frame for deforming
// meshes, so a fresh alloc+map+free cycle per upload was pure
// churn on exactly the hardware this path targets. Keep a small
// ring of staging buffers, each grown to a high-water mark and
// reused — reallocated (by Resize, which defers the outgrown
// allocation) only when a larger upload arrives.
//
// The ring is grown lazily, on first entry into this staged
// branch, so ReBAR/UMA hardware (which always takes the direct
// `if` branch above) never constructs a staging allocation it
// does not use.
//
// Why a ring and not one shared buffer: the vkCmdCopyBuffer below
// still reads the staging buffer after this call returns — the
// reason the old code used DeferredClear. With frames pipelined
// framesInFlight deep, overwriting one shared staging buffer next
// frame would clobber data the previous frame's copy is still
// reading. Indexing by frameCounter % framesInFlight gives each
// in-flight frame its own slot; a slot is rewritten only after
// framesInFlight frames have elapsed — the exact window after
// which single-queue submission order (the same guarantee the
// #101 deletion queue relies on) ensures that copy has completed.
const std::uint32_t ringSize =
Device::framesInFlight ? Device::framesInFlight : 1;
if (stagingRing.size() < ringSize) {
stagingRing.resize(ringSize);
}
VulkanBuffer<T, true>& staging =
stagingRing[Device::frameCounter % ringSize];
// SHADER_DEVICE_ADDRESS: Create always queries the buffer device
// address (and allocates with the device-address bit); the
// staging buffer's own address is otherwise unused. Resize reuses
// the existing allocation (and its mapped pointer) when the
// upload still fits the slot's high-water capacity.
staging.Resize(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count);
std::memcpy(staging.value, src, bytes);
staging.FlushDevice();
VkBufferCopy region { .srcOffset = 0, .dstOffset = 0, .size = bytes };
vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, &region);
srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
Resize(usageFlags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count);
}
if (!direct || buffer == previous) {
CopyThroughStaging(src, 0, count, cmd, dstAccessMask, dstStageMask);
return;
}
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped));
std::memcpy(mapped, src, bytes);
// Match FlushDevice()'s gate: a non-coherent type needs an
// explicit flush before the device reads the written range.
if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
VkMappedMemoryRange range {
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = 0,
.size = VK_WHOLE_SIZE
};
vkFlushMappedMemoryRanges(Device::device, 1, &range);
}
vkUnmapMemory(Device::device, memory);
VkBufferMemoryBarrier barrier = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = srcAccessMask,
.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT,
.dstAccessMask = dstAccessMask,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
@ -325,79 +294,55 @@ namespace Crafter {
.offset = 0,
.size = VK_WHOLE_SIZE
};
vkCmdPipelineBarrier(cmd, srcStageMask, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL);
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_HOST_BIT, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL);
}
// Re-upload only the half-open sub-range [offset, offset+count) of an
// already-allocated device-local buffer — the dirty-range counterpart of
// Re-upload only the half-open sub-range [offset, offset+count) of a
// buffer UploadDeviceLocal made — the dirty-range counterpart of
// UploadDeviceLocal. `src` points at the first changed element (not the
// start of the whole array); `offset` is that element's index in this
// buffer. The buffer must already exist at its full size (a prior
// UploadDeviceLocal / Build sized it): this never Resizes, so the device
// address stays stable and the untouched elements keep their last
// contents. Only the dirty bytes are written + flushed (direct path) or
// staged + copied (staged path), and the post-upload barrier covers only
// that sub-range — so a deforming-mesh refit that nudges a few vertices
// pays for those vertices, not a full-array re-upload + flush + copy (#119).
//
// The direct-vs-staged choice is read from the memory type the buffer was
// actually allocated with (HOST_VISIBLE → map + write in place;
// device-local-only → stage + GPU copy), NOT from the sub-range size: the
// allocation is fixed, so a small dirty range must not be mis-routed to a
// map of a non-host-visible buffer the way UploadDeviceLocal's size-based
// PreferDirectDeviceWrite check would. The staged path's GPU copy needs
// VK_BUFFER_USAGE_TRANSFER_DST_BIT on the destination — already present,
// since UploadDeviceLocal only allocates device-local-only memory on the
// staged path, where it adds that bit.
// buffer. This never Resizes, so the device address stays stable and the
// untouched elements keep their last contents. Only the dirty elements
// are staged and copied, and the barriers cover only that sub-range — so
// a deforming-mesh refit that nudges a few vertices pays for those
// vertices, not a full-array re-upload (#119). The buffer is one a frame
// in flight may still read, so the copy waits for its readers
// (dstStageMask, as in UploadDeviceLocal) on the GPU, whatever memory
// type it lives in.
void UploadDeviceLocalRange(const T* src, std::uint32_t offset, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) {
VkDeviceSize byteOffset = static_cast<VkDeviceSize>(offset) * sizeof(T);
VkDeviceSize bytes = static_cast<VkDeviceSize>(count) * sizeof(T);
VkAccessFlags srcAccessMask;
VkPipelineStageFlags srcStageMask;
if (memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped));
std::memcpy(static_cast<std::byte*>(mapped) + byteOffset, src, bytes);
// Non-coherent memory needs an explicit flush — but only of the
// sub-range actually written, rounded outward to
// nonCoherentAtomSize (and clamped to the allocation size).
if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
MappedFlushRange r = AlignMappedFlushRange(
byteOffset, bytes, Device::nonCoherentAtomSize, mappedSize);
VkMappedMemoryRange range {
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = r.offset,
.size = r.size
};
vkFlushMappedMemoryRanges(Device::device, 1, &range);
}
vkUnmapMemory(Device::device, memory);
srcAccessMask = VK_ACCESS_HOST_WRITE_BIT;
srcStageMask = VK_PIPELINE_STAGE_HOST_BIT;
} else {
// Device-local-only: stage just the dirty elements and copy them
// into place at byteOffset. The staging buffer outlives the queued
// copy via the fence-keyed deletion queue (#101/#102), exactly as
// the full-buffer staged path does.
VulkanBuffer<T, true> staging;
staging.Create(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count);
std::memcpy(staging.value, src, bytes);
staging.FlushDevice();
VkBufferCopy region { .srcOffset = 0, .dstOffset = byteOffset, .size = bytes };
vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, &region);
staging.DeferredClear();
srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
}
CopyThroughStaging(src, offset, count, cmd, dstAccessMask, dstStageMask);
}
// Order only the written sub-range before the consumer reads it. The
// untouched bytes were made visible by their own prior upload's
// barrier and are not written in this submit, so they need no fresh
// dependency even though the build reads the whole buffer.
VkBufferMemoryBarrier barrier = {
// Copy `count` elements from `src` to element `offset` of this buffer on
// the GPU timeline: after the reads by `dstStageMask` recorded earlier
// or still in flight, and before the ones that follow. The bytes go
// through this frame's slot of the staging ring first.
void CopyThroughStaging(const T* src, std::uint32_t offset, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) {
// A copy or barrier of no bytes is invalid usage.
if (count == 0) return;
const VkDeviceSize byteOffset = static_cast<VkDeviceSize>(offset) * sizeof(T);
const VkDeviceSize bytes = static_cast<VkDeviceSize>(count) * sizeof(T);
// Write after read: an execution dependency on the earlier readers.
VkBufferMemoryBarrier readsDone = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = srcAccessMask,
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.buffer = buffer,
.offset = byteOffset,
.size = bytes
};
vkCmdPipelineBarrier(cmd, dstStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, NULL, 1, &readsDone, 0, NULL);
const auto [staging, stagingOffset] = Stage(src, count);
VkBufferCopy region { .srcOffset = stagingOffset, .dstOffset = byteOffset, .size = bytes };
vkCmdCopyBuffer(cmd, staging, buffer, 1, &region);
VkBufferMemoryBarrier copied = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
.dstAccessMask = dstAccessMask,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
@ -405,7 +350,47 @@ namespace Crafter {
.offset = byteOffset,
.size = bytes
};
vkCmdPipelineBarrier(cmd, srcStageMask, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL);
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask, 0, 0, NULL, 1, &copied, 0, NULL);
}
// Put `count` elements in this frame's slot of the staging ring, after
// whatever this frame staged before, and say where: the slot's buffer
// and the byte offset. A copy still in flight never reads the slot, as
// a slot is used again only framesInFlight frames later — the window
// after which single-queue submission order (the same guarantee the
// #101 deletion queue relies on) ensures that copy has completed. Each
// slot keeps its high-water size (issue #120); outgrowing it mid-frame
// hands the old allocation, with this frame's earlier bytes, to the
// deletion queue. Without a window no frame passes; after a
// Device::DrainDeletions (which follows a wait for the queue) nothing
// reads any slot, so they all start over.
std::pair<VkBuffer, VkDeviceSize> Stage(const T* src, std::uint32_t count) requires(!Mapped) {
const std::uint32_t ringSize = Device::framesInFlight ? Device::framesInFlight : 1;
if (stagingRing.size() < ringSize) {
stagingRing.resize(ringSize);
stagingFrame.resize(ringSize, std::numeric_limits<std::uint64_t>::max());
stagingUsed.resize(ringSize, 0);
}
if (stagingDrains != Device::drains) {
stagingDrains = Device::drains;
std::ranges::fill(stagingUsed, 0);
}
const std::size_t slot = Device::frameCounter % ringSize;
if (stagingFrame[slot] != Device::frameCounter) {
stagingFrame[slot] = Device::frameCounter;
stagingUsed[slot] = 0;
}
VulkanBuffer<T, true>& staging = stagingRing[slot];
const std::uint32_t needed = stagingUsed[slot] + count;
if (staging.buffer == VK_NULL_HANDLE || static_cast<VkDeviceSize>(needed) * sizeof(T) > staging.capacity) {
staging.Resize(VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, needed);
stagingUsed[slot] = 0;
}
const std::uint32_t first = stagingUsed[slot];
std::memcpy(staging.value + first, src, static_cast<std::size_t>(count) * sizeof(T));
staging.FlushDevice(static_cast<VkDeviceSize>(first) * sizeof(T), static_cast<VkDeviceSize>(count) * sizeof(T));
stagingUsed[slot] = first + count;
return { staging.buffer, static_cast<VkDeviceSize>(first) * sizeof(T) };
}
void FlushDevice() requires(Mapped) {
@ -516,18 +501,31 @@ namespace Crafter {
vkInvalidateMappedMemoryRanges(Device::device, 1, &range);
}
// Persistent per-frame-in-flight staging ring for the staged
// UploadDeviceLocal path (issue #120). Empty — and so holding zero
// host-visible allocations — until the first upload that actually
// stages, which only happens on no-/small-BAR hardware (ReBAR/UMA always
// takes the direct-write branch and never touches this, so the lazy ring
// keeps the change a no-op there). Sized to Device::framesInFlight so
// each in-flight frame copies from its own slot; each slot grows to a
// high-water mark via Resize and is reused across frames rather than
// reallocated every call — mirroring the TLAS instance/metadata reuse.
// Persistent per-frame-in-flight staging ring for the copies Stage
// records (issue #120). Empty — and so holding zero host-visible
// allocations — until the first upload that stages: on ReBAR/UMA only
// re-uploads do, as a first upload writes directly. Sized to
// Device::framesInFlight so each in-flight frame copies from its own
// slot; each slot grows to a high-water mark via Resize and is reused
// across frames rather than reallocated every call — mirroring the TLAS
// instance/metadata reuse. stagingFrame and stagingUsed say, per slot,
// which frame last staged into it and how many elements it has staged;
// stagingDrains, the Device::drains it last saw.
std::vector<VulkanBuffer<T, true>> stagingRing;
std::vector<std::uint64_t> stagingFrame;
std::vector<std::uint32_t> stagingUsed;
std::uint64_t stagingDrains = 0;
VulkanBuffer(VulkanBuffer&& other) {
VulkanBuffer(VulkanBuffer&& other) noexcept {
*this = std::move(other);
}
// Takes `other`'s buffer over. The one this held goes to the deletion
// queue, as the destructor would send it: a frame in flight may still
// read it.
VulkanBuffer& operator=(VulkanBuffer&& other) noexcept {
if (this == &other) return *this;
DeferredClear();
buffer = other.buffer;
memory = other.memory;
size = other.size;
@ -538,15 +536,21 @@ namespace Crafter {
other.buffer = VK_NULL_HANDLE;
address = other.address;
stagingRing = std::move(other.stagingRing);
stagingFrame = std::move(other.stagingFrame);
stagingUsed = std::move(other.stagingUsed);
stagingDrains = other.stagingDrains;
if constexpr(Mapped) {
VulkanBufferMappedConditional<T, true>::value = other.VulkanBufferMappedConditional<T, true>::value;
}
};
return *this;
}
// Deferred, like DeferredClear: the owner may go while a frame in
// flight still reads the buffer, and a static owner goes after the
// driver has torn down. Call Clear() first to free at once when the
// GPU is known to be idle. The staging ring's buffers defer the same way.
~VulkanBuffer() {
if(buffer != VK_NULL_HANDLE) {
Clear();
}
DeferredClear();
}
VulkanBuffer(VulkanBuffer&) = delete;

View file

@ -56,6 +56,21 @@ export namespace Crafter {
struct DescriptorHeapWebGPU;
#endif
// How a window hands finished frames to the display. Fifo waits for the
// display's refresh; it is the default and the one mode every surface
// has. Mailbox replaces a frame still waiting to be shown, so presenting
// never waits, which suits a window that updates on its own clock
// (Window::updateInterval). Immediate shows each frame at once and may
// tear. A mode the surface lacks falls back to Fifo, as does one whose
// swapchain the driver makes with more images than the frame loop's
// Window::numFrames (Mesa's Mailbox has at least four). In the browser
// the page presents the canvas and the mode has no effect.
enum class PresentMode {
Fifo,
Mailbox,
Immediate,
};
struct Window {
FrameTime currentFrameTime;
std::uint32_t width;
@ -71,6 +86,17 @@ export namespace Crafter {
Event<void> onResize;
bool open = true;
bool updating = false;
// Zero (the default): Update runs when the compositor's frame
// callback says the window is about to show a new frame, so it
// follows the display and stops while the window is hidden or
// minimized. Non-zero: Update runs on this clock instead, visible or
// not, for an app whose output goes somewhere besides the screen.
// Pair it with PresentMode::Mailbox so a present never waits on the
// display. Set it before StartUpdate. Native only: in the browser,
// requestAnimationFrame drives Update.
std::chrono::nanoseconds updateInterval{0};
// The mode SetPresentMode last asked for.
PresentMode presentMode = PresentMode::Fifo;
// Currently-pressed raw key codes. The runtime stores raw platform
// codes only (Win32 PS/2 scancode + extended bit; Wayland kernel
// keycode). Cross-platform default bindings use `Key(CrafterKeys::X)`
@ -156,6 +182,11 @@ export namespace Crafter {
void StartUpdate();
void StopUpdate();
// Makes the swapchain again with the new mode and fires onResize, as
// a resize does, so call it outside StartInit/FinishInit. The
// environment variable CRAFTER_PRESENT_IMMEDIATE overrides the mode,
// for benchmarks.
void SetPresentMode(PresentMode mode);
void SetTitle(const std::string_view title);
// The xdg app id, which the compositor matches to a .desktop file
// for the icon, the task switcher and activation.
@ -208,6 +239,21 @@ export namespace Crafter {
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
// Wakes the event loop when `fd` becomes readable and calls
// `onReadable` from it, which must consume what made the descriptor
// readable, or the loop wakes again at once. For a socket, or an
// eventfd another thread signals, so new data can drive an Update
// right away (call Update from the callback) instead of waiting for
// the next one. StartSync's loop serves the descriptors of every
// window. Remove a descriptor before closing it.
void AddPollFd(int fd, std::function<void()> onReadable);
void RemovePollFd(int fd);
struct PollFd {
int fd;
std::function<void()> onReadable;
};
std::vector<PollFd> pollFds;
float scale = 1.0f;
bool configured = false;
// Pending size from the most recent xdg_toplevel.configure, in
@ -273,14 +319,28 @@ export namespace Crafter {
// stay valid. Does NOT fire onResize — callers do that.
void RecreateSwapchainAndImages();
// Save the current swapchain image (state after Render() returns) to
// a PNG file. Allocates a one-shot staging buffer + command buffer,
// copies image-to-buffer, waits idle, then writes PNG via stb. Useful
// for visual regression tests and screenshotting from headless code.
void SaveFrame(const std::filesystem::path& path);
// Saves the frame the next Render() draws to a PNG at `path`, for
// visual regression tests and screenshots: that Render copies the
// swapchain image into a readback buffer before presenting it, waits
// for the frame, and writes the file. The copy can't come later: a
// presented image belongs to the presentation engine until it is
// acquired again.
void CaptureNextFrame(std::filesystem::path path);
std::optional<std::filesystem::path> pendingCapture;
static constexpr std::uint8_t numFrames = 3;
VkSurfaceKHR vulkanSurface = VK_NULL_HANDLE;
VkSwapchainKHR swapChain = VK_NULL_HANDLE;
// What the swapchain images were created for: STORAGE | TRANSFER_SRC,
// plus TRANSFER_DST where the surface allows it, so a pass can blit or
// clear into the image instead of only writing it from a shader.
VkImageUsageFlags swapchainUsage = 0;
// The mode the swapchain was made with: presentMode, unless the
// surface lacks it or CRAFTER_PRESENT_IMMEDIATE is set.
VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
// The mode presentMode asks for made a swapchain with more images than
// numFrames once, so swapchains are FIFO until SetPresentMode is
// called again, rather than being made twice at every resize.
bool presentModeUnfit = false;
VkFormat colorFormat;
VkColorSpaceKHR colorSpace;
// Supported composite-alpha mode for vulkanSurface. A surface property

File diff suppressed because it is too large Load diff

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