2026-07-22 18:09:06 +02:00
|
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
2026-06-17 17:40:39 +00:00
|
|
|
|
|
|
|
|
// Issue #67: the compressed Mesh::Build path used to keep `compressedStaging`
|
|
|
|
|
// — a per-mesh host-visible staging buffer holding the GDeflate streams —
|
|
|
|
|
// alive for the whole life of the mesh, pinning host-visible memory long after
|
|
|
|
|
// the one decompress that reads it has retired. Build now releases it via
|
|
|
|
|
// VulkanBuffer::DeferredClear() right after recording the decompress, so the
|
|
|
|
|
// fence-keyed deletion queue (#101/#102) frees it once that submit's frame has
|
|
|
|
|
// cleared instead of pinning it forever.
|
|
|
|
|
//
|
|
|
|
|
// This drives the real hardware GPU-decompress path (VK_EXT_memory_decompression,
|
|
|
|
|
// GDeflate 1.0) on a headless device — no swapchain/window needed, a decompress +
|
|
|
|
|
// BLAS build only touches the queue + command pool — and asserts:
|
|
|
|
|
// - After Build, mesh.compressedStaging owns no handle (released).
|
|
|
|
|
// - Exactly that one allocation was handed to Device's deletion queue, tagged
|
|
|
|
|
// with the current frameCounter (so it retires on a later frame, not now).
|
|
|
|
|
// - The decompress + BLAS build still complete with ZERO validation errors and
|
|
|
|
|
// a non-zero BLAS address — proof the staging genuinely outlived the submit
|
|
|
|
|
// (the queue had not yet retired it), so this is no use-after-free.
|
|
|
|
|
// - The GPU-decompressed vertex/index data read back byte-equal to the source,
|
|
|
|
|
// so releasing the staging didn't corrupt the result.
|
|
|
|
|
// - The deferred entry actually retires (and frees) once framesInFlight frames
|
|
|
|
|
// elapse, and not before — the whole point of routing through the queue.
|
|
|
|
|
//
|
2026-06-17 18:50:38 +00:00
|
|
|
// As a regression guard for issue #110 it then repeats the Build with
|
|
|
|
|
// allowUpdate=false and asserts the queue holds exactly TWO entries — the
|
|
|
|
|
// compressed staging PLUS the dead per-mesh BLAS scratch a static build can no
|
|
|
|
|
// longer refit. That extra scratch was the unaccounted-for entry behind #110
|
|
|
|
|
// (the original test did a static build yet asserted size==1); pinning the
|
|
|
|
|
// breakdown keeps the count above a known, named quantity.
|
|
|
|
|
//
|
2026-06-17 17:40:39 +00:00
|
|
|
// On hardware without VK_EXT_memory_decompression the compressed Build takes the
|
|
|
|
|
// CPU-decode fallback, which never allocates compressedStaging, so the new
|
|
|
|
|
// behavior is moot — the test reports that and skips the GPU-path assertions.
|
|
|
|
|
|
|
|
|
|
#include "vulkan/vulkan.h"
|
|
|
|
|
#include <cstring>
|
|
|
|
|
|
|
|
|
|
import Crafter.Graphics;
|
|
|
|
|
import Crafter.Asset;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One-time command buffer helpers — record, submit, block. Mirrors the other
|
|
|
|
|
// headless RT tests (BLASBuildOptions).
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A unit cube (8 verts, 36 indices) — small enough to fit one GDeflate tile,
|
|
|
|
|
// enough topology for a real BLAS.
|
|
|
|
|
MeshAsset<void> MakeCubeMesh() {
|
|
|
|
|
MeshAsset<void> mesh;
|
|
|
|
|
mesh.vertexes = {
|
|
|
|
|
{-1.f, -1.f, -1.f}, { 1.f, -1.f, -1.f},
|
|
|
|
|
{ 1.f, 1.f, -1.f}, {-1.f, 1.f, -1.f},
|
|
|
|
|
{-1.f, -1.f, 1.f}, { 1.f, -1.f, 1.f},
|
|
|
|
|
{ 1.f, 1.f, 1.f}, {-1.f, 1.f, 1.f},
|
|
|
|
|
};
|
|
|
|
|
mesh.indexes = {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
return mesh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
int main() {
|
|
|
|
|
Device::Initialize();
|
|
|
|
|
Device::validationErrorCount = 0;
|
|
|
|
|
|
|
|
|
|
// Build → compress → load a cube via the real asset pipeline.
|
|
|
|
|
MeshAsset<void> srcMesh = MakeCubeMesh();
|
|
|
|
|
const fs::path meshPath =
|
|
|
|
|
fs::temp_directory_path() / "crafter_meshdecompress_staging_release.cmesh";
|
|
|
|
|
srcMesh.SaveCompressed(meshPath);
|
|
|
|
|
CompressedMeshAsset asset = LoadCompressedMesh(meshPath);
|
|
|
|
|
fs::remove(meshPath);
|
|
|
|
|
|
|
|
|
|
Check(asset.vertexCount == srcMesh.vertexes.size()
|
|
|
|
|
&& asset.indexCount == srcMesh.indexes.size(),
|
|
|
|
|
"LoadCompressedMesh round-trips the cube header");
|
|
|
|
|
|
|
|
|
|
if (!Device::memoryDecompressionSupported) {
|
|
|
|
|
// No GPU codec → compressed Build takes the CPU-decode fallback, which
|
|
|
|
|
// never allocates compressedStaging. The release behavior under test
|
|
|
|
|
// only exists on the GPU path, so there is nothing to assert here.
|
|
|
|
|
std::println("VK_EXT_memory_decompression absent — GPU decompress path "
|
|
|
|
|
"not exercised; skipping staging-release assertions.");
|
|
|
|
|
return failures == 0 ? 0 : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Control the deferred-deletion clock: enqueue at frame 0, retire after 2.
|
|
|
|
|
Device::framesInFlight = 2;
|
|
|
|
|
Device::frameCounter = 0;
|
|
|
|
|
Device::deletionQueue.clear();
|
|
|
|
|
|
|
|
|
|
Mesh mesh;
|
|
|
|
|
VkCommandBuffer cmd = BeginCmd();
|
2026-06-17 18:05:13 +00:00
|
|
|
// allowUpdate=true so the per-mesh BLAS scratch is RETAINED for refit
|
|
|
|
|
// (issue #66) rather than deferred-cleared after the build. A static
|
|
|
|
|
// (allowUpdate=false) build would also hand its scratch to the deletion
|
|
|
|
|
// queue, making the count below 2; keeping it isolates this test to the
|
|
|
|
|
// one allocation it cares about — the released compressed staging (#67).
|
|
|
|
|
// The staging-release behavior under test is independent of allowUpdate.
|
|
|
|
|
mesh.Build(asset, cmd, RTBuildOptions{ .allowUpdate = true });
|
2026-06-17 17:40:39 +00:00
|
|
|
|
|
|
|
|
// ── The fix: staging is released to the queue at record time, not pinned. ──
|
|
|
|
|
Check(mesh.compressedStaging.buffer == VK_NULL_HANDLE,
|
|
|
|
|
"Build releases compressedStaging (no handle pinned to the mesh)");
|
|
|
|
|
Check(Device::deletionQueue.size() == 1,
|
|
|
|
|
"exactly one allocation (the staging) handed to the deletion queue");
|
|
|
|
|
const bool taggedNow = !Device::deletionQueue.empty()
|
|
|
|
|
&& Device::deletionQueue.front().retireAfter == 0
|
|
|
|
|
&& Device::deletionQueue.front().buffer != VK_NULL_HANDLE;
|
|
|
|
|
Check(taggedNow,
|
|
|
|
|
"deferred staging is tagged with the enqueue frame and still a live handle");
|
|
|
|
|
|
|
|
|
|
// The decompress recorded above still references the staging's address; it
|
|
|
|
|
// must outlive THIS submit. The queue hasn't retired it (frame 0, retires at
|
|
|
|
|
// 2), so the GPU reads valid memory. Zero validation errors + a real BLAS
|
|
|
|
|
// address prove there's no use-after-free.
|
|
|
|
|
SubmitWait(cmd);
|
|
|
|
|
Check(mesh.blasAddr != 0, "compressed Build produced a non-zero BLAS address");
|
|
|
|
|
Check(Device::validationErrorCount == 0,
|
|
|
|
|
"decompress + BLAS build raised no validation errors");
|
|
|
|
|
|
2026-06-17 18:05:13 +00:00
|
|
|
// The GPU decompress wrote into the device-local vertex/index buffers
|
|
|
|
|
// (issue #73 placed RT geometry in VRAM, so they are no longer host-mapped).
|
|
|
|
|
// Copy them back to host-visible staging — the proper way to inspect
|
|
|
|
|
// device-local memory — and compare: releasing the compressed staging must
|
|
|
|
|
// not have corrupted the decompressed result. The geometry buffers carry
|
|
|
|
|
// TRANSFER_SRC for exactly this (#73).
|
|
|
|
|
VulkanBuffer<Vector<float, 3, 3>, true> vertReadback;
|
|
|
|
|
VulkanBuffer<std::uint32_t, true> idxReadback;
|
|
|
|
|
vertReadback.Resize(VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
|
|
|
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, static_cast<std::uint32_t>(srcMesh.vertexes.size()));
|
|
|
|
|
idxReadback.Resize(VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
|
|
|
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, static_cast<std::uint32_t>(srcMesh.indexes.size()));
|
|
|
|
|
{
|
|
|
|
|
// Safe to copy without a barrier: SubmitWait above wait-idled the queue,
|
|
|
|
|
// so the decompress + BLAS reads of these buffers have fully retired.
|
|
|
|
|
VkCommandBuffer rcmd = BeginCmd();
|
|
|
|
|
VkBufferCopy vRegion { .srcOffset = 0, .dstOffset = 0, .size = vertReadback.size };
|
|
|
|
|
vkCmdCopyBuffer(rcmd, mesh.vertexBuffer.buffer, vertReadback.buffer, 1, &vRegion);
|
|
|
|
|
VkBufferCopy iRegion { .srcOffset = 0, .dstOffset = 0, .size = idxReadback.size };
|
|
|
|
|
vkCmdCopyBuffer(rcmd, mesh.indexBuffer.buffer, idxReadback.buffer, 1, &iRegion);
|
|
|
|
|
SubmitWait(rcmd);
|
|
|
|
|
}
|
|
|
|
|
vertReadback.FlushHost();
|
|
|
|
|
idxReadback.FlushHost();
|
2026-06-17 17:40:39 +00:00
|
|
|
const bool vertsMatch = std::memcmp(
|
2026-06-17 18:05:13 +00:00
|
|
|
vertReadback.value, srcMesh.vertexes.data(),
|
2026-06-17 17:40:39 +00:00
|
|
|
srcMesh.vertexes.size() * sizeof(srcMesh.vertexes[0])) == 0;
|
|
|
|
|
const bool idxMatch = std::memcmp(
|
2026-06-17 18:05:13 +00:00
|
|
|
idxReadback.value, srcMesh.indexes.data(),
|
2026-06-17 17:40:39 +00:00
|
|
|
srcMesh.indexes.size() * sizeof(srcMesh.indexes[0])) == 0;
|
|
|
|
|
Check(vertsMatch, "GPU-decompressed vertices are byte-equal to the source");
|
|
|
|
|
Check(idxMatch, "GPU-decompressed indices are byte-equal to the source");
|
|
|
|
|
|
|
|
|
|
// ── Retire timing: the staging frees once framesInFlight frames elapse,
|
|
|
|
|
// and not a frame before. We've already wait-idled, so freeing is safe. ──
|
|
|
|
|
Device::ReclaimDeletions();
|
|
|
|
|
Check(Device::deletionQueue.size() == 1,
|
|
|
|
|
"staging is NOT freed before framesInFlight frames have elapsed");
|
|
|
|
|
Device::frameCounter = 2; // retireAfter(0) + framesInFlight(2) <= 2
|
|
|
|
|
Device::ReclaimDeletions();
|
|
|
|
|
Check(Device::deletionQueue.empty(),
|
|
|
|
|
"staging is freed once its retire frame is reached");
|
|
|
|
|
|
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>
2026-10-20 19:07:54 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 18:50:38 +00:00
|
|
|
// ── 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
|
|
|
|
|
// for in-place updates (#66). A static (allowUpdate=false) build cannot
|
|
|
|
|
// refit, so it hands its now-dead scratch to the deletion queue too — the
|
|
|
|
|
// SAME Build then lands TWO deferred allocations: the compressed staging
|
|
|
|
|
// (#67) AND the dead scratch. Issue #110 was the original test doing a
|
|
|
|
|
// static build and asserting size==1: the scratch was the unaccounted-for
|
|
|
|
|
// second entry. Pin that breakdown down explicitly so a future change to
|
|
|
|
|
// scratch deferral can't silently shift the count the #67 assertion relies
|
|
|
|
|
// on, and so the "extra deferred allocation" stays a known, named quantity.
|
|
|
|
|
Device::frameCounter = 0;
|
|
|
|
|
Device::deletionQueue.clear();
|
|
|
|
|
Mesh staticMesh;
|
|
|
|
|
VkCommandBuffer scmd = BeginCmd();
|
|
|
|
|
staticMesh.Build(asset, scmd, RTBuildOptions{ .allowUpdate = false });
|
|
|
|
|
Check(staticMesh.compressedStaging.buffer == VK_NULL_HANDLE,
|
|
|
|
|
"static Build also releases compressedStaging (#67 is allowUpdate-independent)");
|
|
|
|
|
Check(Device::deletionQueue.size() == 2,
|
|
|
|
|
"static Build enqueues staging + dead BLAS scratch (the #110 extra deletion)");
|
|
|
|
|
SubmitWait(scmd);
|
|
|
|
|
Check(staticMesh.blasAddr != 0, "static compressed Build produced a non-zero BLAS address");
|
|
|
|
|
Check(Device::validationErrorCount == 0,
|
|
|
|
|
"static decompress + BLAS build raised no validation errors");
|
|
|
|
|
// Retire both entries (enqueued at frame 0, retire at framesInFlight) so the
|
|
|
|
|
// staticMesh's scratch/staging are freed and nothing leaks past this test.
|
|
|
|
|
Device::frameCounter = Device::framesInFlight;
|
|
|
|
|
Device::ReclaimDeletions();
|
|
|
|
|
Check(Device::deletionQueue.empty(),
|
|
|
|
|
"static Build's staging + scratch both retire once their frame elapses");
|
|
|
|
|
|
2026-06-17 17:40:39 +00:00
|
|
|
std::println("{}", failures == 0 ? "ALL PASS" : "FAILURES PRESENT");
|
|
|
|
|
return failures == 0 ? 0 : 1;
|
|
|
|
|
}
|