Merge pull request 'perf(mesh): dirty-range vertex upload for deforming-mesh Refit (#119)' (#138) from claude/issue-119 into master

This commit is contained in:
catbot 2026-06-17 21:51:26 +02:00
commit e3edb87c0f
5 changed files with 199 additions and 8 deletions

View file

@ -164,6 +164,22 @@ export namespace Crafter {
// both buffers. Lifetime contract matches Build: the spans need only
// outlive this call. Call this per frame to track a deforming mesh.
void Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd);
// Dirty-range refit: same as Refit above, but only the contiguous block
// of vertices in [dirtyVertexOffset, dirtyVertexOffset + dirtyVertexCount)
// actually moved, so on the in-place UPDATE path only that sub-range is
// re-uploaded — host-write + flush + barrier (direct) or re-stage + copy
// (staged) scale with the moved vertices, not the whole array (#119). Use
// this for a deforming mesh that nudges a small, known window each frame.
// `verticies` / `indicies` are still the *full* arrays (same contract and
// topology rule as the full-span Refit — the index buffer is read only
// for its count on the UPDATE path); the dirty window simply tells the
// upload which slice changed, and is clamped to the array bounds. When an
// in-place UPDATE is not possible (allowUpdate was not set, or the counts
// changed) this falls back to the full-span Refit, which re-uploads
// everything from `verticies` / `indicies` — so passing the full arrays
// keeps that fallback correct. The AS handle / blasAddr are preserved on
// the UPDATE path exactly as in the full-span Refit.
void Refit(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, std::uint32_t dirtyVertexOffset, std::uint32_t dirtyVertexCount, VkCommandBuffer cmd);
// Procedural analog of Refit: new object-space boxes, same count.
void RefitProcedural(std::span<const RTAabb> aabbs, VkCommandBuffer cmd);
// Zero-copy procedural refit: the device-buffer counterpart of
@ -307,6 +323,17 @@ export namespace Crafter {
void Refit(std::span<Crafter::Vector<float, 3, 3>> vertices,
std::span<std::uint32_t> indices,
WebGPUCommandEncoderRef cmd = 0);
// Dirty-range refit (#119). The software path has no hardware AS to
// update a sub-range of — it rebuilds the host BVH over the full
// geometry regardless — so the dirty window is ignored here and this
// behaves exactly like the full-span Refit above (a fresh build that
// re-publishes blasAddr). The overload exists so portable deforming-mesh
// code that passes a dirty window compiles and stays correct on WebGPU.
void Refit(std::span<Crafter::Vector<float, 3, 3>> vertices,
std::span<std::uint32_t> indices,
std::uint32_t dirtyVertexOffset,
std::uint32_t dirtyVertexCount,
WebGPUCommandEncoderRef cmd = 0);
void RefitProcedural(std::span<const RTAabb> aabbs,
WebGPUCommandEncoderRef cmd = 0);
// Zero-copy procedural refit: re-copy the boxes from the device buffer

View file

@ -140,8 +140,14 @@ namespace Crafter {
};
address = vkGetBufferDeviceAddress(Device::device, &addressInfo);
// Record the allocation's byte size (≥ `size`) for every buffer,
// not just mapped ones: UploadDeviceLocalRange's direct path flushes
// only the dirty sub-range and clamps its rounded-up upper bound to
// this (the nonCoherentAtomSize-aligned-end exception, see
// AlignMappedFlushRange). A non-mapped buffer never maps persistently,
// but its memory is still the allocation a transient map writes into.
mappedSize = memReqs.size;
if constexpr(Mapped) {
mappedSize = memReqs.size;
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, memReqs.size, 0, reinterpret_cast<void**>(&(VulkanBufferMappedConditional<T, true>::value))));
}
}
@ -312,6 +318,86 @@ namespace Crafter {
vkCmdPipelineBarrier(cmd, srcStageMask, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL);
}
// Re-upload only the half-open sub-range [offset, offset+count) of an
// already-allocated device-local buffer — the dirty-range counterpart of
// UploadDeviceLocal. `src` points at the first changed element (not the
// start of the whole array); `offset` is that element's index in this
// buffer. The buffer must already exist at its full size (a prior
// UploadDeviceLocal / Build sized it): this never Resizes, so the device
// address stays stable and the untouched elements keep their last
// contents. Only the dirty bytes are written + flushed (direct path) or
// staged + copied (staged path), and the post-upload barrier covers only
// that sub-range — so a deforming-mesh refit that nudges a few vertices
// pays for those vertices, not a full-array re-upload + flush + copy (#119).
//
// The direct-vs-staged choice is read from the memory type the buffer was
// actually allocated with (HOST_VISIBLE → map + write in place;
// device-local-only → stage + GPU copy), NOT from the sub-range size: the
// allocation is fixed, so a small dirty range must not be mis-routed to a
// map of a non-host-visible buffer the way UploadDeviceLocal's size-based
// PreferDirectDeviceWrite check would. The staged path's GPU copy needs
// VK_BUFFER_USAGE_TRANSFER_DST_BIT on the destination — already present,
// since UploadDeviceLocal only allocates device-local-only memory on the
// staged path, where it adds that bit.
void UploadDeviceLocalRange(const T* src, std::uint32_t offset, std::uint32_t count, VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(!Mapped) {
VkDeviceSize byteOffset = static_cast<VkDeviceSize>(offset) * sizeof(T);
VkDeviceSize bytes = static_cast<VkDeviceSize>(count) * sizeof(T);
VkAccessFlags srcAccessMask;
VkPipelineStageFlags srcStageMask;
if (memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, VK_WHOLE_SIZE, 0, &mapped));
std::memcpy(static_cast<std::byte*>(mapped) + byteOffset, src, bytes);
// Non-coherent memory needs an explicit flush — but only of the
// sub-range actually written, rounded outward to
// nonCoherentAtomSize (and clamped to the allocation size).
if (!(memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
MappedFlushRange r = AlignMappedFlushRange(
byteOffset, bytes, Device::nonCoherentAtomSize, mappedSize);
VkMappedMemoryRange range {
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = r.offset,
.size = r.size
};
vkFlushMappedMemoryRanges(Device::device, 1, &range);
}
vkUnmapMemory(Device::device, memory);
srcAccessMask = VK_ACCESS_HOST_WRITE_BIT;
srcStageMask = VK_PIPELINE_STAGE_HOST_BIT;
} else {
// Device-local-only: stage just the dirty elements and copy them
// into place at byteOffset. The staging buffer outlives the queued
// copy via the fence-keyed deletion queue (#101/#102), exactly as
// the full-buffer staged path does.
VulkanBuffer<T, true> staging;
staging.Create(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count);
std::memcpy(staging.value, src, bytes);
staging.FlushDevice();
VkBufferCopy region { .srcOffset = 0, .dstOffset = byteOffset, .size = bytes };
vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, &region);
staging.DeferredClear();
srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
}
// Order only the written sub-range before the consumer reads it. The
// untouched bytes were made visible by their own prior upload's
// barrier and are not written in this submit, so they need no fresh
// dependency even though the build reads the whole buffer.
VkBufferMemoryBarrier barrier = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = srcAccessMask,
.dstAccessMask = dstAccessMask,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.buffer = buffer,
.offset = byteOffset,
.size = bytes
};
vkCmdPipelineBarrier(cmd, srcStageMask, dstStageMask, 0, 0, NULL, 1, &barrier, 0, NULL);
}
void FlushDevice() requires(Mapped) {
// Coherent memory needs no explicit flush — host writes are
// automatically visible to the device.