perf(rt): batch the WebGPU TLAS instance upload into one writeBuffer (#131) #150

Merged
catbot merged 1 commit from claude/issue-131 into master 2026-06-18 16:00:11 +02:00
Showing only changes of commit 10e07575fb - Show all commits

perf(rt): batch the WebGPU TLAS instance upload into one writeBuffer (#131)

BuildTLASUpload pushed GPU-driven (transformOwnedByGpu) runs one element
at a time — a separate FlushDeviceRange of the 16 strided metadata bytes
per element — each paying WebGPU validation / encode / JS-boundary cost,
while the CPU-driven arm already batched contiguously.

Upload the whole active instance range in a single writeBuffer instead.
Pushing the (stale) transform bytes for GPU-driven slots is harmless: the
only supported way to drive a transform from the GPU is the manual
Upload -> physics compute pass -> Build sequence, and that compute pass
runs after this upload and rewrites the transform on the GPU before the
TLAS build reads it. For the in-repo (all-CPU) usage the bytes uploaded
are identical to before — with one CPU-driven run the old loop already
emitted exactly this single FlushDeviceRange(0, 0, primitiveCount*64).

Verified: native suite (24 passed) and the RTStress wasm example (512
ray-traced instances) render correctly through the new path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
catbot 2026-06-18 13:59:26 +00:00

View file

@ -108,9 +108,16 @@ void RenderingElement3D::BuildTLASUpload(WebGPUCommandEncoderRef /*cmd*/, std::u
auto& dst = tlas.instanceBuffer.value[i]; auto& dst = tlas.instanceBuffer.value[i];
const auto& src = elements[i]->instance; const auto& src = elements[i]->instance;
if (elements[i]->transformOwnedByGpu) { if (elements[i]->transformOwnedByGpu) {
// Preserve whatever the GPU compute shader most recently // GPU owns the transform: copy only the host-authored
// wrote into dst.transform. Update only the non-transform // metadata fields and leave the host mirror's transform bytes
// fields. // alone (the application doesn't maintain them). The upload
// below pushes the whole struct, including these transform
// bytes, but the physics-tlas-transform compute pass — which
// runs after this Upload and before Build in the supported
// Upload -> compute -> Build flow — overwrites the transform
// on the GPU before the TLAS build reads it, so the host-side
// value is moot. (Nothing reads instanceBuffer between Upload
// and that compute pass.)
dst.instanceCustomIndex = src.instanceCustomIndex; dst.instanceCustomIndex = src.instanceCustomIndex;
dst.mask = src.mask; dst.mask = src.mask;
dst.instanceShaderBindingTableRecordOffset = src.instanceShaderBindingTableRecordOffset; dst.instanceShaderBindingTableRecordOffset = src.instanceShaderBindingTableRecordOffset;
@ -122,42 +129,28 @@ void RenderingElement3D::BuildTLASUpload(WebGPUCommandEncoderRef /*cmd*/, std::u
tlas.metadataBuffer.value[i] = elements[i]->userMetadata; tlas.metadataBuffer.value[i] = elements[i]->userMetadata;
} }
// Upload the instance buffer with partial-write semantics: for runs // Upload the active instance range in a single contiguous writeBuffer.
// of CPU-driven elements (transformOwnedByGpu=false) we push the //
// whole 64-byte struct in one writeBuffer call; for GPU-driven runs // An earlier version split this into per-run arms: contiguous runs of
// we push only the trailing 16 metadata bytes per element, leaving // CPU-driven elements (transformOwnedByGpu=false) were pushed as one
// the transform field intact for the physics-tlas-transform compute // block, but GPU-driven runs pushed only the trailing 16 metadata
// shader to update. The two arms below produce identical GPU state // bytes of each element — one FlushDeviceRange per element — to avoid
// when every element is CPU-driven — this is a no-op refactor until // clobbering the GPU-written transform. The metadata bytes are strided
// 3DForts flips its physics elements to transformOwnedByGpu=true. // (one 16-byte chunk per 64-byte slot), so a GPU-driven run couldn't
constexpr std::uint32_t kInstSize = sizeof(RTInstance); // 64 // batch; each element paid a separate WebGPU validation / encode /
constexpr std::uint32_t kTransformSize = sizeof(RTTransformMatrix); // 48 // JS-boundary cost.
constexpr std::uint32_t kMetaSize = kInstSize - kTransformSize; // 16 //
// Pushing the whole struct for GPU-driven elements too is harmless:
std::uint32_t runStart = 0; // the only supported way to drive a transform from the GPU is the
bool runOwned = elements[0]->transformOwnedByGpu; // manual Upload -> physics compute pass -> Build sequence (see the
for (std::uint32_t i = 1; i <= primitiveCount; ++i) { // file header), and that compute pass runs after this upload and
const bool atEnd = (i == primitiveCount); // rewrites the transform on the GPU before the TLAS build reads it.
const bool currOwned = atEnd ? !runOwned : elements[i]->transformOwnedByGpu; // So the stale transform bytes this push carries for those slots are
if (currOwned == runOwned && !atEnd) continue; // overwritten before they matter, and the whole run uploads in one
// call. (The combined BuildTLAS path runs no compute pass and is, as
if (runOwned) { // documented, only valid for CPU-driven transforms.)
// GPU-driven run — metadata only, per element. Cannot batch constexpr std::uint32_t kInstSize = sizeof(RTInstance); // 64
// because the metadata bytes are non-contiguous in the tlas.instanceBuffer.FlushDeviceRange(0, 0, primitiveCount * kInstSize);
// instance buffer (one 16-byte chunk per 64-byte slot).
for (std::uint32_t j = runStart; j < i; ++j) {
const std::uint32_t off = j * kInstSize + kTransformSize;
tlas.instanceBuffer.FlushDeviceRange(off, off, kMetaSize);
}
} else {
// CPU-driven run — one contiguous writeBuffer.
const std::uint32_t startOff = runStart * kInstSize;
const std::uint32_t bytes = (i - runStart) * kInstSize;
tlas.instanceBuffer.FlushDeviceRange(startOff, startOff, bytes);
}
runStart = i;
runOwned = currOwned;
}
tlas.metadataBuffer.FlushDevice(); tlas.metadataBuffer.FlushDevice();
} }