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

@ -157,6 +157,42 @@ int main() {
Check(tri.blasAddr == triAddrBefore,
"Refit kept the same blasAddr (instances stay valid)");
// Dirty-range refit (issue #119): move only a sub-window of the vertices
// and pass it as [offset, count). The ranged upload patches just those
// vertices in place; the UPDATE must still keep the same AS handle /
// blasAddr, and the validation layer (checked at the end) is the real proof
// that the ranged host-write / flush / barrier — and the staged variant
// below — are spec-correct. The untouched vertices retain their last values
// in the device buffer, so the refit BLAS is over the full deformed cube.
{
// Full array of moved positions; we declare only the back half [4,8)
// as the dirty window, so only those 4 vertices are re-uploaded.
auto verts = CubeVerts(2.0f);
auto idx = CubeIndices();
VkCommandBuffer cmd = BeginCmd();
tri.Refit(verts, idx, /*dirtyVertexOffset*/ 4, /*dirtyVertexCount*/ 4, cmd);
SubmitWait(cmd);
}
Check(tri.accelerationStructure == triHandleBefore,
"dirty-range Refit kept the same AS handle (in-place UPDATE, #119)");
Check(tri.blasAddr == triAddrBefore,
"dirty-range Refit kept the same blasAddr (#119)");
// A dirty-range refit on a mesh whose counts changed must still fall back
// to a full rebuild (the dirty window is irrelevant on that path) and
// succeed — same robustness as the full-span Refit fallback.
{
auto verts = CubeVerts(1.0f);
verts.push_back({2.0f, 2.0f, 2.0f}); // count change → topology change
auto idx = CubeIndices();
idx.insert(idx.end(), {0u, 1u, 8u});
VkCommandBuffer cmd = BeginCmd();
tri.Refit(verts, idx, /*dirtyVertexOffset*/ 8, /*dirtyVertexCount*/ 1, cmd);
SubmitWait(cmd);
}
Check(tri.blasAddr != 0 && tri.builtPrimitiveCount == 13,
"dirty-range Refit with a count change rebuilt the BLAS (13 prims, #119)");
// ── Triangle BLAS: fast-build, no update → flags reflect the choice. ─
Mesh triFast;
{
@ -340,6 +376,19 @@ int main() {
Check(staged.accelerationStructure == stagedHandle,
"staged-upload Refit kept the same AS handle (in-place UPDATE, #73)");
// Dirty-range refit on the staged path (#119): the ranged upload must
// take the stage-a-sub-slice + vkCmdCopyBuffer-at-offset path (the
// buffer is pure DEVICE_LOCAL), keep the AS handle, and stay
// validation-clean. Only the front half [0,4) is declared dirty.
{
auto verts3 = CubeVerts(2.0f);
VkCommandBuffer cmd = BeginCmd();
staged.Refit(verts3, idx, /*dirtyVertexOffset*/ 0, /*dirtyVertexCount*/ 4, cmd);
SubmitWait(cmd);
}
Check(staged.accelerationStructure == stagedHandle,
"staged-upload dirty-range Refit kept the same AS handle (#119)");
Device::directWriteBudget = savedBudget;
}