diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index b55e05b9..90009c46 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -601,6 +601,8 @@ rainSurfaces = "Wet Surfaces in Rain", -- MONKEY (wind) foliageWind = "Foliage Wind", + -- GFX (foliage distance) + foliageDistance = "Foliage Distance", windGrass = "Grass", windTrees = "Grass + Trees", foliageWindStrength = "Foliage Wind Strength", -- MONKEY (fix-wind) @@ -927,6 +929,17 @@ .. "is calmer, higher is a blustery day. Storms swing everything harder on top. " .. "Grass still parts around characters the same way. Needs Foliage Wind on.", -- MONKEY (ao): what the contact shadows touch and what they spare. + -- GFX (foliage distance): the ground-clutter horizon. + FOLIAGE_DISTANCE = + "How far grass, reeds and small bushes are drawn. 70 yd is the original " + .. "client's own limit, which is also where you can watch them appear as " + .. "you walk; pushing it out moves that edge far enough away that the " + .. "tufts arriving are too small to notice.\n\n" + .. "Doubling it to 140 yd measured free. Past that it costs a little, and " + .. "it costs it on the processor rather than the graphics card, which is " + .. "the side this client has less of.\n\n" + .. "Applies on Apply, because every patch of grass already drawn has to be " + .. "rebuilt to the new distance.", 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; " @@ -1053,6 +1066,8 @@ rainSurfaces = "Мокрые поверхности в дождь", -- MONKEY (wind) foliageWind = "Ветер листвы", + -- GFX (foliage distance) + foliageDistance = "Дальность травы", windGrass = "Трава", windTrees = "Трава + деревья", foliageWindStrength = "Сила ветра листвы", -- MONKEY (fix-wind) @@ -1386,6 +1401,17 @@ .. "спокойнее, больше — ветреный день. В грозу всё качается ещё сильнее. " .. "Трава по-прежнему расступается вокруг персонажей. Работает, если включён ветер листвы.", -- MONKEY (ao): Russian contact-shadow explanation. + -- GFX (foliage distance) + FOLIAGE_DISTANCE = + "Насколько далеко рисуются трава, тростник и мелкие кусты. 70 ярдов — " + .. "собственный предел оригинального клиента, и на этом же расстоянии " + .. "видно, как они появляются при ходьбе; если отодвинуть край дальше, " + .. "появляющиеся кустики станут слишком мелкими, чтобы это замечать.\n\n" + .. "Удвоение до 140 ярдов по измерениям не стоит ничего. Дальше цена " + .. "появляется, и платит её процессор, а не видеокарта — а именно " + .. "процессора этому клиенту не хватает.\n\n" + .. "Применяется по кнопке «Применить»: всю уже нарисованную траву нужно " + .. "перестроить под новое расстояние.", AMBIENT_OCCLUSION = "Мягкие контактные тени там, где поверхности соприкасаются: под предметами и мебелью, " .. "в углах между стенами и полом, у ног персонажей. «Низкое» использует меньше выборок; " @@ -1505,6 +1531,7 @@ "RowFoliageWind", -- MONKEY (fix-wind): the sway strength, right under its tier. "RowFoliageWindStrength", + "RowFoliageDistance", -- MONKEY (ao): appended so the other rows keep their indices. "RowAmbientOcclusion", -- MONKEY (lampfog) @@ -4379,11 +4406,29 @@ - + + + + OptionsRow_OnLoad(self, "foliageDistance", + BENILLA_ADVGFX.foliageDistance, + "BENILLA_TOOLTIP_FOLIAGE_DISTANCE") + -- `"yd"`: the stored value already IS a distance in + -- yards, like Shadow Distance beside it. + OptionsSliderRow_Setup(self, 70, 300, 10, "yd") + self.deferred = 1 + + + + + + + OptionsRow_OnLoad(self, "ambientOcclusion", BENILLA_ADVGFX.ambientOcclusion, "BENILLA_TOOLTIP_AMBIENT_OCCLUSION") diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 4127e4ae..c15e8148 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -706,6 +706,17 @@ pub(crate) const REGISTERED: &[Registered] = &[ / Ultra, or Custom when the rows match none of them; the reference's options have no \ such preset", ), + // GFX (foliage distance): the ground-clutter horizon in yards. The registered default IS the + // reference's own hardcoded 70 (`clutter::DETAIL_DOODAD_FADE_FAR`, `[0x867958]`), so a fresh + // install and every preset render exactly what they rendered before this row existed; the + // player opts in. Deliberately NOT in any preset ladder for the same reason — it trades main + // thread for grass, and that is a judgement about a machine, not a quality tier. + ours( + "foliageDistance", + "70", + "benilla's own: how far ground clutter (grass, reeds, small bushes) is drawn, 70..300 yd \ + (70 = the original client's own horizon)", + ), // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours( "volumetricFog", @@ -4457,6 +4468,30 @@ mod tests { // `0` is not clutter-off: the callback pins it to 1. apply(&mut app, "frillDensity", "9000"); assert_eq!(res::(&app).density, 16.0); + // GFX (foliage distance): the horizon, in yards, on the SAME resource and clamped to its + // own range. The floor is the reference's own 70 — a write below it is the setting trying + // to undo the client, which `WorldDetail` above is the knob for. + let shipped = *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.start(); + assert_eq!(shipped, benilla_world::clutter::DETAIL_DOODAD_FADE_FAR); + apply(&mut app, "foliageDistance", "140"); + assert_eq!(res::(&app).fade_far, 140.0); + apply(&mut app, "foliagedistance", "9000"); + assert_eq!( + res::(&app).fade_far, + *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.end() + ); + apply(&mut app, "foliageDistance", "0"); + assert_eq!( + res::(&app).fade_far, + shipped, + "below the reference's own horizon clamps back to it" + ); + // The registered default IS that floor, so an untouched install renders what it always did. + assert_eq!( + fresh_registry().get("foliageDistance"), + Some(shipped.to_string().trim_end_matches(".0")), + "the shipped default is the reference's own 70 yd" + ); apply(&mut app, "frillDensity", "0"); assert_eq!(res::(&app).density, 1.0 / 16.0); apply(&mut app, "WorldDetail", "1"); diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 2f4a7006..92061682 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -2619,10 +2619,11 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() { // GFX (auto-exposure): the tier and its blend: 52 -> 54. // GFX (sharpening): the RCAS tier: 54 -> 55. // GFX (froxel volumetrics): the shadowed-air dial beside the haze tier: 55 -> 56. + // GFX (foliage distance): the ground-clutter horizon, under the two wind rows: 56 -> 57. assert_eq!( s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)") .unwrap(), - 56 + 57 ); assert_eq!( s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]") @@ -3615,6 +3616,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { "BENILLA_TOOLTIP_VOLUMETRIC_FOG_SHADOW", "AdvancedGraphicsRowVolumetricFogShadow", ), + // GFX (foliage distance): the ground-clutter horizon. + ( + "BENILLA_TOOLTIP_FOLIAGE_DISTANCE", + "AdvancedGraphicsRowFoliageDistance", + ), // MONKEY (sky): the sky tier row owns its translated tooltip. ( "BENILLA_TOOLTIP_SKY_QUALITY", @@ -3887,7 +3893,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { // GFX (auto-exposure): the tier and its blend. 134 -> 136. // GFX (sharpening) / (field of view): one row each, on the two different pages. 136 -> 138. // GFX (froxel volumetrics): the shadowed-air dial beside the haze tier. 138 -> 139. - assert_eq!(checked, 139, "every tipped row carries a live key"); + // GFX (foliage distance): the ground-clutter horizon. 139 -> 140. + assert_eq!(checked, 140, "every tipped row carries a live key"); assert_eq!( untipped, vec![ @@ -3996,7 +4003,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() { // GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124. // GFX (interior rig): the ten interior-lighting rows. 124 -> 134. // GFX (auto-exposure): the tier and its blend. 134 -> 136. - assert_eq!(raised, 139, "every row but Auto Loot raises a description"); + assert_eq!(raised, 140, "every row but Auto Loot raises a description"); } /// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 5655e90f..af51de8d 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -705,6 +705,17 @@ pub(crate) fn on_cvar( &clutter.frill_density().to_string(), ); } + // GFX (foliage distance): the ground-clutter horizon in yards. Writes the world resource + // directly, like the two density spellings below it — `ClutterConfig` is the lane's own + // resource and `VideoConfig` holds no copy, so there is nothing here to keep in step. + // `clutter::remesh_on_material_change` re-cuts the built chunks off this write, which is + // why the Options row commits on Apply rather than on the drag. + "foliagedistance" => { + clutter.fade_far = v.clamp( + *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.start(), + *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.end(), + ) + } // The same knob in the reference's cells per chunk, clamped to `[1, 256]` // (`ClutterConfig::set_frill_density`); the loaded tiles re-scatter off the change. "frilldensity" => { diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs index b43113a8..1a16a51e 100644 --- a/crates/benilla-world/src/clutter.rs +++ b/crates/benilla-world/src/clutter.rs @@ -33,7 +33,7 @@ impl Plugin for ClutterPlugin { .add_systems( Update, ( - remesh_on_cutout_change, + remesh_on_material_change, stream_chunk_clutter, evict_clutter_geometry, scope_clutter_geometry, @@ -43,19 +43,26 @@ impl Plugin for ClutterPlugin { } } -/// Drop the built meshes when the cutout value moves (a density write must not re-mesh): the ref is -/// baked into the material. The reference's `detailDoodadAlpha` is a console command (`0x6739a0`, -/// registered at `0x63f9e0`), not a CVar, so it never persists. -fn remesh_on_cutout_change( +/// Drop the built meshes when anything BAKED INTO THE MATERIAL moves (a density write must not +/// re-mesh, since that re-scatters on its own): the alpha-test ref and the distance fade are both +/// part of the material key, so a built mesh keeps the old one until it is re-cut. +/// +/// The reference's `detailDoodadAlpha` is a console command (`0x6739a0`, registered at `0x63f9e0`), +/// not a CVar, so it never persists. GFX (foliage distance): `fade_far` IS a CVar +/// (`foliageDistance`), and it rides here for the same reason — `world_assets` keys the material on +/// `fade_far.round() as u16`, so without this a live change builds NEW chunks at the new horizon +/// while every chunk already standing keeps fading at the old one. +fn remesh_on_material_change( mut commands: Commands, cfg: Res, mut chunks: Query<&mut ClutterChunk>, - mut last: Local>, + mut last: Local>, ) { - let Some(prev) = last.replace(cfg.alpha_ref) else { + let now = (cfg.alpha_ref, cfg.fade_far); + let Some(prev) = last.replace(now) else { return; }; - if prev == cfg.alpha_ref { + if prev == now { return; } let mut n = 0; @@ -66,8 +73,9 @@ fn remesh_on_cutout_change( } } info!( - "clutter: detailDoodadAlpha {} — dropped {n} built mesh(es) to re-cut", - (cfg.alpha_ref * 255.0).round() as u32 + "clutter: detailDoodadAlpha {} / foliageDistance {:.0} yd — dropped {n} built mesh(es) to re-cut", + (cfg.alpha_ref * 255.0).round() as u32, + cfg.fade_far ); } @@ -123,7 +131,19 @@ pub(crate) const DETAIL_DOODAD_ALPHA_REF: f32 = 128.0 / 255.0; /// The reference's hardcoded detail-doodad draw distance, 70 yd (`[0x867958]`); clutter fades out /// over its last quarter. -pub(crate) const DETAIL_DOODAD_FADE_FAR: f32 = 70.0; +pub const DETAIL_DOODAD_FADE_FAR: f32 = 70.0; + +/// GFX (foliage distance): what `foliageDistance` may ask for. The floor IS the reference's own +/// horizon, because below it the setting would be undoing the client rather than extending it — +/// `WorldDetail`/`frillDensity` is the knob for wanting less grass. +/// +/// The ceiling is measured, not picked. On an Elwynn fence line (`fence-shadowline-day`, clutter +/// density 2), p50 frame time / main thread: **70 yd 3.25/3.26 ms, 140 yd 3.32/3.24, 200 yd +/// 3.48/3.38, 300 yd 3.69/3.42**, with the GPU flat the whole way. So doubling the horizon is free +/// and the cost starts after that, on the main thread — the budget this client is actually short +/// of. 300 is where it is still worth offering; past it the tufts are sub-pixel anyway (70 -> 140 +/// moves 0.57% of that frame, 70 -> 300 only 0.64%). +pub const FOLIAGE_DISTANCE_RANGE: std::ops::RangeInclusive = DETAIL_DOODAD_FADE_FAR..=300.0; /// Ground-clutter tunables. `density` scales the reference's 16 cells per chunk (`frillDensity`), /// and a change re-scatters loaded tiles, as the 1.12 setter rebuilds its chunks. Seeded from diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index bdaa24f3..bc5b109d 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -149,6 +149,107 @@ pub(super) const MAX_LIVE_POINT_LIGHTS: usize = MAX_POINT_LIGHTS - 1; /// compile time, where a test could only say it after the build that broke it. const _: () = assert!(MAX_LIVE_POINT_LIGHTS <= 255, "EXT_SEL_EMPTY is index 255"); +/// GFX (light fade): how many of the last admitted ranks dim out as the table fills. +/// +/// [`MAX_LIVE_POINT_LIGHTS`] is a HARD ceiling — the per-unit selection packs 8-bit indices and +/// 255 is its "no more" sentinel — so in a city with more fixtures than that, some must be left +/// out, and which ones changes as the camera moves. Measured in the Great Forge: **305 candidates +/// against the cap of 255**. Without this the 50 that do not fit blink on and off as you walk, +/// which is what it looks like: lighting "rendering in". +/// +/// So the cut is made soft instead of silent. The last ranks are scaled toward zero by distance +/// across the band they span, so a fixture about to be evicted is already dark when it goes, and +/// one arriving brightens in from nothing. 24 is about a tenth of the table: wide enough that each +/// rank's step is a few percent, narrow enough that it only ever touches the farthest few that +/// were already the dimmest on screen. +/// +/// **Nothing fades while the table fits.** Under capacity there is no boundary to be near, so +/// every scene that was not already losing lights renders exactly as it did — which is also what +/// makes the tier 0-2 style control possible here. +const LIGHT_FADE_RANKS: usize = 24; + +/// GFX (light fade): how long a light takes to reach full strength after it first appears in the +/// table, in seconds. +/// +/// The capacity cut above is only one of the ways a fixture can arrive: the portal walk reaching +/// its room, its WMO group streaming in, the camera crossing [`INTERIOR_NEAR_ADMIT`] or +/// [`POINT_PACK_RADIUS`] — and all of them are hard edges that switch a light on at full +/// brightness between one frame and the next. **Measured in Ironforge: walking 3 yd exchanged 2 +/// of 172 admitted lights, and 10 yd exchanged 8** — about one appearing per yard, which at a +/// walk is several a second. That is what "the lighting renders in" looks like. +/// +/// This ramps whatever arrives, whichever rule admitted it, so none of those edges has to be +/// found and softened one at a time. It is deliberately NOT symmetric: a light that leaves the +/// table still goes at once, because keeping a departed fixture alive long enough to dim means +/// keeping its room claim alive with it, and a stale claim gates a live light with a dead +/// building's identity. Arriving is also the half that was reported. +/// +/// 0.35 s is about three yards at a walk — long enough to read as brightening rather than +/// switching, short enough that a fixture is at full strength well before you reach it. +const LIGHT_FADE_IN_SECS: f32 = 0.35; + +/// Per-light ramp state: seconds each admitted light has been in the table, rebuilt every frame +/// from exactly the admitted set, so a fixture that leaves and comes back arrives dark again. +/// +/// `spare` is last frame's map, kept only so its allocation can be reused instead of handing the +/// allocator a 255-entry map every frame forever. `secs` carries the duration so the env read +/// happens once, at init, rather than on the hot path — and so a test that is asserting the +/// PACKING contract can set it to 0 and get the instant switch-on that contract was written +/// against, without reaching for a process-wide environment variable. +#[derive(Resource)] +pub struct LightFadeIn { + secs: f32, + ages: std::collections::HashMap, + spare: std::collections::HashMap, +} + +impl Default for LightFadeIn { + fn default() -> Self { + Self { + secs: light_fade_in_secs(), + ages: std::collections::HashMap::new(), + spare: std::collections::HashMap::new(), + } + } +} + +/// `WOW_LIGHT_FADE_IN=` overrides [`LIGHT_FADE_IN_SECS`]; `0` restores the instant switch-on +/// this replaced, which is the A/B. +fn light_fade_in_secs() -> f32 { + static S: std::sync::OnceLock = std::sync::OnceLock::new(); + *S.get_or_init(|| { + std::env::var("WOW_LIGHT_FADE_IN") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| v.is_finite() && *v >= 0.0) + .unwrap_or(LIGHT_FADE_IN_SECS) + }) +} + +/// `WOW_LIGHT_FADE_RANKS=` overrides [`LIGHT_FADE_RANKS`] for a measurement; `0` restores the +/// hard cut this replaced, which is the A/B. +fn light_fade_ranks() -> usize { + static N: std::sync::OnceLock = std::sync::OnceLock::new(); + *N.get_or_init(|| { + std::env::var("WOW_LIGHT_FADE_RANKS") + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(LIGHT_FADE_RANKS, |n| n.min(MAX_LIVE_POINT_LIGHTS)) + }) +} + +/// The weight the rank `from_cut` places inside the boundary is scaled by, given the band's own +/// width in ranks. 0 at the cut itself, 1 at the inside edge, smoothstepped between. +/// +/// Split out so the curve is testable without a world: the shape is the whole feature. +fn fade_weight(from_cut: f32, ranks: f32) -> f32 { + if ranks <= 0.0 { + return 1.0; + } + let t = (from_cut / ranks).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + /// MONKEY (dynamic interiors): a fixture this close to the camera (yd) is packed regardless of the /// portal flood — see the room term in [`build_light_data`]. Sized to a WHOLE BUILDING PLUS its /// approach, not just the room: at 40 yd, walking around the outside of the inn kept crossing the @@ -667,6 +768,8 @@ pub(super) fn register(app: &mut App) { app.init_resource::() // MONKEY (lampfog): CPU mirror of only the live point rows; not extracted or GPU-bound. .init_resource::() + // GFX (light fade): the per-light ramp state, advanced inside `build_light_data`. + .init_resource::() // MONKEY (room gate): the per-fixture room claims, packed beside the point table. .init_resource::() .init_resource::() @@ -1361,6 +1464,10 @@ fn build_light_data( // tuple so every positional destructuring below keeps its index — and read for one // thing only: WHICH live gain owns this row. Has, + // GFX (light fade): LAST on purpose — the ramp needs a stable identity per fixture and + // every destructuring below is positional, so the identity goes where it shifts none + // of them. + Entity, ), Without, >, @@ -1372,6 +1479,8 @@ fn build_light_data( // MONKEY (room gate): packed in the same walk as the point table, so the two can never // disagree about which entry is which. mut claims: ResMut, + // GFX (light fade): the per-light ramp, advanced and pruned once the admitted set is known. + mut fade_in: ResMut, time: Res