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

@ -613,7 +613,7 @@ env.wgpuWriteBufferRange = (handle, dstByteOffset, srcPtr, byteSize) => {
const READBACK_IDLE = 0;
const READBACK_PENDING = 1;
const READBACK_READY = 2;
const readbacks = new Map(); // device-buffer handle → { staging, size, state, pendingData }
const readbacks = new Map(); // device-buffer handle → { staging, capacity, readBytes, state, pendingData }
// Readbacks scheduled this frame that still need their mapAsync kicked
// off — done after the frame's queue.submit so the map waits for the
// compute writes that wrote to `buf` to finish, not just the standalone
@ -623,21 +623,30 @@ const pendingReadbackMaps = [];
env.wgpuReadbackEnqueue = (handle, byteSize, resetBytes) => {
const buf = buffers.get(handle);
if (!buf) return;
const aligned = (byteSize + 3) & ~3;
const resetAligned = resetBytes > 0 ? ((resetBytes + 3) & ~3) : 0;
let rb = readbacks.get(handle);
if (!rb) {
// Size the staging buffer to the FULL device-buffer capacity, not
// this first call's byteSize: a prefix readback (byteCount < size)
// is allowed to vary call-to-call, so the staging must cover any
// later full-size drain without re-allocating.
const capacity = Math.max(16, (buf.size + 3) & ~3);
rb = {
staging: device.createBuffer({
size: Math.max(16, aligned),
size: capacity,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
}),
size: aligned,
capacity,
readBytes: 0, // bytes copied/mapped by the in-flight enqueue
state: READBACK_IDLE,
pendingData: null,
};
readbacks.set(handle, rb);
}
// Copy/map only the requested prefix — that's the whole point of the
// byteCount path: skip the over-provisioned tail GPU→staging→wasm.
const aligned = Math.min((byteSize + 3) & ~3, rb.capacity);
rb.readBytes = aligned;
if (rb.state !== READBACK_IDLE) {
// Previous map still in flight (or has data nobody polled yet);
// skip this enqueue AND the paired reset. Events written by the
@ -681,8 +690,9 @@ env.wgpuReadbackEnqueue = (handle, byteSize, resetBytes) => {
if (resetAligned > 0) enc.clearBuffer(buf, 0, resetAligned);
queue.submit([enc.finish()]);
rb.state = READBACK_PENDING;
rb.staging.mapAsync(GPUMapMode.READ).then(() => {
rb.pendingData = new Uint8Array(rb.staging.getMappedRange()).slice();
const mapBytes = rb.readBytes;
rb.staging.mapAsync(GPUMapMode.READ, 0, mapBytes).then(() => {
rb.pendingData = new Uint8Array(rb.staging.getMappedRange(0, mapBytes)).slice();
rb.staging.unmap();
rb.state = READBACK_READY;
}).catch(e => {
@ -1010,8 +1020,9 @@ env.wgpuFrameEnd = () => {
// writes (not pre-substep state).
while (pendingReadbackMaps.length > 0) {
const rb = pendingReadbackMaps.pop();
rb.staging.mapAsync(GPUMapMode.READ).then(() => {
rb.pendingData = new Uint8Array(rb.staging.getMappedRange()).slice();
const mapBytes = rb.readBytes;
rb.staging.mapAsync(GPUMapMode.READ, 0, mapBytes).then(() => {
rb.pendingData = new Uint8Array(rb.staging.getMappedRange(0, mapBytes)).slice();
rb.staging.unmap();
rb.state = READBACK_READY;
}).catch(e => {