Two settings for things arriving: a foliage distance row, and a ramp for lights
**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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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 @@
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- MONKEY (ao): numeric tiers through the existing localised dropdown. -->
|
||||
<Frame name="$parentRowAmbientOcclusion" inherits="OptionsDropdownRowTemplate">
|
||||
<!-- GFX (foliage distance): the ground-clutter horizon, 70 yd (the
|
||||
reference's own) to 300. Deferred like Shadow Distance: a drag
|
||||
would re-cut every patch of grass already built on every tick. -->
|
||||
<Frame name="$parentRowFoliageDistance" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFoliageWindStrength" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFoliageWindStrength" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
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
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowAmbientOcclusion" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFoliageDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFoliageDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "ambientOcclusion", BENILLA_ADVGFX.ambientOcclusion,
|
||||
"BENILLA_TOOLTIP_AMBIENT_OCCLUSION")
|
||||
|
||||
@@ -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::<ClutterConfig>(&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::<ClutterConfig>(&app).fade_far, 140.0);
|
||||
apply(&mut app, "foliagedistance", "9000");
|
||||
assert_eq!(
|
||||
res::<ClutterConfig>(&app).fade_far,
|
||||
*benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.end()
|
||||
);
|
||||
apply(&mut app, "foliageDistance", "0");
|
||||
assert_eq!(
|
||||
res::<ClutterConfig>(&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::<ClutterConfig>(&app).density, 1.0 / 16.0);
|
||||
apply(&mut app, "WorldDetail", "1");
|
||||
|
||||
@@ -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::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
|
||||
.unwrap(),
|
||||
56
|
||||
57
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("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
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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<ClutterConfig>,
|
||||
mut chunks: Query<&mut ClutterChunk>,
|
||||
mut last: Local<Option<f32>>,
|
||||
mut last: Local<Option<(f32, f32)>>,
|
||||
) {
|
||||
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<f32> = 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
|
||||
|
||||
@@ -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<Entity, f32>,
|
||||
spare: std::collections::HashMap<Entity, f32>,
|
||||
}
|
||||
|
||||
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=<secs>` 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<f32> = std::sync::OnceLock::new();
|
||||
*S.get_or_init(|| {
|
||||
std::env::var("WOW_LIGHT_FADE_IN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite() && *v >= 0.0)
|
||||
.unwrap_or(LIGHT_FADE_IN_SECS)
|
||||
})
|
||||
}
|
||||
|
||||
/// `WOW_LIGHT_FADE_RANKS=<n>` 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<usize> = std::sync::OnceLock::new();
|
||||
*N.get_or_init(|| {
|
||||
std::env::var("WOW_LIGHT_FADE_RANKS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().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::<WowLightData>()
|
||||
// MONKEY (lampfog): CPU mirror of only the live point rows; not extracted or GPU-bound.
|
||||
.init_resource::<ResolvedPointLights>()
|
||||
// GFX (light fade): the per-light ramp state, advanced inside `build_light_data`.
|
||||
.init_resource::<LightFadeIn>()
|
||||
// MONKEY (room gate): the per-fixture room claims, packed beside the point table.
|
||||
.init_resource::<RoomClaimTable>()
|
||||
.init_resource::<WorldShadowActive>()
|
||||
@@ -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<SpellFxLight>,
|
||||
// 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<ShadowProxyLight>,
|
||||
>,
|
||||
@@ -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<RoomClaimTable>,
|
||||
// GFX (light fade): the per-light ramp, advanced and pruned once the admitted set is known.
|
||||
mut fade_in: ResMut<LightFadeIn>,
|
||||
time: Res<Time>,
|
||||
// The three realtime-shadow dials, bundled (see [`ShadowLanes`]).
|
||||
shadow: ShadowLanes,
|
||||
@@ -1513,9 +1622,20 @@ fn build_light_data(
|
||||
// filter below decides whether it is packed at all. Printed by `WOW_POINTS_DUMP` so an
|
||||
// EXTERIOR-lane entry that HAS claims is visible as such: the shader reads claims only on
|
||||
// the interior lane, so `claims 4` beside `EXT` is the readout of exactly that trade.
|
||||
let mut pts: Vec<(f32, Vec3, f32, [f32; 3], bool, f32, RoomClaim, usize)> = lights_q
|
||||
let mut pts: Vec<(
|
||||
f32,
|
||||
Vec3,
|
||||
f32,
|
||||
[f32; 3],
|
||||
bool,
|
||||
f32,
|
||||
RoomClaim,
|
||||
usize,
|
||||
Entity,
|
||||
bool,
|
||||
)> = lights_q
|
||||
.iter()
|
||||
.filter(|(_, gt, rooms, _, _, _, _, _, _, _)| {
|
||||
.filter(|(_, gt, rooms, _, _, _, _, _, _, _, _)| {
|
||||
// The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a
|
||||
// visibility test bolted onto a faithful gather: the reference's register walk has no
|
||||
// such term either, it simply never has a culled room's torch to register. Ungated for
|
||||
@@ -1534,7 +1654,19 @@ fn build_light_data(
|
||||
< INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT)
|
||||
})
|
||||
.filter_map(
|
||||
|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker, daylight, spell)| {
|
||||
|(
|
||||
pl,
|
||||
gt,
|
||||
rooms,
|
||||
synthetic,
|
||||
reach,
|
||||
lane_of,
|
||||
lit_rooms,
|
||||
flicker,
|
||||
daylight,
|
||||
spell,
|
||||
entity,
|
||||
)| {
|
||||
let p = gt.translation();
|
||||
let d2 = p.distance_squared(cam_pos);
|
||||
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
|
||||
@@ -1628,16 +1760,77 @@ fn build_light_data(
|
||||
RoomClaim::UNGATED
|
||||
};
|
||||
let lit_n = lit_rooms.map_or(0, |l| l.rooms.groups.len());
|
||||
(d2, p, pl.range, rgb, synthetic, lane, claim, lit_n)
|
||||
(
|
||||
d2, p, pl.range, rgb, synthetic, lane, claim, lit_n, entity, spell,
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// GFX (light fade): soften the cut BEFORE it is made, and only when there is one to soften.
|
||||
// The scale lands on the colour, so the GPU pack, the CPU mirror below and every lane that
|
||||
// reads either (the fog lamps, the froxel volume) all follow from this one write.
|
||||
//
|
||||
// The band is measured in RANKS but applied by DISTANCE across the span those ranks occupy, so
|
||||
// a fixture's weight moves smoothly as the camera does instead of stepping each time its rank
|
||||
// changes. Where the span is degenerate — a ring of fixtures all equidistant, which is exactly
|
||||
// what a round hall is — distance says nothing and the rank itself is the honest fallback.
|
||||
let ranks = light_fade_ranks();
|
||||
if ranks > 0 && pts.len() > MAX_LIVE_POINT_LIGHTS {
|
||||
let first = MAX_LIVE_POINT_LIGHTS.saturating_sub(ranks);
|
||||
let cut = pts[MAX_LIVE_POINT_LIGHTS - 1].0.sqrt();
|
||||
let band = cut - pts[first].0.sqrt();
|
||||
let span = (MAX_LIVE_POINT_LIGHTS - first) as f32;
|
||||
for (i, entry) in pts[first..MAX_LIVE_POINT_LIGHTS].iter_mut().enumerate() {
|
||||
let from_cut = if band > 1e-3 {
|
||||
(cut - entry.0.sqrt()) / band * span
|
||||
} else {
|
||||
(span - 1.0 - i as f32).max(0.0)
|
||||
};
|
||||
let w = fade_weight(from_cut, span);
|
||||
entry.3 = entry.3.map(|c| c * w);
|
||||
}
|
||||
}
|
||||
// MONKEY (ext light k8): 255, not 256 — index 255 is the shaders' `EXT_SEL_EMPTY` sentinel now
|
||||
// that a draw unit's selection packs eight 8-bit ranks. See [`MAX_LIVE_POINT_LIGHTS`]. The sort
|
||||
// above is nearest-camera-first, so the entry this drops is the farthest of the set.
|
||||
pts.truncate(MAX_LIVE_POINT_LIGHTS);
|
||||
// GFX (light fade): ramp in whatever has just arrived, whichever rule admitted it. Advanced
|
||||
// here, after the truncate, so the ramp only ever runs for lights that are actually packed —
|
||||
// a fixture the cut dropped is not "fading", it is absent, and must start dark when it
|
||||
// returns. The map is then pruned to exactly this set, which is what makes that true.
|
||||
let fade_secs = fade_in.secs;
|
||||
if fade_secs > 0.0 {
|
||||
let dt = time.delta_secs();
|
||||
// Rebuilt rather than retained: pruning in place means asking "is this one still admitted"
|
||||
// once per remembered light, which is quadratic over a 255-entry table on the thread this
|
||||
// client has least of. Reading each admitted light's own age out of last frame's map is
|
||||
// the same answer in one pass, and the ones not read are the ones that left.
|
||||
let mut next = std::mem::take(&mut fade_in.spare);
|
||||
next.clear();
|
||||
next.reserve(pts.len());
|
||||
for entry in pts.iter_mut() {
|
||||
// A SPELL light is exempt, and it is the one exemption that matters: those are not
|
||||
// fixtures arriving, they are flashes the player asked for. A fireball's light lives
|
||||
// about as long as this ramp does, so ramping it would spend the whole effect at half
|
||||
// strength and the cast would read as mushy rather than as smooth.
|
||||
if entry.9 {
|
||||
continue;
|
||||
}
|
||||
let age = (fade_in.ages.get(&entry.8).copied().unwrap_or(0.0) + dt).min(fade_secs);
|
||||
next.insert(entry.8, age);
|
||||
let t = (age / fade_secs).clamp(0.0, 1.0);
|
||||
let w = t * t * (3.0 - 2.0 * t);
|
||||
if w < 1.0 {
|
||||
entry.3 = entry.3.map(|c| c * w);
|
||||
}
|
||||
}
|
||||
fade_in.spare = std::mem::replace(&mut fade_in.ages, next);
|
||||
} else if !fade_in.ages.is_empty() {
|
||||
fade_in.ages.clear();
|
||||
fade_in.spare.clear();
|
||||
}
|
||||
// `.x` = the live entry count; MONKEY (dynamic interiors): `.yzw` = the interior lane's live
|
||||
// knobs (base ambient, per-fixture fill gain, exposure) — free lanes until now.
|
||||
// MONKEY (darkness gains): `interiorGain` scales two of the room lane's three inputs here (the
|
||||
@@ -1652,7 +1845,7 @@ fn build_light_data(
|
||||
dynamic_interiors.fill * dynamic_interiors.interior_gain,
|
||||
dynamic_interiors.exposure,
|
||||
];
|
||||
for (i, (_, p, range, rgb, _, lane, claim, _)) in pts.iter().enumerate() {
|
||||
for (i, (_, p, range, rgb, _, lane, claim, _, _, _)) in pts.iter().enumerate() {
|
||||
fresh.points[2 * i] = [p.x, p.y, p.z, *range];
|
||||
fresh.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], *lane];
|
||||
claim.write(&mut claims.0[i * ROOM_CLAIM_STRIDE..][..ROOM_CLAIM_STRIDE]);
|
||||
@@ -1661,12 +1854,14 @@ fn build_light_data(
|
||||
// after the 255-entry truncate, so a CPU post effect can never see a light the shaders cannot.
|
||||
let next_points: Vec<_> = pts
|
||||
.iter()
|
||||
.map(|(_, p, range, rgb, _, lane, _, _)| ResolvedPointLight {
|
||||
position: *p,
|
||||
range: *range,
|
||||
color: Vec3::from_array(*rgb),
|
||||
lane: *lane,
|
||||
})
|
||||
.map(
|
||||
|(_, p, range, rgb, _, lane, _, _, _, _)| ResolvedPointLight {
|
||||
position: *p,
|
||||
range: *range,
|
||||
color: Vec3::from_array(*rgb),
|
||||
lane: *lane,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
if resolved_points.0 != next_points {
|
||||
resolved_points.0 = next_points;
|
||||
@@ -1697,7 +1892,7 @@ fn build_light_data(
|
||||
let snap = |v: f32| (((half + v) / cell).floor() + 0.5) * cell - half;
|
||||
let anchor = Vec3::new(snap(cam_pos.x), cam_pos.y, snap(cam_pos.z));
|
||||
let (mut boxed, mut sphere) = (0usize, 0usize);
|
||||
for (_, p, _, _, _, _, _, _) in &pts {
|
||||
for (_, p, _, _, _, _, _, _, _, _) in &pts {
|
||||
let dv = *p - anchor;
|
||||
boxed += usize::from(dv.x.abs().max(dv.z.abs()) <= 33.570_166);
|
||||
sphere += usize::from(dv.length() <= 48.0);
|
||||
@@ -1705,7 +1900,7 @@ fn build_light_data(
|
||||
// MONKEY (fire GO lights): how many of the packed entries we INVENTED. The whole
|
||||
// feature is a heuristic over content, so "the inn is too bright" has to be separable
|
||||
// into "too many synthetic sources" and "the authored ones changed" without a rebuild.
|
||||
let synth = pts.iter().filter(|(.., s, _, _, _)| *s).count();
|
||||
let synth = pts.iter().filter(|(.., s, _, _, _, _, _)| *s).count();
|
||||
// MONKEY (flame flicker): how many of the nearby sources BURN — the census that answers
|
||||
// "did the route rule file this room's fixtures as flames at all?" without a rebuild.
|
||||
// Counted over the same radius the pack gathers from rather than off `pts`, so it does
|
||||
@@ -1721,7 +1916,10 @@ fn build_light_data(
|
||||
// MONKEY (light lanes): how the table splits between the two now-disjoint consumer
|
||||
// families. "The inn's candles are lighting the lawn" is an INT count on a row the
|
||||
// exterior lane should never have seen — a number, printed per row below as INT/EXT.
|
||||
let interior = pts.iter().filter(|(.., lane, _, _)| *lane > 0.5).count();
|
||||
let interior = pts
|
||||
.iter()
|
||||
.filter(|(.., lane, _, _, _, _)| *lane > 0.5)
|
||||
.count();
|
||||
eprintln!(
|
||||
"[points] {} packed ({interior} INT / {} EXT, {synth} synthetic, {flames} flickering, gain {:.2}, atten x{:.2}, flicker x{:.2}, night x{:.2}, interior x{:.2}, spell x{:.2}), cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
|
||||
pts.len(),
|
||||
@@ -1739,7 +1937,7 @@ fn build_light_data(
|
||||
// one number away, and the two causes look identical on screen.
|
||||
spell_gain.0,
|
||||
);
|
||||
for (d2, p, _, rgb, synthetic, lane, claim, lit_n) in pts.iter().take(8) {
|
||||
for (d2, p, _, rgb, synthetic, lane, claim, lit_n, _, _) in pts.iter().take(8) {
|
||||
eprintln!(
|
||||
" d {:6.2} at [{:8.2},{:7.2},{:8.2}] rgb [{:.3},{:.3},{:.3}] {}{}",
|
||||
d2.sqrt(),
|
||||
@@ -2173,6 +2371,13 @@ mod tests {
|
||||
/// so a fourth param can't silently red the same way twice.
|
||||
fn packer_app() -> App {
|
||||
let mut app = App::new();
|
||||
// GFX (light fade): these tests spin ONE frame and read the packed colour, and an arriving
|
||||
// light is dark on its first frame by design. The ramp has its own tests; here it is the
|
||||
// instant switch-on the packing contract was written against.
|
||||
app.insert_resource(LightFadeIn {
|
||||
secs: 0.0,
|
||||
..Default::default()
|
||||
});
|
||||
app.init_resource::<WowLighting>()
|
||||
.init_resource::<crate::dev_state::DebugState>()
|
||||
.init_resource::<crate::view::ViewDistance>()
|
||||
@@ -2879,6 +3084,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// GFX (light fade): the curve's two ends are the promise — a fixture at the cut is dark, one
|
||||
/// a full band inside is untouched — and it never leaves `[0, 1]` in between, because the
|
||||
/// weight multiplies a light's colour and anything outside that range would either invent
|
||||
/// light or negate it.
|
||||
#[test]
|
||||
fn the_boundary_fade_runs_from_dark_at_the_cut_to_untouched_inside_it() {
|
||||
let span = LIGHT_FADE_RANKS as f32;
|
||||
assert_eq!(
|
||||
fade_weight(0.0, span),
|
||||
0.0,
|
||||
"the last admitted rank is dark"
|
||||
);
|
||||
assert_eq!(
|
||||
fade_weight(span, span),
|
||||
1.0,
|
||||
"a full band inside is untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
fade_weight(span * 2.0, span),
|
||||
1.0,
|
||||
"and so is anything deeper"
|
||||
);
|
||||
let mut last = -1.0;
|
||||
for step in 0..=(LIGHT_FADE_RANKS * 4) {
|
||||
let w = fade_weight(step as f32 / 4.0, span);
|
||||
assert!((0.0..=1.0).contains(&w), "{w} is not a weight");
|
||||
assert!(w >= last, "the fade has to be monotone: {w} after {last}");
|
||||
last = w;
|
||||
}
|
||||
// A zero-width band is the degenerate ring; it must not divide by it.
|
||||
assert_eq!(fade_weight(0.0, 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// GFX (light fade): the ramp's shape is the feature, so pin its ends and its monotonicity the
|
||||
/// same way the boundary fade's is. `fade_weight` is the shared curve: age/duration in, weight
|
||||
/// out, which is why one test covers both lanes.
|
||||
#[test]
|
||||
fn the_arrival_ramp_starts_dark_reaches_full_and_never_overshoots() {
|
||||
let secs = LIGHT_FADE_IN_SECS;
|
||||
assert!(
|
||||
secs > 0.0,
|
||||
"a zero ramp is the instant switch-on this replaced"
|
||||
);
|
||||
assert_eq!(fade_weight(0.0, secs), 0.0, "a light arrives dark");
|
||||
assert_eq!(fade_weight(secs, secs), 1.0, "and is whole by the end");
|
||||
assert_eq!(fade_weight(secs * 10.0, secs), 1.0, "and stays there");
|
||||
let mut last = -1.0;
|
||||
for step in 0..=40 {
|
||||
let w = fade_weight(secs * step as f32 / 20.0, secs);
|
||||
assert!((0.0..=1.0).contains(&w), "{w} is not a weight");
|
||||
assert!(w >= last, "the ramp has to be monotone");
|
||||
last = w;
|
||||
}
|
||||
// Half way is half lit, not a quarter or three quarters: a symmetric curve, so the ramp
|
||||
// reads as even rather than easing hard at one end.
|
||||
assert!((fade_weight(secs * 0.5, secs) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The knob exists so the hard cut can be measured against the fade; `0` has to BE that cut.
|
||||
#[test]
|
||||
fn the_fade_knob_can_restore_the_hard_cut() {
|
||||
const { assert!(LIGHT_FADE_RANKS > 0 && LIGHT_FADE_RANKS < MAX_LIVE_POINT_LIGHTS) };
|
||||
// The guard in `build_light_data` is `ranks > 0`, so 0 skips the whole block.
|
||||
assert_eq!(
|
||||
fade_weight(0.0, 0.0),
|
||||
1.0,
|
||||
"no band is no fade, not a black light"
|
||||
);
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix): the shader mirrors use literal storage-array lengths and water reads
|
||||
/// the shared buffer as raw rows, so pin those literals to the Rust layout constants.
|
||||
#[test]
|
||||
|
||||
+121
-4
@@ -485,11 +485,61 @@ reason this file's determinism table now needs a caveat: **it screens as determi
|
||||
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
|
||||
### 6. Runtime probe GI — **measured; it is a smaller and more specific job than this said**
|
||||
|
||||
A toroidal voxel clipmap of the static world plus cascaded probe grids traced in compute, order-1 SH
|
||||
with hysteresis. The biggest lighting change available and the most work; it belongs after the five
|
||||
above, not before.
|
||||
This section used to read "a toroidal voxel clipmap of the static world plus cascaded probe grids
|
||||
traced in compute, order-1 SH with hysteresis". That is the textbook answer to a question this
|
||||
renderer is not asking. Before building any of it, two things were measured.
|
||||
|
||||
**First: almost all of the consumer side already exists.** `lighting::sh::prop_probe_coeffs` folds
|
||||
the reference's own `Model2.bls` accumulators into a 7-row order-2 SH probe;
|
||||
`lighting::prop_probes` keeps an 8192-slot table with a sharing allocator and a despawn hook;
|
||||
**both** `static_gx.wgsl` (terrain and WMO) and `wow_model.wgsl` (M2s, props, characters) already
|
||||
bind that table and already evaluate the basis per fragment. Three call sites already fold probes
|
||||
at runtime (`terrain_stream::spawn`, `interior.rs` twice). What is missing is not the SH, the
|
||||
upload, the table or the evaluation — it is **where the rows come from**: they are folded from
|
||||
baked MODD/MOCV colour and addressed per prop, not traced and addressed by position.
|
||||
|
||||
**Second, and this is the part that redirects the work: the gap is indoors, it is large, and we
|
||||
made it ourselves.** `interiorLight` 1 is our fixture-lit WMO interior; `interiorLight` 0 is the
|
||||
reference's baked interior path. The rig replaced the reference's baked lighting — whose vertex
|
||||
colour carries the artist's implied bounce — with physical DIRECT light from the fixtures, and
|
||||
nothing ever put the bounce back. Measured at noon, Goldshire inn, gamma medians:
|
||||
|
||||
| view | our rig (`interiorLight` 1) | reference baked (0) |
|
||||
|---|---|---|
|
||||
| common room | **31** | 56 |
|
||||
| storeroom, facing the hearth | **12** | 58 |
|
||||
| storeroom, facing away from it | **8** | 33 |
|
||||
|
||||
Two to five times darker, consistently. In the storeroom the WALLS read about right and the
|
||||
**props go black** — hanging meat, barrels and crates reduced to silhouettes — because a WMO wall
|
||||
gets the rig's per-fragment fixture light and an M2 prop gets only its probe.
|
||||
|
||||
**The existing bounce knob cannot close it.** `interiorFill` is one scalar, a half-desaturated mix
|
||||
of the fixture colour applied uniformly. Swept on the storeroom against the baked target of 58:
|
||||
0.08 (shipped) → 12, 0.3 → 17, 0.8 → 25, **2.0 (the maximum) → 46**. It nearly reaches the
|
||||
brightness and lands the hue wrong — at 2.0 the mean is (66, 46, 30) against the reference's
|
||||
(92, 58, 29), a red channel 26 short while blue is already over. A scalar cannot carry a direction
|
||||
or a colour. **A 7-row SH probe is exactly the thing that can**, which is why this lane is the
|
||||
answer rather than a bigger `interiorFill`.
|
||||
|
||||
**And SSILVB cannot cover for it, because it is screen-space.** Same room, same eye, two facings:
|
||||
with the hearth on screen the indirect lane moves **13.85% of the frame** (max 44/255); with the
|
||||
same hearth two yards BEHIND the camera it moves **0.00% of pixels, max 2**. Facing-dependent
|
||||
light is not a dimmer version of GI, it is a different room depending on where you look.
|
||||
|
||||
So the job here is **not** a voxel clipmap of the static world. It is: fold a bounce term into the
|
||||
probe rows the shaders already read, from the lights we already resolve and the surfaces the WMO
|
||||
already tells us about, and calibrate it against the reference's baked interior the same way
|
||||
`daylight::daylight_intensity` already calibrates the threshold — by solving for the value that
|
||||
matches the reference's luminance at a surface both laws render. The exterior case (sunlit ground
|
||||
bouncing onto a shaded wall) is the second increment, and it is the one that would want traced
|
||||
visibility; the interior case needs only what is already in memory.
|
||||
|
||||
The calibration target is a judgement call and belongs to whoever plays it: matching the baked
|
||||
path exactly restores the reference's brightness, and anything short of it keeps some of the rig's
|
||||
contrast. That decision should be made against a screenshot, not derived.
|
||||
|
||||
### 7. Upscaling (FSR) — **not now**
|
||||
|
||||
@@ -609,6 +659,73 @@ do not reproduce. Its before/after delta is 0.00% of pixels at a maximum of 1
|
||||
but a scenario that cannot reproduce itself cannot be a control, and the number to check first is
|
||||
always the baseline against itself.
|
||||
|
||||
### 10. Foliage distance — **built**
|
||||
|
||||
`benilla_world::clutter`, cvar `foliageDistance`, Foliage Distance on the Advanced Graphics page.
|
||||
|
||||
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 that ring; it does not change what grass looks like.
|
||||
|
||||
Cost, measured on an Elwynn fence line (`fence-shadowline-day`, clutter density 2), p50 frame time
|
||||
and main thread:
|
||||
|
||||
| horizon | frame | main | GPU |
|
||||
|---|---|---|---|
|
||||
| **70 yd** (the registered default, the reference's own) | 3.25 ms | 3.26 ms | 1.13 ms |
|
||||
| 140 yd | 3.32 | 3.24 | 1.10 |
|
||||
| 200 yd | 3.48 | 3.38 | 1.13 |
|
||||
| 300 yd | 3.69 | 3.42 | 1.12 |
|
||||
|
||||
**Doubling it is free** and the GPU never moves; past 140 yd it costs main thread, which is the
|
||||
budget this client is short of. What it buys in a STILL is small — 70 -> 140 moves 0.57% of that
|
||||
frame and 70 -> 300 only 0.64%, because the far tufts are sub-pixel — and that is the wrong
|
||||
measurement for it: the complaint is grass *arriving*, which a static capture cannot show at all.
|
||||
The row exists so the ring can be pushed out to where the arrivals are too small to notice.
|
||||
|
||||
It commits on **Apply**, not on the drag: `fade_far` is baked into the clutter material key, so a
|
||||
change drops every built patch to be re-cut (measured: **338 meshes** on one write), and doing that
|
||||
on every tick of a drag would re-cut the world thirty times.
|
||||
|
||||
The default is the reference's own 70 and it is in **no preset ladder**, deliberately — it trades
|
||||
main thread for grass, which is a judgement about a machine rather than a quality tier.
|
||||
|
||||
**A trap worth keeping**, which cost two rounds of measurement here: captures force
|
||||
`WOW_CLUTTER_DENSITY=0` (`lib.rs`, clutter scatters per-run and would break byte-stable baselines),
|
||||
so a run that sets `WOW_CLUTTER_FADE` **without also setting the density** measures a scene with no
|
||||
grass in it and reports the horizon as free at every value. The starved control — clutter off
|
||||
against clutter on — is what catches it.
|
||||
|
||||
### 11. Light arrival fade — **built**
|
||||
|
||||
`lighting::global_light`, no setting: it is a correction, not a taste.
|
||||
|
||||
A fixture can enter 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` (90 yd) or
|
||||
`POINT_PACK_RADIUS` (300 yd), or 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 was at 172 of 255 throughout, so this is admission churn and
|
||||
**not** the capacity cap; a count sampled across five vantages ran 110–196 and never reached it.
|
||||
(The cap does bind transiently during a world load — 305 candidates were seen once — which is why
|
||||
the boundary fade below is kept.)
|
||||
|
||||
So arrivals ramp over **0.35 s**, about three yards at a walk, 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. It is 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.
|
||||
|
||||
Beside it, the capacity cut itself is softened: the last 24 admitted ranks scale toward zero across
|
||||
the distance band they span, so a fixture the cut is about to drop is already dark when it goes.
|
||||
**Nothing fades while the table fits**, which is what makes the control possible.
|
||||
|
||||
Control: with the ramp on against off, the Goldshire inn settles **byte-identical**, and the Great
|
||||
Forge to **max 3/255 on 0.008% of pixels** — the residue being lights still arriving there even at a
|
||||
standstill, which is the symptom restating itself.
|
||||
|
||||
## What is not in scope
|
||||
|
||||
`benilla-bourbon` implements most of §2–§7 already, on **Bevy 0.19.1** with a vendored wgpu 29 and a
|
||||
|
||||
Reference in New Issue
Block a user