2026-06-17 17:40:39 +00:00
|
|
|
/*
|
|
|
|
|
Crafter®.Graphics
|
|
|
|
|
Copyright (C) 2026 Catcrafts®
|
|
|
|
|
catcrafts.net
|
|
|
|
|
|
|
|
|
|
This library is free software; you can redistribute it and/or
|
|
|
|
|
modify it under the terms of the GNU Lesser General Public
|
|
|
|
|
License version 3.0 as published by the Free Software Foundation;
|
|
|
|
|
|
|
|
|
|
This library is distributed in the hope that it will be useful,
|
|
|
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
|
|
|
Lesser General Public License for more details.
|
|
|
|
|
|
|
|
|
|
You should have received a copy of the GNU Lesser General Public
|
|
|
|
|
License along with this library; if not, write to the Free Software
|
|
|
|
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
|
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;
|
|
|
|
|
}
|