// WebGPU raygen. Camera state comes from the host every frame via a // storage buffer bound at @group(2) @binding(2); main.cpp drives that // from WASD + mouse-delta through Crafter::Input. // // The shading + shadow trace all happens here because WGSL forbids // recursive function call graphs — closesthit_main can't call traceRay // (that would loop closesthit → traceRay → runClosestHit → closesthit). // Raygen is the entry point and not called by anyone, so it can call // traceRay twice (once primary, once shadow) without forming a cycle. struct Camera { origin: vec3, pad0: f32, right: vec3, tanHalf: f32, up: vec3, aspect: f32, forward: vec3, pad1: f32, }; @group(2) @binding(2) var camera : Camera; // Sun coming through Sponza's open roof. Y is up; this points "down and // slightly along +X" so the light grazes the colonnades on one side. const SUN_DIR_TO_LIGHT: vec3 = vec3(-0.35, 1.00, -0.20); const SUN_COLOR: vec3 = vec3( 1.10, 1.00, 0.85); const AMBIENT_COLOR: vec3 = vec3( 0.18, 0.20, 0.28); fn raygen_main(gid: vec3) { if (gid.x >= hdr.surfaceW || gid.y >= hdr.surfaceH) { return; } let pixel = vec2(f32(gid.x), f32(gid.y)); let resolution = vec2(f32(hdr.surfaceW), f32(hdr.surfaceH)); let uv = (pixel + vec2(0.5)) / resolution; let ndc = uv * 2.0 - vec2(1.0); // Pinhole camera reconstructed from the host basis. ndc.x runs left- // to-right across the screen → +right; ndc.y is top-down so we // negate before applying +up. let direction = normalize( camera.right * (ndc.x * camera.aspect * camera.tanHalf) + camera.up * (-ndc.y * camera.tanHalf) + camera.forward); // ── Primary ray ──────────────────────────────────────────────────── var payload: Payload; payload.color = vec3(0.0); payload.shadowRay = 0u; payload.hit = 0u; traceRay( 0u, 0u, 0xFFu, 0u, 0u, 0u, camera.origin, 0.001, direction, 10000.0, &payload); var finalColor: vec3; if (payload.hit == 1u) { // Closesthit filled albedo/worldPos/worldNormal. Two-sided // shading: flip the normal toward the camera if we hit the back // face — Sponza's curtains in particular have inconsistent // winding, and without this half the surface would go black. let albedo = payload.color; let nFacing = select(-payload.worldNormal, payload.worldNormal, dot(payload.worldNormal, direction) < 0.0); let lightDir = normalize(SUN_DIR_TO_LIGHT); let nDotL = max(0.0, dot(nFacing, lightDir)); // ── Shadow ray ──────────────────────────────────────────────── // Only worth tracing if the surface faces the sun at all. var visibility = 0.0; if (nDotL > 0.0) { // Normal-offset bias on Sponza's units (~3700 wide atrium) // is hefty; 0.5 keeps the shadow ray clear of the originating // triangle without producing visible "floating" shadows. let shadowOrigin = payload.worldPos + nFacing * 0.5; var shadowPayload: Payload; shadowPayload.color = vec3(0.0); // default: blocked shadowPayload.shadowRay = 1u; shadowPayload.hit = 0u; traceRay( 0u, RT_FLAG_SKIP_CLOSEST_HIT | RT_FLAG_TERMINATE_ON_FIRST_HIT, 0xFFu, 0u, 0u, 0u, shadowOrigin, 0.001, lightDir, 10000.0, &shadowPayload); visibility = shadowPayload.color.x; } let lit = AMBIENT_COLOR + SUN_COLOR * (nDotL * visibility); finalColor = albedo * lit; } else { // Sky color was filled by miss_main. finalColor = payload.color; } // Reinhard tonemap + gamma 2.2 so sun-lit albedos don't clip and // shadow detail stays readable. let mapped = finalColor / (finalColor + vec3(1.0)); let gamma = pow(mapped, vec3(1.0 / 2.2)); textureStore(outImage, vec2(i32(gid.x), i32(gid.y)), vec4(gamma, 1.0)); }