perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118)

BuildTLAS rebuilt the host instance+metadata buffers with an O(n) copy of
every 64 B VkAccelerationStructureInstanceKHR + metadata entry every frame,
unconditionally, then flushed the whole high-water capacity (VK_WHOLE_SIZE)
on both buffers. At the millions-of-instances target that copy dominates the
CPU frame, and the whole-buffer flush costs on non-coherent BAR/VRAM.

Add a generation counter so only changed host-authored fields are copied, and
feed the same dirty span into the ranged FlushDevice(offset, bytes) overload:

- RenderingElement3D::hostDataVersion + MarkHostDataDirty() (bumps a global
  monotonic counter). TlasWithBuffer::uploadedVersion records, per frame, the
  version last copied into each slot. A slot is copied only when its element
  advanced past the recorded version; version 0 ("untracked") reads dirty every
  frame, so callers that don't opt in keep the prior copy-every-frame behaviour.
  Globally-unique versions make this correct under relocation (Remove's
  swap-and-pop, and remove+add that nets the same count on the refit path)
  without tracking element identity. The reset on every topology change covers
  buffer reallocation and the reshuffled element->slot mapping.
- The dirty [first, last] envelope drives both the copy and the flush: a new
  VulkanBuffer::FlushDevice(cmd, access, stage, offset, bytes) overload flushes
  + barriers just that span for instanceBuffer, and the ranged
  FlushDevice(offset, bytes) for metadataBuffer. When nothing is dirty both are
  skipped — the skipped HOST->build barrier only ever ordered host writes, never
  the application's compute-written GPU-owned transform (that compute->build
  ordering is the caller's, and is unchanged).

Constraint honoured: transformOwnedByGpu transforms are still never host-copied.
The API field/method are mirrored on the WebGPU class for source portability
(the WebGPU build re-uploads its small mirror wholesale and ignores the version).

New test TLASInstanceDirtyTracking drives the real RT device and reads back the
host-mapped buffers to assert: tracked elements upload once then skip until
re-marked, untracked elements always upload, and relocation on the refit path
re-uploads exactly the moved slots — with zero validation-layer errors over the
ranged flush.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-17 19:58:45 +00:00
commit b5b8c04237
5 changed files with 437 additions and 12 deletions

View file

@ -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<std::uint64_t> 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<RenderingElement3D*> 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<RenderingElement3D*> elements;
inline static TlasWithBuffer tlases[Window::numFrames];

View file

@ -372,6 +372,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.