// Payload declared here so the WGSL assembler sees it before raygen // (the assembler concatenates closesthit/anyhit/miss BEFORE raygen). // // WGSL forbids cycles in the function call graph, so closesthit_main // CAN'T call traceRay (that would create closesthit → traceRay → // runClosestHit → closesthit). The lighting + shadow trace therefore // happens in raygen; closesthit's job is just to gather surface data // into the payload. // // shadowRay = 0 (primary): closesthit fills albedo/worldPos/normal/hit. // shadowRay = 1 (shadow): closesthit is skipped (RT_FLAG_SKIP_CLOSEST_HIT), // miss flips color to white = "lit". struct Payload { color: vec3, shadowRay: u32, worldPos: vec3, hit: u32, worldNormal: vec3, _pad: f32, }; // User-bound resources at group(2). Matches the UICustomBinding span the // host hands to PipelineRTWebGPU::Init. // binding 0 — albedo texture_2d_array, one layer per Sponza material // binding 1 — sampler (linear clamp) // binding 2 — camera storage buffer (read by raygen only) @group(2) @binding(0) var albedos : texture_2d_array; @group(2) @binding(1) var samp : sampler; // VertexNormalTangentUVPacked is `packed` on the outer struct but each // inner `Vector` is SIMD-aligned to a 16-byte stride. So // each vertex is 12 u32 words: normal at 0..2, tangent at 4..6, uv at 8..9. const ATTRIB_STRIDE_U32: u32 = 12u; const ATTRIB_NORMAL_OFFSET: u32 = 0u; const ATTRIB_UV_OFFSET: u32 = 8u; fn fetchUV(meshRec: MeshRecord, vertexIdx: u32) -> vec2 { let base = meshRec.attribsOffset + vertexIdx * ATTRIB_STRIDE_U32 + ATTRIB_UV_OFFSET; return vec2( bitcast(vertexAttribs[base + 0u]), bitcast(vertexAttribs[base + 1u]), ); } fn fetchNormal(meshRec: MeshRecord, vertexIdx: u32) -> vec3 { let base = meshRec.attribsOffset + vertexIdx * ATTRIB_STRIDE_U32 + ATTRIB_NORMAL_OFFSET; return vec3( bitcast(vertexAttribs[base + 0u]), bitcast(vertexAttribs[base + 1u]), bitcast(vertexAttribs[base + 2u]), ); } fn closesthit_main(ray: RayDesc, hit: HitInfo, payload: ptr) { // Resolve hit triangle → 3 vertex indices. let meshIdx = tlasEntries[hit.instanceId].blasMeshIdx; let meshRec = meshRecords[meshIdx]; let baseIdx = meshRec.indexOffset + hit.primitiveId * 3u; let i0 = indices[baseIdx + 0u]; let i1 = indices[baseIdx + 1u]; let i2 = indices[baseIdx + 2u]; let bary = vec3(1.0 - hit.attribs.x - hit.attribs.y, hit.attribs.x, hit.attribs.y); // Albedo via barycentric UV interpolation. let uv0 = fetchUV(meshRec, i0); let uv1 = fetchUV(meshRec, i1); let uv2 = fetchUV(meshRec, i2); let uv = uv0 * bary.x + uv1 * bary.y + uv2 * bary.z; // OBJ V is bottom-up; sampler is top-down. fract for manual tiling. let uvTiled = vec2(fract(uv.x), fract(1.0 - uv.y)); let layer = i32(hit.customIndex); let albedo = textureSampleLevel(albedos, samp, uvTiled, layer, 0.0).rgb; // World-space smooth shading normal. Multiply through the // object-to-world rotation so this stays correct if a future scene // rotates instances (Sponza itself is all identities). let n0 = fetchNormal(meshRec, i0); let n1 = fetchNormal(meshRec, i1); let n2 = fetchNormal(meshRec, i2); let nObj = normalize(n0 * bary.x + n1 * bary.y + n2 * bary.z); let nWorld = normalize(vec3( dot(hit.objectToWorldR0.xyz, nObj), dot(hit.objectToWorldR1.xyz, nObj), dot(hit.objectToWorldR2.xyz, nObj))); (*payload).color = albedo; (*payload).worldPos = ray.origin + ray.direction * hit.t; (*payload).worldNormal = nWorld; (*payload).hit = 1u; }