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

@ -189,8 +189,17 @@ int main() {
push.origin[2] = origin0 + float(kHitZ) * kSpacing;
push.dir[0] = -1.0f; push.dir[1] = 0.0f; push.dir[2] = 0.0f;
// Drive byteCount-bounded prefix readback (issue #133) once the full read
// has verified the hit: re-read ONLY the first 8 bytes (hit +
// instanceCustomIndex) with byteCount=8 after poisoning the host mirror,
// and confirm the two prefix fields land while primitiveIndex (bytes 8..11)
// stays poisoned — i.e. the copy was actually bounded, not full-capacity.
constexpr std::uint32_t kPoison = 0xEEEEEEEEu;
constexpr std::uint32_t kPrefixLen = 2 * sizeof(std::uint32_t); // hit + customIndex
static int frame = 0;
static bool dispatched = false;
static bool prefixSent = false;
static bool reported = false;
EventListener<void> tick(&window.onBeforeUpdate, [&]() {
if (reported) return;
@ -199,7 +208,7 @@ int main() {
pickShader.Dispatch(&push, sizeof(push), pickHandles, 1, 1, 1);
pickBuf.EnqueueReadback();
dispatched = true;
} else if (dispatched && pickBuf.PollReadback()) {
} else if (dispatched && !prefixSent && pickBuf.PollReadback()) {
const PickResult& r = pickBuf.value[0];
const bool ok = (r.hit == 1u) && (r.instanceCustomIndex == kExpectedCustomIndex);
std::println("[RayQueryPick] result: hit={} customIndex={} prim={} t={}",
@ -210,6 +219,20 @@ int main() {
std::println("[RayQueryPick] FAIL — expected hit=1 customIndex={}, got hit={} customIndex={}",
kExpectedCustomIndex, r.hit, r.instanceCustomIndex);
}
// Now kick off the bounded prefix re-read. Poison the whole mirror
// first; the prefix poll must overwrite only bytes [0,8).
pickBuf.value[0] = { kPoison, kPoison, kPoison, 0.0f };
pickBuf.EnqueueReadback(/*resetBytes*/ 0, /*byteCount*/ kPrefixLen);
prefixSent = true;
} else if (prefixSent && pickBuf.PollReadback(/*byteCount*/ kPrefixLen)) {
const PickResult& r = pickBuf.value[0];
const bool prefixOk = (r.hit == 1u)
&& (r.instanceCustomIndex == kExpectedCustomIndex)
&& (r.primitiveIndex == kPoison); // tail untouched
std::println("[RayQueryPick] prefix readback (byteCount={}): hit={} customIndex={} prim=0x{:08X}",
kPrefixLen, r.hit, r.instanceCustomIndex, r.primitiveIndex);
std::println("[RayQueryPick] {} — byteCount-bounded readback copied only the live prefix",
prefixOk ? "PASS" : "FAIL");
// The render loop runs after main's _Exit, where stdio is never
// flushed implicitly — push the verdict out explicitly.
std::fflush(stdout);