// Screen-space ambient occlusion (GTAO) over the resolved native scene. // Ground-truth ambient occlusion after Jimenez et al.: per pixel, a few // screen-space slices each march the depth buffer both ways to find the // horizon angles, and the cosine-weighted arc between them integrates to a // visibility term. Normals are reconstructed from depth (no G-buffer), so // the pass needs only the scene depth + the projection constants. // // Pass chain (all fullscreen triangles, own binding layout): // ps_linearize scene depth (sample 0 when MSAA; AO_MSAA=1 variant) // -> view-space Z, R32_FLOAT, full res // ps_luma scene color -> luminance, R8_UNORM, AO raster (the // sun-lit protection mask input) // ps_gtao linear depth + luma -> the final gamma-encoded AO // multiplier, R8_UNORM (fully-protected pixels skip the // march, most of a daytime frame) // ps_blur depth-aware separable gaussian (2 passes), R8_UNORM // ps_composite AO onto the guest output via dst.rgb * src.a blending // (RGB write mask; the output has no usable alpha anyway) // ps_debug AO greyscale replace, for tuning // // Color space note: the scene target is gamma-encoded (the material shaders // linearize by squaring and return via sqrt), so the composite applies // sqrt(ao_linear): a linear-space multiply expressed in gamma space. cbuffer C : register(b0) { float4 size; // xy = output size in px, zw = 1 / size float4 proj; // x = |m00|, y = |m11|, z = m22, w = m32 (row-vector proj) float4 p0; // x = radius (view units), y = intensity, // z = fade start (view Z), w = 1 / fade range float4 p1; // xy = blur axis in px, z = sun-lit luma protection scale }; // t0 is the per-pass primary input: the scene depth for ps_linearize, the // linear-depth / AO intermediates for the march/blur, the scene color for // copy/composite. Each entry references exactly one of the overlapping t0 // declarations; the others drop out at compile. #ifdef AO_MSAA Texture2DMS depth_ms : register(t0); #endif Texture2D tex0 : register(t0); Texture2D tex0c : register(t0); // ps_luma scene color Texture2D tex1 : register(t1); // gtao: luma; blur: linear depth SamplerState smp_point : register(s0); SamplerState smp_linear : register(s1); static const float kPi = 3.14159265f; // 3 slices x 3 steps/side = 18 taps: the reference GTAO "high" budget. The // per-pixel rotation + depth-aware blur integrate the dither; the nonlinear // step distribution below spends the taps near the contact zone where the // occlusion signal lives. (6 steps was ~2x the cost for a difference the // blur erased; at night, where no pixel early-outs, that doubled the whole // pass.) #define AO_SLICES 3 #define AO_STEPS 3 struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; VSOut vs_main(uint id : SV_VertexID) { VSOut o; float2 uv = float2((id << 1) & 2, id & 2); o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1); o.uv = uv; return o; } // Device depth -> view-space Z. Row-vector projection with m23 = 1: // ndc_z = m22 + m32 / z_view. float LinearZ(float d) { return clamp(proj.w / min(d - proj.z, -1e-6), 0.05f, 4e5f); } float ps_linearize(VSOut i) : SV_Target { #ifdef AO_MSAA float d = depth_ms.Load(int2(i.pos.xy), 0); #else float d = tex0.Load(int3(int2(i.pos.xy), 0)); #endif return LinearZ(d); } // View-space position from UV + linear Z. |m00|/|m11| make the space a // (possibly mirrored) view space; every AO quantity is invariant under a // mirrored axis, so the projection's negative x scale needs no special // handling. float3 ViewPos(float2 uv, float z) { float2 ndc = float2(uv.x * 2.0f - 1.0f, 1.0f - uv.y * 2.0f); return float3(ndc.x * z / proj.x, ndc.y * z / proj.y, z); } float SampleZ(float2 uv) { return tex0.SampleLevel(smp_point, uv, 0); } // Normal from depth: for each axis take the neighbor with the smaller depth // delta (avoids smeared normals across silhouette edges), cross the two // tangents, and face the camera. float3 ReconstructNormal(float2 uv, float3 P) { float2 dx = float2(size.z, 0.0f); float2 dy = float2(0.0f, size.w); float zl = SampleZ(uv - dx), zr = SampleZ(uv + dx); float zu = SampleZ(uv - dy), zd = SampleZ(uv + dy); float3 th = (abs(zr - P.z) < abs(zl - P.z)) ? (ViewPos(uv + dx, zr) - P) : (P - ViewPos(uv - dx, zl)); float3 tv = (abs(zd - P.z) < abs(zu - P.z)) ? (ViewPos(uv + dy, zd) - P) : (P - ViewPos(uv - dy, zu)); float3 n = normalize(cross(tv, th)); return (dot(n, -P) < 0.0f) ? -n : n; } // Spatial rotation/offset noise (interleaved gradient noise); the blur // integrates the resulting high-frequency dither. float Ign(float2 p) { return frac(52.9829189f * frac(dot(p, float2(0.06711056f, 0.00583715f)))); } // Half-res scene luminance for the sun-lit protection mask (low-frequency // by nature, so the AO raster resolution is plenty; bilinear at the 2x // reduction is an exact 2x2 box). float ps_luma(VSOut i) : SV_Target { float3 scene = tex0c.SampleLevel(smp_linear, i.uv, 0).rgb; return dot(scene, float3(0.299f, 0.587f, 0.114f)); } float ps_gtao(VSOut i) : SV_Target { float zc = SampleZ(i.uv); float fade = saturate((zc - p0.z) * p0.w); // Sun-lit protection: real AO attenuates only ambient light; direct sun // is unaffected, but this chain runs post-tonemap where the two are // already summed. Approximate the split by luminance: bright pixels are // sun-lit and resist the darkening (keeping characters from dragging a // gray pool across lit pavement), dim pixels are ambient/shade-lit and // keep it. Fully-protected or fully-faded pixels skip the march. float luma = tex1.SampleLevel(smp_point, i.uv, 0); float protect = saturate(luma * p1.z); protect *= protect; // Skip fully-protected (sun-lit), fully-faded, and near-black pixels; a // multiplier on near-zero color is invisible, so night skies and deep // shadow don't pay for the march either. if (fade >= 1.0f || protect >= 0.999f || luma < 0.015f) { return 1.0f; } float3 P = ViewPos(i.uv, zc); float3 N = ReconstructNormal(i.uv, P); float3 V = -normalize(P); // World-space radius projected to pixels at this depth (vertical FOV // scale), bounded so near surfaces don't walk the whole screen (large // scattered footprints also thrash the texture cache; the dominant cost // of this pass is tap locality, not ALU). float radius_px = clamp(p0.x * proj.y / zc * size.y * 0.5f, 2.0f, 64.0f); float noise_dir = Ign(i.pos.xy); float noise_off = Ign(i.pos.xy + 41.13f); float inv_r = 1.0f / max(p0.x, 1e-4f); float vis = 0.0f; [unroll] for (int s = 0; s < AO_SLICES; ++s) { float phi = (float(s) + noise_dir) * (kPi / float(AO_SLICES)); float2 sdir = float2(cos(phi), sin(phi)); // The view-space direction a +sdir pixel step moves the surface point // (screen y runs down, view y up; aspect via the per-axis pixel sizes). float3 D = normalize(float3(sdir.x * size.z / proj.x, -sdir.y * size.w / proj.y, 0.0f)); float3 axis = normalize(cross(D, V)); float3 ortho = normalize(D - dot(D, V) * V); // Normal projected into the slice plane; its signed angle from V decides // the hemisphere the horizon arc is clamped to. float3 Np = N - axis * dot(N, axis); float npl = length(Np); if (npl < 1e-4f) { vis += 1.0f; continue; } float cos_n = clamp(dot(Np, V) / npl, -1.0f, 1.0f); float n = sign(dot(Np, ortho)) * acos(cos_n); // March both sides, tracking the max cos(angle to V) with a smooth // range falloff so geometry near the radius edge fades instead of pops. float2 hcos = float2(-1.0f, -1.0f); [unroll] for (int side = 0; side < 2; ++side) { float sgn = side == 0 ? -1.0f : 1.0f; [loop] for (int j = 0; j < AO_STEPS; ++j) { // Nonlinear step distribution: taps cluster toward the contact // zone; the falloff discounts the far ones anyway. float fj = (float(j) + noise_off) / float(AO_STEPS); float t = radius_px * fj * sqrt(fj) + 1.0f; float2 suv = i.uv + sgn * sdir * t * size.zw; float3 Ps = ViewPos(suv, SampleZ(suv)); float3 d3 = Ps - P; float dist2 = max(dot(d3, d3), 1e-8f); float rinv = rsqrt(dist2); float c = dot(d3, V) * rinv; float dist = dist2 * rinv; c = lerp(c, -1.0f, saturate(dist * inv_r * 3.333f - 2.333f)); hcos[side] = max(hcos[side], c); } } float h1 = n + max(-acos(clamp(hcos.x, -1.0f, 1.0f)) - n, -0.5f * kPi); float h2 = n + min(acos(clamp(hcos.y, -1.0f, 1.0f)) - n, 0.5f * kPi); // Cosine-weighted arc integral between the two horizons (GTAO). float a = 0.25f * ((-cos(2.0f * h1 - n) + cos_n + 2.0f * h1 * sin(n)) + (-cos(2.0f * h2 - n) + cos_n + 2.0f * h2 * sin(n))); vis += npl * a; } vis = saturate(vis / float(AO_SLICES)); // Bake the full multiplier here so the blur smooths the finished term and // the composite is a plain blend: intensity exponent on the linear // visibility, the extra 0.5 converting the multiply into the target's // gamma-2 encoding, then the protection and distance fades toward 1. float a = pow(vis, p0.y * 0.5f); a = lerp(a, 1.0f, protect); return lerp(a, 1.0f, fade); } // Depth-aware separable gaussian (sigma 2, radius 4): integrates the slice // dither without bleeding AO across depth edges. float ps_blur(VSOut i) : SV_Target { const float w[5] = {1.0f, 0.8825f, 0.6065f, 0.3247f, 0.1353f}; float zc = tex1.SampleLevel(smp_point, i.uv, 0); float acc = tex0.SampleLevel(smp_point, i.uv, 0); float wsum = 1.0f; [unroll] for (int k = 1; k <= 4; ++k) { [unroll] for (int s = 0; s < 2; ++s) { float2 suv = i.uv + (s == 0 ? -1.0f : 1.0f) * p1.xy * float(k) * size.zw; float zs = tex1.SampleLevel(smp_point, suv, 0); float wz = saturate(1.0f - abs(zs - zc) / (zc * 0.05f + 0.05f)); float wk = w[k] * wz; acc += tex0.SampleLevel(smp_point, suv, 0) * wk; wsum += wk; } } return acc / wsum; } // dst.rgb * src.a blend (RGB write mask): the AO plane already carries the // finished gamma-encoded, luma-protected, distance-faded multiplier. float4 ps_composite(VSOut i) : SV_Target { return float4(0.0f, 0.0f, 0.0f, tex0.SampleLevel(smp_linear, i.uv, 0)); } float4 ps_debug(VSOut i) : SV_Target { float ao = tex0.SampleLevel(smp_linear, i.uv, 0); return float4(ao, ao, ao, 1.0f); }