MONKEY (integration): merge gfx/ao
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ behind each constant.
|
||||
| Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` |
|
||||
| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; phase 1 has no rain-occlusion map, so shelter is classified only by interior/unlit state and surface facing | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `wet_hook.wgsl` |
|
||||
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets |
|
||||
| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..4` diagnostic views (factor, protection, distance, raw occlusion), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
|
||||
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
|
||||
|
||||
Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
|
||||
|
||||
@@ -536,6 +536,8 @@
|
||||
foliageWind = "Foliage Wind",
|
||||
windGrass = "Grass",
|
||||
windTrees = "Grass + Trees",
|
||||
-- MONKEY (ao): localised contact-shadow row.
|
||||
ambientOcclusion = "Ambient Occlusion",
|
||||
yards = "%d yd",
|
||||
tips = {
|
||||
LIGHTING_QUALITY =
|
||||
@@ -668,6 +670,13 @@
|
||||
"Grass bends in two travelling wind waves and parts around nearby characters. "
|
||||
.. "Grass + Trees also gives classified tree and bush foliage a gentle sway.\n\n"
|
||||
.. "Off makes no vertex displacement and restores the unchanged image. Tree shadows stay static.",
|
||||
-- MONKEY (ao): what the contact shadows touch and what they spare.
|
||||
AMBIENT_OCCLUSION =
|
||||
"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"
|
||||
.. "Fades out with distance and spares the sky, water, lit windows and flames. "
|
||||
.. "Off leaves the image unchanged.",
|
||||
},
|
||||
},
|
||||
ruRU = {
|
||||
@@ -717,6 +726,8 @@
|
||||
foliageWind = "Ветер листвы",
|
||||
windGrass = "Трава",
|
||||
windTrees = "Трава + деревья",
|
||||
-- MONKEY (ao): Russian contact-shadow row.
|
||||
ambientOcclusion = "Затенение окружения",
|
||||
yards = "%d ярд.",
|
||||
tips = {
|
||||
LIGHTING_QUALITY =
|
||||
@@ -861,6 +872,13 @@
|
||||
"Трава гнётся двумя бегущими волнами ветра и расступается вокруг персонажей. "
|
||||
.. "Трава + деревья также добавляет мягкое качание листвы деревьев и кустов.\n\n"
|
||||
.. "Выключено не смещает вершины и восстанавливает исходный вид. Тени деревьев остаются неподвижными.",
|
||||
-- MONKEY (ao): Russian contact-shadow explanation.
|
||||
AMBIENT_OCCLUSION =
|
||||
"Мягкие контактные тени там, где поверхности соприкасаются: под предметами и мебелью, "
|
||||
.. "в углах между стенами и полом, у ног персонажей. «Низкое» использует меньше выборок; "
|
||||
.. "«Высокое» — более плавное и захватывает чуть больше.\n\n"
|
||||
.. "Ослабевает с расстоянием и не затрагивает небо, воду, освещённые окна и пламя. "
|
||||
.. "«Выкл» оставляет изображение без изменений.",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -933,6 +951,8 @@
|
||||
"RowRainSurfaces",
|
||||
-- MONKEY (wind): appended for conflict-free lane merges.
|
||||
"RowFoliageWind",
|
||||
-- MONKEY (ao): appended so the other rows keep their indices.
|
||||
"RowAmbientOcclusion",
|
||||
},
|
||||
-- Player Guild Names is a child of Player Names, as in 1.12 (l.702-708).
|
||||
Nameplates = {
|
||||
@@ -3082,6 +3102,22 @@
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- MONKEY (ao): numeric tiers through the existing localised dropdown. -->
|
||||
<Frame name="$parentRowAmbientOcclusion" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFoliageWind" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFoliageWind" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "ambientOcclusion", BENILLA_ADVGFX.ambientOcclusion,
|
||||
"BENILLA_TOOLTIP_AMBIENT_OCCLUSION")
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = BENILLA_ADVGFX.fogOff, value = "0" },
|
||||
{ text = BENILLA_ADVGFX.qLow, value = "1" },
|
||||
{ text = BENILLA_ADVGFX.qHigh, value = "2" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
|
||||
settings move from outside it — the Lighting Quality row
|
||||
|
||||
@@ -1650,6 +1650,21 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
name: "wet-stormwind-night", map: Some(MAP_AZEROTH),
|
||||
eye: [-8833.38, 628.63, 98.5], look: [-8815.0, 662.0, 93.5], minute: 0, ui: None,
|
||||
},
|
||||
// MONKEY (ao): contact-shadow subjects. Goldshire's street (props against walls, eaves),
|
||||
// an Elwynn forest floor (trunks, bushes, grass cutouts) and a close unit on open ground.
|
||||
Scenario {
|
||||
name: "ao-goldshire", map: Some(MAP_AZEROTH),
|
||||
eye: [-9430.0, 50.0, 61.0], look: [-9462.0, 30.0, 57.5], minute: 720, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "ao-forest", map: Some(MAP_AZEROTH),
|
||||
eye: [-9560.0, 60.0, 62.0], look: [-9600.0, 90.0, 58.0], minute: 720, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "ao-character", map: Some(MAP_AZEROTH),
|
||||
eye: [-9495.2, 60.8, 59.0], look: [-9500.00, 56.00, 56.9], minute: 720,
|
||||
ui: Some(UiFixture::Subject { kind: SubjectKind::Creature, at: SUBJECT_SUN }),
|
||||
},
|
||||
];
|
||||
|
||||
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
|
||||
|
||||
@@ -959,6 +959,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"1",
|
||||
"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"),
|
||||
ours(
|
||||
"waterQuality",
|
||||
"1",
|
||||
@@ -3067,6 +3069,9 @@ mod tests {
|
||||
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
|
||||
assert_eq!(d["waterQuality"], 1.0, "mirror reflections stay opt-in");
|
||||
assert_eq!(d["lavaLightGain"], 1.0);
|
||||
// MONKEY (ao): registry and renderer agree, and the lane ships Off.
|
||||
assert_eq!(d["ambientOcclusion"], shadows.ambient_occlusion as f32);
|
||||
assert_eq!(d["ambientOcclusion"], 0.0, "ambient occlusion is opt-in");
|
||||
// `lightingQuality` and MONKEY (wind) `foliageWind` deliberately are not in that census:
|
||||
// neither is a `VideoConfig` knob. The former names the rows above; its own weld is
|
||||
// `the_high_preset_is_the_registered_defaults`. The latter bridges directly to the
|
||||
|
||||
@@ -251,6 +251,8 @@ impl PluginGroup for GamePlugins {
|
||||
.add(crate::post::PostPlugin)
|
||||
// MONKEY (sky): the sky tier bridge.
|
||||
.add(crate::sky_quality::SkyQualityPlugin)
|
||||
// MONKEY (ao): contact shadows after the opaque pass, before the water copy.
|
||||
.add(crate::ssao::AmbientOcclusionPlugin)
|
||||
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
|
||||
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
|
||||
.add(crate::realmlist::RealmlistPlugin)
|
||||
|
||||
@@ -81,6 +81,8 @@ mod monkey_gfx;
|
||||
mod post;
|
||||
// MONKEY (sky): the skyQuality cvar bridge.
|
||||
mod sky_quality;
|
||||
// MONKEY (ao): optional screen-space ambient occlusion plugin.
|
||||
mod ssao;
|
||||
mod entities;
|
||||
mod fishing_line;
|
||||
mod footprints;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// MONKEY (ao): depth-only screen-space ambient occlusion for the opaque scene.
|
||||
// Three fullscreen stages, chosen by shader def:
|
||||
// STAGE_AO half resolution: occlusion from a rotated spiral of depth taps (Alchemy-style
|
||||
// cosine term with a range falloff), plus view distance and a bright-pixel mask.
|
||||
// STAGE_BLUR half resolution: 4x4 depth-aware box; the 4x4 Bayer rotation cancels inside it.
|
||||
// STAGE_APPLY full resolution: joint-bilateral upsample, output multiplied into the scene.
|
||||
// Stored texel: r = occlusion 0..1 (fade applied), g = view distance (SKY_DIST for sky),
|
||||
// b = bright-pixel protection 0..1.
|
||||
#import bevy_render::view::View
|
||||
|
||||
struct Ao {
|
||||
// Radius in yards, darkening strength, sample count, cosine bias.
|
||||
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); y: gain.
|
||||
debug: vec4<f32>,
|
||||
}
|
||||
|
||||
const SKY_DIST: f32 = 60000.0;
|
||||
|
||||
#ifdef STAGE_BLUR
|
||||
@group(0) @binding(0) var raw: texture_2d<f32>;
|
||||
#else
|
||||
#ifdef MULTISAMPLED
|
||||
@group(0) @binding(0) var depth: texture_depth_multisampled_2d;
|
||||
#else
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
#endif
|
||||
// STAGE_AO: the resolved opaque scene colour. STAGE_APPLY: the blurred half-resolution AO.
|
||||
@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<uniform> view: View;
|
||||
#endif
|
||||
@group(0) @binding(3) var<uniform> ao: Ao;
|
||||
|
||||
#ifndef STAGE_BLUR
|
||||
// Sample zero, as the water and fog passes read MSAA depth.
|
||||
fn depth_at(p: vec2<i32>) -> f32 {
|
||||
let size = vec2<i32>(textureDimensions(depth));
|
||||
return textureLoad(depth, clamp(p, vec2<i32>(0), size - vec2<i32>(1)), 0);
|
||||
}
|
||||
|
||||
fn view_at(p: vec2<i32>, z: f32) -> vec3<f32> {
|
||||
let uv = (vec2<f32>(p) + 0.5 - view.viewport.xy) / view.viewport.zw;
|
||||
let c = view.view_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), z, 1.0);
|
||||
return c.xyz / c.w;
|
||||
}
|
||||
|
||||
// Reverse-Z with an infinite far plane: the cleared value 0 is the sky.
|
||||
fn is_sky(z: f32) -> bool {
|
||||
return z <= 1e-7;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef STAGE_AO
|
||||
fn bayer4(p: vec2<i32>) -> f32 {
|
||||
var m = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
|
||||
let x = u32(p.x) & 3u;
|
||||
let y = u32(p.y) & 3u;
|
||||
return f32(m[y * 4u + x]);
|
||||
}
|
||||
|
||||
// A neighbour for the normal; the sky counts as infinitely far so it is never chosen.
|
||||
fn neighbour(p: vec2<i32>, centre: vec3<f32>) -> vec3<f32> {
|
||||
let z = depth_at(p);
|
||||
if (is_sky(z)) { return centre + vec3(0.0, 0.0, -1e4); }
|
||||
return view_at(p, z);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn ao_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let h = vec2<i32>(frag.xy);
|
||||
let f = h * 2;
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
// Protection from the brightest pixel of the 2x2 footprint: lit windows, flames, sunlit sand.
|
||||
var bright = 0.0;
|
||||
for (var j = 0; j < 4; j += 1) {
|
||||
let q = clamp(f + vec2(j & 1, j >> 1u), vec2<i32>(0), size - vec2<i32>(1));
|
||||
let c = textureLoad(source, q, 0).rgb;
|
||||
bright = max(bright, max(c.r, max(c.g, c.b)));
|
||||
}
|
||||
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); }
|
||||
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); }
|
||||
|
||||
// Normal from the flatter neighbour on each axis, so silhouettes do not bend it.
|
||||
let pr = neighbour(f + vec2(1, 0), p);
|
||||
let pl = neighbour(f - vec2(1, 0), p);
|
||||
let pd = neighbour(f + vec2(0, 1), p);
|
||||
let pu = neighbour(f - vec2(0, 1), p);
|
||||
let dx = select(p - pl, pr - p, abs(pr.z - p.z) < abs(p.z - pl.z));
|
||||
let dy = select(p - pu, pd - p, abs(pd.z - p.z) < abs(p.z - pu.z));
|
||||
var n = normalize(cross(dx, dy));
|
||||
if (dot(n, p) > 0.0) { n = -n; }
|
||||
|
||||
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;
|
||||
let r2 = radius * radius;
|
||||
var occ = 0.0;
|
||||
for (var i = 0u; i < count; i += 1u) {
|
||||
let t = (f32(i) + jitter) / f32(count);
|
||||
let a = f32(i) * 2.3999632 + rot;
|
||||
// Linear radius: taps crowd the centre, where contact shadows live.
|
||||
let off = vec2(cos(a), sin(a)) * mix(2.0, r_px, t);
|
||||
let q = f + vec2<i32>(round(off));
|
||||
let zq = depth_at(q);
|
||||
if (is_sky(zq)) { continue; }
|
||||
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);
|
||||
}
|
||||
occ = saturate(ao.debug.y * occ / f32(count)) * fade;
|
||||
return vec4(occ, dist, protect, 1.0);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef STAGE_BLUR
|
||||
@fragment
|
||||
fn blur_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let h = vec2<i32>(frag.xy);
|
||||
let size = vec2<i32>(textureDimensions(raw));
|
||||
let c = textureLoad(raw, h, 0);
|
||||
if (c.g >= SKY_DIST) { return c; }
|
||||
let tolerance = 0.04 * c.g + 0.05;
|
||||
var sum = 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 w = saturate(1.0 - abs(s.g - c.g) / tolerance);
|
||||
sum += s.r * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return vec4(sum / max(weight, 1e-4), c.g, c.b, 1.0);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef STAGE_APPLY
|
||||
@fragment
|
||||
fn apply_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let p = vec2<i32>(frag.xy);
|
||||
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.
|
||||
return select(vec4(1.0), vec4(1.0, 0.0, 1.0, 1.0), mode >= 2u);
|
||||
}
|
||||
let dist = -view_at(p, z).z;
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
let hc = frag.xy * 0.5 - 0.5;
|
||||
let base = vec2<i32>(floor(hc));
|
||||
let t = hc - floor(hc);
|
||||
var occ = 0.0;
|
||||
var protect = 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 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;
|
||||
weight += w;
|
||||
}
|
||||
occ /= weight;
|
||||
protect /= weight;
|
||||
if (mode == 2u) { return vec4(protect, protect, protect, 1.0); }
|
||||
// Red = positive view distance, green = negative (a sign error), both / 50 yd.
|
||||
if (mode == 3u) { return vec4(saturate(dist / 50.0), saturate(-dist / 50.0), 0.0, 1.0); }
|
||||
if (mode == 4u) { return vec4(1.0 - occ, 1.0 - occ, 1.0 - occ, 1.0); }
|
||||
let k = 1.0 - ao.params.y * occ * (1.0 - protect);
|
||||
return vec4(k, k, k, 1.0);
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,518 @@
|
||||
//! MONKEY (ao): depth-only screen-space ambient occlusion (soft contact shadows).
|
||||
//!
|
||||
//! No depth or normal prepass exists, and the static world draws in its own node, so Bevy's
|
||||
//! SSAO cannot see it. This lane reads the main-pass depth after the opaque pass instead:
|
||||
//! half-resolution occlusion from depth-reconstructed positions and normals, a 4x4 depth-aware
|
||||
//! blur, then a joint-bilateral upsample MULTIPLIED into the main colour attachment (the MSAA
|
||||
//! attachment when multisampled, so the resolve and every later pass see it). It runs before
|
||||
//! the water depth/colour copy and before transparents, so water, particles and UI are
|
||||
//! 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..4` writes a diagnostic view
|
||||
//! (factor, protection, distance, raw occlusion); `WOW_AO_GAIN/RADIUS/BIAS/STRENGTH` tune it.
|
||||
use crate::video::VideoConfig;
|
||||
use benilla_world::{liquid::WaterDepthLabel, view::WorldCamera};
|
||||
use bevy::{
|
||||
core_pipeline::{
|
||||
core_3d::graph::{Core3d, Node3d},
|
||||
FullscreenShader,
|
||||
},
|
||||
ecs::query::QueryItem,
|
||||
image::ToExtents,
|
||||
prelude::*,
|
||||
render::{
|
||||
camera::ExtractedCamera,
|
||||
diagnostic::RecordDiagnostics,
|
||||
extract_component::{ExtractComponent, ExtractComponentPlugin},
|
||||
render_graph::{
|
||||
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
|
||||
},
|
||||
render_resource::{binding_types::*, *},
|
||||
renderer::{RenderContext, RenderDevice, RenderQueue},
|
||||
texture::{CachedTexture, TextureCache},
|
||||
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
|
||||
Render, RenderApp, RenderStartup, RenderSystems,
|
||||
},
|
||||
shader::ShaderDefVal,
|
||||
};
|
||||
|
||||
pub(crate) struct AmbientOcclusionPlugin;
|
||||
|
||||
#[derive(Resource)]
|
||||
struct AoOverride(Option<u8>);
|
||||
|
||||
/// Per-tier constants, uploaded as one small uniform.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Debug, ExtractComponent, ShaderType)]
|
||||
struct AoView {
|
||||
/// Radius (yd), darkening strength, sample count, cosine bias.
|
||||
params: Vec4,
|
||||
/// 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),
|
||||
/// y = occlusion gain.
|
||||
debug: Vec4,
|
||||
}
|
||||
|
||||
/// Dev tuning knob `WOW_AO_<name>=<f32>`, read once per name; `None` keeps the tier value.
|
||||
fn tuning(name: &'static str) -> Option<f32> {
|
||||
static KNOBS: std::sync::Mutex<Vec<(&'static str, Option<f32>)>> = std::sync::Mutex::new(Vec::new());
|
||||
let mut knobs = KNOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, v)) = knobs.iter().find(|(n, _)| *n == name) {
|
||||
return *v;
|
||||
}
|
||||
let v = std::env::var(format!("WOW_AO_{name}"))
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite() && *v >= 0.0);
|
||||
knobs.push((name, v));
|
||||
v
|
||||
}
|
||||
|
||||
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) };
|
||||
// Outdoor contacts (house bases, trunks) sit 30-60 yd out; Low stops sooner.
|
||||
let fade = if tier >= 2 { (60.0, 120.0) } else { (45.0, 90.0) };
|
||||
let radius = tuning("RADIUS").unwrap_or(radius);
|
||||
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);
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_AO_DEBUG=1..4`: write a diagnostic term instead of darkening the scene.
|
||||
fn debug_mode() -> u8 {
|
||||
static MODE: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
|
||||
*MODE.get_or_init(|| {
|
||||
std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(4)
|
||||
})
|
||||
}
|
||||
|
||||
fn debug_view() -> bool {
|
||||
debug_mode() != 0
|
||||
}
|
||||
|
||||
impl Plugin for AmbientOcclusionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let value = std::env::var("WOW_AO")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u8>().ok())
|
||||
.filter(|v| *v <= 2);
|
||||
app.insert_resource(AoOverride(value))
|
||||
.add_plugins(ExtractComponentPlugin::<AoView>::default())
|
||||
.add_systems(Last, update_ao);
|
||||
// Keep headless policy tests independent of the renderer.
|
||||
if !app.is_plugin_added::<AssetPlugin>() {
|
||||
return;
|
||||
}
|
||||
let shader = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Shader>>()
|
||||
.add(Shader::from_wgsl(include_str!("shaders/ssao.wgsl"), "ssao.wgsl"));
|
||||
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
|
||||
return;
|
||||
};
|
||||
render_app
|
||||
.insert_resource(AoShader(shader))
|
||||
.init_resource::<SpecializedRenderPipelines<AoPipeline>>()
|
||||
.add_systems(RenderStartup, init_pipeline)
|
||||
.add_systems(
|
||||
Render,
|
||||
(
|
||||
prepare_pipelines.in_set(RenderSystems::Prepare),
|
||||
prepare_textures.in_set(RenderSystems::PrepareResources),
|
||||
),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<AoNode>>(Core3d, AoLabel)
|
||||
// After every solid path (static_gx precedes MainOpaquePass), before the water copy.
|
||||
.add_render_graph_edges(Core3d, (Node3d::MainOpaquePass, AoLabel, WaterDepthLabel));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_ao(
|
||||
mut commands: Commands,
|
||||
video: Res<VideoConfig>,
|
||||
override_value: Res<AoOverride>,
|
||||
mut cameras: Query<(Entity, &Camera, &mut Camera3d, Option<&AoView>), With<WorldCamera>>,
|
||||
) {
|
||||
let tier = override_value.0.unwrap_or(video.ambient_occlusion).min(2);
|
||||
for (entity, camera, mut camera3d, current) in &mut cameras {
|
||||
if tier == 0 || !camera.is_active {
|
||||
if current.is_some() {
|
||||
commands.entity(entity).remove::<AoView>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let binding = TextureUsages::TEXTURE_BINDING.bits();
|
||||
if camera3d.depth_texture_usages.0 & binding == 0 {
|
||||
camera3d.depth_texture_usages.0 |= binding;
|
||||
}
|
||||
let want = AoView::tier(tier);
|
||||
if current != Some(&want) {
|
||||
commands.entity(entity).insert(want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct AoShader(Handle<Shader>);
|
||||
|
||||
#[derive(Resource)]
|
||||
struct AoPipeline {
|
||||
ao_layouts: [BindGroupLayoutDescriptor; 2],
|
||||
blur_layout: BindGroupLayoutDescriptor,
|
||||
apply_layouts: [BindGroupLayoutDescriptor; 2],
|
||||
shader: Handle<Shader>,
|
||||
fullscreen: FullscreenShader,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Stage {
|
||||
Ao,
|
||||
Blur,
|
||||
Apply,
|
||||
}
|
||||
|
||||
const AO_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
|
||||
|
||||
fn init_pipeline(
|
||||
mut commands: Commands,
|
||||
shader: Res<AoShader>,
|
||||
fullscreen: Res<FullscreenShader>,
|
||||
) {
|
||||
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 }),
|
||||
uniform_buffer::<ViewUniform>(true),
|
||||
uniform_buffer::<AoView>(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
};
|
||||
let blur_layout = BindGroupLayoutDescriptor::new(
|
||||
"ssao_blur_layout",
|
||||
&BindGroupLayoutEntries::with_indices(
|
||||
ShaderStages::FRAGMENT,
|
||||
(
|
||||
(0, texture_2d(TextureSampleType::Float { filterable: false })),
|
||||
(3, uniform_buffer::<AoView>(false)),
|
||||
),
|
||||
),
|
||||
);
|
||||
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)),
|
||||
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 {
|
||||
let multisampled = samples > 1;
|
||||
let mut defs: Vec<ShaderDefVal> = vec![match stage {
|
||||
Stage::Ao => "STAGE_AO",
|
||||
Stage::Blur => "STAGE_BLUR",
|
||||
Stage::Apply => "STAGE_APPLY",
|
||||
}
|
||||
.into()];
|
||||
if multisampled && stage != Stage::Blur {
|
||||
defs.push("MULTISAMPLED".into());
|
||||
}
|
||||
let (label, entry, layout) = match stage {
|
||||
Stage::Ao => ("ssao", "ao_main", self.ao_layouts[multisampled as usize].clone()),
|
||||
Stage::Blur => ("ssao_blur", "blur_main", self.blur_layout.clone()),
|
||||
Stage::Apply => (
|
||||
"ssao_apply",
|
||||
"apply_main",
|
||||
self.apply_layouts[multisampled as usize].clone(),
|
||||
),
|
||||
};
|
||||
// The apply stage multiplies into the scene (dst * src) and leaves alpha alone.
|
||||
let blend = (stage == Stage::Apply && !debug).then_some(BlendState {
|
||||
color: BlendComponent {
|
||||
src_factor: BlendFactor::Dst,
|
||||
dst_factor: BlendFactor::Zero,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
alpha: BlendComponent {
|
||||
src_factor: BlendFactor::Zero,
|
||||
dst_factor: BlendFactor::One,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
});
|
||||
RenderPipelineDescriptor {
|
||||
label: Some(label.into()),
|
||||
layout: vec![layout],
|
||||
vertex: self.fullscreen.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
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,
|
||||
})],
|
||||
}),
|
||||
multisample: MultisampleState {
|
||||
count: if stage == Stage::Apply { samples } else { 1 },
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct ViewAoPipelines([CachedRenderPipelineId; 3]);
|
||||
|
||||
fn prepare_pipelines(
|
||||
mut commands: Commands,
|
||||
cache: Res<PipelineCache>,
|
||||
pipeline: Res<AoPipeline>,
|
||||
mut specialized: ResMut<SpecializedRenderPipelines<AoPipeline>>,
|
||||
views: Query<(Entity, &ViewTarget, &Msaa), With<AoView>>,
|
||||
) {
|
||||
let debug = debug_view();
|
||||
for (entity, target, msaa) 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)));
|
||||
commands.entity(entity).insert(ViewAoPipelines(ids));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct AoTextures {
|
||||
raw: CachedTexture,
|
||||
blurred: CachedTexture,
|
||||
}
|
||||
|
||||
fn prepare_textures(
|
||||
mut commands: Commands,
|
||||
mut cache: ResMut<TextureCache>,
|
||||
device: Res<RenderDevice>,
|
||||
views: Query<(Entity, &ExtractedCamera), With<AoView>>,
|
||||
) {
|
||||
for (entity, camera) in &views {
|
||||
// The depth texture is sized to the physical target.
|
||||
let Some(size) = camera.physical_target_size else { continue };
|
||||
let half = UVec2::new(size.x.div_ceil(2), size.y.div_ceil(2)).max(UVec2::ONE);
|
||||
let mut texture = |label: &'static str| {
|
||||
cache.get(
|
||||
&device,
|
||||
TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: half.to_extents(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: TextureDimension::D2,
|
||||
format: AO_FORMAT,
|
||||
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
)
|
||||
};
|
||||
let raw = texture("ssao_raw");
|
||||
let blurred = texture("ssao_blurred");
|
||||
commands.entity(entity).insert(AoTextures { raw, blurred });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct AoLabel;
|
||||
|
||||
#[derive(Default)]
|
||||
struct AoNode;
|
||||
|
||||
impl ViewNode for AoNode {
|
||||
type ViewQuery = (
|
||||
&'static ViewTarget,
|
||||
&'static ViewDepthTexture,
|
||||
&'static ViewUniformOffset,
|
||||
&'static AoView,
|
||||
&'static ViewAoPipelines,
|
||||
&'static AoTextures,
|
||||
);
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
context: &mut RenderContext<'w>,
|
||||
(target, depth, view_offset, settings, ids, textures): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let cache = world.resource::<PipelineCache>();
|
||||
let (Some(ao), Some(blur), Some(apply)) = (
|
||||
cache.get_render_pipeline(ids.0[0]),
|
||||
cache.get_render_pipeline(ids.0[1]),
|
||||
cache.get_render_pipeline(ids.0[2]),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !depth
|
||||
.texture
|
||||
.usage()
|
||||
.contains(TextureUsages::TEXTURE_BINDING)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let Some(view) = world.resource::<ViewUniforms>().uniforms.binding() else {
|
||||
return Ok(());
|
||||
};
|
||||
let layouts = world.resource::<AoPipeline>();
|
||||
let ms = (depth.texture.sample_count() > 1) as usize;
|
||||
let device = context.render_device().clone();
|
||||
let mut uniform = UniformBuffer::from(*settings);
|
||||
uniform.write_buffer(&device, world.resource::<RenderQueue>());
|
||||
let params = uniform.binding().unwrap();
|
||||
|
||||
// `main_texture_view` is the single-sample texture the opaque pass resolved into.
|
||||
let ao_bind = device.create_bind_group(
|
||||
"ssao",
|
||||
&cache.get_bind_group_layout(&layouts.ao_layouts[ms]),
|
||||
&BindGroupEntries::sequential((
|
||||
depth.view(),
|
||||
target.main_texture_view(),
|
||||
view.clone(),
|
||||
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 diagnostics = context.diagnostic_recorder();
|
||||
let half_pass = |encoder: &mut CommandEncoder,
|
||||
label: &'static str,
|
||||
out: &TextureView,
|
||||
pipeline: &RenderPipeline,
|
||||
bind: &BindGroup,
|
||||
offsets: &[u32]| {
|
||||
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,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let span = diagnostics.pass_span(&mut pass, label);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, bind, offsets);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
};
|
||||
let encoder = context.command_encoder();
|
||||
half_pass(encoder, "ssao", &textures.raw.default_view, ao, &ao_bind, &[view_offset.offset]);
|
||||
half_pass(encoder, "ssao_blur", &textures.blurred.default_view, blur, &blur_bind, &[]);
|
||||
// The main attachment (MSAA + resolve when multisampled), so later passes load it darkened.
|
||||
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
|
||||
label: Some("ssao_apply"),
|
||||
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");
|
||||
pass.set_pipeline(apply);
|
||||
pass.set_bind_group(0, &apply_bind, &[view_offset.offset]);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tiers_follow_the_cvar_override_and_camera_state() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<VideoConfig>()
|
||||
.insert_resource(AoOverride(None))
|
||||
.add_systems(Update, update_ao);
|
||||
let camera = app
|
||||
.world_mut()
|
||||
.spawn((WorldCamera, Camera::default(), Camera3d::default()))
|
||||
.id();
|
||||
let preview = app
|
||||
.world_mut()
|
||||
.spawn((Camera::default(), Camera3d::default()))
|
||||
.id();
|
||||
// The registered default is Off: no pass, image unchanged.
|
||||
app.update();
|
||||
assert!(app.world().get::<AoView>(camera).is_none());
|
||||
app.world_mut().resource_mut::<VideoConfig>().ambient_occlusion = 1;
|
||||
app.update();
|
||||
let low = *app.world().get::<AoView>(camera).unwrap();
|
||||
assert_eq!(low.params.z, 6.0);
|
||||
assert!(app.world().get::<AoView>(preview).is_none());
|
||||
let usage = app.world().get::<Camera3d>(camera).unwrap().depth_texture_usages.0;
|
||||
assert!(usage & TextureUsages::TEXTURE_BINDING.bits() != 0);
|
||||
app.world_mut().resource_mut::<VideoConfig>().ambient_occlusion = 2;
|
||||
app.update();
|
||||
let high = *app.world().get::<AoView>(camera).unwrap();
|
||||
assert_eq!(high.params.z, 12.0);
|
||||
assert!(high.params.x > low.params.x);
|
||||
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = false;
|
||||
app.update();
|
||||
assert!(app.world().get::<AoView>(camera).is_none());
|
||||
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = true;
|
||||
app.world_mut().resource_mut::<AoOverride>().0 = Some(0);
|
||||
app.update();
|
||||
assert!(app.world().get::<AoView>(camera).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_keep_the_fade_and_protection_ramps_ordered() {
|
||||
for tier in [1, 2] {
|
||||
let v = AoView::tier(tier);
|
||||
assert!(v.fade.x < v.fade.y, "distance fade runs outward");
|
||||
assert!(v.fade.z < v.fade.w, "protection ramps up with brightness");
|
||||
assert!(v.params.y > 0.0 && v.params.y < 1.0, "never a black halo");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2055,6 +2055,42 @@ fn volumetric_fog_dropdown_is_localised_and_live() {
|
||||
}
|
||||
}
|
||||
|
||||
// MONKEY (ao): the row's strings, tooltip and numeric writes, both locales.
|
||||
#[test]
|
||||
fn ambient_occlusion_dropdown_is_localised_and_live() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
|
||||
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
|
||||
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
|
||||
let row = xml.split("<Frame name=\"$parentRowAmbientOcclusion\"").nth(1).unwrap();
|
||||
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").next().unwrap();
|
||||
for (locale, title, labels) in [
|
||||
("enUS", "Ambient Occlusion", ["Off", "Low", "High"]),
|
||||
("ruRU", "Затенение окружения", ["Выкл", "Низкое", "Высокое"]),
|
||||
] {
|
||||
let mut s = audio_harness();
|
||||
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
|
||||
s.run(strings).unwrap();
|
||||
s.run(r#"
|
||||
self = {}
|
||||
function OptionsRow_OnLoad(row, cvar, title, tip)
|
||||
row.cvar, row.title, row.tip = cvar, title, tip
|
||||
end
|
||||
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
|
||||
"#).unwrap();
|
||||
s.run(on_load).unwrap();
|
||||
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
|
||||
assert!(s.eval::<bool>("return getglobal(self.tip) == BENILLA_ADVGFX.tips.AMBIENT_OCCLUSION and string.len(getglobal(self.tip)) > 80").unwrap());
|
||||
// High first: the registered default is Off, and rewriting the same value is no change.
|
||||
for (tier, label) in labels.iter().enumerate().rev() {
|
||||
assert_eq!(s.eval::<String>(&format!("return self.choices[{}].text", tier + 1)).unwrap(), *label);
|
||||
let _ = s.take_cvar_changes();
|
||||
s.run(&format!("SetCVar(self.cvar, self.choices[{}].value)", tier + 1)).unwrap();
|
||||
assert_eq!(s.take_cvar_changes(), vec![("ambientOcclusion".to_string(), tier.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_quality_writes_numeric_tiers_with_localised_labels() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -2067,9 +2103,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
|
||||
// MONKEY (volumetric fog): account for the atmosphere row after water.
|
||||
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18);
|
||||
// MONKEY (integration): all programme rows (sky, post, dither, fog, wet, wind, ao) counted.
|
||||
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 28);
|
||||
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
|
||||
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").unwrap(), "RowLavaGlow");
|
||||
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[21]").unwrap(), "RowLavaGlow");
|
||||
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
|
||||
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
|
||||
assert!(s.eval::<bool>("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
|
||||
@@ -3002,6 +3039,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
("BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog"),
|
||||
// MONKEY (sky): the sky tier row owns its translated tooltip.
|
||||
("BENILLA_TOOLTIP_SKY_QUALITY", "AdvancedGraphicsRowSkyQuality"),
|
||||
// MONKEY (ao): the contact-shadow row's translated tooltip.
|
||||
("BENILLA_TOOLTIP_AMBIENT_OCCLUSION", "AdvancedGraphicsRowAmbientOcclusion"),
|
||||
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
|
||||
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
|
||||
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
|
||||
|
||||
@@ -230,6 +230,8 @@ pub(crate) struct VideoConfig {
|
||||
pub(crate) color_grading: bool,
|
||||
// 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.
|
||||
pub(crate) ambient_occlusion: u8,
|
||||
/// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow.
|
||||
/// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`.
|
||||
pub(crate) lava_light_gain: f32,
|
||||
@@ -493,6 +495,8 @@ impl Default for VideoConfig {
|
||||
color_grading: true,
|
||||
// MONKEY (sky): Classic until a preset or the player picks a tier.
|
||||
sky_quality: 0,
|
||||
// MONKEY (ao): opt-in; the future Graphics preset sets High = 2.
|
||||
ambient_occlusion: 0,
|
||||
lava_light_gain: 1.0,
|
||||
fire_flicker: 1.0,
|
||||
display: if windowed_env() {
|
||||
@@ -580,6 +584,8 @@ 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,
|
||||
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
|
||||
"worldshadows" => cfg.world_shadows = ev.flag(),
|
||||
"charactershadows" => cfg.character_shadows = ev.flag(),
|
||||
|
||||
@@ -33,7 +33,8 @@ use benilla_assets::AssetSet;
|
||||
mod drift;
|
||||
mod lod;
|
||||
mod scene_depth;
|
||||
pub use scene_depth::WaterDepthPlugin;
|
||||
// MONKEY (ao): the label orders the AO pass.
|
||||
pub use scene_depth::{WaterDepthLabel, WaterDepthPlugin};
|
||||
mod query;
|
||||
#[cfg(test)]
|
||||
mod real_data;
|
||||
|
||||
@@ -187,8 +187,9 @@ fn init_pipeline(mut commands: Commands, shader: Res<DepthShader>, cache: Res<Pi
|
||||
commands.insert_resource(DepthPipelines { layouts, pipelines, colour_layout, colour_pipeline });
|
||||
}
|
||||
|
||||
// MONKEY (ao): public so the AO pass can order itself before the opaque copies.
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct WaterDepthLabel;
|
||||
pub struct WaterDepthLabel;
|
||||
#[derive(Default)]
|
||||
struct DepthNode;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user