diff --git a/implementations/Crafter.Graphics-RenderingElement3D.cpp b/implementations/Crafter.Graphics-RenderingElement3D.cpp index d06630c..6e90621 100644 --- a/implementations/Crafter.Graphics-RenderingElement3D.cpp +++ b/implementations/Crafter.Graphics-RenderingElement3D.cpp @@ -139,9 +139,37 @@ void RenderingElement3D::BuildTLAS(VkCommandBuffer cmd, std::uint32_t index, RTB tlas.instanceBuffer.Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, primitiveCount, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); tlas.metadataBuffer.Resize(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, primitiveCount, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); } + + // A topology change reshuffles which element occupies each slot (a count + // change, and Remove's swap-and-pop within it) and may have reallocated + // the host buffers just above — so every slot's prior contents are + // stale. Clear the per-slot version record (sized to the live count) so + // the copy loop below re-uploads all primitiveCount slots this frame. + tlas.uploadedVersion.assign(primitiveCount, 0); } + // Copy only the slots whose host-authored data changed since this frame's + // buffers last saw them (#118), tracking the first/last dirty index so the + // flush below covers just that [first, last] envelope rather than the whole + // high-water capacity (the envelope may span a few clean slots between + // sparse dirty ones — flushing a couple of extra entries is far cheaper than + // the per-slot vkFlushMappedMemoryRanges calls true sub-ranges would need). + // A slot is dirty when its element advanced past the version + // recorded for it; an element left at version 0 (never MarkHostDataDirty'd) + // reads dirty every frame, preserving the pre-#118 copy-every-frame + // behaviour for callers that don't opt into dirty tracking. Globally-unique + // versions make this correct under relocation: a slot now holding a + // different element never matches the version recorded for its previous + // occupant. The GPU-owned transform is never copied here regardless — the + // application's compute shader writes it in place earlier in this submission. + bool anyDirty = false; + std::uint32_t dirtyFirst = 0; + std::uint32_t dirtyLast = 0; for(std::uint32_t i = 0; i < primitiveCount; i++) { + const std::uint64_t version = elements[i]->hostDataVersion; + if (version != 0 && version == tlas.uploadedVersion[i]) { + continue; // slot already holds this element's current host data + } if (elements[i]->transformOwnedByGpu) { // Skip the transform field — the application's compute shader // writes it earlier in this submission. Copy everything else. @@ -156,21 +184,43 @@ void RenderingElement3D::BuildTLAS(VkCommandBuffer cmd, std::uint32_t index, RTB tlas.instanceBuffer.value[i] = elements[i]->instance; } tlas.metadataBuffer.value[i] = elements[i]->userMetadata; + tlas.uploadedVersion[i] = version; + if (!anyDirty) { dirtyFirst = i; anyDirty = true; } + dirtyLast = i; } - tlas.instanceBuffer.FlushDevice(cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); + if (anyDirty) { + // Flush (and, for the instance buffer, barrier) only the [dirtyFirst, + // dirtyLast] span actually rewritten this frame. When nothing is dirty + // there were no host writes at all, so both flushes — and the + // instanceBuffer HOST->build barrier — are skipped: the AS build reads + // memory already made visible by the submit that last wrote it, and any + // GPU-owned transform write is ordered by the application's own + // compute->build barrier, not this host barrier. + const std::uint32_t count = dirtyLast - dirtyFirst + 1; + constexpr VkDeviceSize instStride = sizeof(VkAccelerationStructureInstanceKHR); + constexpr VkDeviceSize metaStride = sizeof(std::uint32_t); - // Make the per-frame metadata host writes available before the consuming - // submit. metadataBuffer is not an AS build input (the build never reads it) - // and has no GPU co-write — only the ray shaders read it, in a later pass — - // so it needs neither the build-stage barrier instanceBuffer takes above 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. This plain host - // flush is what now makes those writes available on a non-coherent - // (BAR/VRAM) type; it self-gates to a no-op on a coherent type (#60), - // preserving the previous behaviour where the type ends up coherent. - tlas.metadataBuffer.FlushDevice(); + tlas.instanceBuffer.FlushDevice( + cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, + static_cast(dirtyFirst) * instStride, + static_cast(count) * instStride); + + // Make the per-frame metadata host writes available before the consuming + // submit. metadataBuffer is not an AS build input (the build never reads + // it) and has no GPU co-write — only the ray shaders read it, in a later + // pass — so it needs neither the build-stage barrier instanceBuffer takes + // above 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. + // This ranged host flush is what now makes those writes available on a + // non-coherent (BAR/VRAM) type; it self-gates to a no-op on a coherent + // type (#60), preserving the previous behaviour where the type ends up + // coherent. + tlas.metadataBuffer.FlushDevice( + static_cast(dirtyFirst) * metaStride, + static_cast(count) * metaStride); + } VkAccelerationStructureGeometryInstancesDataKHR instancesData { .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR, diff --git a/interfaces/Crafter.Graphics-RenderingElement3D.cppm b/interfaces/Crafter.Graphics-RenderingElement3D.cppm index cf88d24..fbc0bc9 100644 --- a/interfaces/Crafter.Graphics-RenderingElement3D.cppm +++ b/interfaces/Crafter.Graphics-RenderingElement3D.cppm @@ -58,6 +58,17 @@ export namespace Crafter { // use flags identical to the originating build, so a change in the // requested preference forces a full rebuild rather than a refit. VkBuildAccelerationStructureFlagsKHR builtFlags = 0; + // Per-slot record of the RenderingElement3D::hostDataVersion that + // BuildTLAS last copied into this frame's instanceBuffer/metadataBuffer + // — parallel to them, sized to the live instance count. The per-frame + // copy re-uploads (and flushes) only the slots whose element advanced + // past the recorded version; an element left at version 0 ("untracked") + // reads dirty every frame, so callers that never opt in keep the + // pre-#118 copy-every-frame behaviour. Reset to all-zero on every + // topology change, since a rebuild reshuffles which element occupies + // each slot (and may have reallocated the buffers). See + // RenderingElement3D::hostDataVersion. + std::vector uploadedVersion; }; class RenderingElement3D { @@ -82,6 +93,43 @@ export namespace Crafter { // already live on the GPU). bool transformOwnedByGpu = false; + // Monotonic version of this element's host-authored TLAS data — the + // instance fields the CPU writes (everything except a GPU-owned + // transform; see transformOwnedByGpu) plus userMetadata. BuildTLAS + // records, per frame, the version it last copied into each buffer slot + // (TlasWithBuffer::uploadedVersion) and re-copies a slot only when its + // element has advanced past the recorded version — so a TLAS dominated + // by instances whose host fields are set once and then left alone (the + // millions-of-GPU-driven-bodies target) pays no per-frame host copy or + // flush after the first upload. + // + // 0 is the "untracked" sentinel: an element left at 0 is copied every + // frame exactly as before this optimization, so code that mutates + // instance/userMetadata without opting in stays correct. Opt in by + // calling MarkHostDataDirty after every host-data change — the standard + // dirty-flag contract (mark on change; the upload clears it until the + // next change). The GPU-owned transform is exempt: BuildTLAS never + // host-copies it, so changing it needs no mark. + std::uint64_t hostDataVersion = 0; + + // Stamp this element's host data with a fresh global version so the next + // BuildTLAS of each frame re-uploads (and flushes) its slot. Call after + // changing any host-authored instance field or userMetadata. See + // hostDataVersion. + void MarkHostDataDirty() { hostDataVersion = ++hostDataVersionCounter; } + + // Source of globally-unique, monotonically-increasing host-data + // versions. Global rather than per-element so a version value names a + // unique (element, edit) pair: a per-slot recorded version then equals + // the slot's current occupant only when that exact element's exact edit + // was the last thing written there. That uniqueness is what makes the + // relocation cases — swap-and-pop in Remove, or a remove+add that nets + // the same instance count and so takes the refit path — fall out + // correctly without tracking element identity: a relocated element's + // version never collides with the (different) element's version recorded + // for that slot. 64-bit, so it does not wrap in practice. + inline static std::uint64_t hostDataVersionCounter = 0; + static std::vector elements; inline static TlasWithBuffer tlases[Window::numFrames]; // Build (or in-place refit) the TLAS for frame `index`. `preference` @@ -181,6 +229,15 @@ export namespace Crafter { // element's instanceBuffer slot directly — BuildTLAS preserves it. bool transformOwnedByGpu = false; + // API-symmetric with the Vulkan side so portable code that opts its + // instances into host-data dirty tracking compiles unchanged. The + // WebGPU BuildTLAS re-uploads the whole CPU mirror every build (the + // counts this path targets are small), so the version is not consulted + // here — it exists purely for cross-backend source compatibility. + std::uint64_t hostDataVersion = 0; + void MarkHostDataDirty() { hostDataVersion = ++hostDataVersionCounter; } + inline static std::uint64_t hostDataVersionCounter = 0; + static std::vector elements; inline static TlasWithBuffer tlases[Window::numFrames]; diff --git a/interfaces/Crafter.Graphics-VulkanBuffer.cppm b/interfaces/Crafter.Graphics-VulkanBuffer.cppm index 77f12dd..3e49460 100644 --- a/interfaces/Crafter.Graphics-VulkanBuffer.cppm +++ b/interfaces/Crafter.Graphics-VulkanBuffer.cppm @@ -484,6 +484,39 @@ namespace Crafter { ); } + // Ranged variant of FlushDevice(cmd, ...): flushes only the host writes + // in [offset, offset+bytes) (rounded outward to nonCoherentAtomSize, and + // a no-op on coherent memory — same gate as the other FlushDevice + // overloads) and records the HOST->(dstStageMask, dstAccessMask) barrier. + // Use after writing a sub-range so the cache-flush cost scales with the + // bytes touched rather than the whole high-water capacity. The barrier + // itself still spans the whole buffer (VK_WHOLE_SIZE): the execution/ + // visibility dependency is cheap regardless of range, and only the + // flush's cache maintenance is bandwidth-sensitive. + void FlushDevice(VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask, VkDeviceSize offset, VkDeviceSize bytes) requires(Mapped) { + FlushDevice(offset, bytes); + VkBufferMemoryBarrier barrier = { + .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_HOST_WRITE_BIT, + .dstAccessMask = dstAccessMask, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .buffer = buffer, + .offset = 0, + .size = VK_WHOLE_SIZE + }; + + vkCmdPipelineBarrier( + cmd, + VK_PIPELINE_STAGE_HOST_BIT, + dstStageMask, + 0, + 0, NULL, + 1, &barrier, + 0, NULL + ); + } + void FlushHost() requires(Mapped) { // Coherent memory needs no explicit invalidate — device writes are // automatically visible to the host. diff --git a/project.cpp b/project.cpp index d913451..f0ec1e9 100644 --- a/project.cpp +++ b/project.cpp @@ -359,6 +359,39 @@ extern "C" Configuration CrafterBuildProject(std::span a tlc.GetInterfacesAndImplementations(ifaces, tlasImpls); cfg.tests.push_back(std::move(tlasTest)); + // Issue #118: the per-frame TLAS instance+metadata host rebuild copies + // (and flushes) only the slots whose element's host-authored data + // changed, tracked via RenderingElement3D::hostDataVersion against the + // per-frame uploadedVersion record; untracked elements (version 0) keep + // the always-copy behaviour. Drives the real AS-build path — a cube + // BLAS plus BuildTLAS at a sequence of marks/mutations — and reads back + // the host-mapped buffers to assert that clean slots are skipped, dirty + // slots re-uploaded, and relocation on the refit path re-uploads exactly + // the moved slots, with the validation layer reporting no errors over + // the ranged FlushDevice the dirty span feeds. Needs a Vulkan RT device + // at runtime, so it shares the native build settings. + Test tlasDirtyTest; + Configuration& tld = tlasDirtyTest.config; + tld.path = cfg.path; + tld.name = "TLASInstanceDirtyTracking"; + tld.outputName = "TLASInstanceDirtyTracking"; + tld.type = ConfigurationType::Executable; + tld.target = cfg.target; + tld.march = cfg.march; + tld.mtune = cfg.mtune; + tld.debug = cfg.debug; + tld.sysroot = cfg.sysroot; + tld.dependencies = cfg.dependencies; + tld.externalDependencies = cfg.externalDependencies; + tld.compileFlags = cfg.compileFlags; + tld.linkFlags = cfg.linkFlags; + tld.defines = cfg.defines; + tld.cFiles = cfg.cFiles; + std::vector tlasDirtyImpls(impls.begin(), impls.end()); + tlasDirtyImpls.emplace_back("tests/TLASInstanceDirtyTracking/main"); + tld.GetInterfacesAndImplementations(ifaces, tlasDirtyImpls); + cfg.tests.push_back(std::move(tlasDirtyTest)); + // Issue #51: FontAtlas only re-uploads the dirty sub-rect now, // tracked via FontAtlas::DirtyRect. The accumulation/clamp math is // pure CPU, so this test drives it directly — no GPU device needed diff --git a/tests/TLASInstanceDirtyTracking/main.cpp b/tests/TLASInstanceDirtyTracking/main.cpp new file mode 100644 index 0000000..2a8bcd9 --- /dev/null +++ b/tests/TLASInstanceDirtyTracking/main.cpp @@ -0,0 +1,252 @@ +/* +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 #118: the per-frame TLAS instance+metadata host rebuild no longer copies +// every entry unconditionally — it copies (and flushes) only the slots whose +// element's host-authored data changed since this frame's buffers last saw it, +// tracked by RenderingElement3D::hostDataVersion against the per-frame +// TlasWithBuffer::uploadedVersion record. Elements left at version 0 stay +// "untracked" and are copied every frame (the pre-#118 behaviour), so callers +// that don't opt in keep working; MarkHostDataDirty() opts an element in. +// +// This drives the real hardware path (a headless Vulkan RT device, a real cube +// BLAS, RenderingElement3D::BuildTLAS into one-time command buffers) exactly as +// TLASHighWaterMark does, and asserts behaviour by reading back the host-mapped +// instanceBuffer/metadataBuffer (host-visible, and we only inspect host-written +// fields, so the mapping reflects what the copy loop wrote — a skipped slot +// retains its previous bytes, which is the whole point). +// +// What is asserted: +// - A tracked (MarkHostDataDirty'd) element is uploaded on first build. +// - Mutating a tracked element's host data WITHOUT marking it dirty and +// rebuilding leaves the buffer holding the OLD value — the slot was skipped. +// - Marking it dirty and rebuilding uploads the NEW value. +// - An untracked element (version 0) is uploaded on every build even without +// a mark — the backward-compatible always-copy path. +// - Relocation on the refit path: a remove+add that nets the same instance +// count (so BuildTLAS refits rather than rebuilding) re-uploads exactly the +// slots whose occupying element changed, and leaves the unchanged slots +// intact — proving globally-unique versions handle swap-and-pop without +// tracking element identity. +// - The Vulkan validation layer reports ZERO errors across all of the above — +// covering the ranged FlushDevice(offset, bytes) the dirty span feeds. + +#include "vulkan/vulkan.h" +#include + +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> 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 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, + }; +} + +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; +} + +void BuildOnce() { + VkCommandBuffer cmd = BeginCmd(); + RenderingElement3D::BuildTLAS(cmd, 0); + SubmitWait(cmd); +} + +// Read back what the host copy loop last wrote into frame-0's metadata buffer +// slot. Host-visible and host-written, so the mapping reflects the last copy +// (or the slot's prior contents if it was skipped) without an invalidate. +std::uint32_t MetaSlot(std::uint32_t i) { + return RenderingElement3D::tlases[0].metadataBuffer.value[i]; +} +std::uint32_t CustomIndexSlot(std::uint32_t i) { + return RenderingElement3D::tlases[0].instanceBuffer.value[i].instanceCustomIndex; +} + +} // namespace + +int main() { + Device::Initialize(); + Device::validationErrorCount = 0; + + 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"); + + constexpr std::uint32_t kMaxElems = 8; + std::vector pool(kMaxElems); + for (auto& e : pool) e.instance = MakeInstance(cube.blasAddr); + + // ── 1. A tracked element is uploaded on first build. ──────────────────── + pool[0].userMetadata = 100; + pool[0].instance.instanceCustomIndex = 100; + pool[0].MarkHostDataDirty(); + RenderingElement3D::Add(&pool[0]); + BuildOnce(); + Check(MetaSlot(0) == 100, "tracked element uploaded on first build (metadata)"); + Check(CustomIndexSlot(0) == 100, "tracked element uploaded on first build (customIndex)"); + + // ── 2. Mutating a tracked element WITHOUT marking it dirty and rebuilding + // leaves the old value — the slot is skipped. The instance count is + // unchanged, so this is the refit path (no topology reset). ───────── + pool[0].userMetadata = 200; + pool[0].instance.instanceCustomIndex = 200; + BuildOnce(); + Check(MetaSlot(0) == 100, + "unmarked mutation is NOT uploaded — clean slot skipped (metadata stays 100)"); + Check(CustomIndexSlot(0) == 100, + "unmarked mutation is NOT uploaded — clean slot skipped (customIndex stays 100)"); + + // ── 3. Marking it dirty and rebuilding uploads the new value. ─────────── + pool[0].MarkHostDataDirty(); + BuildOnce(); + Check(MetaSlot(0) == 200, "marking dirty re-uploads the new metadata (200)"); + Check(CustomIndexSlot(0) == 200, "marking dirty re-uploads the new customIndex (200)"); + + // ── 4. An untracked element (version 0) is uploaded every build even + // without a mark — the backward-compatible always-copy path. Add a + // second element to grow the count (topology change), then mutate it + // in place across same-count rebuilds. ───────────────────────────── + pool[1].userMetadata = 300; // left untracked: hostDataVersion == 0 + RenderingElement3D::Add(&pool[1]); + BuildOnce(); + Check(MetaSlot(1) == 300, "untracked element uploaded on first build (300)"); + pool[1].userMetadata = 400; // mutate in place, no MarkHostDataDirty + BuildOnce(); + Check(MetaSlot(1) == 400, + "untracked element re-uploaded every frame without a mark (400) — legacy path"); + // The tracked element [0] must NOT have been disturbed by [1]'s churn. + Check(MetaSlot(0) == 200, "tracked element [0] untouched while [1] churns (still 200)"); + + // ── 5. Relocation on the refit path. Both live elements are tracked; a + // remove+add that nets the same instance count takes the refit path + // (no topology reset), so only the slots whose occupying element + // changed must be re-uploaded — proving globally-unique versions + // handle swap-and-pop without tracking element identity. ─────────── + // Mark both current elements so neither is the always-copy legacy path. + pool[0].userMetadata = 10; pool[0].MarkHostDataDirty(); + pool[1].userMetadata = 11; pool[1].MarkHostDataDirty(); + BuildOnce(); // slots: [0]=10, [1]=11 + Check(MetaSlot(0) == 10 && MetaSlot(1) == 11, "both tracked elements uploaded (10, 11)"); + + // Remove pool[0] (swap-and-pop moves the last element, pool[1], into slot 0), + // then add pool[2] (appended at slot 1). Net count is unchanged at 2, so + // BuildTLAS refits. New mapping: slot 0 -> pool[1] (was pool[0]), + // slot 1 -> pool[2] (was pool[1]). + pool[2].userMetadata = 22; pool[2].MarkHostDataDirty(); + RenderingElement3D::Remove(&pool[0]); + RenderingElement3D::Add(&pool[2]); + BuildOnce(); + Check(MetaSlot(0) == 11, + "relocation: slot 0 re-uploaded to the swapped-in element (pool[1] -> 11)"); + Check(MetaSlot(1) == 22, + "relocation: slot 1 re-uploaded to the appended element (pool[2] -> 22)"); + + // Unregister everything. + while (!RenderingElement3D::elements.empty()) { + RenderingElement3D::Remove(RenderingElement3D::elements.back()); + } + + Check(Device::validationErrorCount == 0, + std::format("no Vulkan validation errors ({} seen)", Device::validationErrorCount)); + + // Tear down the frame-0 TLAS while the device is still alive (same rationale + // as TLASHighWaterMark — the static tlases[] outlive the static device). + { + 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; +}