feat(rt): BuildProcedural/RefitProcedural from a device AABB buffer (#37)

Add zero-copy procedural BLAS overloads that take the AABB build input
straight from an existing device buffer instead of memcpy-ing a host span
through Mesh::aabbBuffer — the input-source companion to #36's refit work.
A GPU compute producer (e.g. a GPU-resident particle system) can now feed a
moving procedural BLAS that builds/refits each frame with no host round-trip.

Vulkan: new BuildProcedural(VkDeviceAddress, count, ...) and
RefitProcedural(VkDeviceAddress, count, ...) feed the device address directly
into VkAccelerationStructureGeometryAabbsDataKHR; the host-span path is
refactored to share RecordProceduralBuildFromAddress and is otherwise
unchanged.

WebGPU: device-buffer BuildProcedural/RefitProcedural copy the boxes GPU->GPU
into the mesh heap (new wgpuRegisterMeshBLASDeviceAabbs / wgpuRefitMeshBLAS-
DeviceAabbs bridge fns) and wrap them in a single root leaf bounded by a
caller-supplied worldBounds — no wasm round-trip, blasAddr stable across refit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-16 14:12:04 +00:00
commit f14441942a
5 changed files with 306 additions and 21 deletions

View file

@ -51,13 +51,14 @@ function stub(name) {
"wgpuFrameBegin", "wgpuFrameEnd",
"wgpuDispatchQuads", "wgpuDispatchCircles", "wgpuDispatchImages", "wgpuDispatchText",
"wgpuLoadCustomShader", "wgpuDispatchCustom",
"wgpuRegisterMeshBLAS", "wgpuLoadRTPipeline", "wgpuDispatchRT", "wgpuBuildTLAS",
"wgpuRegisterMeshBLAS", "wgpuRegisterMeshBLASDeviceAabbs", "wgpuRefitMeshBLASDeviceAabbs",
"wgpuLoadRTPipeline", "wgpuDispatchRT", "wgpuBuildTLAS",
"wgpuLoadComputePipeline", "wgpuDispatchCompute",
]) {
// Read-write ints don't need a stub-throw; return 0 for the size queries.
e[n] = n.endsWith("Width") || n.endsWith("Height")
? () => 0
: (n === "wgpuRegisterMeshBLAS" ? () => 0 : stub(n));
: ((n === "wgpuRegisterMeshBLAS" || n === "wgpuRegisterMeshBLASDeviceAabbs") ? () => 0 : stub(n));
}
}
@ -2761,6 +2762,12 @@ const rtState = {
// compute shaders at dispatch time.
currentTlas: 0,
currentTlasInstanceCount: 0,
// handle → { vCursor, bvhNode, primCount } for device-buffer procedural
// BLAS (wgpuRegisterMeshBLASDeviceAabbs). Lets the per-frame refit copy
// fresh boxes into the same vertices-heap region and rewrite the root
// leaf bounds without re-registering the mesh.
deviceAabbRegions: new Map(),
};
function rtInit() {
@ -2933,6 +2940,119 @@ env.wgpuRegisterMeshBLAS = (minX, minY, minZ, maxX, maxY, maxZ,
return handle;
};
// Build a single-leaf BVH node (32 bytes) spanning all `count` prims.
function _rtWriteRootLeaf(bvhCursorBytes, count, minX, minY, minZ, maxX, maxY, maxZ) {
const node = new ArrayBuffer(32);
const nf = new Float32Array(node);
const nu = new Uint32Array(node);
nf[0] = minX; nf[1] = minY; nf[2] = minZ;
nu[3] = 0; // firstChildOrPrim: prim base (rel to primRemapOffset)
nf[4] = maxX; nf[5] = maxY; nf[6] = maxZ;
nu[7] = count >>> 0; // primCount > 0 → leaf
queue.writeBuffer(rtState.bvhHeap.gpu, bvhCursorBytes, node);
}
// Zero-copy procedural BLAS: the boxes already live in a device GPUBuffer
// (a compute pass wrote them). Copy them GPU→GPU into the vertices heap and
// register a single-root-leaf BLAS — no wasm round-trip, no SAH build (there
// is no host copy to build over). See wgpuRegisterMeshBLASDeviceAabbs decl in
// Crafter.Graphics-WebGPU.cppm.
env.wgpuRegisterMeshBLASDeviceAabbs = (aabbBufHandle, count,
minX, minY, minZ, maxX, maxY, maxZ,
opaqueFlag) => {
if (!rtState.vertHeap) rtInit();
const src = buffers.get(aabbBufHandle);
if (!src) {
console.error("[crafter-wgpu] wgpuRegisterMeshBLASDeviceAabbs: unknown buffer handle");
return 0;
}
console.log(`[crafter-wgpu] device-AABB BLAS: ${count} aabbs, bbox=(${minX.toFixed(1)}..${maxX.toFixed(1)}, ${minY.toFixed(1)}..${maxY.toFixed(1)}, ${minZ.toFixed(1)}..${maxZ.toFixed(1)}), opaque=${opaqueFlag}`);
const vBytes = count * 24; // 2 vec3 per box, matching RTAabb/VkAabbPositionsKHR
const nBytes = 32; // one root leaf
const rBytes = count * 4; // identity primRemap
rtHeapEnsure(rtState.vertHeap, vBytes);
rtHeapEnsure(rtState.bvhHeap, nBytes);
rtHeapEnsure(rtState.primRemapHeap, rBytes);
const vCursor = rtState.vertHeap.cursor;
const nCursor = rtState.bvhHeap.cursor;
const rCursor = rtState.primRemapHeap.cursor;
const vOff = vCursor / 12; // in vec3 units
const nOff = nCursor / 32; // in BVHNode units
const rOff = rCursor / 4; // in u32 units
// GPU→GPU copy of the boxes — the data never touches wasm memory.
{
const enc = device.createCommandEncoder();
enc.copyBufferToBuffer(src, 0, rtState.vertHeap.gpu, vCursor, vBytes);
queue.submit([enc.finish()]);
}
// Identity primRemap [0..count) — leaf slot i → box i in the heap.
const remap = new Uint32Array(count);
for (let i = 0; i < count; i++) remap[i] = i;
queue.writeBuffer(rtState.primRemapHeap.gpu, rCursor, remap);
_rtWriteRootLeaf(nCursor, count, minX, minY, minZ, maxX, maxY, maxZ);
rtState.vertHeap.cursor += vBytes;
rtState.bvhHeap.cursor += nBytes;
rtState.primRemapHeap.cursor += rBytes;
const handle = rtState.nextMeshHandle++;
rtMeshRecordsEnsure(handle + 1);
const rec = new ArrayBuffer(64);
const f32 = new Float32Array(rec);
const u32 = new Uint32Array(rec);
f32[0] = minX; f32[1] = minY; f32[2] = minZ;
u32[3] = vOff;
f32[4] = maxX; f32[5] = maxY; f32[6] = maxZ;
u32[7] = 0; // indexOffset (unused for AABBs)
u32[8] = nOff;
u32[9] = rOff;
u32[10] = count >>> 0; // primitive count
u32[11] = 0; // attribsOffset (none)
u32[12] = 1; // geomType = AABBs
u32[13] = opaqueFlag ? 1 : 0;
u32[14] = 0;
u32[15] = 0;
queue.writeBuffer(rtState.meshRecordsBuffer, handle * 64, rec);
rtState.deviceAabbRegions.set(handle, { vCursor, nCursor, primCount: count });
return handle;
};
// Zero-copy procedural refit: re-copy the boxes into the existing heap
// region and refresh the root leaf bounds. Handle (and thus blasAddr) is
// preserved. Count must match the original register.
env.wgpuRefitMeshBLASDeviceAabbs = (meshHandle, aabbBufHandle, count,
minX, minY, minZ, maxX, maxY, maxZ) => {
const region = rtState.deviceAabbRegions.get(meshHandle);
const src = buffers.get(aabbBufHandle);
if (!region || !src) {
console.error("[crafter-wgpu] wgpuRefitMeshBLASDeviceAabbs: unknown mesh/buffer handle");
return;
}
if (count !== region.primCount) {
console.error(`[crafter-wgpu] wgpuRefitMeshBLASDeviceAabbs: count ${count} != built ${region.primCount}`);
return;
}
const enc = device.createCommandEncoder();
enc.copyBufferToBuffer(src, 0, rtState.vertHeap.gpu, region.vCursor, count * 24);
queue.submit([enc.finish()]);
// Refresh the root leaf bounds so traversal's node-level cull stays valid.
_rtWriteRootLeaf(region.nCursor, count, minX, minY, minZ, maxX, maxY, maxZ);
// Refresh the MeshRecord root AABB (used by TLAS build for the world box).
const hdr = new Float32Array(8);
hdr[0] = minX; hdr[1] = minY; hdr[2] = minZ;
new Uint32Array(hdr.buffer)[3] = region.vCursor / 12;
hdr[4] = maxX; hdr[5] = maxY; hdr[6] = maxZ;
queue.writeBuffer(rtState.meshRecordsBuffer, meshHandle * 64, hdr.buffer, 0, 32);
};
env.wgpuBuildTLAS = (instanceBufHandle, instanceCount, tlasOutBufHandle,
entryOrderHandle, mortonHandle, binsHandle,
bvhNodesHandle, sortABufHandle, sortBBufHandle) => {