Merge pull request 'perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118)' (#140) from claude/issue-118 into master
This commit is contained in:
commit
2783e47674
5 changed files with 437 additions and 12 deletions
|
|
@ -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.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);
|
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++) {
|
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) {
|
if (elements[i]->transformOwnedByGpu) {
|
||||||
// Skip the transform field — the application's compute shader
|
// Skip the transform field — the application's compute shader
|
||||||
// writes it earlier in this submission. Copy everything else.
|
// 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.instanceBuffer.value[i] = elements[i]->instance;
|
||||||
}
|
}
|
||||||
tlas.metadataBuffer.value[i] = elements[i]->userMetadata;
|
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
|
tlas.instanceBuffer.FlushDevice(
|
||||||
// submit. metadataBuffer is not an AS build input (the build never reads it)
|
cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
|
||||||
// and has no GPU co-write — only the ray shaders read it, in a later pass —
|
static_cast<VkDeviceSize>(dirtyFirst) * instStride,
|
||||||
// so it needs neither the build-stage barrier instanceBuffer takes above nor
|
static_cast<VkDeviceSize>(count) * instStride);
|
||||||
// a HOST->shader command barrier: the queue submit already orders host
|
|
||||||
// writes made available before it against every command in the submission,
|
// Make the per-frame metadata host writes available before the consuming
|
||||||
// exactly as it did when this buffer was HOST_COHERENT. This plain host
|
// submit. metadataBuffer is not an AS build input (the build never reads
|
||||||
// flush is what now makes those writes available on a non-coherent
|
// it) and has no GPU co-write — only the ray shaders read it, in a later
|
||||||
// (BAR/VRAM) type; it self-gates to a no-op on a coherent type (#60),
|
// pass — so it needs neither the build-stage barrier instanceBuffer takes
|
||||||
// preserving the previous behaviour where the type ends up coherent.
|
// above nor a HOST->shader command barrier: the queue submit already
|
||||||
tlas.metadataBuffer.FlushDevice();
|
// 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<VkDeviceSize>(dirtyFirst) * metaStride,
|
||||||
|
static_cast<VkDeviceSize>(count) * metaStride);
|
||||||
|
}
|
||||||
|
|
||||||
VkAccelerationStructureGeometryInstancesDataKHR instancesData {
|
VkAccelerationStructureGeometryInstancesDataKHR instancesData {
|
||||||
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR,
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,17 @@ export namespace Crafter {
|
||||||
// use flags identical to the originating build, so a change in the
|
// use flags identical to the originating build, so a change in the
|
||||||
// requested preference forces a full rebuild rather than a refit.
|
// requested preference forces a full rebuild rather than a refit.
|
||||||
VkBuildAccelerationStructureFlagsKHR builtFlags = 0;
|
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<std::uint64_t> uploadedVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
class RenderingElement3D {
|
class RenderingElement3D {
|
||||||
|
|
@ -82,6 +93,43 @@ export namespace Crafter {
|
||||||
// already live on the GPU).
|
// already live on the GPU).
|
||||||
bool transformOwnedByGpu = false;
|
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<RenderingElement3D*> elements;
|
static std::vector<RenderingElement3D*> elements;
|
||||||
inline static TlasWithBuffer tlases[Window::numFrames];
|
inline static TlasWithBuffer tlases[Window::numFrames];
|
||||||
// Build (or in-place refit) the TLAS for frame `index`. `preference`
|
// 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.
|
// element's instanceBuffer slot directly — BuildTLAS preserves it.
|
||||||
bool transformOwnedByGpu = false;
|
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<RenderingElement3D*> elements;
|
static std::vector<RenderingElement3D*> elements;
|
||||||
inline static TlasWithBuffer tlases[Window::numFrames];
|
inline static TlasWithBuffer tlases[Window::numFrames];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
void FlushHost() requires(Mapped) {
|
||||||
// Coherent memory needs no explicit invalidate — device writes are
|
// Coherent memory needs no explicit invalidate — device writes are
|
||||||
// automatically visible to the host.
|
// automatically visible to the host.
|
||||||
|
|
|
||||||
33
project.cpp
33
project.cpp
|
|
@ -359,6 +359,39 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
tlc.GetInterfacesAndImplementations(ifaces, tlasImpls);
|
tlc.GetInterfacesAndImplementations(ifaces, tlasImpls);
|
||||||
cfg.tests.push_back(std::move(tlasTest));
|
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<fs::path> 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,
|
// Issue #51: FontAtlas only re-uploads the dirty sub-rect now,
|
||||||
// tracked via FontAtlas::DirtyRect. The accumulation/clamp math is
|
// tracked via FontAtlas::DirtyRect. The accumulation/clamp math is
|
||||||
// pure CPU, so this test drives it directly — no GPU device needed
|
// pure CPU, so this test drives it directly — no GPU device needed
|
||||||
|
|
|
||||||
252
tests/TLASInstanceDirtyTracking/main.cpp
Normal file
252
tests/TLASInstanceDirtyTracking/main.cpp
Normal file
|
|
@ -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 <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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RenderingElement3D> 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue