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:
parent
079b6b0e1f
commit
397ff2db51
6 changed files with 516 additions and 74 deletions
|
|
@ -27,9 +27,17 @@
|
|||
// 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).
|
||||
// - builtInstanceCount tracks the live count across every build. A count
|
||||
// change still takes a full build, not a refit, but the acceleration
|
||||
// structure follows a high-water mark too: a build that fits its storage
|
||||
// builds into the same handle (same storage, same device address), and
|
||||
// only one that outgrows it gets new storage and a new handle, queueing
|
||||
// the old handle before the storage it lives in.
|
||||
// - Builds with a change of count recorded into ONE command buffer: one that
|
||||
// fits builds into the same acceleration structure, and one that outgrows
|
||||
// it queues the acceleration structure the earlier build still holds
|
||||
// instead of destroying it while the command buffer is being recorded. It
|
||||
// stays queued until its retire frame.
|
||||
// - 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.
|
||||
|
|
@ -51,8 +59,9 @@
|
|||
// 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.
|
||||
// Needs a Vulkan ray-tracing device (exits 77 without 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>
|
||||
|
|
@ -173,11 +182,42 @@ bool Reused(const InputSnapshot& a, const InputSnapshot& b) {
|
|||
|
||||
constexpr std::uint32_t kEntrySize = sizeof(VkAccelerationStructureInstanceKHR);
|
||||
|
||||
// The acceleration structure's identity: handle, storage and device address.
|
||||
struct ASIdentity {
|
||||
VkAccelerationStructureKHR handle;
|
||||
VkBuffer storage;
|
||||
VkDeviceAddress address;
|
||||
bool operator==(const ASIdentity&) const = default;
|
||||
};
|
||||
ASIdentity CurrentAS() {
|
||||
const auto& tlas = RenderingElement3D::tlases[0];
|
||||
return { tlas.accelerationStructure, tlas.buffer.buffer, tlas.address };
|
||||
}
|
||||
|
||||
// Where an entry sits in the deletion 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; }));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
Device::Initialize();
|
||||
if (!Device::rayTracingSupported) {
|
||||
std::println("SKIP no Vulkan ray-tracing device");
|
||||
return 77;
|
||||
}
|
||||
Device::validationErrorCount = 0;
|
||||
// No window: the test steps Device::frameCounter itself.
|
||||
Device::framesInFlight = Window::numFrames;
|
||||
Device::frameCounter = 0;
|
||||
|
||||
// One real cube BLAS that every TLAS instance references.
|
||||
Mesh cube;
|
||||
|
|
@ -193,7 +233,7 @@ int main() {
|
|||
// 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;
|
||||
constexpr std::uint32_t kMaxElems = 256;
|
||||
std::vector<RenderingElement3D> pool(kMaxElems);
|
||||
for (auto& e : pool) e.instance = MakeInstance(cube.blasAddr);
|
||||
|
||||
|
|
@ -305,6 +345,7 @@ int main() {
|
|||
Check(s16.instSize == 16 * kEntrySize, "instance buffer grew to 16 entries");
|
||||
Check(RenderingElement3D::tlases[0].builtInstanceCount == 16,
|
||||
"builtInstanceCount == 16 after growth");
|
||||
const ASIdentity as16 = CurrentAS();
|
||||
|
||||
// ── 3. Shrink to 2 → REUSES the 16-entry allocation (the core win). ─────
|
||||
SetCount(pool, 2);
|
||||
|
|
@ -316,6 +357,8 @@ int main() {
|
|||
"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");
|
||||
Check(CurrentAS() == as16,
|
||||
"shrinking rebuilt the acceleration structure in place (same handle, storage and address)");
|
||||
|
||||
// ── 4. Grow to 10 (still within the high-water capacity) → REUSES. ──────
|
||||
SetCount(pool, 10);
|
||||
|
|
@ -323,6 +366,7 @@ int main() {
|
|||
InputSnapshot s10 = Snapshot();
|
||||
Check(Reused(s16, s10),
|
||||
"growing to 10 (≤ capacity 16) reused the existing buffers");
|
||||
Check(CurrentAS() == as16, "and rebuilt the acceleration structure in place");
|
||||
|
||||
// ── 5. Grow to exactly 16 (== capacity) → REUSES (boundary: > not >=). ──
|
||||
SetCount(pool, 16);
|
||||
|
|
@ -330,6 +374,7 @@ int main() {
|
|||
InputSnapshot s16b = Snapshot();
|
||||
Check(Reused(s16, s16b),
|
||||
"growing to exactly the capacity (16) reused the existing buffers");
|
||||
Check(CurrentAS() == as16, "and rebuilt the acceleration structure in place");
|
||||
|
||||
// ── 6. Same count again → refit (UPDATE) path, inputs untouched. ────────
|
||||
BuildOnce();
|
||||
|
|
@ -338,12 +383,56 @@ int main() {
|
|||
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. ────────────
|
||||
// ── 7. Grow to 32 (past the high-water) → REALLOCATES again. The
|
||||
// acceleration structure outgrows its storage too, so both are
|
||||
// replaced, the old handle queued ahead of the storage it lives
|
||||
// in. ──────────────────────────────────────────────────────────────
|
||||
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");
|
||||
const ASIdentity as32 = CurrentAS();
|
||||
const std::size_t handleAt = QueuePosition([&](const Device::PendingDeletion& e) { return e.accelerationStructure == as16.handle; });
|
||||
const std::size_t storageAt = QueuePosition([&](const Device::PendingDeletion& e) { return e.buffer == as16.storage; });
|
||||
Check(as32.handle != as16.handle && as32.storage != as16.storage && storageAt < Device::deletionQueue.size(),
|
||||
"the acceleration structure outgrew its storage: a new handle in new storage, the old storage queued");
|
||||
Check(handleAt < storageAt, "the old handle is queued before the storage it lives in");
|
||||
|
||||
// ── 8. Builds with a change of count in ONE command buffer (a scene and
|
||||
// then a model shown in one setup). One that fits builds into the
|
||||
// same acceleration structure. One that outgrows it replaces the
|
||||
// acceleration structure the earlier builds in this command buffer
|
||||
// still hold. That one is queued rather than destroyed, which would
|
||||
// leave the command buffer invalid, and is freed only on its retire
|
||||
// frame. ──────────────────────────────────────────────────────────
|
||||
{
|
||||
Device::DrainDeletions();
|
||||
SetCount(pool, 8);
|
||||
VkCommandBuffer cmd = BeginCmd();
|
||||
RenderingElement3D::BuildTLAS(cmd, 0);
|
||||
const ASIdentity first = CurrentAS();
|
||||
SetCount(pool, 24);
|
||||
RenderingElement3D::BuildTLAS(cmd, 0);
|
||||
Check(CurrentAS() == first && Device::deletionQueue.empty(),
|
||||
"a second build in one command buffer that fits builds into the same acceleration structure, queueing nothing");
|
||||
|
||||
SetCount(pool, kMaxElems);
|
||||
RenderingElement3D::BuildTLAS(cmd, 0);
|
||||
const VkAccelerationStructureKHR grown = RenderingElement3D::tlases[0].accelerationStructure;
|
||||
Check(QueuedCount(first.handle) == 1,
|
||||
"one that outgrows the storage queues the old acceleration structure instead of destroying it while recorded");
|
||||
Check(grown != VK_NULL_HANDLE && grown != first.handle, "and creates a new one");
|
||||
|
||||
// A reclaim before its retire frame leaves it queued while this
|
||||
// command buffer is still being recorded.
|
||||
Device::ReclaimDeletions();
|
||||
Check(QueuedCount(first.handle) == 1, "reclaiming before the command buffer completes keeps it queued");
|
||||
SubmitWait(cmd);
|
||||
Device::frameCounter += Window::numFrames;
|
||||
Device::ReclaimDeletions();
|
||||
Check(QueuedCount(first.handle) == 0, "it is freed on its retire frame");
|
||||
}
|
||||
|
||||
// Unregister everything so nothing dangles past the pool's lifetime.
|
||||
SetCount(pool, 0);
|
||||
|
|
|
|||
Loading…
Reference in a new issue