Merge graphics-programme (review fixes, polish, perf) into main
This commit is contained in:
+25
-1
@@ -31,13 +31,18 @@ behind each constant.
|
||||
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
|
||||
| Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` |
|
||||
| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` |
|
||||
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets |
|
||||
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND` (dev build + `WOW_CAPTURE` only); field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets |
|
||||
| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
|
||||
| Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` |
|
||||
| City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` |
|
||||
| Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` |
|
||||
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
|
||||
|
||||
`zoneSkyboxes = 0` disables only the added living-player zone slot. WMO and ghost skyboxes are
|
||||
the reference 1.12 slots, so their fidelity fixes remain active in Classic: authored colour and
|
||||
alpha tracks affect the layer, non-white M2 colours reach the mesh, deterministic captures pose at
|
||||
their pinned time, and the batch-order base reserves the lower celestial band and final fog cone.
|
||||
|
||||
Players reach all of it from **Options -> Advanced Graphics** (a Graphics Preset over everything, a Render Distance slider, a Lighting Quality preset Off / Low / Medium / High / Ultra plus the individual rows; Classic / Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
|
||||
Bright presets.
|
||||
|
||||
@@ -76,6 +81,16 @@ and High volumetric fog. No rung decides `shadowDistance` or `interiorShadowSoft
|
||||
column at startup (`Cvars::seed_graphics_preset`) on every row the file does not carry; the rows'
|
||||
registered defaults stay their per-lane ones (`farclip` the reference's 350), and captures and
|
||||
tests, which never seed, keep them. The Advanced page's Defaults button lands on High too.
|
||||
The seeded boot value that leaves the reference (`farclip` 777 against 350) is declared in
|
||||
`cvars::SEEDED_DEVIATIONS`, and a test walks the whole High column against the reference rows.
|
||||
Session-owned rows (env levers such as `WOW_FARCLIP`) are skipped when both labels are derived, so
|
||||
an env override never persists `Custom` (MONKEY (reviewfix-a)).
|
||||
|
||||
**Capture fixtures** (MONKEY (reviewfix-a)). A capture reads CVars only from
|
||||
`WOW_CAPTURE_CVARS=<config.toml path>` (read-only; nothing is saved), never from `BENILLA_HOME`.
|
||||
`WOW_FOLIAGE_WIND` is honoured only in a dev build under `WOW_CAPTURE`. A capture has no avatar,
|
||||
so zone grading reads `terrain_stream::CaptureCameraArea` (the area under the camera, written only
|
||||
under `WOW_CAPTURE`) and the grade's day/night weight follows the rendered `GameClock` minute.
|
||||
|
||||
**Saved presets and new rows** (MONKEY (followups)). On load, `Cvars::reapply_saved_presets`
|
||||
writes the saved `graphicsQuality` rung (and the saved `lightingQuality` rung, else the Graphics
|
||||
@@ -163,6 +178,8 @@ is its own module, documented in `WATER.md`.
|
||||
by `cargo check`.
|
||||
- Exterior point lights are selected per draw unit (12 slots, ranked against the chunk's box); the
|
||||
cube-map occlusion is evaluated for the nearest three.
|
||||
- Portal bleed fixtures (`update_bleed_fixtures`, `daylight.rs`) are re-evaluated at 10 Hz, not per
|
||||
frame; a new doorway or a dial change runs it at once. Sources are bucketed per placement.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -173,6 +190,13 @@ cargo build --release -p benilla
|
||||
Debug tracing: `WOW_TORCH_TRACE=1`, `WOW_POINTS_DUMP=1`, `WOW_SHADOW_TRACE=1`, and the cvar
|
||||
`interiorDebug` 1..4.
|
||||
|
||||
Performance: measure a `play`/release build, never `dev`. `WOW_CAPTURE=<scenario>
|
||||
WOW_FPS_PROBE=600 WOW_FPS_JOURNAL=<csv> WOW_GPU_PASSES=1 WOW_WIN=2560x1440` prints frame, CPU and
|
||||
per-pass GPU times; `BENILLA_HOME=<dir>` supplies the `config.toml` (a capture never seeds the
|
||||
Graphics Preset, so write the rows out). `WOW_PERF_CROWD=<n>` stands n players at the look point.
|
||||
Night fixtures: `perf-northshire-night`, `perf-abbey-night`. Per-system CPU: build with
|
||||
`--features benilla-app/trace_chrome`.
|
||||
|
||||
## Licence
|
||||
|
||||
Same as upstream: MIT OR Apache-2.0. Code and techniques taken from other projects (WarcraftXL,
|
||||
|
||||
+2
-1
@@ -31,8 +31,9 @@ where they stand. Do not remove either.
|
||||
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (beach surf, `on_bed`) | `wxl-experimental-water` | `sea/Shore.hpp`, `shaders/Shore.hlsli` | Shore surf limited by the terrain column, so objects standing in the water never foam (principle) |
|
||||
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (Gerstner crests, High) + `crates/benilla-world/src/liquid/waves.rs` | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | Gerstner displacement and fold-driven crest whitecaps; the CPU swimmer sample inverts the same displacement (ported) |
|
||||
| `crates/benilla-world/src/water_fx/bob.rs` | `wxl-experimental-water` | `world/Ride.cpp` | Visual-only wave riding for swimmers; the authoritative position never moves (principle, implemented independently) |
|
||||
| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.*`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported from the GPL-3.0-or-later source implementation, Copyright (C) 2026 WarcraftXL) |
|
||||
| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.cpp`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported; used with the author's permission, attribution required) |
|
||||
| `crates/benilla-world/src/shaders/sky_fx.wgsl` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | Domain-warped billow fbm (quintic value noise, 0.6 cotton blend, 0.55 octave gain) for the cloud detail |
|
||||
| `crates/benilla-world/src/sky_fx.rs` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | The High cloud-detail tier and the sun-march inputs it feeds the sky and cloud materials (ported) |
|
||||
| `crates/benilla-world/src/shaders/cloud.wgsl` | `wxl-retail-clouds` | `Clouds.cpp` | The 3-tap march toward the sun (self-shadow, ambient floor) and the one-tap silver lining |
|
||||
| `crates/benilla-world/src/clouds/layer.rs` (`update_cloud_fx`) | `wxl-retail-clouds` | `Clouds.cpp` | March direction = the sun projected on the sheet, strength folded by its flatness (noon clouds evenly lit) |
|
||||
| `crates/benilla-world/src/wind/mod.rs` | `wxl-experimental-wind` | `field/Wind.hpp`, `field/Wind.cpp` | Stateless three-sine gust and veer field, default profile, and weather gain (ported) |
|
||||
|
||||
@@ -1834,3 +1834,76 @@ fn seed_equipped_bags(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (perf): `WOW_PERF_CROWD=<n>` stands `n` naked human players (display 49) in a grid
|
||||
/// around the scenario's look point, on the ground — a start zone's crowd for the FPS probe, so the
|
||||
/// per-unit costs (animation, the shadow lanes' CPU skinning) are in the measurement. Off unset.
|
||||
pub(super) fn seed_perf_crowd(
|
||||
mut commands: Commands,
|
||||
ctx: Res<CaptureCtx>,
|
||||
progress: Res<WorldLoadProgress>,
|
||||
spatial: avian3d::prelude::SpatialQuery,
|
||||
mut seeded: Local<bool>,
|
||||
) {
|
||||
let Some(n) = std::env::var("WOW_PERF_CROWD").ok().and_then(|v| v.parse::<u32>().ok()) else {
|
||||
return;
|
||||
};
|
||||
let Some(scenario) = ctx.scenario else {
|
||||
return;
|
||||
};
|
||||
if *seeded || !(progress.total > 0 && progress.ready == progress.total) {
|
||||
return;
|
||||
}
|
||||
*seeded = true;
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
let per_row = 6u32;
|
||||
for i in 0..n {
|
||||
let (row, col) = ((i / per_row) as f32, (i % per_row) as f32);
|
||||
let wow = [
|
||||
scenario.look[0] + (row - 1.0) * 2.5,
|
||||
scenario.look[1] + (col - per_row as f32 * 0.5) * 2.5,
|
||||
scenario.look[2] + 40.0,
|
||||
];
|
||||
let mut pos = wow_to_bevy(wow);
|
||||
if let Some(hit) = spatial.cast_ray(
|
||||
pos,
|
||||
Dir3::NEG_Y,
|
||||
200.0,
|
||||
true,
|
||||
&benilla_world::collision::WorldCollision::body_filter(),
|
||||
) {
|
||||
pos.y -= hit.distance;
|
||||
}
|
||||
commands.spawn((
|
||||
crate::net::Guid(0x5100 + u64::from(i)),
|
||||
crate::net::NetEntity {
|
||||
kind: benilla_protocol::EntityKind::Player,
|
||||
display_id: Some(49),
|
||||
scale: 1.0,
|
||||
},
|
||||
crate::net::ObjectStore(ObjectFields::from_pairs(&[
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
(34, 12),
|
||||
(35, 1),
|
||||
(36, 1 | 1 << 8),
|
||||
])),
|
||||
crate::entities::Equipment {
|
||||
settled: true,
|
||||
..default()
|
||||
},
|
||||
benilla_world::world_unit::WorldUnit {
|
||||
wades: true,
|
||||
scale: 1.0,
|
||||
height: crate::entities::CollisionHeight::default().0,
|
||||
bound: None,
|
||||
},
|
||||
Transform {
|
||||
translation: pos,
|
||||
rotation: Quat::from_rotation_y(i as f32 * 0.7),
|
||||
..default()
|
||||
},
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ mod probes;
|
||||
mod scenarios;
|
||||
use crate::run_mode::CaptureMode;
|
||||
pub(crate) use depth_probe::DepthProbePlugin;
|
||||
use fixtures::{seed_ui_fixture, seed_wind_player};
|
||||
use fixtures::{seed_perf_crowd, seed_ui_fixture, seed_wind_player};
|
||||
pub(crate) use live_shot::LiveShotPlugin;
|
||||
pub(crate) use phase_probe::PhaseProbePlugin;
|
||||
pub(crate) use pick_probe::PickProbePlugin;
|
||||
@@ -885,6 +885,14 @@ impl Plugin for CapturePlugin {
|
||||
// constant 16.67 ms for any scene, however slow.
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT))
|
||||
.add_systems(Startup, hold_clock);
|
||||
// MONKEY (perf): a probe window opens unfocused, and `WinitSettings::game()` runs an
|
||||
// unfocused window at reactive 1/60 s — every probe read 16.7 ms whatever the scene cost.
|
||||
if probe_frames > 0 {
|
||||
app.insert_resource(bevy::winit::WinitSettings {
|
||||
focused_mode: bevy::winit::UpdateMode::Continuous,
|
||||
unfocused_mode: bevy::winit::UpdateMode::Continuous,
|
||||
});
|
||||
}
|
||||
app.insert_resource(CaptureMode)
|
||||
.init_resource::<FrameWatch>()
|
||||
.insert_resource(CaptureCtx {
|
||||
@@ -905,6 +913,8 @@ impl Plugin for CapturePlugin {
|
||||
})
|
||||
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
|
||||
.add_systems(Update, seed_wind_player)
|
||||
// MONKEY (perf): the probe crowd (`WOW_PERF_CROWD`).
|
||||
.add_systems(Update, seed_perf_crowd)
|
||||
// Before the UnitFeed pass: the seed stands in for wire data that in live play
|
||||
// filled the app caches on EARLIER frames, so the same frame's feeds (item-template
|
||||
// / player-req pushes, then the merchant paint) must all see it. Unordered, the
|
||||
|
||||
@@ -1779,6 +1779,35 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
// MONKEY (integration): the Westfall sea from 30 yd up, aimed ~72 yd out, so the enhanced
|
||||
// water's 64-80 yd fine/coarse LOD ring crosses the frame centre (review #7, WOW_WATER=2).
|
||||
Scenario {
|
||||
name: "water-ocean-lodring",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-10500.0, 2112.0, 30.0],
|
||||
look: [-10449.0, 2163.0, 0.0],
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
// MONKEY (perf): Northshire at midnight from the ground — where the owner's night FPS drop is
|
||||
// reported (torch lanes, lamp fog, bleed fixtures all live). Probe fixtures, not look fixtures.
|
||||
Scenario {
|
||||
name: "perf-northshire-night",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
// MONKEY (perf): the abbey front at midnight, close to its lamps and candelabra.
|
||||
Scenario {
|
||||
name: "perf-abbey-night",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-8935.0, -145.0, 86.0],
|
||||
look: [-8905.0, -160.0, 85.0],
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
];
|
||||
|
||||
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
|
||||
|
||||
+176
-21
@@ -955,7 +955,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
ours(
|
||||
"skyQuality",
|
||||
"0",
|
||||
"benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \n 2 High (+ detailed sun-lit clouds)",
|
||||
"benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \
|
||||
2 High (+ detailed sun-lit clouds)",
|
||||
),
|
||||
// MONKEY (wind): one tier controls the grass-only and grass-plus-tree receivers.
|
||||
ours(
|
||||
@@ -984,7 +985,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
ours(
|
||||
"zoneSkyboxes",
|
||||
"0",
|
||||
"benilla's own: draw the zone skybox LightParams names for the living, 0 Off / 1 On; the reference draws a DBC skybox only for the ghost",
|
||||
"benilla's own: draw the zone skybox LightParams names for the living, 0 Off / 1 On; the \
|
||||
reference draws a DBC skybox only for the ghost",
|
||||
),
|
||||
ours(
|
||||
"waterQuality",
|
||||
@@ -1133,13 +1135,15 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
ours(
|
||||
"exteriorShadows",
|
||||
"1",
|
||||
"benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day",
|
||||
"benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at \
|
||||
night; no effect by day",
|
||||
),
|
||||
// MONKEY (daylight: terrain torch casters): the ground as a torch caster.
|
||||
ours(
|
||||
"torchTerrainShadows",
|
||||
"0",
|
||||
"benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's light); needs exteriorShadows",
|
||||
"benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's \
|
||||
light); needs exteriorShadows",
|
||||
),
|
||||
// MONKEY (static torch cache): residency and per-frame work have separate live budgets.
|
||||
ours(
|
||||
@@ -2334,8 +2338,11 @@ fn same_value(a: &str, b: &str) -> bool {
|
||||
/// allowed to say, which is what keeps the row from going stale behind a `/console` write.
|
||||
pub(crate) fn derive_lighting_quality(cvars: &Cvars) -> &'static str {
|
||||
for (name, members) in LIGHTING_PRESETS {
|
||||
// MONKEY (reviewfix-a): a session-owned row (an env lever) is not the player's choice and
|
||||
// must not turn the saved label Custom; it is skipped, as the seed skips it.
|
||||
let matched = members
|
||||
.iter()
|
||||
.filter(|(k, _)| !cvars.is_session_owned(k))
|
||||
.all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v)));
|
||||
if matched {
|
||||
return name;
|
||||
@@ -2379,6 +2386,21 @@ pub(crate) const GRAPHICS_PRESET_NAMES: [&str; 5] = ["Classic", "Low", "Medium",
|
||||
/// The rung a player with no saved preset boots into ([`Cvars::seed_graphics_preset`]).
|
||||
pub(crate) const GRAPHICS_DEFAULT: &str = "High";
|
||||
|
||||
/// MONKEY (reviewfix-a): **the rows where the first-boot seed leaves the reference** — the
|
||||
/// [`GRAPHICS_DEFAULT`] column value a new player actually boots with, against the reference's
|
||||
/// own default. The registered row keeps its `same(...)` (the registry default IS the
|
||||
/// reference's; a capture or a test, which never seeds, runs at it), so this list is where the
|
||||
/// deviation of the *seeded* boot value is declared, `Deviates`-style: `(row, reference, why)`.
|
||||
/// `seeded_column_deviates_only_where_declared` walks the whole column against the reference
|
||||
/// rows, both ways.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) const SEEDED_DEVIATIONS: &[(&str, &str, &str)] = &[(
|
||||
"farclip",
|
||||
"350",
|
||||
"owner decision: the High preset is the first-boot default, and High's view distance is the \
|
||||
reference's own slider maximum, 777 yd; Classic keeps 350",
|
||||
)];
|
||||
|
||||
/// **The one place the Graphics Preset is written down**: one line per governed row, one column
|
||||
/// per rung of [`GRAPHICS_PRESET_NAMES`]. `lightingQuality` is itself a row — writing its rung
|
||||
/// writes [`LIGHTING_PRESETS`]' members — so every lighting, water, fog, post and wind row the
|
||||
@@ -2421,8 +2443,10 @@ fn graphics_column(name: &str) -> Option<usize> {
|
||||
/// ladders show). Reads `lightingQuality`'s DERIVED label, so run it after that is mirrored.
|
||||
pub(crate) fn derive_graphics_quality(cvars: &Cvars) -> &'static str {
|
||||
for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() {
|
||||
// MONKEY (reviewfix-a): session-owned rows are skipped, as in the lighting ladder above.
|
||||
let matched = GRAPHICS_PRESETS
|
||||
.iter()
|
||||
.filter(|(k, _)| !cvars.is_session_owned(k))
|
||||
.all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v[col])));
|
||||
if matched {
|
||||
return name;
|
||||
@@ -2636,14 +2660,14 @@ fn stored_config() -> StoredConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (integration): a capture may opt into one explicit, read-only CVar fixture through
|
||||
/// `BENILLA_HOME`. All other local state stays hermetic and [`save_config`] still sees no path,
|
||||
/// so deterministic A/B homes can select graphics tiers without a capture writing anything back.
|
||||
/// MONKEY (integration): a capture may opt into one explicit, read-only CVar fixture.
|
||||
/// MONKEY (reviewfix-a): through its own variable, `WOW_CAPTURE_CVARS=<config.toml path>`, not
|
||||
/// `BENILLA_HOME` (the general local-state override a player shell may carry, which would load
|
||||
/// that player's settings into a baseline). Without it a capture reads no config at all. All
|
||||
/// other local state stays hermetic and [`save_config`] still sees no path.
|
||||
fn config_read_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("WOW_CAPTURE").is_some() {
|
||||
return std::env::var_os("BENILLA_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.map(|home| home.join("config.toml"));
|
||||
return std::env::var_os("WOW_CAPTURE_CVARS").map(std::path::PathBuf::from);
|
||||
}
|
||||
crate::local_state::config_path()
|
||||
}
|
||||
@@ -3740,19 +3764,36 @@ mod tests {
|
||||
/// row's rung values do too.
|
||||
#[test]
|
||||
fn graphics_rows_pass_their_observers_unclamped() {
|
||||
// MONKEY (reviewfix-a): every governed row, every rung — not only `farclip`. Each value
|
||||
// is driven through the real setter and read back off the resource its renderer reads.
|
||||
let mut app = cvar_app();
|
||||
for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() {
|
||||
let farclip = GRAPHICS_PRESETS
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "farclip")
|
||||
.unwrap()
|
||||
.1[col];
|
||||
apply(&mut app, "farclip", farclip);
|
||||
assert_eq!(
|
||||
res::<ViewDistance>(&app).farclip,
|
||||
farclip.parse::<f32>().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
for (k, values) in GRAPHICS_PRESETS {
|
||||
if *k == "lightingQuality" {
|
||||
continue; // its members are welded by the lighting-preset census test
|
||||
}
|
||||
let value = values[col];
|
||||
apply(&mut app, k, "-1");
|
||||
apply(&mut app, k, value);
|
||||
let video = res::<VideoConfig>(&app);
|
||||
let flag = |b: bool| if b { 1.0 } else { 0.0 };
|
||||
let applied = match *k {
|
||||
"farclip" => res::<ViewDistance>(&app).farclip,
|
||||
"skyQuality" => video.sky_quality as f32,
|
||||
"skyDither" => flag(res::<benilla_world::ffx_glow::SkyDither>(&app).0),
|
||||
"fogModel" => {
|
||||
flag(res::<benilla_world::lighting::FogModelSetting>(&app).modern())
|
||||
}
|
||||
"rainSurfaces" => flag(res::<benilla_world::weather::RainSurfaces>(&app).0),
|
||||
"torchTerrainShadows" => flag(video.torch_terrain_shadows),
|
||||
"daylightWindowSplit" => flag(video.daylight_window_split),
|
||||
"ambientOcclusion" => video.ambient_occlusion as f32,
|
||||
"zoneSkyboxes" => flag(res::<benilla_world::skybox::ZoneSkyboxes>(&app).0),
|
||||
"lampFog" => video.lamp_fog as f32,
|
||||
_ => panic!("add the observer readback for {k}"),
|
||||
};
|
||||
assert_eq!(applied, value.parse::<f32>().unwrap(), "{name}/{k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4382,6 +4423,12 @@ mod tests {
|
||||
app.init_resource::<benilla_world::weather::RainSurfaces>(); // MONKEY (wet)
|
||||
app.add_observer(crate::monkey_gfx::on_cvar);
|
||||
},
|
||||
// MONKEY (reviewfix-a): the zone-skybox bridge, so every governed row has its observer.
|
||||
|app| {
|
||||
app.insert_resource(crate::zone_skybox::ZoneSkyboxOverride(None));
|
||||
app.init_resource::<benilla_world::skybox::ZoneSkyboxes>();
|
||||
app.add_observer(crate::zone_skybox::on_cvar);
|
||||
},
|
||||
|app| {
|
||||
app.add_observer(crate::player::camera::on_cvar);
|
||||
},
|
||||
@@ -4957,6 +5004,8 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
|
||||
// MONKEY (reviewfix-a): the fixture is named by its own variable, not BENILLA_HOME.
|
||||
let _x = EnvGuard::set("WOW_CAPTURE_CVARS", tmp.join("config.toml").to_str().unwrap());
|
||||
let _c = EnvGuard::set("WOW_CAPTURE", "post-lava-searing");
|
||||
|
||||
assert_eq!(boot_cvar("bloom").as_deref(), Some("0"));
|
||||
@@ -5073,4 +5122,110 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ─── MONKEY (reviewfix-a) ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The value the reference boots a row at, as its [`Reference`] column records it; `None`
|
||||
/// for benilla's own rows (nothing to match).
|
||||
fn reference_boot_value(row: &Registered) -> Option<&'static str> {
|
||||
match &row.reference {
|
||||
Reference::Same(v) => Some(v),
|
||||
// The reference's own boot code lands where our default does.
|
||||
Reference::Overridden { .. } => Some(row.default),
|
||||
Reference::Deviates { value, .. } => Some(value),
|
||||
Reference::Ours(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// **The seeded High column against every reference row** (review #6): a governed row a new
|
||||
/// player boots off the reference's value must be declared in [`SEEDED_DEVIATIONS`] with
|
||||
/// that reference value and a reason, and a declared row must really deviate.
|
||||
#[test]
|
||||
fn seeded_column_deviates_only_where_declared() {
|
||||
let col = graphics_column(GRAPHICS_DEFAULT).unwrap();
|
||||
for (k, values) in GRAPHICS_PRESETS {
|
||||
if k.eq_ignore_ascii_case("lightingQuality") {
|
||||
continue; // High on that ladder IS the registered defaults (its own test)
|
||||
}
|
||||
let row = REGISTERED.iter().find(|r| r.name == *k).expect("governed row registered");
|
||||
let declared = SEEDED_DEVIATIONS.iter().find(|(n, _, _)| n == k);
|
||||
match reference_boot_value(row) {
|
||||
Some(reference) if !same_value(values[col], reference) => {
|
||||
let (_, value, why) = declared.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{k}: the seeded {GRAPHICS_DEFAULT} value {} leaves the reference's \
|
||||
{reference}; declare it in SEEDED_DEVIATIONS",
|
||||
values[col]
|
||||
)
|
||||
});
|
||||
assert!(same_value(value, reference), "{k}: declared reference is stale");
|
||||
assert!(!why.trim().is_empty(), "{k}: a deviation owes a reason");
|
||||
}
|
||||
_ => assert!(
|
||||
declared.is_none(),
|
||||
"{k}: declared in SEEDED_DEVIATIONS but the seed does not deviate"
|
||||
),
|
||||
}
|
||||
}
|
||||
for (n, _, _) in SEEDED_DEVIATIONS {
|
||||
assert!(
|
||||
GRAPHICS_PRESETS.iter().any(|(k, _)| k == n),
|
||||
"{n}: declared but not governed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Review #14: an env lever owns a governed row for the session; the label must not derive
|
||||
/// (and so persist) `Custom` because of it.
|
||||
#[test]
|
||||
fn a_session_owned_row_does_not_derive_custom() {
|
||||
let mut cvars = fresh_registry();
|
||||
apply_graphics_preset(&mut cvars, "High");
|
||||
lighting_quality(&mut cvars);
|
||||
assert_eq!(derive_graphics_quality(&cvars), "High");
|
||||
cvars.own_for_session("farclip", Some("500"));
|
||||
assert_eq!(cvars.get("farclip"), Some("500"));
|
||||
assert_eq!(derive_graphics_quality(&cvars), "High");
|
||||
// A lighting member owned by the session is skipped the same way.
|
||||
cvars.own_for_session("waterQuality", Some("0"));
|
||||
assert_eq!(derive_lighting_quality(&cvars), "High");
|
||||
}
|
||||
|
||||
/// Review #15: help strings are one line — a lost `\` continuation leaves a literal `\n` or a
|
||||
/// run of indentation spaces in the `/console` text.
|
||||
#[test]
|
||||
fn help_strings_have_no_broken_continuations() {
|
||||
for row in REGISTERED {
|
||||
let why = match &row.reference {
|
||||
Reference::Ours(why)
|
||||
| Reference::Deviates { why, .. }
|
||||
| Reference::Overridden { why, .. } => *why,
|
||||
Reference::Same(_) => continue,
|
||||
};
|
||||
assert!(
|
||||
!why.contains('\n') && !why.contains("\\n") && !why.contains(" "),
|
||||
"{}: broken line continuation in {why:?}",
|
||||
row.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Review #13: a capture reads CVars only from the dedicated `WOW_CAPTURE_CVARS` fixture,
|
||||
/// never from a `BENILLA_HOME` the shell happens to carry.
|
||||
#[test]
|
||||
fn a_capture_reads_cvars_only_from_its_fixture() {
|
||||
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = EnvGuard::set("WOW_CAPTURE", "overlook-noon");
|
||||
let _h = EnvGuard::set("BENILLA_HOME", "player-home");
|
||||
let _x = EnvGuard::unset("WOW_CAPTURE_CVARS");
|
||||
assert_eq!(config_read_path(), None);
|
||||
let _x = EnvGuard::set("WOW_CAPTURE_CVARS", "fixture/config.toml");
|
||||
assert_eq!(
|
||||
config_read_path(),
|
||||
Some(std::path::PathBuf::from("fixture/config.toml"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,9 @@ pub(crate) fn on_cvar(
|
||||
}
|
||||
// MONKEY (wind): numeric tier, live; 0 is the exact no-displacement path.
|
||||
"foliagewind" => {
|
||||
// Capture-only A/B override. Normal runs have no such environment variable and obey
|
||||
// the live CVar exactly; the instrument can force Off without rewriting Config.wtf.
|
||||
let requested = std::env::var("WOW_FOLIAGE_WIND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or_else(|| ev.num());
|
||||
// Capture-only A/B pin (MONKEY reviewfix-a: gated like `capture_daylight` — dev
|
||||
// build AND `WOW_CAPTURE`). Player runs obey the live CVar exactly.
|
||||
let requested = foliage_wind_pin().map(f32::from).unwrap_or_else(|| ev.num());
|
||||
let want = requested.clamp(0.0, 2.0) as u8;
|
||||
if foliage_wind.0 != want {
|
||||
foliage_wind.0 = want;
|
||||
@@ -57,6 +54,14 @@ pub(crate) fn on_cvar(
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix-a): the `WOW_FOLIAGE_WIND` capture pin, only in a dev build under `WOW_CAPTURE`.
|
||||
fn foliage_wind_pin() -> Option<u8> {
|
||||
if !crate::run_mode::dev_affordances() {
|
||||
return None;
|
||||
}
|
||||
FoliageWind::capture_override()
|
||||
}
|
||||
|
||||
/// Registers the programme's cvar bridges. Must be added before `CvarPlugin` so the saved values
|
||||
/// applied at Startup reach it (the edge `game_plugins.rs` documents for every knob plugin).
|
||||
pub(crate) struct MonkeyGfxPlugin;
|
||||
@@ -64,7 +69,11 @@ pub(crate) struct MonkeyGfxPlugin;
|
||||
impl Plugin for MonkeyGfxPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SkyDither>()
|
||||
.init_resource::<FoliageWind>()
|
||||
// MONKEY (reviewfix-a): the resource is seeded here so a player build never takes the
|
||||
// capture pin (the world plugin's `init_resource` keeps whatever is already present).
|
||||
.insert_resource(FoliageWind(
|
||||
foliage_wind_pin().unwrap_or(FoliageWind::REGISTERED),
|
||||
))
|
||||
.init_resource::<FogModelSetting>()
|
||||
.init_resource::<RainSurfaces>() // MONKEY (wet)
|
||||
.add_observer(on_cvar);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! MONKEY (post): zone/day-night colour grading through a real 32³ GPU texture.
|
||||
//!
|
||||
//! Ported from WarcraftXL's `wxl-retail-grading` (Copyright (C) 2026 WarcraftXL, GPL-3.0-or-later).
|
||||
//! Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear
|
||||
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-grading, Grading.cpp, shaders/Grading.ps.hlsl.
|
||||
//! Used with the author's permission; attribution required. Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear
|
||||
//! lookup after upload to a 3D texture. See `THIRD-PARTY.md`.
|
||||
|
||||
use super::bloom::BloomLabel;
|
||||
@@ -25,7 +25,7 @@ use bevy::{
|
||||
renderer::{RenderContext, RenderDevice, RenderQueue},
|
||||
texture::GpuImage,
|
||||
view::ViewTarget,
|
||||
RenderApp, RenderStartup,
|
||||
Render, RenderApp, RenderStartup, RenderSystems,
|
||||
},
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -43,10 +43,66 @@ struct GradeCatalog {
|
||||
struct GradeView {
|
||||
day: Handle<Image>,
|
||||
night: Handle<Image>,
|
||||
// x = authored strength, y = night blend.
|
||||
/// MONKEY (polish): the pair being faded out (identity once the fade is done).
|
||||
prev_day: Handle<Image>,
|
||||
prev_night: Handle<Image>,
|
||||
// x = authored strength, y = night blend, z = crossfade weight of the current pair (1 = done),
|
||||
// w = the previous pair's strength.
|
||||
control: Vec4,
|
||||
}
|
||||
|
||||
/// MONKEY (polish): the zone crossfade's length; a border crossing no longer snaps the grade.
|
||||
const GRADE_FADE_S: f32 = 2.0;
|
||||
|
||||
/// One zone's LUT pair at its authored strength; a zone with no row grades at strength 0.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
struct GradePair {
|
||||
day: Handle<Image>,
|
||||
night: Handle<Image>,
|
||||
strength: f32,
|
||||
}
|
||||
|
||||
/// MONKEY (polish): the crossfade from the previous zone's pair to the current one.
|
||||
#[derive(Resource, Default)]
|
||||
struct GradeBlend {
|
||||
current: Option<GradePair>,
|
||||
previous: Option<GradePair>,
|
||||
/// The current pair's weight, 0..1.
|
||||
fade: f32,
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
impl GradeBlend {
|
||||
/// Steps toward `target`. The first graded zone after login (or re-enabling) and a disabled
|
||||
/// grade snap; a return to the
|
||||
/// pair still fading out reverses the fade instead of restarting it.
|
||||
fn step(&mut self, target: Option<GradePair>, dt: f32, snap: bool) {
|
||||
if snap || !self.primed {
|
||||
*self = Self {
|
||||
primed: !snap && target.is_some(),
|
||||
current: target,
|
||||
previous: None,
|
||||
fade: 1.0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if target != self.current {
|
||||
if self.fade < 1.0 && target == self.previous {
|
||||
std::mem::swap(&mut self.current, &mut self.previous);
|
||||
self.fade = 1.0 - self.fade;
|
||||
} else {
|
||||
self.previous = self.current.take();
|
||||
self.current = target;
|
||||
self.fade = 0.0;
|
||||
}
|
||||
}
|
||||
self.fade = (self.fade + dt / GRADE_FADE_S).min(1.0);
|
||||
if self.fade >= 1.0 {
|
||||
self.previous = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ShaderType)]
|
||||
struct GradeUniform {
|
||||
control: Vec4,
|
||||
@@ -55,6 +111,7 @@ struct GradeUniform {
|
||||
impl Plugin for GradingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(ExtractComponentPlugin::<GradeView>::default())
|
||||
.init_resource::<GradeBlend>()
|
||||
.add_systems(Last, update_views);
|
||||
if !app.is_plugin_added::<AssetPlugin>() {
|
||||
return;
|
||||
@@ -73,6 +130,10 @@ impl Plugin for GradingPlugin {
|
||||
render_app
|
||||
.insert_resource(GradeShader(shader))
|
||||
.add_systems(RenderStartup, init_pipeline)
|
||||
.add_systems(
|
||||
Render,
|
||||
prepare_uniforms.in_set(RenderSystems::PrepareResources),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<GradeNode>>(Core3d, GradeLabel)
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
@@ -215,17 +276,25 @@ fn volume_image(cube: Vec<u8>) -> Image {
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_views(
|
||||
mut commands: Commands,
|
||||
video: Res<VideoConfig>,
|
||||
catalog: Option<Res<GradeCatalog>>,
|
||||
area: Res<CurrentArea>,
|
||||
// MONKEY (reviewfix-a): a capture has no avatar; it grades by the area under the camera.
|
||||
capture_area: Option<Res<benilla_world::terrain_stream::CaptureCameraArea>>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
time: Res<WorldTime>,
|
||||
// MONKEY (reviewfix-a): the minute the lighting actually rendered (server, or the manual /
|
||||
// capture clock); `WorldTime` alone is the server's and stays at its noon default offline.
|
||||
rendered: Option<Res<benilla_world::lighting::GameClock>>,
|
||||
clock: Res<Time>,
|
||||
mut blend: ResMut<GradeBlend>,
|
||||
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
|
||||
) {
|
||||
let selected = catalog.as_ref().and_then(|catalog| {
|
||||
let leaf = area.0?;
|
||||
let leaf = area.0.or_else(|| capture_area.as_ref().and_then(|a| a.0))?;
|
||||
let zone = areas
|
||||
.as_ref()
|
||||
.and_then(|areas| areas.0.top_zone(leaf))
|
||||
@@ -234,7 +303,7 @@ fn update_views(
|
||||
.rows
|
||||
.for_area(leaf)
|
||||
.or_else(|| catalog.rows.for_area(zone))?;
|
||||
(row.strength > 0.0).then(|| GradeView {
|
||||
(row.strength > 0.0).then(|| GradePair {
|
||||
day: catalog
|
||||
.luts
|
||||
.get(&row.day_lut)
|
||||
@@ -245,7 +314,34 @@ fn update_views(
|
||||
.get(&row.night_lut)
|
||||
.unwrap_or(&catalog.identity)
|
||||
.clone(),
|
||||
control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0),
|
||||
strength: row.strength,
|
||||
})
|
||||
});
|
||||
blend.step(selected, clock.delta_secs(), !video.color_grading);
|
||||
let minute = rendered_minute(&time, rendered.as_deref());
|
||||
let selected = catalog.as_ref().and_then(|catalog| {
|
||||
if blend.current.is_none() && blend.previous.is_none() {
|
||||
return None;
|
||||
}
|
||||
let pair = |p: &Option<GradePair>| {
|
||||
p.as_ref().map_or_else(
|
||||
|| (catalog.identity.clone(), catalog.identity.clone(), 0.0),
|
||||
|p| (p.day.clone(), p.night.clone(), p.strength),
|
||||
)
|
||||
};
|
||||
let (day, night, strength) = pair(&blend.current);
|
||||
let (prev_day, prev_night, prev_strength) = pair(&blend.previous);
|
||||
Some(GradeView {
|
||||
day,
|
||||
night,
|
||||
prev_day,
|
||||
prev_night,
|
||||
control: Vec4::new(
|
||||
strength,
|
||||
night_weight(minute),
|
||||
blend.fade,
|
||||
prev_strength,
|
||||
),
|
||||
})
|
||||
});
|
||||
for (entity, camera, old) in &cameras {
|
||||
@@ -264,6 +360,15 @@ fn update_views(
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix-a): the server's fractional minute while it is the minute being rendered,
|
||||
/// else the rendered (manual or capture) minute.
|
||||
fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 {
|
||||
match rendered {
|
||||
Some(c) if time.minute_f.floor() as u32 != c.minute => c.minute as f32,
|
||||
_ => time.minute_f,
|
||||
}
|
||||
}
|
||||
|
||||
fn night_weight(minute: f32) -> f32 {
|
||||
let dawn = smoothstep(330.0, 450.0, minute);
|
||||
let dusk = smoothstep(1110.0, 1230.0, minute);
|
||||
@@ -307,6 +412,9 @@ fn init_pipeline(
|
||||
texture_3d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
uniform_buffer::<GradeUniform>(false),
|
||||
// MONKEY (polish): the previous zone's pair, crossfaded out.
|
||||
texture_3d(TextureSampleType::Float { filterable: true }),
|
||||
texture_3d(TextureSampleType::Float { filterable: true }),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -344,17 +452,47 @@ fn init_pipeline(
|
||||
});
|
||||
}
|
||||
|
||||
/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's.
|
||||
#[derive(Component)]
|
||||
struct GradeUniformBuffer(Buffer);
|
||||
|
||||
fn prepare_uniforms(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
views: Query<(Entity, &GradeView, Option<&GradeUniformBuffer>)>,
|
||||
) {
|
||||
for (entity, grade, buffer) in &views {
|
||||
let rows = [grade.control.to_array()];
|
||||
match buffer {
|
||||
Some(buffer) => queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&rows)),
|
||||
None => {
|
||||
let buffer = device.create_buffer_with_data(&BufferInitDescriptor {
|
||||
label: Some("post_grading_uniform"),
|
||||
contents: bytemuck::cast_slice(&rows),
|
||||
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||
});
|
||||
commands.entity(entity).insert(GradeUniformBuffer(buffer));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GradeNode;
|
||||
|
||||
impl ViewNode for GradeNode {
|
||||
type ViewQuery = (&'static ViewTarget, &'static GradeView);
|
||||
type ViewQuery = (
|
||||
&'static ViewTarget,
|
||||
&'static GradeView,
|
||||
&'static GradeUniformBuffer,
|
||||
);
|
||||
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
context: &mut RenderContext<'w>,
|
||||
(target, grade): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
(target, grade, uniform): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let settings = world.resource::<GradePipeline>();
|
||||
@@ -363,14 +501,15 @@ impl ViewNode for GradeNode {
|
||||
return Ok(());
|
||||
};
|
||||
let images = world.resource::<RenderAssets<GpuImage>>();
|
||||
let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.night)) else {
|
||||
let (Some(day), Some(night), Some(prev_day), Some(prev_night)) = (
|
||||
images.get(&grade.day),
|
||||
images.get(&grade.night),
|
||||
images.get(&grade.prev_day),
|
||||
images.get(&grade.prev_night),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let device = context.render_device();
|
||||
let mut uniform = UniformBuffer::from(GradeUniform {
|
||||
control: grade.control,
|
||||
});
|
||||
uniform.write_buffer(device, world.resource::<RenderQueue>());
|
||||
let out = target.post_process_write();
|
||||
let bind = device.create_bind_group(
|
||||
"post_grading",
|
||||
@@ -381,7 +520,9 @@ impl ViewNode for GradeNode {
|
||||
&day.texture_view,
|
||||
&night.texture_view,
|
||||
&settings.lut_sampler,
|
||||
uniform.binding().unwrap(),
|
||||
uniform.0.as_entire_binding(),
|
||||
&prev_day.texture_view,
|
||||
&prev_night.texture_view,
|
||||
)),
|
||||
);
|
||||
let diagnostics = context.diagnostic_recorder();
|
||||
@@ -412,6 +553,25 @@ impl ViewNode for GradeNode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// MONKEY (reviewfix-a): offline (capture / manual clock) the grade follows the rendered
|
||||
/// minute, not the server's noon default; live it keeps the server's fraction.
|
||||
#[test]
|
||||
fn grade_night_weight_follows_the_rendered_minute() {
|
||||
let server = WorldTime::default();
|
||||
let manual = benilla_world::lighting::GameClock {
|
||||
minute: 1260,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(rendered_minute(&server, Some(&manual)), 1260.0);
|
||||
assert_eq!(night_weight(rendered_minute(&server, Some(&manual))), 1.0);
|
||||
let live = WorldTime {
|
||||
minute_f: 1260.5,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(rendered_minute(&live, Some(&manual)), 1260.5);
|
||||
assert_eq!(rendered_minute(&live, None), 1260.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_volume_has_exact_axes() {
|
||||
let cube = identity_cube();
|
||||
@@ -422,6 +582,44 @@ mod tests {
|
||||
assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_change_crossfades_and_a_quick_return_reverses() {
|
||||
let pair = |id: u128, strength| GradePair {
|
||||
day: Handle::Uuid(bevy::asset::uuid::Uuid::from_u128(id), default()),
|
||||
night: Handle::Uuid(bevy::asset::uuid::Uuid::from_u128(id + 1), default()),
|
||||
strength,
|
||||
};
|
||||
let (a, b) = (pair(10, 1.0), pair(20, 0.5));
|
||||
let mut blend = GradeBlend::default();
|
||||
// No zone yet, then the first graded zone snaps in.
|
||||
blend.step(None, 0.016, false);
|
||||
blend.step(Some(a.clone()), 0.016, false);
|
||||
assert_eq!((blend.current.clone(), blend.fade), (Some(a.clone()), 1.0));
|
||||
// A border crossing fades over GRADE_FADE_S.
|
||||
blend.step(Some(b.clone()), 0.5, false);
|
||||
assert_eq!(blend.previous, Some(a.clone()));
|
||||
assert!((blend.fade - 0.25).abs() < 1e-6);
|
||||
// Stepping back mid-fade reverses from the same image.
|
||||
blend.step(Some(a.clone()), 0.0, false);
|
||||
assert_eq!(
|
||||
(blend.current.clone(), blend.previous.clone()),
|
||||
(Some(a.clone()), Some(b))
|
||||
);
|
||||
assert!((blend.fade - 0.75).abs() < 1e-6);
|
||||
blend.step(Some(a.clone()), 1.0, false);
|
||||
assert_eq!((blend.fade, blend.previous.is_none()), (1.0, true));
|
||||
// An ungraded zone fades out to identity rather than cutting.
|
||||
blend.step(None, 1.0, false);
|
||||
assert_eq!(
|
||||
(blend.current.is_none(), blend.previous.clone()),
|
||||
(true, Some(a))
|
||||
);
|
||||
assert!((blend.fade - 0.5).abs() < 1e-6);
|
||||
// Disabling snaps.
|
||||
blend.step(None, 0.0, true);
|
||||
assert!(blend.previous.is_none() && blend.current.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_night_blend_is_continuous() {
|
||||
assert_eq!(night_weight(0.0), 1.0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Colour grading ported from WarcraftXL wxl-retail-grading.
|
||||
// Copyright (C) 2026 WarcraftXL. GPL-3.0-or-later; see THIRD-PARTY.md.
|
||||
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-grading, Grading.cpp, shaders/Grading.ps.hlsl.
|
||||
// Used with the author's permission; attribution required. See THIRD-PARTY.md.
|
||||
// MONKEY (post): real 32³ textures make the original 1024x32 strip's two bilinear taps plus
|
||||
// lerp(fract(b*31)) one hardware-trilinear lookup, for both the day and night cubes.
|
||||
|
||||
@@ -15,6 +15,9 @@ struct Grade {
|
||||
control: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(5) var<uniform> grade: Grade;
|
||||
// MONKEY (polish): the previous zone's pair, crossfaded out over `grade.control.z`.
|
||||
@group(0) @binding(6) var prev_day_lut: texture_3d<f32>;
|
||||
@group(0) @binding(7) var prev_night_lut: texture_3d<f32>;
|
||||
|
||||
fn grade_one(lut: texture_3d<f32>, color: vec3<f32>) -> vec3<f32> {
|
||||
// Half a texel in from each face, with 31 intervals across the 32-texel cube.
|
||||
@@ -29,7 +32,16 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let day = grade_one(day_lut, base);
|
||||
let night = grade_one(night_lut, base);
|
||||
let authored = mix(day, night, grade.control.y);
|
||||
let graded = mix(base, authored, grade.control.x);
|
||||
var graded = mix(base, authored, grade.control.x);
|
||||
// MONKEY (polish): mid-crossfade, the previous zone's pair through the same day/night blend
|
||||
// at its own strength; a missing zone or LUT is the identity cube, so it fades to ungraded.
|
||||
if (grade.control.z < 1.0) {
|
||||
let prev_day = grade_one(prev_day_lut, base);
|
||||
let prev_night = grade_one(prev_night_lut, base);
|
||||
let prev_authored = mix(prev_day, prev_night, grade.control.y);
|
||||
let prev_graded = mix(base, prev_authored, grade.control.w);
|
||||
graded = mix(prev_graded, graded, grade.control.z);
|
||||
}
|
||||
// Bloom already consumed the HDR signal; preserve its remaining excess for the FFX clamp.
|
||||
return vec4(graded + max(source.rgb - vec3(1.0), vec3(0.0)), source.a);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! MONKEY (post): screen-space sun shafts from the visible sky depth.
|
||||
//!
|
||||
//! MONKEY (polish): the depth is read once per half-resolution texel into an R8 sky mask
|
||||
//! (`fs_mask`, the only MSAA-specialised stage); the 28-tap radial blur then samples the mask.
|
||||
|
||||
use super::bloom::BloomLabel;
|
||||
use crate::video::VideoConfig;
|
||||
@@ -11,6 +14,7 @@ use bevy::{
|
||||
ecs::query::QueryItem,
|
||||
prelude::*,
|
||||
render::{
|
||||
camera::ExtractedCamera,
|
||||
diagnostic::RecordDiagnostics,
|
||||
extract_component::{ExtractComponent, ExtractComponentPlugin},
|
||||
render_graph::{
|
||||
@@ -18,7 +22,8 @@ use bevy::{
|
||||
},
|
||||
render_resource::{binding_types::*, *},
|
||||
renderer::{RenderContext, RenderDevice, RenderQueue},
|
||||
view::{Msaa, ViewDepthTexture, ViewTarget},
|
||||
texture::{CachedTexture, TextureCache},
|
||||
view::{ViewDepthTexture, ViewTarget},
|
||||
Render, RenderApp, RenderStartup, RenderSystems,
|
||||
},
|
||||
};
|
||||
@@ -54,7 +59,13 @@ impl Plugin for SunShaftsPlugin {
|
||||
.insert_resource(ShaftShader(shader))
|
||||
.init_resource::<SpecializedRenderPipelines<ShaftPipeline>>()
|
||||
.add_systems(RenderStartup, init_pipeline)
|
||||
.add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
|
||||
.add_systems(
|
||||
Render,
|
||||
(
|
||||
prepare_pipelines.in_set(RenderSystems::Prepare),
|
||||
prepare_masks.in_set(RenderSystems::PrepareResources),
|
||||
),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<ShaftNode>>(Core3d, ShaftLabel)
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
@@ -139,12 +150,25 @@ struct ShaftShader(Handle<Shader>);
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ShaftPipeline {
|
||||
layouts: [BindGroupLayoutDescriptor; 2],
|
||||
/// The blur's layout; the mask's per MSAA (`[single, multisampled]`).
|
||||
layout: BindGroupLayoutDescriptor,
|
||||
mask_layouts: [BindGroupLayoutDescriptor; 2],
|
||||
masks: [CachedRenderPipelineId; 2],
|
||||
shader: Handle<Shader>,
|
||||
fullscreen: FullscreenShader,
|
||||
sampler: Sampler,
|
||||
}
|
||||
|
||||
/// The half-resolution sky mask the blur marches through.
|
||||
const MASK_FORMAT: TextureFormat = TextureFormat::R8Unorm;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ShaftMask(CachedTexture);
|
||||
|
||||
/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's.
|
||||
#[derive(Component)]
|
||||
struct ShaftUniform(Buffer);
|
||||
|
||||
#[derive(Component)]
|
||||
struct ViewShaftPipeline(CachedRenderPipelineId);
|
||||
|
||||
@@ -154,29 +178,61 @@ struct ShaftLabel;
|
||||
fn init_pipeline(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
cache: Res<PipelineCache>,
|
||||
shader: Res<ShaftShader>,
|
||||
fullscreen: Res<FullscreenShader>,
|
||||
) {
|
||||
let layouts = [false, true].map(|multisampled| {
|
||||
let layout = BindGroupLayoutDescriptor::new(
|
||||
"post_sun_shafts_layout",
|
||||
&BindGroupLayoutEntries::sequential(
|
||||
ShaderStages::FRAGMENT,
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
uniform_buffer::<ShaftView>(false),
|
||||
),
|
||||
),
|
||||
);
|
||||
let mask_layouts = [false, true].map(|multisampled| {
|
||||
BindGroupLayoutDescriptor::new(
|
||||
"post_sun_shafts_layout",
|
||||
&BindGroupLayoutEntries::sequential(
|
||||
"post_sun_shafts_mask_layout",
|
||||
&BindGroupLayoutEntries::single(
|
||||
ShaderStages::FRAGMENT,
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
if multisampled {
|
||||
texture_depth_2d_multisampled()
|
||||
} else {
|
||||
texture_depth_2d()
|
||||
},
|
||||
uniform_buffer::<ShaftView>(false),
|
||||
),
|
||||
if multisampled {
|
||||
texture_depth_2d_multisampled()
|
||||
} else {
|
||||
texture_depth_2d()
|
||||
},
|
||||
),
|
||||
)
|
||||
});
|
||||
let masks = [false, true].map(|multisampled| {
|
||||
let mut shader_defs = vec!["SHAFT_MASK".into()];
|
||||
if multisampled {
|
||||
shader_defs.push("MULTISAMPLED".into());
|
||||
}
|
||||
cache.queue_render_pipeline(RenderPipelineDescriptor {
|
||||
label: Some("post_sun_shafts_mask".into()),
|
||||
layout: vec![mask_layouts[multisampled as usize].clone()],
|
||||
vertex: fullscreen.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
shader: shader.0.clone(),
|
||||
shader_defs,
|
||||
entry_point: Some("fs_mask".into()),
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format: MASK_FORMAT,
|
||||
blend: None,
|
||||
write_mask: ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
});
|
||||
commands.insert_resource(ShaftPipeline {
|
||||
layouts,
|
||||
layout,
|
||||
mask_layouts,
|
||||
masks,
|
||||
shader: shader.0.clone(),
|
||||
fullscreen: fullscreen.clone(),
|
||||
sampler: device.create_sampler(&SamplerDescriptor {
|
||||
@@ -190,19 +246,16 @@ fn init_pipeline(
|
||||
}
|
||||
|
||||
impl SpecializedRenderPipeline for ShaftPipeline {
|
||||
type Key = (TextureFormat, bool);
|
||||
type Key = TextureFormat;
|
||||
|
||||
fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor {
|
||||
fn specialize(&self, format: Self::Key) -> RenderPipelineDescriptor {
|
||||
RenderPipelineDescriptor {
|
||||
label: Some("post_sun_shafts".into()),
|
||||
layout: vec![self.layouts[multisampled as usize].clone()],
|
||||
layout: vec![self.layout.clone()],
|
||||
vertex: self.fullscreen.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
shader: self.shader.clone(),
|
||||
shader_defs: multisampled
|
||||
.then(|| "MULTISAMPLED".into())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
shader_defs: vec![],
|
||||
entry_point: Some("fragment".into()),
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format,
|
||||
@@ -220,25 +273,68 @@ fn prepare_pipelines(
|
||||
cache: Res<PipelineCache>,
|
||||
pipeline: Res<ShaftPipeline>,
|
||||
mut specialized: ResMut<SpecializedRenderPipelines<ShaftPipeline>>,
|
||||
views: Query<(Entity, &ViewTarget, &Msaa), With<ShaftView>>,
|
||||
all_views: Query<(&ViewTarget, &Msaa), With<Camera3d>>,
|
||||
views: Query<(Entity, &ViewTarget), With<ShaftView>>,
|
||||
all_views: Query<&ViewTarget, With<Camera3d>>,
|
||||
) {
|
||||
// MONKEY (integration): warm every reachable key on every 3-D view, feature on or off, so the
|
||||
// compile happens under the entry cover and never live when the player turns the row on.
|
||||
// (Named in `pipe_warm/menagerie.rs`'s custom-lane census.)
|
||||
for (target, msaa) in &all_views {
|
||||
specialized.specialize(&cache, &pipeline, (target.main_texture_format(), msaa.samples() > 1));
|
||||
// (Named in `pipe_warm/menagerie.rs`'s custom-lane census.) The mask pipelines (per MSAA) are
|
||||
// queued once at init, so only the blur's format key needs warming here.
|
||||
for target in &all_views {
|
||||
specialized.specialize(&cache, &pipeline, target.main_texture_format());
|
||||
}
|
||||
for (entity, target, msaa) in &views {
|
||||
let id = specialized.specialize(
|
||||
&cache,
|
||||
&pipeline,
|
||||
(target.main_texture_format(), msaa.samples() > 1),
|
||||
);
|
||||
for (entity, target) in &views {
|
||||
let id = specialized.specialize(&cache, &pipeline, target.main_texture_format());
|
||||
commands.entity(entity).insert(ViewShaftPipeline(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// The half-size mask target, rounded up so an odd edge column still has a texel, and the
|
||||
/// view's uniform, written in place.
|
||||
fn prepare_masks(
|
||||
mut commands: Commands,
|
||||
mut textures: ResMut<TextureCache>,
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
views: Query<(Entity, &ExtractedCamera, &ShaftView, Option<&ShaftUniform>)>,
|
||||
) {
|
||||
for (entity, camera, shaft, uniform) in &views {
|
||||
let Some(size) = camera.physical_viewport_size else {
|
||||
continue;
|
||||
};
|
||||
let rows = [shaft.sun.to_array(), shaft.color.to_array()];
|
||||
match uniform {
|
||||
Some(uniform) => queue.write_buffer(&uniform.0, 0, bytemuck::cast_slice(&rows)),
|
||||
None => {
|
||||
let buffer = device.create_buffer_with_data(&BufferInitDescriptor {
|
||||
label: Some("post_sun_shafts_uniform"),
|
||||
contents: bytemuck::cast_slice(&rows),
|
||||
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||
});
|
||||
commands.entity(entity).insert(ShaftUniform(buffer));
|
||||
}
|
||||
}
|
||||
let mask = textures.get(
|
||||
&device,
|
||||
TextureDescriptor {
|
||||
label: Some("post_sun_shafts_mask"),
|
||||
size: Extent3d {
|
||||
width: size.x.div_ceil(2).max(1),
|
||||
height: size.y.div_ceil(2).max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: TextureDimension::D2,
|
||||
format: MASK_FORMAT,
|
||||
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
);
|
||||
commands.entity(entity).insert(ShaftMask(mask));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ShaftNode;
|
||||
|
||||
@@ -246,19 +342,27 @@ impl ViewNode for ShaftNode {
|
||||
type ViewQuery = (
|
||||
&'static ViewTarget,
|
||||
&'static ViewDepthTexture,
|
||||
// Gates the pass: the mask and uniform outlive a disabled frame.
|
||||
&'static ShaftView,
|
||||
&'static ViewShaftPipeline,
|
||||
&'static ShaftMask,
|
||||
&'static ShaftUniform,
|
||||
);
|
||||
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
context: &mut RenderContext<'w>,
|
||||
(target, depth, shaft, id): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
(target, depth, _, id, mask, uniform): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let cache = world.resource::<PipelineCache>();
|
||||
let Some(pipeline) = cache.get_render_pipeline(id.0) else {
|
||||
let settings = world.resource::<ShaftPipeline>();
|
||||
let multisampled = (depth.texture.sample_count() > 1) as usize;
|
||||
let (Some(pipeline), Some(mask_pipeline)) = (
|
||||
cache.get_render_pipeline(id.0),
|
||||
cache.get_render_pipeline(settings.masks[multisampled]),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !depth
|
||||
@@ -268,24 +372,45 @@ impl ViewNode for ShaftNode {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let settings = world.resource::<ShaftPipeline>();
|
||||
let device = context.render_device();
|
||||
let mut uniform = UniformBuffer::from(*shaft);
|
||||
uniform.write_buffer(device, world.resource::<RenderQueue>());
|
||||
let mask_bind = device.create_bind_group(
|
||||
"post_sun_shafts_mask",
|
||||
&cache.get_bind_group_layout(&settings.mask_layouts[multisampled]),
|
||||
&BindGroupEntries::single(depth.view()),
|
||||
);
|
||||
let out = target.post_process_write();
|
||||
let bind = device.create_bind_group(
|
||||
"post_sun_shafts",
|
||||
&cache.get_bind_group_layout(
|
||||
&settings.layouts[(depth.texture.sample_count() > 1) as usize],
|
||||
),
|
||||
&cache.get_bind_group_layout(&settings.layout),
|
||||
&BindGroupEntries::sequential((
|
||||
out.source,
|
||||
&settings.sampler,
|
||||
depth.view(),
|
||||
uniform.binding().unwrap(),
|
||||
&mask.0.default_view,
|
||||
uniform.0.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
let diagnostics = context.diagnostic_recorder();
|
||||
{
|
||||
let mut pass = context
|
||||
.command_encoder()
|
||||
.begin_render_pass(&RenderPassDescriptor {
|
||||
label: Some("post_sun_shafts_mask"),
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &mask.0.default_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: Operations::default(),
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let span = diagnostics.pass_span(&mut pass, "post_sun_shafts_mask");
|
||||
pass.set_pipeline(mask_pipeline);
|
||||
pass.set_bind_group(0, &mask_bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
span.end(&mut pass);
|
||||
}
|
||||
let mut pass = context
|
||||
.command_encoder()
|
||||
.begin_render_pass(&RenderPassDescriptor {
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
// MONKEY (post): screen-space radial blur through sky pixels in the scene depth.
|
||||
// MONKEY (polish): two passes. `SHAFT_MASK` reads the depth once per half-resolution texel into
|
||||
// a sky mask; the blur then samples that mask 28 times instead of the full-size depth.
|
||||
|
||||
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
|
||||
|
||||
#ifdef SHAFT_MASK
|
||||
|
||||
#ifdef MULTISAMPLED
|
||||
@group(0) @binding(0) var depth: texture_depth_multisampled_2d;
|
||||
#else
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
#endif
|
||||
|
||||
@fragment
|
||||
fn fs_mask(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let dims = vec2<i32>(textureDimensions(depth));
|
||||
let p = min(vec2<i32>(in.position.xy) * 2, dims - vec2<i32>(1));
|
||||
// MONKEY (fix-post): one sample, not a max over all of them: the march is a blur.
|
||||
// Only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z) is sky; a ramp on
|
||||
// `near / z` would move with the live `nearclip` cvar and pour shafts through far terrain.
|
||||
let sky = select(0.0, 1.0, textureLoad(depth, p, 0) <= 1.0e-7);
|
||||
return vec4(sky, 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
@group(0) @binding(0) var scene: texture_2d<f32>;
|
||||
@group(0) @binding(1) var scene_sampler: sampler;
|
||||
#ifdef MULTISAMPLED
|
||||
@group(0) @binding(2) var depth: texture_depth_multisampled_2d;
|
||||
#else
|
||||
@group(0) @binding(2) var depth: texture_depth_2d;
|
||||
#endif
|
||||
@group(0) @binding(2) var mask: texture_2d<f32>;
|
||||
|
||||
struct Shafts {
|
||||
sun: vec4<f32>,
|
||||
@@ -16,18 +35,6 @@ struct Shafts {
|
||||
};
|
||||
@group(0) @binding(3) var<uniform> shafts: Shafts;
|
||||
|
||||
fn scene_depth(uv: vec2<f32>) -> f32 {
|
||||
let dims = textureDimensions(depth);
|
||||
let p = vec2<i32>(clamp(uv, vec2(0.0), vec2(0.999999)) * vec2<f32>(dims));
|
||||
#ifdef MULTISAMPLED
|
||||
// MONKEY (fix-post): one sample, not a max over all of them: the march is a blur, and the
|
||||
// per-sample loop cost 28 x N loads per pixel (224 at 8x).
|
||||
return textureLoad(depth, p, 0);
|
||||
#else
|
||||
return textureLoad(depth, p, 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let base = textureSample(scene, scene_sampler, in.uv);
|
||||
@@ -41,10 +48,8 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
if (i >= count) { break; }
|
||||
uv += step_uv;
|
||||
decay *= 0.965;
|
||||
// MONKEY (fix-post): only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z)
|
||||
// is sky. The old ramp read d = near / z, so it moved with the live `nearclip` cvar and
|
||||
// let shafts pour through terrain beyond ~125 yd at nearclip 0.01.
|
||||
let sky = select(0.0, 1.0, scene_depth(uv) <= 1.0e-7);
|
||||
// The clamp-to-edge bilinear tap is the old clamped depth read, pre-thresholded.
|
||||
let sky = textureSampleLevel(mask, scene_sampler, uv, 0.0).r;
|
||||
light += sky * decay;
|
||||
norm += decay;
|
||||
}
|
||||
@@ -56,3 +61,5 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let rays = light / max(norm, 0.0001) * radial * radial;
|
||||
return vec4(base.rgb + shafts.color.rgb * rays * shafts.sun.z * 0.16, base.a);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -98,7 +98,12 @@ fn ao_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let pu = neighbour(f - vec2(0, 1), p);
|
||||
let dx = select(p - pl, pr - p, abs(pr.z - p.z) < abs(p.z - pl.z));
|
||||
let dy = select(p - pu, pd - p, abs(pd.z - p.z) < abs(p.z - pu.z));
|
||||
var n = normalize(cross(dx, dy));
|
||||
// MONKEY (reviewfix-a): sky on both sides of an axis makes the two steps parallel (a 1-px pole
|
||||
// or rope against the sky); normalize(0) would be NaN and bloom would spread it. No AO there.
|
||||
let c = cross(dx, dy);
|
||||
let l2 = dot(c, c);
|
||||
if (l2 < 1e-12) { return vec4(0.0, dist, protect, 1.0); }
|
||||
var n = c * inverseSqrt(l2);
|
||||
if (dot(n, p) > 0.0) { n = -n; }
|
||||
// MONKEY (followups): cutout leaves scatter depth spikes through a canopy, and there the taps
|
||||
// are noise. The spike is the smaller of the two one-sided steps two pixels out, capped by the
|
||||
|
||||
@@ -129,6 +129,8 @@ impl Plugin for AmbientOcclusionPlugin {
|
||||
(
|
||||
prepare_pipelines.in_set(RenderSystems::Prepare),
|
||||
prepare_textures.in_set(RenderSystems::PrepareResources),
|
||||
// MONKEY (reviewfix-a): one uniform per view, rewritten, not allocated per frame.
|
||||
prepare_ao_uniforms.in_set(RenderSystems::PrepareResources),
|
||||
),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<AoNode>>(Core3d, AoLabel)
|
||||
@@ -357,6 +359,31 @@ fn prepare_textures(
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct AoLabel;
|
||||
|
||||
/// MONKEY (reviewfix-a): the view's AO uniform, created once and rewritten in prepare.
|
||||
#[derive(Component)]
|
||||
struct AoUniform(UniformBuffer<AoView>);
|
||||
|
||||
fn prepare_ao_uniforms(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
mut views: Query<(Entity, &AoView, Option<&mut AoUniform>)>,
|
||||
) {
|
||||
for (entity, settings, uniform) in &mut views {
|
||||
match uniform {
|
||||
Some(mut uniform) => {
|
||||
uniform.0.set(*settings);
|
||||
uniform.0.write_buffer(&device, &queue);
|
||||
}
|
||||
None => {
|
||||
let mut uniform = UniformBuffer::from(*settings);
|
||||
uniform.write_buffer(&device, &queue);
|
||||
commands.entity(entity).insert(AoUniform(uniform));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AoNode;
|
||||
|
||||
@@ -365,7 +392,7 @@ impl ViewNode for AoNode {
|
||||
&'static ViewTarget,
|
||||
&'static ViewDepthTexture,
|
||||
&'static ViewUniformOffset,
|
||||
&'static AoView,
|
||||
&'static AoUniform,
|
||||
&'static ViewAoPipelines,
|
||||
&'static AoTextures,
|
||||
);
|
||||
@@ -397,9 +424,7 @@ impl ViewNode for AoNode {
|
||||
let layouts = world.resource::<AoPipeline>();
|
||||
let ms = (depth.texture.sample_count() > 1) as usize;
|
||||
let device = context.render_device().clone();
|
||||
let mut uniform = UniformBuffer::from(*settings);
|
||||
uniform.write_buffer(&device, world.resource::<RenderQueue>());
|
||||
let params = uniform.binding().unwrap();
|
||||
let params = settings.0.binding().unwrap();
|
||||
|
||||
// `main_texture_view` is the single-sample texture the opaque pass resolved into.
|
||||
let ao_bind = device.create_bind_group(
|
||||
|
||||
@@ -5732,3 +5732,29 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
|
||||
assert!(speed_thumb(&mut s));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix-a): the Advanced Graphics farclip slider's range is `view::FARCLIP_RANGE`,
|
||||
/// and its 60-yd grid lands exactly on the maximum (the 1.12 grid the page advertises).
|
||||
#[test]
|
||||
fn farclip_slider_range_is_the_view_range() {
|
||||
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
|
||||
let row = xml
|
||||
.split("<Frame name=\"$parentRowRenderDistance\"")
|
||||
.nth(1)
|
||||
.expect("the farclip row");
|
||||
let args = row
|
||||
.split("OptionsSliderRow_Setup(self, ")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(')').next())
|
||||
.expect("the slider setup call");
|
||||
let nums: Vec<f32> = args
|
||||
.split(',')
|
||||
.take(3)
|
||||
.map(|v| v.trim().parse().expect("numeric slider bound"))
|
||||
.collect();
|
||||
let range = benilla_world::view::FARCLIP_RANGE;
|
||||
assert_eq!(nums[0], *range.start(), "slider min");
|
||||
assert_eq!(nums[1], benilla_world::view::FARCLIP_MAX, "slider max");
|
||||
assert_eq!(nums[1], *range.end());
|
||||
assert_eq!((nums[1] - nums[0]) % nums[2], 0.0, "the step lands on the maximum");
|
||||
}
|
||||
|
||||
@@ -135,7 +135,14 @@ impl Plugin for VolumetricFogPlugin {
|
||||
.insert_resource(FogShader(shader))
|
||||
.init_resource::<SpecializedRenderPipelines<FogPipeline>>()
|
||||
.add_systems(RenderStartup, init_pipeline)
|
||||
.add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
|
||||
.add_systems(
|
||||
Render,
|
||||
(
|
||||
prepare_pipelines.in_set(RenderSystems::Prepare),
|
||||
// MONKEY (reviewfix-a): one uniform per view, rewritten, not allocated per frame.
|
||||
prepare_fog_uniforms.in_set(RenderSystems::PrepareResources),
|
||||
),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<FogNode>>(Core3d, FogLabel)
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
@@ -289,6 +296,12 @@ fn update_fog(
|
||||
} else {
|
||||
FogLamps::default()
|
||||
};
|
||||
// MONKEY (reviewfix-a): no haze tier and no lamp in range is an identity pass — skip the
|
||||
// node (and its depth bind and ping-pong) rather than run it every frame.
|
||||
if tier == 0 && lamps.count == 0 {
|
||||
commands.entity(entity).remove::<FogView>();
|
||||
continue;
|
||||
}
|
||||
let fog = Vec3::from_array(lighting.fog_color);
|
||||
// MONKEY (fog): rows 1-3 as packed for the light buffer.
|
||||
let rows = monkey.as_ref().map_or([[0.0; 4]; 16], |m| m.pack(0.0, 0.0));
|
||||
@@ -323,6 +336,31 @@ fn update_fog(
|
||||
|
||||
#[derive(Resource)]
|
||||
struct FogShader(Handle<Shader>);
|
||||
|
||||
/// MONKEY (reviewfix-a): the view's fog uniform, created once and rewritten in prepare.
|
||||
#[derive(Component)]
|
||||
struct FogUniform(UniformBuffer<FogView>);
|
||||
|
||||
fn prepare_fog_uniforms(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
mut views: Query<(Entity, &FogView, Option<&mut FogUniform>)>,
|
||||
) {
|
||||
for (entity, fog, uniform) in &mut views {
|
||||
match uniform {
|
||||
Some(mut uniform) => {
|
||||
uniform.0.set(*fog);
|
||||
uniform.0.write_buffer(&device, &queue);
|
||||
}
|
||||
None => {
|
||||
let mut uniform = UniformBuffer::from(*fog);
|
||||
uniform.write_buffer(&device, &queue);
|
||||
commands.entity(entity).insert(FogUniform(uniform));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Resource)]
|
||||
struct FogPipeline {
|
||||
layouts: [BindGroupLayoutDescriptor; 2],
|
||||
@@ -441,7 +479,7 @@ impl ViewNode for FogNode {
|
||||
&'static ViewUniformOffset,
|
||||
&'static ViewLightsUniformOffset,
|
||||
&'static ViewShadowBindings,
|
||||
&'static FogView,
|
||||
&'static FogUniform,
|
||||
&'static ViewFogPipeline,
|
||||
);
|
||||
fn run<'w>(
|
||||
@@ -474,8 +512,6 @@ impl ViewNode for FogNode {
|
||||
};
|
||||
let settings = world.resource::<FogPipeline>();
|
||||
let device = context.render_device();
|
||||
let mut uniform = UniformBuffer::from(*fog);
|
||||
uniform.write_buffer(device, world.resource::<RenderQueue>());
|
||||
let out = target.post_process_write();
|
||||
let bind = device.create_bind_group(
|
||||
"gamma_fog",
|
||||
@@ -489,7 +525,7 @@ impl ViewNode for FogNode {
|
||||
lights,
|
||||
&shadows.directional_light_depth_texture_view,
|
||||
&settings.comparison,
|
||||
uniform.binding().unwrap(),
|
||||
fog.0.binding().unwrap(),
|
||||
)),
|
||||
);
|
||||
let diagnostics = context.diagnostic_recorder();
|
||||
|
||||
@@ -6,7 +6,7 @@ use bevy::prelude::*;
|
||||
|
||||
/// The session override, read once.
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
struct ZoneSkyboxOverride(Option<bool>);
|
||||
pub(crate) struct ZoneSkyboxOverride(pub(crate) Option<bool>); // MONKEY (reviewfix-a): test census
|
||||
|
||||
pub(crate) struct ZoneSkyboxPlugin;
|
||||
|
||||
@@ -31,7 +31,7 @@ fn apply_override(
|
||||
}
|
||||
}
|
||||
|
||||
fn on_cvar(
|
||||
pub(crate) fn on_cvar(
|
||||
ev: On<crate::cvars::CvarChanged>,
|
||||
forced: Res<ZoneSkyboxOverride>,
|
||||
zone: Option<ResMut<benilla_world::skybox::ZoneSkyboxes>>,
|
||||
|
||||
@@ -91,6 +91,8 @@ fn water_rain_open(world_pos: vec3<f32>) -> f32 {
|
||||
struct WaterFragment {
|
||||
clip_position: vec4<f32>,
|
||||
world_position: vec4<f32>,
|
||||
// MONKEY (reviewfix): world XZ before `water_swell`, so fragment waves share its source domain.
|
||||
source_xz: vec2<f32>,
|
||||
// The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0.
|
||||
depth: f32,
|
||||
// MONKEY (water): an interior WMO pool's authored MOMT diffuse colour; white on other lanes.
|
||||
@@ -132,9 +134,25 @@ fn water_time() -> f32 {
|
||||
|
||||
// Vertex Gerstner displacement for the ocean's long swell (zero everywhere else).
|
||||
// `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract.
|
||||
fn water_swell(xz: vec2<f32>, authored_depth: f32) -> vec3<f32> {
|
||||
fn water_swell(xz: vec2<f32>, authored_depth: f32, stitch: f32) -> vec3<f32> {
|
||||
if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 {
|
||||
return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz;
|
||||
let now = water_time();
|
||||
let shore = swell_shore_fade(authored_depth);
|
||||
// MONKEY (reviewfix): a tagged fine boundary vertex follows the straight edge between the
|
||||
// two displaced coarse endpoints. That closes the T-junction against a coarse neighbour.
|
||||
if stitch >= 1.0 {
|
||||
let lane = u32(floor(stitch));
|
||||
let along = fract(stitch);
|
||||
var direction = vec2<f32>(1.0, 0.0);
|
||||
if lane == 2u { direction = vec2<f32>(-1.0, 0.0); }
|
||||
if lane == 3u { direction = vec2<f32>(0.0, 1.0); }
|
||||
if lane == 4u { direction = vec2<f32>(0.0, -1.0); }
|
||||
let lo = xz - direction * along * 4.1666665;
|
||||
let hi = lo + direction * 4.1666665;
|
||||
return mix(water_gerstner(lo, now, shore).xyz,
|
||||
water_gerstner(hi, now, shore).xyz, along);
|
||||
}
|
||||
return water_gerstner(xz, now, shore).xyz;
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
@@ -539,7 +557,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
|
||||
}
|
||||
let t = water_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase.
|
||||
let fog = water_fog(in.world_position.xyz, in.room_fog);
|
||||
let p = in.world_position.xz;
|
||||
let p = in.source_xz;
|
||||
let energy = clamp(water.mode.y, 0.0, 1.0);
|
||||
let ocean_mesh = water.lane.y > 0.5 && water.lane.x < 0.5;
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ struct LiquidVsOut {
|
||||
@location(5) vcolor: vec4<f32>,
|
||||
// `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`.
|
||||
@location(6) @interpolate(flat) room_fog: u32,
|
||||
// MONKEY (reviewfix): the analytic water domain before the ocean's vertex displacement.
|
||||
@location(7) source_xz: vec2<f32>,
|
||||
}
|
||||
|
||||
// Sun sheen (`secondary`): the Blinn highlight `light_spec.rgb · (N·H)^shininess`.
|
||||
@@ -147,8 +149,9 @@ fn vertex(in: Vertex) -> LiquidVsOut {
|
||||
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
|
||||
out.world_position =
|
||||
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(in.position, 1.0));
|
||||
out.source_xz = out.world_position.xz;
|
||||
// MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces.
|
||||
out.world_position += vec4<f32>(water_swell(out.world_position.xz, in.uv_b.x), 0.0);
|
||||
out.world_position += vec4<f32>(water_swell(out.source_xz, in.uv_b.x, in.uv_b.y), 0.0);
|
||||
out.clip_position = position_world_to_clip(out.world_position.xyz);
|
||||
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
|
||||
out.uv = in.uv;
|
||||
@@ -221,7 +224,8 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
return enhanced_water(
|
||||
// MONKEY (water): the enhanced interior arm consumes the MOMT colour already carried
|
||||
// by the reference fragment interface; exterior/ADT vertices supply white.
|
||||
WaterFragment(in.clip_position, in.world_position, in.depth, in.vcolor, in.room_fog),
|
||||
WaterFragment(in.clip_position, in.world_position, in.source_xz, in.depth, in.vcolor,
|
||||
in.room_fog),
|
||||
shallow_enhanced, deep_enhanced);
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,9 @@ pub struct LightCatalog {
|
||||
/// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along
|
||||
/// ([`SkyboxDef`]), read from either table layout.
|
||||
skyboxes: HashMap<u32, SkyboxDef>,
|
||||
/// MONKEY (reviewfix): normalised model path в†’ lowest matching `LightSkybox` id. Some patched
|
||||
/// tables alias a model with different flags; path lookup must not depend on `HashMap` order.
|
||||
skybox_by_path: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
fn light_schema() -> Schema {
|
||||
@@ -328,6 +331,7 @@ impl LightCatalog {
|
||||
};
|
||||
// MONKEY (skybox): both layouts, keyed by the header's field count.
|
||||
let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?;
|
||||
let skybox_by_path = skybox::index_paths(&skyboxes);
|
||||
Ok(LightCatalog {
|
||||
lights,
|
||||
int_bands,
|
||||
@@ -337,6 +341,7 @@ impl LightCatalog {
|
||||
light_params_water_alpha,
|
||||
light_params_skybox,
|
||||
skyboxes,
|
||||
skybox_by_path,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,19 @@ pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result<HashMap<u32
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix): build the deterministic reverse index used by WMO and ghost path lookups.
|
||||
/// The lowest id wins when patched tables alias one model with multiple rows.
|
||||
pub(super) fn index_paths(skyboxes: &HashMap<u32, SkyboxDef>) -> HashMap<String, u32> {
|
||||
let mut by_path = HashMap::with_capacity(skyboxes.len());
|
||||
for (&id, def) in skyboxes {
|
||||
by_path
|
||||
.entry(def.path.to_ascii_lowercase())
|
||||
.and_modify(|old: &mut u32| *old = (*old).min(id))
|
||||
.or_insert(id);
|
||||
}
|
||||
by_path
|
||||
}
|
||||
|
||||
/// The modern collector's step: dedupe by model with the larger alpha, then scale every other
|
||||
/// entry by `1 − alpha` (`addSkyBox` steps 1 and 4).
|
||||
pub(super) fn collect(list: &mut Vec<ZoneSkybox>, id: u32, alpha: f32) {
|
||||
@@ -126,9 +139,10 @@ impl LightCatalog {
|
||||
|
||||
/// The row whose model is `path`, for a MOSB skybox the table also names.
|
||||
pub fn skybox_def_by_path(&self, path: &str) -> Option<&SkyboxDef> {
|
||||
self.skyboxes
|
||||
.values()
|
||||
.find(|d| d.path.eq_ignore_ascii_case(path))
|
||||
let path = crate::models::model_path(path).to_ascii_lowercase();
|
||||
self.skybox_by_path
|
||||
.get(&path)
|
||||
.and_then(|id| self.skyboxes.get(id))
|
||||
}
|
||||
|
||||
/// The skybox the slot's param names; a weather slot without one falls back to the clear
|
||||
@@ -234,6 +248,23 @@ mod tests {
|
||||
assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_paths_resolve_to_the_lowest_id() {
|
||||
let def = |path: &str| SkyboxDef {
|
||||
path: path.into(),
|
||||
flags: 0,
|
||||
celestial: None,
|
||||
};
|
||||
let skyboxes = HashMap::from([
|
||||
(17, def(r"environments\stars\shared.m2")),
|
||||
(3, def(r"ENVIRONMENTS\STARS\SHARED.M2")),
|
||||
(9, def(r"environments\stars\other.m2")),
|
||||
]);
|
||||
let paths = index_paths(&skyboxes);
|
||||
assert_eq!(paths.get(r"environments\stars\shared.m2"), Some(&3));
|
||||
assert_eq!(paths.get(r"environments\stars\other.m2"), Some(&9));
|
||||
}
|
||||
|
||||
/// The installed chain loads in either layout.
|
||||
#[test]
|
||||
fn the_installed_table_loads() {
|
||||
|
||||
@@ -492,16 +492,20 @@ pub fn parse_m2_render_submeshes(
|
||||
.get(batch.texture_coord_combo_index as usize + 1)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let combo = batch.texture_transform_combo_index.wrapping_add(1);
|
||||
// MONKEY (reviewfix): 0xffff is the no-transform sentinel. Advancing stage 1 must
|
||||
// keep it absent rather than wrap it onto stage 0's transform at index zero.
|
||||
let combo = batch.texture_transform_combo_index.checked_add(1);
|
||||
Some((
|
||||
resolve_texture(rec, dir, skins).0,
|
||||
(rec.wrap_x, rec.wrap_y),
|
||||
set == 1,
|
||||
batch.shader_id & 0xf == 4,
|
||||
tex_anim::bake_uv_anim(model, combo, seq0_slot),
|
||||
tex_anim::bake_uv_rot_seqs(model, combo, &seq_slots)
|
||||
combo.and_then(|combo| tex_anim::bake_uv_anim(model, combo, seq0_slot)),
|
||||
combo
|
||||
.and_then(|combo| tex_anim::bake_uv_rot_seqs(model, combo, &seq_slots))
|
||||
.and_then(|s| s.seq(None).cloned()),
|
||||
tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots)
|
||||
combo
|
||||
.and_then(|combo| tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots))
|
||||
.and_then(|s| s.seq(None).cloned()),
|
||||
))
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
//! `0x58ac70` upload): a 12-ring cap from the pole to the 45° rim, faded at the rim, drawn last in
|
||||
//! the sky pass so clouds blend over a setting sun. [`crate::sky_order::CLOUDS_BIAS`] holds that
|
||||
//! place; the shared far-depth pin puts it behind all terrain.
|
||||
//!
|
||||
//! The sun march feed (`update_cloud_fx`):
|
||||
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp.
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::camera::Projection;
|
||||
@@ -12,7 +15,7 @@ use bevy::pbr::{
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{
|
||||
AsBindGroup, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
AsBindGroup, Buffer, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
TextureDimension, TextureFormat,
|
||||
};
|
||||
use bevy::shader::ShaderRef;
|
||||
@@ -35,13 +38,18 @@ pub struct CloudExt {
|
||||
#[texture(100)]
|
||||
#[sampler(101)]
|
||||
pub(crate) texels: Handle<Image>,
|
||||
/// MONKEY (sky): `x` the sky tier (detail at 2), `y` the sky clock, `zw` the unit direction
|
||||
/// toward the glow body on the sheet ([`update_cloud_fx`]).
|
||||
/// MONKEY (sky): `x` the sky tier (detail at 2), `y` reserved (0; the clock is
|
||||
/// [`Self::clock`]), `zw` the unit direction toward the glow body on the sheet
|
||||
/// ([`update_cloud_fx`]).
|
||||
#[uniform(102)]
|
||||
pub(crate) fx: Vec4,
|
||||
/// MONKEY (sky): the Light.dbc cloud sun colour (`rgb`, gamma) and the march strength (`w`).
|
||||
#[uniform(102)]
|
||||
pub(crate) lit: Vec4,
|
||||
/// MONKEY (polish): the shared sky clock ([`crate::sky_fx::SkyClockBuffer`]), outside the
|
||||
/// uniform so the drift never rewrites the material.
|
||||
#[storage(103, read_only, buffer)]
|
||||
pub(crate) clock: Buffer,
|
||||
}
|
||||
|
||||
/// MONKEY (sky): the pipeline key: High compiles `SKY_FX_CLOUDS` into `cloud.wgsl`; below High
|
||||
@@ -159,7 +167,11 @@ pub(super) fn setup_cloud_layer(
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<CloudMaterial>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
clock: Option<Res<crate::sky_fx::SkyClockBuffer>>,
|
||||
) {
|
||||
let Some(clock) = clock else {
|
||||
return;
|
||||
};
|
||||
let image = images.add(Image::new(
|
||||
Extent3d {
|
||||
width: COLS as u32,
|
||||
@@ -186,6 +198,7 @@ pub(super) fn setup_cloud_layer(
|
||||
// MONKEY (sky): Classic until `update_cloud_fx` reads the tier.
|
||||
fx: Vec4::ZERO,
|
||||
lit: Vec4::ZERO,
|
||||
clock: clock.0.clone(),
|
||||
},
|
||||
});
|
||||
commands.spawn((
|
||||
@@ -251,7 +264,6 @@ pub(super) fn follow_cloud_dome(
|
||||
pub(super) fn update_cloud_fx(
|
||||
light: Res<crate::lighting::WowLighting>,
|
||||
quality: Res<crate::sky_fx::SkyQuality>,
|
||||
clock: Res<crate::sky_fx::SkyClock>,
|
||||
layer: Option<Res<CloudLayer>>,
|
||||
mut materials: ResMut<Assets<CloudMaterial>>,
|
||||
) {
|
||||
@@ -268,7 +280,7 @@ pub(super) fn update_cloud_fx(
|
||||
let q = |v: f32| benilla_assets::quantize(v, 4096.0);
|
||||
let sun = light.cloud_colors[0];
|
||||
(
|
||||
Vec4::new(f32::from(quality.0), clock.secs, q(d.x), q(d.y)),
|
||||
Vec4::new(f32::from(quality.0), 0.0, q(d.x), q(d.y)),
|
||||
Vec4::new(q(sun[0]), q(sun[1]), q(sun[2]), q(strength.clamp(0.0, 1.0))),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1811,9 +1811,14 @@ impl BleedSeed {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (perf): seconds between two bleed evaluations (see [`update_bleed_fixtures`]).
|
||||
const BLEED_EVAL_PERIOD: f64 = 0.1;
|
||||
|
||||
/// One interior-lane source as the bleed evaluation needs it — the packer's own arithmetic, minus
|
||||
/// the flicker (a bleed must not breathe with the hearth it carries: the pool it feeds is a whole
|
||||
/// doorway, and a wobbling doorway is a wobbling building).
|
||||
// MONKEY (perf): Copy, so the per-placement buckets in `update_bleed_fixtures` can hold rows.
|
||||
#[derive(Clone, Copy)]
|
||||
struct BleedSource<'a> {
|
||||
pos: Vec3,
|
||||
/// Committed colour, NORMALISED like the shader's `c_norm`.
|
||||
@@ -1981,7 +1986,22 @@ pub fn update_bleed_fixtures(
|
||||
>,
|
||||
time: Res<Time>,
|
||||
mut last_dump: Local<f64>,
|
||||
// MONKEY (perf): the 10 Hz cadence below, and the arrivals that bypass it.
|
||||
mut next_eval: Local<f64>,
|
||||
added: Query<(), Added<BleedFixture>>,
|
||||
) {
|
||||
// MONKEY (perf): a doorway carries the time of day and its rooms' static fixtures, so it is
|
||||
// re-evaluated at 10 Hz rather than every frame (~0.5 ms/frame of main thread in Northshire at
|
||||
// High). A new doorway, a dial move or the lane switching runs it at once.
|
||||
let now_s = time.elapsed_secs_f64();
|
||||
let urgent = !added.is_empty()
|
||||
|| knobs.is_changed()
|
||||
|| fire_gain.is_changed()
|
||||
|| spell_gain.is_changed();
|
||||
if !urgent && now_s < *next_eval && now_s >= *next_eval - BLEED_EVAL_PERIOD {
|
||||
return;
|
||||
}
|
||||
*next_eval = now_s + BLEED_EVAL_PERIOD;
|
||||
// The room lane being off is the feature being off, exactly as for a daylight fixture.
|
||||
let on = knobs.enabled && bleed_enabled();
|
||||
// The packed fill gain — `interiorFill x interiorGain`, the same product `build_light_data`
|
||||
@@ -2057,6 +2077,24 @@ pub fn update_bleed_fixtures(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// MONKEY (perf): bucket the sources per placement once. `claim_weight` gives a source that
|
||||
// claims ANOTHER placement weight 0, so each doorway only needs its own placement's claimants
|
||||
// plus the ungated ones — same rows, same order, same sums. Walking every interior source in
|
||||
// the world four times per doorway measured ~1 ms/frame in Northshire at High.
|
||||
let mut by_placement: HashMap<Entity, Vec<BleedSource<'_>>> =
|
||||
live.iter().map(|i| (*i, Vec::new())).collect();
|
||||
for s in &src {
|
||||
match s.claims.filter(|c| !c.rooms.groups.is_empty()) {
|
||||
Some(c) => {
|
||||
if let Some(v) = by_placement.get_mut(&c.rooms.instance) {
|
||||
v.push(*s);
|
||||
}
|
||||
}
|
||||
None => by_placement.values_mut().for_each(|v| v.push(*s)),
|
||||
}
|
||||
}
|
||||
let empty: Vec<BleedSource<'_>> = Vec::new();
|
||||
let placement_src = |instance: Entity| by_placement.get(&instance).unwrap_or(&empty).as_slice();
|
||||
|
||||
/// One bleed fixture's per-frame working row — the two passes' shared scratch.
|
||||
struct Row {
|
||||
@@ -2082,8 +2120,8 @@ pub fn update_bleed_fixtures(
|
||||
let r_eff = super::interior_reach(reach.map_or(fx.reach, |r| r.0), knobs.atten_scale);
|
||||
let lit = if on {
|
||||
brighter(
|
||||
room_irradiance(&src, &[], bl.probe, fx.instance, bl.sides[0], k_fill),
|
||||
room_irradiance(&src, &[], bl.probe, fx.instance, bl.sides[1], k_fill),
|
||||
room_irradiance(placement_src(fx.instance), &[], bl.probe, fx.instance, bl.sides[0], k_fill),
|
||||
room_irradiance(placement_src(fx.instance), &[], bl.probe, fx.instance, bl.sides[1], k_fill),
|
||||
)
|
||||
} else {
|
||||
[0.0; 3]
|
||||
@@ -2133,7 +2171,7 @@ pub fn update_bleed_fixtures(
|
||||
let lit = if on {
|
||||
brighter(
|
||||
room_irradiance(
|
||||
&src,
|
||||
placement_src(r.instance),
|
||||
&side_extra(r.sides[0]),
|
||||
r.probe,
|
||||
r.instance,
|
||||
@@ -2141,7 +2179,7 @@ pub fn update_bleed_fixtures(
|
||||
k_fill,
|
||||
),
|
||||
room_irradiance(
|
||||
&src,
|
||||
placement_src(r.instance),
|
||||
&side_extra(r.sides[1]),
|
||||
r.probe,
|
||||
r.instance,
|
||||
@@ -2164,8 +2202,12 @@ pub fn update_bleed_fixtures(
|
||||
};
|
||||
match pl {
|
||||
Some(mut pl) if *intensity > 1e-4 => {
|
||||
pl.color = *hue;
|
||||
pl.intensity = 4.0 * std::f32::consts::PI * intensity;
|
||||
// MONKEY (perf): write only a real change, so the packer's change ticks stay quiet.
|
||||
let packed = 4.0 * std::f32::consts::PI * intensity;
|
||||
if pl.color != *hue || pl.intensity != packed {
|
||||
pl.color = *hue;
|
||||
pl.intensity = packed;
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
commands.entity(r.e).remove::<WorldPointLight>();
|
||||
|
||||
@@ -67,7 +67,7 @@ struct LightStd430 {
|
||||
rows: [[f32; 4]; LIGHT_HEADER_ROWS],
|
||||
points: [[f32; 4]; 2 * MAX_POINT_LIGHTS],
|
||||
// MONKEY (p0 MonkeyFrame): the programme block, appended AFTER the point table so no earlier
|
||||
// offset moves (8528 -> 8784 B); packed by [`pack_monkey_frame`] from [`super::MonkeyFrame`].
|
||||
// offset moves (the per-frame prefix is now 8784 B); packed by [`pack_monkey_frame`].
|
||||
monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS],
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ pub fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
|
||||
/// interior set; [`build_light_data`] packs the nearest-to-camera first when over capacity.
|
||||
///
|
||||
/// This is the BUFFER shape (`LightStd430` and [`RoomClaimTable`] size against it) and it must not
|
||||
/// move: the blob is 8528 B and is mirrored by three shaders plus the portrait booth. How many of
|
||||
/// those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
|
||||
/// move: the per-frame blob is 8784 B and is mirrored by the shaders plus the portrait booth. How
|
||||
/// many of those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
|
||||
pub(super) const MAX_POINT_LIGHTS: usize = 256;
|
||||
|
||||
/// MONKEY (ext light k8): how many of [`MAX_POINT_LIGHTS`] slots may hold a LIVE entry — **255**,
|
||||
@@ -140,7 +140,7 @@ pub(super) const MAX_POINT_LIGHTS: usize = 256;
|
||||
///
|
||||
/// So the pack truncates one entry earlier. The lights are sorted nearest-camera-first before the
|
||||
/// truncation, so the one this drops is the farthest of a 256-strong set — sub-pixel and usually
|
||||
/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8528 B
|
||||
/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8784 B
|
||||
/// blob or the room-claim table changes.
|
||||
pub(super) const MAX_LIVE_POINT_LIGHTS: usize = MAX_POINT_LIGHTS - 1;
|
||||
|
||||
@@ -478,7 +478,7 @@ impl Default for FireLightGain {
|
||||
}
|
||||
|
||||
/// MONKEY (post): live `bloom` tier. The packer adds it to `light_diffuse.w`, whose old value 1
|
||||
/// was an unread clamp marker, so the 8528-byte shared buffer and every existing decode stay put.
|
||||
/// was an unread clamp marker, so the 8784-byte per-frame prefix and every existing decode stay put.
|
||||
#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug)]
|
||||
pub struct EmissiveTier(pub u8);
|
||||
|
||||
@@ -897,7 +897,7 @@ pub(super) struct ShadowLanes<'w> {
|
||||
/// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both
|
||||
/// directional-shadow weights.
|
||||
///
|
||||
/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three
|
||||
/// **There is still no free f32** ([`LightStd430`] is 8784 B, pinned by tests and mirrored by three
|
||||
/// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The
|
||||
/// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the
|
||||
/// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees
|
||||
@@ -931,7 +931,7 @@ fn unpack_shadow_lane(w: f32) -> (f32, f32) {
|
||||
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
|
||||
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
|
||||
///
|
||||
/// **There was no free f32 left.** [`LightStd430`] is 8528 B, mirrored by three shaders plus the
|
||||
/// **There was no free f32 left.** [`LightStd430`] is 8784 B, mirrored by three shaders plus the
|
||||
/// portrait booth, and must not grow; every `.w` in rows 0..=20 is spoken for (the Mod2x/clamp/SH
|
||||
/// enables, terrain shininess, fog enable, farclip, the ambient DC lanes, `sh_c13_*.w` which the
|
||||
/// SH `dot(row, quad)` reads as a real band, the world-shadow flag, the shadow distance, the
|
||||
@@ -963,7 +963,7 @@ pub const DAYLIGHT_LANE_SCALE: f32 = 0.49;
|
||||
/// MONKEY (bake floor): how [`DynamicInteriors::bake_floor`] rides to the shader — as the
|
||||
/// FRACTIONAL part of the packed `sh_c16.w` lane, `w = world_shadow_flag + bake × this`.
|
||||
///
|
||||
/// **There is still no free f32.** [`LightStd430`] is 8528 B and mirrored by three shaders plus the
|
||||
/// **There is still no free f32.** [`LightStd430`] is 8784 B and mirrored by three shaders plus the
|
||||
/// portrait booth (see [`DAYLIGHT_LANE_SCALE`] for the full accounting of why it must not grow).
|
||||
/// `wmo_fog_params.w` — the lane the daylight floor rides — is now spent: its integer part is
|
||||
/// `1 + interiorDebug` and its fraction is `interiorDaylight × 0.49`, and two fractions cannot
|
||||
@@ -1360,7 +1360,7 @@ fn build_light_data(
|
||||
// MONKEY (darkness gains): `nightGain` — one live dim over everything the EXTERIOR law lights
|
||||
// (terrain, models, WMO exteriors, and the ext-class night blend, all of which derive from the
|
||||
// ambient/diffuse/specular rows below). Folded CPU-side into those packed rows rather than
|
||||
// added as a shader uniform because `LightStd430` has no free lane left (8528 B, mirrored by
|
||||
// added as a shader uniform because `LightStd430` has no free lane left (8784 B, mirrored by
|
||||
// three WGSL structs) — and a pack-time fold costs the GPU exactly nothing anyway.
|
||||
//
|
||||
// The ramp is the DUSK CLOCK every other night feature already fades on: `sun_w` is
|
||||
@@ -1437,7 +1437,7 @@ fn build_light_data(
|
||||
// the dim and the shadow fade can never drift onto two different dusk clocks.
|
||||
// MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one.
|
||||
// The body may have changed abruptly this frame; the shared state holds zero until the new
|
||||
// basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes.
|
||||
// basis has propagated and then ramps. One signed float still keeps the prefix at 8784 bytes.
|
||||
rows[5][2] = shadow.handover.weight;
|
||||
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
|
||||
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
|
||||
@@ -2717,14 +2717,48 @@ mod tests {
|
||||
assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders");
|
||||
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
|
||||
// 21 header rows + 2 x 256 point rows, 16 B each.
|
||||
// MONKEY (p0 MonkeyFrame): 8528 + the 256-byte programme block.
|
||||
// MONKEY (p0 MonkeyFrame): the fixed rows plus the 256-byte programme block.
|
||||
assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn programme_and_shelter_shader_literals_match_the_rust_layout() {
|
||||
const WATER: &str = include_str!("../../../benilla-assets/src/shaders/enhanced_water.wgsl");
|
||||
const TERRAIN: &str = include_str!("../../../benilla-assets/src/shaders/terrain.wgsl");
|
||||
const MODEL: &str = include_str!("../../../benilla-assets/src/shaders/wow_model.wgsl");
|
||||
const STATIC_GX: &str = include_str!("../shaders/static_gx.wgsl");
|
||||
|
||||
let monkey_row = LIGHT_HEADER_ROWS + 2 * MAX_POINT_LIGHTS;
|
||||
let shelter_row = monkey_row + super::super::monkey_frame::MONKEY_FRAME_ROWS;
|
||||
assert!(
|
||||
WATER.contains(&format!("const MONKEY_ROW: u32 = {monkey_row}u;")),
|
||||
"enhanced_water.wgsl MONKEY_ROW must follow the header and point table"
|
||||
);
|
||||
assert!(
|
||||
WATER.contains(&format!("const SHELTER_ROW: u32 = {shelter_row}u;")),
|
||||
"enhanced_water.wgsl SHELTER_ROW must follow MonkeyFrame"
|
||||
);
|
||||
|
||||
let shelter_cells = crate::weather::shelter::GRID * crate::weather::shelter::GRID;
|
||||
let declaration = format!("array<u32, {shelter_cells}>");
|
||||
for (name, shader) in [
|
||||
("terrain.wgsl", TERRAIN),
|
||||
("wow_model.wgsl", MODEL),
|
||||
("static_gx.wgsl", STATIC_GX),
|
||||
] {
|
||||
assert!(
|
||||
shader.contains(&declaration),
|
||||
"{name} shelter length must equal shelter::GRID squared"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's
|
||||
/// on/off word, and every existing decode of that word must be blind to it.
|
||||
///
|
||||
/// This is the test the feature stands on: there was no free `f32` left in an 8528-byte layout
|
||||
/// This is the test the feature stands on: there was no free `f32` left in the 8784-byte layout
|
||||
/// three shaders mirror, so the value shares a lane with two other facts. If the fraction ever
|
||||
/// grew past 0.5 — a wider `DAYLIGHT_LANE_SCALE`, a `daylight` that escaped its clamp — the
|
||||
/// debug decode `u32(max(w - 1, 0) + 0.5)` would round UP and every building in the frame would
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//!
|
||||
//! Wgpu has no tessellation stage. The coarse grid therefore remains the streamed source of
|
||||
//! truth, while a 4x bilinear copy is built only when the camera enters the surface's near ring.
|
||||
//! Every original edge vertex survives bit-for-bit, so a fine chunk and its coarse neighbour share
|
||||
//! the same boundary. Hysteresis avoids rebuilding while the camera hovers at the threshold.
|
||||
//! Every original edge vertex survives bit-for-bit. Non-corner fine vertices on an outer edge carry
|
||||
//! a compact stitch tag in UV1.y, so the vertex shader lerps the displaced coarse endpoints instead
|
||||
//! of evaluating a new Gerstner point that can leave the neighbour's straight edge. Hysteresis
|
||||
//! avoids rebuilding while the camera hovers at the threshold.
|
||||
//! A full MCLQ copy is 1,089 vertices + 6,144 indices (about 68 KiB without allocator overhead);
|
||||
//! the 64 yd ring bounds ordinary terrain residency to roughly 3.5 MiB, and a per-surface ceiling
|
||||
//! refuses pathological MLIQ grids rather than allowing one pool to consume an unbounded copy.
|
||||
@@ -135,6 +137,46 @@ fn bilerp<const N: usize>(
|
||||
out
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix): encode an outer-edge fine vertex as `axis/sign + coarse-edge fraction`.
|
||||
/// UV1.y is otherwise zero. Coarse corners and interior fine vertices need no stitch.
|
||||
fn stitch_tag(
|
||||
positions: &[[f32; 3]],
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
fine_cols: usize,
|
||||
fine_rows: usize,
|
||||
fx: usize,
|
||||
fy: usize,
|
||||
sx: usize,
|
||||
sy: usize,
|
||||
tx: f32,
|
||||
ty: f32,
|
||||
) -> f32 {
|
||||
let (a, b, t) = if (fy == 0 || fy + 1 == fine_rows) && fx % SUBDIVISIONS != 0 {
|
||||
let y = if fy == 0 { 0 } else { rows - 1 };
|
||||
(y * cols + sx, y * cols + sx + 1, tx)
|
||||
} else if (fx == 0 || fx + 1 == fine_cols) && fy % SUBDIVISIONS != 0 {
|
||||
let x = if fx == 0 { 0 } else { cols - 1 };
|
||||
(sy * cols + x, (sy + 1) * cols + x, ty)
|
||||
} else {
|
||||
return 0.0;
|
||||
};
|
||||
let dx = positions[b][0] - positions[a][0];
|
||||
let dz = positions[b][2] - positions[a][2];
|
||||
let lane = if dx.abs() >= dz.abs() {
|
||||
if dx >= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
2.0
|
||||
}
|
||||
} else if dz >= 0.0 {
|
||||
3.0
|
||||
} else {
|
||||
4.0
|
||||
};
|
||||
lane + t
|
||||
}
|
||||
|
||||
/// Four-way subdivision of every source cell. The output is still one indexed regular grid: there
|
||||
/// are no duplicated cell-edge vertices, and dry source cells emit no triangles.
|
||||
fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option<Mesh> {
|
||||
@@ -185,7 +227,11 @@ fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option<Mesh> {
|
||||
let tx = (fx - sx * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32;
|
||||
fine_positions.push(bilerp(positions, cols, sx, sy, tx, ty));
|
||||
fine_uv0.push(bilerp(uv0, cols, sx, sy, tx, ty));
|
||||
fine_uv1.push(bilerp(uv1, cols, sx, sy, tx, ty));
|
||||
let mut uv1 = bilerp(uv1, cols, sx, sy, tx, ty);
|
||||
uv1[1] = stitch_tag(
|
||||
positions, cols, rows, fine_cols, fine_rows, fx, fy, sx, sy, tx, ty,
|
||||
);
|
||||
fine_uv1.push(uv1);
|
||||
if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) {
|
||||
output.push(bilerp(source, cols, sx, sy, tx, ty));
|
||||
}
|
||||
@@ -265,6 +311,14 @@ mod tests {
|
||||
assert_eq!(positions[20], [0.0, 0.0, 1.0]);
|
||||
assert_eq!(positions[24], [1.0, 1.0, 1.0]);
|
||||
assert_eq!(positions[12], [0.5, 0.25, 0.5]);
|
||||
let Some(VertexAttributeValues::Float32x2(uv1)) = fine.attribute(Mesh::ATTRIBUTE_UV_1)
|
||||
else {
|
||||
panic!("uv1");
|
||||
};
|
||||
assert_eq!(uv1[0][1], 0.0, "coarse corner");
|
||||
assert_eq!(uv1[2][1], 1.5, "top-edge midpoint, +x at t=0.5");
|
||||
assert_eq!(uv1[10][1], 3.5, "left-edge midpoint, +z at t=0.5");
|
||||
assert_eq!(uv1[12][1], 0.0, "interior fine vertex");
|
||||
assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var cloud_tex: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var cloud_samp: sampler;
|
||||
|
||||
// MONKEY (sky): `fx.x` the sky tier, `.y` the sky clock (s), `.zw` the unit direction toward the
|
||||
// MONKEY (sky): `fx.x` the sky tier, `.y` reserved, `.zw` the unit direction toward the
|
||||
// glow body on the sheet (u = world x, v = world z); `lit.rgb` the Light.dbc cloud sun colour
|
||||
// (gamma), `.w` the march strength (the body's flatness × the glow envelope).
|
||||
struct CloudFx {
|
||||
@@ -22,6 +22,8 @@ struct CloudFx {
|
||||
lit: vec4<f32>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> cfx: CloudFx;
|
||||
// MONKEY (polish): x = the sky clock (s, wrapped at a day), written per frame outside the material.
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var<storage, read> sky_clock: vec4<f32>;
|
||||
|
||||
#ifdef SKY_FX_CLOUDS
|
||||
// MONKEY (sky): coverage with detail at a sheet point; `oct` detail octaves.
|
||||
@@ -30,7 +32,7 @@ fn detailed_cover(uv: vec2<f32>, oct: i32) -> f32 {
|
||||
if (a <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, cfx.fx.y, oct));
|
||||
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, sky_clock.x, oct));
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -43,7 +45,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// kernel's texels as they are.
|
||||
#ifdef SKY_FX_CLOUDS
|
||||
if (cfx.fx.x >= 1.5) {
|
||||
let n = sky_fx::cloud_detail(in.uv, cfx.fx.y, 4);
|
||||
let n = sky_fx::cloud_detail(in.uv, sky_clock.x, 4);
|
||||
a = sky_fx::cloud_erode(a, n);
|
||||
// A painterly tone inside the mass from the same detail.
|
||||
rgb = rgb * (0.93 + 0.14 * n);
|
||||
|
||||
@@ -21,7 +21,7 @@ struct SkyColors {
|
||||
sky4: vec4<f32>, // 1.8°
|
||||
fog: vec4<f32>, // horizon (0°) and below: LightIntBand row 7
|
||||
warp: vec4<f32>, // x = dawn/dusk warp strength S (0 = off), y = sun azimuth (rad), zw reserved
|
||||
// MONKEY (sky): x = skyQuality (0 Classic), y = sky clock (s), z = night-sky alpha, w = glow
|
||||
// MONKEY (sky): x = skyQuality (0 Classic), y = reserved, z = night-sky alpha, w = glow
|
||||
// strength.
|
||||
fx: vec4<f32>,
|
||||
// MONKEY (sky): xyz = camera to the visible sun, w unused.
|
||||
@@ -34,6 +34,8 @@ struct SkyColors {
|
||||
mf_fog_d: vec4<f32>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> sky: SkyColors;
|
||||
// MONKEY (polish): x = the sky clock (s, wrapped at a day), written per frame outside the material.
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var<storage, read> sky_clock: vec4<f32>;
|
||||
|
||||
// Glow `g` for a sun-relative azimuth phase, sampled linearly like `0x6d0f50` from the reference's
|
||||
// six-keyframe wrap-around table at `0xce9af8` (written by `0x6ce210`); 0.125 is the sun bearing.
|
||||
@@ -137,7 +139,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let px = length(fwidth(dir)) * sky_fx::STAR_GRID * 0.5;
|
||||
if (night > 0.0 && dir.y > 0.0) {
|
||||
let band = sky_fx::galaxy_band(dir);
|
||||
let stars = sky_fx::star_field(dir, sky.fx.y, max(px, 1e-4), band)
|
||||
let stars = sky_fx::star_field(dir, sky_clock.x, max(px, 1e-4), band)
|
||||
* smoothstep(0.0, 0.2, dir.y);
|
||||
let milky = sky_fx::milky_way(dir, band) * 0.05 * smoothstep(0.03, 0.35, dir.y);
|
||||
lin += (stars * 0.85 + milky) * night;
|
||||
|
||||
@@ -54,14 +54,18 @@ fn hash_cell(p: vec2<i32>, salt: u32) -> f32 {
|
||||
|
||||
// Value noise with the quintic fade: the cubic's second-derivative seam shows as square plateaus
|
||||
// on a slowly drifting sky (the WarcraftXL note).
|
||||
fn value_noise_plane(p: vec2<f32>, salt: u32) -> f32 {
|
||||
// MONKEY (polish): the lattice repeats every `period` cells, so a shift by `period` is no change.
|
||||
fn value_noise_plane(p: vec2<f32>, salt: u32, period: i32) -> f32 {
|
||||
let i = vec2<i32>(floor(p));
|
||||
let f = fract(p);
|
||||
let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
|
||||
let a = hash_cell(i, salt);
|
||||
let b = hash_cell(i + vec2<i32>(1, 0), salt);
|
||||
let c = hash_cell(i + vec2<i32>(0, 1), salt);
|
||||
let d = hash_cell(i + vec2<i32>(1, 1), salt);
|
||||
let per = vec2<i32>(period);
|
||||
let lo = ((i % per) + per) % per;
|
||||
let hi = (lo + vec2<i32>(1)) % per;
|
||||
let a = hash_cell(lo, salt);
|
||||
let b = hash_cell(vec2<i32>(hi.x, lo.y), salt);
|
||||
let c = hash_cell(vec2<i32>(lo.x, hi.y), salt);
|
||||
let d = hash_cell(hi, salt);
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
@@ -244,6 +248,15 @@ fn milky_way(dir: vec3<f32>, band: f32) -> vec3<f32> {
|
||||
|
||||
// ---- S3: cloud detail -------------------------------------------------------------------------
|
||||
|
||||
// MONKEY (polish): the detail field loops over the sky clock's wrap. It tiles every `CLOUD_TILE`
|
||||
// cells of the detail plane (octave o every `CLOUD_TILE << o` of its own lattice; the half-rate
|
||||
// warp every `CLOUD_TILE / 2`), and the drift covers whole tiles per wrap, so the field at
|
||||
// `SKY_WRAP` is the field at 0. One tile is 64 / 36 of the sheet, wider than the dome's UV.
|
||||
const CLOUD_TILE: i32 = 64;
|
||||
// Tiles of drift per wrap on u and v: 0.01111 and 0.00444 cells/s, WarcraftXL's 0.011 and 0.0043
|
||||
// rounded to whole tiles.
|
||||
const CLOUD_DRIFT_TILES: vec2<f32> = vec2<f32>(15.0, 6.0);
|
||||
|
||||
// WarcraftXL's billow blend: fold each octave toward rounded puffs by `cotton` (0.6).
|
||||
fn billow(v: f32) -> f32 {
|
||||
return v + (1.0 - abs(2.0 * v - 1.0) - v) * 0.6;
|
||||
@@ -256,7 +269,7 @@ fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
|
||||
var tot = 0.0;
|
||||
var q = p;
|
||||
for (var o = 0; o < octaves; o++) {
|
||||
s += a * billow(value_noise_plane(q, u32(o) * 57u));
|
||||
s += a * billow(value_noise_plane(q, u32(o) * 57u, CLOUD_TILE << u32(o)));
|
||||
tot += a;
|
||||
a *= 0.55;
|
||||
q = q * 2.0 + vec2<f32>(3.7, 1.9);
|
||||
@@ -267,8 +280,14 @@ fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
|
||||
// The detail field in [0, 1] on the sheet: a broad warp twists the domain before the billowed fbm
|
||||
// is read, which turns thresholded blobs into cauliflower edges.
|
||||
fn cloud_detail(uv: vec2<f32>, t: f32, octaves: i32) -> f32 {
|
||||
let p = uv * 36.0 + vec2<f32>(0.011, 0.0043) * t;
|
||||
let w = vec2<f32>(value_noise_plane(p * 0.5, 173u), value_noise_plane(p * 0.5 + 5.3, 219u)) - 0.5;
|
||||
// The drift modulo one tile: seamless at the wrap, and small enough to keep f32 precision.
|
||||
let drift = fract(CLOUD_DRIFT_TILES * (t / SKY_WRAP)) * f32(CLOUD_TILE);
|
||||
let p = uv * 36.0 + drift;
|
||||
let half_tile = CLOUD_TILE / 2;
|
||||
let w = vec2<f32>(
|
||||
value_noise_plane(p * 0.5, 173u, half_tile),
|
||||
value_noise_plane(p * 0.5 + 5.3, 219u, half_tile),
|
||||
) - 0.5;
|
||||
return cloud_billow(p + w * 2.6, octaves);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use bevy::pbr::{
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{
|
||||
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
AsBindGroup, Buffer, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
};
|
||||
use bevy::shader::ShaderRef;
|
||||
|
||||
@@ -57,7 +57,8 @@ pub struct SkyExt {
|
||||
#[uniform(100)]
|
||||
pub(crate) warp: Vec4,
|
||||
/// MONKEY (sky): `x` the [`crate::sky_fx::SkyQuality`] tier (0 = Classic, the fields below
|
||||
/// unused), `y` the sky clock in seconds, `z` the night-sky alpha, `w` the sun-glow strength.
|
||||
/// unused), `y` reserved (0; the clock is [`Self::clock`]), `z` the night-sky alpha, `w` the
|
||||
/// sun-glow strength.
|
||||
#[uniform(100)]
|
||||
pub(crate) fx: Vec4,
|
||||
/// MONKEY (sky): camera to the visible sun (`xyz`).
|
||||
@@ -73,6 +74,10 @@ pub struct SkyExt {
|
||||
pub(crate) mf_fog_c: Vec4,
|
||||
#[uniform(100)]
|
||||
pub(crate) mf_fog_d: Vec4,
|
||||
/// MONKEY (polish): the shared sky clock ([`crate::sky_fx::SkyClockBuffer`]), outside the
|
||||
/// uniform so the clock never rewrites the material.
|
||||
#[storage(101, read_only, buffer)]
|
||||
pub(crate) clock: Buffer,
|
||||
}
|
||||
|
||||
/// MONKEY (sky): the pipeline key: Enhanced/High compile `SKY_FX` into `sky.wgsl`; Classic keeps
|
||||
@@ -206,7 +211,11 @@ fn setup_sky(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<SkyMaterial>>,
|
||||
clock: Option<Res<crate::sky_fx::SkyClockBuffer>>,
|
||||
) {
|
||||
let Some(clock) = clock else {
|
||||
return;
|
||||
};
|
||||
let mesh = meshes.add(dome_mesh());
|
||||
// Seeded with the neutral-day fallback; `update_sky_colors` overwrites from Light.dbc each frame.
|
||||
let material = materials.add(SkyMaterial {
|
||||
@@ -231,6 +240,7 @@ fn setup_sky(
|
||||
mf_fog_b: Vec4::ZERO,
|
||||
mf_fog_c: Vec4::ZERO,
|
||||
mf_fog_d: Vec4::ZERO,
|
||||
clock: clock.0.clone(),
|
||||
},
|
||||
});
|
||||
commands.spawn((
|
||||
@@ -293,9 +303,8 @@ fn apply_sky_visibility(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_sky_colors(
|
||||
light: Res<WowLighting>,
|
||||
// MONKEY (sky): the tier, the sky clock and the cloud cover over the sun.
|
||||
// MONKEY (sky): the tier and the cloud cover over the sun.
|
||||
quality: Res<crate::sky_fx::SkyQuality>,
|
||||
clock: Res<crate::sky_fx::SkyClock>,
|
||||
clouds: Res<crate::clouds::CloudCoverage>,
|
||||
// MONKEY (fog): the Modern fog rows for the shared horizon colour.
|
||||
monkey: Res<crate::lighting::MonkeyFrame>,
|
||||
@@ -326,7 +335,7 @@ fn update_sky_colors(
|
||||
0.0,
|
||||
);
|
||||
// MONKEY (sky): the Enhanced/High inputs; all zero at Classic so the write gate stays quiet.
|
||||
let (fx, sun_v, glow) = sky_fx_inputs(&light, *quality, clock.secs, &clouds);
|
||||
let (fx, sun_v, glow) = sky_fx_inputs(&light, *quality, &clouds);
|
||||
// MONKEY (fog): rows B-D as packed for the light buffer; clock lanes are not read here.
|
||||
let rows = monkey.pack(0.0, 0.0);
|
||||
let mf_fog = [
|
||||
@@ -374,7 +383,6 @@ fn update_sky_colors(
|
||||
fn sky_fx_inputs(
|
||||
light: &WowLighting,
|
||||
quality: crate::sky_fx::SkyQuality,
|
||||
secs: f32,
|
||||
clouds: &crate::clouds::CloudCoverage,
|
||||
) -> (Vec4, Vec4, Vec4) {
|
||||
if !quality.enhanced() {
|
||||
@@ -393,7 +401,7 @@ fn sky_fx_inputs(
|
||||
// Quantized like every other lane here, so an idle frame writes nothing.
|
||||
let q = |v: f32| benilla_assets::quantize(v, 4096.0);
|
||||
(
|
||||
Vec4::new(f32::from(quality.0), secs, q(night), q(strength)),
|
||||
Vec4::new(f32::from(quality.0), 0.0, q(night), q(strength)),
|
||||
Vec4::new(q(sun.x), q(sun.y), q(sun.z), 0.0),
|
||||
Vec4::new(q(glow.x), q(glow.y), q(glow.z), 0.0),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp, Clouds.hpp.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::{
|
||||
extract_resource::{ExtractResource, ExtractResourcePlugin},
|
||||
render_resource::{Buffer, BufferDescriptor, BufferUsages},
|
||||
renderer::{RenderDevice, RenderQueue},
|
||||
Render, RenderApp, RenderSystems,
|
||||
};
|
||||
|
||||
/// The sky tier: 0 Classic, 1 Enhanced, 2 High. Written by the app from the `skyQuality` cvar;
|
||||
/// `$WOW_SKY_QUALITY` pins it for the session (captures).
|
||||
@@ -41,8 +47,8 @@ impl SkyQuality {
|
||||
///
|
||||
/// MONKEY (fix-sky): it wrapped hourly in f32, and every star's twinkle jumped at the wrap. Now it
|
||||
/// accumulates in f64 and wraps at [`SKY_CLOCK_WRAP_S`]; the shader's twinkle rates are whole
|
||||
/// cycles per wrap (seamless). The High cloud-detail drift still re-patterns at the wrap, once
|
||||
/// per 24 h of continuous play.
|
||||
/// cycles per wrap (seamless). MONKEY (polish): the High cloud detail tiles and drifts whole
|
||||
/// tiles per wrap (`CLOUD_TILE`, `CLOUD_DRIFT_TILES`), so it is seamless there too.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SkyClock {
|
||||
pub secs: f32,
|
||||
@@ -53,6 +59,25 @@ pub struct SkyClock {
|
||||
/// The sky clock's wrap in seconds; `sky_fx.wgsl`'s `SKY_WRAP` must match.
|
||||
pub const SKY_CLOCK_WRAP_S: f64 = 86_400.0;
|
||||
|
||||
/// MONKEY (polish): the sky clock on the GPU, one `vec4` (`x` = [`SkyClock::secs`]) that the sky
|
||||
/// and cloud materials bind read-only. It is rewritten in place each frame in the render world,
|
||||
/// so the materials re-prepare only when a real input moves, not to advance the clock. Bevy's
|
||||
/// `globals.time` is not used: it wraps hourly, and the twinkle rates are whole cycles per day.
|
||||
#[derive(Resource, Clone, ExtractResource)]
|
||||
pub struct SkyClockBuffer(pub Buffer);
|
||||
|
||||
/// The clock value the render world uploads.
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
struct SkyClockSecs(f32);
|
||||
|
||||
impl ExtractResource for SkyClockSecs {
|
||||
type Source = SkyClock;
|
||||
|
||||
fn extract_resource(source: &SkyClock) -> Self {
|
||||
Self(source.secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// How strongly the glow shows: a broad halo at `GLOW_GAIN` × the sun colour at the sun itself.
|
||||
pub(crate) const GLOW_GAIN: f32 = 0.22;
|
||||
|
||||
@@ -92,10 +117,50 @@ impl Plugin for SkyFxPlugin {
|
||||
let quality = SkyQuality(SkyQuality::env_override().unwrap_or(0));
|
||||
app.insert_resource(quality)
|
||||
// MONKEY (integration): the clock ticks before its readers (sky colours, cloud FX).
|
||||
.add_systems(Update, tick_sky_clock.before(crate::lighting::LightingConsumeSet));
|
||||
.add_systems(Update, tick_sky_clock.before(crate::lighting::LightingConsumeSet))
|
||||
// Beside the shared light buffer, ahead of the sky and cloud materials built after it.
|
||||
.add_systems(
|
||||
Startup,
|
||||
init_sky_clock_buffer.in_set(benilla_assets::AssetSet::Open),
|
||||
)
|
||||
.add_plugins((
|
||||
ExtractResourcePlugin::<SkyClockBuffer>::default(),
|
||||
ExtractResourcePlugin::<SkyClockSecs>::default(),
|
||||
));
|
||||
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
|
||||
render_app.add_systems(
|
||||
Render,
|
||||
upload_sky_clock.in_set(RenderSystems::PrepareResources),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless build has no device, and then no sky or cloud dome either.
|
||||
fn init_sky_clock_buffer(mut commands: Commands, device: Option<Res<RenderDevice>>) {
|
||||
let Some(device) = device else {
|
||||
return;
|
||||
};
|
||||
commands.insert_resource(SkyClockBuffer(device.create_buffer(&BufferDescriptor {
|
||||
label: Some("sky_clock"),
|
||||
size: 16,
|
||||
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})));
|
||||
}
|
||||
|
||||
/// Render-world: the clock into the shared buffer before any sky draw reads it.
|
||||
fn upload_sky_clock(
|
||||
queue: Res<RenderQueue>,
|
||||
buffer: Option<Res<SkyClockBuffer>>,
|
||||
secs: Option<Res<SkyClockSecs>>,
|
||||
) {
|
||||
let (Some(buffer), Some(secs)) = (buffer, secs) else {
|
||||
return;
|
||||
};
|
||||
queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&[secs.0, 0.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
fn tick_sky_clock(time: Res<Time>, mut clock: ResMut<SkyClock>) {
|
||||
if let Some(t) = clock.frozen {
|
||||
if clock.secs != t {
|
||||
@@ -111,6 +176,39 @@ fn tick_sky_clock(time: Res<Time>, mut clock: ResMut<SkyClock>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A `const NAME ... = <value>;` line of `sky_fx.wgsl`, as text.
|
||||
fn wgsl_const(name: &str) -> String {
|
||||
let src = include_str!("shaders/sky_fx.wgsl");
|
||||
let line = src
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&format!("const {name}:")))
|
||||
.unwrap_or_else(|| panic!("no const {name}"));
|
||||
let value = line.split('=').nth(1).unwrap().trim();
|
||||
value.trim_end_matches(';').trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_wrap_and_the_cloud_loop_match_the_clock() {
|
||||
let wrap: f64 = wgsl_const("SKY_WRAP").parse().unwrap();
|
||||
assert_eq!(wrap, SKY_CLOCK_WRAP_S);
|
||||
// The loop needs an even tile (the half-rate warp tiles at half of it) and whole tiles of
|
||||
// drift per wrap; the rates stay within 5% of WarcraftXL's 0.011 and 0.0043 cells/s.
|
||||
let tile: i32 = wgsl_const("CLOUD_TILE").parse().unwrap();
|
||||
assert!(tile > 0 && tile % 2 == 0);
|
||||
let drift = wgsl_const("CLOUD_DRIFT_TILES");
|
||||
let tiles: Vec<f64> = drift
|
||||
.trim_start_matches("vec2<f32>(")
|
||||
.trim_end_matches(')')
|
||||
.split(',')
|
||||
.map(|v| v.trim().parse().unwrap())
|
||||
.collect();
|
||||
for (n, rate) in tiles.iter().zip([0.011, 0.0043]) {
|
||||
assert_eq!(n.fract(), 0.0, "whole tiles per wrap");
|
||||
let actual = n * f64::from(tile) / SKY_CLOCK_WRAP_S;
|
||||
assert!((actual / rate - 1.0).abs() < 0.05, "drift {actual} vs {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_fades_out_below_the_horizon_and_under_cloud() {
|
||||
assert_eq!(glow_strength(-0.2, 0.0, 0.0), 0.0);
|
||||
|
||||
@@ -24,8 +24,15 @@
|
||||
//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon;
|
||||
//! a celestial model draws as a layer under its main model. Every batch animates
|
||||
//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks.
|
||||
//!
|
||||
//! MONKEY (reviewfix): those M2-fidelity repairs deliberately also apply when `zoneSkyboxes` is
|
||||
//! off. The reference WMO and ghost slots animate their authored colour/alpha and texture tracks,
|
||||
//! use non-white M2 colours, and draw batches in authored order; deterministic captures pose those
|
||||
//! same tracks at their pinned time rather than substituting the bind pose. The batch-order layout
|
||||
//! leaves room for a celestial under-layer and the final fog cone. The cvar gates the added
|
||||
//! zone slot, not fixes to the two 1.12 slots.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
@@ -47,11 +54,9 @@ use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
|
||||
/// `WmoGroupNav::flags`.
|
||||
const SHOW_SKYBOX: u32 = 0x40000;
|
||||
|
||||
/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so
|
||||
/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last.
|
||||
const MAIN_ORDER_BASE: u16 = 24;
|
||||
/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step.
|
||||
const FOG_CONE_ORDER: u16 = 56;
|
||||
/// MONKEY (reviewfix): the fog cone owns the skybox band's final distinct rung. Orders above this
|
||||
/// clamp onto it in [`crate::model_render::skybox_sort_bias`], so model batches stop one rung below.
|
||||
const FOG_CONE_ORDER: u16 = 58;
|
||||
|
||||
/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox
|
||||
/// from `LightParams`. Off leaves the reference's two slots only.
|
||||
@@ -147,9 +152,10 @@ enum PartPose {
|
||||
#[derive(Component, Default)]
|
||||
struct SkyboxLocal(Affine3A);
|
||||
|
||||
/// Skybox paths built this session, failed loads included, so a failure is not retried per frame.
|
||||
/// Skybox paths built this session в†’ their batch counts. Failed loads use zero, so a failure is not
|
||||
/// retried per frame; successful celestial counts set the main model's dynamic order base.
|
||||
#[derive(Resource, Default)]
|
||||
struct BuiltSkyboxes(HashSet<String>);
|
||||
struct BuiltSkyboxes(HashMap<String, u16>);
|
||||
|
||||
/// MONKEY (skybox): each built model's rig, by path.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -220,6 +226,15 @@ fn norm(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (reviewfix): celestial batches occupy the low rungs and a main model starts immediately
|
||||
/// after the largest active celestial model. Oversized art compresses onto the last model rung but
|
||||
/// can never collide with the fog cone.
|
||||
fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) -> u16 {
|
||||
let base = if celestial { 0 } else { celestial_batches };
|
||||
base.saturating_add(u16::try_from(batch.saturating_add(1)).unwrap_or(u16::MAX))
|
||||
.min(FOG_CONE_ORDER - 1)
|
||||
}
|
||||
|
||||
/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the
|
||||
/// larger weight and every other layer is scaled by `1 − weight`.
|
||||
fn collect_layer(layers: &mut Vec<SkyboxLayer>, path: &str, weight: f32, flags: u32) {
|
||||
@@ -423,9 +438,13 @@ fn build_skybox(
|
||||
if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) {
|
||||
build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats);
|
||||
}
|
||||
for layer in &want.0 {
|
||||
// MONKEY (reviewfix): build celestial models first so main bases derive from their real batch
|
||||
// counts instead of a fixed split that aliases sufficiently large models.
|
||||
let mut pending: Vec<_> = want.0.iter().collect();
|
||||
pending.sort_by_key(|layer| !layer.celestial);
|
||||
for layer in pending {
|
||||
let path = layer.path.as_str();
|
||||
if built.0.contains(path) {
|
||||
if built.0.contains_key(path) {
|
||||
continue;
|
||||
}
|
||||
// MONKEY (skybox): the rig, off the same bytes the batches come from.
|
||||
@@ -445,16 +464,22 @@ fn build_skybox(
|
||||
Ok(subs) if !subs.is_empty() => subs,
|
||||
Ok(_) => {
|
||||
warn!("skybox '{path}' has no render batches — keeping the gradient dome");
|
||||
built.0.insert(path.to_string());
|
||||
built.0.insert(path.to_string(), 0);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}");
|
||||
built.0.insert(path.to_string());
|
||||
built.0.insert(path.to_string(), 0);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE };
|
||||
let celestial_batches = want
|
||||
.0
|
||||
.iter()
|
||||
.filter(|layer| layer.celestial)
|
||||
.filter_map(|layer| built.0.get(&layer.path).copied())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
for (i, sub) in subs.iter().enumerate() {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
@@ -498,7 +523,7 @@ fn build_skybox(
|
||||
let tint = sub.rgb_anim.clone().map(Arc::new);
|
||||
// The authored batch order after the layer's base (0 is unordered): every batch shares
|
||||
// one sort distance.
|
||||
let order = base + u16::try_from(i + 1).unwrap_or(0);
|
||||
let order = skybox_batch_order(layer.celestial, celestial_batches, i);
|
||||
let Some(mut pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref())
|
||||
else {
|
||||
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
|
||||
@@ -534,14 +559,16 @@ fn build_skybox(
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut lane_materials = [pair.steady.clone(), pair.fade_blend.clone()];
|
||||
let lane = SkyMatLane::register(
|
||||
sub,
|
||||
uv,
|
||||
tint,
|
||||
&mut table,
|
||||
mats.materials(),
|
||||
&[pair.steady.id(), pair.fade_blend.id()],
|
||||
&mut lane_materials,
|
||||
);
|
||||
[pair.steady, pair.fade_blend] = lane_materials;
|
||||
let mesh = meshes.add(mesh);
|
||||
let pose = match sole_bone(sub) {
|
||||
Some(b) if rig.bone_moves(b) => PartPose::Rigid(b),
|
||||
@@ -581,7 +608,9 @@ fn build_skybox(
|
||||
}
|
||||
}
|
||||
rigs.0.insert(path.to_string(), rig);
|
||||
built.0.insert(path.to_string());
|
||||
built
|
||||
.0
|
||||
.insert(path.to_string(), u16::try_from(subs.len()).unwrap_or(u16::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +743,9 @@ fn animate_skyboxes(
|
||||
return;
|
||||
}
|
||||
let deterministic = crate::dev_state::deterministic_run();
|
||||
let now = time.elapsed_secs();
|
||||
// MONKEY (reviewfix): retain sub-frame precision for skybox loops across long sessions; only
|
||||
// narrow the wrapped sequence clock that the f32 track sampler consumes.
|
||||
let now = time.elapsed_secs_f64();
|
||||
let day = clock.minute.min(1439) as f32 / 1440.0;
|
||||
clocks.clear();
|
||||
for layer in want.0.iter().filter(|l| l.weight > 0.0) {
|
||||
@@ -724,12 +755,17 @@ fn animate_skyboxes(
|
||||
// A capture poses at `t = 0`, not the bind pose: a converted skybox places its layers with
|
||||
// bone keys, and every row at `t = 0` is its seed.
|
||||
let (band_t, gseq, live) = if layer.flags & SKYBOX_FULL_DAY != 0 {
|
||||
let g = if deterministic { 0.0 } else { f64::from(now) };
|
||||
let g = if deterministic { 0.0 } else { now };
|
||||
(rig.duration * day, g, true)
|
||||
} else if deterministic {
|
||||
(capture_t(), f64::from(capture_t()), true)
|
||||
} else {
|
||||
(now, f64::from(now), true)
|
||||
let band_t = if rig.duration > 0.0 {
|
||||
(now % f64::from(rig.duration)) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(band_t, now, true)
|
||||
};
|
||||
let pose = if live && rig.animates() {
|
||||
rig.pose(rig.band_time(band_t), gseq)
|
||||
@@ -954,6 +990,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skybox_order_bands_follow_batch_counts_and_reserve_the_cone() {
|
||||
assert_eq!(skybox_batch_order(true, 0, 23), 24);
|
||||
assert_eq!(skybox_batch_order(false, 24, 0), 25);
|
||||
assert_eq!(skybox_batch_order(false, 0, 31), 32);
|
||||
assert_eq!(skybox_batch_order(false, 40, usize::MAX), FOG_CONE_ORDER - 1);
|
||||
assert_eq!(FOG_CONE_ORDER, 58);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wmo_sky_crossfades_the_zone_sky() {
|
||||
let mut layers = vec![layer("a.m2", 1.0)];
|
||||
|
||||
@@ -333,21 +333,38 @@ impl SkyMatLane {
|
||||
tint: Option<Arc<benilla_formats::RgbAnim>>,
|
||||
table: &mut crate::mat_anim_table::MatAnimTable,
|
||||
materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
|
||||
mats: &[AssetId<benilla_assets::materials::WowModelMaterial>],
|
||||
mats: &mut [Handle<benilla_assets::materials::WowModelMaterial>],
|
||||
) -> Self {
|
||||
let mut lane = SkyMatLane::default();
|
||||
let write = |materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
|
||||
f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| {
|
||||
for id in mats {
|
||||
crate::model_render::lazy::with_material_mut(materials, *id, |m| f(m));
|
||||
// MONKEY (reviewfix): `model_material` is a cache. Animation slots are per sky batch, so
|
||||
// detach its handles before writing any slot (notably affine-only `.z`) into the material.
|
||||
let originals = mats.to_vec();
|
||||
for i in 0..mats.len() {
|
||||
if let Some(prior) = (0..i).find(|&j| originals[j].id() == originals[i].id()) {
|
||||
mats[i] = mats[prior].clone();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(material) =
|
||||
crate::model_render::lazy::with_material_mut(materials, originals[i].id(), |m| {
|
||||
m.clone()
|
||||
})
|
||||
{
|
||||
mats[i] = materials.add(material);
|
||||
}
|
||||
}
|
||||
let mut lane = SkyMatLane::default();
|
||||
let write =
|
||||
|materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
|
||||
f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| {
|
||||
for id in mats.iter() {
|
||||
crate::model_render::lazy::with_material_mut(materials, id.id(), |m| f(m));
|
||||
}
|
||||
};
|
||||
if let Some(uv) = uv.filter(|a| a.period > 0.0) {
|
||||
if let Some(slot) = table.alloc() {
|
||||
let seed = mats
|
||||
.first()
|
||||
.and_then(|id| {
|
||||
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
|
||||
.and_then(|handle| {
|
||||
crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| {
|
||||
[m.extension.sun_scale.z, m.extension.sun_scale.w]
|
||||
})
|
||||
})
|
||||
@@ -376,8 +393,8 @@ impl SkyMatLane {
|
||||
if let Some(slot) = table.alloc() {
|
||||
let seed = mats
|
||||
.first()
|
||||
.and_then(|id| {
|
||||
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
|
||||
.and_then(|handle| {
|
||||
crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| {
|
||||
[m.extension.tint.x, m.extension.tint.y, m.extension.tint.z]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ pub use spawn::{
|
||||
use queries::update_current_area;
|
||||
pub use queries::{
|
||||
area_id_under, doodad_ground_shade, ground_effect_under, terrain_height_under,
|
||||
terrain_height_under_cached, AreaAuthoritySet, CurrentArea, ShadeResolve,
|
||||
terrain_height_under_cached, AreaAuthoritySet, CaptureCameraArea, CurrentArea, ShadeResolve,
|
||||
};
|
||||
|
||||
/// Wall-clock per frame for spawning streamed tiles and placements before the rest waits a frame;
|
||||
@@ -389,6 +389,8 @@ impl Plugin for TerrainPlugin {
|
||||
.init_resource::<StaticMerge>()
|
||||
.init_resource::<crate::model_forms::ModelForms>()
|
||||
.init_resource::<CurrentArea>()
|
||||
// MONKEY (reviewfix-a): the capture-only camera area (zone grading in captures).
|
||||
.init_resource::<CaptureCameraArea>()
|
||||
// In `WorldStage::Stream`, between the teleport snap (Input) and the loading cover
|
||||
// (Present), so a swap never renders uncovered. `finish_colliders` heads the chain so
|
||||
// the collider queue read downstream is this frame's.
|
||||
|
||||
@@ -14,6 +14,37 @@ use super::TerrainStreamer;
|
||||
#[derive(Resource, Default, PartialEq, Eq)]
|
||||
pub struct CurrentArea(pub Option<u32>);
|
||||
|
||||
/// MONKEY (reviewfix-a): the area under the CAMERA, written only under `WOW_CAPTURE` while there
|
||||
/// is no avatar. A deterministic capture has no body, so [`CurrentArea`] stays `None`; area-keyed
|
||||
/// look lanes (zone grading) read this as their capture fallback. The area authority (music,
|
||||
/// channels, names) never reads it.
|
||||
#[derive(Resource, Default, PartialEq, Eq)]
|
||||
pub struct CaptureCameraArea(pub Option<u32>);
|
||||
|
||||
/// MONKEY (reviewfix-a): see [`CaptureCameraArea`]; a helper of [`update_current_area`] (one
|
||||
/// system, so the schedule gains no new unordered pairs).
|
||||
fn capture_camera_area(
|
||||
out: &mut CaptureCameraArea,
|
||||
streamer: &TerrainStreamer,
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
eye: Option<Vec3>,
|
||||
) {
|
||||
let Some(eye) = eye else {
|
||||
return;
|
||||
};
|
||||
let wow = bevy_to_wow(eye);
|
||||
let (tx, ty) = world_to_tile(wow[0], wow[1]);
|
||||
let found = streamer
|
||||
.tiles
|
||||
.get(&(tx as i32, ty as i32))
|
||||
.and_then(|ts| adt_tiles.get(&ts.handle))
|
||||
.and_then(|adt| benilla_formats::area_id_at(&adt.chunks, wow))
|
||||
.filter(|&id| id != 0);
|
||||
if found.is_some() && out.0 != found {
|
||||
out.0 = found;
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of [`update_current_area`]. Every consumer that acts on the area orders after it, so
|
||||
/// leaf, indoor bit and names come from one frame (the reference resolves them in one pass).
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -118,8 +149,18 @@ pub(super) fn update_current_area(
|
||||
adt_tiles: Res<Assets<AdtTile>>,
|
||||
interior: Res<crate::wmo_portal::CurrentAreaInterior>,
|
||||
wmo_areas: Option<Res<crate::wmo_portal::WmoAreas>>,
|
||||
// MONKEY (reviewfix-a): the capture-only camera area (absent in unit-test worlds).
|
||||
capture_area: Option<ResMut<CaptureCameraArea>>,
|
||||
camera: Query<&GlobalTransform, With<crate::view::WorldCamera>>,
|
||||
mut capture: Local<Option<bool>>,
|
||||
) {
|
||||
let Some(wow) = focus.body_pos() else {
|
||||
if let Some(mut out) = capture_area {
|
||||
if *capture.get_or_insert_with(|| std::env::var_os("WOW_CAPTURE").is_some()) {
|
||||
let eye = camera.iter().next().map(GlobalTransform::translation);
|
||||
capture_camera_area(&mut out, &streamer, &adt_tiles, eye);
|
||||
}
|
||||
}
|
||||
// No avatar: the area dies with the character session, or the next login reads this
|
||||
// character's zone. `body_pos()` is `None` at both glue screens; a recoverable disconnect
|
||||
// keeps the body, and the area with it.
|
||||
|
||||
@@ -74,17 +74,29 @@ impl Default for WindField {
|
||||
#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FoliageWind(pub u8);
|
||||
|
||||
impl FoliageWind {
|
||||
/// The registered `foliageWind` default (grass + trees).
|
||||
pub const REGISTERED: u8 = 2;
|
||||
|
||||
/// MONKEY (reviewfix-a): the capture-only A/B pin `WOW_FOLIAGE_WIND`, read only under
|
||||
/// `WOW_CAPTURE` (like the other capture levers) so a player run always obeys the saved CVar.
|
||||
/// The app adds the dev-build gate on top.
|
||||
pub fn capture_override() -> Option<u8> {
|
||||
std::env::var_os("WOW_CAPTURE")?;
|
||||
std::env::var("WOW_FOLIAGE_WIND")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite())
|
||||
.map(|v| v.clamp(0.0, 2.0) as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FoliageWind {
|
||||
fn default() -> Self {
|
||||
// The resource exists before the CVar host. Its ordinary default therefore has to equal
|
||||
// the registered default; a default-valued CVar emits no change event at boot. The env
|
||||
// arm is capture-only and lets the A/B harness force the exact zero-displacement path.
|
||||
let tier = std::env::var("WOW_FOLIAGE_WIND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(2.0)
|
||||
.clamp(0.0, 2.0) as u8;
|
||||
Self(tier)
|
||||
// the registered default; a default-valued CVar emits no change event at boot. The
|
||||
// capture pin lets the A/B harness force the exact zero-displacement path.
|
||||
Self(Self::capture_override().unwrap_or(Self::REGISTERED))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user