Crafter.Graphics/tests/TLASHighWaterMark/main.cpp

392 lines
18 KiB
C++
Raw Normal View History

/*
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 #64: TLAS host-visible input buffers (instanceBuffer / metadataBuffer)
// grow on a high-water mark instead of being reallocated to the exact instance
// count on every topology change. These buffers only ever need to hold *at
// least* primitiveCount entries — the AS build reads exactly primitiveCount of
// them — so an allocation left over from a larger earlier frame is reused.
//
// This drives the real hardware path: a headless Vulkan RT device (no swapchain
// needed — a TLAS build only touches the queue + command pool), a real cube
// BLAS, and RenderingElement3D::BuildTLAS recorded into one-time command
// buffers at a sequence of instance counts.
//
// What is asserted:
// - First build at a given count ALLOCATES the host inputs (non-null handle,
// non-zero device address, size == count·sizeof(entry)).
// - Growing PAST the current capacity REALLOCATES (new VkBuffer handle, new
// address, larger size) — the only case that still pays the realloc.
// - SHRINKING reuses the existing allocation unchanged (same handle, same
// address, same size) — the core win of this issue, and the case the
// pre-fix code reallocated on.
// - Growing back up but still WITHIN the high-water capacity also reuses it.
// - Growing to EXACTLY the capacity reuses it (boundary: count > capacity,
// not >=).
// - instanceBuffer and metadataBuffer grow in lockstep (one capacity check
// governs both).
// - A same-count rebuild takes the refit (UPDATE) path: builtInstanceCount
// is unchanged and the inputs are obviously not reallocated.
// - builtInstanceCount tracks the live count across every build (the AS
// itself is still rebuilt on a count change — the fix only spares the two
// host buffers, not the AS storage/scratch).
// - The Vulkan validation layer reports ZERO errors across all of the above
// — the strongest check that feeding an oversized instance buffer to the
// AS build (with tlasRangeInfo.primitiveCount < capacity) is spec-correct.
//
perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65) The TLAS instance buffer is rebuilt/refit every frame and is co-written by both the CPU (host-authored fields) and the GPU (the compute shader writes the transform field in place for transformOwnedByGpu elements). Allocated HOST_VISIBLE | HOST_COHERENT (system RAM), both GPU accesses crossed PCIe: the compute write of the transform, and the AS build's read of the instances. Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference (BAR/VRAM), keeping the single shared, persistently-mapped buffer — no staging, no copy, no extra barrier. A whole-buffer staging copy was rejected: it would clobber the GPU-written transforms in this in-place co-write design. Now the compute write and AS build stay in local VRAM; the CPU's write-only, sequential, never-read-back field writes are the ideal write-combined BAR load. Correctness: - DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR. - HOST_COHERENT is dropped from the required set — the combined type may not be coherent. The existing FlushDevice(cmd, ...) already establishes host->build visibility and gates its flush-skip on the *chosen* type's flags (#60), so a non-coherent BAR type still flushes correctly. The barrier is never removed. metadataBuffer gets the same upgrade separately (#75). Extends the TLASHighWaterMark hardware test to assert the chosen memory type matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request and lands on a DEVICE_LOCAL type where one is reachable; the existing zero-validation-errors check covers the dropped HOST_COHERENT path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:37:39 +00:00
// 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.
//
perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75) The TLAS metadata buffer is CPU-written every frame (one userMetadata word per element in BuildTLAS's copy loop) and read by the ray shaders as a STORAGE_BUFFER via device address every frame. Allocated HOST_VISIBLE | HOST_COHERENT (system RAM), every per-frame shader read traversed PCIe — the same cost the sibling instance buffer paid before #65. Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference (BAR/VRAM), keeping the single persistently-mapped buffer. The CPU's writes are write-only, sequential, never-read-back — the ideal write-combined BAR load — and the shader reads now hit local VRAM. Unlike the instance buffer there is no GPU co-write, so the direct upgrade is unconditional. Staging is still deliberately avoided: this buffer is rewritten by the CPU every frame, so a per-frame stage+copy+barrier would defeat the purpose (#75 caveat 2). Correctness: - DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR. - HOST_COHERENT is dropped from the required set — the combined type may not be coherent. A FlushDevice() is added after the copy loop to make the host writes available on a non-coherent type; it self-gates to a no-op on a coherent type (#60), so the previous behaviour is preserved wherever the type ends up coherent. The metadata buffer is not an AS build input and has no GPU co-write, so it needs neither the build-stage barrier the instance buffer takes nor a HOST->shader command barrier: the queue submit already orders host writes made available before it against every command in the submission, exactly as it did when this buffer was HOST_COHERENT. Extends the TLASHighWaterMark hardware test with a metadata-buffer assertion block mirroring #65's instance-buffer check: the chosen memory type must equal what GetMemoryType resolves for the HOST_VISIBLE | prefer-DEVICE_LOCAL request, and must be DEVICE_LOCAL wherever a host-visible device-local type is reachable. The zero-validation-errors check covers the dropped HOST_COHERENT path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:50:01 +00:00
// 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.
#include "vulkan/vulkan.h"
#include <cstdlib>
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;
}
// One-time command buffer helpers — record a build, submit, block.
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, 12 triangles) — enough topology for a real BLAS.
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,
};
}
// One TLAS instance referencing `blasAddr`, identity transform, visible mask.
VkAccelerationStructureInstanceKHR MakeInstance(VkDeviceAddress blasAddr) {
VkAccelerationStructureInstanceKHR inst{};
inst.transform = VkTransformMatrixKHR{{
{1.0f, 0.0f, 0.0f, 0.0f},
{0.0f, 1.0f, 0.0f, 0.0f},
{0.0f, 0.0f, 1.0f, 0.0f},
}};
inst.mask = 0xFF;
inst.accelerationStructureReference = blasAddr;
return inst;
}
// Register exactly `n` elements from a stable (reserved, never-reallocated)
// pool. Clears the current registration first so the live count is exactly n.
void SetCount(std::vector<RenderingElement3D>& pool, std::uint32_t n) {
while (!RenderingElement3D::elements.empty()) {
RenderingElement3D::Remove(RenderingElement3D::elements.back());
}
for (std::uint32_t i = 0; i < n; ++i) {
RenderingElement3D::Add(&pool[i]);
}
}
// Build the frame-0 TLAS for the currently-registered elements.
void BuildOnce() {
VkCommandBuffer cmd = BeginCmd();
RenderingElement3D::BuildTLAS(cmd, 0);
SubmitWait(cmd);
}
// Snapshot of the host-input buffer identity, for before/after comparison.
struct InputSnapshot {
VkBuffer instBuf;
VkDeviceAddress instAddr;
std::uint32_t instSize;
VkBuffer metaBuf;
VkDeviceAddress metaAddr;
std::uint32_t metaSize;
};
InputSnapshot Snapshot() {
auto& tlas = RenderingElement3D::tlases[0];
return {
tlas.instanceBuffer.buffer, tlas.instanceBuffer.address, tlas.instanceBuffer.size,
tlas.metadataBuffer.buffer, tlas.metadataBuffer.address, tlas.metadataBuffer.size,
};
}
// Both host inputs untouched (same VkBuffer handle, address and size).
bool Reused(const InputSnapshot& a, const InputSnapshot& b) {
return a.instBuf == b.instBuf && a.instAddr == b.instAddr && a.instSize == b.instSize
&& a.metaBuf == b.metaBuf && a.metaAddr == b.metaAddr && a.metaSize == b.metaSize;
}
constexpr std::uint32_t kEntrySize = sizeof(VkAccelerationStructureInstanceKHR);
} // namespace
int main() {
Device::Initialize();
Device::validationErrorCount = 0;
// One real cube BLAS that every TLAS instance references.
Mesh cube;
{
auto verts = CubeVerts(1.0f);
auto idx = CubeIndices();
VkCommandBuffer cmd = BeginCmd();
cube.Build(verts, idx, cmd, RTBuildOptions{ .allowUpdate = true });
SubmitWait(cmd);
}
Check(cube.blasAddr != 0, "cube BLAS produced a non-zero blasAddr");
// Stable backing store for the elements. Reserve to the max count this
// test ever registers so the vector never reallocates (the static
// `elements` array holds raw pointers into it).
constexpr std::uint32_t kMaxElems = 32;
std::vector<RenderingElement3D> pool(kMaxElems);
for (auto& e : pool) e.instance = MakeInstance(cube.blasAddr);
// ── 1. First build at 4 instances → allocates the host inputs. ──────────
SetCount(pool, 4);
BuildOnce();
InputSnapshot s4 = Snapshot();
Check(s4.instBuf != VK_NULL_HANDLE, "first build allocated the instance buffer");
Check(s4.metaBuf != VK_NULL_HANDLE, "first build allocated the metadata buffer");
Check(s4.instAddr != 0, "instance buffer has a non-zero device address");
Check(s4.instSize == 4 * kEntrySize, "instance buffer sized for 4 entries");
Check(RenderingElement3D::tlases[0].builtInstanceCount == 4,
"builtInstanceCount == 4 after first build");
perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65) The TLAS instance buffer is rebuilt/refit every frame and is co-written by both the CPU (host-authored fields) and the GPU (the compute shader writes the transform field in place for transformOwnedByGpu elements). Allocated HOST_VISIBLE | HOST_COHERENT (system RAM), both GPU accesses crossed PCIe: the compute write of the transform, and the AS build's read of the instances. Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference (BAR/VRAM), keeping the single shared, persistently-mapped buffer — no staging, no copy, no extra barrier. A whole-buffer staging copy was rejected: it would clobber the GPU-written transforms in this in-place co-write design. Now the compute write and AS build stay in local VRAM; the CPU's write-only, sequential, never-read-back field writes are the ideal write-combined BAR load. Correctness: - DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR. - HOST_COHERENT is dropped from the required set — the combined type may not be coherent. The existing FlushDevice(cmd, ...) already establishes host->build visibility and gates its flush-skip on the *chosen* type's flags (#60), so a non-coherent BAR type still flushes correctly. The barrier is never removed. metadataBuffer gets the same upgrade separately (#75). Extends the TLASHighWaterMark hardware test to assert the chosen memory type matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request and lands on a DEVICE_LOCAL type where one is reachable; the existing zero-validation-errors check covers the dropped HOST_COHERENT path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:37:39 +00:00
// ── 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)");
}
}
perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75) The TLAS metadata buffer is CPU-written every frame (one userMetadata word per element in BuildTLAS's copy loop) and read by the ray shaders as a STORAGE_BUFFER via device address every frame. Allocated HOST_VISIBLE | HOST_COHERENT (system RAM), every per-frame shader read traversed PCIe — the same cost the sibling instance buffer paid before #65. Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference (BAR/VRAM), keeping the single persistently-mapped buffer. The CPU's writes are write-only, sequential, never-read-back — the ideal write-combined BAR load — and the shader reads now hit local VRAM. Unlike the instance buffer there is no GPU co-write, so the direct upgrade is unconditional. Staging is still deliberately avoided: this buffer is rewritten by the CPU every frame, so a per-frame stage+copy+barrier would defeat the purpose (#75 caveat 2). Correctness: - DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR. - HOST_COHERENT is dropped from the required set — the combined type may not be coherent. A FlushDevice() is added after the copy loop to make the host writes available on a non-coherent type; it self-gates to a no-op on a coherent type (#60), so the previous behaviour is preserved wherever the type ends up coherent. The metadata buffer is not an AS build input and has no GPU co-write, so it needs neither the build-stage barrier the instance buffer takes nor a HOST->shader command barrier: the queue submit already orders host writes made available before it against every command in the submission, exactly as it did when this buffer was HOST_COHERENT. Extends the TLASHighWaterMark hardware test with a metadata-buffer assertion block mirroring #65's instance-buffer check: the chosen memory type must equal what GetMemoryType resolves for the HOST_VISIBLE | prefer-DEVICE_LOCAL request, and must be DEVICE_LOCAL wherever a host-visible device-local type is reachable. The zero-validation-errors check covers the dropped HOST_COHERENT path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:50:01 +00:00
// ── 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();
InputSnapshot s16 = Snapshot();
Check(s16.instBuf != s4.instBuf, "growing past capacity reallocated the instance buffer");
Check(s16.metaBuf != s4.metaBuf, "growing past capacity reallocated the metadata buffer");
Check(s16.instSize == 16 * kEntrySize, "instance buffer grew to 16 entries");
Check(RenderingElement3D::tlases[0].builtInstanceCount == 16,
"builtInstanceCount == 16 after growth");
// ── 3. Shrink to 2 → REUSES the 16-entry allocation (the core win). ─────
SetCount(pool, 2);
BuildOnce();
InputSnapshot s2 = Snapshot();
Check(Reused(s16, s2),
"shrinking to 2 reused the existing buffers (no realloc — high-water mark)");
Check(s2.instSize == 16 * kEntrySize,
"instance buffer kept its 16-entry high-water size after shrink");
Check(RenderingElement3D::tlases[0].builtInstanceCount == 2,
"builtInstanceCount tracks the live count (2) even on the reuse path");
// ── 4. Grow to 10 (still within the high-water capacity) → REUSES. ──────
SetCount(pool, 10);
BuildOnce();
InputSnapshot s10 = Snapshot();
Check(Reused(s16, s10),
"growing to 10 (≤ capacity 16) reused the existing buffers");
// ── 5. Grow to exactly 16 (== capacity) → REUSES (boundary: > not >=). ──
SetCount(pool, 16);
BuildOnce();
InputSnapshot s16b = Snapshot();
Check(Reused(s16, s16b),
"growing to exactly the capacity (16) reused the existing buffers");
// ── 6. Same count again → refit (UPDATE) path, inputs untouched. ────────
BuildOnce();
InputSnapshot s16c = Snapshot();
Check(Reused(s16, s16c), "same-count rebuild left the inputs untouched");
Check(RenderingElement3D::tlases[0].builtInstanceCount == 16,
"same-count rebuild kept builtInstanceCount == 16 (took the refit path)");
// ── 7. Grow to 32 (past the high-water) → REALLOCATES again. ────────────
SetCount(pool, 32);
BuildOnce();
InputSnapshot s32 = Snapshot();
Check(s32.instBuf != s16.instBuf, "growing past the high-water (32) reallocated again");
Check(s32.instSize == 32 * kEntrySize, "instance buffer grew to 32 entries");
// Unregister everything so nothing dangles past the pool's lifetime.
SetCount(pool, 0);
Check(Device::validationErrorCount == 0,
std::format("no Vulkan validation errors ({} seen)", Device::validationErrorCount));
// Tear down the frame-0 TLAS while the Vulkan device is still alive. The
// static tlases[] array is destroyed at process exit, by which point the
// device (also static) may already be gone — so its VulkanBuffer
// destructors would fault. Releasing here keeps the exit path clean.
{
auto& t0 = RenderingElement3D::tlases[0];
if (t0.accelerationStructure != VK_NULL_HANDLE) {
Device::vkDestroyAccelerationStructureKHR(Device::device, t0.accelerationStructure, nullptr);
t0.accelerationStructure = VK_NULL_HANDLE;
}
if (t0.instanceBuffer.buffer != VK_NULL_HANDLE) t0.instanceBuffer.Clear();
if (t0.metadataBuffer.buffer != VK_NULL_HANDLE) t0.metadataBuffer.Clear();
if (t0.scratchBuffer.buffer != VK_NULL_HANDLE) t0.scratchBuffer.Clear();
if (t0.buffer.buffer != VK_NULL_HANDLE) t0.buffer.Clear();
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return EXIT_FAILURE;
}
std::println("all checks passed");
return EXIT_SUCCESS;
}