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:
parent
9d9f9d9d2c
commit
b5b8c04237
5 changed files with 437 additions and 12 deletions
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue