diff --git a/additional/dom-webgpu.js b/additional/dom-webgpu.js index 72ed20e..77f5211 100644 --- a/additional/dom-webgpu.js +++ b/additional/dom-webgpu.js @@ -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 => { diff --git a/examples/RayQueryPick/main.cpp b/examples/RayQueryPick/main.cpp index 4cb0606..91001d5 100644 --- a/examples/RayQueryPick/main.cpp +++ b/examples/RayQueryPick/main.cpp @@ -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 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); diff --git a/interfaces/Crafter.Graphics-WebGPUBuffer.cppm b/interfaces/Crafter.Graphics-WebGPUBuffer.cppm index 4dec146..2f43b13 100644 --- a/interfaces/Crafter.Graphics-WebGPUBuffer.cppm +++ b/interfaces/Crafter.Graphics-WebGPUBuffer.cppm @@ -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(size), + static_cast(n), static_cast(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(size)) != 0; + static_cast(n)) != 0; } // Non-consuming readiness probe. Returns true if a subsequent // PollReadback would succeed without changing state otherwise.