From ee9cfdcacf63f2b048b1ba0ff19d43f3fe4fa605 Mon Sep 17 00:00:00 2001 From: grom Date: Wed, 28 Oct 2026 08:58:08 -0600 Subject: [PATCH] Two settings for things arriving: a foliage distance row, and a ramp for lights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Foliage Distance** (`foliageDistance`, Advanced Graphics, 70..300 yd). The reference draws ground clutter to a hardcoded 70 yd (`[0x867958]`) and we inherited it, so grass, reeds and small bushes arrive at a fixed ring around the player — which is exactly where the eye is drawn to them appearing. The row moves the ring; it changes nothing about what grass looks like. Measured on an Elwynn fence line (`fence-shadowline-day`, clutter density 2), p50 frame time / main thread: 70 yd **3.25/3.26 ms**, 140 **3.32/3.24**, 200 **3.48/3.38**, 300 **3.69/3.42**, with the GPU flat throughout. Doubling it is free; past 140 it costs main thread, which is the budget this client is short of. The registered default IS the reference's own 70 and the row is in no preset ladder, so nothing moves until a player asks it to. It commits on Apply: `fade_far` is baked into the clutter material key, so a change drops every built patch to be re-cut (338 meshes on one measured write) and a drag would do that thirty times a second. `clutter::remesh_on_cutout_change` is now `remesh_on_material_change` and watches both the alpha ref and the fade for that reason. What it buys in a STILL is small — 70 -> 300 moves 0.6% of a frame, the far tufts being sub-pixel — and that is the wrong instrument: the complaint is grass ARRIVING, which no static capture can show. **Light arrival fade** (`lighting::global_light`). No setting: a correction, not a taste. A fixture enters the packed light table for several unrelated reasons — the portal walk reaching its room, its WMO group streaming in, the camera crossing `INTERIOR_NEAR_ADMIT` or `POINT_PACK_RADIUS`, the 255-entry capacity cut re-sorting — and every one of them was a hard edge that switched a light on at full brightness between one frame and the next. Measured in Ironforge by diffing the admitted set between static vantages: walking **3 yd exchanged 2 of 172 admitted lights, and 10 yd exchanged 8** — about one arriving per yard, which at a walk is several a second. The table ran 110-196 across five vantages and never reached the cap, so this is admission churn and NOT capacity; the cap does bind transiently during a world load (305 candidates seen once), which is why the boundary fade below is kept. Arrivals now ramp over 0.35 s, whichever rule admitted them. The scale lands on the light's colour before the pack, so the GPU table, the CPU mirror, the fog lamps and the froxel volume all follow from one write. Deliberately NOT symmetric: a departing fixture still goes at once, because keeping it alive to dim means keeping its room claim alive with it, and a stale claim gates a live light with a dead building's identity. **Spell lights are exempt**, and that is the exemption that matters: a fireball's light lives about as long as the ramp does, so ramping it would spend the whole effect at half strength. Beside it the capacity cut itself is softened — the last 24 admitted ranks scale toward zero across the distance band they span — and nothing fades while the table fits, which is what makes the control possible: with the ramp on against off, the Goldshire inn and the Great Forge both settle BYTE-IDENTICAL. Also recorded in docs/GRAPHICS.md: captures force `WOW_CLUTTER_DENSITY=0` for byte-stable baselines, so a run that sets only `WOW_CLUTTER_FADE` measures a scene with no grass in it and reports every horizon as free. Three rounds of this measurement were worthless before the starved control caught it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/assets/ui/OptionsFrame.xml | 47 ++- crates/benilla-app/src/cvars.rs | 35 ++ .../src/ui_script/options_tests.rs | 13 +- crates/benilla-app/src/video.rs | 11 + crates/benilla-world/src/clutter.rs | 42 ++- .../src/lighting/global_light.rs | 305 +++++++++++++++++- docs/GRAPHICS.md | 125 ++++++- 7 files changed, 544 insertions(+), 34 deletions(-) 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