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:
catbot 2026-06-18 15:07:44 +02:00
commit 2783e47674
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

@ -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) {
// Coherent memory needs no explicit invalidate — device writes are
// automatically visible to the host.