Merge pull request 'feat(webgpu-rt): any-hit + AABB (procedural) geometry support' (#14) from claude/issue-13 into master

This commit is contained in:
catbot 2026-06-03 00:10:17 +02:00
commit b9f65f5273
17 changed files with 785 additions and 55 deletions

View file

@ -22,7 +22,11 @@ The two backends share the same C++ surface for the high-level pieces
(`*Vulkan` vs `*WebGPU`) live behind `#ifdef CRAFTER_GRAPHICS_WINDOW_DOM`. (`*Vulkan` vs `*WebGPU`) live behind `#ifdef CRAFTER_GRAPHICS_WINDOW_DOM`.
Vulkan ray tracing is hardware (`VK_KHR_ray_tracing_pipeline`); WebGPU Vulkan ray tracing is hardware (`VK_KHR_ray_tracing_pipeline`); WebGPU
ray tracing is a library-built software path (BVH + traceRay in a ray tracing is a library-built software path (BVH + traceRay in a
compute pipeline composed from user-supplied WGSL stages). compute pipeline composed from user-supplied WGSL stages). The WebGPU
path supports triangle and AABB (procedural, `VK_GEOMETRY_TYPE_AABBS_KHR`)
geometry, closest-hit / miss / any-hit / intersection shaders — see
[examples/RTVolume](examples/RTVolume/README.md) for procedural spheres
shaded through an intersection shader with an any-hit cut-out.
> **Native RT status:** reading an acceleration structure through > **Native RT status:** reading an acceleration structure through
> `VK_EXT_descriptor_heap` currently aborts with `VK_ERROR_DEVICE_LOST` on > `VK_EXT_descriptor_heap` currently aborts with `VK_ERROR_DEVICE_LOST` on

View file

@ -1357,6 +1357,15 @@ struct BVHNode {
// per-vertex stride lives in the user's WGSL — the library doesn't store // per-vertex stride lives in the user's WGSL — the library doesn't store
// it because the layout is example-defined (Sponza uses 8 u32 / vertex // it because the layout is example-defined (Sponza uses 8 u32 / vertex
// for VertexNormalTangentUVPacked). // for VertexNormalTangentUVPacked).
//
// geomType selects the BLAS primitive kind: 0 = triangles (the default;
// the vertices/indices streams are positions + a triangle index buffer),
// 1 = AABBs (VK_GEOMETRY_TYPE_AABBS_KHR — the vertices stream holds 2 vec3
// per primitive [min, max] and there are no indices; a registered
// intersection shader determines the hit). opaque is the geometry's opaque
// bit (1 = opaque, no any-hit; 0 = any-hit may run, subject to the ray /
// instance force flags). triangleCount doubles as the AABB primitive count
// for geomType == 1.
struct MeshRecord { struct MeshRecord {
rootAabbMin: vec3<f32>, rootAabbMin: vec3<f32>,
vertexOffset: u32, vertexOffset: u32,
@ -1366,6 +1375,21 @@ struct MeshRecord {
primRemapOffset: u32, primRemapOffset: u32,
triangleCount: u32, triangleCount: u32,
attribsOffset: u32, attribsOffset: u32,
geomType: u32,
opaque: u32,
_padMr0: u32,
_padMr1: u32,
};
// Result of an intersection shader (procedural / AABB geometry) and the
// TRACE-stage candidate inspection. hit gates the rest; t is the
// object-space ray parameter; attribs are forwarded to closest-hit /
// any-hit (HitInfo.attribs); hitKind is user-defined (e.g. front/back).
struct IntersectionResult {
hit: bool,
t: f32,
attribs: vec2<f32>,
hitKind: u32,
}; };
// Per-instance TLAS record built by the TLAS-build compute pass. // Per-instance TLAS record built by the TLAS-build compute pass.
@ -1469,6 +1493,28 @@ fn _rtFetchTri(meshRec: MeshRecord, triIndex: u32) -> array<vec3<f32>, 3> {
); );
} }
// Fetch one procedural AABB (geomType == 1). The vertices heap stores
// 2 vec3 per primitive [min, max]; vertexOffset is the per-mesh base in
// vec3 units (matching the triangle path's vertexOffset units).
fn _rtFetchAabb(meshRec: MeshRecord, primIndex: u32) -> array<vec3<f32>, 2> {
let base = (meshRec.vertexOffset + primIndex * 2u) * 3u;
return array<vec3<f32>, 2>(
vec3<f32>(vertices[base + 0u], vertices[base + 1u], vertices[base + 2u]),
vec3<f32>(vertices[base + 3u], vertices[base + 4u], vertices[base + 5u]),
);
}
// DXR/VK opacity resolution. Ray FORCE flags win, then instance FORCE
// flags, then the geometry's own opaque bit. Non-opaque candidates run
// the any-hit shader during traversal.
fn _rtResolveOpaque(rayFlags: u32, instFlags: u32, geomOpaque: bool) -> bool {
if ((rayFlags & RT_FLAG_OPAQUE) != 0u) { return true; }
if ((rayFlags & RT_FLAG_NO_OPAQUE) != 0u) { return false; }
if ((instFlags & RT_INSTANCE_FORCE_OPAQUE) != 0u) { return true; }
if ((instFlags & RT_INSTANCE_FORCE_NO_OPAQUE) != 0u) { return false; }
return geomOpaque;
}
fn _rtAabb(ro: vec3<f32>, invRd: vec3<f32>, mn: vec3<f32>, mx: vec3<f32>, tMax: f32) -> bool { fn _rtAabb(ro: vec3<f32>, invRd: vec3<f32>, mn: vec3<f32>, mx: vec3<f32>, tMax: f32) -> bool {
// Reject degenerate (mn > mx) boxes outright. The min(t0,t1)/ // Reject degenerate (mn > mx) boxes outright. The min(t0,t1)/
// max(t0,t1) trick below silently re-orients an inverted box // max(t0,t1) trick below silently re-orients an inverted box
@ -1681,9 +1727,41 @@ fn rtEmitRay(origin: vec3<f32>, tMin: f32, dir: vec3<f32>, tMax: f32,
wfPayload[r.payloadSlot] = payload; wfPayload[r.payloadSlot] = payload;
} }
// Opaque-only BLAS descent (no anyhit — TRACE runs zero user code). // Inspect one candidate hit: run the any-hit shader for non-opaque
fn _rtwTraverseBlas(rayObj: RayDesc, flags: u32, meshRec: MeshRecord, // geometry, commit on accept. Returns 0 = ignored (keep searching, do not
instanceId: u32, hitGroupBase: u32, // shrink bestT), 1 = committed (continue traversal with the tighter bestT),
// 2 = end search (committed + terminate). rayWorld is what any-hit sees
// as the ray (world space, matching closest-hit); cand carries the
// object-space hit. When RT_HAS_ANYHIT is false the any-hit call is
// const-folded away and this reduces to a plain commit — so opaque,
// triangle-only scenes keep TRACE's zero-user-code footprint.
fn _rtwCommitCandidate(rayWorld: RayDesc, flags: u32, instFlags: u32,
geomOpaque: bool, hitGroupBase: u32, cand: HitInfo,
payload: ptr<function, Payload>,
bestHit: ptr<function, HitInfo>,
bestT: ptr<function, f32>) -> u32 {
let opaque = _rtResolveOpaque(flags, instFlags, geomOpaque);
var verdict = RT_ANYHIT_ACCEPT;
if (RT_HAS_ANYHIT && !opaque) {
verdict = runAnyHit(hitGroupBase, rayWorld, cand, payload);
}
if (verdict == RT_ANYHIT_IGNORE) { return 0u; }
*bestHit = cand;
*bestT = cand.t;
if (verdict == RT_ANYHIT_END_SEARCH) { return 2u; }
if ((flags & RT_FLAG_TERMINATE_ON_FIRST_HIT) != 0u) { return 2u; }
return 1u;
}
// BLAS descent. Handles both triangle and AABB (procedural) geometry and
// runs the any-hit shader for non-opaque candidates. payload is threaded
// in so any-hit / intersection can read & mutate it (_wfTrace writes it
// back). For opaque triangle-only scenes the user-callback branches
// const-fold out (RT_HAS_ANYHIT / RT_HAS_INTERSECTION both false), leaving
// the original pure-traversal loop.
fn _rtwTraverseBlas(rayObj: RayDesc, rayWorld: RayDesc, flags: u32, instFlags: u32,
meshRec: MeshRecord, instanceId: u32, hitGroupBase: u32,
payload: ptr<function, Payload>,
bestHit: ptr<function, HitInfo>, bestHit: ptr<function, HitInfo>,
bestT: ptr<function, f32>) -> bool { bestT: ptr<function, f32>) -> bool {
let invD = vec3<f32>(1.0) / rayObj.direction; let invD = vec3<f32>(1.0) / rayObj.direction;
@ -1698,27 +1776,57 @@ fn _rtwTraverseBlas(rayObj: RayDesc, flags: u32, meshRec: MeshRecord,
sp = sp - 1u; nodeRel = stack[sp]; continue; sp = sp - 1u; nodeRel = stack[sp]; continue;
} }
if (node.primCount > 0u) { if (node.primCount > 0u) {
for (var i: u32 = 0u; i < node.primCount; i = i + 1u) { if (RT_HAS_INTERSECTION && meshRec.geomType == 1u) {
let triIndex = primRemap[meshRec.primRemapOffset + node.firstChildOrPrim + i]; // ── AABB / procedural geometry (VK_GEOMETRY_TYPE_AABBS) ──
let verts = _rtFetchTri(meshRec, triIndex); if ((flags & RT_FLAG_SKIP_AABBS) == 0u) {
let tr = _rtTri(rayObj.origin, rayObj.direction, for (var i: u32 = 0u; i < node.primCount; i = i + 1u) {
verts[0], verts[1], verts[2], rayObj.tMin, *bestT); let primId = primRemap[meshRec.primRemapOffset + node.firstChildOrPrim + i];
if (!tr.hit) { continue; } let box = _rtFetchAabb(meshRec, primId);
let geomNormal = cross(verts[1] - verts[0], verts[2] - verts[0]); if (!_rtAabb(rayObj.origin, invD, box[0], box[1], *bestT)) { continue; }
let facing = dot(geomNormal, rayObj.direction); var iray: RayDesc;
if ((flags & RT_FLAG_CULL_BACK_FACING_TRIANGLES) != 0u && facing > 0.0) { continue; } iray.origin = rayObj.origin; iray.tMin = rayObj.tMin;
if ((flags & RT_FLAG_CULL_FRONT_FACING_TRIANGLES) != 0u && facing < 0.0) { continue; } iray.direction = rayObj.direction; iray.tMax = *bestT;
var candidate: HitInfo; let ir = runIntersection(hitGroupBase, iray, box[0], box[1], primId);
candidate.t = tr.t; if (!ir.hit) { continue; }
candidate.instanceId = instanceId; if (ir.t < rayObj.tMin || ir.t > *bestT) { continue; }
candidate.primitiveId = triIndex; var cand: HitInfo;
candidate.hitGroupIndex = hitGroupBase; cand.t = ir.t;
candidate.attribs = vec2<f32>(tr.u, tr.v); cand.instanceId = instanceId;
candidate.objectRayOrigin = rayObj.origin; cand.primitiveId = primId;
candidate.objectRayDirection = rayObj.direction; cand.hitGroupIndex = hitGroupBase;
*bestHit = candidate; cand.attribs = ir.attribs;
*bestT = tr.t; cand.objectRayOrigin = rayObj.origin;
if ((flags & RT_FLAG_TERMINATE_ON_FIRST_HIT) != 0u) { return true; } cand.objectRayDirection = rayObj.direction;
let r = _rtwCommitCandidate(rayWorld, flags, instFlags,
meshRec.opaque != 0u, hitGroupBase,
cand, payload, bestHit, bestT);
if (r == 2u) { return true; }
}
}
} else if ((flags & RT_FLAG_SKIP_TRIANGLES) == 0u) {
for (var i: u32 = 0u; i < node.primCount; i = i + 1u) {
let triIndex = primRemap[meshRec.primRemapOffset + node.firstChildOrPrim + i];
let verts = _rtFetchTri(meshRec, triIndex);
let tr = _rtTri(rayObj.origin, rayObj.direction,
verts[0], verts[1], verts[2], rayObj.tMin, *bestT);
if (!tr.hit) { continue; }
let geomNormal = cross(verts[1] - verts[0], verts[2] - verts[0]);
let facing = dot(geomNormal, rayObj.direction);
if ((flags & RT_FLAG_CULL_BACK_FACING_TRIANGLES) != 0u && facing > 0.0) { continue; }
if ((flags & RT_FLAG_CULL_FRONT_FACING_TRIANGLES) != 0u && facing < 0.0) { continue; }
var candidate: HitInfo;
candidate.t = tr.t;
candidate.instanceId = instanceId;
candidate.primitiveId = triIndex;
candidate.hitGroupIndex = hitGroupBase;
candidate.attribs = vec2<f32>(tr.u, tr.v);
candidate.objectRayOrigin = rayObj.origin;
candidate.objectRayDirection = rayObj.direction;
let r = _rtwCommitCandidate(rayWorld, flags, instFlags,
meshRec.opaque != 0u, hitGroupBase,
candidate, payload, bestHit, bestT);
if (r == 2u) { return true; }
}
} }
if (sp == 0u) { break; } if (sp == 0u) { break; }
sp = sp - 1u; nodeRel = stack[sp]; continue; sp = sp - 1u; nodeRel = stack[sp]; continue;
@ -1749,6 +1857,7 @@ fn _rtwTraverseBlas(rayObj: RayDesc, flags: u32, meshRec: MeshRecord,
fn _rtwTraverseTlas(rayWorld: RayDesc, flags: u32, cullMask: u32, fn _rtwTraverseTlas(rayWorld: RayDesc, flags: u32, cullMask: u32,
sbtRecordOffset: u32, sbtRecordOffset: u32,
payload: ptr<function, Payload>,
bestHit: ptr<function, HitInfo>, bestHit: ptr<function, HitInfo>,
bestT: ptr<function, f32>) -> bool { bestT: ptr<function, f32>) -> bool {
let invD = vec3<f32>(1.0) / rayWorld.direction; let invD = vec3<f32>(1.0) / rayWorld.direction;
@ -1793,7 +1902,7 @@ fn _rtwTraverseTlas(rayWorld: RayDesc, flags: u32, cullMask: u32,
let hitGroupBase = sbtRecordOffset + hitGroupOffset; let hitGroupBase = sbtRecordOffset + hitGroupOffset;
let meshRec = meshRecords[inst.blasMeshIdx]; let meshRec = meshRecords[inst.blasMeshIdx];
let pre = *bestT; let pre = *bestT;
let endSearch = _rtwTraverseBlas(rayObj, effective, meshRec, i, hitGroupBase, bestHit, bestT); let endSearch = _rtwTraverseBlas(rayObj, rayWorld, effective, iflags, meshRec, i, hitGroupBase, payload, bestHit, bestT);
if ((*bestT) < pre || endSearch) { if ((*bestT) < pre || endSearch) {
(*bestHit).objectToWorldR0 = inst.objectToWorldR0; (*bestHit).objectToWorldR0 = inst.objectToWorldR0;
(*bestHit).objectToWorldR1 = inst.objectToWorldR1; (*bestHit).objectToWorldR1 = inst.objectToWorldR1;
@ -1861,7 +1970,19 @@ fn _wfTrace(i: u32) {
var bestHit: HitInfo; var bestHit: HitInfo;
bestHit.t = ray.tMax; bestHit.t = ray.tMax;
var bestT = ray.tMax; var bestT = ray.tMax;
_rtwTraverseTlas(rd, ray.flags, ray.cullMask & 0xFFu, ray.sbtRecordOffset, &bestHit, &bestT); // Any-hit / intersection shaders run inside traversal and may read &
// mutate the payload, so load it here and write it back below. For an
// opaque triangle-only scene both consts are false and the payload
// touch const-folds away — TRACE keeps reading zero user state.
var payload: Payload;
if (RT_HAS_ANYHIT || RT_HAS_INTERSECTION) {
_wfPixel = ray.pixel;
payload = wfPayload[ray.payloadSlot];
}
_rtwTraverseTlas(rd, ray.flags, ray.cullMask & 0xFFu, ray.sbtRecordOffset, &payload, &bestHit, &bestT);
if (RT_HAS_ANYHIT || RT_HAS_INTERSECTION) {
wfPayload[ray.payloadSlot] = payload;
}
var hr: HitResult; var hr: HitResult;
if (bestT < ray.tMax) { if (bestT < ray.tMax) {
hr.t = bestHit.t; hr.t = bestHit.t;
@ -1966,7 +2087,10 @@ fn _rqTraverseBlas(rayObj: RayDesc, flags: u32, meshRec: MeshRecord,
sp = sp - 1u; nodeRel = stack[sp]; continue; sp = sp - 1u; nodeRel = stack[sp]; continue;
} }
if (node.primCount > 0u) { if (node.primCount > 0u) {
for (var i: u32 = 0u; i < node.primCount; i = i + 1u) { // rayQuery is triangle-only; AABB (procedural) BLAS need an
// intersection shader the rayQuery path doesn't run, so skip
// their leaves rather than misread the AABB stream as triangles.
for (var i: u32 = 0u; i < node.primCount * select(0u, 1u, meshRec.geomType == 0u); i = i + 1u) {
let triIndex = primRemap[meshRec.primRemapOffset + node.firstChildOrPrim + i]; let triIndex = primRemap[meshRec.primRemapOffset + node.firstChildOrPrim + i];
let verts = _rtFetchTri(meshRec, triIndex); let verts = _rtFetchTri(meshRec, triIndex);
let tr = _rtTri(rayObj.origin, rayObj.direction, let tr = _rtTri(rayObj.origin, rayObj.direction,
@ -2572,7 +2696,7 @@ function rtInit() {
rtState.attribsHeap = makeRtHeap(); rtState.attribsHeap = makeRtHeap();
rtState.meshRecordsCapacity = 16; rtState.meshRecordsCapacity = 16;
rtState.meshRecordsBuffer = device.createBuffer({ rtState.meshRecordsBuffer = device.createBuffer({
size: rtState.meshRecordsCapacity * 48, size: rtState.meshRecordsCapacity * 64,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
}); });
rtState.rtHeader = device.createBuffer({ rtState.rtHeader = device.createBuffer({
@ -2635,12 +2759,12 @@ function rtMeshRecordsEnsure(meshCount) {
let cap = rtState.meshRecordsCapacity; let cap = rtState.meshRecordsCapacity;
while (cap < meshCount) cap *= 2; while (cap < meshCount) cap *= 2;
const ng = device.createBuffer({ const ng = device.createBuffer({
size: cap * 48, size: cap * 64,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
}); });
const enc = device.createCommandEncoder(); const enc = device.createCommandEncoder();
enc.copyBufferToBuffer(rtState.meshRecordsBuffer, 0, ng, 0, enc.copyBufferToBuffer(rtState.meshRecordsBuffer, 0, ng, 0,
rtState.meshRecordsCapacity * 48); rtState.meshRecordsCapacity * 64);
queue.submit([enc.finish()]); queue.submit([enc.finish()]);
rtState.meshRecordsBuffer.destroy(); rtState.meshRecordsBuffer.destroy();
rtState.meshRecordsBuffer = ng; rtState.meshRecordsBuffer = ng;
@ -2653,9 +2777,11 @@ env.wgpuRegisterMeshBLAS = (minX, minY, minZ, maxX, maxY, maxZ,
indicesPtr, indexCount, indicesPtr, indexCount,
bvhNodesPtr, bvhNodeCount, bvhNodesPtr, bvhNodeCount,
primRemapPtr, primRemapCount, primRemapPtr, primRemapCount,
attribsPtr, attribsByteCount) => { attribsPtr, attribsByteCount,
geomType, opaqueFlag, primCount) => {
if (!rtState.vertHeap) rtInit(); if (!rtState.vertHeap) rtInit();
console.log(`[crafter-wgpu] mesh BLAS: bbox=(${minX.toFixed(1)}..${maxX.toFixed(1)}, ${minY.toFixed(1)}..${maxY.toFixed(1)}, ${minZ.toFixed(1)}..${maxZ.toFixed(1)}), ${vertexCount} verts, ${indexCount/3} tris, attribs=${attribsByteCount}B`); const kind = (geomType === 1) ? `${primCount} aabbs` : `${indexCount/3} tris`;
console.log(`[crafter-wgpu] mesh BLAS: bbox=(${minX.toFixed(1)}..${maxX.toFixed(1)}, ${minY.toFixed(1)}..${maxY.toFixed(1)}, ${minZ.toFixed(1)}..${maxZ.toFixed(1)}), ${vertexCount} verts, ${kind}, opaque=${opaqueFlag}, attribs=${attribsByteCount}B`);
const vBytes = vertexCount * 12; const vBytes = vertexCount * 12;
const iBytes = indexCount * 4; const iBytes = indexCount * 4;
@ -2701,8 +2827,8 @@ env.wgpuRegisterMeshBLAS = (minX, minY, minZ, maxX, maxY, maxZ,
const handle = rtState.nextMeshHandle++; const handle = rtState.nextMeshHandle++;
rtMeshRecordsEnsure(handle + 1); rtMeshRecordsEnsure(handle + 1);
// Build the MeshRecord (48 bytes) and write it. // Build the MeshRecord (64 bytes) and write it.
const rec = new ArrayBuffer(48); const rec = new ArrayBuffer(64);
const f32 = new Float32Array(rec); const f32 = new Float32Array(rec);
const u32 = new Uint32Array(rec); const u32 = new Uint32Array(rec);
f32[0] = minX; f32[1] = minY; f32[2] = minZ; f32[0] = minX; f32[1] = minY; f32[2] = minZ;
@ -2711,9 +2837,16 @@ env.wgpuRegisterMeshBLAS = (minX, minY, minZ, maxX, maxY, maxZ,
u32[7] = iOff; u32[7] = iOff;
u32[8] = nOff; u32[8] = nOff;
u32[9] = rOff; u32[9] = rOff;
u32[10] = (vertexCount > 0) ? (indexCount / 3) : 0; // triangleCount field doubles as the primitive count (= AABB count for
// geomType 1). Triangle meshes derive it from the index buffer.
u32[10] = (geomType === 1) ? (primCount >>> 0)
: ((vertexCount > 0) ? (indexCount / 3) : 0);
u32[11] = aOff; u32[11] = aOff;
queue.writeBuffer(rtState.meshRecordsBuffer, handle * 48, rec); u32[12] = (geomType === 1) ? 1 : 0; // geomType
u32[13] = opaqueFlag ? 1 : 0; // opaque bit
u32[14] = 0; // _padMr0
u32[15] = 0; // _padMr1
queue.writeBuffer(rtState.meshRecordsBuffer, handle * 64, rec);
return handle; return handle;
}; };
@ -2924,6 +3057,12 @@ env.wgpuLoadRTPipeline = (wgslPtr, wgslLen, bindingsPtr, bindingsCount) => {
+ beforeHelpers + "\n" + rtWgslPureHelpers + "\n" + beforeHelpers + "\n" + rtWgslPureHelpers + "\n"
+ rtWgslWavefrontHelpers + "\n" + afterHelpers; + rtWgslWavefrontHelpers + "\n" + afterHelpers;
// When the pipeline registers any-hit / intersection shaders, those run
// inside TRACE and may sample the user's @group(3+) resources — so TRACE
// needs the full user pipeline layout (and its bind groups set at
// dispatch). PipelineRTWebGPU emits this exact marker when so.
const traceHasUser = fullWgsl.includes("@CRAFTER_RT_TRACE_USER = true");
// Parse user bindings (same wire format as wgpuLoadCustomShader). For // Parse user bindings (same wire format as wgpuLoadCustomShader). For
// the wavefront RT pipeline, group 0 = WfParams, group 1 = data heaps, // the wavefront RT pipeline, group 0 = WfParams, group 1 = data heaps,
// group 2 = indirect args — so user bindings must start at group 3. // group 2 = indirect args — so user bindings must start at group 3.
@ -3003,11 +3142,14 @@ env.wgpuLoadRTPipeline = (wgslPtr, wgslLen, bindingsPtr, bindingsCount) => {
const entry = { const entry = {
genPipe: mk(userLayout, "wfGenerate"), genPipe: mk(userLayout, "wfGenerate"),
prepPipe: mk(prepLayout, "wfPrep"), prepPipe: mk(prepLayout, "wfPrep"),
tracePipe: mk(traceLayout, "wfTrace"), // TRACE gets the user layout only when any-hit / intersection
// shaders run there; otherwise it keeps the minimal params+data
// layout and zero user code (the common opaque-triangle path).
tracePipe: mk(traceHasUser ? userLayout : traceLayout, "wfTrace"),
shadePipe: mk(userLayout, "wfShade"), shadePipe: mk(userLayout, "wfShade"),
resolvePipe: mk(userLayout, "wfResolve"), resolvePipe: mk(userLayout, "wfResolve"),
paramsBgl, dataBgl, indirectBgl, emptyBgl, userBgls, paramsBgl, dataBgl, indirectBgl, emptyBgl, userBgls,
byGroup, sortedGroups, byGroup, sortedGroups, traceHasUser,
}; };
const handle = newHandle(); const handle = newHandle();
rtPipelines.set(handle, entry); rtPipelines.set(handle, entry);
@ -3187,6 +3329,9 @@ env.wgpuDispatchRT = (pipelineHandle, pushPtr, pushBytes,
p.setPipeline(pipe.tracePipe); p.setPipeline(pipe.tracePipe);
p.setBindGroup(0, paramsBg, [slotOff(traceSlot)]); p.setBindGroup(0, paramsBg, [slotOff(traceSlot)]);
p.setBindGroup(1, dataBg); p.setBindGroup(1, dataBg);
// Any-hit / intersection shaders run in TRACE and may read the
// user @group(3+) resources — bind them when present.
if (pipe.traceHasUser) setUser(p);
p.dispatchWorkgroupsIndirect(wf.indirect, 0); p.dispatchWorkgroupsIndirect(wf.indirect, 0);
p.end(); p.end();
} }

View file

@ -0,0 +1,26 @@
# RTVolume
WebGPU software ray tracing of **procedural (AABB) geometry** with an
**any-hit** cut-out — the two features added for issue #13.
A 3×3×3 grid of unit boxes is registered as an AABB BLAS
(`Mesh::BuildProcedural`, the WebGPU analog of `VK_GEOMETRY_TYPE_AABBS_KHR`).
The hit group is a `RTShaderGroupType::ProceduralHitGroup` carrying:
- `intersection.wgsl` — analytic raysphere test that turns each box into a
radius-1 sphere (runs in TRACE, once per box the ray enters);
- `anyhit.wgsl` — returns `RT_ANYHIT_IGNORE` for half the cells of a
spherical checkerboard, so the ray passes through and the background /
spheres behind show through (the visible proof any-hit runs);
- `closesthit.wgsl` — normal-based Lambert shading, tinted per instance.
The geometry is registered **non-opaque** and the instances clear their
force-opaque flag, which is what lets the any-hit shader run. Flip the
instance flag to `kRTGeometryInstanceForceOpaque` (or build the mesh with
`opaque = true`) to skip any-hit and see solid spheres.
WebGPU/DOM only:
```
crafter-build --target=wasm32-wasip1 -r
```

View file

@ -0,0 +1,24 @@
// RTVolume any-hit shader (runs in TRACE on every candidate sphere hit,
// because the geometry is registered non-opaque). Punches a spherical
// checkerboard of holes: for half the cells it returns RT_ANYHIT_IGNORE,
// so the ray passes straight through and the background / spheres behind
// show through. Returning RT_ANYHIT_ACCEPT keeps the hit. This is the
// visible proof the any-hit path runs with it the spheres are perforated,
// without it they would be solid.
fn anyhit_main(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) -> u32 {
// Object-space hit point on the unit sphere its normal/direction.
let posObj = hit.objectRayOrigin + hit.objectRayDirection * hit.t;
let n = normalize(posObj);
let PI = 3.14159265;
let longitude = atan2(n.z, n.x); // [-PI, PI]
let latitude = asin(clamp(n.y, -1.0, 1.0)); // [-PI/2, PI/2]
let cu = i32(floor((longitude + PI) / PI * 6.0));
let cv = i32(floor((latitude + PI * 0.5) / PI * 6.0));
if (((cu + cv) & 1) == 0) {
return RT_ANYHIT_IGNORE; // cut-out cell see through
}
return RT_ANYHIT_ACCEPT;
}

View file

@ -0,0 +1,37 @@
// RTVolume closest-hit (runs in SHADE). Shades the procedural sphere by
// its surface normal with a fixed sun + ambient, tinted per instance.
//
// Payload declared here so the assembler sees it before wfPayload / SHADE.
struct Payload {
color: vec3<f32>,
};
const SUN_DIR_TO_LIGHT: vec3<f32> = vec3<f32>(0.40, 0.85, 0.35);
const SUN_COLOR: vec3<f32> = vec3<f32>(1.20, 1.10, 0.95);
const AMBIENT_COLOR: vec3<f32> = vec3<f32>(0.16, 0.18, 0.24);
fn instanceAlbedo(i: u32) -> vec3<f32> {
let h = i * 2654435761u;
return vec3<f32>(
0.35 + 0.6 * f32((h >> 0u) & 255u) / 255.0,
0.35 + 0.6 * f32((h >> 8u) & 255u) / 255.0,
0.35 + 0.6 * f32((h >> 16u) & 255u) / 255.0);
}
fn closesthit_main(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) {
// Object-space hit point on the unit sphere is its object-space normal.
let posObj = hit.objectRayOrigin + hit.objectRayDirection * hit.t;
let nObj = normalize(posObj);
let nWorld = normalize(vec3<f32>(
dot(hit.objectToWorldR0.xyz, nObj),
dot(hit.objectToWorldR1.xyz, nObj),
dot(hit.objectToWorldR2.xyz, nObj)));
let albedo = instanceAlbedo(hit.customIndex);
let viewDir = -ray.direction;
let nFacing = select(-nWorld, nWorld, dot(nWorld, viewDir) > 0.0);
let sunDir = normalize(SUN_DIR_TO_LIGHT);
let nDotL = max(0.0, dot(nFacing, sunDir));
rtAccumulate(albedo * (AMBIENT_COLOR + SUN_COLOR * nDotL));
}

View file

@ -0,0 +1,33 @@
// RTVolume intersection shader (runs in TRACE, per AABB the ray enters).
// Analytic ray-sphere test: the unit box [-1,1]^3 is treated as the
// bounding volume of a sphere of radius 1 centred at the box centre. The
// ray is in object space and is NOT normalised (it is worldToObject *
// worldRay), so the returned t is directly comparable to the world-space
// ray parameter the tracer commits solve the quadratic with the general
// a = dot(d,d) form rather than assuming |d| == 1.
fn intersection_main(ray: RayDesc, aabbMin: vec3<f32>, aabbMax: vec3<f32>,
primitiveId: u32) -> IntersectionResult {
var r: IntersectionResult;
r.hit = false;
let center = (aabbMin + aabbMax) * 0.5;
let radius = (aabbMax.x - aabbMin.x) * 0.5;
let oc = ray.origin - center;
let a = dot(ray.direction, ray.direction);
let b = 2.0 * dot(oc, ray.direction);
let c = dot(oc, oc) - radius * radius;
let disc = b * b - 4.0 * a * c;
if (disc < 0.0) { return r; }
let sq = sqrt(disc);
var t = (-b - sq) / (2.0 * a); // near root
if (t < ray.tMin) { t = (-b + sq) / (2.0 * a); } // fall back to far root
if (t < ray.tMin || t > ray.tMax) { return r; }
r.hit = true;
r.t = t;
r.attribs = vec2<f32>(0.0);
r.hitKind = 0u;
return r;
}

199
examples/RTVolume/main.cpp Normal file
View file

@ -0,0 +1,199 @@
// RTVolume — procedural (AABB) ray tracing on the WebGPU wavefront tracer.
// Demonstrates the two features this example was written to exercise:
//
// * VK_GEOMETRY_TYPE_AABBS_KHR equivalent — a BLAS built from AABBs
// (Mesh::BuildProcedural) whose surface is supplied by an intersection
// shader (here an analytic raysphere test). The boxes are unit cubes
// [-1,1]^3; the intersection shader turns each into a sphere.
//
// * any-hit — the spheres are registered non-opaque, and an any-hit
// shader punches a spherical checkerboard of holes by returning
// RT_ANYHIT_IGNORE for half the cells. Without any-hit the spheres are
// solid; with it you can see the background (and other spheres)
// through the cut-out cells.
//
// A 3×3×3 grid of these procedural spheres is shaded by surface normal +
// a fixed sun. WebGPU/DOM only — this is the software RT path.
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
int main() { return 0; } // native path is hardware RT; out of scope here
#else
import Crafter.Graphics;
import Crafter.Math;
import Crafter.Event;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
constexpr int kGrid = 3;
constexpr float kSpacing = 3.0f;
struct CameraGPU {
float origin[3]; float pad0;
float right[3]; float tanHalf;
float up[3]; float aspect;
float forward[3]; float pad1;
};
static_assert(sizeof(CameraGPU) == 64);
}
int main() {
const int instanceCount = kGrid * kGrid * kGrid;
std::println("[RTVolume] grid {}^3 = {} procedural spheres", kGrid, instanceCount);
Device::Initialize();
static Window window(1280, 720, "RTVolume");
auto cmd = window.StartInit();
DescriptorHeapWebGPU heap;
heap.Initialize(/*images*/ 1, /*buffers*/ 2, /*samplers*/ 1);
// SBT order fixes the shader indices used by the groups below.
std::array<WebGPUShader, 6> shaders {{
WebGPUShader(fs::path("raygen.wgsl"), "raygen_main", WebGPURTStage::Raygen),
WebGPUShader(fs::path("miss.wgsl"), "miss_main", WebGPURTStage::Miss),
WebGPUShader(fs::path("closesthit.wgsl"), "closesthit_main", WebGPURTStage::ClosestHit),
WebGPUShader(fs::path("anyhit.wgsl"), "anyhit_main", WebGPURTStage::AnyHit),
WebGPUShader(fs::path("intersection.wgsl"), "intersection_main", WebGPURTStage::Intersection),
WebGPUShader(fs::path("resolve.wgsl"), "resolve_main", WebGPURTStage::Resolve),
}};
ShaderBindingTableWebGPU sbt;
sbt.Init(shaders);
std::array<RTShaderGroup, 1> raygenGroups {{ { .type = RTShaderGroupType::General, .generalShader = 0 } }};
std::array<RTShaderGroup, 1> missGroups {{ { .type = RTShaderGroupType::General, .generalShader = 1 } }};
// One procedural hit group: closest-hit + any-hit + intersection.
std::array<RTShaderGroup, 1> hitGroups {{ {
.type = RTShaderGroupType::ProceduralHitGroup,
.closestHitShader = 2,
.anyHitShader = 3,
.intersectionShader = 4,
} }};
std::array<UICustomBinding, 1> bindings {{
{ .group = 3, .binding = 0, .kind = UICustomBindingKind::Buffer, ._pad = 0, .pushOffset = 0 },
}};
PipelineRTWebGPU pipeline;
pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, sbt, bindings);
// ── One procedural unit-box BLAS. The intersection shader treats the
// box as the bounding volume of a radius-1 sphere centred at the
// object origin. opaque=false so the any-hit cut-out runs. ─────────
static std::array<RTAabb, 1> boxes {{
{ .min = {-1.0f, -1.0f, -1.0f}, .max = {1.0f, 1.0f, 1.0f} },
}};
static Mesh sphere;
sphere.BuildProcedural(boxes, /*opaque*/ false, cmd);
// ── Camera buffer + handle array. ─────────────────────────────────
WebGPUBuffer<CameraGPU, true> cameraBuf;
cameraBuf.Create(1);
static std::array<std::uint32_t, 1> userHandles { cameraBuf.handle };
// ── Instance grid. ────────────────────────────────────────────────
static std::vector<RenderingElement3D> renderers;
renderers.reserve(static_cast<std::size_t>(instanceCount));
const float origin0 = -0.5f * static_cast<float>(kGrid - 1) * kSpacing;
for (int x = 0; x < kGrid; ++x)
for (int y = 0; y < kGrid; ++y)
for (int z = 0; z < kGrid; ++z) {
renderers.emplace_back();
RenderingElement3D& r = renderers.back();
auto& tx = r.instance.transform.matrix;
tx[0][0] = 1; tx[0][1] = 0; tx[0][2] = 0; tx[0][3] = origin0 + float(x) * kSpacing;
tx[1][0] = 0; tx[1][1] = 1; tx[1][2] = 0; tx[1][3] = origin0 + float(y) * kSpacing;
tx[2][0] = 0; tx[2][1] = 0; tx[2][2] = 1; tx[2][3] = origin0 + float(z) * kSpacing;
r.instance.instanceCustomIndex = static_cast<std::uint32_t>(renderers.size() - 1);
r.instance.mask = 0xFF;
r.instance.instanceShaderBindingTableRecordOffset = 0;
// flags = 0: do NOT force opaque, so the any-hit shader runs.
r.instance.flags = 0;
r.instance.accelerationStructureReference = sphere.blasAddr;
RenderingElement3D::Add(&r);
}
RenderingElement3D::BuildTLAS(cmd, 0);
window.descriptorHeap = &heap;
window.FinishInit();
RTPass rtPass(&pipeline);
rtPass.handlesPtr = userHandles.data();
rtPass.handlesCount = static_cast<std::uint32_t>(userHandles.size());
rtPass.maxDepth = 1; // primary only
window.passes.push_back(&rtPass);
// ── Free camera framing the grid. ─────────────────────────────────
const float ext = float(kGrid - 1) * kSpacing;
struct CamState {
Vector<float, 3, 4> position;
float yaw;
float pitch;
} cam {
Vector<float, 3, 4>{ ext * 1.1f, ext * 0.8f, ext * 1.6f },
0.0f, 0.0f,
};
{
Vector<float, 3, 4> d { -cam.position.x, -cam.position.y, -cam.position.z };
const float len = std::sqrt(d.x*d.x + d.y*d.y + d.z*d.z);
cam.yaw = std::atan2(d.z, d.x);
cam.pitch = std::asin(d.y / len);
}
Input::Map inputMap;
Input::Action& moveAct = inputMap.AddAction("Move", Input::ActionType::Vector2);
Input::Action& lookAct = inputMap.AddAction("Look", Input::ActionType::Vector2);
moveAct.bindings = { Input::WASDBind{
Key(CrafterKeys::W), Key(CrafterKeys::S), Key(CrafterKeys::A), Key(CrafterKeys::D) } };
lookAct.bindings = { Input::MouseDeltaBind{ 1.0f } };
inputMap.Attach(window);
const float kMoveSpeed = ext * 0.8f + 1.0f;
const float kLookSens = 0.05f;
const float kDt = 1.0f / 60.0f;
EventListener<void> camTick(&window.onBeforeUpdate, [&]() {
inputMap.Tick();
cam.yaw += lookAct.vector2.x * kLookSens;
cam.pitch -= lookAct.vector2.y * kLookSens;
cam.pitch = std::clamp(cam.pitch, -1.55f, 1.55f);
const float cp = std::cos(cam.pitch), sp = std::sin(cam.pitch);
const float cy = std::cos(cam.yaw), sy = std::sin(cam.yaw);
Vector<float, 3, 4> forward { cp * cy, sp, cp * sy };
Vector<float, 3, 4> worldUp { 0.0f, 1.0f, 0.0f };
Vector<float, 3, 4> right { forward.y*worldUp.z - forward.z*worldUp.y,
forward.z*worldUp.x - forward.x*worldUp.z,
forward.x*worldUp.y - forward.y*worldUp.x };
const float rLen = std::sqrt(right.x*right.x + right.y*right.y + right.z*right.z);
right.x /= rLen; right.y /= rLen; right.z /= rLen;
Vector<float, 3, 4> up { right.y*forward.z - right.z*forward.y,
right.z*forward.x - right.x*forward.z,
right.x*forward.y - right.y*forward.x };
const float dx = moveAct.vector2.x * kMoveSpeed * kDt;
const float dy = moveAct.vector2.y * kMoveSpeed * kDt;
cam.position.x += right.x*dx + forward.x*dy;
cam.position.y += right.y*dx + forward.y*dy;
cam.position.z += right.z*dx + forward.z*dy;
CameraGPU& g = cameraBuf.value[0];
g.origin[0]=cam.position.x; g.origin[1]=cam.position.y; g.origin[2]=cam.position.z; g.pad0=0;
g.right[0]=right.x; g.right[1]=right.y; g.right[2]=right.z;
g.up[0]=up.x; g.up[1]=up.y; g.up[2]=up.z;
g.forward[0]=forward.x; g.forward[1]=forward.y; g.forward[2]=forward.z;
g.aspect = float(window.width) / float(window.height);
g.tanHalf = std::tan(70.0f * 3.14159265f / 360.0f);
g.pad1 = 0;
cameraBuf.FlushDevice();
});
window.Render();
window.StartUpdate();
window.StartSync();
return 0;
}
#endif

View file

@ -0,0 +1,7 @@
// RTVolume miss (runs in SHADE). Vertical sky gradient also what shows
// through the any-hit cut-out cells.
fn miss_main(ray: RayDesc, payload: ptr<function, Payload>) {
let t = clamp(ray.direction.y * 0.5 + 0.5, 0.0, 1.0);
rtAccumulate(mix(vec3<f32>(0.05, 0.07, 0.12),
vec3<f32>(0.45, 0.60, 0.85), t));
}

View file

@ -0,0 +1,48 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
bool isWasm = false;
for (std::string_view a : args) {
if (a.starts_with("--target=") && a.find("wasm") != std::string_view::npos) {
isWasm = true;
break;
}
}
std::vector<std::string> graphicsArgs(args.begin(), args.end());
Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp",
.args = graphicsArgs,
});
Configuration cfg;
cfg.path = "./";
cfg.name = "RTVolume";
cfg.outputName = "RTVolume";
cfg.type = ConfigurationType::Executable;
if (isWasm) {
cfg.target = "wasm32-wasip1";
cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_DOM", ""});
cfg.compileFlags.push_back("-msimd128");
}
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
if (isWasm) {
cfg.files.emplace_back(fs::path("raygen.wgsl"));
cfg.files.emplace_back(fs::path("intersection.wgsl"));
cfg.files.emplace_back(fs::path("anyhit.wgsl"));
cfg.files.emplace_back(fs::path("closesthit.wgsl"));
cfg.files.emplace_back(fs::path("miss.wgsl"));
cfg.files.emplace_back(fs::path("resolve.wgsl"));
EnableWasiBrowserRuntime(cfg);
}
return cfg;
}

View file

@ -0,0 +1,34 @@
// RTVolume raygen (runs in GENERATE). Host-driven pinhole camera at
// @group(3) (groups 0..2 are reserved by the wavefront pipeline:
// 0 = WfParams, 1 = data heaps, 2 = indirect args).
struct Camera {
origin: vec3<f32>,
pad0: f32,
right: vec3<f32>,
tanHalf: f32,
up: vec3<f32>,
aspect: f32,
forward: vec3<f32>,
pad1: f32,
};
@group(3) @binding(0) var<storage, read> camera : Camera;
fn raygen_main(gid: vec3<u32>) {
if (gid.x >= wfParams.surfaceW || gid.y >= wfParams.surfaceH) { return; }
let pixelf = vec2<f32>(f32(gid.x), f32(gid.y));
let res = vec2<f32>(f32(wfParams.surfaceW), f32(wfParams.surfaceH));
let uv = (pixelf + vec2<f32>(0.5)) / res;
let ndc = uv * 2.0 - vec2<f32>(1.0);
let direction = normalize(
camera.right * (ndc.x * camera.aspect * camera.tanHalf) +
camera.up * (-ndc.y * camera.tanHalf) +
camera.forward);
var p: Payload;
p.color = vec3<f32>(0.0);
rtEmitPrimaryRay(camera.origin, 0.01, direction, 100000.0,
0u, 0xFFu, 0u, 0u, p);
}

View file

@ -0,0 +1,7 @@
// RTVolume RESOLVE-stage tonemap: Reinhard + gamma 2.2 over the linear
// accumulator.
fn resolve_main(coord: vec2<u32>, hdr: vec4<f32>) -> vec4<f32> {
let mapped = hdr.rgb / (hdr.rgb + vec3<f32>(1.0));
let g = pow(mapped, vec3<f32>(1.0 / 2.2));
return vec4<f32>(g, 1.0);
}

View file

@ -213,6 +213,25 @@ namespace {
nodes.emplace_back(); nodes.emplace_back();
BuildRecursive(0, 0, triCount); BuildRecursive(0, 0, triCount);
} }
// AABB (procedural) geometry: one PrimRef per box, the box itself
// is the primitive bound. The same SAH BVH2 then partitions them.
void BuildFromAabbs(std::span<const RTAabb> aabbs) {
std::uint32_t count = static_cast<std::uint32_t>(aabbs.size());
prims.resize(count);
for (std::uint32_t i = 0; i < count; ++i) {
auto& pr = prims[i];
pr.box.Extend(aabbs[i].min);
pr.box.Extend(aabbs[i].max);
pr.centroid[0] = (pr.box.lo[0] + pr.box.hi[0]) * 0.5f;
pr.centroid[1] = (pr.box.lo[1] + pr.box.hi[1]) * 0.5f;
pr.centroid[2] = (pr.box.lo[2] + pr.box.hi[2]) * 0.5f;
pr.triIndex = i;
}
nodes.reserve(count * 2);
nodes.emplace_back();
BuildRecursive(0, 0, count);
}
}; };
} }
@ -243,7 +262,10 @@ namespace {
indices.data(), static_cast<std::int32_t>(indices.size()), indices.data(), static_cast<std::int32_t>(indices.size()),
builder.nodes.data(), static_cast<std::int32_t>(builder.nodes.size()), builder.nodes.data(), static_cast<std::int32_t>(builder.nodes.size()),
primRemap.data(), static_cast<std::int32_t>(primRemap.size()), primRemap.data(), static_cast<std::int32_t>(primRemap.size()),
attribsBytes.data(), static_cast<std::int32_t>(attribsBytes.size())); attribsBytes.data(), static_cast<std::int32_t>(attribsBytes.size()),
/*geomType*/ 0,
/*opaqueFlag*/ mesh.opaque ? 1 : 0,
/*primCount*/ static_cast<std::int32_t>(mesh.triangleCount));
} }
} }
@ -273,3 +295,43 @@ void Mesh::Build(const CompressedMeshAsset& asset,
BuildBVHAndRegister(*this, vertices, indices, std::span(dataBytes)); BuildBVHAndRegister(*this, vertices, indices, std::span(dataBytes));
} }
void Mesh::BuildProcedural(std::span<const RTAabb> aabbs,
bool opaque_,
WebGPUCommandEncoderRef /*cmd*/) {
const std::uint32_t count = static_cast<std::uint32_t>(aabbs.size());
opaque = opaque_;
triangleCount = 0; // not a triangle mesh
vertexCount = count * 2; // 2 "vertices" (min,max) per box
Builder builder;
builder.BuildFromAabbs(aabbs);
// The AABB stream is uploaded in *original* primitive order (2 vec3 per
// box). primRemap maps each BVH leaf slot back to its original index, so
// the intersection shader's _rtFetchAabb(meshRec, primId) reads the
// right box — exactly mirroring how the triangle path indexes vertices.
std::vector<Vector<float, 3, 3>> boxVerts(count * 2);
for (std::uint32_t i = 0; i < count; ++i) {
boxVerts[i*2 + 0] = Vector<float, 3, 3>{ aabbs[i].min[0], aabbs[i].min[1], aabbs[i].min[2] };
boxVerts[i*2 + 1] = Vector<float, 3, 3>{ aabbs[i].max[0], aabbs[i].max[1], aabbs[i].max[2] };
}
std::vector<std::uint32_t> primRemap(count);
for (std::uint32_t i = 0; i < count; ++i) {
primRemap[i] = builder.prims[i].triIndex;
}
const BVHNode& root = builder.nodes[0];
blasAddr = WebGPU::wgpuRegisterMeshBLAS(
root.aabbMin[0], root.aabbMin[1], root.aabbMin[2],
root.aabbMax[0], root.aabbMax[1], root.aabbMax[2],
boxVerts.data(), static_cast<std::int32_t>(boxVerts.size()),
nullptr, 0,
builder.nodes.data(), static_cast<std::int32_t>(builder.nodes.size()),
primRemap.data(), static_cast<std::int32_t>(primRemap.size()),
nullptr, 0,
/*geomType*/ 1,
/*opaqueFlag*/ opaque ? 1 : 0,
/*primCount*/ static_cast<std::int32_t>(count));
}

View file

@ -36,6 +36,8 @@ namespace {
"fn _crafter_default_anyhit(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) -> u32 { return RT_ANYHIT_ACCEPT; }"; "fn _crafter_default_anyhit(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) -> u32 { return RT_ANYHIT_ACCEPT; }";
constexpr std::string_view kPlaceholderMiss = constexpr std::string_view kPlaceholderMiss =
"fn _crafter_default_miss(ray: RayDesc, payload: ptr<function, Payload>) {}"; "fn _crafter_default_miss(ray: RayDesc, payload: ptr<function, Payload>) {}";
constexpr std::string_view kPlaceholderIntersection =
"fn _crafter_default_intersection(ray: RayDesc, aabbMin: vec3<f32>, aabbMax: vec3<f32>, primitiveId: u32) -> IntersectionResult { var r: IntersectionResult; r.hit = false; return r; }";
void AppendCase(std::string& out, void AppendCase(std::string& out,
std::uint32_t hitGroupIndex, std::uint32_t hitGroupIndex,
@ -60,6 +62,17 @@ namespace {
out += entryFn; out += entryFn;
out += "(ray, hit, payload); }\n"; out += "(ray, hit, payload); }\n";
} }
// intersection has a return type — forwards the AABB args + the result.
void AppendIntersectionCase(std::string& out,
std::uint32_t hitGroupIndex,
std::string_view entryFn) {
out += " case ";
out += std::to_string(hitGroupIndex);
out += "u: { return ";
out += entryFn;
out += "(ray, aabbMin, aabbMax, primitiveId); }\n";
}
} }
void PipelineRTWebGPU::Init(WebGPUCommandEncoderRef /*cmd*/, void PipelineRTWebGPU::Init(WebGPUCommandEncoderRef /*cmd*/,
@ -150,6 +163,46 @@ void PipelineRTWebGPU::Init(WebGPUCommandEncoderRef /*cmd*/,
wgsl += " }\n"; wgsl += " }\n";
wgsl += "}\n"; wgsl += "}\n";
// runIntersection — per-AABB procedural intersection dispatch. For a
// ProceduralHitGroup the intersection shader determines the hit; for
// triangle groups (or groups with no intersection shader) the default
// reports no hit, so the BLAS leaf falls back to the triangle path.
wgsl += "\nfn runIntersection(hg: u32, ray: RayDesc, aabbMin: vec3<f32>, aabbMax: vec3<f32>, primitiveId: u32) -> IntersectionResult {\n";
wgsl += " switch hg {\n";
bool anyIntersection = false;
for (std::uint32_t i = 0; i < hitGroups.size(); ++i) {
const auto& g = hitGroups[i];
if (g.intersectionShader == kRTShaderUnused) continue;
if (g.intersectionShader >= sbt.shaders.size()) continue;
const auto& fn = sbt.shaders[g.intersectionShader].entryFn;
AppendIntersectionCase(wgsl, i, fn);
anyIntersection = true;
}
if (!anyIntersection) wgsl += " // (no intersection shaders registered)\n";
wgsl += " default: { }\n";
wgsl += " }\n";
wgsl += " var none: IntersectionResult;\n";
wgsl += " none.hit = false;\n";
wgsl += " return none;\n";
wgsl += "}\n";
// Trace-time capability flags. The library traversal (injected at the
// marker below) gates its any-hit / intersection callbacks on these
// consts, so a triangle-only opaque scene dead-strips all user code out
// of TRACE and keeps its zero-user-code register footprint. When either
// is set the JS side also gives the TRACE pipeline the user bind-group
// layout (so any-hit / intersection shaders can sample @group(3+)
// resources) — it scans for the exact `@CRAFTER_RT_TRACE_USER` marker.
wgsl += "\nconst RT_HAS_ANYHIT: bool = ";
wgsl += (anyAnyhit ? "true" : "false");
wgsl += ";\n";
wgsl += "const RT_HAS_INTERSECTION: bool = ";
wgsl += (anyIntersection ? "true" : "false");
wgsl += ";\n";
if (anyAnyhit || anyIntersection) {
wgsl += "// @CRAFTER_RT_TRACE_USER = true\n";
}
// runResolve — RESOLVE-stage tonemap hook. The first registered // runResolve — RESOLVE-stage tonemap hook. The first registered
// Resolve shader wins; with none, identity passthrough (alpha forced // Resolve shader wins; with none, identity passthrough (alpha forced
// to 1) so the wavefront output matches a megakernel that wrote raw // to 1) so the wavefront output matches a megakernel that wrote raw

View file

@ -87,6 +87,17 @@ export namespace Crafter {
}; };
static_assert(sizeof(BVHNode) == 32); static_assert(sizeof(BVHNode) == 32);
// One procedural primitive's axis-aligned bounding box, in object
// space. The analog of VkAabbPositionsKHR — the BLAS stores these
// instead of triangles and an intersection shader (registered in the
// hit group as a ProceduralHitGroup) reports the actual surface hit
// for each AABB the ray enters.
struct RTAabb {
float min[3];
float max[3];
};
static_assert(sizeof(RTAabb) == 24);
class Mesh { class Mesh {
public: public:
// BLAS "handle": opaque identity that goes into // BLAS "handle": opaque identity that goes into
@ -119,6 +130,19 @@ export namespace Crafter {
// as `vertexAttribs : array<u32>` with a per-mesh u32-word offset. // as `vertexAttribs : array<u32>` with a per-mesh u32-word offset.
void Build(const ::Crafter::CompressedMeshAsset& asset, void Build(const ::Crafter::CompressedMeshAsset& asset,
WebGPUCommandEncoderRef cmd = 0); WebGPUCommandEncoderRef cmd = 0);
// Build an AABB (procedural) BLAS from a list of object-space boxes
// — the WebGPU analog of a VK_GEOMETRY_TYPE_AABBS_KHR geometry. The
// hit group bound to instances of this mesh must be a
// ProceduralHitGroup carrying an intersection shader; that shader is
// invoked for each box the ray enters and reports the surface hit.
// `opaque` is the geometry's opaque bit: pass false to let any-hit
// shaders run (the default for procedural geometry, which is usually
// transparent / volumetric). The `cmd` parameter is unused on
// WebGPU — kept for API symmetry with the triangle path.
void BuildProcedural(std::span<const RTAabb> aabbs,
bool opaque = false,
WebGPUCommandEncoderRef cmd = 0);
}; };
} }
#endif // CRAFTER_GRAPHICS_WINDOW_DOM #endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -60,24 +60,33 @@ export namespace Crafter {
inline constexpr std::uint8_t kRTGeometryInstanceForceOpaque = 0x4; inline constexpr std::uint8_t kRTGeometryInstanceForceOpaque = 0x4;
inline constexpr std::uint8_t kRTGeometryInstanceForceNoOpaque = 0x8; inline constexpr std::uint8_t kRTGeometryInstanceForceNoOpaque = 0x8;
// Hit-group identification. Matches VkRayTracingShaderGroupTypeKHR for // Hit-group identification. Matches VkRayTracingShaderGroupTypeKHR.
// the two types we actually support (general + triangles-hit). // General — raygen / miss / callable
// TrianglesHitGroup — closest-hit/any-hit over triangle geometry
// ProceduralHitGroup — closest-hit/any-hit + an intersection shader
// over AABB (VK_GEOMETRY_TYPE_AABBS_KHR) geometry
enum class RTShaderGroupType : std::uint8_t { enum class RTShaderGroupType : std::uint8_t {
General = 0, // raygen / miss / callable General = 0, // raygen / miss / callable
TrianglesHitGroup = 1, TrianglesHitGroup = 1,
ProceduralHitGroup = 2,
}; };
// Cross-backend description of one entry in the shader-group array // Cross-backend description of one entry in the shader-group array
// passed to PipelineRT::Init. Mirrors the meaningful subset of // passed to PipelineRT::Init. Mirrors the meaningful subset of
// VkRayTracingShaderGroupCreateInfoKHR: per group, the type and the // VkRayTracingShaderGroupCreateInfoKHR: per group, the type and the
// indices (into the SBT's shader array) for general / closestHit / // indices (into the SBT's shader array) for general / closestHit /
// anyHit, with kRTShaderUnused == VK_SHADER_UNUSED_KHR for "none". // anyHit / intersection, with kRTShaderUnused == VK_SHADER_UNUSED_KHR
// for "none". `intersectionShader` is only consulted for
// ProceduralHitGroup; it names the shader run for each AABB the ray
// enters (the analog of VkRayTracingShaderGroupCreateInfoKHR::
// intersectionShader).
inline constexpr std::uint32_t kRTShaderUnused = 0xFFFFFFFFu; inline constexpr std::uint32_t kRTShaderUnused = 0xFFFFFFFFu;
struct RTShaderGroup { struct RTShaderGroup {
RTShaderGroupType type = RTShaderGroupType::General; RTShaderGroupType type = RTShaderGroupType::General;
std::uint32_t generalShader = kRTShaderUnused; std::uint32_t generalShader = kRTShaderUnused;
std::uint32_t closestHitShader = kRTShaderUnused; std::uint32_t closestHitShader = kRTShaderUnused;
std::uint32_t anyHitShader = kRTShaderUnused; std::uint32_t anyHitShader = kRTShaderUnused;
std::uint32_t intersectionShader = kRTShaderUnused;
}; };
} }

View file

@ -14,15 +14,23 @@ import std;
export namespace Crafter { export namespace Crafter {
enum class WebGPURTStage : std::uint8_t { enum class WebGPURTStage : std::uint8_t {
Raygen = 0, Raygen = 0,
Miss = 1, Miss = 1,
ClosestHit = 2, ClosestHit = 2,
AnyHit = 3, AnyHit = 3,
// Wavefront RESOLVE-stage tonemap/output hook. Optional: if no // Wavefront RESOLVE-stage tonemap/output hook. Optional: if no
// Resolve shader is registered, RESOLVE writes the linear accum // Resolve shader is registered, RESOLVE writes the linear accum
// buffer through unchanged. Signature: // buffer through unchanged. Signature:
// fn <entryFn>(coord: vec2<u32>, hdr: vec4<f32>) -> vec4<f32> // fn <entryFn>(coord: vec2<u32>, hdr: vec4<f32>) -> vec4<f32>
Resolve = 4, Resolve = 4,
// Intersection shader for AABB (procedural) geometry. Run for each
// AABB primitive the ray enters during TRACE; reports whether the
// procedural surface is hit and at what distance. Signature:
// fn <entryFn>(ray: RayDesc, aabbMin: vec3<f32>, aabbMax: vec3<f32>,
// primitiveId: u32) -> IntersectionResult
// `ray` is in object space. IntersectionResult{ hit, t, attribs,
// hitKind } is declared by the library prelude.
Intersection = 5,
}; };
// One WGSL shader source + the function name PipelineRTWebGPU should // One WGSL shader source + the function name PipelineRTWebGPU should
@ -35,6 +43,9 @@ export namespace Crafter {
// ClosestHit: fn <entryFn>(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) // ClosestHit: fn <entryFn>(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>)
// AnyHit: fn <entryFn>(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) -> u32 // AnyHit: fn <entryFn>(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) -> u32
// returns RT_ANYHIT_ACCEPT / RT_ANYHIT_IGNORE / RT_ANYHIT_END_SEARCH. // returns RT_ANYHIT_ACCEPT / RT_ANYHIT_IGNORE / RT_ANYHIT_END_SEARCH.
// Intersection: fn <entryFn>(ray: RayDesc, aabbMin: vec3<f32>, aabbMax: vec3<f32>,
// primitiveId: u32) -> IntersectionResult
// IntersectionResult{ hit: bool, t: f32, attribs: vec2<f32>, hitKind: u32 }.
// //
// `RayDesc`, `HitInfo`, the `RT_*` flag/return constants, the `tlas` / // `RayDesc`, `HitInfo`, the `RT_*` flag/return constants, the `tlas` /
// BLAS / mesh-record bindings, and the `traceRay` function are all // BLAS / mesh-record bindings, and the `traceRay` function are all

View file

@ -167,6 +167,12 @@ namespace Crafter::WebGPU {
// that gets appended to a global attribs heap and exposed to RT // that gets appended to a global attribs heap and exposed to RT
// closest-hit shaders as `vertexAttribs : array<u32>` at // closest-hit shaders as `vertexAttribs : array<u32>` at
// @group(1) @binding(7). Pass (nullptr, 0) for positions-only meshes. // @group(1) @binding(7). Pass (nullptr, 0) for positions-only meshes.
// `geomType` selects the primitive kind: 0 = triangles (the
// verticesPtr/indicesPtr streams), 1 = AABBs (VK_GEOMETRY_TYPE_AABBS) —
// then verticesPtr holds 2 vec3 per primitive [min, max], indexCount is
// 0, and an intersection shader supplies the hit. `opaqueFlag` is the
// geometry's opaque bit (0 lets any-hit run). `primCount` is the
// triangle / AABB primitive count.
__attribute__((import_module("env"), import_name("wgpuRegisterMeshBLAS"))) __attribute__((import_module("env"), import_name("wgpuRegisterMeshBLAS")))
extern "C" std::uint32_t wgpuRegisterMeshBLAS( extern "C" std::uint32_t wgpuRegisterMeshBLAS(
float minX, float minY, float minZ, float minX, float minY, float minZ,
@ -175,7 +181,8 @@ namespace Crafter::WebGPU {
const void* indicesPtr, std::int32_t indexCount, const void* indicesPtr, std::int32_t indexCount,
const void* bvhNodesPtr, std::int32_t bvhNodeCount, const void* bvhNodesPtr, std::int32_t bvhNodeCount,
const void* primRemapPtr, std::int32_t primRemapCount, const void* primRemapPtr, std::int32_t primRemapCount,
const void* attribsPtr, std::int32_t attribsByteCount); const void* attribsPtr, std::int32_t attribsByteCount,
std::int32_t geomType, std::int32_t opaqueFlag, std::int32_t primCount);
// RT pipeline build. The library composes WGSL by concatenating the // RT pipeline build. The library composes WGSL by concatenating the
// traversal library, generated hit-group switches, and the user- // traversal library, generated hit-group switches, and the user-