perf(mesh): dirty-range vertex upload for deforming-mesh Refit (#119)

Mesh::Refit re-uploaded the entire vertex array every frame on the
in-place UPDATE path (full host write + flush + barrier on the direct
path; full re-stage + copy on the staged path), even when only a handful
of vertices moved.

Add VulkanBuffer::UploadDeviceLocalRange — a dirty-range counterpart to
UploadDeviceLocal that writes/flushes/stages/copies and barriers only the
half-open element range [offset, offset+count) of an already-allocated
device-local buffer. It picks direct-map vs staged-copy from the memory
type the buffer was actually allocated with (not the sub-range size, which
PreferDirectDeviceWrite would mis-route), and the direct path's ranged
flush is rounded to nonCoherentAtomSize and clamped to the allocation size
(mappedSize is now recorded for every buffer, not just mapped ones).

Add a dirty-range Refit overload taking the full vertex/index arrays plus
a (dirtyVertexOffset, dirtyVertexCount) window. The full-span Refit now
delegates to it with the whole array as the window. On the in-place UPDATE
path only the declared window is uploaded — the rest of the device buffer
retains last refit's positions; when an UPDATE isn't possible it falls
back to the full-span rebuild, which is why the full arrays are still
passed. The WebGPU/DOM backend keeps API symmetry: it has no hardware AS,
so it ignores the window and rebuilds the host BVH from the full geometry.

BLASBuildOptions exercises the dirty-range refit on the direct path, the
staged path, and the count-change rebuild fallback, asserting AS-handle /
blasAddr stability and zero Vulkan validation errors.

Resolves #119

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-17 19:50:48 +00:00
commit 1f4c77000a
5 changed files with 199 additions and 8 deletions

View file

@ -371,6 +371,11 @@ void Mesh::BuildProcedural(VkDeviceAddress aabbAddress, std::uint32_t count, boo
}
void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd) {
// Whole-vertex-array refit: the entire array is the dirty range.
Refit(verticies, indicies, 0, static_cast<std::uint32_t>(verticies.size()), cmd);
}
void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, std::uint32_t dirtyVertexOffset, std::uint32_t dirtyVertexCount, VkCommandBuffer cmd) {
// A hardware in-place UPDATE is only valid when the original build asked
// for it, an AS already exists, and the topology is unchanged. Otherwise
// fall back to a full rebuild (mode BUILD) of the same buffers — still
@ -383,7 +388,9 @@ void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
if (!update) {
// Counts may have changed (or update wasn't permitted): take the
// resizing build path, preserving the caller's original flags.
// resizing build path, preserving the caller's original flags. The
// dirty window is irrelevant here — a fresh build re-uploads the whole
// array, which is why the full arrays are passed even on this overload.
Build(verticies, indicies, cmd, RTBuildOptions{
.preference = (buildFlags & VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR)
? RTBuildPreference::FastBuild : RTBuildPreference::FastTrace,
@ -392,7 +399,7 @@ void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
return;
}
// Same-sized buffers: overwrite the vertex positions in place so the
// Same-sized buffers: overwrite the moved vertex positions in place so the
// device address (and thus the geometry the UPDATE reads) stays stable.
// The index buffer is deliberately left untouched: a hardware AS UPDATE
// cannot change topology, so the indices are immutable here — re-uploading
@ -400,11 +407,21 @@ void Mesh::Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
// refit. This means a bitwise-different-but-topologically-equivalent index
// array passed on refit is ignored, consistent with the documented
// contract that a refit may only move vertex positions.
// Re-upload only the vertex positions; UploadDeviceLocal reuses the same
// device-local allocation (count unchanged) so the address the UPDATE reads
// stays stable, then barriers the write before the build. On the staged
// path this re-stages per refit (the deforming-mesh fallback, #73).
vertexBuffer.UploadDeviceLocal(kVertexUsageBase, verticies.data(), static_cast<std::uint32_t>(verticies.size()), cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
//
// Re-upload only the dirty sub-range [dirtyVertexOffset, +dirtyVertexCount):
// the rest of the vertex buffer already holds last refit's positions, so a
// ranged upload patches just the vertices that moved (#119). The window is
// clamped to the array — a caller window past the end would otherwise read
// out of bounds / overflow the copy. UploadDeviceLocalRange writes into the
// existing same-sized allocation (no Resize), so the address the UPDATE
// reads stays stable, then barriers just that sub-range before the build. On
// the staged path this re-stages only the dirty slice (the deforming-mesh
// fallback, #73).
std::uint32_t offset = std::min(dirtyVertexOffset, static_cast<std::uint32_t>(verticies.size()));
std::uint32_t count = std::min(dirtyVertexCount, static_cast<std::uint32_t>(verticies.size()) - offset);
if (count != 0) {
vertexBuffer.UploadDeviceLocalRange(verticies.data() + offset, offset, count, cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR);
}
RecordBLASBuild(*this, static_cast<std::uint32_t>(verticies.size()), static_cast<std::uint32_t>(indicies.size()), buildFlags, /*update*/ true, cmd);
}