perf(webgpu): byteCount-bounded readback for over-provisioned buffers (#133)

WebGPUBuffer::EnqueueReadback / PollReadback always copied the full
buffer `size` GPU→staging→wasm. Over-provisioned event-queue buffers
(e.g. Forts3D's GPU physics event drain) paid the full-capacity copy
every drain even when only a small live prefix held data.

Add an optional `byteCount` parameter (default 0 = whole buffer, so the
existing full-buffer callers are unchanged) bounding the readback to the
live prefix. Pass the same byteCount to the paired PollReadback so the
matching number of bytes lands in `.value`.

JS bridge: the staging buffer is now sized to the full device-buffer
capacity (not the first call's byteSize) so a varying prefix length never
overflows it, while the copyBufferToBuffer + mapAsync/getMappedRange are
bounded to the requested prefix — that's where the copy saving lands.

Verified end-to-end in the browser via RayQueryPick: the full readback
still resolves correctly, and a follow-up 8-byte prefix read copies only
hit+customIndex while primitiveIndex keeps its poison sentinel, proving
the copy was bounded. Native `crafter-build test` suite: 24 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-18 14:00:36 +00:00
commit 931500ddc3
3 changed files with 64 additions and 17 deletions

View file

@ -102,10 +102,18 @@ export namespace Crafter {
FlushDeviceRange(off, off, kStride);
}
// Schedule a GPU→CPU readback of this buffer's entire contents.
// Asynchronous; data isn't ready until a later PollReadback
// returns true. Successive Enqueues without a Poll are dropped
// — they're a no-op while the previous map is in flight.
// Schedule a GPU→CPU readback of this buffer. Asynchronous; data
// isn't ready until a later PollReadback returns true. Successive
// Enqueues without a Poll are dropped — they're a no-op while the
// previous map is in flight.
//
// `byteCount` (0 = the whole buffer) bounds the readback to the
// live prefix: an over-provisioned event-queue buffer need only
// copy its header / used span GPU→staging→wasm, not the full
// capacity, every drain. Clamped to `size`. The staging buffer is
// sized to the full capacity, so the prefix length may vary from
// one Enqueue to the next; pass the SAME byteCount to the matching
// PollReadback so the right number of bytes lands in `.value`.
//
// `resetBytes` ≥ 0 — if non-zero, the first `resetBytes` bytes
// of THIS buffer are clearBuffer-cleared on the GPU command
@ -114,17 +122,22 @@ export namespace Crafter {
// The reset is tied to a successful enqueue (skipped enqueue =
// skipped reset), preserving accumulated state across missed
// drains.
void EnqueueReadback(std::uint32_t resetBytes = 0) {
void EnqueueReadback(std::uint32_t resetBytes = 0, std::uint32_t byteCount = 0) {
const std::uint32_t n = (byteCount == 0 || byteCount > size) ? size : byteCount;
WebGPU::wgpuReadbackEnqueue(handle,
static_cast<std::int32_t>(size),
static_cast<std::int32_t>(n),
static_cast<std::int32_t>(resetBytes));
}
// Try to copy the readback bytes into this->value. Returns true
// if the previous EnqueueReadback resolved and the data is now
// mirrored into .value; false if the map is still pending.
bool PollReadback() requires(Mapped) {
// `byteCount` must match the value passed to the paired
// EnqueueReadback (0 = whole buffer); it bounds the staging→wasm
// copy to the live prefix that was captured.
bool PollReadback(std::uint32_t byteCount = 0) requires(Mapped) {
const std::uint32_t n = (byteCount == 0 || byteCount > size) ? size : byteCount;
return WebGPU::wgpuReadbackPoll(handle, this->value,
static_cast<std::int32_t>(size)) != 0;
static_cast<std::int32_t>(n)) != 0;
}
// Non-consuming readiness probe. Returns true if a subsequent
// PollReadback would succeed without changing state otherwise.