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

Merged
catbot merged 1 commit from claude/issue-133 into master 2026-06-18 16:01:18 +02:00
Member

Resolves #133

Problem

WebGPUBuffer::EnqueueReadback / PollReadback always passed the full buffer size, so over-provisioned event-queue buffers copied their full capacity GPU→staging→wasm on every drain even when only a small live prefix held data.

Fix

  • C++ (interfaces/Crafter.Graphics-WebGPUBuffer.cppm): add an optional byteCount parameter to both EnqueueReadback and PollReadback (default 0 = whole buffer, so existing full-buffer callers are byte-for-byte unchanged). It bounds the readback to the live prefix and is clamped to size. Callers pass the same byteCount to the paired poll.
  • JS bridge (additional/dom-webgpu.js): the staging buffer is now sized to the full device-buffer capacity (rather than the first call's byteSize), so a prefix length that varies call-to-call never overflows it. The copyBufferToBuffer and mapAsync/getMappedRange are bounded to the requested prefix — that is where the copy saving actually lands.

Constraint (per the audit note)

Only the fixed header/used prefix is trivially shrinkable here; the live event count is GPU-written and unknown at enqueue, so a fully-dynamic array drain still needs a two-stage readback. This change delivers the trivially-shrinkable prefix case.

Verification

  • Native suite: crafter-build test24 passed.
  • wasm/DOM build compiles clean.
  • End-to-end in Firefox via RayQueryPick (extended to exercise the new path):
    • Full readback still resolves: hit=1 customIndex=484 → PASS.
    • 8-byte prefix readback (byteCount=8): hit=1 customIndex=484 prim=0xEEEEEEEE — the first 8 bytes land while primitiveIndex (bytes 8–11) keeps its poison sentinel, proving the copy was bounded to the live prefix rather than the full 16-byte capacity → PASS.

Backend/memory-path change with no visual output, so no screenshot.

🤖 Generated with Claude Code

Resolves #133 ## Problem `WebGPUBuffer::EnqueueReadback` / `PollReadback` always passed the full buffer `size`, so over-provisioned event-queue buffers copied their full capacity GPU→staging→wasm on every drain even when only a small live prefix held data. ## Fix - **C++ (`interfaces/Crafter.Graphics-WebGPUBuffer.cppm`)**: add an optional `byteCount` parameter to both `EnqueueReadback` and `PollReadback` (default `0` = whole buffer, so existing full-buffer callers are byte-for-byte unchanged). It bounds the readback to the live prefix and is clamped to `size`. Callers pass the same `byteCount` to the paired poll. - **JS bridge (`additional/dom-webgpu.js`)**: the staging buffer is now sized to the **full device-buffer capacity** (rather than the first call's `byteSize`), so a prefix length that varies call-to-call never overflows it. The `copyBufferToBuffer` and `mapAsync`/`getMappedRange` are bounded to the requested prefix — that is where the copy saving actually lands. ## Constraint (per the audit note) Only the fixed header/used prefix is trivially shrinkable here; the live event *count* is GPU-written and unknown at enqueue, so a fully-dynamic array drain still needs a two-stage readback. This change delivers the trivially-shrinkable prefix case. ## Verification - Native suite: `crafter-build test` → **24 passed**. - wasm/DOM build compiles clean. - End-to-end in Firefox via `RayQueryPick` (extended to exercise the new path): - Full readback still resolves: `hit=1 customIndex=484` → PASS. - 8-byte **prefix** readback (`byteCount=8`): `hit=1 customIndex=484 prim=0xEEEEEEEE` — the first 8 bytes land while `primitiveIndex` (bytes 8–11) keeps its poison sentinel, proving the copy was bounded to the live prefix rather than the full 16-byte capacity → PASS. Backend/memory-path change with no visual output, so no screenshot. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
catbot merged commit 23e2416ef0 into master 2026-06-18 16:01:18 +02:00
catbot deleted branch claude/issue-133 2026-06-18 16:01:19 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!151
No description provided.