SSILVB: the AO lane stops only darkening and starts bouncing light
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<f32>,
|
||||
// Distance fade start and end in yards, protection luma ramp start and end.
|
||||
fade: vec4<f32>,
|
||||
// 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<f32>,
|
||||
}
|
||||
|
||||
const SKY_DIST: f32 = 60000.0;
|
||||
const PI: f32 = 3.14159265;
|
||||
|
||||
#ifdef STAGE_BLUR
|
||||
@group(0) @binding(0) var raw: texture_2d<f32>;
|
||||
#ifdef GI
|
||||
@group(0) @binding(1) var raw_bounce: texture_2d<f32>;
|
||||
#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<uniform> view: View;
|
||||
#endif
|
||||
@group(0) @binding(3) var<uniform> ao: Ao;
|
||||
#ifdef GI
|
||||
#ifdef STAGE_APPLY
|
||||
@group(0) @binding(4) var blurred_bounce: texture_2d<f32>;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifndef STAGE_BLUR
|
||||
// Sample zero, as the water and fog passes read MSAA depth.
|
||||
@@ -68,9 +85,139 @@ fn neighbour(p: vec2<i32>, centre: vec3<f32>) -> vec3<f32> {
|
||||
return view_at(p, z);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn ao_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let h = vec2<i32>(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<f32>,
|
||||
bounce: vec3<f32>,
|
||||
}
|
||||
|
||||
#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<f32>,
|
||||
}
|
||||
|
||||
fn ao_slice_bitmask(
|
||||
f: vec2<i32>,
|
||||
size: vec2<i32>,
|
||||
p: vec3<f32>,
|
||||
n: vec3<f32>,
|
||||
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<f32>(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<f32>(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<f32>(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<i32>(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<f32>(dot(delta * inverseSqrt(dd), v_dir), dot(normalize(back), v_dir)),
|
||||
vec2<f32>(-1.0),
|
||||
vec2<f32>(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<i32>(0), size - vec2<i32>(1)), 0).rgb,
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(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<i32>) -> AoResult {
|
||||
let f = h * 2;
|
||||
let size = vec2<i32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
}
|
||||
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<f32>(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<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
// 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<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
);
|
||||
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<f32>) -> @location(0) vec4<f32> {
|
||||
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<f32>(0.0);
|
||||
#endif
|
||||
return AoResult(vec4(occ, dist, protect, smooth_surface), bounce);
|
||||
}
|
||||
|
||||
#ifdef GI
|
||||
struct AoTargets {
|
||||
@location(0) packed: vec4<f32>,
|
||||
@location(1) bounce: vec4<f32>,
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn ao_main(@builtin(position) frag: vec4<f32>) -> AoTargets {
|
||||
let r = ao_compute(vec2<i32>(frag.xy));
|
||||
return AoTargets(r.packed, vec4<f32>(r.bounce, 1.0));
|
||||
}
|
||||
#else
|
||||
@fragment
|
||||
fn ao_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return ao_compute(vec2<i32>(frag.xy)).packed;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef STAGE_BLUR
|
||||
@fragment
|
||||
fn blur_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let h = vec2<i32>(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<f32>,
|
||||
bounce: vec3<f32>,
|
||||
}
|
||||
|
||||
fn blur_compute(h: vec2<i32>) -> BlurResult {
|
||||
let size = vec2<i32>(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<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
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<f32>(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<i32>(0), size - vec2<i32>(1)), 0);
|
||||
let q = clamp(h + vec2(x, y), vec2<i32>(0), size - vec2<i32>(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<f32>,
|
||||
@location(1) bounce: vec4<f32>,
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn blur_main(@builtin(position) frag: vec4<f32>) -> BlurTargets {
|
||||
let r = blur_compute(vec2<i32>(frag.xy));
|
||||
return BlurTargets(r.packed, vec4<f32>(r.bounce, 1.0));
|
||||
}
|
||||
#else
|
||||
@fragment
|
||||
fn blur_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return blur_compute(vec2<i32>(frag.xy)).packed;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef STAGE_APPLY
|
||||
@@ -182,8 +406,14 @@ fn apply_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
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<i32>(textureDimensions(source));
|
||||
@@ -193,15 +423,20 @@ fn apply_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
var occ = 0.0;
|
||||
var protect = 0.0;
|
||||
var smooth_surface = 0.0;
|
||||
var bounce = vec3<f32>(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<i32>(0), size - vec2<i32>(1)), 0);
|
||||
let q = clamp(base + o, vec2<i32>(0), size - vec2<i32>(1));
|
||||
let s = textureLoad(source, q, 0);
|
||||
let b = mix(1.0 - t, t, vec2<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
// 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
|
||||
|
||||
+283
-94
@@ -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<f32> {
|
||||
|
||||
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<u8> = 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::<u8>().ok())
|
||||
.filter(|v| *v <= 2);
|
||||
.filter(|v| *v <= GI_TIER);
|
||||
app.insert_resource(AoOverride(value))
|
||||
.add_plugins(ExtractComponentPlugin::<AoView>::default())
|
||||
.add_plugins(ExtractComponentPlugin::<AoGi>::default())
|
||||
.add_systems(Last, update_ao);
|
||||
// Keep headless policy tests independent of the renderer.
|
||||
if !app.is_plugin_added::<AssetPlugin>() {
|
||||
@@ -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<AoGi>,
|
||||
),
|
||||
With<WorldCamera>,
|
||||
>;
|
||||
|
||||
fn update_ao(
|
||||
mut commands: Commands,
|
||||
video: Res<VideoConfig>,
|
||||
override_value: Res<AoOverride>,
|
||||
mut cameras: Query<(Entity, &Camera, &mut Camera3d, Option<&AoView>), With<WorldCamera>>,
|
||||
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::<AoView>();
|
||||
}
|
||||
if had_gi {
|
||||
commands.entity(entity).remove::<AoGi>();
|
||||
}
|
||||
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::<AoGi>();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,9 +242,14 @@ struct AoShader(Handle<Shader>);
|
||||
|
||||
#[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<Shader>,
|
||||
fullscreen: FullscreenShader,
|
||||
}
|
||||
@@ -201,18 +264,22 @@ enum Stage {
|
||||
const AO_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
|
||||
|
||||
fn init_pipeline(mut commands: Commands, shader: Res<AoShader>, fullscreen: Res<FullscreenShader>) {
|
||||
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::<ViewUniform>(true),
|
||||
uniform_buffer::<AoView>(false),
|
||||
),
|
||||
@@ -221,30 +288,58 @@ fn init_pipeline(mut commands: Commands, shader: Res<AoShader>, fullscreen: Res<
|
||||
};
|
||||
let blur_layout = BindGroupLayoutDescriptor::new(
|
||||
"ssao_blur_layout",
|
||||
&BindGroupLayoutEntries::with_indices(
|
||||
ShaderStages::FRAGMENT,
|
||||
((0, plain()), (3, uniform_buffer::<AoView>(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::<AoView>(false)),
|
||||
),
|
||||
),
|
||||
);
|
||||
let apply_gi = |multisampled: bool| {
|
||||
BindGroupLayoutDescriptor::new(
|
||||
"ssao_apply_gi_layout",
|
||||
&BindGroupLayoutEntries::sequential(
|
||||
ShaderStages::FRAGMENT,
|
||||
(
|
||||
depth_entry(multisampled),
|
||||
plain(),
|
||||
uniform_buffer::<ViewUniform>(true),
|
||||
uniform_buffer::<AoView>(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<ShaderDefVal> = 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<PipelineCache>,
|
||||
pipeline: Res<AoPipeline>,
|
||||
mut specialized: ResMut<SpecializedRenderPipelines<AoPipeline>>,
|
||||
views: Query<(Entity, &ViewTarget, &Msaa), With<AoView>>,
|
||||
views: Query<(Entity, &ViewTarget, &Msaa, Has<AoGi>), With<AoView>>,
|
||||
all_views: Query<(&ViewTarget, &Msaa), With<Camera3d>>,
|
||||
) {
|
||||
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<TextureCache>,
|
||||
device: Res<RenderDevice>,
|
||||
views: Query<(Entity, &ExtractedCamera), With<AoView>>,
|
||||
views: Query<(Entity, &ExtractedCamera, Has<AoGi>), With<AoView>>,
|
||||
) {
|
||||
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::<PipelineCache>();
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
+89
-3
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user