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>
This commit is contained in:
catbot 2026-06-18 13:59:26 +00:00
commit 10e07575fb

View file

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