fix(rt): rebuild TLASes in place, after earlier traces, and queue their handles like a Mesh's

BuildTLAS
- A rebuild for a new instance count or build preference used to create a
  new acceleration structure and destroy the old one at once, though the
  command buffer being recorded could still use it (a scene and then a
  model built in one setup). The acceleration structure now spans its whole
  storage buffer, and a rebuild that fits builds into it again, so the
  handle and tlases[i].address stay the same and nothing is destroyed. A
  TLAS that outgrows its storage gets new storage and a new handle; the old
  handle goes to the deletion queue ahead of the storage it lives in. It is
  queued after the scratch is resized, and its address goes with it, so a
  Resize or create that throws leaves no address of a queued acceleration
  structure behind.
- The barrier before the build also orders earlier traces and compute ray
  queries of the TLAS, which a refit or a rebuild in place would otherwise
  overwrite while they read it (BuildTlasWriteBarrier). Its source access
  mask holds no read bit: one does nothing, but makes synchronization
  validation 1.4.363 report the second of two builds in one command buffer
  as a write-after-write hazard.
- TlasReadBarrier is now TlasBarrier, shared by both barriers.
- Documented: call BuildTLAS once every earlier submission that used
  tlases[index] has completed, and when the address stays the same.

TlasWithBuffer
- Moves hand the acceleration structure over, and destroying or assigning
  over one queues it ahead of its storage. The implicit members dropped the
  handle over freed storage, or left two owners and a moved-from TLAS that
  refit through buffers it no longer had.

Tests
- TLASLifetime: destroying, moving, move-assigning and resetting a
  TlasWithBuffer.
- TLASHighWaterMark skips without ray tracing. It asserts the acceleration
  structure is rebuilt in place on a shrink or a growth within capacity,
  that the old handle is queued before its storage on a growth past it, and
  that builds in one command buffer keep the handle while they fit, queue
  it once when one outgrows the storage, and free it only on its retire
  frame.
- TLASBuildBarrier checks the barrier before the build too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 02:32:48 +01:00
commit 397ff2db51
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 516 additions and 74 deletions

221
tests/TLASLifetime/main.cpp Normal file
View file

