Merge graphics-programme (review fixes, polish, perf) into main

This commit is contained in:
pkuzic
2026-09-26 04:17:14 +01:00
35 changed files with 1412 additions and 227 deletions
+25 -1
View File
@@ -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
View File
@@ -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(),
));
}
}
+11 -1
View File
@@ -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
View File
@@ -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"))
);
}
}
+16 -7
View File
@@ -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);
+213 -15
View File
@@ -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);
+15 -3
View File
@@ -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);
}
+170 -45
View File
@@ -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 {
+28 -21
View File
@@ -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
+6 -1
View File
@@ -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
+29 -4
View File
@@ -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");
}
+41 -5
View File
@@ -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();
+2 -2
View File
@@ -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);
}
+5
View File
@@ -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,
})
}
+34 -3
View File
@@ -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()),
))
})
+17 -5
View File
@@ -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 {
+48 -6
View File
@@ -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
+57 -3
View File
@@ -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);
}
+5 -3
View File
@@ -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);
+4 -2
View File
@@ -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;
+27 -8
View File
@@ -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);
}
+15 -7
View File
@@ -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),
)
+101 -3
View File
@@ -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);
+64 -19
View File
@@ -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)];
+28 -11
View File
@@ -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]
})
})
+3 -1
View File
@@ -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.
+20 -8
View File
@@ -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))
}
}