From 848c840ecf17325f4468d93e2b059eff397ec94e Mon Sep 17 00:00:00 2001 From: grom Date: Tue, 27 Oct 2026 18:48:00 -0600 Subject: [PATCH] SSILVB: the AO lane stops only darkening and starts bouncing light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ambientOcclusion` 3 (Indirect Light), the Ultra rung. Tier 3 of the AO row rather than a row of its own, because it is one trace: the bitmask that measures occlusion properly is what produces the bounce, so a player choosing between them would be choosing between two halves of the same thing. Classic SSAO sums a cosine term per tap, so two occluders covering the same direction count twice and a cluttered corner over-darkens. A bitmask counts DIRECTIONS: each slice owns a 32-bit word of angular sectors, a tap sets the run it spans, overlap is a no-op. And the bits a tap sets that were NOT already set are exactly the share of the hemisphere it is the nearest thing in — i.e. the fraction of the receiver's light it alone is responsible for. Its own pixel colour, weighted by that, IS the one-bounce indirect term. No second trace, no probes, no extra pass. ## The lane was already shaped for it - `STAGE_AO` already bound the resolved scene colour, for the bright-pixel protection mask. That buffer is exactly what a screen-space gather reads. - The apply needed no new pass and no dual-source blending: visibility in `src.a`, bounce in `src.rgb`, blend `One, SrcAlpha` = `bounce + dst*vis`. - Only the half-res stages grew, by one attachment each, and the uniform's two spare floats carried the strength and thickness, so its layout never moved. ## The one part that is ours and not the paper's **The bounce is scaled BY THE OCCLUSION.** A physically-measured bounce is wrong here: 1.12's lighting already carries an ambient term standing in for indirect light, so adding a measured one double-counts it. Unscaled it lifted a daylight exterior's median from 85.7 to 101.3 and washed the frame flat. Scaled by `occ`, the lane puts back only the share of the ambient the occlusion just took away, in the occluder's colour. An open field gets nothing, because nothing was removed there; a corner gets the colour of the wall shading it. Redridge overlook noon median 85.7 -> 86.7 Goldshire inn noon median 26.7 -> 29.0 Neutral in daylight, +8.6% in the shaded room — the same shape as the auto-exposure lane's floor of 1.0, for the same reason. The gather runs in LINEAR (`c*c` in, `sqrt` out): the scene is stored in gamma, and 0.5 gamma is 0.22 of the light, not half of it. ## Cost Frame time is unchanged (inn 2.22 -> 2.21 ms over 600 frames; overlook 2.25 -> 2.11). That is the frame being CPU-bound, not the lane being free. The GPU meter is the honest instrument: whole-frame GPU 1.68 -> 1.81 ms, and the bucket these passes land in 0.40 -> 0.54. So **+0.14 ms of GPU over High and nothing on the main thread** — which is why the Options text says "a fraction of a millisecond" rather than what I first wrote before reading the meter. ## Two bugs, both found in the intermediate rather than the frame - **Screen +y is view -y.** `view_at` flips clip y, so a slice direction built as `vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice of two was right and the other inverted, averaging to A FLAT FLOOR READING 50% OCCLUDED. `WOW_AO_DEBUG=4` showed it in one run; the composited frame never would have. - **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's divisor into one reciprocal — a tidy-up, not a change — moved `water-lake` at tier **1** by up to 53/255 on 0.021% of its pixels, because the AO is multiplied into the colour the water lane's depth-march then refines by bisection, and one ulp picks a different step. Restored to two divides. ## Control Tiers 0-2 byte-identical to the build before this, on three deterministic scenarios at three tiers each, nine of nine. `fence-shadowline-night` had to be dropped mid-control, and it sharpens the determinism table added last commit: it screens as deterministic at the DEFAULT tier and is not at tier 1. Screen a scenario at the setting you are about to measure it at, not at its default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/assets/ui/OptionsFrame.xml | 20 + crates/benilla-app/src/cvars.rs | 20 +- crates/benilla-app/src/shaders/ssao.wgsl | 285 ++++++++++++- crates/benilla-app/src/ssao.rs | 377 +++++++++++++----- crates/benilla-app/src/video.rs | 8 +- docs/GRAPHICS.md | 92 ++++- 6 files changed, 679 insertions(+), 123 deletions(-) diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index b6f5186b..153ceb7a 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -542,6 +542,9 @@ qMedium = "Medium", qHigh = "High", qCustom = "Custom", + -- GFX (ssilvb): the Ambient Occlusion row's fourth tier. Named for what it adds + -- rather than "Ultra", because it is not more of the same thing. + aoIndirect = "Indirect Light", characterShadows = "Real Character Shadows", worldShadows = "World Shadows", shadowDistance = "Shadow Distance", @@ -903,6 +906,13 @@ "Soft contact shadows where surfaces meet: under props and furniture, in the corners " .. "between walls and floors, and around characters' feet. Low uses fewer samples; " .. "High is smoother and reaches a little further.\n\n" + .. "Indirect Light goes further than shadow: it also lets surfaces throw " + .. "their own colour onto what is near them, so a red rug warms the floor " + .. "around it and a sunlit wall lights the shade beside it. It measures " + .. "the darkening more carefully at the same time, so corners stop " + .. "doubling up on themselves. The most expensive setting on this page, " + .. "though on a modern card that is still a fraction of a millisecond " + .. "and nothing at all on the processor.\n\n" .. "Fades out with distance and spares the sky, water, lit windows and flames. " .. "Off leaves the image unchanged.", -- MONKEY (integration): daylight window split. @@ -977,6 +987,8 @@ torchShadowStrength = "Темнота теней от огня", everyFrame = "Каждый кадр", hz = "%d в секунду", + -- GFX (ssilvb) + aoIndirect = "Непрямой свет", -- GFX (auto-exposure) autoExposure = "Автоэкспозиция", autoExposureStrength = "Сила автоэкспозиции", @@ -1330,6 +1342,12 @@ "Мягкие контактные тени там, где поверхности соприкасаются: под предметами и мебелью, " .. "в углах между стенами и полом, у ног персонажей. «Низкое» использует меньше выборок; " .. "«Высокое» — более плавное и захватывает чуть больше.\n\n" + .. "«Непрямой свет» идёт дальше теней: поверхности начинают отбрасывать " + .. "собственный цвет на то, что рядом, — красный ковёр согревает пол " + .. "вокруг себя, освещённая солнцем стена подсвечивает тень возле неё. " + .. "Заодно затенение измеряется точнее, и углы перестают затемняться " + .. "дважды. Самая дорогая настройка на этой странице — но на современной " + .. "видеокарте это доли миллисекунды, а процессору она не стоит ничего.\n\n" .. "Ослабевает с расстоянием и не затрагивает небо, воду, освещённые окна и пламя. " .. "«Выкл» оставляет изображение без изменений.", -- MONKEY (integration): daylight window split. @@ -4305,6 +4323,8 @@ { text = BENILLA_ADVGFX.fogOff, value = "0" }, { text = BENILLA_ADVGFX.qLow, value = "1" }, { text = BENILLA_ADVGFX.qHigh, value = "2" }, + -- GFX (ssilvb) + { text = BENILLA_ADVGFX.aoIndirect, value = "3" }, }) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index c96eb746..09138dda 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -812,7 +812,15 @@ pub(crate) const REGISTERED: &[Registered] = &[ "benilla's own: rain wets sky-exposed surfaces and rings the water, 0 Off / 1 On", ), // MONKEY (ao): opt-in contact shadows; the High graphics preset value is 2. - ours("ambientOcclusion", "0", "benilla's own: screen-space ambient occlusion, 0 Off / 1 Low / 2 High"), + // GFX (ssilvb): tier 3 is the same lane carrying indirect COLOUR, not a second row — the + // bitmask that measures occlusion properly is what produces the bounce, so a player choosing + // between them would be choosing between two halves of one trace. + ours( + "ambientOcclusion", + "0", + "benilla's own: screen-space ambient occlusion, 0 Off / 1 Low / 2 High / 3 Indirect \ + Light (bounced colour, not only darkening)", + ), // MONKEY (skybox): the living player's zone skybox from `LightParams`; High sets 1. ours( "zoneSkyboxes", @@ -2581,7 +2589,10 @@ pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[ ("daylightWindowSplit", ["0", "1", "1", "1", "1"]), // MONKEY (integration): the three rows that landed in round 3. Low keeps them off (AO and // the lamp halos are GPU passes; the zone skybox is a look change), Medium takes the Low tiers. - ("ambientOcclusion", ["0", "0", "1", "2", "2"]), + // GFX (ssilvb): Ultra takes tier 3, the indirect-light bitmask. It is the one rung that was + // sitting on High's value with nothing above it, and a tier that costs real GPU time is what + // the top rung is for. + ("ambientOcclusion", ["0", "0", "1", "2", "3"]), // GFX (sharpening): the same shape as the row above it, and for the same reason. Classic is // the reference client, and Low's job is hardware that cannot afford what this machine can — // which is not the argument here, since the pass is one 5-tap cross on a GPU two thirds idle, @@ -3902,7 +3913,8 @@ mod tests { let mut cvars = fresh_registry(); cvars.load_file(file); assert!(cvars.reapply_saved_presets() > 0); - assert_eq!(cvars.get("ambientOcclusion"), Some("2")); + // GFX (ssilvb): Ultra's AO rung is tier 3, the indirect-light bitmask. + assert_eq!(cvars.get("ambientOcclusion"), Some("3")); assert_eq!(cvars.get("lampFog"), Some("2")); assert_eq!(cvars.get("farclip"), Some("1497")); assert_eq!(cvars.get("zoneSkyboxes"), Some("0"), "a saved row stays"); @@ -3946,7 +3958,7 @@ mod tests { )])); owned.reapply_saved_presets(); assert_eq!(owned.get("farclip"), Some("500")); - assert_eq!(owned.get("ambientOcclusion"), Some("2")); + assert_eq!(owned.get("ambientOcclusion"), Some("3")); } /// MONKEY (followups): a saved Lighting Quality rung fills the lighting members the file diff --git a/crates/benilla-app/src/shaders/ssao.wgsl b/crates/benilla-app/src/shaders/ssao.wgsl index afbbad39..12f76c89 100644 --- a/crates/benilla-app/src/shaders/ssao.wgsl +++ b/crates/benilla-app/src/shaders/ssao.wgsl @@ -7,6 +7,12 @@ // Stored texel: r = occlusion 0..1 (fade applied), g = view distance (SKY_DIST for sky), // b = bright-pixel protection 0..1, a (MONKEY (followups)) = surface smoothness 0..1 after // STAGE_AO (0 where both neighbours on an axis jump in depth), 1 - foliage mask after STAGE_BLUR. +// +// GFX (ssilvb): the `GI` def swaps the spiral for a VISIBILITY BITMASK and carries indirect +// COLOUR beside the occlusion, on a second attachment. Only `ao_slice_bitmask` differs; every +// other term here — the normal, the foliage spike, the distance fade, the bright-pixel +// protection, both filters — is shared, which is the whole reason the tier is a branch in this +// file rather than a lane of its own. See the block above `ao_slice_bitmask`. #import bevy_render::view::View struct Ao { @@ -14,14 +20,20 @@ struct Ao { params: vec4, // Distance fade start and end in yards, protection luma ramp start and end. fade: vec4, - // x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion, 5 smoothness); y: gain. + // x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion, 5 smoothness, + // GFX (ssilvb) 6 = the indirect colour alone); y: gain; z: indirect strength; w: the + // bitmask's assumed occluder thickness in yards (both z and w are GI-only). debug: vec4, } const SKY_DIST: f32 = 60000.0; +const PI: f32 = 3.14159265; #ifdef STAGE_BLUR @group(0) @binding(0) var raw: texture_2d; +#ifdef GI +@group(0) @binding(1) var raw_bounce: texture_2d; +#endif #else #ifdef MULTISAMPLED @group(0) @binding(0) var depth: texture_depth_multisampled_2d; @@ -33,6 +45,11 @@ const SKY_DIST: f32 = 60000.0; @group(0) @binding(2) var view: View; #endif @group(0) @binding(3) var ao: Ao; +#ifdef GI +#ifdef STAGE_APPLY +@group(0) @binding(4) var blurred_bounce: texture_2d; +#endif +#endif #ifndef STAGE_BLUR // Sample zero, as the water and fog passes read MSAA depth. @@ -68,9 +85,139 @@ fn neighbour(p: vec2, centre: vec3) -> vec3 { return view_at(p, z); } -@fragment -fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { - let h = vec2(frag.xy); +// What one half-res texel carries out of this stage: the packed quartet every later stage reads, +// and (GI only, zero otherwise) the indirect colour gathered at it. +struct AoResult { + packed: vec4, + bounce: vec3, +} + +#ifdef GI +// ── GFX (ssilvb): the visibility bitmask ────────────────────────────────────────────────────── +// +// Classic SSAO sums a cosine term per tap, so two occluders covering the SAME direction are +// counted twice and a cluttered corner over-darkens. A bitmask counts DIRECTIONS instead: each +// slice through the view ray owns a 32-bit word whose bits are angular sectors of the receiver's +// hemisphere, and a tap sets the run of sectors it spans. Overlap is a no-op, because the bit was +// already set. Occlusion is then just the population count. +// +// And that is what makes the colour half nearly free. The bits a tap sets that were NOT already +// set are exactly the share of the hemisphere it is the nearest thing in — i.e. the fraction of +// the receiver's incoming light that this tap, and nothing else, is responsible for. So its own +// pixel colour, weighted by that fraction, IS the one-bounce indirect term. No second trace, no +// probes, no extra passes; the gather rides the loop that was already computing occlusion. +// +// Therrien et al., "Screen Space Indirect Lighting with Visibility Bitmask" (2023). +const SECTORS: u32 = 32u; +// Two slices and eight steps per direction = 32 taps, the same order as the spiral it replaces. +const GI_SLICES: u32 = 2u; +const GI_STEPS: u32 = 8u; + +// OR the sector run [lo, hi] — both already normalised across the receiver's hemisphere — into +// the slice's word. A tap whose span rounds to nothing sets nothing, which is what keeps a flat +// floor from occluding itself: a coplanar neighbour lands ON the hemisphere boundary and its +// back face lands outside it, so `saturate` collapses both to the same end and the span is zero. +fn update_sectors(lo: f32, hi: f32, bits: u32) -> u32 { + let start = min(u32(lo * f32(SECTORS)), SECTORS - 1u); + let span = min(u32(ceil((hi - lo) * f32(SECTORS))), SECTORS); + if (span == 0u) { return bits; } + let run = 0xFFFFFFFFu >> (SECTORS - span); + return bits | (run << start); +} + +struct Gathered { + occlusion: f32, + bounce: vec3, +} + +fn ao_slice_bitmask( + f: vec2, + size: vec2, + p: vec3, + n: vec3, + r_px: f32, + rot: f32, + jitter: f32, +) -> Gathered { + // View space has the eye at the origin, so the view ray is just the way back to it. + let v_dir = normalize(-p); + let thickness = ao.debug.w; + var occ_sum = 0.0; + var bounce = vec3(0.0); + for (var s = 0u; s < GI_SLICES; s += 1u) { + // Slices span half a turn; walking both directions covers the other half. The Bayer + // rotation already used by the spiral decorrelates the slice angle between texels, and + // the 4x4 blur below is what averages the pattern back out. + let phi = (f32(s) + rot / 6.2831853) * (PI / f32(GI_SLICES)); + let omega = vec2(cos(phi), sin(phi)); + // The SAME direction in view space. Screen +y is view -y (`view_at` flips clip y), so a + // slice that forgets the flip measures its taps against a mirrored normal and reports + // roughly half occlusion everywhere — which is what a flat floor at 50% looked like. + let dir3 = vec3(omega.x, -omega.y, 0.0); + let axis = cross(dir3, v_dir); + let axis_len2 = dot(axis, axis); + if (axis_len2 < 1e-8) { continue; } + let axis_n = axis * inverseSqrt(axis_len2); + let ortho = normalize(dir3 - v_dir * dot(dir3, v_dir)); + // The receiver normal flattened into the slice plane, and its signed angle off the view + // ray. The hemisphere this slice measures is the half turn centred on that angle. + let proj_n = n - axis_n * dot(n, axis_n); + let proj_len = length(proj_n); + if (proj_len < 1e-5) { continue; } + let n_angle = sign(dot(ortho, proj_n)) + * acos(clamp(dot(proj_n, v_dir) / proj_len, -1.0, 1.0)); + + var bits = 0u; + for (var side = 0u; side < 2u; side += 1u) { + let sd = select(-1.0, 1.0, side == 0u); + for (var k = 0u; k < GI_STEPS; k += 1u) { + let t = (f32(k) + jitter) / f32(GI_STEPS); + // Quadratic spacing: taps crowd the centre, where contact shadows live and where + // a bounce is brightest, and thin out toward the radius. + let q = f + vec2(round(omega * (sd * mix(2.0, r_px, t * t)))); + let zq = depth_at(q); + if (is_sky(zq)) { continue; } + let delta = view_at(q, zq) - p; + let dd = dot(delta, delta); + if (dd < 1e-6) { continue; } + // The tap's front face, and the back face it would have if every occluder were + // `thickness` deep. Without the second angle a thin rail would occlude the whole + // wedge behind it rather than its own slab. + let back = delta - v_dir * thickness; + let horizons = acos(clamp( + vec2(dot(delta * inverseSqrt(dd), v_dir), dot(normalize(back), v_dir)), + vec2(-1.0), + vec2(1.0), + )); + // Signed into the slice (the tap is on side `sd` by construction), then measured + // off the normal's own half turn and normalised to 0..1. + let hn = saturate((sd * horizons - n_angle) / PI + 0.5); + let before = bits; + bits = update_sectors(min(hn.x, hn.y), max(hn.x, hn.y), bits); + let gained = f32(countOneBits(bits & ~before)) / f32(SECTORS); + if (gained > 0.0) { + // Gathered in LINEAR. The scene is stored in the reference's gamma + // convention (docs/GRAPHICS.md, "Post passes are gamma"), and adding gamma + // values overstates every mid tone — 0.5 gamma is 0.22 of the light, not + // half of it. `c*c` and the `sqrt` on the way out are the gamma-2.0 + // approximation: cheap, monotone, and exact at both ends. + let c = clamp( + textureLoad(source, clamp(q, vec2(0), size - vec2(1)), 0).rgb, + vec3(0.0), + vec3(1.0), + ); + bounce += c * c * gained; + } + } + } + occ_sum += f32(countOneBits(bits)) / f32(SECTORS); + } + let inv = 1.0 / f32(GI_SLICES); + return Gathered(saturate(occ_sum * inv), sqrt(bounce * inv)); +} +#endif + +fn ao_compute(h: vec2) -> AoResult { let f = h * 2; let size = vec2(textureDimensions(source)); // Protection from the brightest pixel of the 2x2 footprint: lit windows, flames, sunlit sand. @@ -82,14 +229,14 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { } let protect = smoothstep(ao.fade.z, ao.fade.w, bright); let z = depth_at(f); - if (is_sky(z)) { return vec4(0.0, SKY_DIST, protect, 1.0); } + if (is_sky(z)) { return AoResult(vec4(0.0, SKY_DIST, protect, 1.0), vec3(0.0)); } let p = view_at(f, z); let dist = -p.z; let fade = 1.0 - smoothstep(ao.fade.x, ao.fade.y, dist); let radius = ao.params.x; let proj = 0.5 * view.viewport.w * view.clip_from_view[1][1]; let r_px = min(radius * proj / max(dist, 0.01), 0.12 * view.viewport.w); - if (fade <= 0.0 || r_px < 2.0) { return vec4(0.0, dist, protect, 1.0); } + if (fade <= 0.0 || r_px < 2.0) { return AoResult(vec4(0.0, dist, protect, 1.0), vec3(0.0)); } // Normal from the flatter neighbour on each axis, so silhouettes do not bend it. let pr = neighbour(f + vec2(1, 0), p); @@ -102,7 +249,7 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { // or rope against the sky); normalize(0) would be NaN and bloom would spread it. No AO there. let c = cross(dx, dy); let l2 = dot(c, c); - if (l2 < 1e-12) { return vec4(0.0, dist, protect, 1.0); } + if (l2 < 1e-12) { return AoResult(vec4(0.0, dist, protect, 1.0), vec3(0.0)); } var n = c * inverseSqrt(l2); if (dot(n, p) > 0.0) { n = -n; } // MONKEY (followups): cutout leaves scatter depth spikes through a canopy, and there the taps @@ -120,11 +267,27 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { ); let smooth_surface = 1.0 - smoothstep(0.01, 0.04, spike / dist); - let count = u32(ao.params.z); let rot = (bayer4(h) + 0.5) * (6.2831853 / 16.0); let jitter = (bayer4(h + vec2(2, 1)) + 0.5) / 16.0; +#ifdef GI + let gathered = ao_slice_bitmask(f, size, p, n, r_px, rot, jitter); + let occ = gathered.occlusion * fade; + // The bounce is scaled BY THE OCCLUSION, which is the one thing that makes this lane sit + // honestly on top of 1.12's lighting. The reference's ambient term already stands in for + // indirect light everywhere, so a physically-measured bounce added on top double-counts it: + // unscaled, it lifted a daylight exterior's median from 85.7 to 101.3 and washed the whole + // frame. Scaled by `occ`, the lane puts back, in the occluder's own colour, only the share of + // the ambient the occlusion just took away — an open field gets nothing because nothing was + // removed there, and a corner gets the colour of the wall that is actually shading it. + // + // The bright-pixel mask holds it back too: that marks a texel which is its own light, a flame + // or a lit window, and piling a bounce onto one is both wrong and invisible, since the + // combine clamps at 1.0 a few passes later. + let bounce = gathered.bounce * ao.debug.z * occ * (1.0 - protect); +#else + let count = u32(ao.params.z); let r2 = radius * radius; - var occ = 0.0; + var sum = 0.0; for (var i = 0u; i < count; i += 1u) { let t = (f32(i) + jitter) / f32(count); let a = f32(i) * 2.3999632 + rot; @@ -136,20 +299,51 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let v = view_at(q, zq) - p; let vv = dot(v, v); let w = saturate(1.0 - vv / r2); - occ += w * max(0.0, dot(v, n) * inverseSqrt(vv + 1e-6) - ao.params.w); + sum += w * max(0.0, dot(v, n) * inverseSqrt(vv + 1e-6) - ao.params.w); } - occ = saturate(ao.debug.y * occ / f32(count)) * fade; - return vec4(occ, dist, protect, smooth_surface); + let occ = saturate(ao.debug.y * sum / f32(count)) * fade; + let bounce = vec3(0.0); +#endif + return AoResult(vec4(occ, dist, protect, smooth_surface), bounce); } + +#ifdef GI +struct AoTargets { + @location(0) packed: vec4, + @location(1) bounce: vec4, +} + +@fragment +fn ao_main(@builtin(position) frag: vec4) -> AoTargets { + let r = ao_compute(vec2(frag.xy)); + return AoTargets(r.packed, vec4(r.bounce, 1.0)); +} +#else +@fragment +fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { + return ao_compute(vec2(frag.xy)).packed; +} +#endif #endif #ifdef STAGE_BLUR -@fragment -fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { - let h = vec2(frag.xy); +// The 4x4 depth-aware box, shared by both tiers. `GI` adds the indirect colour to the same +// weights — it is the same signal at the same resolution with the same discontinuities, so +// filtering it twice with two kernels would only make the two disagree at an edge. +struct BlurResult { + packed: vec4, + bounce: vec3, +} + +fn blur_compute(h: vec2) -> BlurResult { let size = vec2(textureDimensions(raw)); let c = textureLoad(raw, h, 0); - if (c.g >= SKY_DIST) { return c; } +#ifdef GI + let c_bounce = textureLoad(raw_bounce, h, 0).rgb; +#else + let c_bounce = vec3(0.0); +#endif + if (c.g >= SKY_DIST) { return BlurResult(c, c_bounce); } // MONKEY (followups): foliage = spike density over the window. There the depth test would // reject every leaf neighbour and keep the grain, so the tolerance widens and the leftover // occlusion is scaled down; a window of smooth surfaces keeps the tight edge and full AO. @@ -162,17 +356,47 @@ fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let foliage = saturate(spikes / 16.0 * 4.0); let tolerance = (0.04 * c.g + 0.05) * (1.0 + 10.0 * foliage); var sum = 0.0; + var bounce = vec3(0.0); var weight = 0.0; for (var y = -2; y <= 1; y += 1) { for (var x = -2; x <= 1; x += 1) { - let s = textureLoad(raw, clamp(h + vec2(x, y), vec2(0), size - vec2(1)), 0); + let q = clamp(h + vec2(x, y), vec2(0), size - vec2(1)); + let s = textureLoad(raw, q, 0); let w = saturate(1.0 - abs(s.g - c.g) / tolerance); sum += s.r * w; +#ifdef GI + bounce += textureLoad(raw_bounce, q, 0).rgb * w; +#endif weight += w; } } - return vec4(sum / max(weight, 1e-4) * (1.0 - 0.4 * foliage), c.g, c.b, 1.0 - foliage); + // Two divides rather than one shared reciprocal: `a / b` and `a * (1.0 / b)` differ in the + // last ulp, and this value is multiplied into the scene colour that the water lane's + // depth-march then refines by bisection — where one ulp picks a different step and moves a + // reflected pixel by 53/255. Tiers 1 and 2 have to come out bit for bit as they did. + return BlurResult( + vec4(sum / max(weight, 1e-4) * (1.0 - 0.4 * foliage), c.g, c.b, 1.0 - foliage), + bounce / max(weight, 1e-4), + ); } + +#ifdef GI +struct BlurTargets { + @location(0) packed: vec4, + @location(1) bounce: vec4, +} + +@fragment +fn blur_main(@builtin(position) frag: vec4) -> BlurTargets { + let r = blur_compute(vec2(frag.xy)); + return BlurTargets(r.packed, vec4(r.bounce, 1.0)); +} +#else +@fragment +fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { + return blur_compute(vec2(frag.xy)).packed; +} +#endif #endif #ifdef STAGE_APPLY @@ -182,8 +406,14 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let z = depth_at(p); let mode = u32(ao.debug.x); if (is_sky(z)) { - // Sky is magenta in the diagnostic views, so "depth reads as sky" is visible. + // Sky is magenta in the diagnostic views, so "depth reads as sky" is visible. Outside + // them the pass must be a no-op there, and the no-op depends on the blend: a multiply + // leaves white, an add-and-scale leaves "add nothing, keep all of it". +#ifdef GI + return select(vec4(0.0, 0.0, 0.0, 1.0), vec4(1.0, 0.0, 1.0, 1.0), mode >= 2u); +#else return select(vec4(1.0), vec4(1.0, 0.0, 1.0, 1.0), mode >= 2u); +#endif } let dist = -view_at(p, z).z; let size = vec2(textureDimensions(source)); @@ -193,15 +423,20 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { var occ = 0.0; var protect = 0.0; var smooth_surface = 0.0; + var bounce = vec3(0.0); var weight = 0.0; for (var j = 0; j < 4; j += 1) { let o = vec2(j & 1, j >> 1u); - let s = textureLoad(source, clamp(base + o, vec2(0), size - vec2(1)), 0); + let q = clamp(base + o, vec2(0), size - vec2(1)); + let s = textureLoad(source, q, 0); let b = mix(1.0 - t, t, vec2(o)); let w = b.x * b.y / (0.02 + abs(s.g - dist) / dist) + 1e-5; occ += s.r * w; protect += s.b * w; smooth_surface += s.a * w; +#ifdef GI + bounce += textureLoad(blurred_bounce, q, 0).rgb * w; +#endif weight += w; } occ /= weight; @@ -213,6 +448,16 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { // MONKEY (followups): 5 = foliage mask (black = treated as foliage). if (mode == 5u) { let m = smooth_surface / weight; return vec4(m, m, m, 1.0); } let k = 1.0 - ao.params.y * occ * (1.0 - protect); +#ifdef GI + // GFX (ssilvb): 6 = the indirect colour alone, on black. + if (mode == 6u) { return vec4(bounce / weight, 1.0); } + // The lane's own blend is `src.rgb + dst * src.a`: the bounce is ADDED and the visibility + // multiplies what is already there, in one pass with no copy of the scene and no dual-source + // blending. That is the only reason this upgrade needed no new pass. + return vec4(bounce / weight, k); +#else + if (mode == 6u) { return vec4(0.0, 0.0, 0.0, 1.0); } return vec4(k, k, k, 1.0); +#endif } #endif diff --git a/crates/benilla-app/src/ssao.rs b/crates/benilla-app/src/ssao.rs index 5a805b29..0195ece3 100644 --- a/crates/benilla-app/src/ssao.rs +++ b/crates/benilla-app/src/ssao.rs @@ -9,9 +9,16 @@ //! untouched and refraction sees the darkened bed. Sky, distant pixels and //! bright pixels (lit windows, flames, sunlit sand) are protected; fade 45-90 yd (Low) / 60-120 yd (High). //! -//! cvar `ambientOcclusion`: 0 Off (pass not scheduled, image unchanged), 1 Low, 2 High. -//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1..5` writes a diagnostic view -//! (factor, protection, distance, raw occlusion); `WOW_AO_GAIN/RADIUS/BIAS/STRENGTH` tune it. +//! cvar `ambientOcclusion`: 0 Off (pass not scheduled, image unchanged), 1 Low, 2 High, and +//! GFX (ssilvb) **3 Indirect Light** — the same three passes with the spiral swapped for a +//! visibility bitmask that carries indirect COLOUR on a second attachment, so the lane stops +//! only darkening and starts bouncing light. Tiers 1 and 2 are untouched, down to the pipeline +//! key, which is what keeps them byte-identical. +//! +//! `WOW_AO=0|1|2|3` overrides it for the session; `WOW_AO_DEBUG=1..6` writes a diagnostic view +//! (factor, protection, distance, raw occlusion, foliage, indirect colour); +//! `WOW_AO_GAIN/RADIUS/BIAS/STRENGTH` tune it, and `WOW_AO_GI`/`WOW_AO_THICKNESS` the tier-3 +//! pair. use crate::video::VideoConfig; use benilla_world::{liquid::WaterDepthLabel, view::WorldCamera}; use bevy::{ @@ -51,7 +58,9 @@ struct AoView { /// Distance fade start and end (yd), bright-pixel protection ramp (gamma max channel). fade: Vec4, /// x = debug view (0 off, 1 AO factor, 2 protection, 3 distance/50, 4 raw occlusion, - /// 5 surface smoothness, MONKEY (followups)), y = occlusion gain. + /// 5 surface smoothness, MONKEY (followups), 6 indirect colour, GFX (ssilvb)), + /// y = occlusion gain, z = indirect strength, w = assumed occluder thickness in yards. + /// The last two were the uniform's two spare floats, so tier 3 cost no layout change. debug: Vec4, } @@ -75,10 +84,14 @@ fn tuning(name: &'static str) -> Option { impl AoView { fn tier(tier: u8) -> Self { - let (radius, strength, samples) = if tier >= 2 { - (1.5, 0.7, 12.0) - } else { - (1.2, 0.65, 6.0) + // GFX (ssilvb): tier 3 reaches further than High because a bounce travels further than a + // contact shadow — a wall lights the floor several yards out, where the crease it casts + // is over in one. The bitmask's own sample count is a pair of shader consts, so + // `samples` is read only by the spiral. + let (radius, strength, samples) = match tier { + 3 => (3.0, 0.7, 12.0), + 2 => (1.5, 0.7, 12.0), + _ => (1.2, 0.65, 6.0), }; // Outdoor contacts (house bases, trunks) sit 30-60 yd out; Low stops sooner. let fade = if tier >= 2 { @@ -90,14 +103,28 @@ impl AoView { let strength = tuning("STRENGTH").unwrap_or(strength).min(1.0); let bias = tuning("BIAS").unwrap_or(0.05); let gain = tuning("GAIN").unwrap_or(6.0); + // The indirect term's own gain, and the depth the bitmask assumes an occluder has. A + // thickness of zero would make every surface an infinitely deep wall and shadow the whole + // wedge behind it; a yard is about a doorframe, and it is what the lane was tuned at. + let gi = tuning("GI").unwrap_or(1.0); + let thickness = tuning("THICKNESS").unwrap_or(1.0); Self { params: Vec4::new(radius, strength, samples, bias), fade: Vec4::new(fade.0, fade.1, 0.7, 0.95), - debug: Vec4::new(debug_mode() as f32, gain, 0.0, 0.0), + debug: Vec4::new(debug_mode() as f32, gain, gi, thickness), } } } +/// GFX (ssilvb): tier 3, as a marker rather than a field on [`AoView`] — that type is the +/// uniform's own layout, and the render world needs this where it picks pipelines and textures, +/// not where it reads floats. +#[derive(Component, Clone, Copy, ExtractComponent)] +struct AoGi; + +/// The tier at which the bitmask and its indirect colour replace the spiral. +pub(crate) const GI_TIER: u8 = 3; + /// `WOW_AO_DEBUG=1..5`: write a diagnostic term instead of darkening the scene. fn debug_mode() -> u8 { static MODE: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -106,7 +133,7 @@ fn debug_mode() -> u8 { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0) - .min(5) + .min(6) }) } @@ -119,9 +146,10 @@ impl Plugin for AmbientOcclusionPlugin { let value = std::env::var("WOW_AO") .ok() .and_then(|v| v.parse::().ok()) - .filter(|v| *v <= 2); + .filter(|v| *v <= GI_TIER); app.insert_resource(AoOverride(value)) .add_plugins(ExtractComponentPlugin::::default()) + .add_plugins(ExtractComponentPlugin::::default()) .add_systems(Last, update_ao); // Keep headless policy tests independent of the renderer. if !app.is_plugin_added::() { @@ -154,18 +182,39 @@ impl Plugin for AmbientOcclusionPlugin { } } +/// The world cameras this lane seats itself on. A `type` alias because the query names five +/// things and clippy counts that as one too many. +type AoCameras<'w, 's> = Query< + 'w, + 's, + ( + Entity, + &'static Camera, + &'static mut Camera3d, + Option<&'static AoView>, + Has, + ), + With, +>; + fn update_ao( mut commands: Commands, video: Res, override_value: Res, - mut cameras: Query<(Entity, &Camera, &mut Camera3d, Option<&AoView>), With>, + mut cameras: AoCameras, ) { - let tier = override_value.0.unwrap_or(video.ambient_occlusion).min(2); - for (entity, camera, mut camera3d, current) in &mut cameras { + let tier = override_value + .0 + .unwrap_or(video.ambient_occlusion) + .min(GI_TIER); + for (entity, camera, mut camera3d, current, had_gi) in &mut cameras { if tier == 0 || !camera.is_active { if current.is_some() { commands.entity(entity).remove::(); } + if had_gi { + commands.entity(entity).remove::(); + } continue; } let binding = TextureUsages::TEXTURE_BINDING.bits(); @@ -176,6 +225,15 @@ fn update_ao( if current != Some(&want) { commands.entity(entity).insert(want); } + match (tier >= GI_TIER, had_gi) { + (true, false) => { + commands.entity(entity).insert(AoGi); + } + (false, true) => { + commands.entity(entity).remove::(); + } + _ => {} + } } } @@ -184,9 +242,14 @@ struct AoShader(Handle); #[derive(Resource)] struct AoPipeline { + /// Indexed by `multisampled`. GFX (ssilvb): the AO stage's INPUTS are the same at every + /// tier — depth, the scene colour, the two uniforms — so only its output count changes, and + /// one layout still serves both. ao_layouts: [BindGroupLayoutDescriptor; 2], - blur_layout: BindGroupLayoutDescriptor, - apply_layouts: [BindGroupLayoutDescriptor; 2], + /// Indexed by `gi`, which adds the half-res indirect colour at binding 1. + blur_layouts: [BindGroupLayoutDescriptor; 2], + /// Indexed by `[gi][multisampled]`; `gi` adds the blurred indirect colour at binding 4. + apply_layouts: [[BindGroupLayoutDescriptor; 2]; 2], shader: Handle, fullscreen: FullscreenShader, } @@ -201,18 +264,22 @@ enum Stage { const AO_FORMAT: TextureFormat = TextureFormat::Rgba16Float; fn init_pipeline(mut commands: Commands, shader: Res, fullscreen: Res) { + let plain = || texture_2d(TextureSampleType::Float { filterable: false }); + let depth_entry = |multisampled: bool| { + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + } + }; let with_depth = |label: &'static str, multisampled: bool| { BindGroupLayoutDescriptor::new( label, &BindGroupLayoutEntries::sequential( ShaderStages::FRAGMENT, ( - if multisampled { - texture_depth_2d_multisampled() - } else { - texture_depth_2d() - }, - texture_2d(TextureSampleType::Float { filterable: false }), + depth_entry(multisampled), + plain(), uniform_buffer::(true), uniform_buffer::(false), ), @@ -221,30 +288,58 @@ fn init_pipeline(mut commands: Commands, shader: Res, fullscreen: Res< }; let blur_layout = BindGroupLayoutDescriptor::new( "ssao_blur_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::FRAGMENT, + ((0, plain()), (3, uniform_buffer::(false))), + ), + ); + // GFX (ssilvb): the two GI layouts. The indirect colour rides binding 1 in the blur (where + // the uniform already sat at 3, leaving the slot free) and binding 4 in the apply. + let blur_gi_layout = BindGroupLayoutDescriptor::new( + "ssao_blur_gi_layout", &BindGroupLayoutEntries::with_indices( ShaderStages::FRAGMENT, ( - ( - 0, - texture_2d(TextureSampleType::Float { filterable: false }), - ), + (0, plain()), + (1, plain()), (3, uniform_buffer::(false)), ), ), ); + let apply_gi = |multisampled: bool| { + BindGroupLayoutDescriptor::new( + "ssao_apply_gi_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + depth_entry(multisampled), + plain(), + uniform_buffer::(true), + uniform_buffer::(false), + plain(), + ), + ), + ) + }; commands.insert_resource(AoPipeline { ao_layouts: [false, true].map(|ms| with_depth("ssao_layout", ms)), - blur_layout, - apply_layouts: [false, true].map(|ms| with_depth("ssao_apply_layout", ms)), + blur_layouts: [blur_layout, blur_gi_layout], + apply_layouts: [ + [false, true].map(|ms| with_depth("ssao_apply_layout", ms)), + [false, true].map(apply_gi), + ], shader: shader.0.clone(), fullscreen: fullscreen.clone(), }); } impl SpecializedRenderPipeline for AoPipeline { - /// Stage, target format, main-pass sample count, debug view. - type Key = (Stage, TextureFormat, u32, bool); - fn specialize(&self, (stage, format, samples, debug): Self::Key) -> RenderPipelineDescriptor { + /// Stage, target format, main-pass sample count, debug view, GFX (ssilvb) indirect light. + type Key = (Stage, TextureFormat, u32, bool, bool); + fn specialize( + &self, + (stage, format, samples, debug, gi): Self::Key, + ) -> RenderPipelineDescriptor { let multisampled = samples > 1; let mut defs: Vec = vec![match stage { Stage::Ao => "STAGE_AO", @@ -255,24 +350,42 @@ impl SpecializedRenderPipeline for AoPipeline { if multisampled && stage != Stage::Blur { defs.push("MULTISAMPLED".into()); } - let (label, entry, layout) = match stage { - Stage::Ao => ( - "ssao", + if gi { + defs.push("GI".into()); + } + let (label, entry, layout) = match (stage, gi) { + (Stage::Ao, _) => ( + if gi { "ssao_gi" } else { "ssao" }, "ao_main", self.ao_layouts[multisampled as usize].clone(), ), - Stage::Blur => ("ssao_blur", "blur_main", self.blur_layout.clone()), - Stage::Apply => ( - "ssao_apply", + (Stage::Blur, _) => ( + if gi { "ssao_gi_blur" } else { "ssao_blur" }, + "blur_main", + self.blur_layouts[gi as usize].clone(), + ), + (Stage::Apply, _) => ( + if gi { "ssao_gi_apply" } else { "ssao_apply" }, "apply_main", - self.apply_layouts[multisampled as usize].clone(), + self.apply_layouts[gi as usize][multisampled as usize].clone(), ), }; - // The apply stage multiplies into the scene (dst * src) and leaves alpha alone. + // The apply stage multiplies into the scene (dst * src) and leaves alpha alone — or, with + // indirect light, ADDS the bounce and multiplies by the visibility the same source alpha + // carries: `src.rgb + dst * src.a`. That one blend state is why the colour half needed no + // extra pass, no copy of the scene and no dual-source blending. let blend = (stage == Stage::Apply && !debug).then_some(BlendState { color: BlendComponent { - src_factor: BlendFactor::Dst, - dst_factor: BlendFactor::Zero, + src_factor: if gi { + BlendFactor::One + } else { + BlendFactor::Dst + }, + dst_factor: if gi { + BlendFactor::SrcAlpha + } else { + BlendFactor::Zero + }, operation: BlendOperation::Add, }, alpha: BlendComponent { @@ -281,6 +394,21 @@ impl SpecializedRenderPipeline for AoPipeline { operation: BlendOperation::Add, }, }); + // GFX (ssilvb): the half-res stages gain a second attachment for the indirect colour. + let half_target = Some(ColorTargetState { + format: AO_FORMAT, + blend: None, + write_mask: ColorWrites::ALL, + }); + let targets = match (stage, gi) { + (Stage::Apply, _) => vec![Some(ColorTargetState { + format, + blend, + write_mask: ColorWrites::ALL, + })], + (_, true) => vec![half_target.clone(), half_target], + (_, false) => vec![half_target], + }; RenderPipelineDescriptor { label: Some(label.into()), layout: vec![layout], @@ -289,15 +417,7 @@ impl SpecializedRenderPipeline for AoPipeline { shader: self.shader.clone(), shader_defs: defs, entry_point: Some(entry.into()), - targets: vec![Some(ColorTargetState { - format: if stage == Stage::Apply { - format - } else { - AO_FORMAT - }, - blend, - write_mask: ColorWrites::ALL, - })], + targets, }), multisample: MultisampleState { count: if stage == Stage::Apply { samples } else { 1 }, @@ -316,27 +436,37 @@ fn prepare_pipelines( cache: Res, pipeline: Res, mut specialized: ResMut>, - views: Query<(Entity, &ViewTarget, &Msaa), With>, + views: Query<(Entity, &ViewTarget, &Msaa, Has), With>, all_views: Query<(&ViewTarget, &Msaa), With>, ) { let debug = debug_view(); // MONKEY (integration): warm every reachable key on every 3-D view, feature on or off, so the // compile happens under the entry cover and never live when the player turns the row on. - // (Named in `pipe_warm/menagerie.rs`'s custom-lane census.) + // (Named in `pipe_warm/menagerie.rs`'s custom-lane census.) GFX (ssilvb): the `gi` half of + // the key doubles the set, and it has to — switching Ambient Occlusion to Indirect Light in + // the Options window is exactly the live compile this loop exists to prevent. for (target, msaa) in &all_views { for stage in [Stage::Ao, Stage::Blur, Stage::Apply] { - specialized.specialize( - &cache, - &pipeline, - (stage, target.main_texture_format(), msaa.samples(), debug), - ); + for gi in [false, true] { + specialized.specialize( + &cache, + &pipeline, + ( + stage, + target.main_texture_format(), + msaa.samples(), + debug, + gi, + ), + ); + } } } - for (entity, target, msaa) in &views { + for (entity, target, msaa, gi) in &views { let format = target.main_texture_format(); let samples = msaa.samples(); let ids = [Stage::Ao, Stage::Blur, Stage::Apply].map(|stage| { - specialized.specialize(&cache, &pipeline, (stage, format, samples, debug)) + specialized.specialize(&cache, &pipeline, (stage, format, samples, debug, gi)) }); commands.entity(entity).insert(ViewAoPipelines(ids)); } @@ -346,15 +476,18 @@ fn prepare_pipelines( struct AoTextures { raw: CachedTexture, blurred: CachedTexture, + /// GFX (ssilvb): the indirect colour's own pair, allocated only at tier 3 — half of a + /// half-res `Rgba16Float` frame each, which at 1600x900 is 2.9 MB a side. + bounce: Option<[CachedTexture; 2]>, } fn prepare_textures( mut commands: Commands, mut cache: ResMut, device: Res, - views: Query<(Entity, &ExtractedCamera), With>, + views: Query<(Entity, &ExtractedCamera, Has), With>, ) { - for (entity, camera) in &views { + for (entity, camera, gi) in &views { // The depth texture is sized to the physical target. let Some(size) = camera.physical_target_size else { continue; @@ -377,7 +510,12 @@ fn prepare_textures( }; let raw = texture("ssao_raw"); let blurred = texture("ssao_blurred"); - commands.entity(entity).insert(AoTextures { raw, blurred }); + let bounce = gi.then(|| [texture("ssao_gi_raw"), texture("ssao_gi_blurred")]); + commands.entity(entity).insert(AoTextures { + raw, + blurred, + bounce, + }); } } @@ -428,6 +566,10 @@ impl ViewNode for AoNode { (target, depth, view_offset, settings, ids, textures): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World, ) -> Result<(), NodeRunError> { + // GFX (ssilvb): the tier is read off the textures rather than the marker, so the pass and + // the allocation can never disagree — a frame where one says GI and the other does not + // would bind a layout against the wrong attachment count. + let gi = textures.bounce.is_some(); let cache = world.resource::(); let (Some(ao), Some(blur), Some(apply)) = ( cache.get_render_pipeline(ids.0[0]), @@ -451,7 +593,9 @@ impl ViewNode for AoNode { let device = context.render_device().clone(); let params = settings.0.binding().unwrap(); - // `main_texture_view` is the single-sample texture the opaque pass resolved into. + // `main_texture_view` is the single-sample texture the opaque pass resolved into. At tier + // 3 it is read twice over: once for the bright-pixel mask, and once per newly-occluded + // tap as the radiance that tap bounces. let ao_bind = device.create_bind_group( "ssao", &cache.get_bind_group_layout(&layouts.ao_layouts[ms]), @@ -462,39 +606,73 @@ impl ViewNode for AoNode { params.clone(), )), ); - let blur_bind = device.create_bind_group( - "ssao_blur", - &cache.get_bind_group_layout(&layouts.blur_layout), - &BindGroupEntries::with_indices(((0, &textures.raw.default_view), (3, params.clone()))), - ); - let apply_bind = device.create_bind_group( - "ssao_apply", - &cache.get_bind_group_layout(&layouts.apply_layouts[ms]), - &BindGroupEntries::sequential(( - depth.view(), - &textures.blurred.default_view, - view, - params, - )), - ); + let blur_bind = match &textures.bounce { + Some([raw_bounce, _]) => device.create_bind_group( + "ssao_gi_blur", + &cache.get_bind_group_layout(&layouts.blur_layouts[1]), + &BindGroupEntries::with_indices(( + (0, &textures.raw.default_view), + (1, &raw_bounce.default_view), + (3, params.clone()), + )), + ), + None => device.create_bind_group( + "ssao_blur", + &cache.get_bind_group_layout(&layouts.blur_layouts[0]), + &BindGroupEntries::with_indices(( + (0, &textures.raw.default_view), + (3, params.clone()), + )), + ), + }; + let apply_bind = match &textures.bounce { + Some([_, blurred_bounce]) => device.create_bind_group( + "ssao_gi_apply", + &cache.get_bind_group_layout(&layouts.apply_layouts[1][ms]), + &BindGroupEntries::sequential(( + depth.view(), + &textures.blurred.default_view, + view, + params, + &blurred_bounce.default_view, + )), + ), + None => device.create_bind_group( + "ssao_apply", + &cache.get_bind_group_layout(&layouts.apply_layouts[0][ms]), + &BindGroupEntries::sequential(( + depth.view(), + &textures.blurred.default_view, + view, + params, + )), + ), + }; let diagnostics = context.diagnostic_recorder(); let half_pass = |encoder: &mut CommandEncoder, label: &'static str, - out: &TextureView, + outs: &[&TextureView], pipeline: &RenderPipeline, bind: &BindGroup, offsets: &[u32]| { + // One attachment at tiers 1-2, two at tier 3 (the packed quartet and the bounce). + let attachments: Vec<_> = outs + .iter() + .map(|out| { + Some(RenderPassColorAttachment { + view: out, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Clear(Default::default()), + store: StoreOp::Store, + }, + }) + }) + .collect(); let mut pass = encoder.begin_render_pass(&RenderPassDescriptor { label: Some(label), - color_attachments: &[Some(RenderPassColorAttachment { - view: out, - depth_slice: None, - resolve_target: None, - ops: Operations { - load: LoadOp::Clear(Default::default()), - store: StoreOp::Store, - }, - })], + color_attachments: &attachments, depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, @@ -506,31 +684,42 @@ impl ViewNode for AoNode { span.end(&mut pass); }; let encoder = context.command_encoder(); + let (ao_outs, blur_outs): (Vec<&TextureView>, Vec<&TextureView>) = match &textures.bounce { + Some([raw_bounce, blurred_bounce]) => ( + vec![&textures.raw.default_view, &raw_bounce.default_view], + vec![&textures.blurred.default_view, &blurred_bounce.default_view], + ), + None => ( + vec![&textures.raw.default_view], + vec![&textures.blurred.default_view], + ), + }; half_pass( encoder, - "ssao", - &textures.raw.default_view, + if gi { "ssao_gi" } else { "ssao" }, + &ao_outs, ao, &ao_bind, &[view_offset.offset], ); half_pass( encoder, - "ssao_blur", - &textures.blurred.default_view, + if gi { "ssao_gi_blur" } else { "ssao_blur" }, + &blur_outs, blur, &blur_bind, &[], ); // The main attachment (MSAA + resolve when multisampled), so later passes load it darkened. + let apply_label = if gi { "ssao_gi_apply" } else { "ssao_apply" }; let mut pass = encoder.begin_render_pass(&RenderPassDescriptor { - label: Some("ssao_apply"), + label: Some(apply_label), color_attachments: &[Some(target.get_color_attachment())], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); - let span = diagnostics.pass_span(&mut pass, "ssao_apply"); + let span = diagnostics.pass_span(&mut pass, apply_label); pass.set_pipeline(apply); pass.set_bind_group(0, &apply_bind, &[view_offset.offset]); pass.draw(0..3, 0..1); diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 64275bd7..0c07d98b 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -136,6 +136,8 @@ pub(crate) struct VideoConfig { // MONKEY (sky): the sky tier: 0 Classic, 1 Enhanced, 2 High (`sky_quality::bridge`). pub(crate) sky_quality: u8, // MONKEY (ao): screen-space contact shadows: 0 Off, 1 Low, 2 High. + // GFX (ssilvb): 3 Indirect Light — the same lane with a visibility bitmask that also + // carries bounced colour, so it stops only darkening (`ssao::GI_TIER`). pub(crate) ambient_occlusion: u8, // MONKEY (lampfog): point-light fog tier: 0 Off, 1 nearest 16, 2 nearest 32. pub(crate) lamp_fog: u8, @@ -533,8 +535,10 @@ pub(crate) fn on_cvar( "colorgrading" => cfg.color_grading = v != 0.0, // MONKEY (sky): the sky tier, clamped to Classic..High. "skyquality" => cfg.sky_quality = v.clamp(0.0, 2.0) as u8, - // MONKEY (ao): constrain UI/console writes to supported tiers. - "ambientocclusion" => cfg.ambient_occlusion = v.clamp(0.0, 2.0) as u8, + // MONKEY (ao) / GFX (ssilvb): constrain UI/console writes to supported tiers. + "ambientocclusion" => { + cfg.ambient_occlusion = v.clamp(0.0, f32::from(crate::ssao::GI_TIER)) as u8 + } // MONKEY (lampfog): 0 Off / 1 nearest 16 / 2 nearest 32. "lampfog" => cfg.lamp_fog = v.clamp(0.0, 2.0) as u8, // GFX (volumetric light): 0 Off / 1 Medium / 2 High, and its 0..2 strength. diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md index 2bf6e3aa..2217a595 100644 --- a/docs/GRAPHICS.md +++ b/docs/GRAPHICS.md @@ -60,6 +60,11 @@ the same binary and once more over six runs: | `stormwind-canal-near` | **no** — bimodal: 7 of 15 pairs identical, the rest differ on **0.01%** of pixels by up to **96**/255 | | `inn-noon`, `volfog-inn` | **no** — usually the bottom bit, but one pair came back **37.8%** of pixels at up to **199** | +**And the table is per SETTING, not per scenario.** `fence-shadowline-night` sits in the first row +only at the default `ambientOcclusion` 0; at tier 1 it differs from itself by the bottom bit, which +cost half a control before it was noticed. Screen the scenario at the setting you are about to +measure it at. + So a byte-identity claim can only rest on the first row. The second row is the dangerous one: the canal's own flicker reaches a channel delta of **96**, which is exactly the amplitude a real finding there was once read at — amplitude alone says nothing, only extent does, and the canal's @@ -289,10 +294,91 @@ fog into a participating medium in a 3D froxel volume with reprojected history, correctly, moves with the camera without swimming, and costs a fixed compute dispatch instead of scaling with overdraw. -### 5. Screen-space indirect light (SSILVB) +### 5. Screen-space indirect light (SSILVB) — **built** -An upgrade from classic SSAO to a visibility-bitmask trace that carries indirect *colour*, not only -occlusion. GPU-side, and it is most of what makes a modern frame look lit rather than painted. +`crates/benilla-app/src/ssao.rs`, cvar `ambientOcclusion` **3 (Indirect Light)**, the Ultra rung, +Ambient Occlusion on the Advanced Graphics page. Tiers 1 and 2 are untouched, down to the pipeline +key. + +It is tier 3 of the AO row rather than a row of its own because it is one trace. Classic SSAO sums +a cosine term per tap, so two occluders covering the same direction are counted twice and a +cluttered corner over-darkens. A bitmask counts DIRECTIONS instead: each slice through the view ray +owns a 32-bit word whose bits are angular sectors of the receiver's hemisphere, a tap sets the run +it spans, and overlap is a no-op because the bit was already set. Occlusion is the population +count. + +**And that is what makes the colour half nearly free.** The bits a tap sets that were *not* already +set are exactly the share of the hemisphere it is the nearest thing in — the fraction of the +receiver's incoming light that this tap, and nothing else, is responsible for. So the tap's own +pixel colour, weighted by that fraction, is the one-bounce indirect term. No second trace, no +probes, no extra pass. (Therrien et al., 2023.) + +**The lane was already shaped for it**, which is why this landed in one sitting: + +- `STAGE_AO` already bound the resolved opaque scene colour, for the bright-pixel protection mask. + That buffer is exactly what a screen-space gather reads; the plumbing existed for an unrelated + reason. +- The apply needed no new pass and no dual-source blending. Put visibility in `src.a` and the + bounce in `src.rgb` and blend `One, SrcAlpha`: `bounce + dst · vis`, one draw into the same + attachment the multiply used. +- Only the half-res stages grew, by one `Rgba16Float` attachment each (2.9 MB a side at 1600x900), + and the two spare floats on the existing uniform carried the strength and the thickness, so the + uniform layout never moved. + +**The one thing that is ours and not the paper's: the bounce is scaled BY THE OCCLUSION.** A +physically-measured bounce is wrong on this renderer, because 1.12's lighting already carries an +ambient term that stands in for indirect light everywhere — adding a measured one on top +double-counts it. Unscaled it lifted a daylight exterior's median from **85.7 to 101.3** and washed +the whole frame flat. Scaled by `occ`, the lane puts back only the share of the ambient the +occlusion just took away, in the occluder's own colour: an open field gets nothing, because nothing +was removed there, and a corner gets the colour of the wall that is actually shading it. Measured +after: + +| scene | median, High | median, Indirect Light | +|---|---|---| +| Redridge overlook, noon | 85.7 | **86.7** | +| Goldshire inn, noon | 26.7 | **29.0** | + +Neutral in daylight, +8.6% in the shaded room — the same shape as the auto-exposure lane's floor of +1.0, and arrived at for the same reason. + +**The gather runs in LINEAR.** The scene is stored in the reference's gamma convention, and adding +gamma values overstates every mid tone: 0.5 gamma is 0.22 of the light, not half of it. `c*c` in +and `sqrt` out is the gamma-2.0 approximation — cheap, monotone, exact at both ends. + +**Cost.** Frame time is unchanged: Goldshire inn 2.22 → 2.21 ms over 600 frames, Redridge overlook +2.25 → 2.11. That is not the lane being free, it is the frame being CPU-bound; the GPU meter +(`/console fpsJournal 1`, the `gpu_other` column, which is where these passes land) is the honest +instrument: + +| | whole frame GPU | the AO passes | +|---|---|---| +| Off | 1.60 ms | 0.29 ms (everything else in the bucket) | +| High | 1.68 ms | 0.40 ms → **+0.11** | +| Indirect Light | 1.81 ms | 0.54 ms → **+0.25** | + +So the bitmask plus the bounce is **+0.14 ms of GPU over High** and nothing on the main thread — +which by this file's own rule is nearly free, and is why the Options text says "a fraction of a +millisecond" rather than what it said before the meter was read. + +**Two bugs, both found by looking at the intermediate rather than the frame** (`WOW_AO_DEBUG=4` is +the raw occlusion, and `6` is the new indirect-colour view): + +- **Screen +y is view −y.** `view_at` flips clip y, so a slice direction built as + `vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice of two was right and + the other inverted, which averaged to **a flat floor reading 50% occluded** — a uniform grey over + the whole foreground that no amount of staring at the composited frame would have explained. +- **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's shared divisor into + one reciprocal — a tidy-up, not a change — moved `water-lake` at tier **1** by up to **53/255** + on 0.021% of its pixels. The AO is multiplied into the scene colour that the water lane's + depth-march then refines by bisection, and one ulp picks a different step. Restored to two + divides. + +**Tiers 0-2 byte-identical** to the build before this, on all three deterministic scenarios at all +three tiers (nine of nine). `fence-shadowline-night` had to be dropped mid-control and is the +reason this file's determinism table now needs a caveat: **it screens as deterministic at the +default tier and is not at tier 1**, where it differs from *itself* by the bottom bit. Determinism +is a property of a scenario AND the settings it is rendered with, not of the scenario alone. ### 6. Runtime probe GI