@ -0,0 +1,221 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A TlasWithBuffer hands its acceleration structure to the deferred-deletion
// queue (issue #101) when it lets go of it, the way a Mesh does its BLAS
// (MeshLifetime). Its special members used to be implicit, so only
// BuildTLAS's replacement of the handle went through the queue:
// - Destroying one dropped the handle while its buffers queued themselves,
// leaking the acceleration structure over freed storage. The handle is now
// queued too, ahead of the storage it lives in.
// - Assigning over one, the way to reset a scene's TLAS, dropped the handle it
// held the same way. It is now queued the same way.
// - Moving one copied the handle, leaving two owners, and left the moved-from
// TlasWithBuffer with its count but none of its buffers, so its next
// same-count BuildTLAS refit through buffers it no longer had. Moving now
// hands the handle over, and the moved-from one builds a new TLAS.
//
// Needs a Vulkan ray-tracing device (exits 77 without one) and no window: the
// test steps Device::frameCounter itself. The validation check only means
// something with CRAFTER_GRAPHICS_VALIDATION set.
#include <cstdlib>
#include "vulkan/vulkan.h"
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);
}
// Build the TLASes of the given frames for the registered elements.
void Build(std::initializer_list<std::uint32_t> frames) {
VkCommandBuffer cmd = BeginCmd();
for (std::uint32_t frame : frames) RenderingElement3D::BuildTLAS(cmd, frame);
SubmitWait(cmd);
}
// Where an entry sits in the queue, or the queue size when it isn't there.
std::size_t QueuePosition(auto matches) {
const auto it = std::ranges::find_if(Device::deletionQueue, matches);
return static_cast<std::size_t>(it - Device::deletionQueue.begin());
}
std::size_t QueuedCount(VkAccelerationStructureKHR handle) {
return static_cast<std::size_t>(std::ranges::count_if(Device::deletionQueue,
[&](const Device::PendingDeletion& e) { return e.accelerationStructure == handle; }));
}
bool Queued(VkBuffer buffer) {
return std::ranges::any_of(Device::deletionQueue,
[&](const Device::PendingDeletion& e) { return e.buffer == buffer; });
}
// The handle is queued exactly once, ahead of the storage it lives in.
bool QueuedAhead(VkAccelerationStructureKHR handle, VkBuffer storage) {
const std::size_t handleAt = QueuePosition([&](const Device::PendingDeletion& e) { return e.accelerationStructure == handle; });
const std::size_t storageAt = QueuePosition([&](const Device::PendingDeletion& e) { return e.buffer == storage; });
return QueuedCount(handle) == 1 && storageAt < Device::deletionQueue.size() && handleAt < storageAt;
}
} // namespace
int main() {
Device::Initialize();
if (!Device::rayTracingSupported) {
std::println("SKIP no Vulkan ray-tracing device");
return 77;
}
Device::validationErrorCount = 0;
Device::framesInFlight = Window::numFrames;
Device::frameCounter = 0;
Device::DrainDeletions();
// A cube BLAS and a few instances of it, so the TLASes have something in
// them.
Mesh cube;
{
std::vector<Vector<float, 3, 3>> verts {
{-1,-1,-1}, { 1,-1,-1}, { 1, 1,-1}, {-1, 1,-1},
{-1,-1, 1}, { 1,-1, 1}, { 1, 1, 1}, {-1, 1, 1},
};
std::vector<std::uint32_t> indices {
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,
};
VkCommandBuffer cmd = BeginCmd();
cube.Build(verts, indices, cmd);
SubmitWait(cmd);
}
std::vector<RenderingElement3D> elements(4);
for (RenderingElement3D& element : elements) {
element.instance = {
.mask = 0xFF,
.accelerationStructureReference = cube.blasAddr,
};
MatrixRowMajor<float, 4, 3, 1>::Identity().Store(reinterpret_cast<float*>(element.instance.transform.matrix));
RenderingElement3D::Add(&element);
}
std::array<TlasWithBuffer, Window::numFrames>& tlases = RenderingElement3D::tlases;
Device::DrainDeletions();
// ── Moving hands the TLAS over; destroying it queues the handle first ────
{
Build({0});
const VkAccelerationStructureKHR handle = tlases[0].accelerationStructure;
const VkDeviceAddress address = tlases[0].address;
const VkBuffer storage = tlases[0].buffer.buffer;
const VkBuffer scratch = tlases[0].scratchBuffer.buffer;
const VkBuffer instances = tlases[0].instanceBuffer.buffer;
const VkBuffer metadata = tlases[0].metadataBuffer.buffer;
{
TlasWithBuffer taken(std::move(tlases[0]));
Check(tlases[0].accelerationStructure == VK_NULL_HANDLE && tlases[0].address == 0,
"a moved-from TlasWithBuffer no longer holds the acceleration structure");
Check(taken.accelerationStructure == handle && taken.address == address && taken.buffer.buffer == storage,
"the one it moved to does");
Check(Device::deletionQueue.empty(), "moving queues nothing");
}
Check(QueuedCount(handle) == 1, "destroying a TlasWithBuffer queues its acceleration structure");
Check(Queued(storage) && Queued(scratch) && Queued(instances) && Queued(metadata),
"and its storage, scratch, instance and metadata buffers");
Check(!Device::deletionQueue.empty() && Device::deletionQueue.front().accelerationStructure == handle,
"the acceleration structure is queued before the storage it lives in");
// The moved-from TLAS still has its count; it has to build afresh
// rather than refit through the buffers it gave away.
Build({0});
Check(tlases[0].accelerationStructure != VK_NULL_HANDLE && tlases[0].accelerationStructure != handle
&& tlases[0].address != 0 && tlases[0].builtInstanceCount == elements.size(),
"the moved-from TlasWithBuffer builds a new TLAS");
Device::frameCounter += Window::numFrames;
Device::ReclaimDeletions();
Check(Device::deletionQueue.empty(), "everything a destroyed TlasWithBuffer queued is freed on its retire frame");
}
// ── Assigning an empty TlasWithBuffer resets the TLAS ───────────────────
{
const VkAccelerationStructureKHR handle = tlases[0].accelerationStructure;
const VkBuffer storage = tlases[0].buffer.buffer;
tlases[0] = TlasWithBuffer{};
Check(tlases[0].accelerationStructure == VK_NULL_HANDLE && tlases[0].address == 0
&& tlases[0].buffer.buffer == VK_NULL_HANDLE,
"assigning an empty TlasWithBuffer leaves no TLAS");
Check(QueuedAhead(handle, storage),
"and queues the acceleration structure it held once, ahead of its storage");
Build({0});
Check(tlases[0].accelerationStructure != VK_NULL_HANDLE && tlases[0].builtInstanceCount == elements.size(),
"the reset TLAS builds again");
Device::DrainDeletions();
}
// ── Move assignment hands the TLAS over and queues the one it replaces ───
{
Build({1});
const VkAccelerationStructureKHR kept = tlases[0].accelerationStructure;
const VkDeviceAddress keptAddress = tlases[0].address;
const VkAccelerationStructureKHR replaced = tlases[1].accelerationStructure;
const VkBuffer replacedStorage = tlases[1].buffer.buffer;
tlases[1] = std::move(tlases[0]);
Check(tlases[0].accelerationStructure == VK_NULL_HANDLE && tlases[0].address == 0
&& tlases[1].accelerationStructure == kept && tlases[1].address == keptAddress,
"move assignment hands the acceleration structure over");
Check(QueuedAhead(replaced, replacedStorage) && QueuedCount(kept) == 0,
"and queues the one it replaces once, ahead of its storage");
Build({0, 1});
Check(tlases[0].accelerationStructure != VK_NULL_HANDLE && tlases[1].accelerationStructure == kept,
"both TLASes build again, the one moved in in place");
Device::DrainDeletions();
}
for (RenderingElement3D& element : elements) RenderingElement3D::Remove(&element);
if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) {
Check(Device::validationErrorCount == 0,
std::format("no Vulkan validation errors across the run ({} seen)", Device::validationErrorCount));
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}