Merge remote-tracking branch 'origin/master' into claude/issue-73
# Conflicts: # interfaces/Crafter.Graphics-Mesh.cppm
This commit is contained in:
commit
aafa458d41
9 changed files with 473 additions and 14 deletions
197
tests/MeshDecompressStagingRelease/main.cpp
Normal file
197
tests/MeshDecompressStagingRelease/main.cpp
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
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.
|
||||
//
|
||||
// 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();
|
||||
mesh.Build(asset, cmd);
|
||||
|
||||
// ── 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");
|
||||
|
||||
// The GPU decompress wrote into the host-visible vertex/index buffers;
|
||||
// invalidate and read them back — releasing the staging must not corrupt
|
||||
// the decompressed result.
|
||||
mesh.vertexBuffer.FlushHost();
|
||||
mesh.indexBuffer.FlushHost();
|
||||
const bool vertsMatch = std::memcmp(
|
||||
mesh.vertexBuffer.value, srcMesh.vertexes.data(),
|
||||
srcMesh.vertexes.size() * sizeof(srcMesh.vertexes[0])) == 0;
|
||||
const bool idxMatch = std::memcmp(
|
||||
mesh.indexBuffer.value, srcMesh.indexes.data(),
|
||||
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");
|
||||
|
||||
std::println("{}", failures == 0 ? "ALL PASS" : "FAILURES PRESENT");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
|
@ -50,6 +50,23 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
// — the strongest check that feeding an oversized instance buffer to the
|
||||
// AS build (with tlasRangeInfo.primitiveCount < capacity) is spec-correct.
|
||||
//
|
||||
// Issue #65: this test also guards the instance buffer's memory-type request —
|
||||
// HOST_VISIBLE (required) with a best-effort DEVICE_LOCAL preference and no
|
||||
// mandatory HOST_COHERENT, so the per-frame GPU accesses stay in local VRAM
|
||||
// (BAR) rather than crossing PCIe. See the assertion block after the first
|
||||
// build. The zero-validation-errors check below also covers the dropped
|
||||
// HOST_COHERENT: the FlushDevice(cmd, ...) in BuildTLAS gates on the chosen
|
||||
// type's coherency flag, so a non-coherent BAR type still flushes correctly.
|
||||
//
|
||||
// Issue #75: the metadata buffer gets the identical upgrade — it is CPU-written
|
||||
// every frame and read by the ray shaders as a STORAGE_BUFFER every frame, so
|
||||
// the same HOST_VISIBLE | prefer-DEVICE_LOCAL, no-mandatory-HOST_COHERENT
|
||||
// request keeps those per-frame shader reads in local VRAM. A second assertion
|
||||
// block after the first build checks its chosen memory type the same way. The
|
||||
// dropped HOST_COHERENT is covered by the zero-validation-errors check: the
|
||||
// FlushDevice() added after the copy loop in BuildTLAS makes the host writes
|
||||
// available on a non-coherent type and self-gates to a no-op on a coherent one.
|
||||
//
|
||||
// Validation layers are required for the last check to be meaningful; the build
|
||||
// marks this test as needing the SDK layers.
|
||||
|
||||
|
|
@ -207,6 +224,94 @@ int main() {
|
|||
Check(RenderingElement3D::tlases[0].builtInstanceCount == 4,
|
||||
"builtInstanceCount == 4 after first build");
|
||||
|
||||
// ── Issue #65: the instance buffer is allocated HOST_VISIBLE (required,
|
||||
// mappable) with a best-effort DEVICE_LOCAL preference (BAR/VRAM) and the
|
||||
// mandatory HOST_COHERENT dropped — so the per-frame compute-write of the
|
||||
// transform field and the AS build's read of the instances both stay in
|
||||
// local VRAM instead of crossing PCIe. The chosen type must match exactly
|
||||
// what GetMemoryType resolves for this buffer's memoryTypeBits with that
|
||||
// prefer-but-don't-require request: DEVICE_LOCAL when a host-visible
|
||||
// device-local type is reachable (resizable BAR), plain HOST_VISIBLE
|
||||
// otherwise. Asserting equality with the recomputed selection makes the
|
||||
// check device-independent — it tracks the call-site request, not the
|
||||
// particular GPU this runs on. ────────────────────────────────────────────
|
||||
{
|
||||
auto& ib = RenderingElement3D::tlases[0].instanceBuffer;
|
||||
VkMemoryRequirements req{};
|
||||
vkGetBufferMemoryRequirements(Device::device, ib.buffer, &req);
|
||||
std::uint32_t expectIdx = Device::GetMemoryType(
|
||||
req.memoryTypeBits,
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
|
||||
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
VkMemoryPropertyFlags expectFlags =
|
||||
Device::memoryProperties.memoryTypes[expectIdx].propertyFlags;
|
||||
Check((ib.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0,
|
||||
"instance buffer memory is HOST_VISIBLE (mappable — the required flag)");
|
||||
Check(ib.memoryPropertyFlagsChosen == expectFlags,
|
||||
"instance buffer memory matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request (#65)");
|
||||
|
||||
bool barReachable = false;
|
||||
constexpr VkMemoryPropertyFlags kBar =
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
|
||||
for (std::uint32_t i = 0; i < Device::memoryProperties.memoryTypeCount; ++i) {
|
||||
if ((req.memoryTypeBits & (1u << i))
|
||||
&& (Device::memoryProperties.memoryTypes[i].propertyFlags & kBar) == kBar) {
|
||||
barReachable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (barReachable) {
|
||||
Check((ib.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0,
|
||||
"instance buffer landed on a DEVICE_LOCAL (BAR/VRAM) type where one is reachable");
|
||||
} else {
|
||||
std::println("INFO no host-visible device-local type reachable — "
|
||||
"fell back to plain HOST_VISIBLE (expected on non-BAR GPUs)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue #75: the metadata buffer carries the same memory-type request as
|
||||
// the instance buffer — HOST_VISIBLE (required, mappable) with a best-effort
|
||||
// DEVICE_LOCAL preference (BAR/VRAM) and the mandatory HOST_COHERENT dropped.
|
||||
// It is CPU-written every frame and read by the ray shaders every frame, so
|
||||
// landing it in local VRAM keeps those per-frame storage-buffer reads off
|
||||
// PCIe. Same device-independent check: assert the recorded chosen flags equal
|
||||
// what GetMemoryType resolves for this buffer's memoryTypeBits with that
|
||||
// request, and that DEVICE_LOCAL is chosen wherever a host-visible
|
||||
// device-local type is reachable. ───────────────────────────────────────────
|
||||
{
|
||||
auto& mb = RenderingElement3D::tlases[0].metadataBuffer;
|
||||
VkMemoryRequirements req{};
|
||||
vkGetBufferMemoryRequirements(Device::device, mb.buffer, &req);
|
||||
std::uint32_t expectIdx = Device::GetMemoryType(
|
||||
req.memoryTypeBits,
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
|
||||
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
VkMemoryPropertyFlags expectFlags =
|
||||
Device::memoryProperties.memoryTypes[expectIdx].propertyFlags;
|
||||
Check((mb.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0,
|
||||
"metadata buffer memory is HOST_VISIBLE (mappable — the required flag)");
|
||||
Check(mb.memoryPropertyFlagsChosen == expectFlags,
|
||||
"metadata buffer memory matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request (#75)");
|
||||
|
||||
bool barReachable = false;
|
||||
constexpr VkMemoryPropertyFlags kBar =
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
|
||||
for (std::uint32_t i = 0; i < Device::memoryProperties.memoryTypeCount; ++i) {
|
||||
if ((req.memoryTypeBits & (1u << i))
|
||||
&& (Device::memoryProperties.memoryTypes[i].propertyFlags & kBar) == kBar) {
|
||||
barReachable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (barReachable) {
|
||||
Check((mb.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0,
|
||||
"metadata buffer landed on a DEVICE_LOCAL (BAR/VRAM) type where one is reachable");
|
||||
} else {
|
||||
std::println("INFO no host-visible device-local type reachable — "
|
||||
"metadata fell back to plain HOST_VISIBLE (expected on non-BAR GPUs)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Grow to 16 (past capacity) → REALLOCATES. ────────────────────────
|
||||
SetCount(pool, 16);
|
||||
BuildOnce();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue