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:
parent
a4fdbf7d30
commit
10e07575fb
1 changed files with 32 additions and 39 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue