Merge graphics-programme (Everwood graphics programme) into main

This commit is contained in:
pkuzic
2026-09-25 23:46:18 +01:00
109 changed files with 12337 additions and 609 deletions
+110 -5
View File
@@ -15,26 +15,93 @@ behind each constant.
| Lane | What you see | Main switch |
|---|---|---|
| Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` |
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside | `worldShadows` |
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside; the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` |
| Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` |
| Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` |
| Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` |
| Daylight and doorways | calibrated fixtures at doors, windows and open boundaries carry daylight into a room, and doorways between rooms bleed light | `interiorDaylight`, env `WOW_DAYLIGHT`, `WOW_BLEED` |
| Terrain in torch shadows | the ground casts into an outdoor fire's cube map, so a hill or bank blocks the fire (settled exterior slots only; `torch_terrain.rs` gathers the resident MCNK chunks inside the cube's 48 yd) | `torchTerrainShadows` (default 0, High 1), env `WOW_TORCH_TERRAIN=0\|1` |
| City day floor | `interiorDaylight`'s room floor also reaches Stormwind/Ironforge rooms that the portal graph connects to the sky (an exterior-facing portal, or one hop from one; `district_sky_rooms` in `daylight.rs`); before this no city room could take it | `interiorDaylight` (default 0, suggested High 0.10) |
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
| 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 |
| 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` |
Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
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.
## Graphics Preset
MONKEY (presets). The top row of Advanced Graphics, cvar `graphicsQuality`: Classic / Low / Medium /
High / Ultra, or Custom when the rows match none of them. The table is `GRAPHICS_PRESETS` in
`benilla-app/src/cvars.rs` (one line per governed row); choosing a rung writes each row through
the ordinary cvar path, and the label is re-derived every frame, so editing any governed row
(or any Lighting Quality member) shows Custom. `lightingQuality` is itself a governed row, so
every row the lighting ladder owns (`LIGHTING_PRESETS`: both sun lanes, shadow resolution, the
room and torch lanes, moon shadows, `waterQuality`, `volumetricFog`, `bloom`, `sunShafts`,
`colorGrading`, `foliageWind`, the fire/spell/lava gains, `nightGain`, `interiorGain`) follows it.
| row | Classic | Low | Medium | High | Ultra |
|---|---|---|---|---|---|
| `lightingQuality` | Off | Low | Medium | High | Ultra |
| `farclip` (Render Distance, yd) | 350 | 350 | 477 | 777 | 1497 |
| `skyQuality` | 0 | 1 | 1 | 2 | 2 |
| `skyDither` | 0 | 1 | 1 | 1 | 1 |
| `fogModel` | 0 | 1 | 1 | 1 | 1 |
| `rainSurfaces` | 0 | 1 | 1 | 1 | 1 |
| `torchTerrainShadows` | 0 | 0 | 0 | 1 | 1 |
| `daylightWindowSplit` | 0 | 1 | 1 | 1 | 1 |
| `ambientOcclusion` | 0 | 0 | 1 | 2 | 2 |
| `zoneSkyboxes` | 0 | 0 | 1 | 1 | 1 |
| `lampFog` | 0 | 0 | 1 | 2 | 2 |
What the lighting rungs set, beyond Off (everything off, gains 1.0) and High (the registered
defaults): Low = character shadows only at a 1024 map, no torch or moon shadows, bloom Low;
Medium = both sun lanes, indoor torch shadows (6 resident / 2 moving), moon shadows, bloom Low,
no sun shafts or colour grading; Ultra = High with a 4096 sun map, 16 / 8 torch maps, High water
and High volumetric fog. No rung decides `shadowDistance` or `interiorShadowSoft`.
**Default: High.** A player whose `config.toml` names no `graphicsQuality` is seeded to the High
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.
**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
rung's) over every governed row the file does NOT carry, so a row registered after the save gets
the preset's value, not its registered default. Rows the file carries and session-owned rows stay;
`Custom` writes nothing.
**Render Distance** is `farclip`, the same cvar as the Graphics page's Terrain Distance (which
keeps the stock 177–777 slider). Its clamp (`view::FARCLIP_RANGE`) now reaches `FARCLIP_MAX` =
1497 yd, the 1.12 grid of 60 carried on. Terrain residency (`terrain_stream/window.rs`), the
model/WMO and particle walls (`view::within_farclip`) and the art-cache sweep radius (now 7 tiles,
`art_scope.rs`) all follow it; at 1497 the residency window is at most 7×7 ADT tiles against
777's 5×5, which is the memory bound. Past 777 only Modern fog (`fog_model::modern_fog_end`)
stretches the fog end with it; under Classic fog the zone's fog end still hides the extra reach.
The optional enhanced water (Water Quality, refraction, caustics, High reflections, lava glow)
is its own module, documented in `WATER.md`.
## Where the code lives
- **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling),
`fog_hook.wgsl` (MONKEY p0: the ONE distance-fog law — `fog_linear`, `fog_sample`, `apply_fog`;
terrain, wow_model, static_gx, liquid, enhanced_water, wow_effect and wdl all call it; classic is
bit-identical to the old per-shader copies, and `MonkeyFrame.fog_model` = 1 is the FOG lane's
extension point), `monkey_frame.wgsl` (the MonkeyFrame struct),
`crates/benilla-assets/src/shaders/emissive_hook.wgsl` (shared opt-in HDR multipliers),
`crates/benilla-world/src/shaders/torch_depth.wgsl`, and the lighting lanes inside
`static_gx.wgsl`, `wow_model.wgsl` and `terrain.wgsl`. The three receivers mirror each other; the
comments say where.
@@ -47,14 +114,51 @@ is its own module, documented in `WATER.md`.
`character_shadow.rs`, `world_shadow.rs`, `blob_shadow.rs`, `entities/carried_light.rs`,
`entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and
the cvars in `cvars.rs` / `video.rs`.
- **Sky** (`crates/benilla-world/src`): `sky_fx.rs` (tier, clock, glow inputs), `shaders/sky_fx.wgsl` (gradient curve, glow, stars, cloud noise), the tier branches in `shaders/sky.wgsl` and `shaders/cloud.wgsl`, `clouds/layer.rs` (`update_cloud_fx`); the cvar bridge is `benilla-app/src/sky_quality.rs`.
- **Census**: `cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture`
(`WOW_CENSUS_WMO`, `WOW_CENSUS_PLACE=map,tx,ty,uid`) prints, per interior room of a city WMO,
whether a daylight fixture, a bleed fixture or neither reaches it, and the sky-room count.
- **Wind**: `wind/mod.rs` resolves the WXL-derived shared field and nearest-unit benders;
`clutter.rs` authors blade weights/phases; `static_gx` classifies and marks alpha-tested leaf
batches. `wind_hook.wgsl` owns the displacement shared by clutter, retained trees and fade twins.
- **Tools**: `benilla-extract <Data> wmolights <wmo> [--verts <group>]`, `wmolamps`, `m2firescan`
print the inputs the system works from (groups, batch classes, portals, claims, flame emitters).
## Invariants worth knowing before editing
- The shared light buffer stays 8528 bytes and the torch table 6416 bytes; both are mirrored in
three shaders and pinned by tests.
- The shared light buffer's per-frame blob is 8784 bytes and the torch table 6416 bytes; both are
mirrored in the shaders and pinned by tests. The blob is 21 header rows (336 B), the 256-slot
point-light table (8192 B), then the MONKEY **MonkeyFrame** block (16 rows, 256 B; nothing before
it moves). Every `WowLight` mirror (terrain, wow_model, static_gx, liquid, enhanced_water,
wow_effect, wdl) declares it as `monkey: monkey_frame::MonkeyFrame` after `points`:
| row | x | y | z | w |
|---|---|---|---|---|
| `fog_a` | height_fog_density | height_fog_height | height_fog_falloff | curve_blend |
| `fog_b` | sun_fog r | g | b | sun_fog_strength |
| `fog_c` | end_fog r | g | b | end_fog_distance |
| `fog_d` | fog_model (0 classic; Modern = scene fog end in yd) | sun_fog_angle | sun direction octahedral x | y |
| `wind_a` | dir_x | dir_y | base_heading (rad) | gust |
| `wind_b` | travel (yd, wrapped at 4096) | sway_strength | grass_strength | tree_strength |
| `wet_a` | rain_rate | wetness | ripple_time_s | snow |
| `misc` | bender_count | time_of_day 0..1 | night 0..1 | 0 |
| `benders[8]` | world x | world y | world z | radius |
MONKEY (rainshelter): the rain-shelter region follows the MonkeyFrame block (row 549: two
header rows, then 16384 packed `u32` cells, 65568 B; `weather/shelter.rs`), and the prop-probe
region moved up by that much. `terrain`, `wow_model` and `static_gx` mirror it as
`shelter_hdr`, `shelter_cfg`, `shelter`; `enhanced_water` reads it through its row view.
Positions are Bevy world space (Y up, yards; WoW `(x, y, z)` = Bevy `(-y, z, -x)`), the wind
direction is a unit vector in world XZ. Lanes write the `MonkeyFrame` resource
(`benilla-world/src/lighting/monkey_frame.rs`); `global_light::pack_monkey_frame` packs it after
the point table and fills `time_of_day` / `night` itself. All zero = no visual change.
- World lights use upstream's `WorldPointLight`, never Bevy's `PointLight`.
- Foliage wind is vertex-only. Tree and leaf shadow meshes stay static; the small mismatch is the
accepted first-stage cost/complexity tradeoff.
- MONKEY (lampfog): `ResolvedPointLights` is a CPU-only read view of the live, truncated point table;
it does not add a row to the shared GPU buffer. Lamp selection is capped at 100 yd and ranks both
exterior and interior rows together before the 16/32 nearest limit is applied.
- WGSL only fails at pipeline creation, so a shader edit is verified by running a world scene, not
by `cargo check`.
- Exterior point lights are selected per draw unit (12 slots, ranked against the chunk's box); the
@@ -71,4 +175,5 @@ Debug tracing: `WOW_TORCH_TRACE=1`, `WOW_POINTS_DUMP=1`, `WOW_SHADOW_TRACE=1`, a
## Licence
Same as upstream: MIT OR Apache-2.0.
Same as upstream: MIT OR Apache-2.0. Code and techniques taken from other projects (WarcraftXL,
by iThorgrim) are credited file by file in [`THIRD-PARTY.md`](THIRD-PARTY.md).
+2 -1
View File
@@ -89,4 +89,5 @@ adapters and developer frames, not copies of it.
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components
under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect —
keep their own upstream licenses, alongside each.
keep their own upstream licenses, alongside each. Code and techniques ported from other projects
(WarcraftXL, by iThorgrim) are credited file by file in [`THIRD-PARTY.md`](THIRD-PARTY.md).
+44
View File
@@ -0,0 +1,44 @@
# Third-party credits
Code and techniques this fork takes from other projects, with the files they landed in. Vendored
components under `third_party/` carry their own licences beside them and are listed in `README.md`.
## WarcraftXL
- **Project: [WarcraftXL](https://github.com/WarcraftXL)** — client extensions for the **3.3.5a**
(build 12340) client, written in C++ with D3D9 / HLSL.
- **Author: iThorgrim.**
WarcraftXL's code is reused here on the condition that the author and the original project are
named. This file, and the header line in each file below, is that attribution; keep both wherever
the code is copied or shared.
**Rule for contributors.** A file that ports code or a technique from WarcraftXL carries this line
in its header:
```
Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module <module>, <source files>.
```
and gets a row in the table below. Blocks inside a file that port a specific routine also say so
where they stand. Do not remove either.
| Benilla file | WarcraftXL module | Source files | What was taken |
|---|---|---|---|
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (DEPTH LOOK block) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` | Per-channel Beer-Lambert extinction over the view path through the water column, the body as a lerp from scattered colour to what is behind by the transmittance, normal-bent scene-copy refraction scaled by the water depth (ported) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (caustics) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Noise.cpp` | Two caustic layers at incommensurate scales and rates, multiplied (ported; our layers are built from animated cell edges, not value noise) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (HIGH reflections) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl` | Screen-space reflection of the scene copy along an almost-planar normal, masked by the screen edge and by rays turning back toward the eye (principle; ours is a bisected march against the scene depth, not a single probe) |
| `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-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/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) |
| `crates/benilla-assets/src/shaders/wind_hook.wgsl` | `wxl-experimental-wind` | `grass/GrassWind.hpp`, `grass/GrassWind.cpp` | Two-wave grass sway, gust response, lean, blade phase/variance, distance fade and radial parting (ported; extended to eight benders) |
| `crates/benilla-assets/src/shaders/wow_model.wgsl` (MONKEY wind hook) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Grass vertex displacement call seam (technique) |
| `crates/benilla-world/src/clutter.rs` (MONKEY wind attributes) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Per-blade bend weight and per-tuft phase inputs (technique; height replaces WXL's unverified texture-V weight) |
Planned (not yet in the tree): breaker index from `sea/Shore.hpp`, `shaders/Wave.hlsli`.
The lane that ports one adds its row here.
+18 -5
View File
@@ -2,7 +2,7 @@
An optional enhanced water renderer for [benilla](https://github.com/samwhosung/benilla), the Rust +
Bevy reimplementation of the 1.12.1 client. It needs no data changes: everything is derived from
what a 1.12 install already carries (MCLQ / MLIQ liquid grids and their authored depth bytes, the
what a 1.12 install already carries (MCLQ liquid depth and MLIQ opacity bytes, their liquid grids, the
`Light.dbc` water and sky rows, the opaque scene depth).
It is a **module on top of the reference water, not a replacement for it**. The reference path
@@ -12,7 +12,7 @@ player picks it, and every part of it can be switched off.
## Credits
- **Original project: [WarcraftXL](https://github.com/WarcraftXL)** — the `wxl-experimental-water`
module for the 1.12 client.
3.3.5a client extension.
- **Author: iThorgrim.**
The WarcraftXL water module is the design reference for this one, and its author has given
@@ -21,8 +21,11 @@ named. This section is that attribution; it must travel with the module (this fi
`enhanced_water.wgsl` header) wherever the module is copied or shared.
Rule for contributors: a file that ports code or a technique from WarcraftXL says so in its header
(`Portions derived from WarcraftXL wxl-experimental-water by iThorgrim, used with permission`) and
the technique is listed in the table below. Do not remove either.
(`Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module <module>, <source
files>.`; blocks inside a file keep their `Portions derived from WarcraftXL ...` line), the
technique is listed in the table below, and the file is listed in
[`THIRD-PARTY.md`](THIRD-PARTY.md), the one table of every WarcraftXL-derived file across the
fork. Do not remove any of them.
| Technique | Status here | From WarcraftXL |
|---|---|---|
@@ -40,12 +43,16 @@ the technique is listed in the table below. Do not remove either.
|---|---|---|
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier |
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier |
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier |
| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier |
| Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier |
| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
| Lava glow | magma surfaces emit warm point lights through the lighting module | Advanced Graphics → Lava Glow, `lavaLightGain` |
| Lava HDR | magma partially resists distance fog and feeds the screen-space halo | Advanced Graphics → Bloom, `bloom` (Off/Low/High) |
Rivers deliberately have NO flow effect: a derived current was tried and removed at the owner's
request. If it ever returns it should read the authored MCLQ flow records, not derive one.
@@ -64,7 +71,9 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
`materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`).
- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after
the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
(per-kind material parameters), `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
(per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
`liquid/lod.rs` (High-only 4x near-grid swap),
`water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
- **App** (`crates/benilla-app/src`): the setting in `cvars.rs` / `video.rs`, the options rows in
`assets/ui/OptionsFrame.xml`, the capture scenes `water-*` and `lava-*` in `capture/scenarios.rs`.
@@ -73,7 +82,11 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- `Classic` must stay byte-identical: capture `water-noon` with `WOW_WATER=0` and compare with the
baseline before and after any edit.
- The CPU swell (`liquid/waves.rs`) and the shader's `WATER_WAVES` table are one contract.
- MLIQ's per-vertex byte is an opacity-ramp coordinate, not water depth; enhanced WMO water uses
reconstructed scene depth for its column measurement.
- Every screen derivative in `enhanced_water()` is taken at the top of the function (WGSL
uniformity); WGSL only fails at pipeline creation, so verify by running a `water-*` scene.
- Never rotate a noise domain by a per-pixel angle at world coordinates (~1e4 yd): it shears.
- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
the weather lane supplies shared wind.
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
+496 -7
View File
@@ -492,9 +492,22 @@
enUS = {
page = "Advanced Graphics",
lightingQuality = "Lighting Quality",
-- MONKEY (presets): the Graphics Preset and Render Distance rows.
graphicsQuality = "Graphics Preset",
renderDistance = "Render Distance",
pClassic = "Classic",
qUltra = "Ultra",
-- MONKEY (volumetric fog): localised atmospheric quality row.
volumetricFog = "Volumetric Fog",
-- MONKEY (post): HDR excess + halo quality.
bloom = "Bloom",
sunShafts = "Sun Shafts",
colorGrading = "Colour Grading",
-- MONKEY (sky): the sky tier row.
skyQuality = "Sky Quality",
fogOff = "Off",
-- MONKEY (skybox): the zone skybox row.
zoneSkyboxes = "Zone Skyboxes",
waterQuality = "Water Quality",
waterClassic = "Classic",
waterEnhanced = "Enhanced",
@@ -513,27 +526,83 @@
interiorLight = "Dynamic Interior Lighting",
interiorShadows = "Indoor Torch Shadows",
exteriorShadows = "Outdoor Torch Shadows",
torchTerrainShadows = "Terrain Blocks Torchlight",
torchSoft = "Torch Shadow Softness",
fireLight = "Fire Light Brightness",
fireFlicker = "Fire Flicker",
spellLights = "Spell Lights",
nightDarkness = "Night Darkness",
interiorDarkness = "Interior Darkness",
-- MONKEY (p0 skyDither)
skyDither = "Sky Dithering",
-- MONKEY (fog)
fogModel = "Modern Fog",
-- MONKEY (wet)
rainSurfaces = "Wet Surfaces in Rain",
-- MONKEY (wind)
foliageWind = "Foliage Wind",
windGrass = "Grass",
windTrees = "Grass + Trees",
-- MONKEY (ao): localised contact-shadow row.
ambientOcclusion = "Ambient Occlusion",
-- MONKEY (lampfog)
lampFog = "Lamp Fog",
-- MONKEY (integration): the daylight lane's district window split.
daylightWindowSplit = "City Window Daylight",
yards = "%d yd",
tips = {
-- MONKEY (presets)
GRAPHICS_QUALITY =
"Sets every graphics setting at once: Lighting Quality and everything it sets, "
.. "Render Distance, Sky Quality, Sky Dithering, Modern Fog, Wet Surfaces in Rain, "
.. "Terrain Blocks Torchlight, the daylight through city windows, Ambient Occlusion, Lamp Fog "
.. "and Zone Skyboxes.\n\n"
.. "Classic is the original client: every enhancement off and the original 350 yd "
.. "view distance. Low and Medium add the enhancements that cost almost nothing; High "
.. "is the recommended look; Ultra raises every quality to its maximum and the view "
.. "distance to 1497 yd, and costs the most.\n\n"
.. "Shadow Distance and Torch Shadow Softness stay as you set them. Changing any "
.. "setting the preset sets shows Custom.",
RENDER_DISTANCE =
"How far the detailed world is drawn, in yards: the same setting as Terrain "
.. "Distance on the Graphics page, with a longer reach. Past 777 yd, the original "
.. "client's limit, the extra distance shows through Modern Fog; with the original "
.. "fog the zone's own fog hides it.\n\n"
.. "Farther costs more memory, loading and GPU time.",
LIGHTING_QUALITY =
"Sets character, world, indoor torch and outdoor torch shadows; dynamic "
.. "interior lighting; moon shadows; volumetric fog; water quality; lava glow; fire and spell brightness; fire flicker; "
.. "and night and interior darkness. Low, Medium and High also set shadow "
.. "foliage wind; and night and interior darkness. Low, Medium and High also set shadow "
.. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
.. "Off restores the original client's look: Classic water, no volumetric fog, lava glow or synthesised fire lights, "
.. "night gain 1.0 and interior gain 1.0. All other settings, including "
.. "Shadow Distance and Torch Shadow Softness, stay as you set them. "
.. "Low, Medium and High use Enhanced water; mirror reflections stay opt-in. "
.. "Ultra also raises shadow resolution to 4096 and the torch shadow counts to 16 and 8, "
.. "and uses High water and High volumetric fog. "
.. "Changing a preset member shows Custom when no preset matches.",
-- MONKEY (volumetric fog): explain cost and the shadow-map dependency.
-- MONKEY (post): Off guarantees the pre-HDR image.
BLOOM =
"Makes fire, lava, spells and lit windows carry a soft halo. Low uses a smaller blur chain; High is smoother. Off leaves the original image unchanged.",
SUN_SHAFTS =
"Draws sunlight through gaps in trees and buildings. Scene depth blocks the rays; they fade at night, off-screen and indoors.",
COLOR_GRADING =
"Applies a subtle zone-specific colour palette to the 3D world and blends between its day and night looks. The interface is never graded.",
VOLUMETRIC_FOG =
"Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.",
-- MONKEY (sky): what each sky tier adds.
SKY_QUALITY =
"Classic is the original client's sky. Enhanced smooths the sky's colour "
.. "gradient, adds a soft glow around the sun and a starry night sky with the "
.. "Milky Way. High also gives the clouds finer detail and sunlit edges. "
.. "Costs very little.",
-- MONKEY (skybox): what the zone skybox row changes.
ZONE_SKYBOXES =
"Draws the painted sky a zone's lighting names, as later expansions do: "
.. "the volcanic sky of the Burning Steppes, the alien sky of the Blasted Lands "
.. "and the starry night over Mount Hyjal, fading in at the zone's edge. "
.. "Off keeps the original client's sky everywhere but ghost and dungeon skies. Nearly free.",
WATER_QUALITY =
"Classic is the original client's water. Enhanced adds soft shorelines, "
.. "animated foam around shores and objects, moving wave highlights, sky reflection "
@@ -578,6 +647,11 @@
"The same for fires out in the open: campfires, braziers and lampposts "
.. "cast real shadows after dark.\n\n"
.. "It does nothing by day and does not require Dynamic Interior Lighting.",
TORCH_TERRAIN_SHADOWS =
"Lets the ground itself take part in outdoor torch shadows: a hill or a "
.. "bank between a fire and the slope behind it blocks the fire's light.\n\n"
.. "Adds the nearby terrain to each outdoor fire's shadow map when it is "
.. "rebuilt; the per-frame cost is small. Needs Outdoor Torch Shadows.",
TORCH_SOFT =
"How soft the edge of a torch shadow is where the object meets the "
.. "ground. Low is hard and crisp; high spreads the edge the way a real "
@@ -611,14 +685,68 @@
"Scales interior fixture light and room ambient, including the light from "
.. "lamps and candles. Lower values make both dimmer.\n\n"
.. "1.0 leaves these inputs unchanged. Free at any setting.",
-- MONKEY (p0 skyDither)
SKY_DITHER =
"Adds a faint noise to the final image so smooth sky and fog gradients do not "
.. "break into visible bands. Off is the original client's look. Practically free.",
-- MONKEY (fog)
FOG_MODEL =
"Softer distance fog in the style of the modern client: it thickens gradually "
.. "instead of in a straight ramp, glows toward the sun by day and shifts to the "
.. "sky's colour in the distance, so the land melts into the horizon. Each zone "
.. "keeps its own fog distance; above the original view-distance limit the fog "
.. "recedes with it.\n\nOff is the original client's fog. Practically free.",
-- MONKEY (wet)
RAIN_SURFACES =
"Rain slowly soaks the ground, stone and wood under the open sky: they darken, "
.. "puddles gather on flat ground and wet tops catch the light; rain drops ring "
.. "the water. It dries over a few minutes after the rain stops. Off is the "
.. "original client's look. Nearly free, and only active while things are wet.",
-- MONKEY (wind)
FOLIAGE_WIND =
"Grass bends in two travelling wind waves and parts around nearby characters. "
.. "Grass + Trees also gives classified tree and bush foliage a gentle sway.\n\n"
.. "Off makes no vertex displacement and restores the unchanged image. Tree shadows stay static.",
-- MONKEY (ao): what the contact shadows touch and what they spare.
AMBIENT_OCCLUSION =
"Soft contact shadows where surfaces meet: under props and furniture, in the corners "
.. "between walls and floors, and around characters' feet. Low uses fewer samples; "
.. "High is smoother and reaches a little further.\n\n"
.. "Fades out with distance and spares the sky, water, lit windows and flames. "
.. "Off leaves the image unchanged.",
-- MONKEY (integration): daylight window split.
DAYLIGHT_WINDOW_SPLIT =
"Large city buildings take daylight through each window on its own instead of "
.. "treating a whole wall of windows as one opening, so halls like the cathedral "
.. "catch the sun pane by pane.\n\nApplies to buildings loaded after the change. "
.. "Off is the earlier look.",
-- MONKEY (lampfog)
LAMP_FOG =
"Makes nearby lamps and fires glow through the air after dark. Low uses the "
.. "nearest 16 lights; High uses 32. Rain and fog strengthen the halos. "
.. "The effect is unshadowed, but stops at visible scene depth.\n\n"
.. "Off is unchanged, and daylight is unchanged at every setting.",
},
},
ruRU = {
page = "Расширенная графика",
lightingQuality = "Качество освещения",
-- MONKEY (presets): the Graphics Preset and Render Distance rows.
graphicsQuality = "Набор графики",
renderDistance = "Дальность прорисовки",
pClassic = "Классика",
qUltra = "Ультра",
-- MONKEY (post): Russian HDR excess + halo quality row.
bloom = "Свечение",
sunShafts = "Солнечные лучи",
colorGrading = "Цветокоррекция",
-- MONKEY (volumetric fog): Russian atmospheric quality row.
volumetricFog = "Объёмный туман",
-- MONKEY (sky): the sky tier row.
skyQuality = "Качество неба",
fogOff = "Выкл",
-- MONKEY (skybox): the zone skybox row.
zoneSkyboxes = "Небосводы зон",
waterQuality = "Качество воды",
waterClassic = "Классическое",
waterEnhanced = "Улучшенное",
@@ -637,14 +765,49 @@
interiorLight = "Динамическое освещение помещений",
interiorShadows = "Тени факелов в помещениях",
exteriorShadows = "Тени огней под открытым небом",
torchTerrainShadows = "Рельеф заслоняет огни",
torchSoft = "Мягкость теней от огня",
fireLight = "Яркость огня",
fireFlicker = "Мерцание огня",
spellLights = "Свет от заклинаний",
nightDarkness = "Темнота ночи",
interiorDarkness = "Темнота помещений",
-- MONKEY (p0 skyDither)
skyDither = "Дизеринг неба",
-- MONKEY (fog)
fogModel = "Современный туман",
-- MONKEY (wet)
rainSurfaces = "Мокрые поверхности в дождь",
-- MONKEY (wind)
foliageWind = "Ветер листвы",
windGrass = "Трава",
windTrees = "Трава + деревья",
-- MONKEY (ao): Russian contact-shadow row.
ambientOcclusion = "Затенение окружения",
-- MONKEY (lampfog)
lampFog = "Свечение фонарей в тумане",
-- MONKEY (integration): the daylight lane's district window split.
daylightWindowSplit = "Дневной свет через окна города",
yards = "%d ярд.",
tips = {
-- MONKEY (presets)
GRAPHICS_QUALITY =
"Задаёт все настройки графики сразу: качество освещения и всё, что оно задаёт, "
.. "дальность прорисовки, качество неба, дизеринг неба, современный туман, мокрые "
.. "поверхности в дождь, заслонение огней рельефом, дневной свет в окнах городов, "
.. "затенение окружения, свечение фонарей в тумане и небосводы зон.\n\n"
.. "«Классика» — оригинальный клиент: все улучшения выключены, дальность обзора "
.. "прежние 350 ярдов. «Низкое» и «Среднее» добавляют почти бесплатные улучшения; "
.. "«Высокое» — рекомендуемый вид; «Ультра» поднимает всё качество до максимума, а "
.. "дальность — до 1497 ярдов, и стоит дороже всего.\n\n"
.. "Дальность теней и мягкость теней от огня сохраняют ваши значения. Изменение "
.. "любой настройки набора показывает «Своё».",
RENDER_DISTANCE =
"Как далеко рисуется подробный мир, в ярдах: та же настройка, что «Дальность "
.. "обзора» на странице «Графика», но с большим пределом. Дальше 777 ярдов, предела "
.. "оригинального клиента, дополнительное расстояние видно с современным туманом; "
.. "с оригинальным туманом его скрывает туман зоны.\n\n"
.. "Чем дальше, тем больше памяти, загрузки и работы видеокарты.",
LIGHTING_QUALITY =
"Задаёт тени персонажей, мира и огней внутри и снаружи помещений; "
.. "динамическое освещение помещений; лунные тени; объёмный туман; качество воды; свечение лавы; яркость огня и заклинаний; "
@@ -655,11 +818,32 @@
.. "источников света, с коэффициентами яркости ночи и помещений 1.0. "
.. "Все остальные настройки, включая дальность теней и мягкость теней от огня, "
.. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; "
.. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», "
.. "зеркальные отражения включаются отдельно. «Ультра» также поднимает разрешение теней "
.. "до 4096 и число теней от огней до 16 и 8, а вода и объёмный туман — «Высокое». "
.. "Изменение настройки набора показывает «Своё», "
.. "если ни один набор больше не совпадает.",
-- MONKEY (post): localised bloom effect and Off contract.
BLOOM =
"Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.",
SUN_SHAFTS =
"Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.",
COLOR_GRADING =
"Применяет мягкую цветовую палитру зоны и плавно меняет дневной вариант на ночной. Интерфейс не изменяется.",
-- MONKEY (volumetric fog): localised effect and cost explanation.
VOLUMETRIC_FOG =
"Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.",
-- MONKEY (sky): what each sky tier adds.
SKY_QUALITY =
"«Классическое» — небо оригинального клиента. «Улучшенное» сглаживает "
.. "цветовой переход неба, добавляет мягкое сияние вокруг солнца и звёздное "
.. "ночное небо с Млечным Путём. «Высокое» также добавляет облакам мелкие "
.. "детали и освещённые солнцем края. Почти не влияет на производительность.",
-- MONKEY (skybox): what the zone skybox row changes.
ZONE_SKYBOXES =
"Рисует нарисованное небо, заданное освещением зоны, как в поздних дополнениях: "
.. "вулканическое небо Пылающих степей, чужое небо Выжженных земель "
.. "и звёздную ночь над горой Хиджал, с плавным появлением на границе зоны. "
.. "«Выкл.» оставляет небо оригинального клиента везде, кроме неба призрака и подземелий. Почти бесплатно.",
WATER_QUALITY =
"«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, "
.. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба "
@@ -710,6 +894,12 @@
"То же для огней под открытым небом: костры, жаровни и фонари "
.. "отбрасывают настоящие тени после наступления темноты.\n\n"
.. "Днём не действует и не требует динамического освещения помещений.",
TORCH_TERRAIN_SHADOWS =
"Земля сама участвует в тенях от огней снаружи: холм или склон между "
.. "костром и местностью за ним заслоняет свет огня.\n\n"
.. "Добавляет ближний рельеф в карту теней каждого уличного огня при её "
.. "перестройке; затраты на кадр невелики. Требует теней огней под "
.. "открытым небом.",
TORCH_SOFT =
"Насколько мягок край тени от огня там, где предмет касается пола. "
.. "Низкое значение — резкий край, высокое — размытый, как от "
@@ -743,6 +933,48 @@
"Масштабирует свет светильников и фоновое освещение помещений, включая "
.. "свет ламп и свечей. Меньшие значения ослабляют и то и другое.\n\n"
.. "1.0 оставляет эти составляющие без изменений. Ничего не стоит при любом значении.",
-- MONKEY (p0 skyDither)
SKY_DITHER =
"Добавляет едва заметный шум в итоговое изображение, чтобы плавные переходы цвета "
.. "неба и тумана не распадались на полосы. Выключено — вид оригинального клиента. "
.. "Почти ничего не стоит.",
-- MONKEY (fog)
FOG_MODEL =
"Более мягкий туман в духе современного клиента: он сгущается постепенно, а не "
.. "по прямой, днём светлеет в сторону солнца, а вдали принимает цвет неба, "
.. "так что земля растворяется в горизонте. Дальность тумана в каждой зоне "
.. "прежняя; за пределом дальности обзора оригинала туман отступает вместе с ней."
.. "\n\nВыключено — туман оригинального клиента. Почти ничего не стоит.",
-- MONKEY (wet)
RAIN_SURFACES =
"Дождь постепенно намачивает землю, камень и дерево под открытым небом: они темнеют, "
.. "на ровной земле собираются лужи, мокрые поверхности блестят, а капли расходятся "
.. "кругами по воде. После дождя всё высыхает за несколько минут. Выключено — вид "
.. "оригинального клиента. Почти ничего не стоит и работает, только пока всё мокрое.",
-- MONKEY (wind)
FOLIAGE_WIND =
"Трава гнётся двумя бегущими волнами ветра и расступается вокруг персонажей. "
.. "Трава + деревья также добавляет мягкое качание листвы деревьев и кустов.\n\n"
.. "Выключено не смещает вершины и восстанавливает исходный вид. Тени деревьев остаются неподвижными.",
-- MONKEY (ao): Russian contact-shadow explanation.
AMBIENT_OCCLUSION =
"Мягкие контактные тени там, где поверхности соприкасаются: под предметами и мебелью, "
.. "в углах между стенами и полом, у ног персонажей. «Низкое» использует меньше выборок; "
.. "«Высокое» — более плавное и захватывает чуть больше.\n\n"
.. "Ослабевает с расстоянием и не затрагивает небо, воду, освещённые окна и пламя. "
.. "«Выкл» оставляет изображение без изменений.",
-- MONKEY (integration): daylight window split.
DAYLIGHT_WINDOW_SPLIT =
"Большие городские здания пропускают дневной свет через каждое окно отдельно, "
.. "а не через всю стену окон как один проём, поэтому залы вроде собора "
.. "освещаются солнцем окно за окном.\n\nДействует для зданий, загруженных после "
.. "изменения. Выключено — прежний вид.",
-- MONKEY (lampfog)
LAMP_FOG =
"После заката ближайшие фонари и огни светятся сквозь воздух. Низкое "
.. "качество использует 16 ближайших источников, высокое — 32. Дождь и туман усиливают ореолы. "
.. "Эффект не рассчитывает тени, но обрывается на видимой глубине.\n\n"
.. "Выключено — без изменений; днём изображение не меняется при любом значении.",
},
},
}
@@ -794,13 +1026,37 @@
-- the rig they share, and the Graphics page had become the place where a player found
-- three of eighteen shadow settings and no hint that the other fifteen existed.
AdvancedGraphics = {
-- MONKEY (presets): the ladder over every row below, then the view distance.
"RowGraphicsQuality", "RowRenderDistance",
-- MONKEY (volumetric fog): include in refresh, defaults and search.
"RowLightingQuality", "RowWaterQuality", "RowVolumetricFog",
-- MONKEY (sky): the sky tier row.
"RowSkyQuality",
-- MONKEY (post): the world-only screen effects.
"RowBloom", "RowSunShafts", "RowColorGrading",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows",
-- MONKEY (daylight: terrain torch casters)
"RowTorchTerrainShadows", "RowTorchSoft",
"RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow",
"RowNightDarkness", "RowInteriorDarkness",
-- MONKEY (p0 skyDither)
"RowSkyDither",
-- MONKEY (fog)
"RowFogModel",
-- MONKEY (wet)
"RowRainSurfaces",
-- MONKEY (wind): appended for conflict-free lane merges.
"RowFoliageWind",
-- MONKEY (ao): appended so the other rows keep their indices.
"RowAmbientOcclusion",
-- MONKEY (lampfog)
"RowLampFog",
-- MONKEY (integration): daylight window split.
"RowDaylightWindowSplit",
-- MONKEY (integration): the skybox lane's row (the lane left it out of this list).
"RowZoneSkyboxes",
},
-- Player Guild Names is a child of Player Names, as in 1.12 (l.702-708).
Nameplates = {
@@ -1315,6 +1571,9 @@
local torch = (interior and GetCVar("interiorShadows") ~= "0")
or GetCVar("exteriorShadows") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch)
-- MONKEY (daylight: terrain torch casters): the ground only casts for outdoor fires.
OptionsRow_SetEnabled(getglobal(base .. "RowTorchTerrainShadows"),
GetCVar("exteriorShadows") ~= "0")
return
end
if key == "Controls" then
@@ -1408,7 +1667,20 @@
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal(base .. rkey)
row.pending = nil
OptionsRow_Set(row, OptionsRow_Default(row))
if not row.preset then
OptionsRow_Set(row, OptionsRow_Default(row))
end
end
-- MONKEY (presets): a preset row goes last, so its default rung (the Graphics
-- Preset's High) is what the page lands on rather than its members' own defaults.
-- `SetCVar` queues only a moved value, so "Custom" (which the host applies as
-- nothing) goes first: the rung is then always a queued write, after the members.
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal(base .. rkey)
if row.preset then
OptionsRow_Set(row, "Custom")
OptionsRow_Set(row, OptionsRow_Default(row))
end
end
OptionsPage_Refresh(key)
end
@@ -2537,11 +2809,51 @@
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowLightingQuality" inherits="OptionsDropdownRowTemplate">
<!-- MONKEY (presets): the Graphics Preset. Like Lighting
Quality below it, ONE CVar: the host's
`cvars::GRAPHICS_PRESETS` writes the rows and
re-derives this one every frame. -->
<Frame name="$parentRowGraphicsQuality" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "graphicsQuality",
BENILLA_ADVGFX.graphicsQuality,
"BENILLA_TOOLTIP_GRAPHICS_QUALITY")
self.preset = 1
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.pClassic, value = "Classic" },
{ text = BENILLA_ADVGFX.qLow, value = "Low" },
{ text = BENILLA_ADVGFX.qMedium, value = "Medium" },
{ text = BENILLA_ADVGFX.qHigh, value = "High" },
{ text = BENILLA_ADVGFX.qUltra, value = "Ultra" },
{ text = BENILLA_ADVGFX.qCustom, value = "Custom" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (presets): `farclip`, the Graphics page's Terrain
Distance, on the extended range (`view::FARCLIP_RANGE`,
177..1497 on the 1.12 grid of 60). Live like its
Graphics twin; both read the one CVar. -->
<Frame name="$parentRowRenderDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowGraphicsQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowGraphicsQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "farclip",
BENILLA_ADVGFX.renderDistance,
"BENILLA_TOOLTIP_RENDER_DISTANCE")
OptionsSliderRow_Setup(self, 177, 1497, 60, "yd")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowLightingQuality" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowRenderDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowRenderDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- The preset ladder. This row writes ONE CVar and
-- nothing else: the host's own table
@@ -2566,6 +2878,7 @@
{ text = BENILLA_ADVGFX.qLow, value = "Low" },
{ text = BENILLA_ADVGFX.qMedium, value = "Medium" },
{ text = BENILLA_ADVGFX.qHigh, value = "High" },
{ text = BENILLA_ADVGFX.qUltra, value = "Ultra" },
{ text = BENILLA_ADVGFX.qCustom, value = "Custom" },
})
</OnLoad></Scripts>
@@ -2602,11 +2915,66 @@
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<!-- MONKEY (sky): the sky tier, Classic / Enhanced / High. -->
<Frame name="$parentRowSkyQuality" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "skyQuality", BENILLA_ADVGFX.skyQuality,
"BENILLA_TOOLTIP_SKY_QUALITY")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.waterClassic, value = "0" },
{ text = BENILLA_ADVGFX.waterEnhanced, value = "1" },
{ text = BENILLA_ADVGFX.waterHigh, value = "2" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (post): live HDR/bloom tier; Off is the baseline. -->
<Frame name="$parentRowBloom" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSkyQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSkyQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "bloom", BENILLA_ADVGFX.bloom,
"BENILLA_TOOLTIP_BLOOM")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.fogOff, value = "0" },
{ text = BENILLA_ADVGFX.qLow, value = "1" },
{ text = BENILLA_ADVGFX.qHigh, value = "2" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (post): depth-occluded radial sunlight, world only. -->
<Frame name="$parentRowSunShafts" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowBloom" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowBloom" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "sunShafts", BENILLA_ADVGFX.sunShafts,
"BENILLA_TOOLTIP_SUN_SHAFTS")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (post): zone/day-night 32-cube grade, world only. -->
<Frame name="$parentRowColorGrading" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading,
"BENILLA_TOOLTIP_COLOR_GRADING")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<!-- MONKEY (post): screen effects sit immediately above this one. -->
<Anchor point="TOPLEFT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows",
BENILLA_ADVGFX.characterShadows,
@@ -2721,11 +3089,23 @@
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<!-- MONKEY (daylight: terrain torch casters) -->
<Frame name="$parentRowTorchTerrainShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "torchTerrainShadows",
BENILLA_ADVGFX.torchTerrainShadows,
"BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowTorchTerrainShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTorchTerrainShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0.5..3, the CVar's own clamp verbatim: it is a
-- SCALE on the PCF tap radius at a contact, so the
@@ -2827,6 +3207,115 @@
OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (p0 skyDither): the combine's deband dither. -->
<Frame name="$parentRowSkyDither" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowInteriorDarkness" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowInteriorDarkness" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "skyDither",
BENILLA_ADVGFX.skyDither,
"BENILLA_TOOLTIP_SKY_DITHER")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (fog): the distance-fog model. -->
<Frame name="$parentRowFogModel" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSkyDither" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSkyDither" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "fogModel",
BENILLA_ADVGFX.fogModel,
"BENILLA_TOOLTIP_FOG_MODEL")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (wet): wet surfaces + rain ripples. -->
<Frame name="$parentRowRainSurfaces" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFogModel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFogModel" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "rainSurfaces",
BENILLA_ADVGFX.rainSurfaces,
"BENILLA_TOOLTIP_RAIN_SURFACES")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (wind): three foliage tiers; appended at the end of the page. -->
<Frame name="$parentRowFoliageWind" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowRainSurfaces" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowRainSurfaces" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "foliageWind",
BENILLA_ADVGFX.foliageWind,
"BENILLA_TOOLTIP_FOLIAGE_WIND")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.qOff, value = "0" },
{ text = BENILLA_ADVGFX.windGrass, value = "1" },
{ text = BENILLA_ADVGFX.windTrees, value = "2" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (ao): numeric tiers through the existing localised dropdown. -->
<Frame name="$parentRowAmbientOcclusion" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFoliageWind" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFoliageWind" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "ambientOcclusion", BENILLA_ADVGFX.ambientOcclusion,
"BENILLA_TOOLTIP_AMBIENT_OCCLUSION")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.fogOff, value = "0" },
{ text = BENILLA_ADVGFX.qLow, value = "1" },
{ text = BENILLA_ADVGFX.qHigh, value = "2" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (lampfog): night point-light airlight tiers. -->
<Frame name="$parentRowLampFog" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAmbientOcclusion" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAmbientOcclusion" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "lampFog", BENILLA_ADVGFX.lampFog,
"BENILLA_TOOLTIP_LAMP_FOG")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.fogOff, value = "0" },
{ text = BENILLA_ADVGFX.qLow, value = "1" },
{ text = BENILLA_ADVGFX.qHigh, value = "2" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (integration): the daylight lane's district window split (checkbox). -->
<Frame name="$parentRowDaylightWindowSplit" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowLampFog" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowLampFog" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "daylightWindowSplit",
BENILLA_ADVGFX.daylightWindowSplit,
"BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (skybox): the zone skybox switch (integration: appended at the page end). -->
<Frame name="$parentRowZoneSkyboxes" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowDaylightWindowSplit" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowDaylightWindowSplit" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "zoneSkyboxes",
BENILLA_ADVGFX.zoneSkyboxes,
"BENILLA_TOOLTIP_ZONE_SKYBOXES")
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
settings move from outside it — the Lighting Quality row
@@ -29,6 +29,65 @@ const CHEST_GUID: u64 = (0xF110u64 << 48) | 0x744;
/// face, `rear` the tail — the same body, its lit and unlit sides.
const SUBJECT_YAW: f32 = 2.36;
/// MONKEY (wind): stand one real player in the grass-parting world capture without loading the
/// player UI. `UiFixture::Subject` intentionally opts the whole HUD in; parting needs the live
/// entity path but a pristine world framebuffer. `look` is the scenario's documented feet point.
pub(super) fn seed_wind_player(
mut commands: Commands,
ctx: Res<CaptureCtx>,
progress: Res<WorldLoadProgress>,
mut seeded: Local<bool>,
) {
let Some(scenario) = ctx.scenario else {
return;
};
if *seeded
|| scenario.name != "wind-player-parting"
|| !(progress.total > 0 && progress.ready == progress.total)
{
return;
}
*seeded = true;
use benilla_protocol::messages::ObjectFields;
commands.spawn((
crate::net::Guid(0x51),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Player,
// HumanMale.m2; the normal entity visual path supplies the body.
display_id: Some(49),
scale: 1.0,
},
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(22, 100), // UNIT_FIELD_HEALTH
(28, 100), // UNIT_FIELD_MAXHEALTH
(34, 12), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE
(36, 1 | 1 << 8), // UNIT_FIELD_BYTES_0: human warrior
])),
// A player visual waits for its equipment resolution handshake before attaching. This
// fixture is intentionally naked, so its final resolved set is known immediately.
crate::entities::Equipment {
settled: true,
..default()
},
// Mark this visible remote body as the capture's viewer. The real client publishes the
// local body through `Viewer::at`; this server-less instrument has no active Player.
benilla_world::world_unit::ViewerUnit,
benilla_world::world_unit::WorldUnit {
wades: true,
scale: 1.0,
height: crate::entities::CollisionHeight::default().0,
bound: None,
},
Transform {
translation: wow_to_bevy(scenario.look),
rotation: Quat::from_rotation_y(SUBJECT_YAW),
..default()
},
Visibility::default(),
));
}
/// Seed the fixture window's state resources once, right after the scene goes resident — the real
/// feeds then push it into the VM during the settle window exactly as live wire data would. Item
/// icons resolve through the offline `ItemDisplayCatalog` (display ids chosen from entries that
+2 -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;
use fixtures::{seed_ui_fixture, seed_wind_player};
pub(crate) use live_shot::LiveShotPlugin;
pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
@@ -904,6 +904,7 @@ impl Plugin for CapturePlugin {
bailed: false,
})
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
.add_systems(Update, seed_wind_player)
// 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
+430
View File
@@ -253,6 +253,8 @@ pub(super) enum SubjectKind {
pub(super) const GROUND_EYE: [f32; 3] = [-8980.0, -160.0, 110.0];
pub(super) const GROUND_LOOK: [f32; 3] = [-8949.95, -132.49, 84.0];
pub(super) const SKY_EYE: [f32; 3] = [-8980.0, -160.0, 112.0];
/// MONKEY (sky): 80 yd over the Northshire sky eye, above every canopy.
pub(super) const SKY_HIGH_EYE: [f32; 3] = [-8980.0, -160.0, 190.0];
pub(super) const SKY_LOOK: [f32; 3] = [-8740.0, 80.0, 168.0]; // up + out: horizon in the lower third, dome above
// Farmhouse viewpoints (decision 0071): compass looks from the human-start login spot. Kept
@@ -1378,9 +1380,424 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
look: LAVA_RIVER_LOOK,
minute: 0,
ui: None,
}, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords
// from the census (`lighting::daylight::census`, Stormwind uid 10047, Ironforge uid 7706).
// The Gilded Rose ground floor (group g268, portal + aperture seeds).
Scenario {
name: "daylight-sw-inn", map: Some(MAP_AZEROTH),
eye: [-8871.0, 681.0, 99.8], look: [-8858.0, 668.0, 98.6], minute: 720, ui: None,
},
// The Cathedral of Light nave (groups g135/g146, lit only by two EXT-class window batches).
Scenario {
name: "daylight-sw-cathedral", map: Some(MAP_AZEROTH),
eye: [-8556.0, 826.0, 109.0], look: [-8515.0, 862.0, 112.0], minute: 720, ui: None,
},
// A Trade District house (group g68 NEH02, one exterior portal).
Scenario {
name: "daylight-sw-shop", map: Some(MAP_AZEROTH),
eye: [-8799.0, 696.0, 104.3], look: [-8786.0, 707.0, 103.0], minute: 720, ui: None,
},
// Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the
// city's one exterior portal).
Scenario {
name: "daylight-if-forge", map: Some(MAP_AZEROTH),
eye: [-4930.0, -945.0, 503.5], look: [-4890.0, -985.0, 503.0], minute: 720, ui: None,
},
Scenario {
name: "daylight-if-gate", map: Some(MAP_AZEROTH),
eye: [-4975.0, -895.0, 503.5], look: [-5005.0, -852.0, 506.0], minute: 720, ui: None,
}, // Goldshire's lamps at midnight: terrain receiving (and, with `torchTerrainShadows`, casting)
// torch cube shadows.
Scenario {
name: "daylight-torch-goldshire", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None,
}, // Two Elwynn road lampposts whose own light the terrain blocks most (the census's
// `lamp_terrain_occlusion_scan`: 18 % and 16 % of the ground within 25 yd), at midnight, framed
// across the lamp toward the blocked side.
Scenario {
name: "daylight-torch-hill-a", map: Some(MAP_AZEROTH),
eye: [-9314.8, 134.1, 70.0], look: [-9320.4, 165.6, 64.0], minute: 0, ui: None,
},
Scenario {
name: "daylight-torch-hill-b", map: Some(MAP_AZEROTH),
eye: [-9163.1, 181.1, 77.0], look: [-9147.1, 153.4, 71.0], minute: 0, ui: None,
},
// MONKEY (post): lane-specific A/B and perf viewpoints. Kept at the end for merge isolation.
Scenario {
name: "post-lava-searing",
map: Some(MAP_AZEROTH),
eye: LAVA_RIVER_EYE,
look: LAVA_RIVER_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "post-stormwind-night",
map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 96.0],
look: [-8809.1, 672.3, 94.0],
minute: 0,
ui: None,
},
Scenario {
name: "post-sunshafts-noon",
map: Some(MAP_AZEROTH),
eye: WATER_EYE,
// Noon's 85-degree sun above the Goldshire trees (azimuth 45 degrees).
look: [-9508.5, -292.1, 369.7],
minute: 720,
ui: None,
},
Scenario {
name: "post-duskwood-grade",
map: Some(MAP_AZEROTH),
eye: [-10580.0, -1200.0, 45.0],
look: [-10540.0, -1160.0, 30.0],
minute: 1260,
ui: None,
},
// ---- MONKEY (p0 baseline): the graphics programme's baseline set ----
// Every lane diffs its work against captures of these (plus the water-*, volfog-* and canal
// scenes above). Raw WoW coords, map per scenario; noon = 720, dusk = 1170, night = 0.
// Elwynn from the Goldshire road toward the lake and the forest: sky, fog and trees.
Scenario {
name: "elwynn-noon", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 720, ui: None,
},
Scenario {
name: "elwynn-dusk", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 1170, ui: None,
},
Scenario {
name: "elwynn-night", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 0, ui: None,
},
// The same Elwynn view for rain. Capture has no weather field: run it with
// `WOW_WEATHER=1,0.8` (kind 1 rain, grade 0.8; `weather::parse_env_script`).
Scenario {
name: "elwynn-rain", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 720, ui: None,
},
// Westfall: a farmstead, fields and the dry palette.
Scenario {
name: "westfall-farm", map: Some(MAP_AZEROTH),
eye: [-10080.0, 1000.0, 58.0], look: [-10140.0, 1070.0, 38.0], minute: 720, ui: None,
},
// Redridge: Lake Everstill from above Lakeshire, a wider frame than `water-lake`.
Scenario {
name: "redridge-lake", map: Some(MAP_AZEROTH),
eye: [-9350.0, -2340.0, 82.0], look: [-9500.0, -2650.0, 50.0], minute: 720, ui: None,
},
// Duskwood: the road into Darkshire, the zone's short dark fog.
Scenario {
name: "duskwood-road", map: Some(MAP_AZEROTH),
eye: [-10560.0, -1000.0, 50.0], look: [-10570.0, -1200.0, 32.0], minute: 720, ui: None,
},
// Burning Steppes: the ash plain and the sky a skybox would replace.
Scenario {
name: "burning-steppes", map: Some(MAP_AZEROTH),
eye: [-7700.0, -2100.0, 200.0], look: [-7900.0, -1700.0, 150.0], minute: 720, ui: None,
},
// Blasted Lands: the Dark Portal from the north.
Scenario {
name: "blasted-lands-portal", map: Some(MAP_AZEROTH),
eye: [-11700.0, -3200.0, 20.0], look: [-11900.0, -3208.0, 0.0], minute: 720, ui: None,
},
// Mount Hyjal (Kalimdor), inside Light.dbc sphere 270.
Scenario {
name: "hyjal-mount", map: Some(MAP_KALIMDOR),
eye: GFX_HYJAL_EYE, look: GFX_HYJAL_LOOK, minute: 720, ui: None,
},
// Stormwind: the Cathedral of Light's nave (interior WMO lighting).
Scenario {
name: "stormwind-cathedral-interior", map: Some(MAP_AZEROTH),
eye: GFX_CATHEDRAL_EYE, look: GFX_CATHEDRAL_LOOK, minute: 720, ui: None,
},
// Ironforge: the Great Forge (lava, fire light, a huge interior).
Scenario {
name: "ironforge-forge", map: Some(MAP_AZEROTH),
eye: GFX_FORGE_EYE, look: GFX_FORGE_LOOK, minute: 720, ui: None,
},
// MONKEY (sky): the sky lane's fixtures, from 80 yd above Northshire (clear of the canopy).
// Dusk faces the low sun (az 45°, elev ≈5° at 20:00); night faces away from the moon at 01:00
// (full star curve); zenith looks nearly straight up; overcast is meant for
// `WOW_WEATHER=1,0.5`; noon faces away from the sun.
Scenario {
name: "sky-elwynn-dusk",
map: Some(MAP_AZEROTH),
eye: SKY_HIGH_EYE,
look: [-8770.0, 50.0, 232.0],
minute: 1200,
ui: None,
},
Scenario {
name: "sky-elwynn-night",
map: Some(MAP_AZEROTH),
eye: SKY_HIGH_EYE,
look: [-9164.0, -344.0, 340.0],
minute: 60,
ui: None,
},
Scenario {
name: "sky-zenith-night",
map: Some(MAP_AZEROTH),
eye: SKY_HIGH_EYE,
look: [-9017.0, -197.0, 485.0],
minute: 60,
ui: None,
},
Scenario {
name: "sky-overcast",
map: Some(MAP_AZEROTH),
eye: SKY_HIGH_EYE,
look: [-9185.0, 45.0, 268.0],
minute: 1000,
ui: None,
},
Scenario {
name: "sky-noon",
map: Some(MAP_AZEROTH),
eye: SKY_HIGH_EYE,
look: [-9185.0, -365.0, 268.0],
minute: 720,
ui: None,
},
// MONKEY wind: keep these at the END of the table. They are the three visual instruments for
// W1: authored grass weight/lean, static-tree classification/sway, and the viewer bender.
Scenario {
name: "wind-grass-elwynn",
map: Some(MAP_AZEROTH),
// MONKEY (integration): restore the lane's earlier world-visible framing. The closer
// handoff coordinates put the camera inside a Goldshire building and showed no grass.
eye: [-9505.0, 85.0, 64.0],
look: [-9460.0, 45.0, 57.0],
minute: 720,
ui: None,
},
Scenario {
name: "wind-forest-elwynn",
map: Some(MAP_AZEROTH),
// East of Goldshire looking south-west across the lake and Elwynn forest.
eye: [-9380.0, -30.0, 80.0],
look: [-9600.0, -200.0, 62.0],
minute: 720,
ui: None,
},
Scenario {
name: "wind-player-parting",
map: Some(MAP_AZEROTH),
eye: [-9472.0, 57.0, 60.5],
look: [-9460.0, 45.0, 57.5],
minute: 720,
// The capture plugin stands a real player at `look` without opting into the HUD: this is
// a world-image instrument, and UI script errors must not cover the grass it measures.
ui: None,
},
// MONKEY (fog): Duskwood's short zone fog (Light 15, LightParams 14) from above the canopy west
// of Darkshire toward the town, for the Classic/Modern fog A/B (`WOW_FOGMODEL=0|1`).
Scenario {
name: "fog-duskwood-noon",
map: Some(MAP_AZEROTH),
eye: [-10700.0, -900.0, 160.0],
look: [-10560.0, -1180.0, 60.0],
minute: 720,
ui: None,
},
// MONKEY (fog): a long Elwynn vista from the Northshire ridge south over Goldshire's forest.
Scenario {
name: "fog-vista-noon",
map: Some(MAP_AZEROTH),
eye: [-9000.0, -100.0, 200.0],
look: [-9500.0, -100.0, 90.0],
minute: 720,
ui: None,
},
Scenario {
name: "fog-vista-dusk",
map: Some(MAP_AZEROTH),
eye: [-9000.0, -100.0, 200.0],
look: [-9500.0, -100.0, 90.0],
minute: 1170,
ui: None,
},
Scenario {
name: "fog-duskwood-dusk",
map: Some(MAP_AZEROTH),
eye: [-10700.0, -900.0, 160.0],
look: [-10560.0, -1180.0, 60.0],
minute: 1170,
ui: None,
},
// MONKEY (wet): rain on surfaces. Run with rain forced and the ground pre-soaked, e.g.
// `WOW_WEATHER=1,0.8 WOW_WETNESS=1 WOW_WET_T=3`, and A/B with `WOW_RAIN_SURFACES=0|1`.
// Goldshire's square: the road, the grass, the inn's roofs and walls.
Scenario {
name: "wet-goldshire", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 59.5], look: [-9452.0, 30.0, 55.0], minute: 720, ui: None,
},
// The Elwynn river bank, close enough (< 25 yd) for the rain rings.
Scenario {
name: "wet-river", map: Some(MAP_AZEROTH),
eye: [-9514.0, -330.0, 64.0], look: [-9500.0, -352.0, 61.4], minute: 720, ui: None,
},
// A Stormwind Trade District street: exterior WMO paving, walls, eaves.
Scenario {
name: "wet-stormwind", map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 98.5], look: [-8815.0, 662.0, 93.5], minute: 720, ui: None,
},
// MONKEY (fix-wet): the wet-stormwind framing at midnight: lit windows must stay lit in rain.
Scenario {
name: "wet-stormwind-night", map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 98.5], look: [-8815.0, 662.0, 93.5], minute: 0, ui: None,
},
// MONKEY (ao): contact-shadow subjects. Goldshire's street (props against walls, eaves),
// an Elwynn forest floor (trunks, bushes, grass cutouts) and a close unit on open ground.
Scenario {
name: "ao-goldshire", map: Some(MAP_AZEROTH),
eye: [-9430.0, 50.0, 61.0], look: [-9462.0, 30.0, 57.5], minute: 720, ui: None,
},
Scenario {
name: "ao-forest", map: Some(MAP_AZEROTH),
eye: [-9560.0, 60.0, 62.0], look: [-9600.0, 90.0, 58.0], minute: 720, ui: None,
},
Scenario {
name: "ao-character", map: Some(MAP_AZEROTH),
eye: [-9495.2, 60.8, 59.0], look: [-9500.00, 56.00, 56.9], minute: 720,
ui: Some(UiFixture::Subject { kind: SubjectKind::Creature, at: SUBJECT_SUN }),
},
// MONKEY (lampfog): lampFog 0/2 A/B set. Kept at the END so parallel lane tables merge cleanly.
// Goldshire square: the Lion's Pride fixtures and outdoor lamps share one night frame.
Scenario {
name: "lampfog-goldshire-night",
map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0],
look: [-9450.0, 20.0, 61.0],
minute: 0,
ui: None,
},
// Stormwind Trade District from the established city-scale camera, after dark.
Scenario {
name: "lampfog-stormwind-night",
map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 96.0],
look: [-8809.1, 672.3, 94.0],
minute: 0,
ui: None,
},
// Goldshire's east road, looking through the known exterior lamp at (-9477, 53, 60). This is
// deliberately not the fence instrument: that camera looks down at an unlit verge at night.
Scenario {
name: "lampfog-elwynn-road-night",
map: Some(MAP_AZEROTH),
eye: [-9505.0, 36.0, 61.5],
look: [-9455.0, 75.0, 59.0],
minute: 0,
ui: None,
},
// Exact Goldshire framing at noon: lampFog 0 and 2 must be pixel-identical.
Scenario {
name: "lampfog-goldshire-day",
map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0],
look: [-9450.0, 20.0, 61.0],
minute: 720,
ui: None,
},
// MONKEY (skybox): zone skyboxes (run with `WOW_ZONE_SKYBOXES=1`) and the two stock skybox
// lanes. Karazahn40 (Turtle map 814) is the one stock sphere naming a clear-slot skybox
// (Light 553, HellfireSkyBox); the zone shots need the sky patch.
// The stock MOSB lane: Stratholme_B's groups flagged 0x40000, weight = the interior crossfade.
Scenario {
name: "skybox-stratholme-noon",
map: Some(329),
eye: SKYBOX_STRAT_EYE,
look: SKYBOX_STRAT_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-karazhan-noon",
map: Some(814),
eye: SKYBOX_KZ_EYE,
look: SKYBOX_KZ_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-karazhan-night",
map: Some(814),
eye: SKYBOX_KZ_EYE,
look: SKYBOX_KZ_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "skybox-steppes-noon",
map: Some(MAP_AZEROTH),
eye: SKYBOX_STEPPES_EYE,
look: SKYBOX_STEPPES_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-steppes-night",
map: Some(MAP_AZEROTH),
eye: SKYBOX_STEPPES_EYE,
look: SKYBOX_STEPPES_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "skybox-blasted-noon",
map: Some(MAP_AZEROTH),
eye: SKYBOX_BLASTED_EYE,
look: SKYBOX_BLASTED_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-blasted-night",
map: Some(MAP_AZEROTH),
eye: SKYBOX_BLASTED_EYE,
look: SKYBOX_BLASTED_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "skybox-hyjal-noon",
map: Some(MAP_KALIMDOR),
eye: SKYBOX_HYJAL_EYE,
look: SKYBOX_HYJAL_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-hyjal-night",
map: Some(MAP_KALIMDOR),
eye: SKYBOX_HYJAL_EYE,
look: SKYBOX_HYJAL_LOOK,
minute: 0,
ui: None,
},
];
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
/// south-west over the forest to the river and the hills (framed with the `vista` instrument).
pub(super) const GFX_ELWYNN_EYE: [f32; 3] = [-9400.0, -100.0, 122.0];
pub(super) const GFX_ELWYNN_LOOK: [f32; 3] = [-9477.6, -160.6, 104.6];
/// MONKEY (p0 baseline): Mount Hyjal, over Light.dbc sphere 270's centre (world yards; the
/// sphere's own z 873 is below the terrain), pitched a little up: its params-269 fog (end 278 yd,
/// orange) fills the frame today, which is the subject the skybox and fog lanes change.
pub(super) const GFX_HYJAL_EYE: [f32; 3] = [4636.0, -4461.0, 1152.0];
pub(super) const GFX_HYJAL_LOOK: [f32; 3] = [4724.2, -4416.0, 1165.9];
/// MONKEY (p0 baseline): the Cathedral of Light nave, standing over the floor.
pub(super) const GFX_CATHEDRAL_EYE: [f32; 3] = [-8530.0, 845.0, 112.0];
pub(super) const GFX_CATHEDRAL_LOOK: [f32; 3] = [-8500.0, 880.0, 110.0];
/// MONKEY (p0 baseline): Ironforge's interior by the Great Forge ring (a lit WMO interior; the
/// camera must stand over a floor face or the portal cull hides the city).
pub(super) const GFX_FORGE_EYE: [f32; 3] = [-4880.0, -1000.0, 506.0];
pub(super) const GFX_FORGE_LOOK: [f32; 3] = [-4950.4, -929.6, 497.3];
/// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing
/// 1.53 rad, Searing Gorge, map 0. The eye takes the standard [`VISTA_EYE_HEIGHT`]-ish lift off
/// the pin (a `.go` lands at the feet) and the look runs 60 yd down the reported facing,
@@ -1482,6 +1899,19 @@ pub(crate) fn ui_opted_in() -> bool {
std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") || scenario_declares_ui()
}
// MONKEY (skybox): the skybox shots, eye just above the ground at a sphere's centre, pitched up so
// the upper frame is sky.
const SKYBOX_STRAT_EYE: [f32; 3] = [3450.0, -3380.0, 150.0];
const SKYBOX_STRAT_LOOK: [f32; 3] = [3600.0, -3300.0, 200.0];
const SKYBOX_KZ_EYE: [f32; 3] = [-6474.0, -2912.0, 40.0];
const SKYBOX_KZ_LOOK: [f32; 3] = [-6300.0, -2800.0, 95.0];
const SKYBOX_STEPPES_EYE: [f32; 3] = [-7979.0, -2571.0, 260.0];
const SKYBOX_STEPPES_LOOK: [f32; 3] = [-7800.0, -2450.0, 320.0];
const SKYBOX_BLASTED_EYE: [f32; 3] = [-11300.0, -3073.0, 30.0];
const SKYBOX_BLASTED_LOOK: [f32; 3] = [-11120.0, -2960.0, 90.0];
const SKYBOX_HYJAL_EYE: [f32; 3] = [4637.0, -4461.0, 1130.0];
const SKYBOX_HYJAL_LOOK: [f32; 3] = [4800.0, -4350.0, 1230.0];
#[cfg(test)]
mod ui_opt_in_tests {
use super::*;
+742 -18
View File
@@ -916,11 +916,76 @@ pub(crate) const REGISTERED: &[Registered] = &[
"lightingQuality",
"High",
"benilla's own: the preset ladder over the dynamic light + shadow rows — Off / Low / \
Medium / High, or Custom when the members match none of them; the reference has no \
Medium / High / Ultra, or Custom when the members match none of them; the reference has no \
realtime light or shadow system to preset",
),
// MONKEY (presets): the Graphics Preset — `lightingQuality`'s posture one level up, a NAME
// for every graphics row at once ([`GRAPHICS_PRESETS`]), re-derived every frame. "High" is
// what a player with no saved preset boots into: [`Cvars::seed_graphics_preset`] writes the
// High column over every row their `config.toml` does not carry, so the rows themselves keep
// their per-lane defaults (Classic-leaning, and `farclip` the reference's 350) and a capture
// or a test, which never seeds, keeps them too.
ours(
"graphicsQuality",
"High",
"benilla's own: the preset ladder over every graphics row — Classic / Low / Medium / High \
/ Ultra, or Custom when the rows match none of them; the reference's options have no \
such preset",
),
// MONKEY (volumetric fog): saved live tier; capture override stays session-only.
ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"),
// MONKEY (lampfog): opt-in point-light halos; High graphics preset value is 2.
ours(
"lampFog",
"0",
"benilla's own: lamps scattering through night fog, 0 Off / 1 Low (16 lamps) / 2 High (32 lamps)",
),
// MONKEY (p0 skyDither): the FFXGlow combine's deband dither (was env WOW_DITHER only).
// Default 0 = the reference look; the Graphics preset's High sets 1.
ours(
"skyDither",
"0",
"benilla's own: faint screen dither against sky and fog banding, 0 Off / 1 On",
),
// MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical.
ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"),
ours("sunShafts", "1", "benilla's own: depth-occluded screen-space sun shafts"),
ours("colorGrading", "1", "benilla's own: zone and day/night 32-cube colour grade"),
// MONKEY (sky): the sky tier; the High graphics preset sets 2.
ours(
"skyQuality",
"0",
"benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \n 2 High (+ detailed sun-lit clouds)",
),
// MONKEY (wind): one tier controls the grass-only and grass-plus-tree receivers.
ours(
"foliageWind",
"2",
"benilla's own: foliage wind, 0 Off / 1 Grass / 2 Grass + trees",
),
// MONKEY (fog): the distance-fog model. 0 = the 1.12 linear fog (byte-identical), 1 = Modern
// (exponential, sun/horizon colour, end-fog shift, fog end decoupled from farclip past 777).
// Default 0; the Graphics preset's High sets 1.
ours(
"fogModel",
"0",
"benilla's own: distance fog, 0 Classic (1.12 linear) / 1 Modern (soft, sun-coloured horizon)",
),
// MONKEY (wet): rain darkens and glosses sky-exposed surfaces and rings the water. Default 1
// (only visible while it rains or the ground dries); the Graphics preset's High sets 1.
ours(
"rainSurfaces",
"1",
"benilla's own: rain wets sky-exposed surfaces and rings the water, 0 Off / 1 On",
),
// MONKEY (ao): opt-in contact shadows; the High graphics preset value is 2.
ours("ambientOcclusion", "0", "benilla's own: screen-space ambient occlusion, 0 Off / 1 Low / 2 High"),
// MONKEY (skybox): the living player's zone skybox from `LightParams`; High sets 1.
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",
),
ours(
"waterQuality",
"1",
@@ -1070,6 +1135,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1",
"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",
),
// MONKEY (static torch cache): residency and per-frame work have separate live budgets.
ours(
"interiorShadowCasters",
@@ -1144,6 +1215,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
"0.0",
"benilla's own: daylight floor for rooms inside a building, 0..1 (0 = none, the old look)",
),
// MONKEY (fix-daylight): the district window split, gateable (default on = merged behaviour).
ours(
"daylightWindowSplit",
"1",
"benilla's own: split city window batches into window-sized daylight apertures (applies to newly loaded buildings)",
),
// MONKEY (bake floor): the share of an interior batch's own MOCV bake that survives the live-
// fixture lane. The lane throws the bake away and lets the fixtures decide, which leaves a room
// no fixture reaches (the Lion's Pride Inn's east vestibule: MOLR 0, no claims, one faded
@@ -1683,6 +1760,9 @@ impl Cvars {
if name.eq_ignore_ascii_case("lightingQuality") {
apply_lighting_preset(self, value);
}
if name.eq_ignore_ascii_case("graphicsQuality") {
apply_graphics_preset(self, value);
}
outcome
}
@@ -1694,9 +1774,96 @@ impl Cvars {
if name.eq_ignore_ascii_case("lightingQuality") {
apply_lighting_preset(self, value);
}
if name.eq_ignore_ascii_case("graphicsQuality") {
apply_graphics_preset(self, value);
}
outcome
}
/// MONKEY (presets): **the first boot's Graphics Preset** — a player whose `config.toml`
/// names no `graphicsQuality` (a new player, or one from before the ladder) gets the
/// [`GRAPHICS_DEFAULT`] column written over every governed row the file does not carry and the
/// session does not own. Rows the file carries are the player's and stay; the lighting rung
/// is left alone, because High on that ladder IS the registered defaults
/// (`the_high_preset_is_the_registered_defaults`). Returns how many rows moved.
pub(crate) fn seed_graphics_preset(&mut self) -> usize {
let carried = |cvars: &Self, k: &str| {
cvars.file.keys().any(|f| f.eq_ignore_ascii_case(k)) || cvars.is_session_owned(k)
};
if carried(self, "graphicsQuality") {
return 0;
}
let col = graphics_column(GRAPHICS_DEFAULT).expect("the default is a rung");
let mut moved = 0;
for (k, values) in GRAPHICS_PRESETS {
if k.eq_ignore_ascii_case("lightingQuality") || carried(self, k) {
continue;
}
if self.set(k, values[col]) == SetOutcome::Changed {
moved += 1;
}
}
moved
}
/// MONKEY (followups): **re-apply the saved presets to rows the file does not carry.** A saved
/// rung name implies every governed row matched it when the file was written (the label is
/// re-derived every frame), and a row at its registered default is not saved. So a governed
/// row ABSENT from the file is one that was registered after the save (or held a rung value
/// equal to its default): it takes the saved rung's value, not its own registered default.
/// Rows the file carries and session-owned rows are never touched; `Custom` (or no saved
/// name) writes nothing. The lighting rung is the file's own `lightingQuality`, or else the
/// saved Graphics rung's. Returns how many rows moved.
pub(crate) fn reapply_saved_presets(&mut self) -> usize {
let carried = |cvars: &Self, k: &str| {
cvars.file.keys().any(|f| f.eq_ignore_ascii_case(k)) || cvars.is_session_owned(k)
};
let saved = |cvars: &Self, k: &str| {
if cvars.is_session_owned(k) {
return None;
}
cvars
.file
.iter()
.find(|(f, _)| f.eq_ignore_ascii_case(k))
.map(|(_, v)| v.clone())
};
let mut moved = 0;
let col = saved(self, "graphicsQuality").and_then(|v| graphics_column(v.trim()));
if let Some(col) = col {
for (k, values) in GRAPHICS_PRESETS {
if k.eq_ignore_ascii_case("lightingQuality") || carried(self, k) {
continue;
}
if self.set(k, values[col]) == SetOutcome::Changed {
moved += 1;
}
}
}
let lighting = saved(self, "lightingQuality").or_else(|| {
let col = col?;
GRAPHICS_PRESETS
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("lightingQuality"))
.map(|(_, values)| values[col].to_string())
});
let members = lighting.and_then(|name| {
LIGHTING_PRESETS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
.map(|(_, members)| *members)
});
for (k, v) in members.unwrap_or(&[]) {
if carried(self, k) {
continue;
}
if self.set(k, v) == SetOutcome::Changed {
moved += 1;
}
}
moved
}
/// **The table follows a value the engine already applied** — a mirror, not a write: the
/// applied value moves, the config dirties, the VM's mirror learns it, and **no observer
/// fires**, because the knob is already there. For a second registered spelling of one knob
@@ -2008,12 +2175,18 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "0"),
// MONKEY (volumetric fog): Off preset restores the original atmosphere.
("volumetricFog", "0"),
// MONKEY (post): no HDR lift or post pass is the byte-identical baseline.
("bloom", "0"),
("sunShafts", "0"),
("colorGrading", "0"),
("lavaLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
("interiorGain", "1.0"),
("moonShadowStrength", "0"),
("fireFlicker", "0"),
// MONKEY (wind): Off is the exact zero-displacement path.
("foliageWind", "0"),
],
),
// Character silhouettes and lit rooms, and nothing that costs a second shadow pass: no world
@@ -2031,12 +2204,18 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
// MONKEY (post): quarter-resolution halo.
("bloom", "1"),
("sunShafts", "0"),
("colorGrading", "0"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0"),
("fireFlicker", "1"),
// MONKEY (wind): Low animates the denser grass lane only.
("foliageWind", "1"),
],
),
// Both sun lanes, indoor torch shadows at half the residency, and the moon term — but no
@@ -2057,12 +2236,18 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
// MONKEY (post): quarter-resolution halo.
("bloom", "1"),
("sunShafts", "0"),
("colorGrading", "0"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0.35"),
("fireFlicker", "1"),
// MONKEY (wind): Medium and High include classified trees.
("foliageWind", "2"),
],
),
// **High IS the shipped default**, member for member — which is a property, not a coincidence:
@@ -2084,12 +2269,47 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
// MONKEY (post): half-resolution halo.
("bloom", "2"),
("sunShafts", "1"),
("colorGrading", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0.35"),
("fireFlicker", "1"),
// MONKEY (wind): High preset = grass plus classified trees.
("foliageWind", "2"),
],
),
// MONKEY (presets): High plus every lane at its maximum — the 4096 sun map, all sixteen
// resident torch maps with eight of them tracking movers, mirror-reflection water and the
// High fog march. Disjoint from High on `shadowMapSize`, so derivation never confuses them.
(
"Ultra",
&[
("characterShadows", "1"),
("worldShadows", "1"),
("shadowMapSize", "4096"),
("interiorLight", "1"),
("interiorShadows", "1"),
("interiorShadowCasters", "16"),
("interiorShadowDynamic", "8"),
("exteriorShadows", "1"),
("spellLightGain", "1"),
("waterQuality", "2"),
("volumetricFog", "2"),
("bloom", "2"),
("sunShafts", "1"),
("colorGrading", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0.35"),
("fireFlicker", "1"),
("foliageWind", "2"),
],
),
];
@@ -2145,6 +2365,82 @@ fn lighting_quality(cvars: &mut Cvars) {
let derived = derive_lighting_quality(cvars);
// A derived label is a mirror, never another request to apply a preset.
cvars.mirror("lightingQuality", derived);
// MONKEY (presets): after the lighting label, which the graphics ladder reads as a member.
let graphics = derive_graphics_quality(cvars);
cvars.mirror("graphicsQuality", graphics);
}
// ─── MONKEY (presets): the Graphics Preset ladder ─────────────────────────────────────────────
/// The Graphics Preset's rungs, in the order [`derive_graphics_quality`] tries them and the
/// column order of [`GRAPHICS_PRESETS`].
pub(crate) const GRAPHICS_PRESET_NAMES: [&str; 5] = ["Classic", "Low", "Medium", "High", "Ultra"];
/// The rung a player with no saved preset boots into ([`Cvars::seed_graphics_preset`]).
pub(crate) const GRAPHICS_DEFAULT: &str = "High";
/// **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
/// lighting ladder owns is governed here without a second copy. The other rows are disjoint from
/// that ladder, which is what lets both labels be right at once.
///
/// Classic is the reference client: every lane off, `farclip` at its registered 350. High is
/// each lane's documented High (LIGHTING.md, WATER.md); Ultra is High at every maximum, and the
/// only rung past the reference's 777 yd (`FARCLIP_RANGE`); Low and Medium keep the practically
/// free lanes (sky, dither, modern fog, wet surfaces) and leave the costly ones to High.
///
/// A row that lands later is ONE line here, and only once it is registered (an unregistered
/// member would never match, and the ladder would read Custom forever).
#[rustfmt::skip]
pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[
// Classic Low Medium High Ultra
("lightingQuality", ["Off", "Low", "Medium", "High", "Ultra"]),
("farclip", ["350", "350", "477", "777", "1497"]),
("skyQuality", ["0", "1", "1", "2", "2"]),
("skyDither", ["0", "1", "1", "1", "1"]),
("fogModel", ["0", "1", "1", "1", "1"]),
("rainSurfaces", ["0", "1", "1", "1", "1"]),
("torchTerrainShadows", ["0", "0", "0", "1", "1"]),
("daylightWindowSplit", ["0", "1", "1", "1", "1"]),
// MONKEY (integration): the three rows that landed in round 3. Low keeps them off (AO and
// the lamp halos are GPU passes; the zone skybox is a look change), Medium takes the Low tiers.
("ambientOcclusion", ["0", "0", "1", "2", "2"]),
("zoneSkyboxes", ["0", "0", "1", "1", "1"]),
("lampFog", ["0", "0", "1", "2", "2"]),
];
/// The column of a rung, matched case-insensitively; `None` for `Custom` or anything else.
fn graphics_column(name: &str) -> Option<usize> {
GRAPHICS_PRESET_NAMES
.iter()
.position(|n| n.eq_ignore_ascii_case(name))
}
/// The rung every governed row currently spells, or [`LIGHTING_CUSTOM`] (the one "Custom" both
/// 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() {
let matched = GRAPHICS_PRESETS
.iter()
.all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v[col])));
if matched {
return name;
}
}
LIGHTING_CUSTOM
}
/// Write one rung's column through [`Cvars::set`]; `false` (and nothing written) for an unknown
/// name, `Custom` included.
pub(crate) fn apply_graphics_preset(cvars: &mut Cvars, name: &str) -> bool {
let Some(col) = graphics_column(name) else {
return false;
};
for (k, values) in GRAPHICS_PRESETS {
cvars.set(k, values[col]);
}
true
}
/// **What the environment took for this session, and what it set it to** — read off the knobs
@@ -2239,6 +2535,11 @@ fn session_values(world: &World) -> Vec<(&'static str, Option<String>)> {
fn load_config(world: &mut World) {
let session = session_values(world);
let stored = stored_config();
let stored_kind = match &stored {
StoredConfig::Absent => StoredKind::Absent,
StoredConfig::Table(_) => StoredKind::Table,
StoredConfig::Bad(_) => StoredKind::Bad,
};
let events = {
let mut cvars = world.resource_mut::<Cvars>();
for (name, value) in session {
@@ -2251,14 +2552,23 @@ fn load_config(world: &mut World) {
// outlives the GPU it described.
cvars.own_for_session("gxApi", None);
match stored {
StoredConfig::Absent => {} // no file, hermetic capture, or no install
StoredConfig::Absent => {} // no file, capture without a fixture, or no install
StoredConfig::Bad(msg) => {
// A malformed file is preserved, not clobbered: nothing loads, but nothing
// saves over it either until a change actually happens — and the warn names
// the file.
warn!("{msg}");
}
StoredConfig::Table(table) => cvars.load_file(table),
StoredConfig::Table(table) => {
cvars.load_file(table);
// MONKEY (followups): rows registered after the save follow the saved preset.
cvars.reapply_saved_presets();
}
}
// MONKEY (presets): a player's own run (never a capture, never a malformed file) boots
// into the default Graphics Preset over whatever its file leaves unsaid.
if seeds_graphics_preset(&stored_kind) {
cvars.seed_graphics_preset();
}
cvars.take_events()
};
@@ -2267,9 +2577,25 @@ fn load_config(world: &mut World) {
}
}
/// MONKEY (presets): which [`StoredConfig`] arm a boot took, kept past the table's move.
enum StoredKind {
Absent,
Table,
Bad,
}
/// MONKEY (presets): whether this boot seeds the default Graphics Preset — a run that reads and
/// saves a player's `config.toml` and found it absent or well-formed. A capture (hermetic, or on
/// an explicit fixture) keeps the registered defaults so its A/B stays exact, and a malformed
/// file is left for the player rather than papered over.
fn seeds_graphics_preset(kind: &StoredKind) -> bool {
let players_file = std::env::var_os("WOW_CAPTURE").is_none() && config_read_path().is_some();
players_file && matches!(kind, StoredKind::Absent | StoredKind::Table)
}
/// What the one read of `config.toml` found.
enum StoredConfig {
/// No file, no install, or a hermetic capture — every value is its registered default.
/// No file, no install, or a capture without an explicit fixture — use registered defaults.
Absent,
/// The file's `[cvars]` table.
Table(BTreeMap<String, String>),
@@ -2293,8 +2619,8 @@ enum StoredConfig {
/// law can legitimately move under a run. The thing worth having exactly one of is this function,
/// not its result.
fn stored_config() -> StoredConfig {
let Some(path) = crate::local_state::config_path() else {
return StoredConfig::Absent; // hermetic capture, or no install — session-only state
let Some(path) = config_read_path() else {
return StoredConfig::Absent; // capture without a fixture, or no install — session-only
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
@@ -2310,6 +2636,18 @@ 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.
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"));
}
crate::local_state::config_path()
}
/// One CVar as `config.toml` holds it, matched case-insensitively — **before the `App` exists**
/// (decision 1627).
///
@@ -2888,7 +3226,7 @@ mod tests {
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
// ── MONKEY (lighting): the weld for the dynamic light + shadow system's whole row set ──
//
// **All 32, as one census, with the count asserted.** Every one of them lands on
// **All 34, as one census, with the count asserted.** Every one of them lands on
// `VideoConfig` and is read from there per frame (`shadow_core::update_shadows`,
// `dynamic_interior::bridge`, `torch_shadow`), so a registered default that drifts from
// the struct's literal is a setting that reads one way in `config.toml` and renders
@@ -2898,10 +3236,16 @@ mod tests {
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
// MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
let lighting: [(&str, f32); 33] = [
let lighting: [(&str, f32); 38] = [
("waterQuality", shadows.water_quality as f32),
// MONKEY (volumetric fog): weld registry and renderer defaults.
("volumetricFog", shadows.volumetric_fog as f32),
// MONKEY (lampfog): opt-in point-light fog.
("lampFog", shadows.lamp_fog as f32),
// MONKEY (post): weld registry and renderer defaults.
("bloom", shadows.bloom as f32),
("sunShafts", flag(shadows.sun_shafts)),
("colorGrading", flag(shadows.color_grading)),
("lavaLightGain", shadows.lava_light_gain),
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
@@ -2926,6 +3270,8 @@ mod tests {
// MONKEY (torch shadows): the cube-map lane, indoors and out.
("interiorShadows", flag(shadows.interior_shadows)),
("exteriorShadows", flag(shadows.exterior_shadows)),
// MONKEY (daylight: terrain torch casters)
("torchTerrainShadows", flag(shadows.torch_terrain_shadows)),
(
"interiorShadowCasters",
shadows.interior_shadow_casters as f32,
@@ -2953,11 +3299,12 @@ mod tests {
for (name, want) in lighting {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
// row the client does not have; the length is the other half — 33 rows, 33 welds.
// …and the census is the VideoConfig-backed row set. A name here that nothing registers
// would weld against a row the client does not have; the length is the other half — 38
// VideoConfig rows, 38 welds (MONKEY lampfog: + lampFog). MONKEY (wind): `foliageWind` is a world resource bridge.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows");
assert_eq!(welded.len(), 38, "the lighting lane welds 38 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -2974,10 +3321,13 @@ mod tests {
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
assert_eq!(d["waterQuality"], 1.0, "mirror reflections stay opt-in");
assert_eq!(d["lavaLightGain"], 1.0);
// The 33rd lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
// only one with no `VideoConfig` knob to weld to, because it is a NAME for the rows above
// rather than a knob of its own. Its own weld is `the_high_preset_is_the_registered_
// defaults` — a fresh config must read "High", not "Custom".
// MONKEY (ao): registry and renderer agree, and the lane ships Off.
assert_eq!(d["ambientOcclusion"], shadows.ambient_occlusion as f32);
assert_eq!(d["ambientOcclusion"], 0.0, "ambient occlusion is opt-in");
// `lightingQuality` and MONKEY (wind) `foliageWind` deliberately are not in that census:
// neither is a `VideoConfig` knob. The former names the rows above; its own weld is
// `the_high_preset_is_the_registered_defaults`. The latter bridges directly to the
// world-owned `FoliageWind` resource in `monkey_gfx`.
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
@@ -3015,6 +3365,8 @@ mod tests {
for member in ["fireLightGain", "waterQuality", "volumetricFog", "lavaLightGain", "nightGain", "interiorGain"] {
let expected = if *name == "Off" {
if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" }
} else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog") {
"2" // MONKEY (presets): Ultra's two maxima over the defaults.
} else {
cvars.default_of(member).unwrap()
};
@@ -3099,10 +3451,311 @@ mod tests {
let mut cvars = fresh_registry();
apply_lighting_preset(&mut cvars, "Medium");
assert!(!apply_lighting_preset(&mut cvars, LIGHTING_CUSTOM));
assert!(!apply_lighting_preset(&mut cvars, "Ultra"));
assert!(!apply_lighting_preset(&mut cvars, "Epic"));
assert_eq!(derive_lighting_quality(&cvars), "Medium");
}
// ── MONKEY (presets): the Graphics Preset ladder ──────────────────────────────────────────
/// Every governed row is registered and names no row the lighting ladder already owns — the
/// disjointness that lets both labels be right at once.
#[test]
fn graphics_rows_are_registered_and_disjoint_from_the_lighting_ladder() {
assert_eq!(GRAPHICS_PRESETS.len(), 11, "add the row count with the row");
let cvars = fresh_registry();
for (k, values) in GRAPHICS_PRESETS {
assert!(cvars.get(k).is_some(), "{k}: not registered");
for (rung, members) in LIGHTING_PRESETS {
assert!(
members.iter().all(|(m, _)| !m.eq_ignore_ascii_case(k)),
"{k} is also a member of lighting {rung}"
);
}
if *k == "lightingQuality" {
for v in values {
assert!(
LIGHTING_PRESETS.iter().any(|(n, _)| n == v),
"{v}: a lighting rung"
);
}
}
}
// The rungs are distinct columns, so no two can derive to the same name.
for a in 0..GRAPHICS_PRESET_NAMES.len() {
for b in a + 1..GRAPHICS_PRESET_NAMES.len() {
assert!(
GRAPHICS_PRESETS
.iter()
.any(|(_, v)| !same_value(v[a], v[b])),
"{} and {} are the same column",
GRAPHICS_PRESET_NAMES[a],
GRAPHICS_PRESET_NAMES[b]
);
}
}
}
/// **Preset → rows → the same preset**, from every rung to every rung, through the ordinary
/// `set` path — with the two rows no preset decides left where the player put them.
#[test]
fn every_graphics_preset_derives_back_to_its_own_name() {
for from in GRAPHICS_PRESET_NAMES {
for name in GRAPHICS_PRESET_NAMES {
let mut cvars = fresh_registry();
cvars.set("graphicsQuality", from);
cvars.set("shadowDistance", "120");
cvars.set("interiorShadowSoft", "2.5");
cvars.set("graphicsQuality", name);
lighting_quality(&mut cvars);
assert_eq!(derive_graphics_quality(&cvars), name, "{from} -> {name}");
assert_eq!(cvars.get("graphicsQuality"), Some(name));
let col = graphics_column(name).unwrap();
assert_eq!(
cvars.get("lightingQuality"),
Some(GRAPHICS_PRESETS[0].1[col])
);
assert_eq!(cvars.get("shadowDistance"), Some("120"));
assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
}
}
}
/// Classic is the reference client: the lighting ladder's Off, every programme lane off and
/// `farclip` at the reference's registered 350.
#[test]
fn the_classic_preset_is_every_lane_off_at_the_reference_view_distance() {
let mut cvars = fresh_registry();
assert!(apply_graphics_preset(&mut cvars, "Classic"));
assert_eq!(cvars.get("lightingQuality"), Some("Off"));
assert_eq!(cvars.get("farclip"), cvars.default_of("farclip"));
for (k, values) in GRAPHICS_PRESETS {
if !matches!(*k, "lightingQuality" | "farclip") {
assert!(same_value(values[0], "0"), "{k}: Classic leaves it on");
}
}
// Ultra is the one rung past the reference's 777, and inside the extended clamp.
let ultra = GRAPHICS_PRESETS
.iter()
.find(|(k, _)| *k == "farclip")
.unwrap()
.1[4];
let ultra: f32 = ultra.parse().unwrap();
assert!(ultra > 777.0 && FARCLIP_RANGE.contains(&ultra));
}
/// **Editing one governed row is Custom** — for every rung and every row of it, the lighting
/// rung included (moved by one of ITS members, as the page's lighting rows would).
#[test]
fn one_changed_graphics_row_derives_custom() {
for name in GRAPHICS_PRESET_NAMES {
let col = graphics_column(name).unwrap();
for (k, values) in GRAPHICS_PRESETS {
let mut cvars = fresh_registry();
apply_graphics_preset(&mut cvars, name);
let (row, moved) = if *k == "lightingQuality" {
let to = if values[col] == "Off" { "1" } else { "0" };
("characterShadows", to.to_string())
} else {
let v: f32 = values[col].parse().unwrap();
let to = if *k == "farclip" {
v + 60.0
} else if v == 0.0 {
1.0
} else {
0.0
};
(*k, to.to_string())
};
assert_eq!(cvars.set(row, &moved), SetOutcome::Changed, "{name}/{row}");
lighting_quality(&mut cvars);
assert_eq!(
cvars.get("graphicsQuality"),
Some(LIGHTING_CUSTOM),
"{name}/{row}"
);
// …and picking the rung again puts every row back.
assert!(apply_graphics_preset(&mut cvars, name));
lighting_quality(&mut cvars);
assert_eq!(
cvars.get("graphicsQuality"),
Some(name),
"{name}/{row} re-pick"
);
}
}
let mut cvars = fresh_registry();
assert!(!apply_graphics_preset(&mut cvars, LIGHTING_CUSTOM));
assert!(!apply_graphics_preset(&mut cvars, "Epic"));
}
/// **A new player boots into High**: the seed writes the High column over a registry with no
/// file, and the registered default of the label agrees, so it never reaches `config.toml`.
#[test]
fn a_fresh_player_is_seeded_to_the_high_graphics_preset() {
assert_eq!(
fresh_registry().default_of("graphicsQuality"),
Some(GRAPHICS_DEFAULT)
);
let mut cvars = fresh_registry();
assert!(cvars.seed_graphics_preset() > 0);
lighting_quality(&mut cvars);
assert_eq!(cvars.get("graphicsQuality"), Some("High"));
assert_eq!(cvars.get("lightingQuality"), Some("High"));
assert_eq!(cvars.get("farclip"), Some("777"));
assert!(
!cvars.compose().contains_key("graphicsQuality"),
"the default label is not saved"
);
// A second boot over what the first one saved seeds nothing.
let mut again = fresh_registry();
again.load_file(cvars.compose());
assert_eq!(again.seed_graphics_preset(), 0);
lighting_quality(&mut again);
assert_eq!(again.get("graphicsQuality"), Some("High"));
}
/// MONKEY (followups): a saved Graphics rung fills the governed rows the file lacks (a row
/// registered after the save) with the rung's value; rows the file carries stay; Custom and
/// no saved name write nothing.
#[test]
fn a_saved_graphics_preset_fills_rows_missing_from_the_file() {
// An Ultra save made before `ambientOcclusion` / `lampFog` existed.
let file = BTreeMap::from([
("graphicsQuality".to_string(), "Ultra".to_string()),
("lightingQuality".to_string(), "Ultra".to_string()),
("farclip".to_string(), "1497".to_string()),
("zoneSkyboxes".to_string(), "0".to_string()), // the player's own later edit
]);
let mut cvars = fresh_registry();
cvars.load_file(file);
assert!(cvars.reapply_saved_presets() > 0);
assert_eq!(cvars.get("ambientOcclusion"), Some("2"));
assert_eq!(cvars.get("lampFog"), Some("2"));
assert_eq!(cvars.get("farclip"), Some("1497"));
assert_eq!(cvars.get("zoneSkyboxes"), Some("0"), "a saved row stays");
// The lighting members follow Ultra too.
assert_eq!(cvars.get("shadowMapSize"), Some("4096"));
assert_eq!(cvars.get("volumetricFog"), Some("2"));
// Medium without a saved lighting rung: the Graphics rung's lighting rung applies.
let mut medium = fresh_registry();
medium.load_file(BTreeMap::from([(
"graphicsQuality".to_string(),
"Medium".to_string(),
)]));
medium.reapply_saved_presets();
assert_eq!(medium.get("ambientOcclusion"), Some("1"));
assert_eq!(medium.get("farclip"), Some("477"));
lighting_quality(&mut medium);
assert_eq!(medium.get("lightingQuality"), Some("Medium"));
assert_eq!(medium.get("graphicsQuality"), Some("Medium"));
// Custom writes nothing; neither does a file with no preset name.
for file in [
BTreeMap::from([("graphicsQuality".to_string(), LIGHTING_CUSTOM.to_string())]),
BTreeMap::from([("skyQuality".to_string(), "1".to_string())]),
] {
let mut custom = fresh_registry();
custom.load_file(file);
assert_eq!(custom.reapply_saved_presets(), 0);
assert_eq!(
custom.get("ambientOcclusion"),
custom.default_of("ambientOcclusion")
);
}
// A session-owned row is left to the session.
let mut owned = fresh_registry();
owned.own_for_session("farclip", Some("500"));
owned.load_file(BTreeMap::from([(
"graphicsQuality".to_string(),
"Ultra".to_string(),
)]));
owned.reapply_saved_presets();
assert_eq!(owned.get("farclip"), Some("500"));
assert_eq!(owned.get("ambientOcclusion"), Some("2"));
}
/// MONKEY (followups): a saved Lighting Quality rung fills the lighting members the file
/// lacks; members the file carries stay; Custom writes nothing.
#[test]
fn a_saved_lighting_preset_fills_members_missing_from_the_file() {
let mut cvars = fresh_registry();
cvars.load_file(BTreeMap::from([
("lightingQuality".to_string(), "Ultra".to_string()),
("volumetricFog".to_string(), "1".to_string()),
]));
assert!(cvars.reapply_saved_presets() > 0);
assert_eq!(cvars.get("shadowMapSize"), Some("4096"));
assert_eq!(cvars.get("interiorShadowCasters"), Some("16"));
assert_eq!(cvars.get("volumetricFog"), Some("1"), "a saved member stays");
// No Graphics rung saved: the graphics rows keep their defaults.
assert_eq!(
cvars.get("ambientOcclusion"),
cvars.default_of("ambientOcclusion")
);
let mut off = fresh_registry();
off.load_file(BTreeMap::from([(
"lightingQuality".to_string(),
"Off".to_string(),
)]));
off.reapply_saved_presets();
assert_eq!(off.get("worldShadows"), Some("0"));
lighting_quality(&mut off);
assert_eq!(off.get("lightingQuality"), Some("Off"));
let mut custom = fresh_registry();
custom.load_file(BTreeMap::from([(
"lightingQuality".to_string(),
LIGHTING_CUSTOM.to_string(),
)]));
assert_eq!(custom.reapply_saved_presets(), 0);
}
/// The seed never overrides the player: a saved preset stops it outright, a saved row keeps
/// its value, and a session-owned row (an env lever) is left to the session.
#[test]
fn the_graphics_seed_leaves_saved_and_session_rows_alone() {
let mut saved = fresh_registry();
saved.load_file(BTreeMap::from([(
"graphicsQuality".into(),
"Classic".into(),
)]));
assert_eq!(saved.seed_graphics_preset(), 0);
assert_eq!(saved.get("skyQuality"), saved.default_of("skyQuality"));
let mut row = fresh_registry();
row.load_file(BTreeMap::from([("skyQuality".into(), "1".into())]));
row.own_for_session("farclip", Some("500"));
row.seed_graphics_preset();
lighting_quality(&mut row);
assert_eq!(row.get("skyQuality"), Some("1"));
assert_eq!(row.get("farclip"), Some("500"));
assert_eq!(row.get("fogModel"), Some("1"));
assert_eq!(row.get("graphicsQuality"), Some(LIGHTING_CUSTOM));
}
/// Ultra's `farclip` survives the real observer (the extended clamp), and every governed
/// row's rung values do too.
#[test]
fn graphics_rows_pass_their_observers_unclamped() {
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}"
);
}
}
/// Every member every rung names is a REGISTERED row — a typo'd member would otherwise make
/// its whole preset underivable (a `cvars.get` that answers `None` never matches), and the
/// only symptom would be a dropdown stuck on `Custom`.
@@ -3133,6 +3786,14 @@ mod tests {
"waterQuality" => video.water_quality as f32,
// MONKEY (volumetric fog): verify presets reach the renderer.
"volumetricFog" => video.volumetric_fog as f32,
// MONKEY (post): the post lane's live tier.
"bloom" => video.bloom as f32,
"sunShafts" => video.sun_shafts as u32 as f32,
"colorGrading" => video.color_grading as u32 as f32,
// MONKEY (integration): wind is bridged directly to its world resource rather
// than through VideoConfig, but it is still a preset member and needs the same
// real-observer clamp weld as every VideoConfig-backed row above.
"foliageWind" => res::<benilla_world::wind::FoliageWind>(&app).0 as f32,
"lavaLightGain" => video.lava_light_gain,
"fireLightGain" => video.fire_light_gain,
"moonShadowStrength" => video.moon_shadow_strength,
@@ -3621,7 +4282,16 @@ mod tests {
assert!(text.contains("MusicVolume = \"0.75\""), "{text}");
assert!(!text.contains("MasterVolume"), "defaults stay out:\n{text}");
let back: LocalConfig = toml::from_str(&text).unwrap();
assert_eq!(back.cvars.len(), 1, "a diff, not a dump: {text}");
// MONKEY (presets): the file named no `graphicsQuality`, so the first boot seeded the
// High rungs over the governed rows; those are the only other entries, and the label
// itself, at its default, stays out.
let player: Vec<&String> = back
.cvars
.keys()
.filter(|k| !GRAPHICS_PRESETS.iter().any(|(g, _)| g.eq_ignore_ascii_case(k)))
.collect();
assert_eq!(player.len(), 1, "a diff, not a dump: {text}");
assert!(!text.contains("graphicsQuality"), "{text}");
std::fs::remove_dir_all(&tmp).ok();
}
@@ -3702,6 +4372,16 @@ mod tests {
|app| {
app.add_observer(crate::video::on_cvar);
},
// MONKEY (p0 graphics programme)
|app| {
app.init_resource::<benilla_world::ffx_glow::SkyDither>();
// MONKEY (fog)
app.init_resource::<benilla_world::lighting::FogModelSetting>();
// MONKEY (wind)
app.init_resource::<benilla_world::wind::FoliageWind>();
app.init_resource::<benilla_world::weather::RainSurfaces>(); // MONKEY (wet)
app.add_observer(crate::monkey_gfx::on_cvar);
},
|app| {
app.add_observer(crate::player::camera::on_cvar);
},
@@ -3985,7 +4665,14 @@ mod tests {
strings.sort_unstable(); // the list is the claim, not where the rows sit in the table
assert_eq!(
strings,
vec!["gxApi", "gxResolution", "realmList", "realmName"]
vec![
"graphicsQuality",
"gxApi",
"gxResolution",
"lightingQuality",
"realmList",
"realmName"
]
);
let default_of = |name: &str| {
REGISTERED
@@ -4251,6 +4938,38 @@ mod tests {
std::fs::remove_dir_all(&tmp).ok();
}
/// MONKEY (integration): deterministic graphics A/B captures may read one explicit fixture,
/// but the local-state law still exposes no writable capture path.
#[test]
fn a_capture_can_read_an_explicit_cvar_fixture_without_enabling_writes() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!(
"benilla-capture-cvar-fixture-{}",
std::process::id()
));
std::fs::remove_dir_all(&tmp).ok();
crate::local_state::write_atomic(
&tmp.join("config.toml"),
"[cvars]\nbloom = \"0\"\nsunShafts = \"0\"\ncolorGrading = \"0\"\n",
)
.unwrap();
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let _c = EnvGuard::set("WOW_CAPTURE", "post-lava-searing");
assert_eq!(boot_cvar("bloom").as_deref(), Some("0"));
assert_eq!(boot_cvar("sunShafts").as_deref(), Some("0"));
assert_eq!(boot_cvar("colorGrading").as_deref(), Some("0"));
assert_eq!(
crate::local_state::config_path(),
None,
"capture fixtures are read-only; persistence remains hermetic"
);
std::fs::remove_dir_all(&tmp).ok();
}
/// **Every registered row has a reader** — the honest-tree rule (module doc, 1140), made
/// structural now that the registry has no central arm to answer `false` from. A row's
/// reader is a string literal naming it somewhere in the crate's code or `benilla-ui`'s (an
@@ -4270,6 +4989,11 @@ mod tests {
"GameTooltip's binding-line gate, stock and pfUI (1316)",
),
("gxApi", "pfUI's system tooltip names the backend (2151)"),
(
"graphicsQuality",
"MONKEY (presets): the Advanced Graphics page's Graphics Preset row; the host \
writes and derives it here in cvars.rs",
),
(
"useUiScale",
"UIOptionsFrame.lua and OptionsFrame.lua branch on it to gate the uiScale slider",
+6 -4
View File
@@ -105,12 +105,14 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
// 4-second crossfade (== the interior-fog blend, one number): `w 0.00` beside a name means
// the flood published a skybox the crossfade hasn't engaged — faithful standing outside
// the gate, not a resolve bug.
match world.skybox.0.as_deref() {
Some(path) => {
// MONKEY (skybox): the heaviest layer of the weighted list, at its own weight.
match world.skybox.primary() {
Some(layer) => {
let path = layer.path.as_str();
let leaf = path.rsplit('\\').next().unwrap_or(path);
line.push_str(&format!(
" · skybox {leaf} w {:.2}",
world.skybox_weight.0
" · skybox {leaf} w {:.2} (stand-down {:.2})",
layer.weight, world.skybox_weight.0
));
}
None => line.push_str(" · sky gradient"),
+30 -2
View File
@@ -25,7 +25,9 @@
//!
//! Water quality and lava glow use the same guarded bridge into their renderer resources.
use benilla_assets::WaterQuality;
use benilla_world::lighting::{DynamicInteriors, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain};
use benilla_world::lighting::{
DynamicInteriors, EmissiveTier, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain,
};
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -60,6 +62,8 @@ fn bridge(
mut moon: ResMut<MoonShadowStrength>,
mut water: ResMut<WaterQuality>,
mut lava: ResMut<LavaLightGain>,
// MONKEY (post): bloom also arms the HDR source hooks through the shared light blob.
mut emissive: ResMut<EmissiveTier>,
) {
if water.0 != video.water_quality {
water.0 = video.water_quality;
@@ -67,6 +71,9 @@ fn bridge(
if lava.0 != video.lava_light_gain {
lava.0 = video.lava_light_gain;
}
if emissive.0 != video.bloom {
emissive.0 = video.bloom;
}
if fire.0 != video.fire_light_gain {
fire.0 = video.fire_light_gain;
}
@@ -76,6 +83,8 @@ fn bridge(
if moon.0 != video.moon_shadow_strength {
moon.0 = video.moon_shadow_strength;
}
// MONKEY (fix-daylight): `daylightWindowSplit` (an atomic store; seeds read it at load).
benilla_world::lighting::set_window_split(video.daylight_window_split);
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
@@ -104,7 +113,9 @@ fn bridge(
// MONKEY (enclosed day floor): the daylight floor rides the same bridge, but is consumed in
// the SHADER (it rides the packed `wmo_fog_params.w` fraction) rather than folded on the
// CPU — the packer's only job is to put it in the lane.
daylight: video.interior_daylight,
// MONKEY (daylight): `WOW_INTERIOR_DAYLIGHT=<0..1>` overrides it in a hermetic capture,
// which reads no config.toml (dev builds only; read once).
daylight: capture_daylight().unwrap_or(video.interior_daylight),
// MONKEY (bake floor): the bake floor rides the same bridge. Half CPU, half shader: the
// packer folds `interiorGain` in and puts the product in the `sh_c16.w` fraction, and the
// two interior lanes read it from there — so `SetCVar("interiorBakeFloor", 0)` restores
@@ -116,6 +127,22 @@ fn bridge(
}
}
/// MONKEY (daylight): the capture-only `interiorDaylight` override (see the bridge above).
fn capture_daylight() -> Option<f32> {
static V: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
*V.get_or_init(|| {
// MONKEY (integration): the dev door is `run_mode` (decision 1179).
if !crate::run_mode::dev_affordances() || std::env::var_os("WOW_CAPTURE").is_none() {
return None;
}
std::env::var("WOW_INTERIOR_DAYLIGHT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.map(|v| v.clamp(0.0, 1.0))
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -130,6 +157,7 @@ mod tests {
.init_resource::<MoonShadowStrength>()
.init_resource::<WaterQuality>()
.init_resource::<LavaLightGain>()
.init_resource::<EmissiveTier>()
.add_plugins(DynamicInteriorPlugin);
assert_eq!(app.world().resource::<WaterQuality>().0, VideoConfig::default().water_quality);
assert_eq!(app.world().resource::<LavaLightGain>().0, VideoConfig::default().lava_light_gain);
@@ -639,7 +639,8 @@ mod tests {
// The ONSET: a firework's fuse. Dark for the whole flight, up at the detonation.
let fuse = || SpellLight::new(1.0, 1.5, SpellLightMode::Burst { span: 0.6 });
assert_eq!(at(fuse(), 1.4), 0.0);
assert_eq!(at(fuse(), 1.5 + SPELL_LIGHT_RAMP), 1.0);
// MONKEY (integration): (1.5 + ramp) - 1.5 is not exactly `ramp` in f32.
assert!((at(fuse(), 1.5 + SPELL_LIGHT_RAMP) - 1.0).abs() < 1e-6);
// The reap fade: a kit light goes dark on its instance's `FxDecay`, quicker than the
// model's own Decay sequence — the aura is over the moment the server said so.
+19 -1
View File
@@ -245,6 +245,16 @@ impl PluginGroup for GamePlugins {
.add(crate::dynamic_interior::DynamicInteriorPlugin)
// MONKEY (volumetric fog): bridge the live setting to Bevy.
.add(crate::volumetric_fog::VolumetricFogPlugin)
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
.add(crate::monkey_gfx::MonkeyGfxPlugin)
// MONKEY (post): world-only HDR effects, before the legacy FFX clamp/UI composite.
.add(crate::post::PostPlugin)
// MONKEY (sky): the sky tier bridge.
.add(crate::sky_quality::SkyQualityPlugin)
// MONKEY (ao): contact shadows after the opaque pass, before the water copy.
.add(crate::ssao::AmbientOcclusionPlugin)
// MONKEY (skybox): bridge the zone-skybox setting to the world lane.
.add(crate::zone_skybox::ZoneSkyboxPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add(crate::realmlist::RealmlistPlugin)
@@ -1079,7 +1089,15 @@ pub(crate) mod schedule_tests {
/// the net drain's. Same tree, the class dropped, nothing else moved: 5,052 + 556.
/// **5,554 (decision 2345)** — lowered: the TAB scan stopped reading `Visibility` (the draw
/// election's verdict), which ended its pairs with every `Visibility` writer.
const UPDATE_ACTIONABLE_CEILING: usize = 5_554;
/// **5,582 (MONKEY integration round 3, graphics programme)** — measured on the merged tree
/// after declaring the orders the lanes had left out (wind → wetness → fog-model writers of
/// `MonkeyFrame`; the viewer publish before its new readers; the sky clock and sky tier before
/// the consume set; the grass benders on `GlobalTransform` and after the camera-pose copy).
/// What remains, read off the dump: 13 pairs of `skybox::animate_skyboxes` against the
/// `MatAnimTable` allocators (attach/spawn/doodad systems) — the allocator case of 2300, the
/// skybox writes only the rows it owns — and one pair each of the new systems against the two
/// exclusive systems (`finish_colliders`, `apply_net_updates`), the 2343 class.
const UPDATE_ACTIONABLE_CEILING: usize = 5_582;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+18 -7
View File
@@ -72,9 +72,19 @@ mod debug_panel;
mod dev;
mod doodad_events;
/// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane.
mod dynamic_interior;
// MONKEY (volumetric fog): optional near-field atmosphere plugin.
mod volumetric_fog;
mod dynamic_interior;
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
mod monkey_gfx;
// MONKEY (post): optional world-only HDR post-processing.
mod post;
// MONKEY (sky): the skyQuality cvar bridge.
mod sky_quality;
// MONKEY (ao): optional screen-space ambient occlusion plugin.
mod ssao;
// MONKEY (skybox): the zoneSkyboxes cvar bridge.
mod zone_skybox;
mod entities;
mod fishing_line;
mod footprints;
@@ -309,12 +319,13 @@ pub fn run(build: BuildId) -> AppExit {
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = benilla_world::bgwin::background_run();
if capturing {
// Ground clutter scatters with per-run randomness, so disable it for byte-stable baselines
// — clutter isn't what the lighting rework validates, and the regression diff must not be
// masked by grass wobble. Set before plugins build so `ClutterConfig::from_env` reads it.
// It is not the only source of per-run drift, though it was long documented as such: the
// other is the frame clock itself, frozen in `capture` (decision 0723).
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
// Ordinary baselines omit clutter, but a feature-specific capture may explicitly ask for
// it (MONKEY wind does). Scatter is now chunk-seeded and deterministic; the default stays
// zero so unrelated lighting diffs are not masked by grass. Set before plugins build so
// `ClutterConfig::default` reads it.
if std::env::var_os("WOW_CLUTTER_DENSITY").is_none() {
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
}
// Third source of per-run drift, and the one the lighting matrix (decision 0746) hit: the
// anim-LOD park/wake gate (`creature_anim::lod::gate_rig_animation`). Whether a rig is
// parked, and which pose it wakes into, depends on when its model finished loading relative
+72
View File
@@ -0,0 +1,72 @@
//! MONKEY (p0 graphics programme): the cvar bridges of the graphics programme's own settings, each
//! landing on the benilla-world resource its renderer reads. One observer, one arm per cvar;
//! lanes add their rows here rather than to `video.rs`'s reference block.
//!
//! | cvar | values | default | Graphics preset High | resource |
//! |---|---|---|---|---|
//! | `skyDither` | 0 Off / 1 On | 0 | 1 | `benilla_world::ffx_glow::SkyDither` |
//! | `foliageWind` | 0 Off / 1 Grass / 2 Grass + trees | 2 | 2 | `benilla_world::wind::FoliageWind` |
//! | `fogModel` | 0 Classic / 1 Modern | 0 | 1 | `benilla_world::lighting::FogModelSetting` |
//! | `rainSurfaces` | 0 Off / 1 On | 1 | 1 | `benilla_world::weather::RainSurfaces` |
use benilla_world::ffx_glow::SkyDither;
use benilla_world::lighting::FogModelSetting;
use benilla_world::wind::FoliageWind;
use bevy::prelude::*;
use benilla_world::weather::RainSurfaces; // MONKEY (wet)
/// The programme's cvar observer (registered by [`MonkeyGfxPlugin`]).
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut dither: ResMut<SkyDither>,
mut foliage_wind: ResMut<FoliageWind>,
// MONKEY (fog)
mut fog_model: ResMut<FogModelSetting>,
mut rain: ResMut<RainSurfaces>, // MONKEY (wet)
) {
match ev.key().as_str() {
"skydither" => {
let want = ev.num() >= 0.5;
if dither.0 != want {
dither.0 = want;
}
}
// 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());
let want = requested.clamp(0.0, 2.0) as u8;
if foliage_wind.0 != want {
foliage_wind.0 = want;
}
}
// MONKEY (fog): `WOW_FOGMODEL` still wins for the session (captures).
"fogmodel" => fog_model.set_from_cvar(ev.num()),
// MONKEY (wet): wet surfaces + rain ripples.
"rainsurfaces" => {
let want = ev.num() >= 0.5;
if rain.0 != want {
rain.0 = want;
}
}
_ => {}
}
}
/// 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;
impl Plugin for MonkeyGfxPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SkyDither>()
.init_resource::<FoliageWind>()
.init_resource::<FogModelSetting>()
.init_resource::<RainSurfaces>() // MONKEY (wet)
.add_observer(on_cvar);
}
}
@@ -278,6 +278,7 @@ pub(super) fn spawn_menagerie(
// doodad's trunk batch is Opaque, its canopy Blend, and the builder's untextured fallback is
// Opaque + back-cull with the fade still armed — each its own key (0958's sweep; 0938 warmed
// Mask only).
let mut clutter_mats: Vec<Handle<WowModelMaterial>> = Vec::new();
for two_sided in [false, true] {
for blend in [ModelBlend::Opaque, ModelBlend::AlphaTest, ModelBlend::Blend] {
let plain = model_material(
@@ -316,6 +317,8 @@ pub(super) fn spawn_menagerie(
let mut m = m.clone();
m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0);
let clutter = materials.add(m);
// MONKEY (fix-wind): also on the clutter mesh layout (UV_1 = wind height/phase).
clutter_mats.push(clutter.clone());
mats.push(clutter);
}
}
@@ -496,6 +499,19 @@ pub(super) fn spawn_menagerie(
let posuv = meshes.add(warm_pos_uv_mesh());
let liquid_mesh = meshes.add(warm_liquid_mesh(false));
let liquid_color_mesh = meshes.add(warm_liquid_mesh(true));
// MONKEY (fix-wind): clutter meshes carry POS + NORMAL + UV_0 + UV_1 (wind) + COLOR, a layout
// (and `VERTEX_UVS_B`) the model quads lack; the liquid colour quad has exactly that set.
for mat in &clutter_mats {
spawn_lane_rig(
commands,
cam,
None,
&liquid_color_mesh,
None,
mat.clone(),
&mut count,
);
}
// Celestial discs + glares (`sun::setup` quads: position+normal+UV).
for mat in lane_handles(&mut lanes.celestial) {
spawn_lane_rig(
@@ -889,6 +905,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
rgb_seq: None,
wmo_batch: None,
section: None,
stage1: None,
}
}
@@ -915,6 +932,12 @@ mod tests {
// also has its OWN pair, for the frames nothing claims it, and 2262 found it compiling
// live at app exit. `prepare_textures` now specialises that pair on every frame rather
// than only on the frame it first needs it.)
// - MONKEY (integration) ShaftPipeline (`post::sun_shafts`), FogPipeline
// (`volumetric_fog`, incl. lamp fog) and AoPipeline (`ssao`): fullscreen passes keyed
// only on the view's target format and MSAA (plus the AO stage/debug flag). Each lane's
// `prepare_pipelines` specialises every such key for EVERY `Camera3d` view on every
// frame, whether its cvar is on or not, so the variants compile behind the entry cover
// and a player enabling the row later hits the cache, not a live compile.
let exempt = ["UiGammaPipeline"];
let own_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let warm_src = std::fs::read_to_string(own_src.join("pipe_warm/mod.rs")).unwrap()
+5 -1
View File
@@ -328,7 +328,11 @@ impl Plugin for PlayerPlugin {
Update,
(world_focus::publish_viewer, world_focus::publish_view_focus)
.after(benilla_world::schedule::WorldStage::Input)
.before(benilla_world::schedule::WorldStage::Stream),
.before(benilla_world::schedule::WorldStage::Stream)
// MONKEY (integration): the `Viewer` readers the programme added (grass benders,
// fog model, zone skybox) run after this frame's publish.
.before(benilla_world::wind::WindTick)
.before(benilla_world::lighting::LightingResolveSet),
)
.add_systems(
Update,
+366
View File
@@ -0,0 +1,366 @@
//! MONKEY (post): HDR-excess bloom. This runs on the world view before FFXGlow clamps it;
//! the later UI camera therefore never enters either the extraction or halo combine.
use crate::video::VideoConfig;
use benilla_world::{ffx_glow::FfxGlowLabel, view::WorldCamera};
use bevy::{
core_pipeline::{
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
},
ecs::query::QueryItem,
prelude::*,
render::{
camera::ExtractedCamera,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
texture::{CachedTexture, TextureCache},
view::ViewTarget,
Render, RenderApp, RenderStartup, RenderSystems,
},
};
pub(super) struct BloomPlugin;
#[derive(Component, Clone, Copy, ExtractComponent)]
struct BloomView {
tier: u32,
}
impl Plugin for BloomPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(ExtractComponentPlugin::<BloomView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(
include_str!("bloom.wgsl"),
"post/bloom.wgsl",
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(BloomShader(shader))
.add_systems(RenderStartup, init_pipeline)
.add_systems(
Render,
prepare_textures.in_set(RenderSystems::PrepareResources),
)
.add_render_graph_node::<ViewNodeRunner<BloomNode>>(Core3d, BloomLabel)
.add_render_graph_edges(
Core3d,
(
Node3d::StartMainPassPostProcessing,
BloomLabel,
FfxGlowLabel,
),
);
}
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
cameras: Query<(Entity, &Camera, Option<&BloomView>), With<WorldCamera>>,
) {
let tier = u32::from(video.bloom.min(2));
for (entity, camera, old) in &cameras {
if tier == 0 || !camera.is_active {
if old.is_some() {
commands.entity(entity).remove::<BloomView>();
}
} else if old.is_none_or(|old| old.tier != tier) {
commands.entity(entity).insert(BloomView { tier });
}
}
}
#[derive(Resource)]
struct BloomShader(Handle<Shader>);
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
pub(super) struct BloomLabel;
#[derive(Resource)]
struct BloomPipelines {
layout: BindGroupLayoutDescriptor,
sampler: Sampler,
extract: CachedRenderPipelineId,
blur_h: CachedRenderPipelineId,
blur_v: CachedRenderPipelineId,
combine: CachedRenderPipelineId,
}
fn init_pipeline(
mut commands: Commands,
render_device: Res<RenderDevice>,
fullscreen: Res<FullscreenShader>,
shader: Res<BloomShader>,
pipeline_cache: Res<PipelineCache>,
) {
let layout = BindGroupLayoutDescriptor::new(
"post_bloom_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
texture_2d(TextureSampleType::Float { filterable: true }),
uniform_buffer_sized(false, std::num::NonZeroU64::new(16)),
),
),
);
let sampler = render_device.create_sampler(&SamplerDescriptor {
min_filter: FilterMode::Linear,
mag_filter: FilterMode::Linear,
address_mode_u: AddressMode::ClampToEdge,
address_mode_v: AddressMode::ClampToEdge,
..default()
});
let pipeline = |label: &'static str, entry: &'static str| RenderPipelineDescriptor {
label: Some(label.into()),
layout: vec![layout.clone()],
vertex: fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: shader.0.clone(),
shader_defs: vec![],
entry_point: Some(entry.into()),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
};
let extract =
pipeline_cache.queue_render_pipeline(pipeline("post_bloom_extract", "fs_extract"));
let blur_h = pipeline_cache.queue_render_pipeline(pipeline("post_bloom_blur_h", "fs_blur_h"));
let blur_v = pipeline_cache.queue_render_pipeline(pipeline("post_bloom_blur_v", "fs_blur_v"));
let combine =
pipeline_cache.queue_render_pipeline(pipeline("post_bloom_combine", "fs_combine"));
commands.insert_resource(BloomPipelines {
layout,
sampler,
extract,
blur_h,
blur_v,
combine,
});
}
#[derive(Component)]
struct BloomTextures {
a: CachedTexture,
b: CachedTexture,
uniform: Buffer,
bind_ab: BindGroup,
bind_ba: BindGroup,
tier: u32,
}
fn prepare_textures(
mut commands: Commands,
mut cache: ResMut<TextureCache>,
render_device: Res<RenderDevice>,
pipeline_cache: Res<PipelineCache>,
pipelines: Res<BloomPipelines>,
views: Query<(Entity, &ExtractedCamera, &BloomView, Option<&BloomTextures>)>,
) {
let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout);
for (entity, camera, view, old) in &views {
let Some(size) = camera.physical_viewport_size else {
continue;
};
let divisor = if view.tier == 1 { 4 } else { 2 };
let mut texture = |label: &'static str, width: u32, height: u32| {
cache.get(
&render_device,
TextureDescriptor {
label: Some(label),
size: Extent3d {
width: width.max(8),
height: height.max(8),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: ViewTarget::TEXTURE_FORMAT_HDR,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
)
};
let a = texture("post_bloom_a", size.x / divisor, size.y / divisor);
let b = texture("post_bloom_b", size.x / divisor, size.y / divisor);
if old.is_some_and(|old| {
old.tier == view.tier
&& old.a.texture.id() == a.texture.id()
&& old.b.texture.id() == b.texture.id()
}) {
continue;
}
let uniform = render_device.create_buffer(&BufferDescriptor {
label: Some("post_bloom_uniform"),
size: 16,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind = |label, source: &TextureView, auxiliary: &TextureView| {
render_device.create_bind_group(
label,
&layout,
&BindGroupEntries::sequential((
source,
&pipelines.sampler,
auxiliary,
uniform.as_entire_binding(),
)),
)
};
let bind_ab = bind("post_bloom_a_to_b", &a.default_view, &a.default_view);
let bind_ba = bind("post_bloom_b_to_a", &b.default_view, &b.default_view);
commands.entity(entity).insert(BloomTextures {
a,
b,
uniform,
bind_ab,
bind_ba,
tier: view.tier,
});
}
}
#[derive(Default)]
struct BloomNode;
impl ViewNode for BloomNode {
type ViewQuery = (
&'static ViewTarget,
&'static BloomView,
&'static BloomTextures,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
(target, view, textures): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let pipelines = world.resource::<BloomPipelines>();
let cache = world.resource::<PipelineCache>();
let (Some(extract), Some(blur_h), Some(blur_v), Some(combine)) = (
cache.get_render_pipeline(pipelines.extract),
cache.get_render_pipeline(pipelines.blur_h),
cache.get_render_pipeline(pipelines.blur_v),
cache.get_render_pipeline(pipelines.combine),
) else {
return Ok(());
};
let gain = if view.tier == 1 { 0.55 } else { 0.78 };
let params = [view.tier as f32, gain, 0.0, 0.0];
world.resource::<RenderQueue>().write_buffer(
&textures.uniform,
0,
bytemuck::cast_slice(&params),
);
let device = render_context.render_device().clone();
let layout = cache.get_bind_group_layout(&pipelines.layout);
let source = target.main_texture_view();
let extract_bind = device.create_bind_group(
"post_bloom_extract",
&layout,
&BindGroupEntries::sequential((
source,
&pipelines.sampler,
&textures.b.default_view,
textures.uniform.as_entire_binding(),
)),
);
let diagnostics = render_context.diagnostic_recorder();
for (label, pipeline, bind, destination) in [
(
"post_bloom_extract",
extract,
&extract_bind,
&textures.a.default_view,
),
(
"post_bloom_blur_h",
blur_h,
&textures.bind_ab,
&textures.b.default_view,
),
(
"post_bloom_blur_v",
blur_v,
&textures.bind_ba,
&textures.a.default_view,
),
] {
let mut pass =
render_context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(RenderPassColorAttachment {
view: destination,
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, label);
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bind, &[]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
}
let output = target.post_process_write();
let combine_bind = device.create_bind_group(
"post_bloom_combine",
&layout,
&BindGroupEntries::sequential((
output.source,
&pipelines.sampler,
&textures.a.default_view,
textures.uniform.as_entire_binding(),
)),
);
let mut pass = render_context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("post_bloom_combine"),
color_attachments: &[Some(RenderPassColorAttachment {
view: output.destination,
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_bloom_combine");
pass.set_pipeline(combine);
pass.set_bind_group(0, &combine_bind, &[]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
Ok(())
}
}
+72
View File
@@ -0,0 +1,72 @@
// MONKEY (post): world-only HDR excess extraction, dual-filter blur, and halo composite.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
@group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var linear_sampler: sampler;
@group(0) @binding(2) var auxiliary_tex: texture_2d<f32>;
struct BloomParams {
tier: f32,
gain: f32,
unused: vec2<f32>,
};
@group(0) @binding(3) var<uniform> bloom: BloomParams;
fn tap(tex: texture_2d<f32>, uv: vec2<f32>, offset: vec2<f32>) -> vec4<f32> {
let size = vec2<f32>(textureDimensions(tex));
return textureSampleLevel(tex, linear_sampler, uv + offset / size, 0.0);
}
@fragment
fn fs_extract(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// 13-tap downfilter: a broad, stable footprint when Low uses a quarter-size target.
// MONKEY (fix-post): the footprint scales with the target divisor (Low = quarter size, so
// 2x offsets), else 16 source pixels fed each output from a 5x5 area and thin sparks shimmered.
let spread = select(1.0, 2.0, bloom.tier < 1.5);
var c = tap(source_tex, in.uv, vec2(0.0)) * 0.20;
c += tap(source_tex, in.uv, vec2( 1.0, 0.0) * spread) * 0.10;
c += tap(source_tex, in.uv, vec2(-1.0, 0.0) * spread) * 0.10;
c += tap(source_tex, in.uv, vec2( 0.0, 1.0) * spread) * 0.10;
c += tap(source_tex, in.uv, vec2( 0.0, -1.0) * spread) * 0.10;
c += tap(source_tex, in.uv, vec2( 1.0, 1.0) * spread) * 0.06;
c += tap(source_tex, in.uv, vec2(-1.0, 1.0) * spread) * 0.06;
c += tap(source_tex, in.uv, vec2( 1.0, -1.0) * spread) * 0.06;
c += tap(source_tex, in.uv, vec2(-1.0, -1.0) * spread) * 0.06;
c += tap(source_tex, in.uv, vec2( 2.0, 0.0) * spread) * 0.04;
c += tap(source_tex, in.uv, vec2(-2.0, 0.0) * spread) * 0.04;
c += tap(source_tex, in.uv, vec2( 0.0, 2.0) * spread) * 0.04;
c += tap(source_tex, in.uv, vec2( 0.0, -2.0) * spread) * 0.04;
// MONKEY (fix-post): cap the excess so a +inf pixel cannot become an inf/inf NaN halo.
let excess = min(max(c.rgb - vec3(1.0), vec3(0.0)), vec3(64.0));
let knee = excess * excess / (excess + vec3(0.50));
// Frost-Nova/firework stacks can reach 5-10x; compress them without hard clipping.
let capped = vec3(5.0) * (vec3(1.0) - exp(-knee / vec3(5.0)));
return vec4(capped, 1.0);
}
fn blur_axis(uv: vec2<f32>, axis: vec2<f32>) -> vec4<f32> {
var c = tap(source_tex, uv, vec2(0.0)) * 0.227027;
c += tap(source_tex, uv, axis * 1.384615) * 0.316216;
c += tap(source_tex, uv, axis * -1.384615) * 0.316216;
c += tap(source_tex, uv, axis * 3.230769) * 0.070270;
c += tap(source_tex, uv, axis * -3.230769) * 0.070270;
return c;
}
@fragment
fn fs_blur_h(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
return blur_axis(in.uv, vec2(1.0, 0.0));
}
@fragment
fn fs_blur_v(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
return blur_axis(in.uv, vec2(0.0, 1.0));
}
@fragment
fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let scene = textureSample(source_tex, linear_sampler, in.uv);
let halo = textureSample(auxiliary_tex, linear_sampler, in.uv).rgb;
return vec4(scene.rgb + halo * bloom.gain, scene.a);
}
+432
View File
@@ -0,0 +1,432 @@
//! 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
//! lookup after upload to a 3D texture. See `THIRD-PARTY.md`.
use super::bloom::BloomLabel;
use crate::video::VideoConfig;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::{lighting::WorldTime, terrain_stream::CurrentArea, view::WorldCamera};
use bevy::{
asset::RenderAssetUsages,
core_pipeline::{core_3d::graph::Core3d, FullscreenShader},
ecs::query::QueryItem,
image::Image,
prelude::*,
render::{
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_asset::RenderAssets,
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
texture::GpuImage,
view::ViewTarget,
RenderApp, RenderStartup,
},
};
use std::collections::{HashMap, HashSet};
pub(super) struct GradingPlugin;
#[derive(Resource)]
struct GradeCatalog {
rows: benilla_formats::MonkeyZoneGrades,
luts: HashMap<String, Handle<Image>>,
identity: Handle<Image>,
}
#[derive(Component, Clone, PartialEq, ExtractComponent)]
struct GradeView {
day: Handle<Image>,
night: Handle<Image>,
// x = authored strength, y = night blend.
control: Vec4,
}
#[derive(Clone, Copy, ShaderType)]
struct GradeUniform {
control: Vec4,
}
impl Plugin for GradingPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(ExtractComponentPlugin::<GradeView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
app.add_systems(Startup, load_catalog.after(AssetSet::Open));
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(
include_str!("grading.wgsl"),
"post/grading.wgsl",
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(GradeShader(shader))
.add_systems(RenderStartup, init_pipeline)
.add_render_graph_node::<ViewNodeRunner<GradeNode>>(Core3d, GradeLabel)
.add_render_graph_edges(
Core3d,
(
BloomLabel,
GradeLabel,
benilla_world::ffx_glow::FfxGlowLabel,
),
);
}
}
fn load_catalog(
mut commands: Commands,
assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
let identity = images.add(volume_image(identity_cube()));
let Some(assets) = assets else {
commands.insert_resource(GradeCatalog {
rows: default(),
luts: default(),
identity,
});
return;
};
let mut chain = assets.chain.lock_recover();
let loose_root = benilla_formats::wow_data();
let loaded_rows = loose_root
.as_ref()
.map(|root| root.join("DBFilesClient").join("MonkeyZoneGrade.dbc"))
.filter(|path| path.is_file())
.map(|path| {
std::fs::read(path)
.map_err(anyhow::Error::from)
.and_then(|bytes| benilla_formats::parse_monkey_zone_grades(&bytes))
})
.unwrap_or_else(|| benilla_formats::load_monkey_zone_grades(&mut chain));
let rows = match loaded_rows {
Ok(rows) => rows,
Err(error) => {
info!("post grading: no MonkeyZoneGrade.dbc; identity fallback ({error:#})");
commands.insert_resource(GradeCatalog {
rows: default(),
luts: default(),
identity,
});
return;
}
};
let paths: HashSet<String> = rows
.rows()
.flat_map(|row| [row.day_lut.clone(), row.night_lut.clone()])
.collect();
let mut luts = HashMap::new();
for path in paths {
let loose = loose_root
.as_ref()
.map(|root| root.join(path.replace('\\', "/")))
.filter(|path| path.is_file())
.map(std::fs::read)
.transpose()
.map_err(anyhow::Error::from);
let result = loose
.and_then(|bytes| match bytes {
Some(bytes) => Ok(bytes),
None => chain.read_file(&path.replace('/', "\\")),
})
.and_then(|bytes| benilla_formats::blp_bytes_to_mip_chain(&bytes));
match result.and_then(|blp| strip_to_cube(&blp)) {
Ok(cube) => {
luts.insert(path, images.add(volume_image(cube)));
}
Err(error) => warn!("post grading: {path} rejected; identity fallback: {error:#}"),
}
}
info!(
"post grading: {} area rows, {} resident LUTs",
rows.rows().count(),
luts.len()
);
commands.insert_resource(GradeCatalog {
rows,
luts,
identity,
});
}
fn strip_to_cube(blp: &benilla_formats::BlpMipChain) -> anyhow::Result<Vec<u8>> {
anyhow::ensure!(
blp.width == 1024 && blp.height == 32,
"expected 1024x32, got {}x{}",
blp.width,
blp.height
);
anyhow::ensure!(!blp.mips.is_empty(), "BLP has no base mip");
let strip = &blp.mips[0];
anyhow::ensure!(
strip.len() == 1024 * 32 * 4,
"base mip has {} bytes",
strip.len()
);
let mut cube = vec![0; 32 * 32 * 32 * 4];
for b in 0..32usize {
for g in 0..32usize {
for r in 0..32usize {
let source = (g * 1024 + b * 32 + r) * 4;
let destination = ((b * 32 + g) * 32 + r) * 4;
cube[destination..destination + 4].copy_from_slice(&strip[source..source + 4]);
}
}
}
Ok(cube)
}
fn identity_cube() -> Vec<u8> {
let mut cube = Vec::with_capacity(32 * 32 * 32 * 4);
for b in 0..32u32 {
for g in 0..32u32 {
for r in 0..32u32 {
cube.extend([r, g, b].map(|v| ((v * 255 + 15) / 31) as u8));
cube.push(255);
}
}
}
cube
}
fn volume_image(cube: Vec<u8>) -> Image {
Image::new(
Extent3d {
width: 32,
height: 32,
depth_or_array_layers: 32,
},
TextureDimension::D3,
cube,
TextureFormat::Rgba8Unorm,
RenderAssetUsages::RENDER_WORLD,
)
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
catalog: Option<Res<GradeCatalog>>,
area: Res<CurrentArea>,
areas: Option<Res<crate::area::AreaTableRes>>,
time: Res<WorldTime>,
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
) {
let selected = catalog.as_ref().and_then(|catalog| {
let leaf = area.0?;
let zone = areas
.as_ref()
.and_then(|areas| areas.0.top_zone(leaf))
.unwrap_or(leaf);
let row = catalog
.rows
.for_area(leaf)
.or_else(|| catalog.rows.for_area(zone))?;
(row.strength > 0.0).then(|| GradeView {
day: catalog
.luts
.get(&row.day_lut)
.unwrap_or(&catalog.identity)
.clone(),
night: catalog
.luts
.get(&row.night_lut)
.unwrap_or(&catalog.identity)
.clone(),
control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0),
})
});
for (entity, camera, old) in &cameras {
let next = (video.color_grading && camera.is_active)
.then(|| selected.clone())
.flatten();
match next {
Some(next) if old != Some(&next) => {
commands.entity(entity).insert(next);
}
None if old.is_some() => {
commands.entity(entity).remove::<GradeView>();
}
_ => {}
}
}
}
fn night_weight(minute: f32) -> f32 {
let dawn = smoothstep(330.0, 450.0, minute);
let dusk = smoothstep(1110.0, 1230.0, minute);
1.0 - dawn * (1.0 - dusk)
}
fn smoothstep(a: f32, b: f32, value: f32) -> f32 {
let t = ((value - a) / (b - a)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
#[derive(Resource)]
struct GradeShader(Handle<Shader>);
#[derive(Resource)]
struct GradePipeline {
layout: BindGroupLayoutDescriptor,
scene_sampler: Sampler,
lut_sampler: Sampler,
pipeline: CachedRenderPipelineId,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct GradeLabel;
fn init_pipeline(
mut commands: Commands,
device: Res<RenderDevice>,
cache: Res<PipelineCache>,
shader: Res<GradeShader>,
fullscreen: Res<FullscreenShader>,
) {
let layout = BindGroupLayoutDescriptor::new(
"post_grading_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
texture_3d(TextureSampleType::Float { filterable: true }),
texture_3d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
uniform_buffer::<GradeUniform>(false),
),
),
);
let pipeline = cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("post_grading".into()),
layout: vec![layout.clone()],
vertex: fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: shader.0.clone(),
shader_defs: vec![],
entry_point: Some("fragment".into()),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
});
let sampler = |filter| {
device.create_sampler(&SamplerDescriptor {
min_filter: filter,
mag_filter: filter,
address_mode_u: AddressMode::ClampToEdge,
address_mode_v: AddressMode::ClampToEdge,
address_mode_w: AddressMode::ClampToEdge,
..default()
})
};
commands.insert_resource(GradePipeline {
layout,
scene_sampler: sampler(FilterMode::Nearest),
lut_sampler: sampler(FilterMode::Linear),
pipeline,
});
}
#[derive(Default)]
struct GradeNode;
impl ViewNode for GradeNode {
type ViewQuery = (&'static ViewTarget, &'static GradeView);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, grade): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let settings = world.resource::<GradePipeline>();
let cache = world.resource::<PipelineCache>();
let Some(pipeline) = cache.get_render_pipeline(settings.pipeline) else {
return Ok(());
};
let images = world.resource::<RenderAssets<GpuImage>>();
let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.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",
&cache.get_bind_group_layout(&settings.layout),
&BindGroupEntries::sequential((
out.source,
&settings.scene_sampler,
&day.texture_view,
&night.texture_view,
&settings.lut_sampler,
uniform.binding().unwrap(),
)),
);
let diagnostics = context.diagnostic_recorder();
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("post_grading"),
color_attachments: &[Some(RenderPassColorAttachment {
view: out.destination,
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_grading");
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_volume_has_exact_axes() {
let cube = identity_cube();
let at = |r: usize, g: usize, b: usize| &cube[((b * 32 + g) * 32 + r) * 4..][..4];
assert_eq!(at(0, 0, 0), &[0, 0, 0, 255]);
assert_eq!(at(31, 0, 0), &[255, 0, 0, 255]);
assert_eq!(at(0, 31, 0), &[0, 255, 0, 255]);
assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]);
}
#[test]
fn day_night_blend_is_continuous() {
assert_eq!(night_weight(0.0), 1.0);
assert_eq!(night_weight(720.0), 0.0);
assert_eq!(night_weight(1439.0), 1.0);
assert!((night_weight(390.0) - 0.5).abs() < 1.0e-6);
}
}
+35
View File
@@ -0,0 +1,35 @@
// Colour grading ported from WarcraftXL wxl-retail-grading.
// Copyright (C) 2026 WarcraftXL. GPL-3.0-or-later; 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.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
@group(0) @binding(0) var scene: texture_2d<f32>;
@group(0) @binding(1) var scene_sampler: sampler;
@group(0) @binding(2) var day_lut: texture_3d<f32>;
@group(0) @binding(3) var night_lut: texture_3d<f32>;
@group(0) @binding(4) var lut_sampler: sampler;
struct Grade {
control: vec4<f32>,
};
@group(0) @binding(5) var<uniform> grade: Grade;
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.
let uvw = color * (31.0 / 32.0) + vec3(0.5 / 32.0);
return textureSampleLevel(lut, lut_sampler, uvw, 0.0).rgb;
}
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let source = textureSampleLevel(scene, scene_sampler, in.uv, 0.0);
let base = clamp(source.rgb, vec3(0.0), vec3(1.0));
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);
// 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);
}
+15
View File
@@ -0,0 +1,15 @@
//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp.
mod bloom;
mod grading;
mod sun_shafts;
pub(crate) struct PostPlugin;
impl bevy::prelude::Plugin for PostPlugin {
fn build(&self, app: &mut bevy::prelude::App) {
app.add_plugins(bloom::BloomPlugin);
app.add_plugins(grading::GradingPlugin);
app.add_plugins(sun_shafts::SunShaftsPlugin);
}
}
+322
View File
@@ -0,0 +1,322 @@
//! MONKEY (post): screen-space sun shafts from the visible sky depth.
use super::bloom::BloomLabel;
use crate::video::VideoConfig;
use benilla_world::{lighting::WowLighting, view::WorldCamera, wmo_portal::CameraInteriorClaim};
use bevy::{
core_pipeline::{
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
},
ecs::query::QueryItem,
prelude::*,
render::{
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::{Msaa, ViewDepthTexture, ViewTarget},
Render, RenderApp, RenderStartup, RenderSystems,
},
};
pub(super) struct SunShaftsPlugin;
#[derive(Component, Clone, Copy, PartialEq, ExtractComponent, ShaderType)]
struct ShaftView {
// xy = sun UV, z = faded strength, w = sample count.
sun: Vec4,
// Gamma-space sun colour; the framebuffer is still gamma-space here.
color: Vec4,
}
impl Plugin for SunShaftsPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(ExtractComponentPlugin::<ShaftView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(
include_str!("sun_shafts.wgsl"),
"post/sun_shafts.wgsl",
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(ShaftShader(shader))
.init_resource::<SpecializedRenderPipelines<ShaftPipeline>>()
.add_systems(RenderStartup, init_pipeline)
.add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
.add_render_graph_node::<ViewNodeRunner<ShaftNode>>(Core3d, ShaftLabel)
.add_render_graph_edges(
Core3d,
(Node3d::StartMainPassPostProcessing, ShaftLabel, BloomLabel),
);
}
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
interior: Res<CameraInteriorClaim>,
mut cameras: Query<
(
Entity,
&Camera,
&GlobalTransform,
&Projection,
&mut Camera3d,
Option<&ShaftView>,
),
With<WorldCamera>,
>,
) {
let to_sun = lighting.celestial_dir().normalize_or_zero();
for (entity, camera, transform, projection, mut camera3d, old) in &mut cameras {
let disabled =
!video.sun_shafts || !camera.is_active || interior.0.is_some() || to_sun == Vec3::ZERO;
if disabled {
if old.is_some() {
commands.entity(entity).remove::<ShaftView>();
}
continue;
}
let far = match projection {
Projection::Perspective(p) => p.far,
_ => 3000.0,
};
let world = transform.translation() + to_sun * far * 0.85;
let (Ok(screen), Some(size)) = (
camera.world_to_viewport(transform, world),
camera.logical_viewport_size(),
) else {
commands.entity(entity).remove::<ShaftView>();
continue;
};
let uv = screen / size;
// Keep a small off-screen skirt so shafts leave the frame continuously.
let outside = Vec2::new(
(-uv.x).max(uv.x - 1.0).max(0.0),
(-uv.y).max(uv.y - 1.0).max(0.0),
)
.max_element();
let edge_fade = 1.0 - smoothstep(0.0, 0.18, outside);
let daylight = smoothstep(-0.02, 0.16, to_sun.y);
let strength = edge_fade * daylight;
if strength <= 0.001 {
commands.entity(entity).remove::<ShaftView>();
continue;
}
camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits();
let next = ShaftView {
sun: uv.extend(strength).extend(28.0),
color: Vec3::from_array(lighting.diffuse)
.lerp(Vec3::ONE, 0.18)
.extend(0.0),
};
if old != Some(&next) {
commands.entity(entity).insert(next);
}
}
}
fn smoothstep(a: f32, b: f32, value: f32) -> f32 {
let t = ((value - a) / (b - a)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
#[derive(Resource)]
struct ShaftShader(Handle<Shader>);
#[derive(Resource)]
struct ShaftPipeline {
layouts: [BindGroupLayoutDescriptor; 2],
shader: Handle<Shader>,
fullscreen: FullscreenShader,
sampler: Sampler,
}
#[derive(Component)]
struct ViewShaftPipeline(CachedRenderPipelineId);
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct ShaftLabel;
fn init_pipeline(
mut commands: Commands,
device: Res<RenderDevice>,
shader: Res<ShaftShader>,
fullscreen: Res<FullscreenShader>,
) {
let layouts = [false, true].map(|multisampled| {
BindGroupLayoutDescriptor::new(
"post_sun_shafts_layout",
&BindGroupLayoutEntries::sequential(
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),
),
),
)
});
commands.insert_resource(ShaftPipeline {
layouts,
shader: shader.0.clone(),
fullscreen: fullscreen.clone(),
sampler: device.create_sampler(&SamplerDescriptor {
min_filter: FilterMode::Linear,
mag_filter: FilterMode::Linear,
address_mode_u: AddressMode::ClampToEdge,
address_mode_v: AddressMode::ClampToEdge,
..default()
}),
});
}
impl SpecializedRenderPipeline for ShaftPipeline {
type Key = (TextureFormat, bool);
fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor {
RenderPipelineDescriptor {
label: Some("post_sun_shafts".into()),
layout: vec![self.layouts[multisampled as usize].clone()],
vertex: self.fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: self.shader.clone(),
shader_defs: multisampled
.then(|| "MULTISAMPLED".into())
.into_iter()
.collect(),
entry_point: Some("fragment".into()),
targets: vec![Some(ColorTargetState {
format,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
}
}
}
fn prepare_pipelines(
mut commands: Commands,
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>>,
) {
// 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));
}
for (entity, target, msaa) in &views {
let id = specialized.specialize(
&cache,
&pipeline,
(target.main_texture_format(), msaa.samples() > 1),
);
commands.entity(entity).insert(ViewShaftPipeline(id));
}
}
#[derive(Default)]
struct ShaftNode;
impl ViewNode for ShaftNode {
type ViewQuery = (
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ShaftView,
&'static ViewShaftPipeline,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, depth, shaft, id): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let cache = world.resource::<PipelineCache>();
let Some(pipeline) = cache.get_render_pipeline(id.0) else {
return Ok(());
};
if !depth
.texture
.usage()
.contains(TextureUsages::TEXTURE_BINDING)
{
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 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],
),
&BindGroupEntries::sequential((
out.source,
&settings.sampler,
depth.view(),
uniform.binding().unwrap(),
)),
);
let diagnostics = context.diagnostic_recorder();
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("post_sun_shafts"),
color_attachments: &[Some(RenderPassColorAttachment {
view: out.destination,
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");
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edge_and_night_fades_are_bounded() {
assert_eq!(smoothstep(0.0, 1.0, -1.0), 0.0);
assert_eq!(smoothstep(0.0, 1.0, 2.0), 1.0);
assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1.0e-6);
}
}
@@ -0,0 +1,58 @@
// MONKEY (post): screen-space radial blur through sky pixels in the scene depth.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
@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
struct Shafts {
sun: vec4<f32>,
color: vec4<f32>,
};
@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);
let count = u32(shafts.sun.w);
let step_uv = (shafts.sun.xy - in.uv) * (0.88 / shafts.sun.w);
var uv = in.uv;
var decay = 1.0;
var light = 0.0;
var norm = 0.0;
for (var i = 0u; i < 32u; i++) {
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);
light += sky * decay;
norm += decay;
}
// MONKEY (fix-post): the path mean is only the occlusion ratio; the shaft itself falls off
// radially from the sun (aspect-corrected), else every clear sky pixel got the same lift.
let dims = vec2<f32>(textureDimensions(scene));
let to_sun = (in.uv - shafts.sun.xy) * vec2<f32>(dims.x / dims.y, 1.0);
let radial = 1.0 - clamp(length(to_sun) / 0.9, 0.0, 1.0);
let rays = light / max(norm, 0.0001) * radial * radial;
return vec4(base.rgb + shafts.color.rgb * rays * shafts.sun.z * 0.16, base.a);
}
+3 -1
View File
@@ -26,6 +26,8 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster_cutout_prepass.wgsl");
// MONKEY (ao): the contact-shadow pass.
bevy::asset::embedded_asset!(app, "shaders/ssao.wgsl");
}
#[cfg(test)]
@@ -61,6 +63,6 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
assert_eq!(found, 7, "the game's shader set changed size");
assert_eq!(found, 8, "the game's shader set changed size");
}
}
+213
View File
@@ -0,0 +1,213 @@
// MONKEY (ao): depth-only screen-space ambient occlusion for the opaque scene.
// Three fullscreen stages, chosen by shader def:
// STAGE_AO half resolution: occlusion from a rotated spiral of depth taps (Alchemy-style
// cosine term with a range falloff), plus view distance and a bright-pixel mask.
// STAGE_BLUR half resolution: 4x4 depth-aware box; the 4x4 Bayer rotation cancels inside it.
// STAGE_APPLY full resolution: joint-bilateral upsample, output multiplied into the scene.
// Stored texel: r = occlusion 0..1 (fade applied), g = view distance (SKY_DIST for sky),
// b = bright-pixel protection 0..1, a (MONKEY (followups)) = surface smoothness 0..1 after
// STAGE_AO (0 where both neighbours on an axis jump in depth), 1 - foliage mask after STAGE_BLUR.
#import bevy_render::view::View
struct Ao {
// Radius in yards, darkening strength, sample count, cosine bias.
params: vec4<f32>,
// Distance fade start and end in yards, protection luma ramp start and end.
fade: vec4<f32>,
// x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion, 5 smoothness); y: gain.
debug: vec4<f32>,
}
const SKY_DIST: f32 = 60000.0;
#ifdef STAGE_BLUR
@group(0) @binding(0) var raw: texture_2d<f32>;
#else
#ifdef MULTISAMPLED
@group(0) @binding(0) var depth: texture_depth_multisampled_2d;
#else
@group(0) @binding(0) var depth: texture_depth_2d;
#endif
// STAGE_AO: the resolved opaque scene colour. STAGE_APPLY: the blurred half-resolution AO.
@group(0) @binding(1) var source: texture_2d<f32>;
@group(0) @binding(2) var<uniform> view: View;
#endif
@group(0) @binding(3) var<uniform> ao: Ao;
#ifndef STAGE_BLUR
// Sample zero, as the water and fog passes read MSAA depth.
fn depth_at(p: vec2<i32>) -> f32 {
let size = vec2<i32>(textureDimensions(depth));
return textureLoad(depth, clamp(p, vec2<i32>(0), size - vec2<i32>(1)), 0);
}
fn view_at(p: vec2<i32>, z: f32) -> vec3<f32> {
let uv = (vec2<f32>(p) + 0.5 - view.viewport.xy) / view.viewport.zw;
let c = view.view_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), z, 1.0);
return c.xyz / c.w;
}
// Reverse-Z with an infinite far plane: the cleared value 0 is the sky.
fn is_sky(z: f32) -> bool {
return z <= 1e-7;
}
#endif
#ifdef STAGE_AO
fn bayer4(p: vec2<i32>) -> f32 {
var m = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
let x = u32(p.x) & 3u;
let y = u32(p.y) & 3u;
return f32(m[y * 4u + x]);
}
// A neighbour for the normal; the sky counts as infinitely far so it is never chosen.
fn neighbour(p: vec2<i32>, centre: vec3<f32>) -> vec3<f32> {
let z = depth_at(p);
if (is_sky(z)) { return centre + vec3(0.0, 0.0, -1e4); }
return view_at(p, z);
}
@fragment
fn ao_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let h = vec2<i32>(frag.xy);
let f = h * 2;
let size = vec2<i32>(textureDimensions(source));
// Protection from the brightest pixel of the 2x2 footprint: lit windows, flames, sunlit sand.
var bright = 0.0;
for (var j = 0; j < 4; j += 1) {
let q = clamp(f + vec2(j & 1, j >> 1u), vec2<i32>(0), size - vec2<i32>(1));
let c = textureLoad(source, q, 0).rgb;
bright = max(bright, max(c.r, max(c.g, c.b)));
}
let protect = smoothstep(ao.fade.z, ao.fade.w, bright);
let z = depth_at(f);
if (is_sky(z)) { return vec4(0.0, SKY_DIST, protect, 1.0); }
let p = view_at(f, z);
let dist = -p.z;
let fade = 1.0 - smoothstep(ao.fade.x, ao.fade.y, dist);
let radius = ao.params.x;
let proj = 0.5 * view.viewport.w * view.clip_from_view[1][1];
let r_px = min(radius * proj / max(dist, 0.01), 0.12 * view.viewport.w);
if (fade <= 0.0 || r_px < 2.0) { return vec4(0.0, dist, protect, 1.0); }
// Normal from the flatter neighbour on each axis, so silhouettes do not bend it.
let pr = neighbour(f + vec2(1, 0), p);
let pl = neighbour(f - vec2(1, 0), p);
let pd = neighbour(f + vec2(0, 1), p);
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));
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
// second difference, which is ~0 on any plane (even at grazing angles) and on a silhouette
// (one side continuous). Contact creases stay smooth, so contact shadows keep full strength.
// The blur turns the spike DENSITY of its window into the foliage mask.
let ax = neighbour(f + vec2(2, 0), p).z - p.z;
let bx = p.z - neighbour(f - vec2(2, 0), p).z;
let ay = neighbour(f + vec2(0, 2), p).z - p.z;
let by = p.z - neighbour(f - vec2(0, 2), p).z;
let spike = max(
min(min(abs(ax), abs(bx)), abs(ax - bx)),
min(min(abs(ay), abs(by)), abs(ay - by)),
);
let smooth_surface = 1.0 - smoothstep(0.01, 0.04, spike / dist);
let count = u32(ao.params.z);
let rot = (bayer4(h) + 0.5) * (6.2831853 / 16.0);
let jitter = (bayer4(h + vec2(2, 1)) + 0.5) / 16.0;
let r2 = radius * radius;
var occ = 0.0;
for (var i = 0u; i < count; i += 1u) {
let t = (f32(i) + jitter) / f32(count);
let a = f32(i) * 2.3999632 + rot;
// Linear radius: taps crowd the centre, where contact shadows live.
let off = vec2(cos(a), sin(a)) * mix(2.0, r_px, t);
let q = f + vec2<i32>(round(off));
let zq = depth_at(q);
if (is_sky(zq)) { continue; }
let v = view_at(q, zq) - p;
let vv = dot(v, v);
let w = saturate(1.0 - vv / r2);
occ += w * max(0.0, dot(v, n) * inverseSqrt(vv + 1e-6) - ao.params.w);
}
occ = saturate(ao.debug.y * occ / f32(count)) * fade;
return vec4(occ, dist, protect, smooth_surface);
}
#endif
#ifdef STAGE_BLUR
@fragment
fn blur_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let h = vec2<i32>(frag.xy);
let size = vec2<i32>(textureDimensions(raw));
let c = textureLoad(raw, h, 0);
if (c.g >= SKY_DIST) { return c; }
// MONKEY (followups): foliage = spike density over the window. There the depth test would
// reject every leaf neighbour and keep the grain, so the tolerance widens and the leftover
// occlusion is scaled down; a window of smooth surfaces keeps the tight edge and full AO.
var spikes = 0.0;
for (var y = -2; y <= 1; y += 1) {
for (var x = -2; x <= 1; x += 1) {
spikes += 1.0 - textureLoad(raw, clamp(h + vec2(x, y), vec2<i32>(0), size - vec2<i32>(1)), 0).a;
}
}
let foliage = saturate(spikes / 16.0 * 4.0);
let tolerance = (0.04 * c.g + 0.05) * (1.0 + 10.0 * foliage);
var sum = 0.0;
var weight = 0.0;
for (var y = -2; y <= 1; y += 1) {
for (var x = -2; x <= 1; x += 1) {
let s = textureLoad(raw, clamp(h + vec2(x, y), vec2<i32>(0), size - vec2<i32>(1)), 0);
let w = saturate(1.0 - abs(s.g - c.g) / tolerance);
sum += s.r * w;
weight += w;
}
}
return vec4(sum / max(weight, 1e-4) * (1.0 - 0.4 * foliage), c.g, c.b, 1.0 - foliage);
}
#endif
#ifdef STAGE_APPLY
@fragment
fn apply_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(frag.xy);
let z = depth_at(p);
let mode = u32(ao.debug.x);
if (is_sky(z)) {
// Sky is magenta in the diagnostic views, so "depth reads as sky" is visible.
return select(vec4(1.0), vec4(1.0, 0.0, 1.0, 1.0), mode >= 2u);
}
let dist = -view_at(p, z).z;
let size = vec2<i32>(textureDimensions(source));
let hc = frag.xy * 0.5 - 0.5;
let base = vec2<i32>(floor(hc));
let t = hc - floor(hc);
var occ = 0.0;
var protect = 0.0;
var smooth_surface = 0.0;
var weight = 0.0;
for (var j = 0; j < 4; j += 1) {
let o = vec2(j & 1, j >> 1u);
let s = textureLoad(source, clamp(base + o, vec2<i32>(0), size - vec2<i32>(1)), 0);
let b = mix(1.0 - t, t, vec2<f32>(o));
let w = b.x * b.y / (0.02 + abs(s.g - dist) / dist) + 1e-5;
occ += s.r * w;
protect += s.b * w;
smooth_surface += s.a * w;
weight += w;
}
occ /= weight;
protect /= weight;
if (mode == 2u) { return vec4(protect, protect, protect, 1.0); }
// Red = positive view distance, green = negative (a sign error), both / 50 yd.
if (mode == 3u) { return vec4(saturate(dist / 50.0), saturate(-dist / 50.0), 0.0, 1.0); }
if (mode == 4u) { return vec4(1.0 - occ, 1.0 - occ, 1.0 - occ, 1.0); }
// MONKEY (followups): 5 = foliage mask (black = treated as foliage).
if (mode == 5u) { let m = smooth_surface / weight; return vec4(m, m, m, 1.0); }
let k = 1.0 - ao.params.y * occ * (1.0 - protect);
return vec4(k, k, k, 1.0);
}
#endif
+53
View File
@@ -0,0 +1,53 @@
//! MONKEY (sky): the `skyQuality` cvar → [`benilla_world::sky_fx::SkyQuality`] bridge.
//!
//! 0 Classic (the reference's sky, unchanged), 1 Enhanced (smooth gradient, sun glow, night sky),
//! 2 High (+ detailed, sun-lit clouds). `$WOW_SKY_QUALITY` pins the tier for the session, so
//! captures stay independent of `config.toml`.
use bevy::prelude::*;
use benilla_world::sky_fx::SkyQuality;
use crate::video::VideoConfig;
pub(crate) struct SkyQualityPlugin;
impl Plugin for SkyQualityPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SkyQuality>().add_systems(
Update,
// MONKEY (integration): the tier lands before the sky and cloud readers.
bridge.before(benilla_world::lighting::LightingConsumeSet),
);
}
}
fn bridge(video: Res<VideoConfig>, mut out: ResMut<SkyQuality>, mut pinned: Local<Option<Option<u8>>>) {
let pin = *pinned.get_or_insert_with(SkyQuality::env_override);
let want = SkyQuality(pin.unwrap_or(video.sky_quality).min(SkyQuality::MAX));
if *out != want {
*out = want;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bridge_publishes_the_tier_without_dirtying_idle_frames() {
if SkyQuality::env_override().is_some() {
return; // pinned by the environment; nothing to observe
}
let mut app = App::new();
app.init_resource::<VideoConfig>().add_plugins(SkyQualityPlugin);
app.update();
assert_eq!(app.world().resource::<SkyQuality>().0, VideoConfig::default().sky_quality);
for tier in [2u8, 1, 0] {
app.world_mut().resource_mut::<VideoConfig>().sky_quality = tier;
app.update();
assert_eq!(app.world().resource::<SkyQuality>().0, tier);
app.update();
assert!(!app.world().resource_ref::<SkyQuality>().is_changed());
}
}
}
+532
View File
@@ -0,0 +1,532 @@
//! MONKEY (ao): depth-only screen-space ambient occlusion (soft contact shadows).
//!
//! No depth or normal prepass exists, and the static world draws in its own node, so Bevy's
//! SSAO cannot see it. This lane reads the main-pass depth after the opaque pass instead:
//! half-resolution occlusion from depth-reconstructed positions and normals, a 4x4 depth-aware
//! blur, then a joint-bilateral upsample MULTIPLIED into the main colour attachment (the MSAA
//! attachment when multisampled, so the resolve and every later pass see it). It runs before
//! the water depth/colour copy and before transparents, so water, particles and UI are
//! untouched and refraction sees the darkened bed. Sky, distant pixels and
//! bright pixels (lit windows, flames, sunlit sand) are protected; fade 45-90 yd (Low) / 60-120 yd (High).
//!
//! cvar `ambientOcclusion`: 0 Off (pass not scheduled, image unchanged), 1 Low, 2 High.
//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1..5` writes a diagnostic view
//! (factor, protection, distance, raw occlusion); `WOW_AO_GAIN/RADIUS/BIAS/STRENGTH` tune it.
use crate::video::VideoConfig;
use benilla_world::{liquid::WaterDepthLabel, view::WorldCamera};
use bevy::{
core_pipeline::{
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
},
ecs::query::QueryItem,
image::ToExtents,
prelude::*,
render::{
camera::ExtractedCamera,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
texture::{CachedTexture, TextureCache},
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
Render, RenderApp, RenderStartup, RenderSystems,
},
shader::ShaderDefVal,
};
pub(crate) struct AmbientOcclusionPlugin;
#[derive(Resource)]
struct AoOverride(Option<u8>);
/// Per-tier constants, uploaded as one small uniform.
#[derive(Component, Clone, Copy, PartialEq, Debug, ExtractComponent, ShaderType)]
struct AoView {
/// Radius (yd), darkening strength, sample count, cosine bias.
params: Vec4,
/// Distance fade start and end (yd), bright-pixel protection ramp (gamma max channel).
fade: Vec4,
/// x = debug view (0 off, 1 AO factor, 2 protection, 3 distance/50, 4 raw occlusion,
/// 5 surface smoothness, MONKEY (followups)), y = occlusion gain.
debug: Vec4,
}
/// Dev tuning knob `WOW_AO_<name>=<f32>`, read once per name; `None` keeps the tier value.
fn tuning(name: &'static str) -> Option<f32> {
static KNOBS: std::sync::Mutex<Vec<(&'static str, Option<f32>)>> = std::sync::Mutex::new(Vec::new());
let mut knobs = KNOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, v)) = knobs.iter().find(|(n, _)| *n == name) {
return *v;
}
let v = std::env::var(format!("WOW_AO_{name}"))
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v >= 0.0);
knobs.push((name, v));
v
}
impl AoView {
fn tier(tier: u8) -> Self {
let (radius, strength, samples) = if tier >= 2 { (1.5, 0.7, 12.0) } else { (1.2, 0.65, 6.0) };
// Outdoor contacts (house bases, trunks) sit 30-60 yd out; Low stops sooner.
let fade = if tier >= 2 { (60.0, 120.0) } else { (45.0, 90.0) };
let radius = tuning("RADIUS").unwrap_or(radius);
let strength = tuning("STRENGTH").unwrap_or(strength).min(1.0);
let bias = tuning("BIAS").unwrap_or(0.05);
let gain = tuning("GAIN").unwrap_or(6.0);
Self {
params: Vec4::new(radius, strength, samples, bias),
fade: Vec4::new(fade.0, fade.1, 0.7, 0.95),
debug: Vec4::new(debug_mode() as f32, gain, 0.0, 0.0),
}
}
}
/// `WOW_AO_DEBUG=1..5`: write a diagnostic term instead of darkening the scene.
fn debug_mode() -> u8 {
static MODE: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
*MODE.get_or_init(|| {
std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(5)
})
}
fn debug_view() -> bool {
debug_mode() != 0
}
impl Plugin for AmbientOcclusionPlugin {
fn build(&self, app: &mut App) {
let value = std::env::var("WOW_AO")
.ok()
.and_then(|v| v.parse::<u8>().ok())
.filter(|v| *v <= 2);
app.insert_resource(AoOverride(value))
.add_plugins(ExtractComponentPlugin::<AoView>::default())
.add_systems(Last, update_ao);
// Keep headless policy tests independent of the renderer.
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
// MONKEY (integration): the crate's shader convention — embedded by `shaders::plugin`.
let shader = app
.world()
.resource::<AssetServer>()
.load("embedded://benilla_app/shaders/ssao.wgsl");
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(AoShader(shader))
.init_resource::<SpecializedRenderPipelines<AoPipeline>>()
.add_systems(RenderStartup, init_pipeline)
.add_systems(
Render,
(
prepare_pipelines.in_set(RenderSystems::Prepare),
prepare_textures.in_set(RenderSystems::PrepareResources),
),
)
.add_render_graph_node::<ViewNodeRunner<AoNode>>(Core3d, AoLabel)
// After every solid path (static_gx precedes MainOpaquePass), before the water copy.
.add_render_graph_edges(Core3d, (Node3d::MainOpaquePass, AoLabel, WaterDepthLabel));
}
}
fn update_ao(
mut commands: Commands,
video: Res<VideoConfig>,
override_value: Res<AoOverride>,
mut cameras: Query<(Entity, &Camera, &mut Camera3d, Option<&AoView>), With<WorldCamera>>,
) {
let tier = override_value.0.unwrap_or(video.ambient_occlusion).min(2);
for (entity, camera, mut camera3d, current) in &mut cameras {
if tier == 0 || !camera.is_active {
if current.is_some() {
commands.entity(entity).remove::<AoView>();
}
continue;
}
let binding = TextureUsages::TEXTURE_BINDING.bits();
if camera3d.depth_texture_usages.0 & binding == 0 {
camera3d.depth_texture_usages.0 |= binding;
}
let want = AoView::tier(tier);
if current != Some(&want) {
commands.entity(entity).insert(want);
}
}
}
#[derive(Resource)]
struct AoShader(Handle<Shader>);
#[derive(Resource)]
struct AoPipeline {
ao_layouts: [BindGroupLayoutDescriptor; 2],
blur_layout: BindGroupLayoutDescriptor,
apply_layouts: [BindGroupLayoutDescriptor; 2],
shader: Handle<Shader>,
fullscreen: FullscreenShader,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Stage {
Ao,
Blur,
Apply,
}
const AO_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
fn init_pipeline(
mut commands: Commands,
shader: Res<AoShader>,
fullscreen: Res<FullscreenShader>,
) {
let with_depth = |label: &'static str, multisampled: bool| {
BindGroupLayoutDescriptor::new(
label,
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
if multisampled {
texture_depth_2d_multisampled()
} else {
texture_depth_2d()
},
texture_2d(TextureSampleType::Float { filterable: false }),
uniform_buffer::<ViewUniform>(true),
uniform_buffer::<AoView>(false),
),
),
)
};
let blur_layout = BindGroupLayoutDescriptor::new(
"ssao_blur_layout",
&BindGroupLayoutEntries::with_indices(
ShaderStages::FRAGMENT,
(
(0, texture_2d(TextureSampleType::Float { filterable: false })),
(3, uniform_buffer::<AoView>(false)),
),
),
);
commands.insert_resource(AoPipeline {
ao_layouts: [false, true].map(|ms| with_depth("ssao_layout", ms)),
blur_layout,
apply_layouts: [false, true].map(|ms| with_depth("ssao_apply_layout", ms)),
shader: shader.0.clone(),
fullscreen: fullscreen.clone(),
});
}
impl SpecializedRenderPipeline for AoPipeline {
/// Stage, target format, main-pass sample count, debug view.
type Key = (Stage, TextureFormat, u32, bool);
fn specialize(&self, (stage, format, samples, debug): Self::Key) -> RenderPipelineDescriptor {
let multisampled = samples > 1;
let mut defs: Vec<ShaderDefVal> = vec![match stage {
Stage::Ao => "STAGE_AO",
Stage::Blur => "STAGE_BLUR",
Stage::Apply => "STAGE_APPLY",
}
.into()];
if multisampled && stage != Stage::Blur {
defs.push("MULTISAMPLED".into());
}
let (label, entry, layout) = match stage {
Stage::Ao => ("ssao", "ao_main", self.ao_layouts[multisampled as usize].clone()),
Stage::Blur => ("ssao_blur", "blur_main", self.blur_layout.clone()),
Stage::Apply => (
"ssao_apply",
"apply_main",
self.apply_layouts[multisampled as usize].clone(),
),
};
// The apply stage multiplies into the scene (dst * src) and leaves alpha alone.
let blend = (stage == Stage::Apply && !debug).then_some(BlendState {
color: BlendComponent {
src_factor: BlendFactor::Dst,
dst_factor: BlendFactor::Zero,
operation: BlendOperation::Add,
},
alpha: BlendComponent {
src_factor: BlendFactor::Zero,
dst_factor: BlendFactor::One,
operation: BlendOperation::Add,
},
});
RenderPipelineDescriptor {
label: Some(label.into()),
layout: vec![layout],
vertex: self.fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: self.shader.clone(),
shader_defs: defs,
entry_point: Some(entry.into()),
targets: vec![Some(ColorTargetState {
format: if stage == Stage::Apply { format } else { AO_FORMAT },
blend,
write_mask: ColorWrites::ALL,
})],
}),
multisample: MultisampleState {
count: if stage == Stage::Apply { samples } else { 1 },
..default()
},
..default()
}
}
}
#[derive(Component)]
struct ViewAoPipelines([CachedRenderPipelineId; 3]);
fn prepare_pipelines(
mut commands: Commands,
cache: Res<PipelineCache>,
pipeline: Res<AoPipeline>,
mut specialized: ResMut<SpecializedRenderPipelines<AoPipeline>>,
views: Query<(Entity, &ViewTarget, &Msaa), With<AoView>>,
all_views: Query<(&ViewTarget, &Msaa), With<Camera3d>>,
) {
let debug = debug_view();
// 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 {
for stage in [Stage::Ao, Stage::Blur, Stage::Apply] {
specialized.specialize(
&cache,
&pipeline,
(stage, target.main_texture_format(), msaa.samples(), debug),
);
}
}
for (entity, target, msaa) in &views {
let format = target.main_texture_format();
let samples = msaa.samples();
let ids = [Stage::Ao, Stage::Blur, Stage::Apply]
.map(|stage| specialized.specialize(&cache, &pipeline, (stage, format, samples, debug)));
commands.entity(entity).insert(ViewAoPipelines(ids));
}
}
#[derive(Component)]
struct AoTextures {
raw: CachedTexture,
blurred: CachedTexture,
}
fn prepare_textures(
mut commands: Commands,
mut cache: ResMut<TextureCache>,
device: Res<RenderDevice>,
views: Query<(Entity, &ExtractedCamera), With<AoView>>,
) {
for (entity, camera) in &views {
// The depth texture is sized to the physical target.
let Some(size) = camera.physical_target_size else { continue };
let half = UVec2::new(size.x.div_ceil(2), size.y.div_ceil(2)).max(UVec2::ONE);
let mut texture = |label: &'static str| {
cache.get(
&device,
TextureDescriptor {
label: Some(label),
size: half.to_extents(),
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: AO_FORMAT,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
)
};
let raw = texture("ssao_raw");
let blurred = texture("ssao_blurred");
commands.entity(entity).insert(AoTextures { raw, blurred });
}
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct AoLabel;
#[derive(Default)]
struct AoNode;
impl ViewNode for AoNode {
type ViewQuery = (
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ViewUniformOffset,
&'static AoView,
&'static ViewAoPipelines,
&'static AoTextures,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, depth, view_offset, settings, ids, textures): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let cache = world.resource::<PipelineCache>();
let (Some(ao), Some(blur), Some(apply)) = (
cache.get_render_pipeline(ids.0[0]),
cache.get_render_pipeline(ids.0[1]),
cache.get_render_pipeline(ids.0[2]),
) else {
return Ok(());
};
if !depth
.texture
.usage()
.contains(TextureUsages::TEXTURE_BINDING)
{
return Ok(());
}
let Some(view) = world.resource::<ViewUniforms>().uniforms.binding() else {
return Ok(());
};
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();
// `main_texture_view` is the single-sample texture the opaque pass resolved into.
let ao_bind = device.create_bind_group(
"ssao",
&cache.get_bind_group_layout(&layouts.ao_layouts[ms]),
&BindGroupEntries::sequential((
depth.view(),
target.main_texture_view(),
view.clone(),
params.clone(),
)),
);
let blur_bind = device.create_bind_group(
"ssao_blur",
&cache.get_bind_group_layout(&layouts.blur_layout),
&BindGroupEntries::with_indices((
(0, &textures.raw.default_view),
(3, params.clone()),
)),
);
let apply_bind = device.create_bind_group(
"ssao_apply",
&cache.get_bind_group_layout(&layouts.apply_layouts[ms]),
&BindGroupEntries::sequential((
depth.view(),
&textures.blurred.default_view,
view,
params,
)),
);
let diagnostics = context.diagnostic_recorder();
let half_pass = |encoder: &mut CommandEncoder,
label: &'static str,
out: &TextureView,
pipeline: &RenderPipeline,
bind: &BindGroup,
offsets: &[u32]| {
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(RenderPassColorAttachment {
view: out,
depth_slice: None,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(Default::default()),
store: StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
let span = diagnostics.pass_span(&mut pass, label);
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bind, offsets);
pass.draw(0..3, 0..1);
span.end(&mut pass);
};
let encoder = context.command_encoder();
half_pass(encoder, "ssao", &textures.raw.default_view, ao, &ao_bind, &[view_offset.offset]);
half_pass(encoder, "ssao_blur", &textures.blurred.default_view, blur, &blur_bind, &[]);
// The main attachment (MSAA + resolve when multisampled), so later passes load it darkened.
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("ssao_apply"),
color_attachments: &[Some(target.get_color_attachment())],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
let span = diagnostics.pass_span(&mut pass, "ssao_apply");
pass.set_pipeline(apply);
pass.set_bind_group(0, &apply_bind, &[view_offset.offset]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tiers_follow_the_cvar_override_and_camera_state() {
let mut app = App::new();
app.init_resource::<VideoConfig>()
.insert_resource(AoOverride(None))
.add_systems(Update, update_ao);
let camera = app
.world_mut()
.spawn((WorldCamera, Camera::default(), Camera3d::default()))
.id();
let preview = app
.world_mut()
.spawn((Camera::default(), Camera3d::default()))
.id();
// The registered default is Off: no pass, image unchanged.
app.update();
assert!(app.world().get::<AoView>(camera).is_none());
app.world_mut().resource_mut::<VideoConfig>().ambient_occlusion = 1;
app.update();
let low = *app.world().get::<AoView>(camera).unwrap();
assert_eq!(low.params.z, 6.0);
assert!(app.world().get::<AoView>(preview).is_none());
let usage = app.world().get::<Camera3d>(camera).unwrap().depth_texture_usages.0;
assert!(usage & TextureUsages::TEXTURE_BINDING.bits() != 0);
app.world_mut().resource_mut::<VideoConfig>().ambient_occlusion = 2;
app.update();
let high = *app.world().get::<AoView>(camera).unwrap();
assert_eq!(high.params.z, 12.0);
assert!(high.params.x > low.params.x);
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = false;
app.update();
assert!(app.world().get::<AoView>(camera).is_none());
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = true;
app.world_mut().resource_mut::<AoOverride>().0 = Some(0);
app.update();
assert!(app.world().get::<AoView>(camera).is_none());
}
#[test]
fn tiers_keep_the_fade_and_protection_ramps_ordered() {
for tier in [1, 2] {
let v = AoView::tier(tier);
assert!(v.fade.x < v.fade.y, "distance fade runs outward");
assert!(v.fade.z < v.fade.w, "protection ramps up with brightness");
assert!(v.params.y > 0.0 && v.params.y < 1.0, "never a black halo");
}
}
}
+55 -1
View File
@@ -645,6 +645,23 @@ struct EntityCasters<'w, 's> {
/// bundle rather than being a 17th system param because `update_torch_shadows` already sits at
/// Bevy's 16-param ceiling — the same reason `distance` is here.
lighting: Res<'w, WowLighting>,
/// MONKEY (daylight: terrain torch casters): the resident ADT tiles, for the ground half of an
/// exterior slot's static caster mesh (`torchTerrainShadows`). `Option` so a scene with no
/// terrain streamer (the glue, a booth) is simply terrain-less.
terrain: Option<Res<'w, benilla_world::terrain_stream::TerrainStreamer>>,
adt_tiles: Option<Res<'w, Assets<benilla_assets::AdtTile>>>,
}
/// MONKEY (daylight: terrain torch casters): does an EXTERIOR slot's static map include the ground?
/// The cvar `torchTerrainShadows`, or `WOW_TORCH_TERRAIN=0|1` (read once) for hermetic captures.
fn terrain_casters_on(video: &VideoConfig) -> bool {
static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
ENV.get_or_init(|| match std::env::var("WOW_TORCH_TERRAIN").ok().as_deref() {
Some("1") => Some(true),
Some("0") => Some(false),
_ => None,
})
.unwrap_or(video.torch_terrain_shadows)
}
/// The INTERIOR lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
@@ -1123,9 +1140,22 @@ fn update_torch_shadows(
// While this pair is unchanged the fixture-local source set cannot have changed either, so the
// slot's walk is skipped outright — which is what turns "a full scene scan every frame,
// forever, in a room where nothing ever moves" into "a scan the frame something does".
// MONKEY (daylight: terrain torch casters): the resident terrain set joins the retained
// scene's stamp, so a tile streaming in re-checks the exterior slots near it; the switch itself
// is folded in so toggling the cvar re-keys every slot.
let terrain_on = terrain_casters_on(&video);
let terrain_gen = match (&ents.terrain, &ents.adt_tiles) {
(Some(t), Some(a)) if terrain_on => {
benilla_world::terrain_stream::terrain_torch_generation(t, a) ^ 0x7e44_a1d0
}
_ => 0,
};
let census = (slot_count > 0)
.then(|| PartCensus {
gx: gx.as_ref().map_or(0, |gx| gx.torch_residency_generation()),
gx: gx
.as_ref()
.map_or(0, |gx| gx.torch_residency_generation())
.wrapping_add(terrain_gen),
parts: static_part_census(&ents),
})
.unwrap_or_default();
@@ -1210,6 +1240,19 @@ fn update_torch_shadows(
entity_key = entity_key.wrapping_add(part_hash.finish());
}
entity_key.hash(&mut hash);
// MONKEY (daylight: terrain torch casters): settled EXTERIOR slots only. An interior
// fixture's room already occludes the ground, and a moving slot regathers every frame.
let with_terrain = terrain_gen != 0 && slot.exterior && !moving;
if with_terrain {
// MONKEY (fix-daylight): only the tiles near THIS slot key it; the global stamp
// re-rendered every exterior slot on any stream event anywhere.
if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) {
(benilla_world::terrain_stream::terrain_torch_generation_near(
t, a, slot.pos, TORCH_RANGE,
) ^ 0x7e44_a1d0)
.hash(&mut hash);
}
}
let key = hash.finish();
let dirty = slot.geometry_key != Some(key);
if !dirty {
@@ -1235,6 +1278,17 @@ fn update_torch_shadows(
});
let (_, part_excluded) =
collect_torch_entities(&ents, true, &gather, &mut positions, &mut indices);
if with_terrain {
if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) {
let t0 = std::time::Instant::now();
let tris = benilla_world::terrain_stream::append_terrain_torch_triangles(
t, a, slot.pos, range, &mut positions, &mut indices);
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
info!("torch-terrain: slot {i} +{tris} ground tris within {range:.0} yd in {:.3} ms",
t0.elapsed().as_secs_f64() * 1e3);
}
}
}
slot.trace_excluded = (gx_excluded, part_excluded);
// MONKEY (moving fixture): a live slot MUTATES its mesh in place and keeps its asset
// id; only a settled rebuild takes a fresh one. Two reasons. A new `Mesh` asset every
@@ -2042,7 +2042,7 @@ fn volumetric_fog_dropdown_is_localised_and_live() {
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[3]").unwrap(), "RowVolumetricFog");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[5]").unwrap(), "RowVolumetricFog");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), labels[1]);
let _ = s.take_cvar_changes();
@@ -2055,6 +2055,103 @@ fn volumetric_fog_dropdown_is_localised_and_live() {
}
}
// MONKEY (ao): the row's strings, tooltip and numeric writes, both locales.
#[test]
fn ambient_occlusion_dropdown_is_localised_and_live() {
benilla_formats::wow_data_or_skip!();
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml.split("<Frame name=\"$parentRowAmbientOcclusion\"").nth(1).unwrap();
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").next().unwrap();
for (locale, title, labels) in [
("enUS", "Ambient Occlusion", ["Off", "Low", "High"]),
("ruRU", "Затенение окружения", ["Выкл", "Низкое", "Высокое"]),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
s.run(strings).unwrap();
s.run(r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#).unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s.eval::<bool>("return getglobal(self.tip) == BENILLA_ADVGFX.tips.AMBIENT_OCCLUSION and string.len(getglobal(self.tip)) > 80").unwrap());
// High first: the registered default is Off, and rewriting the same value is no change.
for (tier, label) in labels.iter().enumerate().rev() {
assert_eq!(s.eval::<String>(&format!("return self.choices[{}].text", tier + 1)).unwrap(), *label);
let _ = s.take_cvar_changes();
s.run(&format!("SetCVar(self.cvar, self.choices[{}].value)", tier + 1)).unwrap();
assert_eq!(s.take_cvar_changes(), vec![("ambientOcclusion".to_string(), tier.to_string())]);
}
}
}
// MONKEY (lampfog): the end-of-page quality row uses the same numeric tier contract as the pass.
#[test]
fn lamp_fog_dropdown_is_localised_and_live() {
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml.split("<Frame name=\"$parentRowLampFog\"").nth(1).unwrap();
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").next().unwrap();
for (locale, title, labels) in [
("enUS", "Lamp Fog", ["Off", "Low", "High"]),
(
"ruRU",
"Свечение фонарей в тумане",
["Выкл", "Низкое", "Высокое"],
),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
s.run(strings).unwrap();
s.run(
r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#,
)
.unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s
.eval::<bool>(
"return getglobal(self.tip) == BENILLA_ADVGFX.tips.LAMP_FOG and string.len(getglobal(self.tip)) > 80",
)
.unwrap());
// `lampFog` registers at Off, so seed away from the first choice before asserting that
// every selection produces the expected live write (the CVar layer suppresses no-ops).
s.run("SetCVar(self.cvar, '2')").unwrap();
let _ = s.take_cvar_changes();
for (tier, label) in labels.iter().enumerate() {
assert_eq!(
s.eval::<String>(&format!("return self.choices[{}].text", tier + 1))
.unwrap(),
*label
);
let _ = s.take_cvar_changes();
s.run(&format!(
"SetCVar(self.cvar, self.choices[{}].value)",
tier + 1
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("lampFog".to_string(), tier.to_string())]
);
}
}
}
#[test]
fn water_quality_writes_numeric_tiers_with_localised_labels() {
benilla_formats::wow_data_or_skip!();
@@ -2067,9 +2164,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
// MONKEY (volumetric fog): account for the atmosphere row after water.
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18);
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").unwrap(), "RowLavaGlow");
// MONKEY (integration): Graphics Preset + Render Distance lead the page, then every programme row (sky, post, dither, fog, wet, wind, ao, lamp fog, window split, zone skyboxes): 33.
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 33);
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]").unwrap(), "RowWaterQuality");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
assert!(s.eval::<bool>("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
@@ -3000,6 +3098,16 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
const BENILLA_OWNED: &[(&str, &str)] = &[
// MONKEY (volumetric fog): the row owns a translated tooltip too.
("BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog"),
// MONKEY (sky): the sky tier row owns its translated tooltip.
("BENILLA_TOOLTIP_SKY_QUALITY", "AdvancedGraphicsRowSkyQuality"),
// MONKEY (ao): the contact-shadow row's translated tooltip.
("BENILLA_TOOLTIP_AMBIENT_OCCLUSION", "AdvancedGraphicsRowAmbientOcclusion"),
// MONKEY (lampfog): the quality row owns a translated tooltip too.
("BENILLA_TOOLTIP_LAMP_FOG", "AdvancedGraphicsRowLampFog"),
// MONKEY (integration): the daylight window split row.
("BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT", "AdvancedGraphicsRowDaylightWindowSplit"),
// MONKEY (skybox): the zone skybox row.
("BENILLA_TOOLTIP_ZONE_SKYBOXES", "AdvancedGraphicsRowZoneSkyboxes"),
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
@@ -3031,6 +3139,15 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_LIGHTING_QUALITY",
"AdvancedGraphicsRowLightingQuality",
),
// MONKEY (presets)
(
"BENILLA_TOOLTIP_GRAPHICS_QUALITY",
"AdvancedGraphicsRowGraphicsQuality",
),
(
"BENILLA_TOOLTIP_RENDER_DISTANCE",
"AdvancedGraphicsRowRenderDistance",
),
(
"BENILLA_TOOLTIP_SHADOW_RESOLUTION",
"AdvancedGraphicsRowShadowResolution",
@@ -3051,6 +3168,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS",
"AdvancedGraphicsRowExteriorShadows",
),
// MONKEY (daylight: terrain torch casters)
(
"BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS",
"AdvancedGraphicsRowTorchTerrainShadows",
),
("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"),
("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"),
(
@@ -3135,7 +3257,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row
// changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93.
// Water Quality and Lava Glow add two more: 93 -> 95.
assert_eq!(checked, 95, "every tipped row carries a live key");
// MONKEY (daylight: terrain torch casters): Terrain Blocks Torchlight. 95 -> 96.
// MONKEY (presets): Graphics Preset and Render Distance. +2.
assert_eq!(checked, 98, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -3267,7 +3391,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// whose descriptions are all benilla's.
// Water Quality and Lava Glow add two more: 93 -> 95.
// MONKEY (volumetric fog): the atmosphere dropdown adds one more described row.
assert_eq!(raised, 96, "every row but Auto Loot raises a description");
// MONKEY (daylight: terrain torch casters): …and Terrain Blocks Torchlight one more.
assert_eq!(raised, 97, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -166,6 +166,20 @@ const GAP: &[(&str, &str, &str)] = &[
nothing moves and `ui_char.rs`'s feed has nothing to announce — the stock SkillFrame \
would only repaint under its own Close",
),
// MONKEY (integration): the two temp-point verbs, the same case as `CancelSkillUps` (pre-existing
// upstream gap, surfaced once the Windows path fix let this gate run).
(
"AddSkillUp",
"SKILL_LINES_CHANGED",
"the reference's helper 0x4d32d0 moves a temp point and fires; the temp-point table stays \
empty in this model (`skills.rs`), and `ui_char.rs`'s feed fires on any real block change",
),
(
"RemoveSkillUp",
"SKILL_LINES_CHANGED",
"the reference's helper 0x4d3480, the same as AddSkillUp: no temp points to move here, and \
`ui_char.rs`'s feed fires on any real block change",
),
(
"ClickTargetTradeButton",
"TRADE_REPLACE_ENCHANT",
@@ -279,7 +293,8 @@ fn quoted(text: &str, name: &str) -> bool {
}
fn short(p: &std::path::Path) -> String {
let s = p.to_string_lossy();
// MONKEY (integration): Windows paths use a backslash; the rows are written with `/`.
let s = p.to_string_lossy().replace('\\', "/");
s.split_once("/crates/")
.map_or(s.to_string(), |(_, t)| t.to_string())
}
@@ -370,7 +385,7 @@ fn the_declared_rows_are_still_true() {
}
let named: Vec<&(PathBuf, String)> = src
.iter()
.filter(|(p, _)| p.to_string_lossy().ends_with(file))
.filter(|(p, _)| short(p).ends_with(file)) // MONKEY (integration): separator-agnostic
.collect();
if named.is_empty() {
wrong.push(format!(
+2 -1
View File
@@ -514,7 +514,8 @@ mod tests {
// The chain's own source is the authority on what is wired — not a hand-copied list here,
// which would be the very drift this test exists to catch.
let this_file = include_str!("ui_session.rs");
// MONKEY (integration): a CRLF checkout must still find the function's closing brace.
let this_file = include_str!("ui_session.rs").replace("\r\n", "\n");
let chain = this_file
.split_once("pub(crate) fn feed_interact_npc")
.expect("feed_interact_npc")
+52 -2
View File
@@ -222,6 +222,18 @@ pub(crate) struct VideoConfig {
pub(crate) water_quality: u8,
// MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High.
pub(crate) volumetric_fog: u8,
// MONKEY (post): emissive HDR + bloom quality, 0 Off / 1 Low / 2 High.
pub(crate) bloom: u8,
// MONKEY (post): depth-occluded radial sun shafts.
pub(crate) sun_shafts: bool,
// MONKEY (post): zone/day-night LUT grading at the world-to-UI boundary.
pub(crate) color_grading: bool,
// MONKEY (sky): the sky tier: 0 Classic, 1 Enhanced, 2 High (`sky_quality::bridge`).
pub(crate) sky_quality: u8,
// MONKEY (ao): screen-space contact shadows: 0 Off, 1 Low, 2 High.
pub(crate) ambient_occlusion: u8,
// MONKEY (lampfog): point-light fog tier: 0 Off, 1 nearest 16, 2 nearest 32.
pub(crate) lamp_fog: u8,
/// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow.
/// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`.
pub(crate) lava_light_gain: f32,
@@ -314,6 +326,11 @@ pub(crate) struct VideoConfig {
/// It shares `interior_shadow_casters`' sixteen resident cube slots, capped at half of them
/// (`torch_shadow::exterior_budget`) so a village square cannot evict an inn's candles.
pub(crate) exterior_shadows: bool,
/// MONKEY (daylight: terrain torch casters): whether the GROUND casts into an exterior torch's
/// cube map (`torchTerrainShadows`, default off; High = on) — a hill or bank between a fire and
/// the slope behind it blocks the fire. Only settled exterior slots gather it
/// (`torch_shadow`); `0` leaves the torch lane exactly as it was.
pub(crate) torch_terrain_shadows: bool,
/// MONKEY (static torch cache): resident fixture budget (1..16, default 12). Static
/// geometry renders only on promotion/residency changes; lowering this fades extra slots out.
pub(crate) interior_shadow_casters: u32,
@@ -381,6 +398,10 @@ pub(crate) struct VideoConfig {
/// record table flags as enclosed. `0` restores the pre-feature look exactly; the night look is
/// unaffected at any value (the term is scaled by the sun's own day envelope).
pub(crate) interior_daylight: f32,
/// MONKEY (fix-daylight): split a district's oversized window batch into window-sized
/// daylight apertures (`daylightWindowSplit`, default on = the merged behaviour; a future
/// High-only preset member). Applies to WMOs loaded after a change.
pub(crate) daylight_window_split: bool,
/// MONKEY (bake floor): the share of a WMO interior batch's OWN MOCV bake every interior-lane
/// fragment keeps whether or not a fixture reaches it (`interiorBakeFloor`, 0..1, default
/// **0.12**). Bridged to [`benilla_world::lighting::DynamicInteriors::bake_floor`], packed
@@ -439,6 +460,7 @@ impl Default for VideoConfig {
// MONKEY (outdoor torch shadows): on — a night campfire with no shadow is the thing
// this lane exists to fix, and it costs nothing whenever the sun is up.
exterior_shadows: true,
torch_terrain_shadows: false,
// MONKEY (static torch cache): 12 resident maps, four moving-caster overlays.
interior_shadow_casters: 12,
interior_shadow_dynamic: 4,
@@ -458,6 +480,7 @@ impl Default for VideoConfig {
// MONKEY (enclosed day floor): calibrated so the Goldshire inn's entry floor reads
// ~50 % of the sunlit threshold beside it — see `lighting::DAYLIGHT_LANE_SCALE`.
interior_daylight: 0.0,
daylight_window_split: true,
// MONKEY (bake floor): an eighth of the authored bake — measured to lift the inn's
// black door band from 0.019 to 0.108 x tex while moving candle-lit surfaces by
// under 10 % (see `lighting::DynamicInteriors::bake_floor`).
@@ -467,6 +490,17 @@ impl Default for VideoConfig {
water_quality: 1,
// MONKEY (volumetric fog): default to the inexpensive atmosphere.
volumetric_fog: 1,
// MONKEY (post): the shipped High graphics preset uses the full-resolution tier.
bloom: 2,
// MONKEY (post): part of the shipped High graphics preset.
sun_shafts: true,
color_grading: true,
// MONKEY (sky): Classic until a preset or the player picks a tier.
sky_quality: 0,
// MONKEY (ao): opt-in; the future Graphics preset sets High = 2.
ambient_occlusion: 0,
// MONKEY (lampfog): opt-in; zero is exactly the pre-lane render.
lamp_fog: 0,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
@@ -527,7 +561,8 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
// ── MONKEY (lighting): the dynamic light + shadow system's 33 rows ────────────────────
// ── MONKEY (lighting): the dynamic light + shadow system's 34 rows ────────────────────
// MONKEY (lampfog): lampFog is one of these live VideoConfig rows too.
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
@@ -543,10 +578,21 @@ pub(crate) fn on_cvar(
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
// MONKEY (volumetric fog): the atmospheric tier brings the defaults weld to 33 pairs.
// MONKEY (lampfog): the two atmospheric tiers bring the defaults weld to 34 pairs.
"waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8,
// MONKEY (volumetric fog): constrain UI/console writes to supported tiers.
"volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8,
// MONKEY (post): constrain UI/console writes to the supported bloom tiers.
"bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8,
// MONKEY (post): the shafts lane is binary.
"sunshafts" => cfg.sun_shafts = v != 0.0,
"colorgrading" => cfg.color_grading = v != 0.0,
// MONKEY (sky): the sky tier, clamped to Classic..High.
"skyquality" => cfg.sky_quality = v.clamp(0.0, 2.0) as u8,
// MONKEY (ao): constrain UI/console writes to supported tiers.
"ambientocclusion" => cfg.ambient_occlusion = v.clamp(0.0, 2.0) as u8,
// MONKEY (lampfog): 0 Off / 1 nearest 16 / 2 nearest 32.
"lampfog" => cfg.lamp_fog = v.clamp(0.0, 2.0) as u8,
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
@@ -600,6 +646,8 @@ pub(crate) fn on_cvar(
// reads `VideoConfig` every frame, so `0` fades the outdoor shadows out (the slots evict
// through the same cross-fade a walked-away fixture does) and `1` fades them back in.
"exteriorshadows" => cfg.exterior_shadows = ev.flag(),
// MONKEY (daylight: terrain torch casters)
"torchterrainshadows" => cfg.torch_terrain_shadows = ev.flag(),
// MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the
// edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is
// already the off switch, and a 0 here would be a second, confusing one.
@@ -629,6 +677,8 @@ pub(crate) fn on_cvar(
// MONKEY (enclosed day floor): 0 IS meaningful here (it restores the pre-feature look
// exactly), unlike the two dim dials above whose 0 would be a broken-looking world.
"interiordaylight" => cfg.interior_daylight = v.clamp(0.0, 1.0),
// MONKEY (fix-daylight)
"daylightwindowsplit" => cfg.daylight_window_split = ev.flag(),
// MONKEY (bake floor): 0 IS meaningful here too (it restores the pre-feature look exactly).
// The upper clamp matters more than usual: the packer multiplies this by `interiorGain`
// (up to 1.5) and rides the product in a lane fraction that must stay under 0.5 after
+346 -95
View File
@@ -5,28 +5,28 @@
use crate::{
shadow_core::{ShadowSet, ShadowSun},
video::VideoConfig,
world_shadow::WorldLane,
};
use benilla_world::{
lighting::{WorldTime, WowLighting},
static_gx::StaticGx,
lighting::{ResolvedPointLight, ResolvedPointLights, WorldTime, WowLighting},
view::WorldCamera,
weather::WeatherState,
wmo_portal::CameraInteriorClaim,
};
use bevy::{
camera::primitives::{Frustum, Sphere as CullSphere},
core_pipeline::{
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
core_3d::graph::{Core3d, Node3d},
},
ecs::query::QueryItem,
light::VolumetricLight,
pbr::{
GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT,
MAX_DIRECTIONAL_LIGHTS,
GpuLights, LightMeta, MAX_CASCADES_PER_LIGHT, MAX_DIRECTIONAL_LIGHTS,
ViewLightsUniformOffset, ViewShadowBindings,
},
prelude::*,
render::{
Render, RenderApp, RenderStartup, RenderSystems,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
@@ -35,14 +35,38 @@ use bevy::{
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
Render, RenderApp, RenderStartup, RenderSystems,
},
shader::ShaderDefVal,
};
pub(crate) struct VolumetricFogPlugin;
#[derive(Resource)]
struct FogOverride(Option<u8>, f32);
struct FogOverride {
fog: Option<u8>,
lamp: Option<u8>,
shaft_gain: f32,
}
const MAX_FOG_LAMPS: usize = 32;
const LOW_FOG_LAMPS: usize = 16;
const FOG_LAMP_RADIUS: f32 = 100.0;
#[derive(Clone, Copy)]
struct FogLamps {
positions: [Vec4; MAX_FOG_LAMPS],
colors: [Vec4; MAX_FOG_LAMPS],
count: usize,
}
impl Default for FogLamps {
fn default() -> Self {
Self {
positions: [Vec4::ZERO; MAX_FOG_LAMPS],
colors: [Vec4::ZERO; MAX_FOG_LAMPS],
count: 0,
}
}
}
#[derive(Component, Clone, Copy, ExtractComponent, ShaderType)]
struct FogView {
@@ -52,6 +76,16 @@ struct FogView {
sun_strength: Vec4,
// Actual celestial direction and raymarch step count.
direction_steps: Vec4,
// MONKEY (fog): MonkeyFrame fog rows 1-3. Modern (fog3.x > 0) colours the haze with the shared
// fog colour function and keeps sky pixels (depth 0) out of the distance haze.
mf_fog1: Vec4,
mf_fog2: Vec4,
mf_fog3: Vec4,
// MONKEY (lampfog): count + strength, followed by 32 resolved point-table rows. Colour already
// includes intensity, flame flicker and (for synthesised fire) fireLightGain.
lamp_meta: Vec4,
lamp_positions: [Vec4; MAX_FOG_LAMPS],
lamp_colors: [Vec4; MAX_FOG_LAMPS],
}
impl Plugin for VolumetricFogPlugin {
@@ -60,8 +94,14 @@ impl Plugin for VolumetricFogPlugin {
.ok()
.and_then(|v| v.parse::<u8>().ok())
.filter(|v| *v <= 2);
// MONKEY (lampfog): session-only capture override; the persistent setting is `lampFog`.
let lamp = std::env::var("WOW_LAMPFOG")
.ok()
.and_then(|v| v.parse::<u8>().ok())
.filter(|v| *v <= 2);
// Dev capture instrument: isolate shafts without changing surface shadows or haze.
let shaft_gain = if cfg!(feature = "dev") && std::env::var_os("WOW_CAPTURE").is_some() {
// MONKEY (integration): the dev door is `run_mode` (decision 1179).
let shaft_gain = if crate::run_mode::dev_affordances() && std::env::var_os("WOW_CAPTURE").is_some() {
std::env::var("WOW_VOLFOG_SHAFT_GAIN")
.ok()
.and_then(|v| v.parse::<f32>().ok())
@@ -71,10 +111,15 @@ impl Plugin for VolumetricFogPlugin {
} else {
2.0
};
app.insert_resource(FogOverride(value, shaft_gain))
.add_plugins(ExtractComponentPlugin::<FogView>::default())
.add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes))
.add_systems(Last, update_fog.after(ShadowSet::Lanes));
app.insert_resource(FogOverride {
fog: value,
lamp,
shaft_gain,
})
.add_plugins(ExtractComponentPlugin::<FogView>::default())
// MONKEY (followups): the streamed-caster refresh moved to `world_shadow.rs`; it no longer
// depends on this cvar.
.add_systems(Last, update_fog.after(ShadowSet::Lanes));
// Keep headless policy tests independent of the renderer.
if !app.is_plugin_added::<AssetPlugin>() {
return;
@@ -103,33 +148,77 @@ impl Plugin for VolumetricFogPlugin {
}
}
// The legacy caster cache otherwise misses streamed trees at a stationary camera.
// Off must leave even this old cache behaviour untouched for the exact baseline.
fn refresh_streamed_shadows(
video: Res<VideoConfig>,
override_value: Res<FogOverride>,
gx: Option<Res<StaticGx>>,
mut lane: ResMut<WorldLane>,
mut seen: Local<Option<u64>>,
) {
if override_value.0.unwrap_or(video.volumetric_fog) == 0 || !video.world_shadows {
*seen = None;
return;
}
let Some(gx) = gx else { return };
let generation = gx.torch_residency_generation();
if *seen != Some(generation) {
lane.invalidate_static();
*seen = Some(generation);
}
}
fn density(minute: f32, weather: f32, indoors: bool) -> f32 {
let dawn = (1.0 - ((minute - 390.0) / 90.0).abs()).clamp(0.0, 1.0);
let dawn = dawn * dawn * (3.0 - 2.0 * dawn);
0.004 * (1.0 + dawn * 1.5 + weather.clamp(0.0, 1.0) * 2.0) * if indoors { 0.18 } else { 1.0 }
}
// MONKEY (lampfog): independent near-field haze floor. Full daylight is exactly zero; weather
// only strengthens an already-night-time halo, and interiors keep a restrained local version.
fn lamp_haze_strength(sun_height: f32, weather: f32, indoors: bool) -> f32 {
let daylight = ((sun_height + 0.02) / 0.15).clamp(0.0, 1.0);
(1.0 - daylight) * (0.8 + 1.2 * weather.clamp(0.0, 1.0)) * if indoors { 0.55 } else { 1.0 }
}
// MONKEY (lampfog): pick from the exact CPU mirror of the packed point table. A sphere test keeps
// off-screen fixtures whose halo can still cross the view. Both exterior and interior rows are
// eligible: a street camera can see facade/room fixtures, and scene depth—not a camera-lane guess—
// cuts the view segment. This pass is deliberately unshadowed, so the remaining leakage is the
// documented tradeoff until point-light cube shadowing exists.
fn select_fog_lamps(
points: &[ResolvedPointLight],
camera_position: Vec3,
frustum: Option<&Frustum>,
limit: usize,
) -> FogLamps {
let mut candidates: Vec<(f32, ResolvedPointLight)> = points
.iter()
.copied()
.filter_map(|point| {
let point_is_indoors = point.lane > 0.5;
if point.color.max_element() <= 0.0001 {
return None;
}
let distance_squared = point.position.distance_squared(camera_position);
if distance_squared > FOG_LAMP_RADIUS * FOG_LAMP_RADIUS {
return None;
}
let reach = if point_is_indoors {
point.lane
} else {
point.range
}
.clamp(1.0, 48.0);
let visible = frustum.is_none_or(|frustum| {
frustum.intersects_sphere(
&CullSphere {
center: point.position.into(),
radius: reach,
},
false,
)
});
visible.then_some((distance_squared, point))
})
.collect();
candidates.sort_by(|left, right| left.0.total_cmp(&right.0));
let mut selected = FogLamps::default();
for (_, point) in candidates.into_iter().take(limit.min(MAX_FOG_LAMPS)) {
let reach = if point.lane > 0.5 {
point.lane
} else {
point.range
}
.clamp(1.0, 48.0);
selected.positions[selected.count] = point.position.extend(reach);
selected.colors[selected.count] = point.color.extend(0.0);
selected.count += 1;
}
selected
}
fn update_fog(
mut commands: Commands,
video: Res<VideoConfig>,
@@ -138,21 +227,36 @@ fn update_fog(
clock: Res<WorldTime>,
weather: Res<WeatherState>,
interior: Res<CameraInteriorClaim>,
mut cameras: Query<(Entity, &Camera, &mut Camera3d), With<WorldCamera>>,
// MONKEY (lampfog): the resolved point-table view published by global_light's packer.
point_lights: Res<ResolvedPointLights>,
// MONKEY (fog): the Modern fog rows (Option: absent in unit tests).
monkey: Option<Res<benilla_world::lighting::MonkeyFrame>>,
mut cameras: Query<
(
Entity,
&Camera,
&mut Camera3d,
Option<&GlobalTransform>,
Option<&Frustum>,
),
With<WorldCamera>,
>,
suns: Query<(Entity, &DirectionalLight), With<ShadowSun>>,
) {
let tier = override_value.0.unwrap_or(video.volumetric_fog).min(2);
let enabled = tier != 0 && cameras.iter().any(|(_, c, _)| c.is_active);
let tier = override_value.fog.unwrap_or(video.volumetric_fog).min(2);
let lamp_tier = override_value.lamp.unwrap_or(video.lamp_fog).min(2);
let enabled = (tier != 0 || lamp_tier != 0)
&& cameras.iter().any(|(_, camera, _, _, _)| camera.is_active);
for (entity, sun) in &suns {
// Only a tag for selecting this light in our shader. No Bevy VolumetricFog
// camera/volume is created, and the rig's lux and surface lighting stay intact.
if enabled && sun.shadows_enabled {
if tier != 0 && enabled && sun.shadows_enabled {
commands.entity(entity).insert(VolumetricLight);
} else {
commands.entity(entity).remove::<VolumetricLight>();
}
}
for (entity, camera, mut camera3d) in &mut cameras {
for (entity, camera, mut camera3d, camera_transform, frustum) in &mut cameras {
if !enabled || !camera.is_active {
commands.entity(entity).remove::<FogView>();
continue;
@@ -160,18 +264,58 @@ fn update_fog(
camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits();
let sun = lighting.celestial_dir();
let daylight = ((sun.y + 0.02) / 0.15).clamp(0.0, 1.0);
let weather_amount = weather
.sky_density
.max(weather.effect_density)
.clamp(0.0, 1.0);
// Exact zero by day is both the intended look and the A/B invariant. At night a constant
// floor makes nearby halos survive the zone haze's ten-yard dead zone; rain/fog raises it.
let lamp_strength = if lamp_tier == 0 {
0.0
} else {
lamp_haze_strength(sun.y, weather_amount, interior.0.is_some())
};
let lamps = if lamp_strength > 0.0 {
select_fog_lamps(
point_lights.as_slice(),
camera_transform.map_or(Vec3::ZERO, GlobalTransform::translation),
frustum,
if lamp_tier == 1 {
LOW_FOG_LAMPS
} else {
MAX_FOG_LAMPS
},
)
} else {
FogLamps::default()
};
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));
commands.entity(entity).insert(FogView {
mf_fog1: Vec4::from_array(rows[1]),
mf_fog2: Vec4::from_array(rows[2]),
mf_fog3: Vec4::from_array(rows[3]),
lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, 0.0),
lamp_positions: lamps.positions,
lamp_colors: lamps.colors,
color_density: fog.extend(
density(
clock.minute_f,
weather.sky_density.max(weather.effect_density),
interior.0.is_some(),
) * if tier == 2 { 1.6 } else { 1.0 },
density(clock.minute_f, weather_amount, interior.0.is_some())
* if tier == 0 {
0.0
} else if tier == 2 {
1.6
} else {
1.0
},
),
sun_strength: Vec3::from_array(lighting.diffuse)
.lerp(Vec3::ONE, 0.5)
.extend(override_value.1 * daylight),
.extend(if tier == 0 {
0.0
} else {
override_value.shaft_gain * daylight
}),
direction_steps: sun.extend(if tier == 1 { 24.0 } else { 64.0 }),
});
}
@@ -271,7 +415,14 @@ fn prepare_pipelines(
pipeline: Res<FogPipeline>,
mut specialized: ResMut<SpecializedRenderPipelines<FogPipeline>>,
views: Query<(Entity, &ViewTarget, &Msaa), With<FogView>>,
all_views: Query<(&ViewTarget, &Msaa), 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));
}
for (entity, target, msaa) in &views {
let id = specialized.specialize(
&cache,
@@ -371,6 +522,7 @@ impl ViewNode for FogNode {
const FOG_SHADER: &str = r#"
#import bevy_render::view::View
#import bevy_pbr::mesh_view_types::Lights
#import benilla::fog_hook
@group(0) @binding(0) var scene: texture_2d<f32>;
#ifdef MULTISAMPLED
@group(0) @binding(1) var depth: texture_depth_multisampled_2d;
@@ -381,7 +533,15 @@ const FOG_SHADER: &str = r#"
@group(0) @binding(3) var<uniform> lights: Lights;
@group(0) @binding(4) var shadows: texture_depth_2d_array;
@group(0) @binding(5) var shadow_sampler: sampler_comparison;
struct Fog { color_density: vec4<f32>, sun_strength: vec4<f32>, direction_steps: vec4<f32> }
struct Fog {
color_density: vec4<f32>, sun_strength: vec4<f32>, direction_steps: vec4<f32>,
// MONKEY (fog): MonkeyFrame fog1..fog3.
mf_fog1: vec4<f32>, mf_fog2: vec4<f32>, mf_fog3: vec4<f32>,
// MONKEY (lampfog): x=count, y=night/weather strength; packed point-table rows follow.
lamp_meta: vec4<f32>,
lamp_positions: array<vec4<f32>, 32>,
lamp_colors: array<vec4<f32>, 32>,
}
@group(0) @binding(6) var<uniform> fog: Fog;
// Integral of smoothstep(10, 30, distance): zero in the first ten yards,
@@ -408,6 +568,56 @@ fn visibility(light_index: u32, p: vec3<f32>) -> f32 {
// Unknown outside caster coverage is not evidence of direct sunlight.
return 0.0;
}
// MONKEY (lampfog): a mild Henyey-Greenstein lobe. The inverse-square term is integrated exactly;
// evaluating this slow angular term at the closest admitted point keeps the closed form intact.
fn lamp_phase(cosine: f32) -> f32 {
let anisotropy = 0.2;
let denom = 1.0 + anisotropy * anisotropy - 2.0 * anisotropy * cosine;
return 0.07957747 * (1.0 - anisotropy * anisotropy) / (denom * sqrt(denom));
}
// MONKEY (lampfog): analytic line integral of a softened inverse-square source over the visible
// camera-to-depth segment. It is intentionally UNSHADOWED; scene depth cuts the segment, but no
// point-light cube map is sampled. Range is a smooth screen-space gate around the exact sphere
// intersection. The exponential soft cap plus destination headroom keeps halos below 1.0 so a
// later bloom pass cannot turn a stacked row of lamps into white slabs.
fn lamp_halos(ray: vec3<f32>, distance: f32) -> vec3<f32> {
var energy = vec3<f32>(0.0);
let lamp_count = u32(fog.lamp_meta.x);
for (var lamp_index = 0u; lamp_index < 32u; lamp_index += 1u) {
if (lamp_index >= lamp_count) { break; }
let position_range = fog.lamp_positions[lamp_index];
let to_lamp = position_range.xyz - view.world_position;
let projection = dot(to_lamp, ray);
let perpendicular = to_lamp - ray * projection;
let perpendicular_sq = dot(perpendicular, perpendicular);
let range_sq = position_range.w * position_range.w;
if (perpendicular_sq >= range_sq) { continue; }
let half_span = sqrt(max(range_sq - perpendicular_sq, 0.0));
let segment_start = max(0.0, projection - half_span);
let segment_end = min(distance, projection + half_span);
if (segment_end <= segment_start) { continue; }
// Integral dt / (core^2 + perpendicular^2 + (t - projection)^2).
let core_radius = 1.25;
let height = sqrt(perpendicular_sq + core_radius * core_radius);
let integral = (atan((segment_end - projection) / height)
- atan((segment_start - projection) / height)) / height;
let closest = clamp(projection, segment_start, segment_end);
let sample_to_lamp = view.world_position + ray * closest - position_range.xyz;
let light_path = sample_to_lamp / max(length(sample_to_lamp), 0.0001);
let phase = lamp_phase(dot(light_path, -ray));
let edge = 1.0 - smoothstep(position_range.w * 0.72, position_range.w,
sqrt(perpendicular_sq));
energy += max(fog.lamp_colors[lamp_index].rgb, vec3<f32>(0.0))
* (integral * phase * edge);
}
let capped = min(energy * fog.lamp_meta.y * 0.9, vec3<f32>(0.75));
return vec3<f32>(1.0) - exp(-capped);
}
@fragment
fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
let xy = vec2<i32>(pixel.xy);
@@ -417,37 +627,52 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
let q = view.world_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), max(z, 0.000001), 1.0);
let delta = q.xyz / q.w - view.world_position;
let distance = min(length(delta), 150.0);
if (distance <= 10.0) { return source; }
if (distance <= 10.0 && fog.lamp_meta.x < 0.5) { return source; }
let ray = normalize(delta);
let optical_depth = fog.color_density.w * fog_path(distance);
let opacity = 1.0 - exp(-optical_depth);
let luma = vec3(0.2126, 0.7152, 0.0722);
var haze = mix(source.rgb, fog.color_density.rgb, opacity);
// A darker zone colour may change hue, never the pixel's luminance. Add
// neutral headroom proportionally so saturated channels cannot clip dark.
let missing = max(0.0, dot(source.rgb - haze, luma));
let headroom = max(vec3(0.0), vec3(1.0) - haze);
haze += headroom * clamp(missing / max(dot(headroom, luma), 0.00001), 0.0, 1.0);
if (fog.sun_strength.w > 0.0) {
let g = 0.65;
let cosine = dot(ray, fog.direction_steps.xyz);
let denom = 1.0 + g*g - 2.0*g*cosine;
let phase = 0.07957747 * (1.0-g*g) / (denom * sqrt(denom));
let count = u32(fog.direction_steps.w);
let step_size = (distance - 10.0) / f32(count);
var scatter = 0.0;
for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) {
if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; }
for (var step = 0u; step < count; step += 1u) {
// Fixed midpoint samples: no temporal noise in this gamma lane.
let t = 10.0 + (f32(step) + 0.5) * step_size;
let density = fog.color_density.w * smoothstep(10.0, 30.0, t);
scatter += visibility(light_index, view.world_position + ray*t)
* exp(-fog.color_density.w * fog_path(t)) * density * step_size;
}
var haze = source.rgb;
if (distance > 10.0) {
let optical_depth = fog.color_density.w * fog_path(distance);
var opacity = 1.0 - exp(-optical_depth);
var haze_rgb = fog.color_density.rgb;
if (fog_hook::fog_is_modern(fog.mf_fog3)) {
// MONKEY (fog): the sky (depth 0, clamped to 150 yd above) already carries its own
// horizon fog, so it takes no distance haze; the haze colour is the shared fog colour.
if (z <= 0.0) { opacity = 0.0; }
haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta),
fog.mf_fog1, fog.mf_fog2, fog.mf_fog3);
}
let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter;
haze += max(vec3(0.0), vec3(1.0) - haze) * (vec3(1.0) - exp(-shafts));
haze = mix(source.rgb, haze_rgb, opacity);
// A darker zone colour may change hue, never the pixel's luminance. Add
// neutral headroom proportionally so saturated channels cannot clip dark.
let missing = max(0.0, dot(source.rgb - haze, luma));
let headroom = max(vec3(0.0), vec3(1.0) - haze);
haze += headroom * clamp(missing / max(dot(headroom, luma), 0.00001), 0.0, 1.0);
if (fog.sun_strength.w > 0.0) {
let g = 0.65;
let cosine = dot(ray, fog.direction_steps.xyz);
let denom = 1.0 + g*g - 2.0*g*cosine;
let phase = 0.07957747 * (1.0-g*g) / (denom * sqrt(denom));
let count = u32(fog.direction_steps.w);
let step_size = (distance - 10.0) / f32(count);
var scatter = 0.0;
for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) {
if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; }
for (var step = 0u; step < count; step += 1u) {
// Fixed midpoint samples: no temporal noise in this gamma lane.
let t = 10.0 + (f32(step) + 0.5) * step_size;
let density = fog.color_density.w * smoothstep(10.0, 30.0, t);
scatter += visibility(light_index, view.world_position + ray*t)
* exp(-fog.color_density.w * fog_path(t)) * density * step_size;
}
}
let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter;
haze += max(vec3(0.0), vec3(1.0) - haze) * (vec3(1.0) - exp(-shafts));
}
}
if (fog.lamp_meta.x > 0.5) {
let halo = lamp_halos(ray, distance);
haze += max(vec3(0.0), vec3(1.0) - haze) * halo;
}
return vec4(haze, source.a);
}
@@ -457,24 +682,6 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
mod tests {
use super::*;
#[test]
fn shadow_cache_refresh_is_gated_and_quiet_when_unchanged() {
let mut app = App::new();
app.init_resource::<VideoConfig>()
.init_resource::<StaticGx>()
.init_resource::<WorldLane>()
.insert_resource(FogOverride(Some(0), 2.0))
.add_systems(Update, refresh_streamed_shadows);
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().resource_mut::<FogOverride>().0 = Some(1);
app.world_mut().run_schedule(Update);
assert!(app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().is_changed());
}
#[test]
fn dawn_weather_and_rooms_control_density() {
let noon = density(720.0, 0.0, false);
assert!(density(390.0, 0.0, false) > noon * 2.0);
@@ -484,6 +691,45 @@ mod tests {
assert!(density(390.0, 1.0, true) < noon);
}
#[test]
fn lamp_haze_is_exactly_off_by_day_and_weather_only_strengthens_night() {
assert_eq!(lamp_haze_strength(1.0, 1.0, false), 0.0);
let clear = lamp_haze_strength(-1.0, 0.0, false);
assert!(lamp_haze_strength(-1.0, 1.0, false) > clear * 2.0);
assert!(lamp_haze_strength(-1.0, 0.0, true) < clear);
}
#[test]
fn lamp_picker_keeps_the_nearest_across_both_lanes_and_tier_limit() {
let mut points: Vec<_> = (1..=40)
.rev()
.map(|distance| ResolvedPointLight {
position: Vec3::X * distance as f32,
range: 48.0,
color: Vec3::new(0.8, 0.4, 0.1),
lane: 0.0,
})
.collect();
// Interior and exterior table rows compete by distance; black rows never consume a slot.
points.push(ResolvedPointLight {
position: Vec3::X * 0.25,
range: 48.0,
color: Vec3::ONE,
lane: 8.0,
});
points.push(ResolvedPointLight {
position: Vec3::X * 0.5,
range: 48.0,
color: Vec3::ZERO,
lane: 0.0,
});
let low = select_fog_lamps(&points, Vec3::ZERO, None, LOW_FOG_LAMPS);
assert_eq!(low.count, LOW_FOG_LAMPS);
assert_eq!(low.positions[0], Vec4::new(0.25, 0.0, 0.0, 8.0));
assert_eq!(low.positions[1].x, 1.0);
assert_eq!(low.positions[LOW_FOG_LAMPS - 1].x, 15.0);
assert_eq!(low.colors[0].truncate(), Vec3::ONE);
assert_eq!(low.colors[1].truncate(), Vec3::new(0.8, 0.4, 0.1));
}
#[test]
fn live_tiers_shadow_loss_inactive_camera_and_off() {
let mut app = App::new();
app.init_resource::<VideoConfig>()
@@ -491,7 +737,12 @@ mod tests {
.init_resource::<WorldTime>()
.init_resource::<WeatherState>()
.init_resource::<CameraInteriorClaim>()
.insert_resource(FogOverride(None, 2.0))
.init_resource::<ResolvedPointLights>()
.insert_resource(FogOverride {
fog: None,
lamp: None,
shaft_gain: 2.0,
})
.add_systems(Update, update_fog);
let camera = app
.world_mut()
@@ -538,10 +789,10 @@ mod tests {
app.update();
assert!(app.world().get::<FogView>(camera).is_none());
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = true;
app.world_mut().resource_mut::<FogOverride>().0 = Some(0);
app.world_mut().resource_mut::<FogOverride>().fog = Some(0);
app.update();
assert!(app.world().get::<FogView>(camera).is_none());
app.world_mut().resource_mut::<FogOverride>().0 = None;
app.world_mut().resource_mut::<FogOverride>().fog = None;
app.update();
assert!(app.world().get::<FogView>(camera).is_some());
}
+53 -3
View File
@@ -49,10 +49,34 @@ impl Plugin for WorldShadowPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<CutoutShadowCasterMaterial>::default())
.init_resource::<WorldLane>()
// MONKEY (followups): refresh the static caster cache when streamed geometry arrives.
// This used to live in the volumetric-fog plugin and so only ran with that cvar on.
.add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes))
.add_systems(Last, update_world_shadows.in_set(ShadowSet::Lanes));
}
}
/// MONKEY (followups): the static caster rebuilds on 16 yd of camera drift, so a standing camera
/// never picks up trees and doodads that stream in later. Invalidate it whenever the retained
/// scene's residency changes, while world shadows are on (Classic has them off, so it is inert).
fn refresh_streamed_shadows(
video: Res<VideoConfig>,
gx: Option<Res<StaticGx>>,
mut lane: ResMut<WorldLane>,
mut seen: Local<Option<u64>>,
) {
if !video.world_shadows {
*seen = None;
return;
}
let Some(gx) = gx else { return };
let generation = gx.torch_residency_generation();
if *seen != Some(generation) {
lane.invalidate_static();
*seen = Some(generation);
}
}
/// The alpha-tested twin of [`ShadowCasterMaterial`]: same invisible-in-forward proxy, but its
/// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE casts a
/// leaf-shaped silhouette instead of the solid box a positions-only proxy throws. One instance per
@@ -106,7 +130,7 @@ struct CutoutCaster {
/// The world lane's retained casters.
#[derive(Resource, Default)]
// MONKEY (volumetric fog): allow the fog plugin to invalidate a stale streamed caster cache.
// MONKEY (volumetric fog, followups): the streamed-residency refresh invalidates the cache.
pub(crate) struct WorldLane {
/// The cached static caster — the retained `static_gx` world (trees + buildings), rebuilt on drift.
static_caster: Option<Entity>,
@@ -123,8 +147,7 @@ pub(crate) struct WorldLane {
env_rate: RebuildRate,
}
// MONKEY (volumetric fog): retain ownership of the cache here; the fog plugin decides
// when streamed geometry needs a fresh map, without changing the legacy Off path.
// MONKEY (followups): `refresh_streamed_shadows` decides when streamed geometry needs a fresh map.
impl WorldLane {
pub(crate) fn invalidate_static(&mut self) {
self.static_rebuilt_at = None;
@@ -359,3 +382,30 @@ fn teardown(
// The mesh the gate was pacing is gone — re-arm so a re-enable builds on its first frame.
lane.env_rate.reset();
}
// MONKEY (followups): the refresh must not depend on volumetric fog.
#[cfg(test)]
mod streamed_refresh_tests {
use super::*;
#[test]
fn shadow_cache_refresh_runs_without_volumetric_fog_and_is_quiet_when_unchanged() {
let mut app = App::new();
let mut video = VideoConfig::default();
video.volumetric_fog = 0;
video.world_shadows = false;
app.insert_resource(video)
.init_resource::<StaticGx>()
.init_resource::<WorldLane>()
.add_systems(Update, refresh_streamed_shadows);
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().resource_mut::<VideoConfig>().world_shadows = true;
app.world_mut().run_schedule(Update);
assert!(app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().is_changed());
}
}
+45
View File
@@ -0,0 +1,45 @@
//! MONKEY (skybox): the `zoneSkyboxes` cvar's bridge to [`benilla_world::skybox::ZoneSkyboxes`].
//! `WOW_ZONE_SKYBOXES=0|1` overrides it for the session, which is how a hermetic capture (no config
//! file, registry defaults) turns the lane on.
use bevy::prelude::*;
/// The session override, read once.
#[derive(Resource, Clone, Copy)]
struct ZoneSkyboxOverride(Option<bool>);
pub(crate) struct ZoneSkyboxPlugin;
impl Plugin for ZoneSkyboxPlugin {
fn build(&self, app: &mut App) {
let forced = std::env::var("WOW_ZONE_SKYBOXES")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.map(|v| v != 0);
app.insert_resource(ZoneSkyboxOverride(forced))
.add_observer(on_cvar)
.add_systems(Startup, apply_override);
}
}
fn apply_override(
forced: Res<ZoneSkyboxOverride>,
zone: Option<ResMut<benilla_world::skybox::ZoneSkyboxes>>,
) {
if let (Some(on), Some(mut zone)) = (forced.0, zone) {
zone.set_if_neq(benilla_world::skybox::ZoneSkyboxes(on));
}
}
fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
forced: Res<ZoneSkyboxOverride>,
zone: Option<ResMut<benilla_world::skybox::ZoneSkyboxes>>,
) {
if !ev.is("zoneSkyboxes") || forced.0.is_some() {
return;
}
if let Some(mut zone) = zone {
zone.set_if_neq(benilla_world::skybox::ZoneSkyboxes(ev.flag()));
}
}
+29
View File
@@ -29,6 +29,17 @@ pub fn register_shaders(app: &mut App) {
// (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is
// the ONE place the realtime directional-shadow term lives; the receivers just call into it.
load_shader_library!(app, "shaders/shadow_hook.wgsl");
// MONKEY (p0 MonkeyFrame): `benilla::monkey_frame`, the struct every `WowLight` mirror declares
// after its point table (LIGHTING.md "Invariants").
load_shader_library!(app, "shaders/monkey_frame.wgsl");
// MONKEY (p0 fog hook): `benilla::fog_hook`, the one distance-fog law all seven receivers call.
load_shader_library!(app, "shaders/fog_hook.wgsl");
// MONKEY (post): shared opt-in HDR multipliers; tier 0 is an exact identity.
load_shader_library!(app, "shaders/emissive_hook.wgsl");
// MONKEY (wind): `benilla::wind_hook`, shared vertex displacement for grass and foliage.
load_shader_library!(app, "shaders/wind_hook.wgsl");
// MONKEY (wet): `benilla::wet_hook`, the rain-on-surfaces look terrain/model/static_gx call.
load_shader_library!(app, "shaders/wet_hook.wgsl");
// MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md).
load_shader_library!(app, "shaders/enhanced_water.wgsl");
}
@@ -71,6 +82,8 @@ pub struct WowModelKey {
/// The WMO-skybox lane (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), a key axis
/// because only this model's depth is pinned.
sky_depth: bool,
/// MONKEY (skybox): a second texture stage (`stage1.x`), the `WOW_STAGE1` shader def.
stage1: bool,
// The WMO batch order is deliberately not a key axis (a pipeline per batch index stalls a
// city's first sight): the file-order layering (`0x6b4f10`/`0x6b5190`) rides `sun_scale.y`.
}
@@ -89,6 +102,7 @@ impl From<&WowModelExt> for WowModelKey {
zfill: markers & 0x200 != 0,
far_side: markers & 0x800 != 0,
sky_depth: markers & 0x2000 != 0,
stage1: e.stage1.x > 0.5,
}
}
}
@@ -122,6 +136,14 @@ pub struct WowModelExt {
/// tint, `z` = the texture-transform affine, `w` = the UI tile's cell clip.
#[uniform(100)]
pub anim_slots: Vec4,
/// MONKEY (skybox): a two-texture batch's stage 1: `x` = 0 none, 1 Mod, 2 Mod2x; `z`/`w` = the
/// `matanim` rows of its translation and affine, read on UV set B. `0` on every other batch.
#[uniform(100)]
pub stage1: Vec4,
/// MONKEY (skybox): stage 1's texture; `None` (the fallback image) unless `stage1.x > 0`.
#[texture(94)]
#[sampler(95)]
pub stage1_texture: Option<Handle<Image>>,
/// The shared global light (`lighting::global_light`), updated in place once a frame; the
/// vertex stage reads its point-light table, since Bevy's clusterable lights are fragment-only.
#[storage(90, read_only, buffer, visibility(vertex, fragment))]
@@ -254,6 +276,13 @@ impl MaterialExtension for WowModelExt {
ds.bias.constant = 0;
}
}
// MONKEY (skybox): the second texture stage compiles only where a batch has one.
if key.bind_group_data.stage1 {
descriptor.vertex.shader_defs.push("WOW_STAGE1".into());
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_STAGE1".into());
}
}
// The skybox pins clip z to 0, reverse-Z far, in the vertex stage; its bias is sort-only.
if key.bind_group_data.sky_depth {
descriptor.vertex.shader_defs.push("WOW_SKY_DEPTH".into());
@@ -0,0 +1,45 @@
#define_import_path benilla::emissive_hook
// MONKEY (post): the opt-in HDR seam shared by windows, additive models/effects and magma.
// `light_diffuse.w` was the reference pack's unused clamp marker (always 1); the CPU now adds
// the `bloom` tier, so 1 remains a byte-identical Off sentinel without growing the light blob.
const EMISSIVE_WMO_WINDOW: u32 = 0u;
const EMISSIVE_M2_ADD: u32 = 1u;
const EMISSIVE_PARTICLE_ADD: u32 = 2u;
const EMISSIVE_MAGMA: u32 = 3u;
fn tier(packed_lane: f32) -> f32 {
return clamp(round(packed_lane - 1.0), 0.0, 2.0);
}
fn emissive_boost(
color: vec3<f32>,
kind: u32,
packed_lane: f32,
authored_weight: f32,
) -> vec3<f32> {
let t = tier(packed_lane);
if (t < 0.5) {
return color;
}
let high = step(1.5, t);
var gain = mix(1.45, 1.85, high);
if (kind == EMISSIVE_WMO_WINDOW) { gain = mix(1.70, 2.35, high); }
if (kind == EMISSIVE_MAGMA) { gain = mix(1.80, 2.60, high); }
// MONKEY (fix-post): boost only above a knee, so the base image keeps its look (below 0.6
// untouched) and the excess goes to the bloom extract; at 1.0 the result equals the old gain.
// A flat multiply saturated every additive spell and the lava to white once the combine clamps.
// A window's gain is applied to its emissive light term alone (static_gx), so it stays flat.
if (kind == EMISSIVE_WMO_WINDOW) {
return color * mix(1.0, gain, clamp(authored_weight, 0.0, 1.0));
}
let knee = 0.6;
let over = max(color - vec3<f32>(knee), vec3<f32>(0.0));
return color + over * ((gain - 1.0) * clamp(authored_weight, 0.0, 1.0) / (1.0 - knee));
}
// Distant magma keeps part of its emissive body instead of converging completely to fog.
fn magma_fog_resist(packed_lane: f32) -> f32 {
let t = tier(packed_lane);
return select(0.0, mix(0.32, 0.48, step(1.5, t)), t > 0.5);
}
@@ -1,11 +1,14 @@
#define_import_path benilla::enhanced_water
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-experimental-water, shaders/Surface.ps.hlsl, sea/Spectrum.*, sea/Ocean.*.
// ENHANCED WATER - the optional water module (Video options -> Water Quality; see WATER.md).
//
// Credits: original project WarcraftXL (`wxl-experimental-water`, https://github.com/WarcraftXL),
// author iThorgrim. Its author permits reuse of that code in this module provided the author and
// the original project are named; this notice is that attribution and must stay with the module.
// A block that ports WarcraftXL code says so where it stands and is listed in WATER.md.
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-experimental-water, shaders/Surface.ps.hlsl, render/Refraction.cpp, render/Noise.cpp, sea/Shore.hpp.
// MONKEY (p0 credits): every WarcraftXL-derived file is listed in THIRD-PARTY.md (benilla root).
//
// This file is the whole module on the shader side. `liquid.wgsl` (upstream's liquid renderer)
// carries three hooks only: the import, `water_swell` in its vertex stage, and a branch at the top
@@ -20,13 +23,19 @@
// 106 the opaque scene COLOUR, copied beside the depth: what the refraction looks through
#import bevy_pbr::mesh_view_bindings::{view, globals}
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (rainshelter): the shelter-grid lookup the surface receivers share.
#import benilla::wet_hook
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d<f32>;
struct WaterParams {
// x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18,
// WMO pools 0.12); z = the pinned capture time (WOW_CAPTURE_WATER_T); w = clock enable.
// WMO exterior 0.26, WMO interior 0.08); z = pinned capture time; w = clock enable.
mode: vec4<f32>,
// x = which renderer (0 ADT MCLQ, 1 WMO exterior, 2 WMO interior); y = ocean; z = fullbright;
// w = this surface may take the WMO INTERIOR fog block (upstream's kind.z).
@@ -37,26 +46,46 @@ struct WaterParams {
};
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var<uniform> water: WaterParams;
// The shared global light (`lighting::global_light`), rows 0-20 + the point-light table. Mirrors
// the buffer layout the terrain/model shaders read; keep it in step with theirs.
struct WaterLight {
light_ambient: vec4<f32>, // 0
light_diffuse: vec4<f32>, // 1
light_sun: vec4<f32>, // 2 xyz = sun TRAVEL dir (to-light = -xyz)
light_spec: vec4<f32>, // 3
fog_color: vec4<f32>, // 4
fog_params: vec4<f32>, // 5
_sh: array<vec4<f32>, 6>, // 6-11
_sh_c16: vec4<f32>, // 12
water_river: array<vec4<f32>, 2>, // 13-14
water_ocean: array<vec4<f32>, 2>, // 15-16
_grade: vec4<f32>, // 17
wmo_fog_color: vec4<f32>, // 18
wmo_fog_params: vec4<f32>, // 19
point_count: vec4<f32>, // 20 live count + point-light controls
points: array<vec4<f32>, 512>, // 21+ position/range, colour/lane pairs
};
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var<storage, read> water_light: WaterLight;
// MONKEY (water): the shared global light (`lighting::global_light`), rows 0-20 + the point-light
// table, is deliberately a row view of the exact terrain/model ABI. Bevy's composable-module
// writer rejects a second partial struct view because its unused members require substitution.
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var<storage, read> water_light: array<vec4<f32>>;
// MONKEY (integration): the p0 MonkeyFrame block as seen through the water lane's row view — it
// starts right after the 21 fixed rows + the 512-row point table (row 533; buffer 8528 -> 8784 B).
const MONKEY_ROW: u32 = 533u;
fn water_monkey() -> monkey_frame::MonkeyFrame {
var m: monkey_frame::MonkeyFrame;
m.fog_a = water_light[MONKEY_ROW + 0u];
m.fog_b = water_light[MONKEY_ROW + 1u];
m.fog_c = water_light[MONKEY_ROW + 2u];
m.fog_d = water_light[MONKEY_ROW + 3u];
m.wind_a = water_light[MONKEY_ROW + 4u];
m.wind_b = water_light[MONKEY_ROW + 5u];
m.wet_a = water_light[MONKEY_ROW + 6u];
m.misc = water_light[MONKEY_ROW + 7u];
for (var i = 0u; i < 8u; i++) { m.benders[i] = water_light[MONKEY_ROW + 8u + i]; }
return m;
}
// MONKEY (rainshelter): the rain-occlusion grid right after the MonkeyFrame block
// (`weather/shelter.rs`): two header rows, then the packed cell words, four to a row.
const SHELTER_ROW: u32 = 549u;
fn water_shelter_word(i: u32) -> u32 {
return bitcast<u32>(water_light[SHELTER_ROW + 2u + i / 4u][i % 4u]);
}
// 1 open .. 0 under a bridge or a roof.
fn water_rain_open(world_pos: vec3<f32>) -> f32 {
let hdr = water_light[SHELTER_ROW];
let cfg = water_light[SHELTER_ROW + 1u];
let st = wet_hook::shelter_taps(world_pos, hdr, cfg);
if (st.live <= 0.0) {
return 1.0;
}
return 1.0 - wet_hook::shelter_amount(st, water_shelter_word(st.idx.x),
water_shelter_word(st.idx.y), water_shelter_word(st.idx.z), water_shelter_word(st.idx.w),
world_pos.y, cfg);
}
// What the fragment stage hands over: the three varyings this module reads.
struct WaterFragment {
@@ -64,6 +93,8 @@ struct WaterFragment {
world_position: vec4<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.
colour: vec4<f32>,
// Upstream's per-surface room-fog lane (MeshTag bit 30), to pick the same fog block it would.
room_fog: u32,
};
@@ -74,16 +105,17 @@ struct WaterFragment {
// scenery come out of the scene copy, which the terrain and model shaders have already fogged
// (fogging them again made shallow water foggier than the dry sand beside it).
fn water_fog(world_pos: vec3<f32>, room_fog: u32) -> vec4<f32> {
var colour = water_light.fog_color;
var span = water_light.fog_params.xy;
var colour = water_light[4];
var span = water_light[5].xy;
if water.lane.w > 0.5 && room_fog != 0u {
colour = water_light.wmo_fog_color;
span = water_light.wmo_fog_params.xy;
colour = water_light[18];
span = water_light[19].xy;
}
if colour.w <= 0.5 { return vec4<f32>(colour.rgb, 1.0); }
let eye_z = -(view.view_from_world * vec4<f32>(world_pos, 1.0)).z;
let factor = clamp((span.y - eye_z) / max(span.y - span.x, 0.001), 0.0, 1.0);
return vec4<f32>(colour.rgb, factor);
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl), as a factor; classic is bit-identical.
return fog_hook::fog_sample(colour.rgb, span, eye_z, world_pos, view.world_position, true,
water_monkey());
}
// True where this surface takes the Enhanced path: water (not magma/slime), a tier above
@@ -98,21 +130,25 @@ fn water_time() -> f32 {
return globals.time;
}
// Vertex lift for the ocean's long swell (0 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) -> 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> {
if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 {
return water_waves(xz, water_time(), 0.0, 0.0, swell_shore_fade(authored_depth), true).x;
return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz;
}
return 0.0;
return vec3<f32>(0.0);
}
// Enhanced is entirely analytic: height and its exact x/z derivatives, in yards.
// Direction is radians from world +X toward +Z; phase speed follows deep-water dispersion.
// Two mesh-resolvable long waves sum to at most 0.34 yd before energy/shore attenuation.
// MONKEY (water): one temporary wind bearing until the weather lane supplies shared wind.
// Every wind-aligned water term derives from this constant rather than owning a second heading.
const WATER_WIND_DIR: f32 = 0.35;
const WATER_GERSTNER_CHOP: f32 = 2.4;
const WATER_WAVES: array<vec4<f32>, 8> = array<vec4<f32>, 8>(
// direction, wavelength, amplitude, phase offset
vec4<f32>(0.35, 18.0, 0.200, 0.0),
vec4<f32>(WATER_WIND_DIR, 18.0, 0.200, 0.0),
vec4<f32>(0.80, 12.8, 0.140, 1.7),
vec4<f32>(-0.18, 9.5, 0.090, 3.1),
vec4<f32>(0.52, 4.8, 0.055, 0.8),
@@ -151,7 +187,7 @@ const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash ru
const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent
const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight)
const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests
const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing
const SHORE_WIND_DIR: f32 = WATER_WIND_DIR;
// Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so
// every one of these is gated behind a noise breakup and a depth window; the numbers are the
@@ -159,6 +195,7 @@ const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell
const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid
const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading
const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace
const FOAM_WHITECAP: f32 = 0.68; // High-only open-sea crest fold
const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum
fn swell_shore_fade(depth: f32) -> f32 {
@@ -171,10 +208,10 @@ fn water_waves(p: vec2<f32>, t: f32, distance: f32, footprint: f32,
let energy = clamp(water.mode.y, 0.0, 1.0);
let tempo = mix(0.4, 1.0, sqrt(energy));
let inland = water.lane.y < 0.5 || water.lane.x > 0.5;
// Inland ADT water drifts gently (a constant vector: a rigid translation, never a shear).
// WMO pools have no drift at all.
// MONKEY (water): inland ADT and exterior WMO water drift gently (a constant vector: a rigid
// translation, never a shear). True interior pools stay still.
var ripple_p = p;
if inland && water.lane.x < 0.5 {
if inland && water.lane.x < 1.5 {
ripple_p -= t * vec2<f32>(0.06, 0.025);
}
var result = vec3<f32>(0.0);
@@ -196,6 +233,98 @@ fn water_waves(p: vec2<f32>, t: f32, distance: f32, footprint: f32,
return result;
}
// MONKEY (water): Ported from WarcraftXL's Gerstner displacement and fold-driven gFoam path.
// xyz is horizontal/vertical/horizontal displacement; w is 1 - det(J), the amount the horizontal
// map has compressed toward a fold. Only the two mesh-resolvable long bands move geometry.
fn water_gerstner(p: vec2<f32>, t: f32, shore: f32) -> vec4<f32> {
let energy = clamp(water.mode.y, 0.0, 1.0);
let tempo = mix(0.4, 1.0, sqrt(energy));
var displacement = vec3<f32>(0.0);
var jacobian_xx = 1.0;
var jacobian_xz = 0.0;
var jacobian_zx = 0.0;
var jacobian_zz = 1.0;
for (var i = 0u; i < 2u; i += 1u) {
let wave = WATER_WAVES[i];
let direction = vec2<f32>(cos(wave.x), sin(wave.x));
let k = 6.2831853 / wave.y;
let speed = sqrt(10.72 / k);
let phase = k * (dot(direction, p) - speed * tempo * t) + wave.w;
let amplitude = wave.z * energy * shore;
let horizontal = WATER_GERSTNER_CHOP * amplitude;
displacement += vec3<f32>(direction.x * horizontal * cos(phase),
amplitude * sin(phase), direction.y * horizontal * cos(phase));
let compression = horizontal * k * sin(phase);
jacobian_xx -= compression * direction.x * direction.x;
jacobian_xz -= compression * direction.x * direction.y;
jacobian_zx -= compression * direction.y * direction.x;
jacobian_zz -= compression * direction.y * direction.y;
}
let fold = max(1.0 - (jacobian_xx * jacobian_zz - jacobian_xz * jacobian_zx), 0.0);
return vec4<f32>(displacement, fold);
}
// MONKEY (water): seam for the later wet-weather lane. Its result is a height-gradient
// perturbation in world XZ; zero preserves today's image until that lane supplies rain data.
// MONKEY (wet): rain rings. Each layer is a grid of cells, one drop per cell at a hashed spot and
// phase; a drop lands when its cell hash is under the rain rate, and its ring expands and fades
// over one period. The ring's slope profile is a Gaussian-windowed cosine across the front. The
// clock is MonkeyFrame `wet_a.z` (wraps at 1000 s; the rates are multiples of 1/1000, so the wrap is
// seamless). Dry (`wet_a.x == 0`), WMO interior pools, interior-fog rooms, and pixels too far or too
// coarse to hold a ring return zero.
fn ripple_hash(p: vec2<f32>) -> vec2<f32> {
var q = fract(vec3<f32>(p.xyx) * vec3<f32>(0.1031, 0.1030, 0.0973));
q += dot(q, q.yzx + 33.33);
return fract((q.xx + q.yz) * q.zy);
}
fn rain_ripple_normal(world_pos: vec3<f32>, footprint: f32, distance: f32, room: bool) -> vec2<f32> {
let wet_row = water_monkey().wet_a;
let rain = clamp(wet_row.x, 0.0, 1.0);
if rain <= 0.0 || water.lane.x > 1.5 || room {
return vec2<f32>(0.0);
}
let world_xz = world_pos.xz;
// MONKEY (rainshelter): no rings under a bridge or a roof.
let reach = (1.0 - smoothstep(35.0, 60.0, distance)) * (1.0 - smoothstep(0.05, 0.14, footprint))
* water_rain_open(world_pos);
if reach <= 0.0 {
return vec2<f32>(0.0);
}
var grad = vec2<f32>(0.0);
for (var layer = 0; layer < 2; layer += 1) {
let c = select(0.9, 1.45, layer == 1);
let rate = select(0.8, 0.65, layer == 1);
let base = floor(world_xz / c);
for (var j = -1; j <= 1; j += 1) {
for (var i = -1; i <= 1; i += 1) {
let cell = base + vec2<f32>(f32(i), f32(j));
let seed = cell + vec2<f32>(f32(layer) * 57.0, f32(layer) * 113.0);
let h = ripple_hash(seed);
let h_alt = ripple_hash(seed + 19.19);
// Each period re-rolls whether this cell rains, so the pattern does not repeat.
let cycle = wet_row.z * rate + h.x;
// MONKEY (fix-wet): `% 50` keeps the roll sequence continuous across the 1000 s
// clock wrap (cycle drops by 800 / 650, both multiples of 50).
let roll = ripple_hash(seed + (floor(cycle) % 50.0) * 7.31).x;
if roll > rain * 0.85 {
continue;
}
let phase = fract(cycle);
let centre = (cell + 0.5 + (h_alt - 0.5) * 0.6) * c;
let to_p = world_xz - centre;
let d = length(to_p);
let radius = phase * 0.6 * c;
let x = d - radius;
let width = 0.05 + 0.07 * phase;
let fade = (1.0 - phase) * (1.0 - phase) * smoothstep(0.0, 0.08, phase);
let slope = fade * exp(-(x * x) / (width * width)) * cos(x * 3.1415927 / width);
grad += to_p / max(d, 1e-4) * slope;
}
}
}
return grad * (0.7 + 0.5 * rain) * reach;
}
fn foam_hash(p_in: vec2<f32>) -> f32 {
// MONKEY (foam noise): world coordinates here are ~1e4 yd and are scaled further before they
// arrive, so `p * 0.1031` sat near 2e3 where an f32 keeps only ~13 fraction bits - the hash
@@ -260,8 +389,8 @@ fn caustic_point(cell: vec2<f32>) -> vec2<f32> {
fn caustic_edges(p: vec2<f32>, t: f32) -> f32 {
let cell = floor(p);
let f = fract(p);
var d1 = 8.0;
var d2 = 8.0;
var nearest_distance = 8.0;
var next_distance = 8.0;
for (var j = -1; j <= 1; j += 1) {
for (var i = -1; i <= 1; i += 1) {
let g = vec2<f32>(f32(i), f32(j));
@@ -270,10 +399,15 @@ fn caustic_edges(p: vec2<f32>, t: f32) -> f32 {
let o = 0.5 + 0.38 * sin(vec2<f32>(t * 0.83, t * 0.71) + 6.2831853 * h);
let r = g + o - f;
let d = dot(r, r);
if d < d1 { d2 = d1; d1 = d; } else if d < d2 { d2 = d; }
if d < nearest_distance {
next_distance = nearest_distance;
nearest_distance = d;
} else if d < next_distance {
next_distance = d;
}
}
}
return sqrt(d2) - sqrt(d1);
return sqrt(next_distance) - sqrt(nearest_distance);
}
// Two webs at incommensurate scales and rates: the fine one draws the lines, the coarse one sets
@@ -451,7 +585,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// `+ t/period` with a crest-count that RISES with depth ⇒ a crest of fixed phase slides to
// shallower water as time runs: the train travels shoreward.
let shore_phase = 6.2831853 * (shore_g + t / SHORE_PERIOD);
let shore_phase2 = 6.2831853 * (1.9 * shore_g + t / (SHORE_PERIOD * 0.62)) + 2.1;
let shore_phase_secondary = 6.2831853 * (1.9 * shore_g + t / (SHORE_PERIOD * 0.62)) + 2.1;
// Steepness rises as it shoals (3.5 → 0.75 yd), collapses into the break below ~0.35 yd, and
// is gone past the band's offshore edge.
let shoal = smoothstep(3.5, 0.75, vertical_depth);
@@ -464,7 +598,11 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// 148 yd): where the bed lies well below what the pixel sees, the pixel is an OBJECT, and an
// object gets the thin wet-edge line only. The byte is a FLOOR (1.72 per yd), so on a real beach the
// authored bed is never deeper than the scene by more than interpolation error: allow 0.5 yd.
let bed_yd = clamp(in.depth, 0.0, 1.0) * 148.0;
// MONKEY (water): MLIQ carries an opacity-ramp byte, not ADT's authored bed-depth byte. For a
// WMO pool the only actual column measurement is the reconstructed scene depth, so never turn
// its opacity into 148 yards of fictitious water.
let bed_yd = select(vertical_depth, clamp(in.depth, 0.0, 1.0) * 148.0,
water.lane.x < 0.5);
let on_bed = 1.0 - smoothstep(0.5, 1.0, bed_yd - vertical_depth);
let shore_gain = select(0.0, shoal * shore_collapse * shore_offshore_fade * on_bed, ocean_mesh)
* energy;
@@ -472,12 +610,17 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// and a wavenumber, so a gentle beach and a steep one roll with the same visible strength
// instead of one washing out and the other exploding.
let shore_grad = SHORE_TILT * shore_gain
* (cos(shore_phase) + 0.45 * cos(shore_phase2)) * offshore;
* (cos(shore_phase) + 0.45 * cos(shore_phase_secondary)) * offshore;
let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh);
let wave = water_waves(p, t, length(eye_pos), footprint, shore, false);
var open_sea_fold = 0.0;
if water.mode.x > 1.5 && ocean_mesh {
open_sea_fold = water_gerstner(p, t, shore).w;
}
// One surface gradient: the procedural bands and the shore break.
let surf_grad = wave.yz + shore_grad;
let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xyz, footprint,
length(eye_pos), in.room_fog != 0u && water.lane.w > 0.5);
var n = normalize(vec3<f32>(-surf_grad.x, 1.0, -surf_grad.y));
if dot(n, to_view) < 0.0 { n = -n; }
@@ -507,14 +650,22 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
clamp(inland_row.b, inland_row.g * 0.82, inland_row.g * 1.15));
body = mix(clear_tint, inland_deep, smoothstep(0.0, 5.0, vertical_depth));
}
let to_light = -normalize(water_light.light_sun.xyz);
// MONKEY (water): the reference interior renderer carries MOMT.diffColor on the pool vertex.
// Under a roof that authored colour replaces the outdoor Light.dbc/sky-derived body palette.
if water.lane.x > 1.5 {
body = in.colour.rgb;
}
let to_light = -normalize(water_light[2].xyz);
// MONKEY (water body): water is lit by the light it SCATTERS, not only by N.L on its skin, so
// the lit body keeps a high floor (ambient x 1.35, N.L floor 0.5). With the old 0.25 floor a
// noon lake rendered near-black navy where the owner reference is a bright teal-blue; night
// still darkens because both rows do.
let lighting = clamp(water_light.light_ambient.rgb * 1.35 + water_light.light_diffuse.rgb
let lighting = clamp(water_light[0].rgb * 1.35 + water_light[1].rgb
* max(dot(n, to_light), 0.5), vec3<f32>(0.0), vec3<f32>(1.0));
var rgb = body * lighting;
if water.lane.x > 1.5 {
rgb = body;
}
// MONKEY (shore waves): the shore train is shown mainly as a CONTINUOUS crest brightening, not as
// a normal tilt. Its direction comes from the screen-space gradient of the bed depth, and the
// terrain is flat triangles, so that direction jumps at every triangle edge: at SHORE_TILT 0.30
@@ -522,7 +673,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// it9-water-beach-top). The PHASE is a function of the depth itself and is continuous, so a term
// driven by the phase alone cannot facet. The tilt stays at 0.09 for a little specular life.
let shore_crest = pow(max(sin(shore_phase), 0.0), 2.0)
+ 0.45 * pow(max(sin(shore_phase2), 0.0), 2.0);
+ 0.45 * pow(max(sin(shore_phase_secondary), 0.0), 2.0);
rgb += shore_gain * shore_crest * 0.13 * lighting * vec3<f32>(0.72, 0.95, 0.90);
if !ocean_mesh {
// Keep the zone's green-blue absorption under warm dusk illumination.
@@ -537,9 +688,14 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let reflected = reflect(-to_view, reflection_n);
let sky_linear = mix(water.sky_horizon.rgb, water.sky_zenith.rgb, clamp(reflected.y * 1.4, 0.0, 1.0));
// Linear reflection interpolation, then conversion to the world's gamma blend/fog lane.
let sky = select(12.92 * sky_linear,
var sky = select(12.92 * sky_linear,
1.055 * pow(max(sky_linear, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055,
sky_linear > vec3<f32>(0.0031308));
// MONKEY (water): an interior pool has no sky probe. High may still replace this fallback with
// a real SSR hit, while Enhanced reflects the room's own fog colour and keeps sun glints off.
if water.lane.x > 1.5 {
sky = fog.rgb;
}
var reflectivity = mix(0.06, 0.88, pow(1.0 - max(dot(n, to_view), 0.0), 4.0));
if !ocean_mesh { reflectivity = min(reflectivity, 0.55); }
@@ -572,7 +728,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let web = caustic_web(bed_world.xz + n.xz * 0.35, t)
* (1.0 - smoothstep(0.12, 0.40, footprint * CAUSTIC_SCALE * 4.0));
behind *= 1.0 + web * CAUSTIC_GAIN * sun_focus * caustic_fade
* dot(water_light.light_diffuse.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
* dot(water_light[1].rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
}
// Light that reaches the eye through water has been scattered on the way, so it carries the
// water's own tint as well as the colour extinction leaves: without this, yellow sand under a
@@ -616,7 +772,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
* smoothstep(0.0, 0.1391731, celestial_dir.y);
} else {
rgb += water_light.light_diffuse.rgb
rgb += water_light[1].rgb
* (pow(ndoth, 400.0 / spread) / sqrt(spread)
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
* smoothstep(-0.02, 0.08, celestial_dir.y);
@@ -626,27 +782,30 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// Rank candidates by actual fragment distance, not table order. Only four lights are shaded.
var nearest = array<u32, 4>(256u, 256u, 256u, 256u);
var distances = array<f32, 4>(900.0, 900.0, 900.0, 900.0);
for (var i = 0u; i < min(u32(water_light.point_count.x), 256u); i += 1u) {
let colour = water_light.points[2u * i + 1u];
if colour.w >= 0.5 && water.lane.x < 1.5 { continue; }
let delta = water_light.points[2u * i].xyz - in.world_position.xyz;
let d2 = dot(delta, delta);
if d2 >= distances[3] { continue; }
for (var i = 0u; i < min(u32(water_light[20].x), 256u); i += 1u) {
let colour = water_light[21u + 2u * i + 1u];
// MONKEY (water): outside takes only exterior lights; a true interior pool takes only its
// room fixtures. This keeps street torches on canals and out of pools behind closed walls.
let interior_fixture = colour.w >= 0.5;
if interior_fixture != (water.lane.x > 1.5) { continue; }
let delta = water_light[21u + 2u * i].xyz - in.world_position.xyz;
let squared_distance = dot(delta, delta);
if squared_distance >= distances[3] { continue; }
var slot = 3u;
loop {
if slot == 0u { break; }
if d2 >= distances[slot - 1u] { break; }
if squared_distance >= distances[slot - 1u] { break; }
distances[slot] = distances[slot - 1u];
nearest[slot] = nearest[slot - 1u];
slot -= 1u;
}
distances[slot] = d2;
distances[slot] = squared_distance;
nearest[slot] = i;
}
for (var j = 0u; j < 4u; j += 1u) {
if nearest[j] == 256u { continue; }
let pos = water_light.points[nearest[j] * 2u];
let colour = water_light.points[nearest[j] * 2u + 1u];
let pos = water_light[21u + nearest[j] * 2u];
let colour = water_light[21u + nearest[j] * 2u + 1u];
let delta = pos.xyz - in.world_position.xyz;
let distance = sqrt(max(distances[j], 0.0001));
let light_dir = delta / distance;
@@ -660,6 +819,12 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let noise = 0.7 * foam_noise(p * 0.6 + t * vec2<f32>(0.025, -0.018))
+ 0.3 * foam_noise(p * 1.7 - t * vec2<f32>(0.014, 0.021));
let breakup = smoothstep(0.40, 0.72, noise);
// MONKEY (water): WarcraftXL's white belongs to a connected horizontal fold, not to every
// steep normal. Threshold jitter stops identical crests drawing identical white contours.
let fold_jitter = (noise - 0.5) * 0.04;
let breaking = smoothstep(0.13, 0.25, open_sea_fold + fold_jitter);
let whitecap_alpha = FOAM_WHITECAP * breaking * mix(0.55, 1.0, breakup)
* smoothstep(0.55, 1.0, energy);
// The cached derivatives from the top of the function — same expression as before, one tap.
let depth_gradient = length(vec2<f32>(ddx_depth, ddy_depth))
/ max(length(vec2<f32>(length(ddx_p), length(ddy_p))), 0.001);
@@ -709,8 +874,8 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
FOAM_SWASH * swash_life * swash_band * max(swash_lace, 0.8 * lip), ocean_mesh)
* wall_suppression * energy;
let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha);
let illumination = water_light.light_ambient.rgb + water_light.light_diffuse.rgb;
let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha + whitecap_alpha);
let illumination = water_light[0].rgb + water_light[1].rgb;
let foam_luma = clamp(dot(illumination, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.22, 1.0);
// Matte foam composites on top of reflection with its own coverage.
// The body now carries its own transmission (above), so the surface is opaque bar the very
@@ -0,0 +1,172 @@
#define_import_path benilla::fog_hook
// MONKEY (p0 fog hook): the ONE place the distance fog is computed.
//
// Every receiver — terrain, wow_model, static_gx, liquid, enhanced_water, wow_effect, wdl — calls
// in here instead of hand-rolling the fog law, so a new fog model touches this file only (the
// `shadow_hook.wgsl` pattern). Pure functions: the receivers bind the shared light buffer at
// different slots (material binding 90, group 2 binding 0, group 1 binding 2, binding 105), so
// every value arrives as an argument and this file owns no bindings.
//
// The classic law is the reference's GL_LINEAR fog on PLANAR eye-Z in gamma space (terrain.wgsl
// has the full note): `factor = clamp((end - eye_z) / max(end - start, 0.001), 0, 1)`, then
// `mix(fog_rgb, rgb, factor)`. The expressions below are the ones the receivers carried, in the
// same order, so the classic result is bit-identical.
//
// Extension point for the FOG lane: `fog_sample` branches on `mf.fog_d.x` (`MonkeyFrame.fog_model`,
// 0 classic / 1 modern). The modern arm currently returns the classic result.
//
// MONKEY (fog): the Modern model (cvar `fogModel` 1; CPU half `lighting/fog_model.rs`). `fog_d.x`
// holds the scene fog end when Modern is on (0 = Classic, every path below is then untouched):
// - survival: radial distance, exponential from the zone's start, fitted so 60 % survives at the
// middle of the 1.12 span, closed linearly over the last stretch (from max(start, 0.3·end), the
// modern client's 1/0.7 end fade) so the fog still ends where the zone authored it; optional
// height fog below `fog_a.y` (Bevy Y); `fog_a.w` blends back toward the 1.12 linear ramp.
// - colour (scene colour only, never the black/white/grey blend policies): shifts to the end-fog
// colour `fog_c` by (d / end)³, then leans to the sun-fog colour `fog_b` inside a cubic lobe around
// the sun (cosine threshold `fog_d.y`, direction octahedral in `fog_d.zw`; strength already faded
// for night on the CPU).
// - only spans ending at the scene fog end take it: an interior WMO fog pair stays classic.
// - the WDL hull's own (0, 1) pair asks for the horizon: fully fogged, in the far colour.
// The sky dome (`fog_sky_horizon`) and the volumetric haze (`fog_modern_colour`) call the same
// colour function, so the fogged world, the far hull and the sky's horizon meet in one colour.
#import benilla::monkey_frame::MonkeyFrame
// The classic survival factor: 1 = no fog, 0 = fully fogged.
fn fog_linear(eye_z: f32, span: vec2<f32>) -> f32 {
let denom = max(span.y - span.x, 0.001);
return clamp((span.y - eye_z) / denom, 0.0, 1.0);
}
// MONKEY (fog): exp survival at the span midpoint = 0.6, i.e. k·(end − start) = −2·ln 0.6.
const FOG_EXP_MID: f32 = 1.0216512;
// MONKEY (fog): the unit vector packed by `fog_model::oct_encode`.
fn fog_oct_decode(e: vec2<f32>) -> vec3<f32> {
var v = vec3<f32>(e.x, 1.0 - abs(e.x) - abs(e.y), e.y);
let t = max(-v.y, 0.0);
v.x += select(t, -t, v.x >= 0.0);
v.z += select(t, -t, v.z >= 0.0);
return normalize(v);
}
// MONKEY (fog): whether the Modern model is on for this frame.
fn fog_is_modern(sel_row: vec4<f32>) -> bool {
return sel_row.x > 0.5;
}
// MONKEY (fog): the Modern fog colour seen along unit `dir` at radial distance `d` (yd), from the
// scene fog colour `base`: end-fog shift, then the sun lobe. `d` may be huge (the sky).
fn fog_modern_colour(
base: vec3<f32>,
dir: vec3<f32>,
d: f32,
sun_row: vec4<f32>,
end_row: vec4<f32>,
sel_row: vec4<f32>,
) -> vec3<f32> {
var c = base;
if (end_row.w > 0.0) {
let t = clamp(d / end_row.w, 0.0, 1.0);
c = mix(c, end_row.rgb, t * t * t);
}
if (sun_row.w > 0.0) {
let sun = fog_oct_decode(sel_row.zw);
let a = sel_row.y;
let s = clamp((dot(dir, sun) - a) / max(1.0 - a, 0.001), 0.0, 1.0);
c = mix(c, sun_row.rgb, sun_row.w * s * s * s);
}
return c;
}
// MONKEY (fog): the Modern survival (1 = no fog) at radial distance `d` for the pair `span`.
fn fog_modern_survival(span: vec2<f32>, d: f32, world_y: f32, height_row: vec4<f32>) -> f32 {
let len = max(span.y - span.x, 0.001);
let k = FOG_EXP_MID / len;
let x = max(d - span.x, 0.0);
var expo = exp(-k * x);
if (height_row.x > 0.0) {
// Extra density at and below the plane, fading out `1 / falloff` yd above it.
let hf = clamp(1.0 - (world_y - height_row.y) * height_row.z, 0.0, 1.0);
expo = mix(expo, exp(-k * (1.0 + height_row.x) * x), hf);
}
let fade_from = max(span.x, 0.3 * span.y);
let end_fade = clamp((span.y - d) / max(span.y - fade_from, 0.001), 0.0, 1.0);
let linear = clamp((span.y - d) / len, 0.0, 1.0);
return mix(min(expo, end_fade), linear, clamp(height_row.w, 0.0, 1.0));
}
// MONKEY (fog): the sky dome's low band under Modern. `col` is the dome's own colour at `dir`,
// `horizon_rgb` the colour it uses at 0° (the scene fog colour); the far fog colour along `dir`
// replaces the horizon and eases into the dome's own gradient by 15° of elevation (a mix, never an
// offset, so an already warm dusk ring is not pushed past itself).
// Classic returns `col` untouched.
fn fog_sky_horizon(
col: vec3<f32>,
horizon_rgb: vec3<f32>,
dir: vec3<f32>,
sun_row: vec4<f32>,
end_row: vec4<f32>,
sel_row: vec4<f32>,
) -> vec3<f32> {
if (!fog_is_modern(sel_row)) {
return col;
}
let far = fog_modern_colour(horizon_rgb, dir, 1.0e9, sun_row, end_row, sel_row);
let elev = asin(clamp(dir.y, -1.0, 1.0));
let w = 1.0 - smoothstep(0.0, 0.26179939, elev);
return mix(col, far, w);
}
// The fog a surface stands in: rgb = the fog colour, w = how much of the surface survives (1 = no
// fog). `fog_rgb` is the colour the caller's policy picked; `scene_colour` is true when that is the
// scene's own fog colour (false for the forced black/white/grey policies of additive, Mod and Mod2x
// batches, which a new model may attenuate but must not recolour). `span` = (start, end) yd,
// `eye_z` = planar eye depth (yd), `world_pos` / `camera_pos` in Bevy world space.
fn fog_sample(
fog_rgb: vec3<f32>,
span: vec2<f32>,
eye_z: f32,
world_pos: vec3<f32>,
camera_pos: vec3<f32>,
scene_colour: bool,
mf: MonkeyFrame,
) -> vec4<f32> {
let classic = vec4<f32>(fog_rgb, fog_linear(eye_z, span));
if (mf.fog_d.x > 0.5) {
// MONKEY (fog): the Modern model (header).
let delta = world_pos - camera_pos;
let d = length(delta);
let dir = delta / max(d, 0.0001);
if (span.x == 0.0 && span.y == 1.0) {
// The WDL hull's own pair: the horizon, fully fogged in the far colour.
return vec4<f32>(fog_modern_colour(fog_rgb, dir, d, mf.fog_b, mf.fog_c, mf.fog_d), 0.0);
}
if (abs(span.y - mf.fog_d.x) > 0.01) {
// Not the scene fog (an interior WMO pair): classic.
return classic;
}
var rgb = fog_rgb;
if (scene_colour) {
rgb = fog_modern_colour(fog_rgb, dir, d, mf.fog_b, mf.fog_c, mf.fog_d);
}
return vec4<f32>(rgb, fog_modern_survival(span, d, world_pos.y, mf.fog_a));
}
return classic;
}
// Fog `rgb` in place: `mix(fog colour, rgb, survival)`. Same arguments as `fog_sample`.
fn apply_fog(
rgb: vec3<f32>,
fog_rgb: vec3<f32>,
span: vec2<f32>,
eye_z: f32,
world_pos: vec3<f32>,
camera_pos: vec3<f32>,
scene_colour: bool,
mf: MonkeyFrame,
) -> vec3<f32> {
let f = fog_sample(fog_rgb, span, eye_z, world_pos, camera_pos, scene_colour, mf);
return mix(f.rgb, rgb, f.w);
}
+24 -7
View File
@@ -30,6 +30,12 @@
}
// MONKEY (enhanced water): the optional water module - see enhanced_water.wgsl and WATER.md.
#import benilla::enhanced_water::{water_active, water_swell, enhanced_water, WaterFragment}
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// MONKEY (post): shared tier-gated HDR emission and magma fog resistance.
#import benilla::emissive_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
@@ -60,6 +66,10 @@ struct WowLight {
_grade: vec4<f32>, // 17
wmo_fog_color: vec4<f32>, // 18 rgb = interior fog (block 2); w = enable
wmo_fog_params: vec4<f32>, // 19 x = start yd; y = end yd
_point_count: vec4<f32>, // 20 point-light count, unread here
_points: array<vec4<f32>, 512>, // 21+ point-light table, unread here
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -126,9 +136,9 @@ fn apply_fog(rgb: vec3<f32>, world_pos: vec3<f32>, room_fog: u32) -> vec3<f32> {
return rgb;
}
let eye_z = -(view.view_from_world * vec4<f32>(world_pos, 1.0)).z;
let denom = max(fog_span.y - fog_span.x, 0.001);
let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
return mix(fog_color.xyz, rgb, factor);
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
return fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, world_pos, view.world_position,
true, wow_light.monkey);
}
@vertex
@@ -137,8 +147,8 @@ 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));
// MONKEY (enhanced water): the ocean's long swell; 0 on Classic and on every other surface.
out.world_position.y += water_swell(out.world_position.xz, in.uv_b.x);
// 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.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;
@@ -209,7 +219,9 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// Returned as-is: the module fogs its own surface terms (the scene it shows through is
// already fogged).
return enhanced_water(
WaterFragment(in.clip_position, in.world_position, in.depth, in.room_fog),
// 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),
shallow_enhanced, deep_enhanced);
}
@@ -227,7 +239,12 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// Magma/slime: the sheet is the opaque body, unmodulated (the ADT vertex has no colour, the WMO
// one is `0xffffffff`) and unlit (lighting off on both paths), but fogged.
if (w.kind.x > 0.5) {
return vec4<f32>(apply_fog(detail.rgb, in.world_position.xyz, in.room_fog), 1.0);
// MONKEY (post): distant magma keeps some authored body and crosses 1.0 for bloom.
let fogged = apply_fog(detail.rgb, in.world_position.xyz, in.room_fog);
let hot = emissive_hook::emissive_boost(
detail.rgb, emissive_hook::EMISSIVE_MAGMA, wow_light.light_diffuse.w, 1.0);
return vec4<f32>(mix(fogged, hot,
emissive_hook::magma_fog_resist(wow_light.light_diffuse.w)), 1.0);
}
// V, from the authored depth byte CPU-side: clamp(byte/42) on river/lake (LUT `0xc81768`,
@@ -0,0 +1,23 @@
#define_import_path benilla::monkey_frame
// MONKEY (p0 MonkeyFrame): the graphics programme's per-frame block, 16 rows (256 B) appended to
// the shared light buffer right after the point-light table (`lighting::global_light`, packed from
// the `MonkeyFrame` resource in `lighting/monkey_frame.rs`). Every receiver's `WowLight` mirror
// declares it as `monkey: monkey_frame::MonkeyFrame` after `points`, so the offsets of everything
// before it never move. All zero = no visual change: every reader treats zero as "feature off".
//
// Coordinates are Bevy world space (Y up, 1 unit = 1 yd), the space of the receivers'
// `world_position`: WoW `(x, y, z)` is Bevy `(-y, z, -x)` (`benilla_assets::coords::wow_to_bevy`).
// Member names carry no trailing digit: naga_oil refuses composable-module identifiers that its
// namer would rewrite, so the rows are `fog_a..fog_d`, `wind_a/b`, `wet_a`.
struct MonkeyFrame {
fog_a: vec4<f32>, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend
fog_b: vec4<f32>, // sun_fog_rgb (gamma 0..1), sun_fog_strength
fog_c: vec4<f32>, // end_fog_rgb (gamma 0..1), end_fog_distance (yd)
fog_d: vec4<f32>, // 0 classic or scene fog end for Modern, sun_fog_angle, sun direction octahedral xy
wind_a: vec4<f32>, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), base_heading (rad, fixed profile heading), gust 0..1
wind_b: vec4<f32>, // travel (yd, integrated speed, wrapped at 4096), sway_strength, grass_strength, tree_strength
wet_a: vec4<f32>, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
misc: vec4<f32>, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), 0
benders: array<vec4<f32>, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live
}
@@ -388,25 +388,25 @@ fn torch_map_shadow(
if (dot(normal, normal) > 1e-12) {
let a = select(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 0.0, 1.0),
abs(normal.x) > abs(normal.z));
let t1 = cross(normal, a);
let t2 = cross(normal, t1);
let tangent_first = cross(normal, a);
let tangent_second = cross(normal, tangent_first);
// d(clip) along each tangent, then through the perspective divide:
// `d(ndc) = (d(clip).xyz - ndc * d(clip).w) / clip.w`.
let c1 = view_proj * vec4<f32>(t1, 0.0);
let c2 = view_proj * vec4<f32>(t2, 0.0);
let clip_first = view_proj * vec4<f32>(tangent_first, 0.0);
let clip_second = view_proj * vec4<f32>(tangent_second, 0.0);
let inv_w = 1.0 / clip.w;
let g1 = (c1.xyz - ndc * c1.w) * inv_w;
let g2 = (c2.xyz - ndc * c2.w) * inv_w;
let u1 = g1.xy * vec2<f32>(0.5, -0.5); // the same ndc -> uv flip `uv` above uses
let u2 = g2.xy * vec2<f32>(0.5, -0.5);
let grad_first = (clip_first.xyz - ndc * clip_first.w) * inv_w;
let grad_second = (clip_second.xyz - ndc * clip_second.w) * inv_w;
let uv_first = grad_first.xy * vec2<f32>(0.5, -0.5); // the same ndc -> uv flip `uv` above uses
let uv_second = grad_second.xy * vec2<f32>(0.5, -0.5);
// Solve `dot(grad, u_i) = g_i.z` for i = 1, 2. `det` collapses only as the plane goes
// EDGE-ON to the fixture, where the plane projects to a line in the map, `max(N·L, 0)` has
// already taken the lit term to zero, and a zero gradient is the right answer anyway.
let det = u1.x * u2.y - u1.y * u2.x;
let det = uv_first.x * uv_second.y - uv_first.y * uv_second.x;
if (abs(det) > 1e-9) {
let inv = 1.0 / det;
grad = vec2<f32>((g1.z * u2.y - u1.y * g2.z) * inv,
(u1.x * g2.z - g1.z * u2.x) * inv);
grad = vec2<f32>((grad_first.z * uv_second.y - uv_first.y * grad_second.z) * inv,
(uv_first.x * grad_second.z - grad_first.z * uv_second.x) * inv);
}
}
// …and the ceiling on what any one tap may be moved by. See `TORCH_SLOPE_MAX`.
@@ -420,24 +420,24 @@ fn torch_map_shadow(
let search = base * TORCH_PCSS_SEARCH * inv_dims;
// MONKEY (slope bias): the offsets are NAMED so the search's reference and its sample are the
// same displacement, exactly as in the PCF kernel below.
let s0 = vec2<f32>(-1.0, -1.0) * search;
let s1 = vec2<f32>( 1.0, -1.0) * search;
let s2 = vec2<f32>(-1.0, 1.0) * search;
let s3 = vec2<f32>( 1.0, 1.0) * search;
let b0 = torch_map_depth(depth_tex, layer, uv + s0, dims);
let b1 = torch_map_depth(depth_tex, layer, uv + s1, dims);
let b2 = torch_map_depth(depth_tex, layer, uv + s2, dims);
let b3 = torch_map_depth(depth_tex, layer, uv + s3, dims);
let search_offset_a = vec2<f32>(-1.0, -1.0) * search;
let search_offset_b = vec2<f32>( 1.0, -1.0) * search;
let search_offset_c = vec2<f32>(-1.0, 1.0) * search;
let search_offset_d = vec2<f32>( 1.0, 1.0) * search;
let blocker_a = torch_map_depth(depth_tex, layer, uv + search_offset_a, dims);
let blocker_b = torch_map_depth(depth_tex, layer, uv + search_offset_b, dims);
let blocker_c = torch_map_depth(depth_tex, layer, uv + search_offset_c, dims);
let blocker_d = torch_map_depth(depth_tex, layer, uv + search_offset_d, dims);
var blocker = 0.0;
var blockers = 0.0;
// MONKEY (slope bias): the SEARCH is plane-corrected too, and it has to be. Left on the flat
// reference, a grazing floor detects ITSELF as its own blocker, and the penumbra width below is
// then estimated from a depth difference that is pure bias error — a pool that goes soft with
// nothing in it. Same law, same `grad`, same clamp as the taps.
if (b0 > torch_plane_ref(ref_depth, grad, s0, slope_lim)) { blocker += b0; blockers += 1.0; }
if (b1 > torch_plane_ref(ref_depth, grad, s1, slope_lim)) { blocker += b1; blockers += 1.0; }
if (b2 > torch_plane_ref(ref_depth, grad, s2, slope_lim)) { blocker += b2; blockers += 1.0; }
if (b3 > torch_plane_ref(ref_depth, grad, s3, slope_lim)) { blocker += b3; blockers += 1.0; }
if (blocker_a > torch_plane_ref(ref_depth, grad, search_offset_a, slope_lim)) { blocker += blocker_a; blockers += 1.0; }
if (blocker_b > torch_plane_ref(ref_depth, grad, search_offset_b, slope_lim)) { blocker += blocker_b; blockers += 1.0; }
if (blocker_c > torch_plane_ref(ref_depth, grad, search_offset_c, slope_lim)) { blocker += blocker_c; blockers += 1.0; }
if (blocker_d > torch_plane_ref(ref_depth, grad, search_offset_d, slope_lim)) { blocker += blocker_d; blockers += 1.0; }
// MONKEY (pcss) 2/3 — PENUMBRA WIDTH. `radius` stays at `base` when the search found nothing: a
// caster thinner than the search box (a chair leg, a tent rope, a candlestick) must NOT be
@@ -463,33 +463,33 @@ fn torch_map_shadow(
// MONKEY (slope bias): every tap carries its OWN plane-corrected reference. With `grad` zero -
// a receiver square-on to the fixture, or a zero normal - `torch_plane_ref` returns `ref_depth`
// unchanged and this kernel is the shipped one bit-for-bit.
let p0 = vec2<f32>(-0.5, -0.5) * texel;
let p1 = vec2<f32>( 0.5, -0.5) * texel;
let p2 = vec2<f32>(-0.5, 0.5) * texel;
let p3 = vec2<f32>( 0.5, 0.5) * texel;
let pcf_offset_a = vec2<f32>(-0.5, -0.5) * texel;
let pcf_offset_b = vec2<f32>( 0.5, -0.5) * texel;
let pcf_offset_c = vec2<f32>(-0.5, 0.5) * texel;
let pcf_offset_d = vec2<f32>( 0.5, 0.5) * texel;
var sum = 0.0;
sum += textureSampleCompareLevel(depth_tex, comp, uv + p0, layer,
torch_plane_ref(ref_depth, grad, p0, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p1, layer,
torch_plane_ref(ref_depth, grad, p1, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p2, layer,
torch_plane_ref(ref_depth, grad, p2, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p3, layer,
torch_plane_ref(ref_depth, grad, p3, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_a, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_a, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_b, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_b, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_c, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_c, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_d, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_d, slope_lim));
var pcf = sum * 0.25;
if (radius > TORCH_PCSS_WIDE) {
let p4 = vec2<f32>(-1.0, 0.0) * texel;
let p5 = vec2<f32>( 1.0, 0.0) * texel;
let p6 = vec2<f32>( 0.0, -1.0) * texel;
let p7 = vec2<f32>( 0.0, 1.0) * texel;
sum += textureSampleCompareLevel(depth_tex, comp, uv + p4, layer,
torch_plane_ref(ref_depth, grad, p4, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p5, layer,
torch_plane_ref(ref_depth, grad, p5, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p6, layer,
torch_plane_ref(ref_depth, grad, p6, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p7, layer,
torch_plane_ref(ref_depth, grad, p7, slope_lim));
let pcf_offset_e = vec2<f32>(-1.0, 0.0) * texel;
let pcf_offset_f = vec2<f32>( 1.0, 0.0) * texel;
let pcf_offset_g = vec2<f32>( 0.0, -1.0) * texel;
let pcf_offset_h = vec2<f32>( 0.0, 1.0) * texel;
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_e, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_e, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_f, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_f, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_g, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_g, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + pcf_offset_h, layer,
torch_plane_ref(ref_depth, grad, pcf_offset_h, slope_lim));
pcf = sum * 0.125;
}
// Phase 5: the six cube faces tile the whole sphere, so there is no cone edge to soften — a fade
+37 -3
View File
@@ -18,6 +18,12 @@
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// MONKEY (wet): rain on surfaces (wet_hook.wgsl).
#import benilla::wet_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array<f32>;
@@ -56,6 +62,13 @@ struct WowLight {
// reach in yards) for an INTERIOR one. Terrain consumes only the exterior half.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
// MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z,
// 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl).
shelter_hdr: vec4<f32>,
shelter_cfg: vec4<f32>,
shelter: array<u32, 16384>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -749,9 +762,30 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade)
// (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then
// LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161).
// MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid).
var wet_open = 1.0;
if (wow_light.monkey.wet_a.y > 0.0) {
let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr,
wow_light.shelter_cfg);
if (wet_st.live > 0.0) {
wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x],
wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z],
wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg);
}
}
// MONKEY (wet): rain-darkened ground + puddles (wet_hook.wgsl); a dry frame returns `color`.
let wet = wet_hook::wet_puddles(wet_hook::wet_surface(color, n_lit, in.world_position.xyz,
wet_open, wow_light.monkey), n_lit, in.world_position.xyz, wet_open, wow_light.monkey);
color = wet.albedo;
let diffuse_term = color * primary * (0.3 * shadow_lit_eff + 0.7) * character_shadow_term;
let spec_term = in.specular * specmask * spec_gate;
var tuned = clamp(diffuse_term + spec_term, vec3<f32>(0.0), vec3<f32>(1.0));
// MONKEY (wet): the wet sheen, and the authored `_s` sheen brightens with it.
if (wet.boost > 0.0) {
tuned = clamp(tuned + spec_term * wet.boost + wet_hook::wet_sheen(wet.boost, n_lit,
normalize(view.world_position - in.world_position.xyz), -normalize(wow_light.light_sun.xyz),
wow_light.light_diffuse.rgb, wow_light.fog_color.rgb, spec_gate), vec3<f32>(0.0), vec3<f32>(1.0));
}
// MONKEY (torch debug, interiorDebug 2 on TERRAIN): the ground's OWN cube-map sampling as
// greyscale, the same instrument static_gx and wow_model already paint on walls and bodies. The
@@ -776,9 +810,9 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// the per-pixel form is what survives Bevy's mesh interpolators without a custom slot.
if (wow_light.fog_color.w > 0.5) {
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001);
let factor = clamp((wow_light.fog_params.y - eye_z) / denom, 0.0, 1.0);
tuned = mix(wow_light.fog_color.xyz, tuned, factor);
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
tuned = fog_hook::apply_fog(tuned, wow_light.fog_color.xyz, wow_light.fog_params.xy, eye_z,
in.world_position.xyz, view.world_position, true, wow_light.monkey);
}
// Raw gamma out: the framebuffer holds gamma bytes and blends in gamma like the reference's;
@@ -17,6 +17,11 @@
mesh_view_bindings::view,
}
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
/// How far the band reaches inside the far-clip wall: the reference's far-band near plane
/// `farclip - 33.0` (`[0x8101b0]`, about one WDL outer cell).
const WDL_OVERLAP: f32 = 33.0;
@@ -34,6 +39,11 @@ struct WowLight {
_light_spec: vec4<f32>, // 3
fog_color: vec4<f32>, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
fog_params: vec4<f32>, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z = the signed directional-shadow weight (MONKEY moon shadows: +sun / -moon; unread here); w = farclip wall (0 ⇒ off)
// MONKEY (p0 MonkeyFrame): rows 6-20 and the point table, unread here, so the block lines up.
_rows_6_20: array<vec4<f32>, 15>,
_points: array<vec4<f32>, 512>,
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> w: WowLight;
@@ -73,6 +83,10 @@ fn fragment(in: WdlVsOut) -> WdlFsOut {
var rgb = vec3<f32>(1.0);
if (w.fog_color.w > 0.5) {
rgb = w.fog_color.xyz;
// MONKEY (p0 fog hook): the hull's own pair (start 0, end 1 yd) through the shared law, so
// a new fog model colours the horizon the way it colours the world; classic = the line above.
rgb = fog_hook::apply_fog(vec3<f32>(1.0), rgb, vec2<f32>(0.0, 1.0), eye_z,
in.world_position.xyz, view.world_position, true, w.monkey);
}
var out: WdlFsOut;
@@ -0,0 +1,152 @@
#define_import_path benilla::wet_hook
// MONKEY (wet): rain on surfaces — the ONE place the wet look is computed (the `shadow_hook.wgsl`
// pattern). Receivers (terrain, static_gx, wow_model) call it with one-line hooks and pass the
// MonkeyFrame (its `wet_a` row: `rain_rate, wetness, ripple_time_s, snow`, written by
// `benilla-world/src/weather/wetness.rs`). A zero wetness (dry weather, or `rainSurfaces 0`) returns
// the inputs untouched, so the dry image is bit-identical.
//
// Sky exposure, phase 1: the receiver passes `exposure` = 0 for interior-class / unlit / emissive
// surfaces and 1 otherwise; the normal then takes undersides out (dry), walls half wet, tops fully
// wet and glossy. There is no rain-occlusion map yet, so an exterior surface under a porch or a
// bridge still reads wet.
#import benilla::monkey_frame::MonkeyFrame
// MONKEY (rainshelter): the four cells around a fragment in the camera-centred shelter grid.
// `hdr` = `[origin_x, origin_z, 1 / cell, active]`, `cfg` = `[base_y, cells per side, 0, 0]`;
// `live` is 0 when the map is off or the fragment lies outside it (open sky).
struct ShelterTaps {
idx: vec4<u32>,
frac: vec2<f32>,
live: f32,
}
fn shelter_taps(world_pos: vec3<f32>, hdr: vec4<f32>, cfg: vec4<f32>) -> ShelterTaps {
var t: ShelterTaps;
t.idx = vec4<u32>(0u);
t.frac = vec2<f32>(0.0);
t.live = 0.0;
if (hdr.w < 0.5) {
return t;
}
let g = (world_pos.xz - hdr.xy) * hdr.z - 0.5;
let gf = floor(g);
if (gf.x < 0.0 || gf.y < 0.0 || gf.x + 1.0 > cfg.y - 1.0 || gf.y + 1.0 > cfg.y - 1.0) {
return t;
}
let side = u32(cfg.y);
let first = u32(gf.y) * side + u32(gf.x);
t.idx = vec4<u32>(first, first + 1u, first + side, first + side + 1u);
t.frac = g - gf;
t.live = 1.0;
return t;
}
// One cell: low half `(top − base_y) × 64 + 32768`, high half the soft cover as unorm16
// (`shelter::pack_cell`). Sheltered when a covered top lies a yard or more above the fragment; the
// roof's own surface (at `top`) stays open.
fn shelter_cell(word: u32, y_rel: f32) -> f32 {
let top = (f32(word & 0xffffu) - 32768.0) / 64.0;
let cover = f32(word >> 16u) / 65535.0;
return cover * smoothstep(0.5, 1.5, top - y_rel);
}
// How sheltered the fragment is, 0 open .. 1 under cover, bilinear over the four cells.
fn shelter_amount(t: ShelterTaps, wa: u32, wb: u32, wc: u32, wd: u32, world_y: f32,
cfg: vec4<f32>) -> f32 {
if (t.live <= 0.0) {
return 0.0;
}
let y_rel = world_y - cfg.x;
let near = mix(shelter_cell(wa, y_rel), shelter_cell(wb, y_rel), t.frac.x);
let far = mix(shelter_cell(wc, y_rel), shelter_cell(wd, y_rel), t.frac.x);
return clamp(mix(near, far, t.frac.y), 0.0, 1.0);
}
struct WetSurface {
albedo: vec3<f32>,
// Gloss weight for `wet_sheen`: 0 dry, ~1 a soaked top face, up to 2 in a terrain puddle (the
// part above 1 is standing water: a flat mirror of the sky).
boost: f32,
}
// How wet a surface is from its facing: none below, half on walls, full on tops.
fn wet_amount(n: vec3<f32>, exposure: f32, wetness: f32) -> f32 {
let facing = smoothstep(-0.2, 0.25, n.y);
return clamp(wetness, 0.0, 1.0) * clamp(exposure, 0.0, 1.0) * facing
* mix(0.5, 1.0, smoothstep(0.3, 0.8, n.y));
}
// Wet albedo: darker (water fills the micro-pores) and a touch more saturated; the gloss weight
// is kept for up-facing faces only.
fn wet_surface(albedo: vec3<f32>, n: vec3<f32>, world_pos: vec3<f32>, exposure: f32,
mf: MonkeyFrame) -> WetSurface {
let wet_row = mf.wet_a;
var o: WetSurface;
o.albedo = albedo;
o.boost = 0.0;
if (wet_row.y <= 0.0 || exposure <= 0.0) {
return o;
}
let w = wet_amount(n, exposure, wet_row.y);
let luma = dot(albedo, vec3<f32>(0.299, 0.587, 0.114));
let sat = max(mix(vec3<f32>(luma), albedo, 1.0 + 0.3 * w), vec3<f32>(0.0));
o.albedo = sat * (1.0 - 0.36 * w);
o.boost = w * smoothstep(0.35, 0.9, n.y);
return o;
}
fn wet_hash(p: vec2<f32>) -> f32 {
let q = fract(p * vec2<f32>(0.1031, 0.1030));
let r = q + dot(q, q.yx + 33.33);
return fract((r.x + r.y) * r.x);
}
fn wet_noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
return mix(mix(wet_hash(i), wet_hash(i + vec2<f32>(1.0, 0.0)), u.x),
mix(wet_hash(i + vec2<f32>(0.0, 1.0)), wet_hash(i + vec2<f32>(1.0, 1.0)), u.x), u.y);
}
// Terrain bonus: puddle patches on flat ground once it is well soaked — darker, and a stronger
// gloss weight so they read as standing water catching the sky. MONKEY (rainshelter): `exposure`
// as for `wet_surface`, so no puddle forms under cover.
fn wet_puddles(s: WetSurface, n: vec3<f32>, world_pos: vec3<f32>, exposure: f32,
mf: MonkeyFrame) -> WetSurface {
let wet_row = mf.wet_a;
var o = s;
if (wet_row.y <= 0.0 || exposure <= 0.0) {
return o;
}
let xz = world_pos.xz;
let field = 0.65 * wet_noise(xz * 0.17) + 0.35 * wet_noise(xz * 0.53 + 17.0);
let level = smoothstep(0.94, 0.99, n.y);
// Puddles grow with the wetness: the threshold falls as the ground soaks.
let edge = mix(0.8, 0.6, smoothstep(0.4, 1.0, wet_row.y));
let puddle = smoothstep(edge, edge + 0.06, field) * level * smoothstep(0.35, 0.8, wet_row.y)
* clamp(exposure, 0.0, 1.0);
o.albedo = o.albedo * (1.0 - 0.45 * puddle);
o.boost = max(o.boost, 2.0 * puddle);
return o;
}
// The wet sheen, added to the lit gamma colour: a tight sun highlight (shadowed) and a Fresnel
// reflection of the sky/fog colour. Zero when `boost` is zero.
fn wet_sheen(boost: f32, n: vec3<f32>, to_view: vec3<f32>, to_light: vec3<f32>,
sun_rgb: vec3<f32>, sky_rgb: vec3<f32>, shadow: f32) -> vec3<f32> {
if (boost <= 0.0) {
return vec3<f32>(0.0);
}
// MONKEY (fix-wet): nudged so opposite light/view vectors never normalize a zero vector (NaN).
let h = normalize(to_light + to_view + vec3<f32>(0.0, 1.0e-4, 0.0));
let nl = max(dot(n, to_light), 0.0);
let spec = pow(max(dot(n, h), 0.0), 80.0) * smoothstep(0.0, 0.15, nl);
let fres = 0.03 + 0.97 * pow(1.0 - clamp(dot(n, to_view), 0.0, 1.0), 5.0);
let gloss = min(boost, 1.0);
let mirror = max(boost - 1.0, 0.0);
return sun_rgb * ((0.9 * gloss + 1.2 * mirror) * spec * shadow)
+ sky_rgb * (gloss * min(0.25 * fres, 0.2) + mirror * (0.22 + 0.3 * fres));
}
@@ -0,0 +1,120 @@
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
// wxl-experimental-wind, grass/GrassWind.hpp and grass/GrassWind.cpp.
#define_import_path benilla::wind_hook
#import benilla::monkey_frame::MonkeyFrame
const WIND_TAU: f32 = 6.28318530718;
// MONKEY (fix-wind): `wind_b.x` is the integrated wind travel in yards, wrapped at 4096 on the CPU
// (`wind::TRAVEL_WRAP`). Each wave rate is a whole number of cycles per wrap, so the wrap is
// seamless; the phase advances at k * speed, never k * speed * age.
const WIND_WRAP: f32 = 4096.0;
// The phase (radians) of a wave doing `cycles` whole cycles per wrap of travel.
fn wind_travel_phase(travel: f32, cycles: f32) -> f32 {
return fract(cycles * (travel / WIND_WRAP)) * WIND_TAU;
}
// The fixed profile heading: the waves' spatial term uses it, so the veer (which only turns the
// bend direction) never rotates the phase field about the world origin.
fn wind_base_dir(frame: MonkeyFrame) -> vec2<f32> {
return vec2<f32>(cos(frame.wind_a.z), sin(frame.wind_a.z));
}
fn wind_cross_dir(dir: vec2<f32>) -> vec2<f32> {
// WarcraftXL's secondary wave is 35 degrees off the primary.
return vec2<f32>(
dir.x * 0.819152 - dir.y * 0.573576,
dir.x * 0.573576 + dir.y * 0.819152,
);
}
// MONKEY (wind): WarcraftXL's two travelling waves, gust response, downwind lean, per-tuft
// phase/variance and distance fade, plus the same radial parting extended from the player to the
// eight MonkeyFrame benders. `bend_height` is authored from local mesh height, never texture V.
fn grass_offset(
world: vec3<f32>,
bend_height: f32,
tuft_phase: f32,
camera: vec3<f32>,
frame: MonkeyFrame,
) -> vec3<f32> {
let anchored = clamp((bend_height - 0.30) / 0.70, 0.0, 1.0);
let weight = anchored * anchored;
if (weight <= 0.0 || frame.wind_b.z <= 0.0) {
return vec3<f32>(0.0);
}
let dir_primary = normalize(frame.wind_a.xy + vec2<f32>(1.0e-6, 0.0));
let dir_secondary = wind_cross_dir(dir_primary);
let base_primary = wind_base_dir(frame);
let base_secondary = wind_cross_dir(base_primary);
let wave_primary = WIND_TAU / 18.0;
let wave_secondary = WIND_TAU / 6.5;
// WarcraftXL's k * 0.33 * speed (primary) and k * 0.33 * 1.37 * speed (secondary), as whole
// cycles per wrap: 75 and 285.
let phase_primary = wave_primary * dot(base_primary, world.xz)
- wind_travel_phase(frame.wind_b.x, 75.0) + tuft_phase;
let phase_secondary = wave_secondary * dot(base_secondary, world.xz)
- wind_travel_phase(frame.wind_b.x, 285.0) + tuft_phase * 1.7;
let gust = 1.0 + 0.5 * (frame.wind_a.w - 0.5) * 2.0;
let variance = mix(0.7, 1.3, fract(sin(tuft_phase * 12.9898) * 43758.5453));
let distance_fade = 1.0 / (1.0 + distance(camera, world) * 0.015);
var xz = (
dir_primary * 0.060 * (sin(phase_primary) + 0.35)
+ dir_secondary * 0.020 * sin(phase_secondary)
) * gust * variance * weight * distance_fade * frame.wind_b.y * frame.wind_b.z;
let count = min(u32(frame.misc.x), 8u);
for (var i = 0u; i < 8u; i += 1u) {
if (i < count) {
let bender = frame.benders[i];
let away = world.xz - bender.xz;
let r = length(away);
let radial = away / max(r, 1.0e-3);
let edge = 1.0 - smoothstep(0.0, max(bender.w, 0.01), r);
let cone = smoothstep(0.30, 0.80, world.y - bender.y);
xz += radial * 0.50 * edge * cone * weight * frame.wind_b.z;
}
}
return vec3<f32>(xz.x, 0.0, xz.y);
}
// MONKEY (wind): our tree/bush design (WarcraftXL has no tree wind). Only leaf-card batches call
// this: a low-frequency crown sway, small leaf flutter, stable anchor phase and distance fade.
fn tree_offset(
world: vec3<f32>,
anchor: vec3<f32>,
camera: vec3<f32>,
frame: MonkeyFrame,
) -> vec3<f32> {
if (frame.wind_b.w <= 0.0) {
return vec3<f32>(0.0);
}
let h = max(world.y - anchor.y, 0.0);
// A one-yard bush still needs a crown; tree leaf cards generally begin above this range and
// therefore reach full weight, while the first 0.15 yd remains a planted base.
let crown = smoothstep(0.15, 1.5, h);
let weight = crown * crown;
let dir = normalize(frame.wind_a.xy + vec2<f32>(1.0e-6, 0.0));
let cross = vec2<f32>(-dir.y, dir.x);
let base = wind_base_dir(frame);
let base_cross = vec2<f32>(-base.y, base.x);
let phase = fract(sin(dot(anchor.xz, vec2<f32>(0.173, 0.317))) * 43758.5453) * WIND_TAU;
// 0.08 rad/yd sway, 0.61x side wave and a 2.3 rad/s-at-base-speed flutter, as whole cycles
// per wrap of travel: 52, 32 and 167.
let slow = sin(dot(base, world.xz) * (WIND_TAU / 34.0)
- wind_travel_phase(frame.wind_b.x, 52.0) + phase);
let side = sin(dot(base_cross, world.xz) * (WIND_TAU / 21.0)
- wind_travel_phase(frame.wind_b.x, 32.0) + phase * 1.7);
let flutter = sin(wind_travel_phase(frame.wind_b.x, 167.0)
+ dot(world.xz, vec2<f32>(1.7, 2.1)) + phase * 2.0);
let gust = 0.75 + frame.wind_a.w * 0.5;
let fade = 1.0 / (1.0 + distance(camera, world) * 0.006);
let xz = (
dir * (0.045 + slow * 0.055)
+ cross * side * 0.025
+ dir * flutter * 0.012
) * weight * gust * fade * frame.wind_b.y * frame.wind_b.w;
return vec3<f32>(xz.x, 0.0, xz.y);
}
@@ -8,6 +8,8 @@
// the tuft, which the reference writes onto the clutter vertex.
// Not built: a specular term (the M2 per-material shininess is only inferred) and the WMO
// per-group authored colour.
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
// wxl-experimental-wind, grass/GrassWind.cpp (grass vertex displacement seam).
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
@@ -20,6 +22,16 @@
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (wet): rain on surfaces (wet_hook.wgsl).
#import benilla::wet_hook
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity.
#import benilla::emissive_hook
// MONKEY (wind): WarcraftXL-derived grass motion and the matching tree fade-twin motion.
#import benilla::wind_hook
// bevy_pbr 0.18.1's `forward_io::FragmentOutput`. No depth output: a fragment depth write costs
// the pipeline early-Z, so the sky lane pins its depth in the vertex stage.
@@ -47,8 +59,14 @@ struct ModelParams {
// Rows of `wow_light.matanim`, 0 = identity: x = UV scroll, y = tint, z = texture-transform
// affine, w = the UI tile's cell clip.
anim_slots: vec4<f32>,
// MONKEY (skybox): stage 1 of a two-texture batch: x = 0 none / 1 Mod / 2 Mod2x, z/w = the
// matanim rows of its translation and affine.
stage1: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> m: ModelParams;
// MONKEY (skybox): stage 1's texture, the fallback image off `WOW_STAGE1`.
@group(#{MATERIAL_BIND_GROUP}) @binding(94) var stage1_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(95) var stage1_sampler: sampler;
// A fully covered character shadow retains 45% of the authored model lighting.
const SHADOW_SUN_FLOOR: f32 = 0.45;
@@ -112,6 +130,13 @@ struct WowLight {
// packed reach is always ≥ 1 yd and can never be mistaken for the exterior 0.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
// MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z,
// 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl).
shelter_hdr: vec4<f32>,
shelter_cfg: vec4<f32>,
shelter: array<u32, 16384>,
// The interior-prop SH probes, 7 rows per slot (`MAX_PROP_PROBES` = 8192 slots). Only this
// shader declares this tail; the other shaders bind the same buffer by its prefix.
prop_probes: array<vec4<f32>, 57344>,
@@ -1069,8 +1094,28 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
// Precision: camera-relative to clip space; `clip_from_world × p_world` cancels
// catastrophically with camera and geometry near 9 k yd. `world_position` is absolute again:
// lighting and fog need no such precision.
let p_cam = (frame_from_local * vec4<f32>(vertex.position, 1.0)).xyz
var p_cam = (frame_from_local * vec4<f32>(vertex.position, 1.0)).xyz
+ (frame_origin - view.world_position);
// MONKEY (wind): clutter UV_B is `(height-from-base, per-tuft phase)` authored by clutter.rs;
// UV_0 and vertex colour alpha retain their texture/cutout meanings.
#ifdef VERTEX_UVS_B
if (m.clutter_fade.w > 0.5) {
let world = p_cam + view.world_position;
p_cam += wind_hook::grass_offset(
world, vertex.uv_b.x, vertex.uv_b.y, view.world_position, wow_light.monkey
);
}
#endif
// MONKEY (wind): a retained tree inside its fade band renders here with the same anchor/field
// maths, to avoid a pop. MONKEY (fix-wind): the leaf-batch marker is material bit 14
// (`model_render::FOLIAGE_WIND_MARKER`), never a MeshTag bit (bit 18 aliases the lane weight
// and the interior probe slot, so doorway units and interior props swayed).
if ((u32(m.clutter_fade.z) & 16384u) != 0u && m.clutter_fade.w <= 0.5) {
let world = p_cam + view.world_position;
p_cam += wind_hook::tree_offset(
world, frame_origin, view.world_position, wow_light.monkey
);
}
out.world_position = vec4<f32>(p_cam + view.world_position, 1.0);
let view_rot = mat3x3<f32>(
view.view_from_world[0].xyz,
@@ -1286,6 +1331,22 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
base_color.a = base_color.a * ramp;
}
// MONKEY (skybox): stage 1 multiplies in on UV set B, moved by its own texture transform
// (the stage-0 law above): colour ×1 (Mod) or ×2 (Mod2x), alpha ×1.
#ifdef WOW_STAGE1
#ifdef VERTEX_UVS_B
{
let t1 = wow_light.matanim[u32(m.stage1.z)];
let a1 = wow_light.matanim[u32(m.stage1.w)];
let d1 = (in.uv_b + t1.xy - vec2<f32>(0.5, 0.5)) * vec2<f32>(1.0 + a1.z, 1.0 + a1.w);
let c1 = 1.0 + a1.x;
let uv1 = vec2<f32>(0.5 + d1.x * c1 - d1.y * a1.y, 0.5 + d1.x * a1.y + d1.y * c1);
let s1 = textureSampleBias(stage1_texture, stage1_sampler, uv1, view.mip_bias);
let k1 = select(1.0, 2.0, m.stage1.x > 1.5);
base_color = vec4<f32>(base_color.rgb * s1.rgb * k1, base_color.a * s1.a);
}
#endif
#endif
// The MeshTag (mesh_tag.rs): bit 31 = highlight, bit 30 = interior fog; payload bits 0-5 = the
// fade alpha (a zero payload is untagged, opaque), 19-29 = the rig slot, 6-13 = the ground
// shade (0 lit, 255 MCSH-shadowed) or, on an interior-prop material, 6-18 = the SH-probe slot.
@@ -1702,6 +1763,29 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
rgb = base.rgb;
}
// MONKEY (wet): rain on sky-exposed WMO/M2 surfaces (wet_hook.wgsl); interior, glue booth, unlit,
// Mod/Mod2x, additive and dry = untouched.
let wet_dry = is_interior || interior_fogged || is_rig || is_emissive || is_mod || is_mod2x
|| (u32(m.clutter_fade.z) & 4u) != 0u;
// MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid).
var wet_open = 1.0;
if (wow_light.monkey.wet_a.y > 0.0) {
let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr,
wow_light.shelter_cfg);
if (wet_st.live > 0.0) {
wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x],
wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z],
wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg);
}
}
let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz,
select(1.0, 0.0, wet_dry) * wet_open, wow_light.monkey);
rgb = wet.albedo;
if (wet.boost > 0.0) {
rgb = min(rgb + wet_hook::wet_sheen(wet.boost, n_lit, normalize(view.world_position - in.world_position.xyz),
L, wow_light.light_diffuse.rgb, wow_light.fog_color.rgb, player_shadow), vec3<f32>(1.0));
}
// Linear fog by planar eye depth, as in terrain.wgsl. Per-batch colour policy (`clutter_fade.z`
// bits 4-6, the M2 state setter `0x70baf0`): 0 scene, 1 black (additive), 2 white (Mod),
// 3 grey (Mod2x), 4 unfogged (render flag 0x02). Tag bit 30, not the static `model_flags.z`,
@@ -1716,13 +1800,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
let fog_policy = (u32(m.clutter_fade.z) >> 4u) & 7u;
if (fog_color.w > 0.5 && fog_policy != 4u) {
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
let denom = max(fog_span.y - fog_span.x, 0.001);
let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
var fog_rgb = fog_color.xyz;
if (fog_policy == 1u) { fog_rgb = vec3<f32>(0.0); }
else if (fog_policy == 2u) { fog_rgb = vec3<f32>(1.0); }
else if (fog_policy == 3u) { fog_rgb = vec3<f32>(0.50196078); }
rgb = mix(fog_rgb, rgb, factor);
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
rgb = fog_hook::apply_fog(rgb, fog_rgb, fog_span, eye_z, in.world_position.xyz,
view.world_position, fog_policy == 0u, wow_light.monkey);
}
@@ -1737,6 +1821,9 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
var out_rgb = rgb;
if (is_additive) {
out_rgb = out_rgb * faded_alpha;
// MONKEY (post): additive M2 cards become HDR before their framebuffer blend stacks them.
out_rgb = emissive_hook::emissive_boost(
out_rgb, emissive_hook::EMISSIVE_M2_ADD, wow_light.light_diffuse.w, 1.0);
}
// Mod (bit 7) and Mod2x (bit 8) read no source alpha, so the fade rides the colour as in the
// reference: texenv preset 5, `mix(prev.rgb, tex.rgb, prev.a)`, with the primary colour forced
@@ -577,6 +577,9 @@ impl WorldAssets {
tint: Vec4::ONE, // clutter has no animated M2Color tint and is not a WMO batch
sidn: Vec4::ZERO, // clutter is never SIDN/WINDOW glass (WMO-only)
anim_slots: Vec4::ZERO,
// MONKEY (skybox): no second stage.
stage1: Vec4::ZERO,
stage1_texture: None,
light_buf: self.shared_light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: self.torch.depth.clone(),
+9 -1
View File
@@ -29,6 +29,10 @@ mod camera_shakes;
mod cinematics;
mod creatures;
mod dbc;
mod monkey_zone_grade;
pub use monkey_zone_grade::{
load_monkey_zone_grades, parse_monkey_zone_grades, MonkeyZoneGrade, MonkeyZoneGrades,
};
mod unit_blood;
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog, SpellShakeGroup};
pub use cinematics::{
@@ -128,6 +132,10 @@ pub use ground_effects::{
};
mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
// MONKEY (fog): `LightFogBand.dbc`.
pub use light::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
// MONKEY (skybox): the skybox row and the zone walk's entries.
pub use light::{SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
@@ -271,7 +279,7 @@ pub use models::{
CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks,
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
PlayableAnim, RenderSubmesh, RgbAnim, StageTwo, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
+74 -21
View File
@@ -7,13 +7,19 @@ use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{f32_at, parse, str_at, u32_at};
use crate::dbc::{f32_at, parse, u32_at};
use crate::Chain;
mod atmosphere;
// MONKEY (fog): `LightFogBand.dbc`, the modern fog fields.
mod fog_band;
// MONKEY (skybox): the extended LightSkybox layout and the zone skybox walk.
mod skybox;
mod tables;
pub use atmosphere::Atmosphere;
pub use fog_band::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
pub use skybox::{SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
use atmosphere::{
FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE,
IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP,
@@ -171,9 +177,10 @@ pub struct LightCatalog {
/// `LightParams` id → `lightSkyboxID` ([`LP_SKYBOX`]), for its non-zero rows only.
light_params_skybox: HashMap<u32, u32>,
/// `LightSkybox.dbc` id → model chain path, spelled as a WMO MOSB skybox is so one model never
/// builds twice. Only id 3 (`DeathClouds.mdx`) is reachable from `LightParams`; the other five
/// rows are MOSB skyboxes.
skyboxes: HashMap<u32, String>,
/// builds twice. Stock 1.12 reaches only id 3 (`DeathClouds.mdx`) from `LightParams`; the
/// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along
/// ([`SkyboxDef`]), read from either table layout.
skyboxes: HashMap<u32, SkyboxDef>,
}
fn light_schema() -> Schema {
@@ -319,22 +326,8 @@ impl LightCatalog {
}
(glow, highlight, water_alpha, skybox)
};
let skyboxes = {
let bytes = chain
.read_file(LIGHT_SKYBOX)
.with_context(|| format!("reading {LIGHT_SKYBOX}"))?;
let mut schema = Schema::new("LightSkybox");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
schema.add_field(SchemaField::new("Name", FieldType::String));
let rs = parse(&bytes, schema, "LightSkybox")?;
let mut m = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) {
m.insert(id, crate::models::model_path(&path));
}
}
m
};
// MONKEY (skybox): both layouts, keyed by the header's field count.
let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?;
Ok(LightCatalog {
lights,
int_bands,
@@ -438,7 +431,7 @@ impl LightCatalog {
pub fn ghost_skybox(&self, map: u32, pos: [f32; 3]) -> Option<&str> {
let light = self.pick_light(map, pos)?;
let id = self.light_params_skybox.get(&light.params[SLOT_DEATH])?;
self.skyboxes.get(id).map(String::as_str)
self.skyboxes.get(id).map(|d| d.path.as_str())
}
/// The reference's area-light blend: from the map's global light, lerp toward each local
@@ -509,6 +502,66 @@ impl LightCatalog {
acc
}
/// MONKEY (fog): the `LightParams` ids [`Self::sample_blended`] folds, in its order, each with its
/// lerp weight (the base first at 1.0). A side table keyed by `LightParams` (`LightFogBand.dbc`)
/// blends through this with the same law. Magma and slime give their fixed row alone.
pub fn blend_chain(
&self,
map: u32,
pos: [f32; 3],
stormy: bool,
submersion: Submersion,
ghost: bool,
) -> Vec<(u32, f32)> {
if let Some(p) = submersion.fixed_param() {
if self.has_param(p) {
return vec![(p, 1.0)];
}
}
let slot = weather_slot(ghost, stormy, submersion.is_water());
let param_of = |l: &Light| match l.params[slot] {
0 => l.params[SLOT_CLEAR],
p => p,
};
let mut chain = Vec::new();
let map_has_no_light = !self.lights.iter().any(|l| l.map == map);
if let Some(base) = self
.lights
.iter()
.find(|l| l.map == map && l.global)
.or_else(|| {
map_has_no_light
.then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID))
.flatten()
})
{
let p = param_of(base);
if p >= 1 {
chain.push((p, 1.0));
}
}
let mut locals: Vec<(f32, &Light)> = self
.lights
.iter()
.filter(|l| l.map == map && !l.global)
.filter_map(|l| {
let d = (0..3)
.map(|i| (l.pos[i] - pos[i]).powi(2))
.sum::<f32>()
.sqrt();
(d <= l.falloff_end).then_some((d, l))
})
.collect();
locals.sort_by(|a, b| b.0.total_cmp(&a.0));
for (dist, l) in locals {
let p = param_of(l);
if p >= 1 {
chain.push((p, blend_alpha(dist, l.falloff_start, l.falloff_end)));
}
}
chain
}
/// Debug: the spheres near `pos`, nearest first (the blend applies them farthest first), then
/// the blended result against the `pick_light` sample and the water swatch endpoints.
pub fn debug_blend(&self, map: u32, pos: [f32; 3], time: u32) {
@@ -0,0 +1,204 @@
//! MONKEY (fog): `LightFogBand.dbc`, the modern fog fields 1.12 has no column for.
//!
//! The file is ours (DESIGN-Benilla-Graphics §2a). It mirrors `LightFloatBand.dbc` exactly: the
//! same 34-field, 136-byte band record (`ID, num, time[16], value[16]`, times in half-minutes on
//! the 2880 day), interpolated the same way across midnight. `LightParams` id `P` keys
//! [`FOG_BANDS_PER_PARAM`] rows, band `b` at id `(P − 1)·13 + b + 1`, the `LightFloatBand`
//! indexing with its own stride, so the stock `paramID·6 + k` tables stay untouched.
//!
//! | band | field | unit |
//! |---|---|---|
//! | 0 | height-fog density | extra extinction below the plane, × the distance fog's (0 = off) |
//! | 1 | height-fog height | WoW world Z, yards |
//! | 2 | height-fog falloff | 1/yd: how fast the extra density fades above the plane |
//! | 3 | curve blend | 0 = exponential (modern), 1 = the 1.12 linear ramp |
//! | 4-6 | sun-fog colour r, g, b | gamma 0..1 |
//! | 7 | sun-fog strength | 0..1 |
//! | 8 | sun-fog angle | cosine threshold of the lobe around the sun |
//! | 9-11 | end-fog colour r, g, b | gamma 0..1 |
//! | 12 | end-fog distance | yards (0 = the scene fog end) |
//!
//! A missing file, or a `LightParams` id without rows, is not an error: the caller falls back to
//! the colours it derives from the 1.12 bands.
use std::collections::HashMap;
use benilla_dbc::FieldType;
use super::tables::{band_schema, load_float_bands, sample_float, Band};
use crate::Chain;
const FOG_BAND: &str = "DBFilesClient\\LightFogBand.dbc";
/// Rows per `LightParams` id.
pub const FOG_BANDS_PER_PARAM: u32 = 13;
/// One `LightParams` id's fog fields at one time of day (see the module table).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FogBand {
pub height_density: f32,
/// WoW world Z, yards.
pub height: f32,
pub height_falloff: f32,
pub curve_blend: f32,
pub sun_rgb: [f32; 3],
pub sun_strength: f32,
pub sun_angle: f32,
pub end_rgb: [f32; 3],
/// Yards; 0 = the scene fog end.
pub end_distance: f32,
}
impl FogBand {
/// Componentwise `self + (o − self)·t`, the area and storm blend.
pub fn lerp(&self, o: &FogBand, t: f32) -> FogBand {
let l = |a: f32, b: f32| a + (b - a) * t;
let l3 = |a: [f32; 3], b: [f32; 3]| [l(a[0], b[0]), l(a[1], b[1]), l(a[2], b[2])];
FogBand {
height_density: l(self.height_density, o.height_density),
height: l(self.height, o.height),
height_falloff: l(self.height_falloff, o.height_falloff),
curve_blend: l(self.curve_blend, o.curve_blend),
sun_rgb: l3(self.sun_rgb, o.sun_rgb),
sun_strength: l(self.sun_strength, o.sun_strength),
sun_angle: l(self.sun_angle, o.sun_angle),
end_rgb: l3(self.end_rgb, o.end_rgb),
end_distance: l(self.end_distance, o.end_distance),
}
}
}
/// The parsed `LightFogBand.dbc`; empty when the file is absent.
#[derive(Default)]
pub struct FogBandCatalog {
bands: HashMap<u32, Band<f32>>,
}
impl FogBandCatalog {
/// Read `LightFogBand.dbc` off the chain. Absent or unreadable → an empty catalog (every
/// lookup `None`), which is the 1.12 data set.
pub fn load(chain: &mut Chain) -> Self {
let bands = load_float_bands(
chain,
FOG_BAND,
band_schema("LightFogBand", FieldType::Float32),
)
.unwrap_or_default();
FogBandCatalog { bands }
}
/// Whether the file carried any row.
pub fn is_empty(&self) -> bool {
self.bands.is_empty()
}
/// `LightParams` id `p` at `time` (half-minutes), or `None` when `p` has no rows. A single
/// missing band of a present id reads 0.
pub fn sample(&self, p: u32, time: u32) -> Option<FogBand> {
if p < 1 {
return None;
}
let base = (p - 1) * FOG_BANDS_PER_PARAM + 1;
// MONKEY (fix-fog): a fixed array, no per-frame Vec (review B11).
let rows: [Option<f32>; FOG_BANDS_PER_PARAM as usize] = std::array::from_fn(|b| {
self.bands
.get(&(base + b as u32))
.and_then(|band| sample_float(band, time))
});
if rows.iter().all(Option::is_none) {
return None;
}
let v = |b: usize| rows[b].unwrap_or(0.0);
Some(FogBand {
height_density: v(0),
height: v(1),
height_falloff: v(2),
curve_blend: v(3),
sun_rgb: [v(4), v(5), v(6)],
sun_strength: v(7),
sun_angle: v(8),
end_rgb: [v(9), v(10), v(11)],
end_distance: v(12),
})
}
/// The area/storm blend of [`super::LightCatalog::blend_chain`]: each id's sample, or
/// `fallback` for an id without rows, lerped in chain order. `None` when no id in the chain has
/// rows, so the caller keeps its own derivation untouched.
pub fn sample_chain(
&self,
chain: &[(u32, f32)],
time: u32,
fallback: &FogBand,
) -> Option<FogBand> {
let mut any = false;
let mut acc = *fallback;
for &(p, w) in chain {
let v = match self.sample(p, time) {
Some(v) => {
any = true;
v
}
None => *fallback,
};
acc = acc.lerp(&v, w);
}
any.then_some(acc)
}
/// Test/tool constructor: a catalog from `(row id, [(time, value)])` bands.
pub fn from_rows(rows: &[(u32, &[(u32, f32)])]) -> Self {
let mut bands = HashMap::new();
for (id, keys) in rows {
bands.insert(*id, Band::from_keys(keys));
}
FogBandCatalog { bands }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn neutral() -> FogBand {
FogBand {
height_density: 0.0,
height: 0.0,
height_falloff: 0.0,
curve_blend: 0.0,
sun_rgb: [0.0; 3],
sun_strength: 0.0,
sun_angle: 1.0,
end_rgb: [0.0; 3],
end_distance: 0.0,
}
}
#[test]
fn empty_catalog_is_absent() {
let c = FogBandCatalog::default();
assert!(c.is_empty());
assert_eq!(c.sample(12, 1440), None);
assert_eq!(c.sample_chain(&[(12, 1.0)], 1440, &neutral()), None);
}
#[test]
fn rows_land_at_the_param_stride_and_interpolate() {
// Param 12, band 7 (sun strength): 0 at 06:00, 1 at 18:00.
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(720, 0.0), (2160, 1.0)])]);
let noon = c.sample(12, 1440).unwrap();
assert!((noon.sun_strength - 0.5).abs() < 1e-6);
assert_eq!(noon.height_density, 0.0);
assert_eq!(c.sample(13, 1440), None);
}
#[test]
fn chain_blends_rows_against_the_fallback() {
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(0, 1.0)])]);
// Base 13 (no rows → fallback strength 0), then param 12 at weight 0.25.
let out = c.sample_chain(&[(13, 1.0), (12, 0.25)], 100, &neutral()).unwrap();
assert!((out.sun_strength - 0.25).abs() < 1e-6);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! MONKEY (skybox): `LightSkybox.dbc` in both layouts and the zone skybox walk.
//!
//! The 1.12 table is `ID, Name` (8-byte records). The extended table the sky patch ships is the
//! modern column set with paths in place of FileDataIDs: `ID, Name, Flags, CelestialName`
//! (16-byte records). The layout is picked by the header's field count, so an unpatched data set
//! loads with flags 0 and no celestial model.
//!
//! [`LightCatalog::zone_skyboxes`] is the modern client's skybox collector
//! (`DayNightLightHolder::SkyBoxCollector::addSkyBox`) over the same global-then-farthest-first
//! walk as [`LightCatalog::sample_blended`]: the same model keeps its largest alpha, and every
//! other model collected so far is scaled by `1 − alpha`, so two skyboxes crossfade. A light whose
//! param names no skybox leaves the list alone, as in the modern client.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use super::{blend_alpha, weather_slot, LightCatalog, Submersion, FALLBACK_LIGHT_ID, SLOT_CLEAR};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
/// Flag `0x1`: the model's sequence 0 plays across the game day, `time = duration × day fraction`.
pub const SKYBOX_FULL_DAY: u32 = 0x1;
/// Flag `0x2`: the procedural sky (dome, sun, moons, stars, clouds) keeps drawing under the model.
pub const SKYBOX_KEEP_CELESTIAL: u32 = 0x2;
/// Flag `0x4`: an alpha cone in the final fog colour draws over the model's horizon.
pub const SKYBOX_FOG_BLEND: u32 = 0x4;
/// One `LightSkybox.dbc` row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkyboxDef {
/// The model's chain path, spelled as a WMO MOSB skybox is, so one model never builds twice.
pub path: String,
/// The modern flag word; 0 in the 1.12 layout.
pub flags: u32,
/// The second model the modern client names as the celestial layer; `None` when empty.
pub celestial: Option<String>,
}
/// One weighted skybox the zone walk collected.
#[derive(Clone, Debug, PartialEq)]
pub struct ZoneSkybox {
pub id: u32,
pub weight: f32,
}
/// Read `LightSkybox.dbc`, choosing the schema by the header's field count.
pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result<HashMap<u32, SkyboxDef>> {
let bytes = chain
.read_file(file)
.with_context(|| format!("reading {file}"))?;
let fields = bytes
.get(8..12)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.unwrap_or(2);
let extended = fields >= 4;
let mut schema = Schema::new("LightSkybox");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
schema.add_field(SchemaField::new("Name", FieldType::String));
if extended {
schema.add_field(SchemaField::new("Flags", FieldType::UInt32));
schema.add_field(SchemaField::new("CelestialName", FieldType::String));
// Columns past the four this client reads, if a later table carries them.
for i in 4..fields {
schema.add_field(SchemaField::new(format!("extra{i}"), FieldType::UInt32));
}
}
let rs = parse(&bytes, schema, "LightSkybox")?;
let mut m = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 1)) else {
continue;
};
let (flags, celestial) = if extended {
(
u32_at(r, 2).unwrap_or(0),
str_at(&rs, r, 3)
.filter(|c| !c.trim().is_empty())
.map(|c| crate::models::model_path(&c)),
)
} else {
(0, None)
};
m.insert(
id,
SkyboxDef {
path: crate::models::model_path(&name),
flags,
celestial,
},
);
}
Ok(m)
}
/// 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) {
if alpha <= 0.0 {
return;
}
let (at, alpha) = match list.iter_mut().position(|e| e.id == id) {
Some(i) => {
let a = list[i].weight.max(alpha);
list[i].weight = a;
(i, a)
}
None => {
list.push(ZoneSkybox { id, weight: alpha });
(list.len() - 1, alpha)
}
};
for (i, e) in list.iter_mut().enumerate() {
if i != at {
e.weight *= 1.0 - alpha;
}
}
}
impl LightCatalog {
/// A `LightSkybox.dbc` row by id.
pub fn skybox_def(&self, id: u32) -> Option<&SkyboxDef> {
self.skyboxes.get(&id)
}
/// 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))
}
/// The skybox the slot's param names; a weather slot without one falls back to the clear
/// slot's, because the sky patch authors the clear slot only.
fn light_skybox(&self, params: &[u32; 5], slot: usize) -> Option<u32> {
let of = |p: u32| self.light_params_skybox.get(&p).copied();
let param = match params[slot] {
0 => params[SLOT_CLEAR],
p => p,
};
of(param).or_else(|| (slot != SLOT_CLEAR).then(|| of(params[SLOT_CLEAR])).flatten())
}
/// The weighted skyboxes at `pos` for the living: the global (or fallback) light at alpha 1,
/// then each local sphere containing `pos` farthest first at its falloff alpha, through
/// [`collect`]. The slot is the lighting resolve's ([`weather_slot`]), ghost excluded: the
/// death sky is [`Self::ghost_skybox`].
pub fn zone_skyboxes(
&self,
map: u32,
pos: [f32; 3],
stormy: bool,
submersion: Submersion,
) -> Vec<ZoneSkybox> {
let mut out = Vec::new();
if submersion.fixed_param().is_some() {
return out;
}
let slot = weather_slot(false, stormy, submersion.is_water());
let map_has_no_light = !self.lights.iter().any(|l| l.map == map);
let base = self
.lights
.iter()
.find(|l| l.map == map && l.global)
.or_else(|| {
map_has_no_light
.then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID))
.flatten()
});
if let Some(sky) = base.and_then(|l| self.light_skybox(&l.params, slot)) {
collect(&mut out, sky, 1.0);
}
let mut locals: Vec<(f32, &super::Light)> = self
.lights
.iter()
.filter(|l| l.map == map && !l.global)
.filter_map(|l| {
let d = (0..3)
.map(|i| (l.pos[i] - pos[i]).powi(2))
.sum::<f32>()
.sqrt();
(d <= l.falloff_end).then_some((d, l))
})
.collect();
locals.sort_by(|a, b| b.0.total_cmp(&a.0));
for (dist, l) in locals {
if let Some(sky) = self.light_skybox(&l.params, slot) {
collect(
&mut out,
sky,
blend_alpha(dist, l.falloff_start, l.falloff_end),
);
}
}
out.retain(|e| e.weight > 0.0 && self.skyboxes.contains_key(&e.id));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn w(list: &[ZoneSkybox], id: u32) -> f32 {
list.iter().find(|e| e.id == id).map_or(0.0, |e| e.weight)
}
#[test]
fn a_second_model_crossfades_the_first() {
let mut l = Vec::new();
collect(&mut l, 1, 1.0);
collect(&mut l, 2, 0.25);
assert!((w(&l, 1) - 0.75).abs() < 1e-6);
assert!((w(&l, 2) - 0.25).abs() < 1e-6);
}
#[test]
fn the_same_model_keeps_its_largest_alpha() {
let mut l = Vec::new();
collect(&mut l, 1, 0.5);
collect(&mut l, 2, 0.5);
collect(&mut l, 1, 0.8);
assert_eq!(l.len(), 2);
assert!((w(&l, 1) - 0.8).abs() < 1e-6);
assert!((w(&l, 2) - 0.5 * 0.2).abs() < 1e-6);
}
#[test]
fn a_zero_alpha_leaves_the_list_alone() {
let mut l = Vec::new();
collect(&mut l, 1, 1.0);
collect(&mut l, 2, 0.0);
assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]);
}
/// The installed chain loads in either layout.
#[test]
fn the_installed_table_loads() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let m = load_skyboxes(&mut chain, super::super::LIGHT_SKYBOX).expect("LightSkybox");
assert_eq!(
m.get(&3).map(|d| d.path.as_str()),
Some(r"environments\stars\deathclouds.m2"),
"the ghost sky row"
);
}
}
@@ -18,6 +18,16 @@ pub(super) struct Band<T> {
values: Vec<T>,
}
// MONKEY (fog): a band from `(time, value)` keys, for `fog_band`'s tests and tools.
impl<T: Copy> Band<T> {
pub(super) fn from_keys(keys: &[(u32, T)]) -> Self {
Band {
times: keys.iter().map(|k| k.0).collect(),
values: keys.iter().map(|k| k.1).collect(),
}
}
}
/// The shared band record: `ID, num, time[16], value[16]`, 34 fields, 136 bytes.
pub(super) fn band_schema(name: &str, value: FieldType) -> Schema {
let mut s = Schema::new(name);
@@ -17,7 +17,7 @@ use super::tex_anim;
use super::{le_u16, le_u32, model_path, remap_submesh};
use super::{
AlphaAnim, Billboard, BillboardKind, BoneScaleAnim, CharSkinSlot, FogPolicy, ModelBlend,
RenderSubmesh,
RenderSubmesh, StageTwo,
};
fn parent_dir(path: &str) -> &str {
@@ -479,6 +479,33 @@ pub fn parse_m2_render_submeshes(
let rgb_seq = rgb_track
.and_then(|t| mat_anim::bake_rgb_seqs(t, &model.global_sequences, &seq_slots))
.filter(|set| set.uniform().is_none());
// MONKEY (skybox): stage 1 of a two-texture batch; an env-mapped stage 1 is left out.
let stage1 = (batch.texture_count >= 2 && !model.stage_is_env_mapped(batch, 1))
.then(|| {
let rec = model
.raw_data
.texture_lookup_table
.get(batch.texture_combo_index as usize + 1)
.and_then(|&ti| model.textures.get(ti as usize))?;
let set = model
.texture_unit_lookup
.get(batch.texture_coord_combo_index as usize + 1)
.copied()
.unwrap_or(0);
let combo = batch.texture_transform_combo_index.wrapping_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)
.and_then(|s| s.seq(None).cloned()),
tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots)
.and_then(|s| s.seq(None).cloned()),
))
})
.flatten();
// The reference turns each billboard bone to the camera about its own pivot, and a batch
// can hold cards on several bones (a candelabra's glows): one submesh per billboard bone.
let make_billboard = |bone_idx: usize| -> Option<Billboard> {
@@ -591,6 +618,26 @@ pub fn parse_m2_render_submeshes(
sub.uv_scale_seq = uv_scale_seq.clone();
sub.rgb_anim = rgb_anim.clone();
sub.rgb_seq = rgb_seq.clone();
// MONKEY (skybox): stage 1's UVs follow the submesh's own vertex order.
sub.stage1 = stage1.as_ref().map(
|(texture, (wrap_x, wrap_y), uv1, mod2x, uv_anim, uv_rot, uv_scale)| StageTwo {
texture: texture.clone(),
wrap_x: *wrap_x,
wrap_y: *wrap_y,
uvs: globals
.iter()
.map(|&g| {
let v = &model.vertices[g as usize];
let c = if *uv1 { &v.tex_coords2 } else { &v.tex_coords };
[c.x, c.y]
})
.collect(),
mod2x: *mod2x,
uv_anim: uv_anim.clone(),
uv_rot: uv_rot.clone(),
uv_scale: uv_scale.clone(),
},
);
out.push(sub);
}
}
+1
View File
@@ -100,6 +100,7 @@ fn remap_submesh(
wmo_batch: None,
env_map: false,
section: None,
stage1: None,
},
globals,
)
@@ -262,6 +262,24 @@ pub enum FogPolicy {
Off = 4,
}
/// MONKEY (skybox): a two-texture batch's second stage (`textureCount 2`, which no stock 1.12
/// model authors): texture `textureLookup[combo + 1]` on the UV set `texUnitLookup[coord + 1]`,
/// moved by `texAnimLookup[transform + 1]`, multiplied into stage 0 (`Mod`, or `Mod2x` on the
/// modern `0x4014` combiner).
#[derive(Debug, Clone, Default)]
pub struct StageTwo {
pub texture: Option<String>,
pub wrap_x: bool,
pub wrap_y: bool,
/// Per vertex, parallel to [`RenderSubmesh::positions`], from the stage's own UV set.
pub uvs: Vec<[f32; 2]>,
/// The stage's colour doubles (`Mod2x`).
pub mod2x: bool,
pub uv_anim: Option<UvAnim>,
pub uv_rot: Option<super::key_anim::KeyAnim<[f32; 4]>>,
pub uv_scale: Option<super::key_anim::KeyAnim<[f32; 2]>>,
}
/// One render batch: self-contained geometry and its material.
#[derive(Debug, Clone)]
pub struct RenderSubmesh {
@@ -346,6 +364,8 @@ pub struct RenderSubmesh {
/// over its base); the reference draws both from one vertex array under LEQUAL, the later
/// winning exactly (`0x70c190`), so consolidators refuse a section they cannot take whole.
pub section: Option<u16>,
/// MONKEY (skybox): the second texture stage, `None` on every one-texture batch.
pub stage1: Option<StageTwo>,
/// Texture coordinates generated as a view-space sphere map, not read from [`Self::uvs`]
/// (`texture_unit_lookup[texCoordSet] > 2`): `uv = normalize(P − 2(P·N)N).xy · 0.5 + 0.5`.
/// Such a mesh parks its UVs at one point (`GnomeSubwayGlass.m2`).
@@ -416,6 +436,7 @@ impl Default for RenderSubmesh {
wmo_batch: None,
env_map: false,
section: None,
stage1: None,
}
}
}
@@ -0,0 +1,69 @@
//! MONKEY (post): `MonkeyZoneGrade.dbc`, the optional area-to-day/night LUT table.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{f32_at, parse, str_at, u32_at};
use crate::Chain;
const TABLE: &str = "DBFilesClient\\MonkeyZoneGrade.dbc";
/// One authored zone grade. Missing files and absent area rows are handled by the caller as the
/// identity cube, so this type only represents complete rows.
#[derive(Clone, Debug, PartialEq)]
pub struct MonkeyZoneGrade {
pub id: u32,
pub area_id: u32,
pub day_lut: String,
pub night_lut: String,
pub strength: f32,
}
#[derive(Clone, Debug, Default)]
pub struct MonkeyZoneGrades {
rows: Vec<MonkeyZoneGrade>,
}
impl MonkeyZoneGrades {
pub fn for_area(&self, area_id: u32) -> Option<&MonkeyZoneGrade> {
self.rows.iter().find(|row| row.area_id == area_id)
}
pub fn rows(&self) -> impl Iterator<Item = &MonkeyZoneGrade> {
self.rows.iter()
}
}
/// Read the five-column extension table through the same typed DBC parser as the stock tables.
pub fn load_monkey_zone_grades(chain: &mut Chain) -> Result<MonkeyZoneGrades> {
let bytes = chain
.read_file(TABLE)
.context("reading MonkeyZoneGrade.dbc")?;
parse_monkey_zone_grades(&bytes)
}
/// Parse an in-memory extension table through the typed DBC reader. This is used for the loose
/// capture patch; the MPQ-backed path above and this path therefore share the exact schema.
pub fn parse_monkey_zone_grades(bytes: &[u8]) -> Result<MonkeyZoneGrades> {
let mut schema = Schema::new("MonkeyZoneGrade");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
schema.add_field(SchemaField::new("AreaID", FieldType::UInt32));
schema.add_field(SchemaField::new("DayLUT", FieldType::String));
schema.add_field(SchemaField::new("NightLUT", FieldType::String));
schema.add_field(SchemaField::new("Strength", FieldType::Float32));
let records = parse(bytes, schema, "MonkeyZoneGrade.dbc")?;
let rows = records
.records()
.iter()
.filter_map(|record| {
Some(MonkeyZoneGrade {
id: u32_at(record, 0)?,
area_id: u32_at(record, 1)?,
day_lut: str_at(&records, record, 2)?,
night_lut: str_at(&records, record, 3)?,
strength: f32_at(record, 4).unwrap_or(0.0).clamp(0.0, 1.0),
})
})
.collect();
Ok(MonkeyZoneGrades { rows })
}
@@ -117,6 +117,8 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() {
wmo_batch: None,
env_map: false,
section: None,
// MONKEY (integration): the skybox lane's second texture stage.
stage1: None,
};
assert!(
+5
View File
@@ -173,6 +173,11 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
x: v.f32_at(32).ok_or(Error::Truncated)?,
y: v.f32_at(36).ok_or(Error::Truncated)?,
},
// MONKEY (skybox): the second UV set.
tex_coords2: C2 {
x: v.f32_at(40).ok_or(Error::Truncated)?,
y: v.f32_at(44).ok_or(Error::Truncated)?,
},
});
}
+3 -1
View File
@@ -106,7 +106,7 @@ impl M2Bone {
}
/// One M2 vertex, 48 bytes: position @0, weights @0x0c, indices @0x10, normal @0x14, UV @0x20,
/// and an unread second UV @0x28.
/// and a second UV @0x28.
pub struct M2Vertex {
pub position: C3,
/// Per-vertex bone influence weights (`/255`), paired with [`Self::bone_indices`].
@@ -114,6 +114,8 @@ pub struct M2Vertex {
pub bone_indices: [u8; 4],
pub normal: C3,
pub tex_coords: C2,
/// MONKEY (skybox): the second UV set, which a two-texture batch's stage 1 can read.
pub tex_coords2: C2,
}
/// The texture lookup table and the collision hull's raw bytes.
+5 -4
View File
@@ -17,8 +17,9 @@ use benilla_formats::TILE_SIZE;
/// Real-clock seconds between sweeps (housekeeping, not world time); also the stamp's resolution.
const SWEEP_SECS: f32 = 1.0;
/// Five ADT tiles (2667 yd), the smallest round tile multiple clear of [`radius_floor`].
const DEFAULT_RADIUS_YD: f32 = 5.0 * TILE_SIZE;
/// Seven ADT tiles (3733 yd), the smallest round tile multiple clear of [`radius_floor`]
/// (MONKEY (presets): five until `farclip`'s ceiling rose past 777).
const DEFAULT_RADIUS_YD: f32 = 7.0 * TILE_SIZE;
/// The far corner of the widest tile block the residency window keeps (at the `farclip` clamp's
/// maximum), plus a tile: a stamp is where the viewer stood, so no smaller radius is safe.
@@ -223,8 +224,8 @@ mod tests {
"default {DEFAULT_RADIUS_YD} must clear the floor {}",
radius_floor()
);
// (26 + 1 + 16) chunks each way at farclip 777, to the corner, plus a tile: ~2560 yd.
assert!((radius_floor() - 2560.0).abs() < 1.0, "{}", radius_floor());
// (47 + 1 + 16) chunks each way at farclip 1497, to the corner, plus a tile: ~3550 yd.
assert!((radius_floor() - 3550.3).abs() < 1.0, "{}", radius_floor());
}
/// `$WOW_ART_RADIUS` is unset in the test binary, as in every ordinary run.
+73 -1
View File
@@ -29,11 +29,32 @@ pub type CloudMaterial = ExtendedMaterial<StandardMaterial, CloudExt>;
/// The colored cloud texture. The color math is CPU-side as in the reference, so the texels are
/// raw gamma bytes in a non-sRGB texture.
#[derive(Asset, AsBindGroup, Clone, TypePath)]
#[bind_group_data(CloudFxKey)]
pub struct CloudExt {
/// The colored tile, alpha the coverage, re-uploaded each regen (`0x58ac70`).
#[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`]).
#[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 (sky): the pipeline key: High compiles `SKY_FX_CLOUDS` into `cloud.wgsl`; below High
/// the reference's code runs with nothing added.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct CloudFxKey {
high: bool,
}
impl From<&CloudExt> for CloudFxKey {
fn from(e: &CloudExt) -> Self {
Self { high: e.fx.x >= 1.5 }
}
}
impl MaterialExtension for CloudExt {
@@ -50,8 +71,14 @@ impl MaterialExtension for CloudExt {
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialExtensionKey<Self>,
key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// MONKEY (sky): the High cloud branch.
if key.bind_group_data.high {
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("SKY_FX_CLOUDS".into());
}
}
sky_pipeline_state(descriptor);
Ok(())
}
@@ -156,6 +183,9 @@ pub(super) fn setup_cloud_layer(
},
extension: CloudExt {
texels: image.clone(),
// MONKEY (sky): Classic until `update_cloud_fx` reads the tier.
fx: Vec4::ZERO,
lit: Vec4::ZERO,
},
});
commands.spawn((
@@ -213,6 +243,48 @@ pub(super) fn follow_cloud_dome(
*gt = GlobalTransform::from(*tf);
}
/// MONKEY (sky): feeds the High cloud shading.
/// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp.
/// The march walks the sheet toward the glow body
/// (sun by day, moon by night), scaled by how far the body sits off the zenith (a noon sun has no
/// direction on the sheet worth marching, as in WarcraftXL) and by the kernel's own glow envelope.
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>>,
) {
let Some(layer) = layer else {
return;
};
let (fx, lit) = if quality.high() {
let body = light.cloud_glow_dir;
let flat = Vec2::new(body.x, body.z);
let len = flat.length();
let d = if len > 1e-3 { flat / len } else { Vec2::ZERO };
let strength =
(len * 1.6).min(1.0) * light.cloud_glow_track * (1.0 - 0.75 * light.storm_bcc);
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(q(sun[0]), q(sun[1]), q(sun[2]), q(strength.clamp(0.0, 1.0))),
)
} else {
(Vec4::ZERO, Vec4::ZERO)
};
benilla_assets::write_gated(
&mut materials,
&layer.material,
|m| m.extension.fx != fx || m.extension.lit != lit,
|m| {
m.extension.fx = fx;
m.extension.lit = lit;
},
);
}
#[cfg(test)]
mod tests {
use super::*;
+12 -1
View File
@@ -63,12 +63,18 @@ impl Plugin for CloudsPlugin {
// air's density and palette rather than the water's.
tick_clouds
.after(crate::liquid::SubmersionVerdict)
.in_set(crate::lighting::LightingConsumeSet),
.in_set(crate::lighting::LightingConsumeSet)
.in_set(CloudTick),
// After the skybox resolve and the submersion verdict, so the dome hides in
// the same frame as the rest of the sky.
layer::apply_cloud_visibility
.after(crate::skybox::SkyboxResolve)
.after(crate::liquid::SubmersionVerdict),
// MONKEY (sky): the High cloud shading's per-frame inputs.
// MONKEY (fix-sky): after this frame's tile, deterministically.
layer::update_cloud_fx
.in_set(crate::lighting::LightingConsumeSet)
.after(CloudTick),
),
)
// Camera-anchored after transform propagation, like the sky dome.
@@ -81,6 +87,11 @@ impl Plugin for CloudsPlugin {
/// Advances the coverage field (a full rebuild first, then the 10 Hz band scroll) and re-uploads
/// its texels when they change, as the reference does per regen (`0x58ac70`).
/// MONKEY (fix-sky): the cloud tile/coverage update, so the sky and cloud FX readers can order
/// after it (review B9: the ambiguous order made the glow's cover sample frame-nondeterministic).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CloudTick;
fn tick_clouds(
mut cov: ResMut<CloudCoverage>,
light: Res<WowLighting>,
+26
View File
@@ -1,6 +1,8 @@
//! Ground clutter (`GroundEffect*.dbc`): the terrain streamer scatters each chunk's tufts at tile
//! load, and a chunk's meshes exist only inside the 70 yd detail-doodad horizon (`[0x867958]`),
//! the reference's per-chunk `CDetailDoodadInst` build and unlink.
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
//! `wxl-experimental-wind`, `grass/GrassWind.cpp` (bend-weight/phase technique).
use std::collections::HashMap;
@@ -320,10 +322,23 @@ fn build_chunk_clutter(
if subs.is_empty() {
continue;
}
// MONKEY (wind): source M2 Z is up. Normalize the actual tuft geometry rather than
// trusting texture V (WXL's 3.3.5 detail doodads use inverse V, but vanilla assets are
// not guaranteed to). UV_1 stays free here and carries height + per-tuft phase.
// MONKEY (fix-wind): one span over ALL of the model's submeshes, so a trunk + canopy pair
// bends as one body instead of tearing apart at the seam.
let (min_z, max_z) = subs
.iter()
.flat_map(|sub| sub.positions.iter())
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
(lo.min(p[2]), hi.max(p[2]))
});
let height_span = (max_z - min_z).max(1.0e-4);
for sub in subs.iter() {
let vcount = sub.positions.len() * placements.len();
let mut positions = Vec::with_capacity(vcount);
let mut uvs = Vec::with_capacity(vcount);
let mut wind = Vec::with_capacity(vcount);
// The ground normal under each tuft, never the blade's own M2 normal.
let mut normals = Vec::with_capacity(vcount);
let mut colors = Vec::with_capacity(vcount);
@@ -335,6 +350,7 @@ fn build_chunk_clutter(
let rot = Quat::from_rotation_y(d.yaw);
// The reference's random per-tuft scale, in [0.9, 1.1].
let inst_scale = d.scale;
let phase = tuft_phase(origin);
// One tint per tuft. The reference makes the grey the lit material (colour material
// on ambient and diffuse, `0x59cfec`), `clamp(grey × light) × texture`; the shader
// multiplies it outside the clamp, `texture × grey × clamp(light)`, so under light
@@ -343,6 +359,7 @@ fn build_chunk_clutter(
for (i, p) in sub.positions.iter().enumerate() {
positions.push((rot * (wow_to_bevy(*p) * inst_scale) + origin).to_array());
uvs.push(sub.uvs[i]);
wind.push([((p[2] - min_z) / height_span).clamp(0.0, 1.0), phase]);
normals.push(sp.ground_normal);
colors.push(tint);
}
@@ -354,6 +371,8 @@ fn build_chunk_clutter(
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
// MONKEY (wind): second UV set enables VERTEX_UVS_B only for clutter meshes.
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, wind);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
// The MCSH tint, under the `VERTEX_COLORS` def the shader's clutter path reads.
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
@@ -403,6 +422,13 @@ fn build_chunk_clutter(
out
}
/// MONKEY (wind): deterministic per-tuft phase from its world-space origin. This is an identity
/// hash, not state; rebuild order and frame timing cannot change the blade's motion.
fn tuft_phase(origin: Vec3) -> f32 {
let h = (origin.x * 0.737 + origin.z * 1.311).sin() * 43_758.547;
h.fract().abs() * std::f32::consts::TAU
}
/// Squared distance from a point to an axis-aligned box, zero inside.
fn box_distance_squared(p: Vec3, (lo, hi): (Vec3, Vec3)) -> f32 {
(lo - p).max(p - hi).max(Vec3::ZERO).length_squared()
+21 -8
View File
@@ -308,7 +308,8 @@ fn ensure_ffx_glow(
// ---------------------------------------------------------------- render world
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct FfxGlowLabel;
// MONKEY (post): world-only HDR passes explicitly order themselves before the legacy clamp.
pub struct FfxGlowLabel;
/// The layouts, samplers, wave LUT and pipelines, built once at startup.
#[derive(Resource)]
@@ -684,12 +685,18 @@ fn prepare_textures(
}
}
/// `WOW_DITHER=1` arms the combine's deband dither (`ffx.lane.w`). Deviation, opt-in, off by
/// default: the reference's 8-bit framebuffer is undithered, but a smooth gradient under slow
/// motion steps visibly at 1/255. Bevy's own dither never runs under `Tonemapping::None`.
fn dither_armed() -> f32 {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
match *ON.get_or_init(|| std::env::var_os("WOW_DITHER").is_some()) {
/// MONKEY (p0 skyDither): the combine's deband dither (`ffx.lane.w`), the `skyDither` cvar
/// (Advanced Graphics -> Sky Dithering, 0/1, default 0). Deviation, opt-in: the reference's 8-bit
/// framebuffer is undithered, but a smooth gradient under slow motion steps visibly at 1/255.
/// Bevy's own dither never runs under `Tonemapping::None`. benilla-app's settings bridge writes it.
#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug, ExtractResource)]
pub struct SkyDither(pub bool);
/// `WOW_DITHER=1` still arms the dither for a session (it used to be the only switch); otherwise
/// the live [`SkyDither`] decides.
fn dither_armed(cvar: bool) -> f32 {
static ENV: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
match cvar || *ENV.get_or_init(|| std::env::var_os("WOW_DITHER").is_some()) {
true => 1.0,
false => 0.0,
}
@@ -713,7 +720,7 @@ fn combine_uniform(
zone_gain * glow.gain_scale,
feed.death,
feed.haze,
dither_armed(),
0.0, // MONKEY (p0 skyDither): the dither arm, set by `live_combine` from [`SkyDither`]
wave.phase1,
wave.phase2,
0.0,
@@ -906,6 +913,9 @@ fn live_combine(world: &World, glow: &FfxGlow) -> ([f32; 8], bool) {
glow,
wave,
);
// MONKEY (p0 skyDither): the dither lane, from the cvar (or the old env switch).
let mut uniform = uniform;
uniform[3] = dither_armed(world.get_resource::<SkyDither>().is_some_and(|d| d.0));
(uniform, wave_armed(glow.state, wave, death))
}
@@ -1090,6 +1100,8 @@ impl Plugin for FfxGlowPlugin {
.init_resource::<GlueFfx>()
.init_resource::<FfxHazeMix>()
.init_resource::<FfxWave>()
// MONKEY (p0 skyDither)
.init_resource::<SkyDither>()
.add_plugins((
ExtractComponentPlugin::<FfxGlow>::default(),
ExtractComponentPlugin::<FfxBackdrop>::default(),
@@ -1098,6 +1110,7 @@ impl Plugin for FfxGlowPlugin {
ExtractResourcePlugin::<GlueFfx>::default(),
ExtractResourcePlugin::<FfxHazeMix>::default(),
ExtractResourcePlugin::<FfxWave>::default(),
ExtractResourcePlugin::<SkyDither>::default(),
))
.add_systems(
Update,
+5
View File
@@ -144,8 +144,12 @@ pub mod rig_rider;
pub mod schedule;
mod shaders;
pub mod sky;
// MONKEY (sky): the sky quality tier and the Enhanced/High inputs.
pub mod sky_fx;
pub mod sky_order;
pub mod skybox;
// MONKEY (skybox): the skybox rig and material loops.
mod skybox_anim;
pub mod static_gx;
pub mod static_merge;
pub mod straddle;
@@ -158,6 +162,7 @@ pub mod vis_chain;
pub mod water_fx;
pub mod wdl;
pub mod weather;
pub mod wind; // MONKEY (wind): shared stateless gust/veer field and foliage quality gate
pub mod wmo_portal;
pub mod world_census;
pub mod world_map;
+290 -28
View File
@@ -300,6 +300,90 @@ pub fn daylight_budget(groups: &[WmoGroupInfo]) -> usize {
}
}
/// MONKEY (daylight: district sky rooms): how many portal hops from an exterior-facing opening a
/// DISTRICT room may sit and still count as lit by the day (see [`district_sky_rooms`]).
const DISTRICT_SKY_HOPS: u8 = 1;
/// MONKEY (daylight: district sky rooms): **which rooms of a CITY-scale placement take the
/// enclosed day floor** (`interiorDaylight`), as one flag per group; empty for a building.
///
/// MEASURED (`daylight::census`, 2026-09-25): the floor's own test,
/// [`benilla_formats::room_claim::enclosed_by_building_shell`], is false for EVERY interior group
/// of Stormwind (190) and Ironforge (103), because a district shell is refused as a building by
/// design. So `interiorDaylight` never reached a city room at any value, while 35 Stormwind rooms
/// are reached by no daylight or bleed fixture at all and many more only by a fixture whose 6-20 yd
/// pool dies half-way into a 40 yd hall.
///
/// The rule is the portal graph's, not a box test: a room is SKY-CONNECTED when it authors an
/// exterior-facing portal (the relation the PORTAL seed stands the sun in) or an EXT-class batch (the
/// APERTURE population: a window), or sits within
/// [`DISTRICT_SKY_HOPS`] interior portals of such a room. That lights the shops, taverns and their
/// back rooms and leaves what the data says is deep inside alone: the Stormwind canal tunnels,
/// the Slaughtered Lamb's cellar, and all of Ironforge except the gate halls (the city's ONE
/// exterior portal is `p0`, into `g7`).
pub fn district_sky_rooms<'a, I>(
groups: &[WmoGroupInfo],
portals: PortalGraph<'_>,
batches: I,
) -> Vec<bool>
where
I: IntoIterator<Item = (u16, bool, &'a [[f32; 3]])>,
{
if groups.iter().filter(|g| g.interior).count() <= DAYLIGHT_DISTRICT_ROOMS {
return Vec::new();
}
// Every portal's sides, as the PORTAL seed collects them.
let mut sides: HashMap<u16, Vec<u16>> = HashMap::new();
for (gi, (start, count)) in portals.slices.iter().enumerate() {
let (start, count) = (usize::from(*start), usize::from(*count));
for r in portals.refs.get(start..start + count).unwrap_or(&[]) {
for g in [gi as u16, r.group] {
let e = sides.entry(r.portal).or_default();
if !e.contains(&g) {
e.push(g);
}
}
}
}
let interior = |g: u16| groups.get(usize::from(g)).is_some_and(|g| g.interior);
let mut hop: Vec<Option<u8>> = vec![None; groups.len()];
let mut edges: Vec<(u16, u16)> = Vec::new();
for gs in sides.values() {
let ins: Vec<u16> = gs.iter().copied().filter(|g| interior(*g)).collect();
match ins.as_slice() {
[g] if gs.len() == 1 || gs.iter().any(|o| !interior(*o)) => {
if let Some(h) = hop.get_mut(usize::from(*g)) {
*h = Some(0);
}
}
[a, b] => edges.push((*a, *b)),
_ => {}
}
}
// …and a room with an EXT-class batch — a window or an open side (the APERTURE population).
for (g, ext_class, _) in batches {
if ext_class && interior(g) {
if let Some(h) = hop.get_mut(usize::from(g)) {
*h = Some(0);
}
}
}
for level in 0..DISTRICT_SKY_HOPS {
let mut next = Vec::new();
for &(a, b) in &edges {
for (from, to) in [(a, b), (b, a)] {
if hop[usize::from(from)] == Some(level) && hop[usize::from(to)].is_none() {
next.push(to);
}
}
}
for g in next {
hop[usize::from(g)] = Some(level + 1);
}
}
hop.iter().map(Option::is_some).collect()
}
/// The EFFECTIVE reach (yd) of an opening `diag` yards across — the radius `R` the shader actually
/// windows the pool with: `clamp(1.5*diag + 4, 6, 20)`. The linear term says a wide doorway throws
/// light further in than an arrow slit; the `+4` floor keeps even a small window's pool bigger than
@@ -622,6 +706,9 @@ where
let portal_seeds = out.len();
// --- 2. APERTURE seeds: an EXT-class batch of an INTERIOR group (see the module doc).
// MONKEY (daylight: window split): on a DISTRICT placement an EXT batch wider than
// [`APERTURE_MAX_DIAG`] is split into its spatial clusters first ([`window_boxes`]).
let district = groups.iter().filter(|g| g.interior).count() > DAYLIGHT_DISTRICT_ROOMS;
if want_apertures {
for &(gi, ext_class, positions) in &batches {
if !ext_class || interior_of(gi) != Some(true) {
@@ -630,35 +717,34 @@ where
let Some(g) = groups.get(usize::from(gi)) else {
continue;
};
let Some((lo, hi)) = bounds(positions.iter().copied()) else {
continue;
};
let diag = diagonal(lo, hi);
if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diag) {
continue;
for (lo, hi) in window_boxes(positions, district) {
let diag = diagonal(lo, hi);
if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diag) {
continue;
}
let (c, hz) = seed_point(lo, hi);
let pos = nudge_inward(c, g, None);
// The authored portal wins a shared opening — see [`SEED_DEDUPE_YD`].
let dup = out[..portal_seeds].iter().any(|s| {
(s.pos[0] - pos[0]).powi(2)
+ (s.pos[1] - pos[1]).powi(2)
+ (s.pos[2] - pos[2]).powi(2)
< SEED_DEDUPE_YD * SEED_DEDUPE_YD
});
if dup {
continue;
}
out.push(DaylightSeed {
group: gi,
portal: None,
how: DaylightHow::Aperture,
pos,
diag,
area: opening_area(lo, hi),
hz,
round: 0,
});
}
let (c, hz) = seed_point(lo, hi);
let pos = nudge_inward(c, g, None);
// The authored portal wins a shared opening — see [`SEED_DEDUPE_YD`].
let dup = out[..portal_seeds].iter().any(|s| {
(s.pos[0] - pos[0]).powi(2)
+ (s.pos[1] - pos[1]).powi(2)
+ (s.pos[2] - pos[2]).powi(2)
< SEED_DEDUPE_YD * SEED_DEDUPE_YD
});
if dup {
continue;
}
out.push(DaylightSeed {
group: gi,
portal: None,
how: DaylightHow::Aperture,
pos,
diag,
area: opening_area(lo, hi),
hz,
round: 0,
});
}
}
@@ -710,6 +796,120 @@ where
out
}
/// MONKEY (daylight: window split): the openings one EXT-class batch stands for, as boxes.
///
/// MEASURED (`benilla-extract wmolights` on Stormwind, 2026-09-25): the Cathedral of Light's nave
/// authors ALL its stained-glass windows as ONE EXT batch per wall — `g135 b11` spans
/// 15 x 44 x 20 yd (diag 51), `g146 b10` 30 x 44 x 10 yd (diag 54) — so [`APERTURE_MAX_DIAG`]
/// refused the batch whole and the nave took no daylight through a single window. Its windows are
/// MONKEY (fix-daylight): the district window split's gate (default on: the merged behaviour).
static WINDOW_SPLIT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// MONKEY (fix-daylight): `daylightWindowSplit`. Read when a placement's seeds are built, so a
/// change applies to WMOs loaded afterwards.
pub fn set_window_split(on: bool) {
WINDOW_SPLIT.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// separate panes between pillars, so the batch's vertices fall into separate clusters at the
/// plan linkage of [`plan_clusters`], and each window-sized cluster is an aperture.
///
/// District placements only, and only for an oversized batch: every building (the inn, the abbey)
/// keeps its calibrated seed list byte for byte, and a batch that is already window-sized is one
/// box as before. At most [`WINDOW_SPLIT_MAX`] boxes per batch, largest first.
fn window_boxes(positions: &[[f32; 3]], district: bool) -> Vec<([f32; 3], [f32; 3])> {
let Some((lo, hi)) = bounds(positions.iter().copied()) else {
return Vec::new();
};
// MONKEY (fix-daylight): the split changes the default image (Stormwind 25 -> 33 apertures),
// so it has its own gate, the `daylightWindowSplit` cvar ([`set_window_split`]).
let district = district && WINDOW_SPLIT.load(std::sync::atomic::Ordering::Relaxed);
if !district || diagonal(lo, hi) <= APERTURE_MAX_DIAG {
return vec![(lo, hi)];
}
let mut boxes: Vec<([f32; 3], [f32; 3])> = plan_clusters(positions)
.into_iter()
.filter(|(l, h)| (APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diagonal(*l, *h)))
.collect();
// Stable: `cluster_boxes` is already sorted, and ties keep that order.
boxes.sort_by(|a, b| opening_area(b.0, b.1).total_cmp(&opening_area(a.0, a.1)));
boxes.truncate(WINDOW_SPLIT_MAX);
boxes
}
/// MONKEY (daylight: window split): single-link clustering of `points` in PLAN (x, y) at
/// [`WINDOW_GAP_YD`], one 3-D `(lo, hi)` box per cluster. In plan because a pane's top and bottom
/// corners share their plan position however tall it is (a stained-glass pane is often one quad,
/// its vertices 8 yd apart vertically), while two panes of one wall are split by a pillar.
fn plan_clusters(points: &[[f32; 3]]) -> Vec<([f32; 3], [f32; 3])> {
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let cell = |p: [f32; 3]| {
[
(p[0] / WINDOW_GAP_YD).floor() as i32,
(p[1] / WINDOW_GAP_YD).floor() as i32,
]
};
let mut index: HashMap<[i32; 2], usize> = HashMap::new();
let mut cells: Vec<[i32; 2]> = Vec::new();
let of_point: Vec<usize> = points
.iter()
.map(|p| {
let c = cell(*p);
*index.entry(c).or_insert_with(|| {
cells.push(c);
cells.len() - 1
})
})
.collect();
let mut parent: Vec<usize> = (0..cells.len()).collect();
for (i, c) in cells.iter().enumerate() {
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(&j) = index.get(&[c[0] + dx, c[1] + dy]) {
let (a, b) = (find(&mut parent, i), find(&mut parent, j));
if a != b {
parent[a] = b;
}
}
}
}
}
let mut boxes: HashMap<usize, ([f32; 3], [f32; 3])> = HashMap::new();
for (p, ci) in points.iter().zip(of_point.iter()) {
let root = find(&mut parent, *ci);
let e = boxes.entry(root).or_insert((*p, *p));
for a in 0..3 {
e.0[a] = e.0[a].min(p[a]);
e.1[a] = e.1[a].max(p[a]);
}
}
let mut out: Vec<([f32; 3], [f32; 3])> = boxes.into_values().collect();
// Deterministic: a `HashMap` walk is not.
out.sort_by(|a, b| {
a.0[0]
.total_cmp(&b.0[0])
.then(a.0[1].total_cmp(&b.0[1]))
.then(a.0[2].total_cmp(&b.0[2]))
});
out
}
/// MONKEY (daylight: window split): the plan-cell size (yd) that links one pane's vertices and no
/// more: two points in neighbouring cells are at most ~4 yd apart, and a pillar between two panes
/// is wider than the one-cell gap it takes to split them.
const WINDOW_GAP_YD: f32 = 1.5;
/// MONKEY (daylight: window split): the most apertures one EXT batch may become. A nave wall is
/// three to five panes; the cap keeps a pathological batch (a lattice of posts) from flooding the
/// placement's quota, which ranks one room's second opening after every room's first anyway.
const WINDOW_SPLIT_MAX: usize = 6;
/// MONKEY (daylight fixtures: boundary): every stitched interior<->exterior opening of one root, as
/// `(interior group, cluster lo, cluster hi, inward unit direction)` in WMO model space.
///
@@ -2035,6 +2235,10 @@ pub(super) fn register(app: &mut App) {
);
}
// MONKEY (daylight): the city census instrument.
#[cfg(test)]
mod census;
#[cfg(test)]
mod tests {
use super::*;
@@ -2049,6 +2253,64 @@ mod tests {
}
}
/// MONKEY (daylight: district sky rooms): a building gets nothing; a district flags the room
/// with an exterior portal and its one-hop neighbour, and not the room two hops in.
#[test]
fn district_sky_rooms_follow_the_portal_graph() {
let lo = [0.0; 3];
let hi = [1.0; 3];
// g0 exterior shell, g1..=g3 a chain of rooms, padded with sealed rooms to district size.
let mut groups = vec![group(false, lo, hi)];
groups.extend((0..40).map(|_| group(true, lo, hi)));
let vertices = [[0.0f32; 3]; 4];
let infos: Vec<WmoPortalInfo> = (0..3)
.map(|_| WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] })
.collect();
// p0: g1<->g0 (exterior), p1: g1<->g2, p2: g2<->g3.
let refs = [
WmoPortalRef { portal: 0, group: 0, side: 1 },
WmoPortalRef { portal: 1, group: 2, side: 1 },
WmoPortalRef { portal: 1, group: 1, side: -1 },
WmoPortalRef { portal: 2, group: 3, side: 1 },
WmoPortalRef { portal: 2, group: 2, side: -1 },
];
let mut slices = vec![(0u16, 0u16); groups.len()];
slices[1] = (0, 2);
slices[2] = (2, 2);
slices[3] = (4, 1);
let portals = PortalGraph { vertices: &vertices, infos: &infos, refs: &refs, slices: &slices };
let none: [(u16, bool, &[[f32; 3]]); 0] = [];
let sky = district_sky_rooms(&groups, portals, none);
assert!(sky[1] && sky[2], "the doorway room and its neighbour");
assert!(!sky[3], "two hops in stays dark");
assert!(!sky[0], "the shell is not a room");
assert!(district_sky_rooms(&groups[..5], portals, none).is_empty(), "a building is untouched");
// A window (EXT-class batch) makes g3 a sky room too.
let window = [[0.0f32; 3], [1.0, 1.0, 1.0]];
let sky = district_sky_rooms(&groups, portals, [(3u16, true, &window[..])]);
assert!(sky[3], "a room with a window");
}
/// MONKEY (daylight: window split): a district's wall-long EXT batch becomes its panes; a
/// building's stays one box (and is then refused as before).
#[test]
fn a_district_window_wall_splits_into_panes() {
// Three 2 x 0.2 x 4 yd panes, 10 yd apart along +Y: one batch, diag > 24.
let mut pts = Vec::new();
for i in 0..3 {
let y = i as f32 * 12.0;
for (dy, dz) in [(0.0, 0.0), (2.0, 0.0), (0.0, 4.0), (2.0, 4.0), (1.0, 2.0)] {
pts.push([0.0, y + dy, dz]);
pts.push([0.2, y + dy, dz]);
}
}
pts.push([0.0, 0.0, 0.0]);
assert_eq!(window_boxes(&pts, false).len(), 1, "a building keeps the whole batch");
let panes = window_boxes(&pts, true);
assert_eq!(panes.len(), 3, "a district splits it into its panes");
assert!(panes.iter().all(|(l, h)| diagonal(*l, *h) < 6.0));
}
/// The reach formula: linear in the opening's diagonal, floored at 6 and capped at 20.
#[test]
fn reach_is_the_clamped_opening_width() {
@@ -0,0 +1,343 @@
//! MONKEY (daylight): the city daylight census (test-only instrument). Loads a WMO root from the
//! install, runs the live selection rule over it and prints which interior rooms get a daylight
//! seed, which do not, and what each unseeded room authors (batch classes, WINDOW / SIDN
//! materials, portal neighbours), plus the cost of each seed pass.
//!
//! `WOW_DATA=<Data> cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture`
//! `WOW_CENSUS_WMO=<internal path>` picks the root (default: Stormwind then Ironforge).
use super::*;
use benilla_formats::{open_chain, parse_wmo_portals, parse_wmo_root, wmo_group_header};
struct Loaded {
groups: Vec<WmoGroupInfo>,
names: Vec<String>,
portals: benilla_formats::WmoPortals,
slices: Vec<(u16, u16)>,
/// `(group, class, window, sidn, positions)` per render batch.
batches: Vec<(
u16,
benilla_formats::WmoBatchClass,
bool,
bool,
Vec<[f32; 3]>,
)>,
}
fn group_names(bytes: &[u8]) -> Vec<String> {
let (mut mogn, mut mogi) = (None, None);
let mut o = 0usize;
while o + 8 <= bytes.len() {
let tag = [bytes[o + 3], bytes[o + 2], bytes[o + 1], bytes[o]];
let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().unwrap()) as usize;
let body = bytes.get(o + 8..o + 8 + n);
match (&tag, body) {
(b"MOGN", Some(b)) => mogn = Some(b),
(b"MOGI", Some(b)) => mogi = Some(b),
_ => {}
}
o += 8 + n;
}
let (Some(mogn), Some(mogi)) = (mogn, mogi) else {
return Vec::new();
};
mogi.chunks_exact(32)
.map(|r| {
let off = i32::from_le_bytes(r[28..32].try_into().unwrap());
usize::try_from(off)
.ok()
.and_then(|off| mogn.get(off..))
.map(|rest| {
let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
String::from_utf8_lossy(&rest[..end]).into_owned()
})
.unwrap_or_default()
})
.collect()
}
fn load(path: &str) -> Loaded {
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = open_chain(&data).expect("open MPQ chain");
let root_path = path.replace('/', "\\").to_ascii_lowercase();
let bytes = chain.read_file(&root_path).expect("read root");
let root = parse_wmo_root(&bytes).expect("parse root");
let stem = root_path.strip_suffix(".wmo").unwrap().to_string();
let mut slices = Vec::new();
let mut batches = Vec::new();
for gi in 0..root.group_count() {
let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
slices.push((0, 0));
continue;
};
let h = wmo_group_header(&gbytes);
slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)));
for s in benilla_formats::wmo_group_submeshes(&gbytes, &root).unwrap_or_default() {
if let Some(class) = s.wmo_batch {
batches.push((gi as u16, class, s.window, s.sidn.is_some(), s.positions));
}
}
}
Loaded {
groups: root.group_infos().to_vec(),
names: group_names(&bytes),
portals: parse_wmo_portals(&bytes),
slices,
batches,
}
}
fn census(path: &str) {
let l = load(path);
let graph = PortalGraph {
vertices: &l.portals.vertices,
infos: &l.portals.infos,
refs: &l.portals.refs,
slices: &l.slices,
};
let feed: Vec<(u16, bool, &[[f32; 3]])> = l
.batches
.iter()
.map(|(g, c, _, _, p)| (*g, matches!(c, benilla_formats::WmoBatchClass::Ext), &p[..]))
.collect();
let verts: usize = feed.iter().map(|b| b.2.len()).sum();
let rooms = l.groups.iter().filter(|g| g.interior).count();
println!(
"=== {path}: {} groups, {rooms} interior, {} batches, {verts} batch verts",
l.groups.len(),
feed.len()
);
let t = std::time::Instant::now();
let ranked = daylight_seeds_ranked(&l.groups, graph, feed.iter().copied());
let t_ranked = t.elapsed();
let t = std::time::Instant::now();
let bounds = boundary_clusters(&l.groups, &feed);
let t_bound = t.elapsed();
let (day, bleed) = placement_openings(&l.groups, graph, feed.iter().copied());
println!(
"live ranked seeds {} ({:.1} ms); boundary clusters if forced {} ({:.1} ms); kept {} day + {} bleed of budget {}",
ranked.len(),
t_ranked.as_secs_f64() * 1e3,
bounds.len(),
t_bound.as_secs_f64() * 1e3,
day.len(),
bleed.len(),
daylight_budget(&l.groups),
);
let mut by_how: HashMap<&str, usize> = HashMap::new();
for s in &day {
*by_how.entry(s.how.tag()).or_default() += 1;
}
println!("kept daylight by rule: {by_how:?}");
// Which rooms each lane actually REACHES: the fixture's own claim set (containment, portal hop,
// split), the same set the packer's room gate enforces.
let mut day_claim: HashMap<u16, Vec<String>> = HashMap::new();
for s in &day {
for c in daylight_claims(&l.groups, graph, s) {
day_claim.entry(c.group).or_default().push(format!(
"{}@g{}{:?}",
s.how.tag(),
s.group,
c.how
));
}
}
let mut bleed_claim: HashSet<u16> = HashSet::new();
for s in &bleed {
for c in s.claims(&l.groups, graph) {
bleed_claim.insert(c.group);
}
}
let bound_groups: HashSet<u16> = bounds.iter().map(|b| b.0).collect();
let place = placement();
let (mut n_day, mut n_bleed, mut n_none) = (0, 0, 0);
for (gi, g) in l.groups.iter().enumerate() {
if !g.interior {
continue;
}
let gid = gi as u16;
let tag = if day_claim.contains_key(&gid) {
n_day += 1;
"DAY "
} else if bleed_claim.contains(&gid) {
n_bleed += 1;
"bleed"
} else {
n_none += 1;
"NONE "
};
let (start, count) = l.slices[gi];
let mut nb = Vec::new();
for r in l
.portals
.refs
.iter()
.skip(start as usize)
.take(count as usize)
{
let other =
l.groups
.get(r.group as usize)
.map_or('?', |o| if o.interior { 'i' } else { 'e' });
let area = benilla_formats::room_claim::portal_area(&graph, r.portal).unwrap_or(0.0);
nb.push(format!("p{}->g{}{}({:.0})", r.portal, r.group, other, area));
}
let (mut e, mut w) = (0, 0);
for (bg, c, win, _, _) in &l.batches {
if usize::from(*bg) == gi {
e += usize::from(matches!(c, benilla_formats::WmoBatchClass::Ext));
w += usize::from(*win);
}
}
let c = [
0.5 * (g.bbox_min[0] + g.bbox_max[0]),
0.5 * (g.bbox_min[1] + g.bbox_max[1]),
0.5 * (g.bbox_min[2] + g.bbox_max[2]),
];
let wc = place.map_or(c, |m| {
benilla_assets::coords::bevy_to_wow(
m.transform_point3(benilla_assets::coords::wow_to_bevy(c)),
)
});
println!(
" {tag} g{gi:<3} {:<18} at ({:8.1},{:7.1},{:6.1}) size ({:5.1},{:5.1},{:5.1}) E{e} W{w}{} {} {}",
l.names.get(gi).map(String::as_str).unwrap_or("-"),
wc[0], wc[1], wc[2],
g.bbox_max[0] - g.bbox_min[0],
g.bbox_max[1] - g.bbox_min[1],
g.bbox_max[2] - g.bbox_min[2],
if bound_groups.contains(&gid) { " stitch" } else { "" },
nb.join(" "),
day_claim.get(&gid).map(|v| v.iter().take(3).cloned().collect::<Vec<_>>().join(",")).unwrap_or_default(),
);
}
println!(
"interior rooms: {n_day} reached by daylight, {n_bleed} by bleed only, {n_none} by neither"
);
let sky = district_sky_rooms(&l.groups, graph, feed.iter().copied());
let enclosed = (0..l.groups.len() as u16)
.filter(|g| benilla_formats::room_claim::enclosed_by_building_shell(&l.groups, *g))
.count();
let dark_sky = (0..l.groups.len())
.filter(|g| sky.get(*g).copied().unwrap_or(false))
.filter(|g| !day_claim.contains_key(&(*g as u16)))
.count();
println!(
"day floor: {enclosed} rooms enclosed by a building shell; {} district sky rooms ({dark_sky} of them reached by no daylight fixture)",
sky.iter().filter(|b| **b).count()
);
}
/// `WOW_CENSUS_PLACE=map,tx,ty,uid`: the MODF placement, for world coordinates in the table.
fn placement() -> Option<bevy::math::Affine3A> {
let spec = std::env::var("WOW_CENSUS_PLACE").ok()?;
let p: Vec<&str> = spec.split(',').collect();
let (map, tx, ty, uid) = (
p[0],
p[1].parse().ok()?,
p[2].parse().ok()?,
p[3].parse::<u32>().ok()?,
);
let data = benilla_formats::wow_data()?;
let mut chain = open_chain(&data).ok()?;
let tile = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty).ok()?;
let w = tile.wmos.iter().find(|w| w.unique_id == uid)?;
println!(
"placement uid {uid} pos {:?} rot {:?}",
w.position, w.rotation
);
Some(bevy::math::Affine3A::from_scale_rotation_translation(
Vec3::ONE,
benilla_assets::coords::placement_rotation(w.rotation),
benilla_assets::coords::wow_to_bevy(w.position),
))
}
#[test]
#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"]
fn city_daylight_census() {
match std::env::var("WOW_CENSUS_WMO") {
Ok(p) => census(&p),
Err(_) => {
census(r"World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo");
census(r"World\wmo\KhazModan\Cities\Ironforge\ironforge.wmo");
}
}
}
/// MONKEY (daylight: terrain torch casters): which lampposts have ground they cannot see — the
/// share of terrain points within 25 yd whose line to the lamp head (3.5 yd up) is blocked by the
/// terrain itself. Picks a vantage for the B1 capture. `WOW_LAMP_SCAN=map,x,y,radius,filter`.
#[test]
#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"]
fn lamp_terrain_occlusion_scan() {
let spec = std::env::var("WOW_LAMP_SCAN")
.unwrap_or_else(|_| "Azeroth,-9300,150,1,lamppost".into());
let p: Vec<&str> = spec.split(',').collect();
let (map, cx, cy, r, filter) = (
p[0],
p[1].parse::<f32>().unwrap(),
p[2].parse::<f32>().unwrap(),
p[3].parse::<i32>().unwrap(),
p[4].to_ascii_lowercase(),
);
let data = benilla_formats::wow_data().expect("WOW_DATA");
let mut chain = open_chain(&data).expect("chain");
let (tx, ty) = benilla_formats::world_to_tile(cx, cy);
let mut chunks = Vec::new();
let mut lamps = Vec::new();
for x in tx as i32 - r..=tx as i32 + r {
for y in ty as i32 - r..=ty as i32 + r {
let Ok(t) = benilla_formats::load_tile_mesh(&mut chain, map, x as u32, y as u32) else {
continue;
};
for d in &t.doodads {
if d.model.to_ascii_lowercase().contains(&filter) {
lamps.push(d.position);
}
}
chunks.push(t.chunks);
}
}
let h = |x: f32, y: f32| {
chunks
.iter()
.find_map(|c| benilla_formats::terrain_height_at(c, [x, y, 1.0e4]))
};
let mut rows = Vec::new();
for l in lamps {
let head = Vec3::new(l[0], l[1], l[2] + 3.5);
let (mut total, mut blocked) = (0u32, 0u32);
let mut best = (0.0f32, 0.0f32);
for ring in 1..=10 {
let d = ring as f32 * 2.5;
for k in 0..36 {
let a = k as f32 * std::f32::consts::TAU / 36.0;
let (x, y) = (l[0] + d * a.cos(), l[1] + d * a.sin());
let Some(z) = h(x, y) else { continue };
total += 1;
let target = Vec3::new(x, y, z + 0.3);
let steps = (d / 0.5) as i32;
let hit = (1..steps).any(|s| {
let q = head.lerp(target, s as f32 / steps as f32);
h(q.x, q.y).is_some_and(|g| g > q.z + 0.05)
});
if hit {
blocked += 1;
best = (a.to_degrees(), d);
}
}
}
if total > 0 {
rows.push((blocked as f32 / total as f32, l, best));
}
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
for (f, l, (a, d)) in rows.iter().take(12) {
println!(
"lamp ({:8.1},{:7.1},{:6.1}) terrain-blocked {:5.1}% e.g. bearing {a:5.0} deg at {d:4.1} yd",
l[0], l[1], l[2], f * 100.0
);
}
}
@@ -0,0 +1,310 @@
//! MONKEY (fog): the Modern fog model's CPU half — fills the FOG rows of [`MonkeyFrame`] every
//! frame. The law itself lives in `benilla-assets/src/shaders/fog_hook.wgsl`.
//!
//! Setting: cvar `fogModel` (0 Classic = the 1.12 linear fog, byte-identical; 1 Modern), bridged by
//! `benilla-app/src/monkey_gfx.rs` into [`FogModelSetting`]; `WOW_FOGMODEL=0|1` overrides it for a
//! session (captures).
//!
//! Modern, per frame:
//! - **Distances** stay the zone's: the shader's exponential curve is fitted to the 1.12 start/end
//! pair it already receives. Only past the reference's farclip ceiling does the end grow with the
//! view distance ([`modern_fog_end`], called from `resolve.rs`), so a larger `farclip` shows more.
//! - **Colours** without `LightFogBand.dbc` rows are derived from the 1.12 bands
//! ([`derived_band`]): the sun-fog lobe leans the fog toward the sun colour (IntBand 9) around the
//! sun and fades out at night; the far fog shifts toward the sky's lowest ring (1.8°) so the
//! fogged world, the WDL hull and the sky dome's horizon meet in one colour. Height fog is off.
//! - With `LightFogBand.dbc` rows, those override the derivation per `LightParams`, blended over
//! the same area-light chain as the stock bands.
//!
//! Packed rows (see `monkey_frame.rs`): `fog_a` height density / height (Bevy Y) / falloff / curve
//! blend; `fog_b` sun rgb / strength × day; `fog_c` end rgb / end distance; `fog_d` the scene fog end
//! (> 0 = Modern; the shader applies the model only to spans ending there, so an interior WMO fog
//! stays classic) / sun-fog cosine / the sun direction, octahedral-encoded.
use bevy::prelude::*;
use benilla_assets::{LockRecover, WorldAssets};
use benilla_formats::{FogBand, FogBandCatalog};
use super::{FogModel, LightSampler, MonkeyFrame, WowLighting};
use crate::terrain_stream::SPAWN_XY;
use crate::view::WorldCamera;
use crate::world_map::CurrentMap;
use benilla_assets::coords::bevy_to_wow;
/// The live fog-model choice (cvar `fogModel`). Default Classic; `WOW_FOGMODEL` overrides.
#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
pub struct FogModelSetting(pub FogModel);
impl Default for FogModelSetting {
fn default() -> Self {
FogModelSetting(env_override().unwrap_or(FogModel::Classic))
}
}
/// `WOW_FOGMODEL=0|1`, read once.
fn env_override() -> Option<FogModel> {
static ENV: std::sync::OnceLock<Option<FogModel>> = std::sync::OnceLock::new();
*ENV.get_or_init(|| {
std::env::var("WOW_FOGMODEL").ok().and_then(|v| match v.trim() {
"0" => Some(FogModel::Classic),
"1" => Some(FogModel::Modern),
_ => None,
})
})
}
impl FogModelSetting {
/// Apply a cvar write; the env override, when set, wins for the session.
pub fn set_from_cvar(&mut self, v: f32) {
let want = env_override().unwrap_or(if v >= 0.5 {
FogModel::Modern
} else {
FogModel::Classic
});
if self.0 != want {
self.0 = want;
}
}
pub fn modern(&self) -> bool {
self.0 == FogModel::Modern
}
}
/// `LightFogBand.dbc`, loaded once; empty without the file.
#[derive(Resource, Default)]
pub struct FogBandTable(pub FogBandCatalog);
/// The reference client's farclip ceiling (`FARCLIP_RANGE` end). Up to it Modern keeps the 1.12
/// fog pair exactly; beyond it the fog end scales with the view distance.
pub const FOG_REF_FARCLIP: f32 = 777.0;
/// The Modern scene fog pair: the zone's end, stretched by `farclip / 777` once the view distance
/// passes the reference ceiling, and never past `farclip` (the wall). Classic is
/// `(frac·min(end, farclip), min(end, farclip))`; at `farclip ≤ 777` the two are identical.
pub fn modern_fog_end(fog_end_raw: f32, fog_start_frac: f32, farclip: f32) -> (f32, f32) {
let stretch = (farclip / FOG_REF_FARCLIP).max(1.0);
let end = (fog_end_raw * stretch).min(farclip);
(fog_start_frac * end, end)
}
/// Sun-fog lobe defaults (no `LightFogBand` row): strength and the cosine the lobe starts at.
pub const DERIVED_SUN_FOG_STRENGTH: f32 = 0.25;
pub const DERIVED_SUN_FOG_ANGLE: f32 = 0.4;
/// How far the sun-fog colour leans from the fog colour toward the sun colour (IntBand 9).
const DERIVED_SUN_FOG_LEAN: f32 = 0.4;
/// How far the end-fog colour leans from the fog colour toward the 1.8° sky ring.
const DERIVED_END_FOG_LEAN: f32 = 0.25;
fn mix3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
]
}
/// The Modern fields from 1.12 data alone: no height fog, the exponential curve, a sun-fog lobe in
/// the sun's colour and an end colour leaning to the sky's lowest ring, ending at the scene fog end.
pub fn derived_band(l: &WowLighting) -> FogBand {
FogBand {
height_density: 0.0,
height: 0.0,
height_falloff: 0.0,
curve_blend: 0.0,
sun_rgb: mix3(l.fog_color, l.spec, DERIVED_SUN_FOG_LEAN),
sun_strength: DERIVED_SUN_FOG_STRENGTH,
sun_angle: DERIVED_SUN_FOG_ANGLE,
end_rgb: mix3(l.fog_color, l.sky[4], DERIVED_END_FOG_LEAN),
end_distance: 0.0,
}
}
/// The sun-fog day factor from the visible sun's height (`sin(elevation)`): full from ~3° up,
/// fading through the horizon to 0 by ~6° below it (the modern client's SunAngleBlend ramp).
pub fn sun_fog_day(sun_y: f32) -> f32 {
let t = ((sun_y + 0.1) / 0.15).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Octahedral encoding of a unit vector into `[-1, 1]²` (the shader's `oct_decode`).
pub fn oct_encode(v: Vec3) -> [f32; 2] {
let v = v.normalize_or(Vec3::Y);
let n = v / (v.x.abs() + v.y.abs() + v.z.abs());
if n.y >= 0.0 {
[n.x, n.z]
} else {
let sx = if n.x >= 0.0 { 1.0 } else { -1.0 };
let sz = if n.z >= 0.0 { 1.0 } else { -1.0 };
[(1.0 - n.z.abs()) * sx, (1.0 - n.x.abs()) * sz]
}
}
/// Write the FOG rows of `frame` for `band` under the resolved light. Classic zeroes them.
pub fn fill_frame(frame: &mut MonkeyFrame, model: FogModel, l: &WowLighting, band: &FogBand) {
if model == FogModel::Classic {
frame.fog_model = FogModel::Classic;
frame.fog_scene_end = 0.0;
frame.height_fog_density = 0.0;
frame.height_fog_height = 0.0;
frame.height_fog_falloff = 0.0;
frame.fog_curve_blend = 0.0;
frame.sun_fog_rgb = [0.0; 3];
frame.sun_fog_strength = 0.0;
frame.sun_fog_angle = 0.0;
frame.sun_fog_dir = [0.0; 2];
frame.end_fog_rgb = [0.0; 3];
frame.end_fog_distance = 0.0;
return;
}
let sun = l.celestial_dir;
frame.fog_model = FogModel::Modern;
frame.fog_scene_end = l.fog_end;
frame.height_fog_density = band.height_density.max(0.0);
// WoW Z is Bevy Y.
frame.height_fog_height = band.height;
frame.height_fog_falloff = band.height_falloff.max(0.0);
frame.fog_curve_blend = band.curve_blend.clamp(0.0, 1.0);
frame.sun_fog_rgb = band.sun_rgb;
frame.sun_fog_strength = band.sun_strength.clamp(0.0, 1.0) * sun_fog_day(sun.y);
frame.sun_fog_angle = band.sun_angle.clamp(-1.0, 0.999);
frame.sun_fog_dir = oct_encode(sun);
frame.end_fog_rgb = band.end_rgb;
frame.end_fog_distance = if band.end_distance > 0.0 {
band.end_distance
} else {
l.fog_end
};
}
/// Startup: read `LightFogBand.dbc` (absent → empty).
pub(super) fn load_fog_bands(mut commands: Commands, world_assets: Option<ResMut<WorldAssets>>) {
let Some(world_assets) = world_assets else {
return;
};
let mut chain = world_assets.chain.lock_recover();
let cat = FogBandCatalog::load(&mut chain);
if !cat.is_empty() {
info!("LightFogBand.dbc loaded: Modern fog uses its authored fields");
}
commands.insert_resource(FogBandTable(cat));
}
/// Per frame: the Modern fog fields into [`MonkeyFrame`] (after the light resolve).
#[allow(clippy::too_many_arguments)]
pub(super) fn update_fog_model(
setting: Res<FogModelSetting>,
lighting: Res<WowLighting>,
table: Option<Res<FogBandTable>>,
sampler: Option<Res<LightSampler>>,
clock: Res<super::GameClock>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
current_map: Option<Res<CurrentMap>>,
eye_liquid: crate::liquid::EyeLiquid,
viewer: Res<crate::view::Viewer>,
weather: Option<Res<crate::weather::WeatherState>>,
mut frame: ResMut<MonkeyFrame>,
) {
let derived = derived_band(&lighting);
let band = match (&table, &sampler, setting.modern()) {
(Some(t), Some(s), true) if !t.0.is_empty() => {
let pos = match cam.single() {
Ok(t) => bevy_to_wow(t.translation()),
Err(_) => [SPAWN_XY.0, SPAWN_XY.1, 83.5],
};
let map = current_map.as_ref().map_or(0, |m| m.0);
let sub = eye_liquid.submersion();
let time = clock.minute * 2;
let storm = weather
.as_ref()
.map_or(0.0, |w| crate::weather::storm_blend(w.sky_density));
let chain = |stormy| s.0.blend_chain(map, pos, stormy, sub, viewer.ghost);
let clear = t.0.sample_chain(&chain(false), time, &derived);
let stormy = (storm > 0.0)
.then(|| t.0.sample_chain(&chain(true), time, &derived))
.flatten();
match (clear, stormy) {
(None, None) => derived,
(c, s) => c.unwrap_or(derived).lerp(&s.unwrap_or(derived), storm),
}
}
_ => derived,
};
let mut next = *frame;
fill_frame(&mut next, setting.0, &lighting, &band);
if *frame != next {
*frame = next;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modern_end_matches_classic_up_to_the_reference_farclip() {
for far in [177.0, 350.0, 500.0, 777.0] {
for end in [120.0, 444.0, 900.0] {
let classic_end: f32 = f32::min(end, far);
let (s, e) = modern_fog_end(end, 0.25, far);
assert_eq!(e, classic_end);
assert_eq!(s, 0.25 * classic_end);
}
}
}
#[test]
fn modern_end_grows_past_the_reference_farclip() {
let (_, e) = modern_fog_end(444.0, 0.25, 1554.0);
assert!((e - 888.0).abs() < 1e-3);
// Never past the wall.
let (_, e) = modern_fog_end(900.0, 0.25, 1000.0);
assert_eq!(e, 1000.0);
}
#[test]
fn classic_leaves_the_fog_rows_zero() {
let mut f = MonkeyFrame::default();
let l = WowLighting::default();
fill_frame(&mut f, FogModel::Classic, &l, &derived_band(&l));
assert_eq!(f, MonkeyFrame::default());
assert!(f.pack(0.0, 0.0)[..4].iter().flatten().all(|v| *v == 0.0));
}
#[test]
fn modern_marks_the_scene_end_and_fades_the_sun_at_night() {
let mut l = WowLighting::default();
l.fog_end = 444.0;
l.celestial_dir = Vec3::new(0.0, 0.8, 0.6);
let mut f = MonkeyFrame::default();
fill_frame(&mut f, FogModel::Modern, &l, &derived_band(&l));
assert_eq!(f.fog_scene_end, 444.0);
assert_eq!(f.end_fog_distance, 444.0);
assert!((f.sun_fog_strength - DERIVED_SUN_FOG_STRENGTH).abs() < 1e-6);
l.celestial_dir = Vec3::new(0.0, -0.5, 0.86);
fill_frame(&mut f, FogModel::Modern, &l, &derived_band(&l));
assert_eq!(f.sun_fog_strength, 0.0);
}
#[test]
fn oct_encoding_round_trips() {
fn decode(e: [f32; 2]) -> Vec3 {
let mut v = Vec3::new(e[0], 1.0 - e[0].abs() - e[1].abs(), e[1]);
let t = (-v.y).max(0.0);
v.x += if v.x >= 0.0 { -t } else { t };
v.z += if v.z >= 0.0 { -t } else { t };
v.normalize()
}
for d in [
Vec3::Y,
-Vec3::Y,
Vec3::new(0.3, 0.2, -0.9),
Vec3::new(-0.7, -0.1, 0.7),
Vec3::new(0.1, -0.9, -0.4),
] {
let d = d.normalize();
assert!(decode(oct_encode(d)).dot(d) > 0.9999, "{d:?}");
}
}
}
@@ -66,6 +66,9 @@ use crate::view::WorldCamera;
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`].
monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS],
}
/// Header row count of the layout above (rows 0..=20), which every light blob sizes against.
@@ -474,6 +477,11 @@ 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.
#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug)]
pub struct EmissiveTier(pub u8);
/// MONKEY (spellLightGain): marks a `PointLight` that a SPELL EFFECT invented — a kit's aura glow,
/// a missile's core, an impact flash, a firework shell's burst. The app's own
/// `entities::spell_fx::SpellLight` carries the envelope, the mode and the budget; this is the
@@ -606,6 +614,29 @@ pub struct WorldPointLight {
pub range: f32,
}
/// MONKEY (lampfog): one live entry exactly as [`build_light_data`] resolved it for the shared
/// point table. Colour is already `colour x intensity`, with the source's live gain and flame
/// flicker folded in; position is Bevy world space and `lane` keeps the table's exterior/interior
/// discriminator (an interior lane's value is also its effective reach).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ResolvedPointLight {
pub position: Vec3,
pub range: f32,
pub color: Vec3,
pub lane: f32,
}
/// MONKEY (lampfog): read-only CPU view of the shared point table for post-process consumers.
/// The GPU blob stays private and its pinned layout does not grow; readers get only the live rows.
#[derive(Resource, Default)]
pub struct ResolvedPointLights(Vec<ResolvedPointLight>);
impl ResolvedPointLights {
pub fn as_slice(&self) -> &[ResolvedPointLight] {
&self.0
}
}
/// The packed light for this frame, extracted for [`upload_light`].
#[derive(Resource, Clone, Copy, ExtractResource)]
struct WowLightData(LightStd430);
@@ -615,6 +646,7 @@ impl Default for WowLightData {
Self(LightStd430 {
rows: [[0.0; 4]; 21],
points: [[0.0; 4]; 2 * MAX_POINT_LIGHTS],
monkey: [[0.0; 4]; super::monkey_frame::MONKEY_FRAME_ROWS],
})
}
}
@@ -627,6 +659,8 @@ pub struct SharedLightBuffer(pub Buffer);
/// Registers the light pack, the probe publish, their extracts and the render-world uploads.
pub(super) fn register(app: &mut App) {
app.init_resource::<WowLightData>()
// MONKEY (lampfog): CPU mirror of only the live point rows; not extracted or GPU-bound.
.init_resource::<ResolvedPointLights>()
// MONKEY (room gate): the per-fixture room claims, packed beside the point table.
.init_resource::<RoomClaimTable>()
.init_resource::<WorldShadowActive>()
@@ -641,9 +675,13 @@ pub(super) fn register(app: &mut App) {
.init_resource::<DynamicInteriors>()
// MONKEY (fire GO lights): the live gain on synthesised fire lights.
.init_resource::<FireLightGain>()
// MONKEY (post): opt-in HDR emission, packed into an existing float lane.
.init_resource::<EmissiveTier>()
// MONKEY (spellLightGain): and the one on spell-effect lights, which overrides it.
.init_resource::<SpellLightGain>()
.init_resource::<super::prop_probes::PropProbeExtract>()
// MONKEY (p0 MonkeyFrame): the per-frame programme parameters any system may write.
.init_resource::<super::MonkeyFrame>()
.add_plugins(ExtractResourcePlugin::<WowLightData>::default())
.add_plugins(ExtractResourcePlugin::<RoomClaimTable>::default())
.add_plugins(ExtractResourcePlugin::<SharedLightBuffer>::default())
@@ -656,7 +694,8 @@ pub(super) fn register(app: &mut App) {
// a light that has stood for a frame is packed on this frame's verdict. Its writes go
// through `Commands`, so a NEWLY spawned light is still packed on the fail-safe
// fallback for one frame — see that fallback's note in `build_light_data`.
(classify_light_lanes, build_light_data)
// MONKEY (p0 MonkeyFrame): the programme block is packed right after the table.
(classify_light_lanes, build_light_data, pack_monkey_frame)
.chain()
.after(bevy::transform::TransformSystems::Propagate)
.after(super::update_time_lighting),
@@ -690,15 +729,34 @@ pub fn new_shared_light_buffer(device: &RenderDevice) -> SharedLightBuffer {
/// whole layout at each draw.
pub fn light_blob_bytes() -> u64 {
per_frame_blob_bytes()
// MONKEY (rainshelter): the rain-occlusion grid region (`weather::shelter`).
+ crate::weather::shelter::REGION_BYTES
+ (7 * MAX_PROP_PROBES * 16) as u64
+ crate::rig_palette::palette_regions_bytes()
}
/// The per-frame prefix's size, which is also the probe region's offset.
pub(super) fn per_frame_blob_bytes() -> u64 {
/// The per-frame prefix's size, which is also the rain-shelter region's offset (MONKEY
/// (rainshelter); the probe region follows that).
pub(crate) fn per_frame_blob_bytes() -> u64 {
std::mem::size_of::<LightStd430>() as u64
}
/// MONKEY (p0 MonkeyFrame): copies [`super::MonkeyFrame`] into the block after the point table,
/// plus the two clock fields the packer owns (`misc.y` time of day 0..1, `misc.z` night 0..1, the
/// same dusk ramp `nightGain` uses). Writes through `ResMut` only when a row moved.
fn pack_monkey_frame(
frame: Res<super::MonkeyFrame>,
clock: Res<super::GameClock>,
light: Res<WowLighting>,
mut data: ResMut<WowLightData>,
) {
let night = 1.0 - sun_shadow_strength(light.celestial_dir.y);
let packed = frame.pack(clock.minute as f32 / 1440.0, night);
if data.0.monkey != packed {
data.0.monkey = packed;
}
}
/// Pack the resolved [`WowLighting`] (+ the global fog-disable toggle and the view farclip) into the
/// std430 blob. The `.w` lanes carry the faithful invariants the shaders expect (Mod2x 1.0, clamp on,
/// terrain shininess 20, fog-enable, farclip wall); the model SH coeffs and both water swatches are
@@ -1278,6 +1336,8 @@ fn build_light_data(
// The per-frame portal PVS, for the room term below ([`LightRooms`]).
portals: Query<&crate::wmo_portal::WmoPortalInstance>,
mut data: ResMut<WowLightData>,
// MONKEY (lampfog): publish the already-resolved live rows without exposing the GPU blob.
mut resolved_points: ResMut<ResolvedPointLights>,
// MONKEY (room gate): packed in the same walk as the point table, so the two can never
// disagree about which entry is which.
mut claims: ResMut<RoomClaimTable>,
@@ -1286,10 +1346,11 @@ fn build_light_data(
shadow: ShadowLanes,
// MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`.
dynamic_interiors: Res<DynamicInteriors>,
// MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
fire_gain: Res<FireLightGain>,
// MONKEY (spellLightGain): and the spell lane's own, which overrides it on a spell row.
spell_gain: Res<SpellLightGain>,
// MONKEY (integration): one tuple param, so the system stays within Bevy's 16-param limit.
// - MONKEY (post): the 0/1/2 tier rides the unused `light_diffuse.w` marker.
// - MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
// - MONKEY (spellLightGain): and the spell lane's own, which overrides it on a spell row.
(emissive, fire_gain, spell_gain): (Res<EmissiveTier>, Res<FireLightGain>, Res<SpellLightGain>),
mut last_dump: Local<f64>,
mut last_rows_dump: Local<f64>,
) {
@@ -1353,6 +1414,8 @@ fn build_light_data(
// this packs (rows 0/1, the SH block, the sun's DC redistribution) is LINEAR in that triple, so
// one multiply at the input dims all of them consistently and no derived row can be missed.
pack_model_core_rows(rows, night_dim(l.ambient), night_dim(l.diffuse), l.sun_dir);
// MONKEY (post): old value 1 remains tier Off; no existing shader consumes this marker.
rows[1][3] = 1.0 + emissive.0.min(2) as f32;
// MONKEY (world shadows): pack the world-shadow lane flag into the free `sh_c16.w` lane. The
// MCSH terrain-shadow suppression in `terrain.wgsl` keys on THIS — not on the mere presence of
// a shadow sun — so character-only shadows (sun present, world lane off) keep the baked MCSH.
@@ -1561,6 +1624,20 @@ fn build_light_data(
fresh.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], *lane];
claim.write(&mut claims.0[i * ROOM_CLAIM_STRIDE..][..ROOM_CLAIM_STRIDE]);
}
// MONKEY (lampfog): same order and values as the rows above. This is intentionally updated
// after the 255-entry truncate, so a CPU post effect can never see a light the shaders cannot.
let next_points: Vec<_> = pts
.iter()
.map(|(_, p, range, rgb, _, lane, _, _)| ResolvedPointLight {
position: *p,
range: *range,
color: Vec3::from_array(*rgb),
lane: *lane,
})
.collect();
if resolved_points.0 != next_points {
resolved_points.0 = next_points;
}
// Entries past the count are stale in the point table by design (the count row guards every
// reader) — but the claim table is read at the SAME index, so a stale head there would gate a
// live light with a dead building's identity. Clear the tail instead of trusting the count.
@@ -2027,6 +2104,7 @@ mod tests {
.init_resource::<crate::dev_state::DebugState>()
.init_resource::<crate::view::ViewDistance>()
.init_resource::<WowLightData>()
.init_resource::<ResolvedPointLights>()
// MONKEY (room gate): the packer writes the claim table in the same walk.
.init_resource::<RoomClaimTable>()
.init_resource::<Time>()
@@ -2037,6 +2115,8 @@ mod tests {
.init_resource::<ShadowHandover>()
.init_resource::<DynamicInteriors>()
.init_resource::<FireLightGain>()
// MONKEY (post): the packer reads the live HDR tier too.
.init_resource::<EmissiveTier>()
.init_resource::<SpellLightGain>()
.add_systems(Update, build_light_data);
app.world_mut()
@@ -2257,6 +2337,12 @@ mod tests {
(synthetic[0] - 1.0).abs() < 1e-4,
"the synthesised light takes the gain: {synthetic:?}"
);
// MONKEY (lampfog): the CPU accessor is the live table, not a second light calculation.
let resolved = app.world().resource::<ResolvedPointLights>();
assert_eq!(resolved.as_slice().len(), 2);
assert_eq!(resolved.as_slice()[0].position, Vec3::X);
assert!((resolved.as_slice()[0].color.x - authored[0]).abs() < 1e-4);
assert!((resolved.as_slice()[1].color.x - synthetic[0]).abs() < 1e-4);
// Zero is the kill switch: the invented light commits black, the authored one is unmoved.
app.world_mut().insert_resource(FireLightGain(0.0));
@@ -2264,6 +2350,11 @@ mod tests {
let data = app.world().resource::<WowLightData>().0;
assert_eq!(data.points[3], [0.0, 0.0, 0.0, 0.0], "gain 0 = lane off");
assert!((data.points[1][0] - 2.0).abs() < 1e-4, "authored unaffected");
assert_eq!(
app.world().resource::<ResolvedPointLights>().as_slice()[1].color,
Vec3::ZERO,
"the accessor carries the same live fire-gain fold"
);
}
/// GOLDEN — MONKEY (spellLightGain): a SPELL light takes `spellLightGain` **instead of**
@@ -2626,7 +2717,8 @@ 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.
assert_eq!(per_frame_blob_bytes(), 8528, "the mirrored blob must not change size");
// MONKEY (p0 MonkeyFrame): 8528 + the 256-byte programme block.
assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size");
}
/// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's
+30 -1
View File
@@ -11,6 +11,10 @@ mod daylight; // MONKEY (daylight fixtures): the sun as an interior-lane light i
mod daynight; // the two sun directions + day/night interp + the dawn/dusk warp curve
mod flicker; // MONKEY (flame flicker): the per-light fire wobble folded in at pack time
mod global_light; // the one shared global-light storage buffer (replaces the per-material push)
mod monkey_frame; // MONKEY (p0 MonkeyFrame): the programme's per-frame block after the point table
pub use monkey_frame::{FogModel, MonkeyFrame, MAX_BENDERS, MONKEY_FRAME_ROWS};
pub mod fog_model; // MONKEY (fog): the Modern fog model's CPU half (MonkeyFrame fog rows)
pub use fog_model::FogModelSetting;
mod lava_light; // MONKEY (lava light): magma surface fixtures and their independent gain
pub use lava_light::{LavaLight, LavaLightGain};
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
@@ -23,9 +27,12 @@ pub use blob::LightBlob;
// calls now -- daylight seeds and interior<->interior doorway seeds share the per-placement budget,
// so neither can be ranked without the other. `BleedFixture`/`BleedSeed` are the doorway lane's own
// two types; the fixture itself still wears `DaylightFixture`.
pub use daylight::set_window_split; // MONKEY (fix-daylight)
pub use daylight::{
daylight_claims, daylight_intensity, daylight_lane, daylight_point_light, daylight_reach,
daylight_rooms, daylight_seeds, daylight_target, bleed_seeds, placement_openings, BleedFixture,
// MONKEY (daylight: district sky rooms)
district_sky_rooms,
BleedSeed,
DaylightFixture, DaylightHow, DaylightSeed, BLEED_K, MAX_DAYLIGHT_PER_PLACEMENT,
};
@@ -35,7 +42,7 @@ pub use flicker::{flame_kind_for, flicker_seed, FlameFlicker, FlameKind, Flicker
pub use global_light::{
interior_reach, m2_light_reach, new_shared_light_buffer, DynamicInteriors, FireLightGain,
ClaimFade, LightLane, LightLitRooms, LightReach, LightRooms, RoomClaimTable,
SharedLightBuffer,
ResolvedPointLight, ResolvedPointLights, SharedLightBuffer,
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
WorldShadowActive,
};
@@ -51,12 +58,15 @@ pub use global_light::WorldPointLight;
// MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side
// shadow of benilla-app's own `SpellLight` lifecycle, and the dial the packer folds over it.
pub use global_light::{SpellFxLight, SpellLightGain};
// MONKEY (post): live 0/1/2 emissive tier packed without growing the shared light blob.
pub use global_light::EmissiveTier;
pub use global_light::{
room_claim_bytes, CLAIM_EXT_OK, LIT_ROOM_EXT_DENY, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE,
};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// The std430 layout stays in the crate: off-world producers state values through `LightBlob`,
// never a row index.
pub(crate) use global_light::per_frame_blob_bytes; // MONKEY (rainshelter)
pub(crate) use prop_probes::prop_probe_region_offset;
pub use resolve::WmoCrossfade;
use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting};
@@ -316,6 +326,16 @@ impl Plugin for LightingPlugin {
// This frame's submersion verdict, which the sky-pass suppression also reads.
.after(crate::liquid::SubmersionVerdict),
);
// MONKEY (fog): the fog-model setting, LightFogBand.dbc and the per-frame fog rows.
app.init_resource::<fog_model::FogModelSetting>()
.init_resource::<fog_model::FogBandTable>()
.add_systems(Startup, fog_model::load_fog_bands.after(AssetSet::Open))
.add_systems(
Update,
fog_model::update_fog_model
.after(update_time_lighting)
.in_set(LightingResolveSet),
);
// The shared light buffer, packed after the resolve and uploaded in the render world.
global_light::register(app);
// MONKEY (daylight fixtures): the per-frame re-aim, ordered before the packer's own set.
@@ -393,12 +413,21 @@ mod ordering_tests {
"the celestial follows: PostUpdate, BillboardPlace",
),
("weather/precip/mod.rs", "push_precip: PostUpdate"),
// MONKEY (fog)
(
"lighting/fog_model.rs",
"update_fog_model: in the resolve set, .after(update_time_lighting)",
),
// MONKEY (daylight fixtures / portal bleed): both systems are PostUpdate,
// chained before `global_light::classify_light_lanes`.
(
"lighting/daylight.rs",
"update_daylight_fixtures + update_bleed_fixtures: PostUpdate",
),
(
"clouds/layer.rs",
"update_cloud_fx is registered in CloudsPlugin with LightingConsumeSet",
),
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
@@ -0,0 +1,200 @@
//! MONKEY (p0 MonkeyFrame): the graphics programme's per-frame parameter block.
//!
//! One resource, [`MonkeyFrame`], that any system may write (fog model, wind, wetness, benders).
//! The light packer (`global_light::pack_monkey_frame`) copies it every frame into 16 rows
//! appended to the shared light buffer right after the point-light table, so the offsets of every
//! row before it never move. The WGSL mirror is `benilla::monkey_frame::MonkeyFrame`
//! (`benilla-assets/src/shaders/monkey_frame.wgsl`); every receiver's `WowLight` struct declares
//! it as its `monkey` member.
//!
//! All zero (the default) is "every feature off": nothing reads a zero field as a change, so the
//! image is the same as before the block existed.
//!
//! Layout (16 × `vec4<f32>`, 256 B):
//!
//! | row | xyz / x,y,z | w |
//! |---|---|---|
//! | 0 `fog_a` | height_fog_density, height_fog_height, height_fog_falloff | curve_blend |
//! | 1 `fog_b` | sun_fog_rgb | sun_fog_strength |
//! | 2 `fog_c` | end_fog_rgb | end_fog_distance |
//! | 3 `fog_d` | fog_model (0 classic; Modern = the scene fog end, yd, ≥ 1), sun_fog_angle, sun dir oct.x | sun dir oct.y |
//! | 4 `wind_a` | dir_x, dir_y, base_heading (rad) | gust |
//! | 5 `wind_b` | travel (yd, wrapped), sway_strength, grass_strength | tree_strength |
//! | 6 `wet_a` | rain_rate, wetness, ripple_time_s | snow |
//! | 7 `misc` | bender_count, time_of_day 0..1, night 0..1 | 0 |
//! | 8-15 `benders` | world x, y, z | radius |
//!
//! Coordinates are Bevy world space (Y up, 1 unit = 1 yd), the space the receivers'
//! `world_position` is in: WoW `(x, y, z)` → Bevy `(-y, z, -x)` ([`benilla_assets::coords`]).
//! The wind direction is a unit vector in the world XZ plane: `dir_x` = world x, `dir_y` = world z.
//! `time_of_day` and `night` are filled by the packer from the game clock and the celestial sun;
//! every other field belongs to the lane that owns the feature.
use bevy::prelude::*;
/// Rows in the appended block.
pub const MONKEY_FRAME_ROWS: usize = 16;
/// How many benders (grass/foliage pushers: the player and nearby units) the block carries.
pub const MAX_BENDERS: usize = 8;
/// The fog model selector, `fog_d.x`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FogModel {
/// Today's linear 1.12 fog, byte-identical.
#[default]
Classic,
/// The FOG lane's modern model (height / sun / end-colour / curve fog).
Modern,
}
/// The per-frame programme parameters. Write the fields your lane owns through
/// `ResMut<MonkeyFrame>`; leave the rest alone. See the module docs for the packed layout.
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)]
pub struct MonkeyFrame {
// ── FOG lane (rows 0-3) ──
pub fog_model: FogModel,
pub height_fog_density: f32,
/// World Y (Bevy space, yd) of the height-fog plane.
pub height_fog_height: f32,
pub height_fog_falloff: f32,
pub fog_curve_blend: f32,
/// Gamma 0..1.
pub sun_fog_rgb: [f32; 3],
pub sun_fog_strength: f32,
/// Cosine threshold of the sun-fog lobe.
pub sun_fog_angle: f32,
/// MONKEY (fog): the sun direction (Bevy space), octahedral-encoded (`fog_model::oct_encode`).
pub sun_fog_dir: [f32; 2],
/// MONKEY (fog): the scene fog end (yd) the Modern law applies to; packed as `fog_d.x`, so an
/// interior WMO span (a different end) stays classic.
pub fog_scene_end: f32,
/// Gamma 0..1.
pub end_fog_rgb: [f32; 3],
pub end_fog_distance: f32,
// ── WIND lane (rows 4-5) ──
/// Unit vector in the world XZ plane (`[x, z]`).
pub wind_dir: [f32; 2],
/// MONKEY (fix-wind): the profile's fixed heading in radians. The waves' spatial term uses
/// it, so the veer never rotates the phase field about the world origin.
pub wind_base_heading: f32,
pub wind_gust: f32,
/// MONKEY (fix-wind): the integrated wind travel (speed integrated over time) in yards, wrapped
/// at `wind::TRAVEL_WRAP`; the shader's wave rates are whole cycles per wrap, so it is seamless.
pub wind_travel: f32,
pub sway_strength: f32,
pub grass_strength: f32,
pub tree_strength: f32,
// ── WET lane (row 6) ──
pub rain_rate: f32,
pub wetness: f32,
pub ripple_time_s: f32,
pub snow: f32,
// ── benders (rows 7.x, 8-15) ──
/// Bender slots; only the first `min(bender_count, MAX_BENDERS)` are packed. `[x, y, z, radius]` in
/// Bevy world space.
pub benders: [[f32; 4]; MAX_BENDERS],
pub bender_count: u32,
}
impl MonkeyFrame {
/// The 16 packed rows. `time_of_day` (0..1) and `night` (0..1) are the packer's own inputs.
pub fn pack(&self, time_of_day: f32, night: f32) -> [[f32; 4]; MONKEY_FRAME_ROWS] {
let mut rows = [[0.0f32; 4]; MONKEY_FRAME_ROWS];
// MONKEY (fog): Modern packs the scene fog end (never below 1) as its flag.
let model = match self.fog_model {
FogModel::Classic => 0.0,
FogModel::Modern => self.fog_scene_end.max(1.0),
};
rows[0] = [
self.height_fog_density,
self.height_fog_height,
self.height_fog_falloff,
self.fog_curve_blend,
];
let s = self.sun_fog_rgb;
rows[1] = [s[0], s[1], s[2], self.sun_fog_strength];
let e = self.end_fog_rgb;
rows[2] = [e[0], e[1], e[2], self.end_fog_distance];
rows[3] = [model, self.sun_fog_angle, self.sun_fog_dir[0], self.sun_fog_dir[1]];
rows[4] = [self.wind_dir[0], self.wind_dir[1], self.wind_base_heading, self.wind_gust];
rows[5] = [self.wind_travel, self.sway_strength, self.grass_strength, self.tree_strength];
rows[6] = [self.rain_rate, self.wetness, self.ripple_time_s, self.snow];
let n = (self.bender_count as usize).min(MAX_BENDERS);
rows[7] = [n as f32, time_of_day, night, 0.0];
rows[8..8 + n].copy_from_slice(&self.benders[..n]);
rows
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_frame_packs_to_zero_but_the_clock() {
let rows = MonkeyFrame::default().pack(0.0, 0.0);
assert!(rows.iter().flatten().all(|v| *v == 0.0));
let rows = MonkeyFrame::default().pack(0.5, 1.0);
assert_eq!(rows[7], [0.0, 0.5, 1.0, 0.0]);
}
#[test]
fn fields_land_in_their_documented_rows() {
let f = MonkeyFrame {
fog_model: FogModel::Modern,
height_fog_density: 1.0,
height_fog_height: 2.0,
height_fog_falloff: 3.0,
fog_curve_blend: 4.0,
sun_fog_rgb: [5.0, 6.0, 7.0],
sun_fog_strength: 8.0,
end_fog_rgb: [9.0, 10.0, 11.0],
end_fog_distance: 12.0,
sun_fog_angle: 13.0,
sun_fog_dir: [0.5, -0.5],
fog_scene_end: 444.0,
wind_dir: [14.0, 15.0],
wind_base_heading: 16.0,
wind_gust: 17.0,
wind_travel: 18.0,
sway_strength: 19.0,
grass_strength: 20.0,
tree_strength: 21.0,
rain_rate: 22.0,
wetness: 23.0,
ripple_time_s: 24.0,
snow: 25.0,
benders: [[26.0, 27.0, 28.0, 29.0]; MAX_BENDERS],
bender_count: 20, // clamped to MAX_BENDERS
};
let r = f.pack(0.25, 0.75);
assert_eq!(r[0], [1.0, 2.0, 3.0, 4.0]);
assert_eq!(r[1], [5.0, 6.0, 7.0, 8.0]);
assert_eq!(r[2], [9.0, 10.0, 11.0, 12.0]);
assert_eq!(r[3], [444.0, 13.0, 0.5, -0.5]);
assert_eq!(r[4], [14.0, 15.0, 16.0, 17.0]);
assert_eq!(r[5], [18.0, 19.0, 20.0, 21.0]);
assert_eq!(r[6], [22.0, 23.0, 24.0, 25.0]);
assert_eq!(r[7], [8.0, 0.25, 0.75, 0.0]);
assert_eq!(r[15], [26.0, 27.0, 28.0, 29.0]);
}
/// The WGSL mirror declares the same 16 rows; a member added on one side only would shift
/// every bender.
#[test]
fn the_wgsl_mirror_has_sixteen_rows() {
let src = include_str!("../../../benilla-assets/src/shaders/monkey_frame.wgsl");
let body = src.split_once("struct MonkeyFrame {").unwrap().1;
let body = body.split_once("\n}").unwrap().0;
let mut rows = 0;
for line in body.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) {
if line.contains("array<vec4<f32>, 8>") {
rows += 8;
} else if line.contains("vec4<f32>") {
rows += 1;
}
}
assert_eq!(rows, MONKEY_FRAME_ROWS);
}
}
@@ -195,9 +195,10 @@ pub(super) fn publish_prop_probes(
}
}
/// Byte offset of the probe region in the shared light buffer, right after the per-frame blob.
/// Byte offset of the probe region in the shared light buffer, after the per-frame blob and
/// MONKEY (rainshelter) the rain-shelter grid region.
pub fn prop_probe_region_offset() -> u64 {
super::global_light::per_frame_blob_bytes()
super::global_light::per_frame_blob_bytes() + crate::weather::shelter::REGION_BYTES
}
/// Render world, in `PrepareResources`: writes the probe region when the table changed; the
+9 -2
View File
@@ -177,7 +177,9 @@ pub(super) fn update_time_lighting(
eye_liquid: crate::liquid::EyeLiquid,
viewer: Res<crate::view::Viewer>,
weather: Option<Res<crate::weather::WeatherState>>,
view: Res<crate::view::ViewDistance>,
// MONKEY (fog): paired with the fog-model setting (Modern decouples the fog end from farclip
// past the reference ceiling); a tuple keeps the system under Bevy's 16-parameter limit.
(view, fog_setting): (Res<crate::view::ViewDistance>, Res<super::FogModelSetting>),
time: Res<Time>,
wmo_fog: Res<crate::wmo_portal::CameraWmoFog>,
mut wmo_ramp: ResMut<WmoCrossfade>,
@@ -250,7 +252,12 @@ pub(super) fn update_time_lighting(
// Elwynn commits 125/500 clear and −139/278 at full storm (`0x6cee30`): the negative start is
// the near veil, and the short storm end whites out the middle distance.
let (fog_start, fog_end) = scene_fog(atmo.fog_end, atmo.fog_start_frac, view.farclip);
let (fog_start, fog_end) = if fog_setting.modern() {
// MONKEY (fog): identical up to farclip 777, then the end grows with the view distance.
super::fog_model::modern_fog_end(atmo.fog_end, atmo.fog_start_frac, view.farclip)
} else {
scene_fog(atmo.fog_end, atmo.fog_start_frac, view.farclip)
};
// The interior crossfade is its own triple; the scene fog stays untouched. Submerged, the
// underwater param owns the fog and the ramp is bypassed, as the reference snaps it for magma
// and slime inside an MFOG interior (`0x6cef6f`); the reference's MFOG underwater block
+301
View File
@@ -0,0 +1,301 @@
//! MONKEY (water LOD): High-quality liquid uses a denser near-camera lattice.
//!
//! 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.
//! 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.
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, PrimitiveTopology, VertexAttributeValues};
use bevy::prelude::*;
use benilla_assets::{coords::wow_to_bevy, WaterQuality};
use benilla_formats::LiquidMesh;
use crate::view::WorldCamera;
const SUBDIVISIONS: usize = 4;
const ENTER_YARDS: f32 = 64.0;
const EXIT_YARDS: f32 = 80.0;
const MAX_FINE_VERTICES: usize = 65_536;
/// The coarse asset is retained for the far swap. Only the tiny coverage mask/grid metadata is
/// duplicated per surface; source vertex attributes remain in `Assets<Mesh>` and are copied only
/// during an actual near-ring transition.
#[derive(Component)]
pub(super) struct LiquidLod {
coarse: Handle<Mesh>,
fine: Option<Handle<Mesh>>,
grid: [u32; 2],
wet: Vec<bool>,
local_center: Vec3,
local_radius_xz: f32,
}
impl LiquidLod {
pub(super) fn new(coarse: Handle<Mesh>, source: &LiquidMesh) -> Self {
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for &position in &source.positions {
let p = wow_to_bevy(position);
min = min.min(p);
max = max.max(p);
}
let valid = min.is_finite() && max.is_finite();
let local_center = if valid { (min + max) * 0.5 } else { Vec3::ZERO };
let local_radius_xz = if valid {
Vec2::new(max.x - min.x, max.z - min.z).length() * 0.5
} else {
0.0
};
Self {
coarse,
fine: None,
grid: source.grid,
wet: source.wet.clone(),
local_center,
local_radius_xz,
}
}
}
pub(super) fn register(app: &mut App) {
app.add_systems(Update, update_liquid_lod);
}
fn update_liquid_lod(
quality: Res<WaterQuality>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
mut surfaces: Query<(&GlobalTransform, &mut Mesh3d, &mut LiquidLod)>,
mut meshes: ResMut<Assets<Mesh>>,
) {
let Ok(camera) = camera.single() else { return };
let camera = camera.translation().xz();
for (transform, mut current, mut lod) in &mut surfaces {
if quality.0 < 2 {
if let Some(fine) = lod.fine.take() {
current.0 = lod.coarse.clone();
meshes.remove(fine.id());
}
continue;
}
let centre = transform.transform_point(lod.local_center).xz();
let (scale, _, _) = transform.to_scale_rotation_translation();
let world_radius = lod.local_radius_xz * scale.abs().max_element();
let edge_distance = (camera.distance(centre) - world_radius).max(0.0);
if lod.fine.is_some() {
if edge_distance > EXIT_YARDS {
let fine = lod.fine.take().expect("checked");
current.0 = lod.coarse.clone();
meshes.remove(fine.id());
}
continue;
}
if edge_distance > ENTER_YARDS {
continue;
}
let Some(fine_mesh) = meshes
.get(&lod.coarse)
.and_then(|coarse| subdivide(coarse, lod.grid, &lod.wet))
else {
continue;
};
let fine = meshes.add(fine_mesh);
current.0 = fine.clone();
lod.fine = Some(fine);
}
}
fn bilerp<const N: usize>(
values: &[[f32; N]],
cols: usize,
x: usize,
y: usize,
tx: f32,
ty: f32,
) -> [f32; N] {
let [tl, tr, bl, br] = [
values[y * cols + x],
values[y * cols + x + 1],
values[(y + 1) * cols + x],
values[(y + 1) * cols + x + 1],
];
let mut out = [0.0; N];
for i in 0..N {
let top = tl[i] + (tr[i] - tl[i]) * tx;
let bottom = bl[i] + (br[i] - bl[i]) * tx;
out[i] = top + (bottom - top) * ty;
}
out
}
/// 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> {
let [cols, rows] = grid.map(|v| v as usize);
if cols < 2 || rows < 2 || wet.len() != (cols - 1) * (rows - 1) {
return None;
}
let VertexAttributeValues::Float32x3(positions) = coarse.attribute(Mesh::ATTRIBUTE_POSITION)?
else {
return None;
};
let VertexAttributeValues::Float32x2(uv0) = coarse.attribute(Mesh::ATTRIBUTE_UV_0)? else {
return None;
};
let VertexAttributeValues::Float32x2(uv1) = coarse.attribute(Mesh::ATTRIBUTE_UV_1)? else {
return None;
};
if positions.len() != cols * rows
|| uv0.len() != positions.len()
|| uv1.len() != positions.len()
{
return None;
}
let colours = match coarse.attribute(Mesh::ATTRIBUTE_COLOR) {
Some(VertexAttributeValues::Float32x4(values)) if values.len() == positions.len() => {
Some(values)
}
None => None,
_ => return None,
};
let fine_cols = (cols - 1).checked_mul(SUBDIVISIONS)?.checked_add(1)?;
let fine_rows = (rows - 1).checked_mul(SUBDIVISIONS)?.checked_add(1)?;
let fine_count = fine_cols.checked_mul(fine_rows)?;
if fine_count > MAX_FINE_VERTICES {
return None;
}
let mut fine_positions = Vec::with_capacity(fine_count);
let mut fine_uv0 = Vec::with_capacity(fine_count);
let mut fine_uv1 = Vec::with_capacity(fine_count);
let mut fine_colours = colours.map(|_| Vec::with_capacity(fine_count));
for fy in 0..fine_rows {
let sy = (fy / SUBDIVISIONS).min(rows - 2);
let ty = (fy - sy * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32;
for fx in 0..fine_cols {
let sx = (fx / SUBDIVISIONS).min(cols - 2);
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));
if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) {
output.push(bilerp(source, cols, sx, sy, tx, ty));
}
}
}
let wet_fine_cells = wet.iter().filter(|&&cell| cell).count() * SUBDIVISIONS * SUBDIVISIONS;
let mut indices = Vec::with_capacity(wet_fine_cells * 6);
for y in 0..fine_rows - 1 {
for x in 0..fine_cols - 1 {
let source_x = x / SUBDIVISIONS;
let source_y = y / SUBDIVISIONS;
if !wet[source_y * (cols - 1) + source_x] {
continue;
}
let tl = (y * fine_cols + x) as u32;
let tr = tl + 1;
let bl = ((y + 1) * fine_cols + x) as u32;
let br = bl + 1;
indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
}
}
let mut fine = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
fine.insert_attribute(Mesh::ATTRIBUTE_POSITION, fine_positions);
fine.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; fine_count]);
fine.insert_attribute(Mesh::ATTRIBUTE_UV_0, fine_uv0);
fine.insert_attribute(Mesh::ATTRIBUTE_UV_1, fine_uv1);
if let Some(colours) = fine_colours {
fine.insert_attribute(Mesh::ATTRIBUTE_COLOR, colours);
}
fine.insert_indices(Indices::U32(indices));
Some(fine)
}
#[cfg(test)]
mod tests {
use super::*;
fn coarse() -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
],
);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 4]);
mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]],
);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![[0.0, 0.0]; 4]);
mesh
}
#[test]
fn subdivision_preserves_edges_and_splits_every_wet_cell_sixteen_ways() {
let fine = subdivide(&coarse(), [2, 2], &[true]).expect("fine mesh");
let Some(VertexAttributeValues::Float32x3(positions)) =
fine.attribute(Mesh::ATTRIBUTE_POSITION)
else {
panic!("positions");
};
assert_eq!(positions.len(), 25);
assert_eq!(positions[0], [0.0, 0.0, 0.0]);
assert_eq!(positions[4], [1.0, 0.0, 0.0]);
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]);
assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
}
#[test]
fn dry_parent_cells_stay_holes() {
let mut mesh = coarse();
mesh.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[2.0, 0.0, 1.0],
],
);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 6]);
mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
vec![
[0.0, 0.0],
[0.5, 0.0],
[1.0, 0.0],
[0.0, 1.0],
[0.5, 1.0],
[1.0, 1.0],
],
);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![[0.0, 0.0]; 6]);
let fine = subdivide(&mesh, [3, 2], &[true, false]).expect("fine mesh");
assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
}
}
+4 -1
View File
@@ -31,8 +31,10 @@ use benilla_assets::materials::LiquidMaterial;
use benilla_assets::AssetSet;
mod drift;
mod lod;
mod scene_depth;
pub use scene_depth::WaterDepthPlugin;
// MONKEY (ao): the label orders the AO pass.
pub use scene_depth::{WaterDepthLabel, WaterDepthPlugin};
mod query;
#[cfg(test)]
mod real_data;
@@ -125,5 +127,6 @@ impl Plugin for LiquidPlugin {
);
}
drift::register(app);
lod::register(app);
}
}
@@ -187,8 +187,9 @@ fn init_pipeline(mut commands: Commands, shader: Res<DepthShader>, cache: Res<Pi
commands.insert_resource(DepthPipelines { layouts, pipelines, colour_layout, colour_pipeline });
}
// MONKEY (ao): public so the AO pass can order itself before the opaque copies.
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct WaterDepthLabel;
pub struct WaterDepthLabel;
#[derive(Default)]
struct DepthNode;
+53 -7
View File
@@ -11,6 +11,7 @@ use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use super::lod::LiquidLod;
use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool};
use crate::collision::liquid_layers;
use crate::lighting::WATER_SHININESS;
@@ -181,7 +182,9 @@ pub(crate) fn spawn_liquids<'a>(
if foam {
commands
.entity(*entities.last().expect("just pushed"))
.insert(FoamPatch);
// MONKEY (water LOD): High may swap this coarse water grid for a transient 4x
// near copy; magma/slime remain on their authored topology.
.insert((FoamPatch, LiquidLod::new(mesh_handle, lq)));
}
// The waterline for the camera sweep under `cameraWaterCollision`; nothing else queries it.
if let Some(collider) = liquid_collider(lq) {
@@ -259,6 +262,36 @@ pub(crate) fn spawn_wmo_liquids<'a>(
};
let path = LiquidPath::wmo(interior);
for lq in liquids {
// MONKEY (water): opt-in evidence for WMO-liquid classification and the raw MLIQ opacity
// contract. The byte is not a depth; logging it beside the group and world centre makes a
// deterministic capture sufficient to identify the exact canal/pool surface in question.
if std::env::var_os("WOW_WATER_PROBE").is_some() {
let (depth_min, depth_max, depth_sum) = lq.depths.iter().fold(
(f32::INFINITY, f32::NEG_INFINITY, 0.0),
|(lo, hi, sum), &v| (lo.min(v), hi.max(v), sum + v),
);
let local_center = lq
.positions
.iter()
.map(|&position| wow_to_bevy(position))
.sum::<Vec3>()
/ lq.positions.len().max(1) as f32;
let world_center = transform.transform_point(local_center);
let depth_mean = depth_sum / lq.depths.len().max(1) as f32;
info!(
"water-probe: WMO group={:?} path={path:?} kind={:?} centre_bevy=({:.2},{:.2},{:.2}) opacity_byte=min:{:.0} mean:{:.1} max:{:.0} verts={} wet_cells={}",
pool.owner.map(|room| room.group),
lq.kind,
world_center.x,
world_center.y,
world_center.z,
depth_min.clamp(0.0, 1.0) * 255.0,
depth_mean.clamp(0.0, 1.0) * 255.0,
depth_max.clamp(0.0, 1.0) * 255.0,
lq.positions.len(),
lq.wet.iter().filter(|&&wet| wet).count(),
);
}
// The one path that can scroll, decided by the nibble, not the kind (`scrolls`).
let scroll = scrolls(lq.sound_nibble);
// The interior arm's body colour via the pool's MLIQ `materialId`; none when fullbright.
@@ -278,9 +311,10 @@ pub(crate) fn spawn_wmo_liquids<'a>(
lq.kind, lq.sound_nibble
);
}
let mesh_handle = meshes.add(liquid_bevy_mesh(lq, body_color));
let surface = commands
.spawn((
Mesh3d(meshes.add(liquid_bevy_mesh(lq, body_color))),
Mesh3d(mesh_handle.clone()),
MeshMaterial3d(material),
transform,
LiquidSurface,
@@ -300,7 +334,10 @@ pub(crate) fn spawn_wmo_liquids<'a>(
LiquidSource::WmoGroup(pool),
));
if !lq.kind.is_fullbright() {
commands.entity(surface).insert(FoamPatch);
// MONKEY (water LOD): preserve the WMO grid boundary while refining its near interior.
commands
.entity(surface)
.insert((FoamPatch, LiquidLod::new(mesh_handle, lq)));
}
// The camera's waterline, model-local under the entity's placement `transform`.
if let Some(collider) = liquid_collider(lq) {
@@ -421,10 +458,19 @@ pub(super) fn setup_liquid(
water: benilla_assets::WaterUniform {
mode: Vec4::new(
water_quality.0 as f32,
if kind.is_fullbright() { 0.0 }
else if path != LiquidPath::Adt { 0.12 }
else if kind == LiquidKind::Ocean { 1.0 }
else { 0.18 },
if kind.is_fullbright() {
0.0
// MONKEY (water): exterior WMO canals need a calm but legible ripple
// profile; true interior pools stay quieter under a roof.
} else if path == LiquidPath::WmoExterior {
0.26
} else if path == LiquidPath::WmoInterior {
0.08
} else if kind == LiquidKind::Ocean {
1.0
} else {
0.18
},
capture_time,
if deterministic { 0.0 } else { 1.0 },
),
+100 -12
View File
@@ -31,10 +31,12 @@ use bevy::math::Vec2;
///
/// Components 2..8 are the shader's fragment-only ripple band and are NOT here — see the module
/// doc's `long_only` note. Adding them would be a second design, not a closer mirror.
pub const LONG_SWELL: [[f32; 4]; 2] = [
[0.35, 18.0, 0.200, 0.0],
[0.80, 12.8, 0.140, 1.7],
];
pub const WATER_WIND_DIR: f32 = 0.35;
pub const LONG_SWELL: [[f32; 4]; 2] =
[[WATER_WIND_DIR, 18.0, 0.200, 0.0], [0.80, 12.8, 0.140, 1.7]];
/// Horizontal Gerstner gather, mirrored from `enhanced_water.wgsl::WATER_GERSTNER_CHOP`.
pub const GERSTNER_CHOP: f32 = 2.4;
/// Gravity in yd/s², `enhanced_water.wgsl` — deep-water dispersion, `c = sqrt(g/k)`.
const GRAVITY_YD: f32 = 10.72;
@@ -44,8 +46,8 @@ const GRAVITY_YD: f32 = 10.72;
const TWO_PI: f32 = 6.2831853;
/// The ADT **ocean**'s wave energy — `liquid/surface.rs` packs `water.mode.y = 1.0` for
/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO pools 0.12,
/// fullbright 0.0). The vertex swell arm only ever runs on that one combination
/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO exterior 0.26,
/// WMO interior 0.08, fullbright 0.0). The vertex swell arm only ever runs on that combination
/// (`enhanced_water.wgsl`), so this is the only energy a bob can ever be driven at — the parameter
/// stays open because the function is the shader's, not the bob's.
pub const OCEAN_WAVE_ENERGY: f32 = 1.0;
@@ -60,6 +62,8 @@ pub struct Swell {
/// `(∂height/∂x, ∂height/∂z)` in BEVY world axes, the shader's `result.yz`. The shader builds
/// its normal from exactly this as `normalize(vec3(-grad.x, 1, -grad.y))` (`enhanced_water.wgsl`).
pub grad: Vec2,
/// Horizontal Gerstner displacement in BEVY XZ at the parameter-space point under this sample.
pub horizontal: Vec2,
}
/// `enhanced_water.wgsl`'s `swell_shore_fade`: the authored ocean depth `V` (byte/255, ≈148 yd at
@@ -84,25 +88,69 @@ pub fn swell_shore_fade(depth_v: f32) -> f32 {
/// * `long_only = true` — components 0 and 1, which are also the two the shore fade multiplies.
///
/// `world_xz` is BEVY world XZ. `shallow_fade` is the shore term (1 = open sea, 0 = beach).
pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
#[derive(Clone, Copy, Debug, Default)]
struct RawSwell {
height: f32,
grad: Vec2,
horizontal: Vec2,
jacobian: [f32; 4],
}
/// Evaluate the same parameter-space Gerstner surface the vertex shader receives. The public
/// sampler below inverts its horizontal map because callers hold a displaced world position.
fn raw_swell(parameter_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> RawSwell {
let energy = wave_energy.clamp(0.0, 1.0);
// `mix(0.4, 1.0, sqrt(energy))` — a calm inland sheet also swells SLOWER, not merely lower.
let tempo = 0.4 + 0.6 * energy.sqrt();
let shore = shallow_fade.clamp(0.0, 1.0);
let mut out = Swell::default();
let mut out = RawSwell {
jacobian: [1.0, 0.0, 0.0, 1.0],
..RawSwell::default()
};
for w in LONG_SWELL {
let dir = Vec2::new(w[0].cos(), w[0].sin());
let k = TWO_PI / w[1];
let speed = (GRAVITY_YD / k).sqrt(); // phase speed, yd/s
let phase = k * (dir.dot(world_xz) - speed * tempo * time) + w[3];
let phase = k * (dir.dot(parameter_xz) - speed * tempo * time) + w[3];
// `fade` is 1 at zero footprint; `i < 2` ⇒ both components take the shore term.
let amplitude = w[2] * energy * shore;
out.height += amplitude * phase.sin();
out.grad += dir * (amplitude * k * phase.cos());
let horizontal = GERSTNER_CHOP * amplitude;
out.horizontal += dir * (horizontal * phase.cos());
let compression = horizontal * k * phase.sin();
out.jacobian[0] -= compression * dir.x * dir.x;
out.jacobian[1] -= compression * dir.x * dir.y;
out.jacobian[2] -= compression * dir.y * dir.x;
out.jacobian[3] -= compression * dir.y * dir.y;
}
out
}
/// The displaced Gerstner surface at a WORLD-space point. Four fixed-point steps find the original
/// mesh coordinate under that point; the long-band Jacobian is contractive (worst compression
/// below 0.34), so this converges without a branch or an allocation. The gradient is transformed
/// through the same map, keeping swimmer lean consistent with the visibly pinched crest.
pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
let mut parameter_xz = world_xz;
for _ in 0..4 {
let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade);
parameter_xz = world_xz - raw.horizontal;
}
let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade);
let [j00, j01, j10, j11] = raw.jacobian;
let det = (j00 * j11 - j01 * j10).max(1.0e-4);
let grad = Vec2::new(
(j11 * raw.grad.x - j01 * raw.grad.y) / det,
(-j10 * raw.grad.x + j00 * raw.grad.y) / det,
);
Swell {
height: raw.height,
grad,
horizontal: raw.horizontal,
}
}
/// [`swell`]'s height alone — the shape the brief names, for callers that do not want the slope.
pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> f32 {
swell(world_xz, time, wave_energy, shallow_fade).height
@@ -167,8 +215,19 @@ mod tests {
"enhanced_water.wgsl WATER_WAVES[0..2]"
);
assert_eq!(GRAVITY_YD, 10.72, "enhanced_water.wgsl gravity");
assert_eq!(TWO_PI, 6.2831853, "enhanced_water.wgsl k = 6.2831853/wavelength");
assert_eq!(OCEAN_WAVE_ENERGY, 1.0, "surface.rs water.mode.y for ADT ocean");
assert_eq!(
TWO_PI, 6.2831853,
"enhanced_water.wgsl k = 6.2831853/wavelength"
);
assert_eq!(WATER_WIND_DIR, 0.35, "enhanced_water.wgsl WATER_WIND_DIR");
assert_eq!(
GERSTNER_CHOP, 2.4,
"enhanced_water.wgsl WATER_GERSTNER_CHOP"
);
assert_eq!(
OCEAN_WAVE_ENERGY, 1.0,
"surface.rs water.mode.y for ADT ocean"
);
// The band's ceiling, stated in the shader's own comment ("at most 0.34 yd").
let peak: f32 = LONG_SWELL.iter().map(|w| w[2]).sum();
assert!((peak - 0.34).abs() < 1e-6, "amplitude sum {peak}");
@@ -183,11 +242,13 @@ mod tests {
let s = swell(Vec2::new(-9016.0, 226.0), t, 0.0, 1.0);
assert_eq!(s.height, 0.0, "height at t={t}");
assert_eq!(s.grad, Vec2::ZERO, "grad at t={t}");
assert_eq!(s.horizontal, Vec2::ZERO, "horizontal at t={t}");
}
// The shore term is the other kill switch: a beached vertex does not move either.
let beached = swell(Vec2::new(-9016.0, 226.0), 3.0, OCEAN_WAVE_ENERGY, 0.0);
assert_eq!(beached.height, 0.0);
assert_eq!(beached.grad, Vec2::ZERO);
assert_eq!(beached.horizontal, Vec2::ZERO);
}
/// The swell stays inside the band the shader advertises, and the gradient it reports really
@@ -220,12 +281,39 @@ mod tests {
}
}
/// The CPU query samples by displaced world position while the vertex shader starts in mesh
/// parameter space. Pin the inversion: adding the returned horizontal displacement to the
/// recovered parameter must land back under the swimmer, with the advertised hard bound.
#[test]
fn gerstner_inverse_lands_under_the_world_sample() {
let world = Vec2::new(-9016.0, 226.0);
for step in 0..40 {
let t = step as f32 * 0.37;
let s = swell(world, t, OCEAN_WAVE_ENERGY, 1.0);
let parameter = world - s.horizontal;
let raw = raw_swell(parameter, t, OCEAN_WAVE_ENERGY, 1.0);
let reprojection = parameter + raw.horizontal;
assert!(
(reprojection - world).length() < 2.0e-3,
"reprojection at t={t}"
);
assert!(
s.horizontal.length() <= 0.817,
"horizontal at t={t}: {:?}",
s.horizontal
);
}
}
/// The shore fade's two ends, as the shader's `smoothstep(0.001, 0.025, depth)` places them.
#[test]
fn shore_fade_matches_the_shader_band() {
assert_eq!(swell_shore_fade(0.0), 0.0);
assert_eq!(swell_shore_fade(1.0), 1.0, "open sea (V = 1) is unfaded");
assert!((swell_shore_fade(0.013) - 0.5).abs() < 0.02, "band midpoint");
assert!(
(swell_shore_fade(0.013) - 0.5).abs() < 0.02,
"band midpoint"
);
}
/// The clock: a live run is the wrapped elapsed seconds verbatim (the GPU's `globals.time`),
+150 -1
View File
@@ -75,6 +75,9 @@ pub struct MatKey {
window: bool,
/// A depth-prime twin ([`zfill_material`], the reference's `M2UseZFill` clone at `0x707f7d`).
zfill: bool,
/// MONKEY (fix-wind): a classified tree/bush leaf batch's exile copy, marker bit 14
/// ([`FOLIAGE_WIND_MARKER`]); `wow_model.wgsl` sways only these.
foliage_wind: bool,
/// A WMO skybox batch ([`crate::skybox`]), with its own pipeline and sort rung.
sky_depth: bool,
/// The placement owning this material when its animated loop follows the instance's sequence
@@ -154,6 +157,137 @@ pub fn model_material(
// the sequence its instance is playing (decision 1408): the animated-material registries are
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
instance: Option<Entity>,
) -> Handle<WowModelMaterial> {
model_material_impl(
cache,
materials,
texture,
blend,
two_sided,
is_wmo,
is_interior,
is_emissive,
is_additive,
fade_variant,
no_depth_write,
no_depth_test,
fog_policy,
env_map,
shade,
batch_order,
uv_anim,
rgb_anim,
wmo_class,
sidn,
window,
sky_depth,
light,
torch,
instance,
false,
)
}
/// MONKEY (fix-wind): [`model_material`] for a classified tree/bush leaf batch's exile copy: the
/// same material plus [`FOLIAGE_WIND_MARKER`], so the entity draw sways like the retained batch.
#[allow(clippy::too_many_arguments)]
pub fn foliage_model_material(
cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
texture: Option<Handle<Image>>,
blend: ModelBlend,
two_sided: bool,
is_wmo: bool,
is_interior: bool,
is_emissive: bool,
is_additive: bool,
fade_variant: bool,
no_depth_write: bool,
no_depth_test: bool,
fog_policy: benilla_formats::FogPolicy,
env_map: bool,
shade: ShadeSel,
batch_order: u16,
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
wmo_class: Option<WmoBatchClass>,
sidn: Option<[u8; 3]>,
window: bool,
sky_depth: bool,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
torch: &TorchBinds,
// The ONE placement this material belongs to, or `None` for the shared batch material every
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
// the sequence its instance is playing (decision 1408): the animated-material registries are
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
instance: Option<Entity>,
) -> Handle<WowModelMaterial> {
model_material_impl(
cache,
materials,
texture,
blend,
two_sided,
is_wmo,
is_interior,
is_emissive,
is_additive,
fade_variant,
no_depth_write,
no_depth_test,
fog_policy,
env_map,
shade,
batch_order,
uv_anim,
rgb_anim,
wmo_class,
sidn,
window,
sky_depth,
light,
torch,
instance,
true,
)
}
#[allow(clippy::too_many_arguments)]
fn model_material_impl(
cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
texture: Option<Handle<Image>>,
blend: ModelBlend,
two_sided: bool,
is_wmo: bool,
is_interior: bool,
is_emissive: bool,
is_additive: bool,
fade_variant: bool,
no_depth_write: bool,
no_depth_test: bool,
fog_policy: benilla_formats::FogPolicy,
env_map: bool,
shade: ShadeSel,
batch_order: u16,
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
wmo_class: Option<WmoBatchClass>,
sidn: Option<[u8; 3]>,
window: bool,
sky_depth: bool,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
torch: &TorchBinds,
// The ONE placement this material belongs to, or `None` for the shared batch material every
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
// the sequence its instance is playing (decision 1408): the animated-material registries are
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
instance: Option<Entity>,
foliage_wind: bool,
) -> Handle<WowModelMaterial> {
let key = MatKey {
light: light.id(),
@@ -177,6 +311,7 @@ pub fn model_material(
sidn,
window,
zfill: false,
foliage_wind,
sky_depth,
instance,
};
@@ -263,7 +398,8 @@ pub fn model_material(
| (u16::from(blend == ModelBlend::Mod2x) << 8)
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER)
| (u16::from(env_map) * ENV_MAP_MARKER)
| (u16::from(sky_depth) * SKY_DEPTH_MARKER),
| (u16::from(sky_depth) * SKY_DEPTH_MARKER)
| (u16::from(foliage_wind) * FOLIAGE_WIND_MARKER),
),
0.0,
),
@@ -323,6 +459,9 @@ pub fn model_material(
},
// Zero until a sampler registers this material and bakes its table slot in once.
anim_slots: Vec4::ZERO,
// MONKEY (skybox): no second stage.
stage1: Vec4::ZERO,
stage1_texture: None,
light_buf: light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: torch.depth.clone(),
@@ -349,6 +488,12 @@ pub(crate) const ENV_MAP_MARKER: u16 = 1 << 12;
/// vertex with no raster bias; a `WowModelKey` axis, so every other draw keeps its real depth.
pub(crate) const SKY_DEPTH_MARKER: u16 = 1 << 13;
/// MONKEY (fix-wind): `clutter_fade.z` marker bit 14: a classified tree/bush leaf batch's exile
/// copy, the only entity draw `wow_model.wgsl` applies `tree_offset` to. Vertex-only uniform data,
/// not a `WowModelKey` axis. (It replaced MeshTag bit 18, which aliases the lane weight and the
/// interior probe slot, so doorway units and interior props swayed.)
pub(crate) const FOLIAGE_WIND_MARKER: u16 = 1 << 14;
/// [`BATCH_ORDER_SORT_EPS`] for the WMO skybox lane: at the −6e4 rung the f32 ulp is 0.0039, so
/// 1e-3 would round away; this step is 4 ulps. Every skybox batch pair ties, being eye-anchored.
pub(crate) const SKYBOX_ORDER_EPS: f32 = 0.015625;
@@ -412,6 +557,7 @@ pub fn zfill_material(
sidn: None,
window: false,
zfill: true,
foliage_wind: false,
// No skybox batch has a twin: the sky writes no depth.
sky_depth: false,
instance: None,
@@ -446,6 +592,9 @@ pub fn zfill_material(
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
sidn: Vec4::ZERO,
anim_slots: Vec4::ZERO,
// MONKEY (skybox): no second stage.
stage1: Vec4::ZERO,
stage1_texture: None,
light_buf: light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: torch.depth.clone(),
+42 -4
View File
@@ -110,11 +110,16 @@ impl M2BatchMaterials<'_> {
/// (`[CM2Model+0x180]`, from the interior crossfade `[0xce9bdc]`) scales every batch's alpha,
/// and `0 < A < 1` promotes a batch to SRC_ALPHA blending (`0x70c190`): `fade_blend` is that
/// twin, or `steady` when the batch already blends.
///
/// MONKEY (skybox): `uv` and `tint` are the batch's texture-transform and colour loops, the
/// material's seed and identity; [`crate::skybox_anim`] drives their rows.
pub fn skybox(
&mut self,
sub: &benilla_formats::RenderSubmesh,
texture: Option<Handle<Image>>,
order: u16,
uv: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
tint: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
) -> Option<SkyboxBatch> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
@@ -136,10 +141,9 @@ impl M2BatchMaterials<'_> {
sub.env_map,
ShadeSel::Lit, // unread: every shipped skybox batch is UNLIT (0x01)
order,
// No texture-transform or colour loop: neither shipped skybox authors one. Wiring
// one needs the `M2Model` lane's `Arc`, which the material key identifies it by.
None,
None,
// MONKEY (skybox): the loops' `Arc`s, which the material key identifies them by.
uv,
tint,
None, // M2: no MOBA class, no SIDN, no WINDOW
None,
false,
@@ -161,6 +165,40 @@ impl M2BatchMaterials<'_> {
Some(SkyboxBatch { steady, fade_blend })
}
/// MONKEY (skybox): the flag `0x4` fog cone's material: an untextured, unlit, two-sided blend
/// on the sky lane, its colour the vertex colour times the tint row the skybox lane writes.
pub fn sky_fog_cone(&mut self, order: u16) -> Option<Handle<WowModelMaterial>> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
Some(model_material(
&mut self.cache.0,
&mut self.materials,
None,
ModelBlend::Blend,
true,
false,
false,
true, // unlit
false,
false,
true, // never writes depth
false, // …and always tests it
benilla_formats::FogPolicy::Off,
false,
ShadeSel::Lit,
order,
None,
None,
None,
None,
false,
true, // the sky lane
&light,
&torch,
None,
))
}
/// One steady material lit by a render target's own light buffer (a portrait booth, the model
/// pane, char-select), which [`super::MatKey::light`] keys apart from the world. `rig` lights
/// by the scene's authored M2 rig ([`ShadeSel::Rig`]) and plays the UV animation. `order` must
+4 -1
View File
@@ -11,6 +11,8 @@ use bevy::prelude::*;
pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/sky_vertex.wgsl");
bevy::asset::embedded_asset!(app, "shaders/sky.wgsl");
// MONKEY (sky): the Enhanced/High library `sky.wgsl` and `cloud.wgsl` import.
bevy::asset::embedded_asset!(app, "shaders/sky_fx.wgsl");
bevy::asset::embedded_asset!(app, "shaders/star.wgsl");
bevy::asset::embedded_asset!(app, "shaders/cloud.wgsl");
bevy::asset::embedded_asset!(app, "shaders/celestial.wgsl");
@@ -59,6 +61,7 @@ mod tests {
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass);
// 8 since 2016 added `sky_vertex.wgsl` (the sky's shared far-depth vertex stage);
// 9 since the torch-shadow Phase 1 added `torch_depth.wgsl` (the interior depth map).
assert_eq!(found, 9, "the engine's shader set changed size");
// MONKEY (sky): 10 with `sky_fx.wgsl`, the sky's Enhanced/High library.
assert_eq!(found, 10, "the engine's shader set changed size");
}
}
+53 -1
View File
@@ -2,19 +2,71 @@
// the image the reference binds as its texture (`0x58ac70`). This stage applies the dome's rim
// fade (`0x6d0530`) and blends the raw gamma texels premultiplied. Depth is the far pin in
// `sky_vertex.wgsl`.
//
// MONKEY (sky): at `skyQuality` 2 the texels get GPU detail and a short sun march.
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp.
#import bevy_pbr::forward_io::VertexOutput
#ifdef SKY_FX_CLOUDS
#import benilla_world::sky_fx
#endif
@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
// 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 {
fx: vec4<f32>,
lit: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> cfx: CloudFx;
#ifdef SKY_FX_CLOUDS
// MONKEY (sky): coverage with detail at a sheet point; `oct` detail octaves.
fn detailed_cover(uv: vec2<f32>, oct: i32) -> f32 {
let a = textureSampleLevel(cloud_tex, cloud_samp, uv, 0.0).a;
if (a <= 0.0) {
return 0.0;
}
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, cfx.fx.y, oct));
}
#endif
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let texel = textureSample(cloud_tex, cloud_samp, in.uv);
var a = texel.a;
var rgb = texel.rgb;
// MONKEY (sky): High only (the `SKY_FX_CLOUDS` pipeline); Classic and Enhanced keep the
// 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);
a = sky_fx::cloud_erode(a, n);
// A painterly tone inside the mass from the same detail.
rgb = rgb * (0.93 + 0.14 * n);
let strength = cfx.lit.w;
if (a > 0.003 && strength > 0.0) {
let sd = cfx.fx.zw;
let stp = 0.011;
var occl = detailed_cover(in.uv + sd * stp, 2);
occl += detailed_cover(in.uv + sd * stp * 2.0, 2) * 0.75;
occl += detailed_cover(in.uv + sd * stp * 3.5, 2) * 0.5;
let transmit = exp(-occl * 1.44 * strength);
// WarcraftXL's ambient floor 0.55, softened toward the painted texel.
let light = 0.62 + 0.38 * transmit;
let ahead = detailed_cover(in.uv + sd * stp * 1.5, 2);
let rim = max(ahead - a, 0.0) * (1.0 - a) * transmit * strength;
rgb = rgb * mix(1.0, light, strength) + cfx.lit.rgb * rim * 0.9;
rgb = min(rgb, vec3<f32>(1.0));
}
}
#endif
#ifdef VERTEX_COLORS
a *= in.color.a; // the dome's rim fade (ring alphas)
#endif
// Premultiplied gamma blend; the RGB is already the reference's byte math.
return vec4<f32>(texel.rgb * a, a);
return vec4<f32>(rgb * a, a);
}
@@ -63,7 +63,7 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return select(hi, lo, c <= vec3<f32>(0.04045));
}
// Deviation, opt-in (`WOW_DITHER=1`): a dither against banding, which the reference's 8-bit
// Deviation, opt-in (MONKEY p0: cvar `skyDither`, or `WOW_DITHER=1`): a dither against banding, which the reference's 8-bit
// framebuffer lacks. The hash is Bevy's `screen_space_dither`, copied here because Bevy applies
// it only in the tonemapping pass that `Tonemapping::None` skips.
fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
+72 -2
View File
@@ -5,6 +5,13 @@
forward_io::VertexOutput,
mesh_view_bindings::view,
}
// MONKEY (sky): the Enhanced/High library (smooth gradient, sun glow, night sky), compiled only
// into the `SKY_FX` pipeline so Classic keeps the reference's exact code.
#ifdef SKY_FX
#import benilla_world::sky_fx
#endif
// MONKEY (fog): the Modern fog colour at the horizon.
#import benilla::fog_hook
struct SkyColors {
sky0: vec4<f32>, // zenith (90°)
@@ -14,6 +21,17 @@ 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
// strength.
fx: vec4<f32>,
// MONKEY (sky): xyz = camera to the visible sun, w unused.
sun: vec4<f32>,
// MONKEY (sky): rgb = glow colour (gamma), w unused.
glow: vec4<f32>,
// MONKEY (fog): MonkeyFrame rows B-D (all zero = Classic).
mf_fog_b: vec4<f32>,
mf_fog_c: vec4<f32>,
mf_fog_d: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> sky: SkyColors;
@@ -79,10 +97,62 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
} else if (elev < 16.8) {
col = mix(s2c, s1, (elev - 9.8) / (16.8 - 9.8));
} else {
col = mix(s1, sky.sky0.rgb, (elev - 16.8) / (90.0 - 16.8)); // warped ring1 to the raw apex
col = mix(s1, sky.sky0.rgb, (elev - 16.8) / (90.0 - 16.8));
}
// MONKEY (sky): Enhanced/High. The same stops through a monotone cubic in linear light, then
// the sun glow and the night sky added in linear light, back to gamma, dithered.
#ifdef SKY_FX
if (sky.fx.x >= 0.5) {
let stops = array<vec3<f32>, 6>(
sky_fx::srgb_to_linear(sky.fog.rgb),
sky_fx::srgb_to_linear(s4),
sky_fx::srgb_to_linear(s3),
sky_fx::srgb_to_linear(s2c),
sky_fx::srgb_to_linear(s1),
sky_fx::srgb_to_linear(sky.sky0.rgb),
);
var lin = sky_fx::smooth_gradient(elev, stops);
// MONKEY (fix-sky): the Modern fog horizon goes into the gradient, in linear light, BEFORE
// the glow and stars, so the fog wall meets the dome without a seam and the sunset halo is
// not erased at 0° (the Classic tail below applies it to `col` instead).
if (fog_hook::fog_is_modern(sky.mf_fog_d)) {
let far = fog_hook::fog_modern_colour(
sky.fog.rgb, dir, 1.0e9, sky.mf_fog_b, sky.mf_fog_c, sky.mf_fog_d,
);
let w = 1.0 - smoothstep(0.0, 0.26179939, asin(clamp(dir.y, -1.0, 1.0)));
lin = mix(lin, sky_fx::srgb_to_linear(far), w);
}
// Sun glow: a halo tinted by the sun and fog colours, faded at night and under cloud on the
// CPU; below the horizon it thins out over the fog band.
if (sky.fx.w > 0.0) {
let below = smoothstep(-0.12, 0.02, dir.y);
lin += sky_fx::srgb_to_linear(sky.glow.rgb)
* (sky.fx.w * below * sky_fx::sun_glow(dir, normalize(sky.sun.xyz)));
}
// Night sky: stars and the Milky Way, fixed to world directions, thinned toward the
// horizon. The cloud dome draws over this, so clouds hide it where they stand.
let night = sky.fx.z;
// The pixel's size in star-grid units; derivatives stay in uniform control flow.
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)
* 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;
}
let out = sky_fx::linear_to_srgb(lin) + vec3<f32>(sky_fx::dither_tri(in.position.xy));
// The horizon is already in: skip the Classic-path tail.
return vec4<f32>(clamp(out, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
}
#endif
// Raw gamma out: the reference draws the sky as raw DBC bytes, sRGB off (`0x6d4940`).
let rgb = col;
// MONKEY (fog): Modern eases the low band into the world's far fog colour after the Classic
// gradient (Enhanced/High mixes it in above); Classic fog returns `col` unchanged.
let rgb = fog_hook::fog_sky_horizon(
col, sky.fog.rgb, dir, sky.mf_fog_b, sky.mf_fog_c, sky.mf_fog_d,
);
return vec4<f32>(rgb, 1.0);
}
@@ -0,0 +1,280 @@
// MONKEY (sky): the Enhanced/High sky library (`skyQuality` ≥ 1), imported by `sky.wgsl` and
// `cloud.wgsl`. Classic never calls into it. Colour math is in linear light; the callers convert
// the gamma stops in and the result back out.
//
// The cloud detail (`cloud_billow`, `cloud_detail`):
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp, Clouds.hpp.
#define_import_path benilla_world::sky_fx
const TAU: f32 = 6.2831853;
// MONKEY (fix-sky): the sky clock's wrap (`sky_fx::SKY_CLOCK_WRAP_S`, a day). Twinkle rates are
// whole cycles per wrap, so the stars never jump at the wrap.
const SKY_WRAP: f32 = 86400.0;
// The phase of a `rate` rad/s oscillator, rounded to whole cycles per wrap.
fn wrap_phase(t: f32, rate: f32) -> f32 {
let cycles = round(rate * SKY_WRAP / TAU);
return fract(cycles * (t / SKY_WRAP)) * TAU;
}
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
let lo = c / 12.92;
let hi = pow((max(c, vec3<f32>(0.0)) + 0.055) / 1.055, vec3<f32>(2.4));
return select(hi, lo, c <= vec3<f32>(0.04045));
}
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let lo = c * 12.92;
let hi = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
return select(hi, lo, c <= vec3<f32>(0.0031308));
}
// ---- hashes and noise -------------------------------------------------------------------------
// pcg3d (Jarzynski & Olano 2020): three well-mixed u32 from three.
fn pcg_mix(v_in: vec3<u32>) -> vec3<u32> {
var v = v_in * 1664525u + 1013904223u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
v = v ^ (v >> vec3<u32>(16u));
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
return v;
}
fn hash_three(p: vec3<i32>) -> vec3<f32> {
return vec3<f32>(pcg_mix(bitcast<vec3<u32>>(p)) >> vec3<u32>(8u)) * (1.0 / 16777216.0);
}
fn hash_cell(p: vec2<i32>, salt: u32) -> f32 {
return f32(pcg_mix(vec3<u32>(bitcast<vec2<u32>>(p), salt)).x >> 8u) * (1.0 / 16777216.0);
}
// 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 {
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);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
fn value_noise_space(p: vec3<f32>) -> f32 {
let i = vec3<i32>(floor(p));
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
let noise_aaa = hash_three(i).x;
let noise_baa = hash_three(i + vec3<i32>(1, 0, 0)).x;
let noise_aba = hash_three(i + vec3<i32>(0, 1, 0)).x;
let noise_bba = hash_three(i + vec3<i32>(1, 1, 0)).x;
let noise_aab = hash_three(i + vec3<i32>(0, 0, 1)).x;
let noise_bab = hash_three(i + vec3<i32>(1, 0, 1)).x;
let noise_abb = hash_three(i + vec3<i32>(0, 1, 1)).x;
let noise_bbb = hash_three(i + vec3<i32>(1, 1, 1)).x;
let interp_aa = mix(noise_aaa, noise_baa, u.x);
let interp_ba = mix(noise_aba, noise_bba, u.x);
let interp_ab = mix(noise_aab, noise_bab, u.x);
let interp_bb = mix(noise_abb, noise_bbb, u.x);
return mix(mix(interp_aa, interp_ba, u.y), mix(interp_ab, interp_bb, u.y), u.z);
}
fn fbm_space(p: vec3<f32>) -> f32 {
var s = 0.0;
var a = 0.5;
var q = p;
for (var o = 0; o < 3; o++) {
s += a * value_noise_space(q);
q = q * 2.03 + vec3<f32>(1.7, 9.2, 3.4);
a *= 0.5;
}
return s / 0.875;
}
// ---- S1: the smooth gradient ------------------------------------------------------------------
// The reference's ring elevations (degrees), horizon to zenith.
const STOP_ELEV = array<f32, 6>(0.0, 1.8, 3.7, 9.8, 16.8, 90.0);
// Fritsch–Carlson tangent at an interior stop: 0 at a local extremum, else the weighted harmonic
// mean of the two secants, so the curve never overshoots a stop (no halos, no new colours).
fn pchip_tangent(first_delta: vec3<f32>, second_delta: vec3<f32>, first_span: f32, second_span: f32) -> vec3<f32> {
let left_weight = 2.0 * second_span + first_span;
let right_weight = second_span + 2.0 * first_span;
let left_safe = select(first_delta, vec3<f32>(1.0), first_delta == vec3<f32>(0.0));
let right_safe = select(second_delta, vec3<f32>(1.0), second_delta == vec3<f32>(0.0));
let m = (left_weight + right_weight) / (left_weight / left_safe + right_weight / right_safe);
return select(vec3<f32>(0.0), m, first_delta * second_delta > vec3<f32>(0.0));
}
// Monotone cubic through the six stops (`y[0]` = fog at 0°, `y[5]` = zenith), linear light. The
// end tangents are 0: flat into the fog band below the horizon and flat over the zenith, so the
// curve is C1 everywhere and the rings leave no Mach bands.
fn smooth_gradient(elev: f32, y: array<vec3<f32>, 6>) -> vec3<f32> {
if (elev <= 0.0) {
return y[0];
}
if (elev >= 90.0) {
return y[5];
}
var yy = y;
var se = STOP_ELEV;
var d: array<vec3<f32>, 5>;
var h: array<f32, 5>;
for (var i = 0; i < 5; i++) {
h[i] = se[i + 1] - se[i];
d[i] = (yy[i + 1] - yy[i]) / h[i];
}
var k = 0;
for (var i = 1; i < 5; i++) {
if (elev >= se[i]) {
k = i;
}
}
var tangent_start = vec3<f32>(0.0);
var tangent_end = vec3<f32>(0.0);
if (k > 0) {
tangent_start = pchip_tangent(d[k - 1], d[k], h[k - 1], h[k]);
}
if (k < 4) {
tangent_end = pchip_tangent(d[k], d[k + 1], h[k], h[k + 1]);
}
let hk = h[k];
let t = (elev - se[k]) / hk;
let t_sq = t * t;
let t_cube = t_sq * t;
let blend_start = 2.0 * t_cube - 3.0 * t_sq + 1.0;
let blend_start_tangent = t_cube - 2.0 * t_sq + t;
let blend_end = -2.0 * t_cube + 3.0 * t_sq;
let blend_end_tangent = t_cube - t_sq;
return blend_start * yy[k] + blend_start_tangent * hk * tangent_start
+ blend_end * yy[k + 1] + blend_end_tangent * hk * tangent_end;
}
// Triangular dither of ±1 LSB at 8 bits, fixed per pixel (no temporal shimmer).
fn dither_tri(frag: vec2<f32>) -> f32 {
let h = hash_three(vec3<i32>(vec2<i32>(frag), 7));
return (h.x + h.y - 1.0) / 255.0;
}
// ---- S4: the sun glow -------------------------------------------------------------------------
// Henyey–Greenstein phase normalised to 1 toward the sun.
fn hg_norm(c: f32, g: f32) -> f32 {
let g_sq = g * g;
let a = 1.0 + g_sq - 2.0 * g * c;
let a_min = (1.0 - g) * (1.0 - g);
return pow(a_min / a, 1.5);
}
// A two-lobe Mie-like halo: a broad aureole plus a tighter core round the disc.
fn sun_glow(dir: vec3<f32>, sun: vec3<f32>) -> f32 {
let c = dot(dir, sun);
return 0.55 * hg_norm(c, 0.70) + 0.45 * hg_norm(c, 0.93);
}
// ---- S5: the night sky ------------------------------------------------------------------------
// Grid cells per unit radius: one cell spans ~0.3°, so a candidate star every few pixels.
const STAR_GRID: f32 = 180.0;
// A procedural star field fixed to world directions. Each grid cell holds at most one star (a
// density draw), jittered inside the cell; the eight cells round the sample are tested so no star
// is cut at a cell face. Brightness is a steep power law (few bright, many faint), the footprint
// is at least a pixel wide so a star does not vanish between pixels, and a slow twinkle is
// stronger low in the sky. `px` is the pixel's size in grid units.
fn star_field(dir: vec3<f32>, t: f32, px: f32, band: f32) -> vec3<f32> {
let p = dir * STAR_GRID;
let base = floor(p);
let f = p - base;
let o = select(vec3<f32>(-1.0), vec3<f32>(1.0), f > vec3<f32>(0.5));
let density = 0.022 * (1.0 + 2.5 * band);
var acc = vec3<f32>(0.0);
for (var i = 0u; i < 8u; i++) {
let off = vec3<f32>(f32(i & 1u), f32((i >> 1u) & 1u), f32((i >> 2u) & 1u)) * o;
let cell = vec3<i32>(base + off);
let h = hash_three(cell);
if (h.x > density) {
continue;
}
let j = hash_three(cell + vec3<i32>(7919, 104729, 1299709));
let centre = normalize(vec3<f32>(cell) + 0.5 + (j - 0.5) * 0.9) * STAR_GRID;
let d = length(p - centre);
let mag = pow(h.y, 22.0);
let b = 0.035 + 0.965 * mag;
let sigma = px * (0.55 + 0.7 * mag);
let spot = exp(-d * d / (2.0 * sigma * sigma));
let tw_amp = 0.18 + 0.3 * (1.0 - clamp(dir.y, 0.0, 1.0));
let tw = 1.0 + tw_amp * sin(wrap_phase(t, 1.3 + 3.1 * j.x) + TAU * j.y)
* sin(wrap_phase(t, 0.6 + 1.7 * j.z) + TAU * h.z);
// Star colour: mostly white, a few warm and a few blue-white.
let tint = mix(vec3<f32>(0.78, 0.86, 1.0), vec3<f32>(1.0, 0.88, 0.72), h.z);
acc += tint * (b * spot * tw);
}
return acc;
}
// The Milky Way's plane in Bevy space (y up): tilted so the band arcs across the dome.
const GALAXY_N: vec3<f32> = vec3<f32>(0.40, 0.52, -0.754);
const GALAXY_CORE: vec3<f32> = vec3<f32>(-0.62, 0.36, -0.08);
// Band weight in [0, 1] toward `dir`.
fn galaxy_band(dir: vec3<f32>) -> f32 {
let b = dot(dir, normalize(GALAXY_N));
return exp(-b * b / (2.0 * 0.15 * 0.15));
}
// A faint, mottled band with a dark dust lane down its middle and a brighter core.
fn milky_way(dir: vec3<f32>, band: f32) -> vec3<f32> {
if (band < 0.01) {
return vec3<f32>(0.0);
}
let b = dot(dir, normalize(GALAXY_N));
let cloud = fbm_space(dir * 5.0);
let lane = exp(-(b - 0.02) * (b - 0.02) / (2.0 * 0.03 * 0.03)) * smoothstep(0.35, 0.7, fbm_space(dir * 9.0 + 11.0));
let core = 1.0 + 1.2 * pow(max(dot(dir, normalize(GALAXY_CORE)), 0.0), 4.0);
let v = band * (0.3 + 0.7 * cloud) * (1.0 - 0.75 * lane) * core;
return vec3<f32>(0.72, 0.8, 1.0) * v;
}
// ---- S3: cloud detail -------------------------------------------------------------------------
// 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;
}
// Billowed fbm, amplitude 0.55 per octave as in WarcraftXL.
fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
var s = 0.0;
var a = 1.0;
var tot = 0.0;
var q = p;
for (var o = 0; o < octaves; o++) {
s += a * billow(value_noise_plane(q, u32(o) * 57u));
tot += a;
a *= 0.55;
q = q * 2.0 + vec2<f32>(3.7, 1.9);
}
return s / tot;
}
// 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;
return cloud_billow(p + w * 2.6, octaves);
}
// Apply the detail to a base coverage: the edges (mid coverage) are pushed in and out by the
// detail, solid cores and clear sky stay put, so the CPU tile still says where cloud is.
fn cloud_erode(a: f32, n: f32) -> f32 {
let edge = 4.0 * a * (1.0 - a);
return clamp(a + (n - 0.5) * 1.2 * edge, 0.0, 1.0);
}
@@ -10,6 +10,16 @@
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity.
#import benilla::emissive_hook
// MONKEY (wind): shared foliage vertex displacement; tree shadows intentionally stay static.
#import benilla::wind_hook
// MONKEY (wet): rain on surfaces (wet_hook.wgsl).
#import benilla::wet_hook
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
@@ -40,6 +50,13 @@ struct WowLight {
// yards. wow_model.wgsl owns the full note.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
// MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z,
// 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl).
shelter_hdr: vec4<f32>,
shelter_cfg: vec4<f32>,
shelter: array<u32, 16384>,
// lighting::prop_probes: 8192 slots of 7 rows; the buffer's later regions are not mirrored.
prop_probes: array<vec4<f32>, 57344>,
}
@@ -97,6 +114,8 @@ const WORD_HAS_VC: u32 = 134217728u; // 1 << 27: the batch authors vertex colo
// INTERIOR without WMO is an interior M2 prop, the entity shader's `interior_prop =
// flags.z && !flags.x`: probe lighting, interior fog, no point lights.
const WORD_MATTE: u32 = 268435456u; // 1 << 28: ShadeSel::Matte, fixed intensity 1.0
// MONKEY (wind): alpha-tested leaf batch of a classified static tree/bush model.
const WORD_FOLIAGE_WIND: u32 = 536870912u; // 1 << 29
// MONKEY (room gate): record column `w` bits 15..=26 — this item's ROOM KEY, packed as
// `group + 1`, so **0 means the item names no room** and takes every fixture (a terrain-cell item,
@@ -1276,8 +1295,15 @@ fn vertex(v: GxVertex) -> GxVsOut {
return out;
}
// Camera-relative for f32 precision: the recentred vertex plus (cell origin - camera).
let p_cam = v.position + (cell.origin.xyz - view.world_position);
let world = v.position + cell.origin.xyz;
var p_cam = v.position + (cell.origin.xyz - view.world_position);
var world = v.position + cell.origin.xyz;
// MONKEY (wind): placement anchor is already baked per vertex. Only classified leaf-card
// batches carry the bit; animated doodads were rejected before this retained path.
if ((v.word & WORD_FOLIAGE_WIND) != 0u) {
let offset = wind_hook::tree_offset(world, v.anchor, view.world_position, wow_light.monkey);
world += offset;
p_cam += offset;
}
out.world_position = vec4<f32>(world, 1.0);
let view_rot = mat3x3<f32>(
view.view_from_world[0].xyz,
@@ -1513,7 +1539,13 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
sidn_w = 0.0;
}
}
let sidn_e = sidn_rgb * (wow_light.grade.x * sidn_w);
var sidn_e = sidn_rgb * (wow_light.grade.x * sidn_w);
// MONKEY (fix-post): the bloom window gain lifts only this light term, before fog (it used
// to multiply the whole fogged batch, so fogged buildings glowed as fog x 2.35 at night).
if ((in.word & WORD_WINDOW) != 0u) {
sidn_e = emissive_hook::emissive_boost(
sidn_e, emissive_hook::EMISSIVE_WMO_WINDOW, wow_light.light_diffuse.w, 1.0);
}
// MONKEY (ext-class night law): plenty of geometry INSIDE a building is authored
// EXTERIOR-class — the Goldshire inn's whole 57.7 yd shell is one group (`upstairs`,
// MOGP 0x0a09, and it holds the stair down to the cellar), its east stairwell annex is
@@ -1966,6 +1998,27 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
if ((in.word & WORD_UNLIT) != 0u) {
rgb = folded;
}
// MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid).
var wet_open = 1.0;
if (wow_light.monkey.wet_a.y > 0.0) {
let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr,
wow_light.shelter_cfg);
if (wet_st.live > 0.0) {
wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x],
wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z],
wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg);
}
}
// MONKEY (wet): rain on sky-exposed surfaces (wet_hook.wgsl); interior, unlit and dry = untouched.
let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz,
// MONKEY (fix-wet): lit WMO windows (WORD_WINDOW) are not darkened by rain either.
select(1.0, 0.0, (in.word & (WORD_INTERIOR | WORD_UNLIT | WORD_WINDOW)) != 0u) * wet_open,
wow_light.monkey);
rgb = wet.albedo;
if (wet.boost > 0.0) {
rgb = min(rgb + wet_hook::wet_sheen(wet.boost, n_lit, normalize(view.world_position - in.world_position.xyz),
L, wow_light.light_diffuse.rgb, wow_light.fog_color.rgb, world_shadow), vec3<f32>(1.0));
}
// Planar eye-Z fog; other fog modes belong to blends never admitted here. The interior triple
// keys on the per-frame record bit, not `WORD_INTERIOR`: the client sets it per group under
// `[0xca7f00]` (`0x6b5190` for surfaces, `0x6b62e0` for the group's doodads).
@@ -1976,10 +2029,12 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
fog_span = wow_light.wmo_fog_params.xy;
}
if (fog_color.w > 0.5 && (in.word & WORD_FOG_OFF) == 0u) {
let denom = max(fog_span.y - fog_span.x, 0.001);
let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
rgb = mix(fog_color.xyz, rgb, factor);
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
rgb = fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, in.world_position.xyz,
view.world_position, true, wow_light.monkey);
}
// MONKEY (post): SIDN/window light crosses 1.0 only at night and when bloom is armed; the
// gain is applied to `sidn_e` above (MONKEY fix-post).
// Gamma-space output; alpha pinned 1.0, every draw here is opaque.
return vec4<f32>(rgb, 1.0);
}
@@ -12,6 +12,12 @@
// - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst`, rain's state; reads no alpha.
#import bevy_render::view::View
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity.
#import benilla::emissive_hook
// Prefix of `lighting::global_light`'s buffer; keep in sync with wow_model.wgsl's copy.
struct WowLight {
@@ -33,6 +39,11 @@ struct WowLight {
sh_c16: vec4<f32>,
_water: array<vec4<f32>, 4>,
grade: vec4<f32>,
// MONKEY (p0 MonkeyFrame): rows 18-20 and the point table, unread here, so the block lines up.
_rows_18_20: array<vec4<f32>, 3>,
_points: array<vec4<f32>, 512>,
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
};
@group(0) @binding(0) var<uniform> view: View;
@@ -66,6 +77,8 @@ struct VertexOutput {
@location(1) color: vec4<f32>,
// Planar eye-Z for fog and the farclip wall, positive in front of the camera.
@location(2) view_z: f32,
// MONKEY (p0 fog hook): world position (Bevy space) for the fog hook's modern arm.
@location(3) world_pos: vec3<f32>,
};
@vertex
@@ -78,6 +91,7 @@ fn vertex(v: Vertex) -> VertexOutput {
out.clip_position = view.clip_from_world * vec4<f32>(v.position, 1.0);
// Eye-Z via the full affine transform; its rounding is yard-scale and harmless.
out.view_z = -(view.view_from_world * vec4<f32>(v.position, 1.0)).z;
out.world_pos = v.position; // MONKEY (p0 fog hook)
#else
// Cam-relative verts: view_from_world is [R | −R·cam] and the rebase already subtracted cam,
// so only the rotation applies.
@@ -88,6 +102,7 @@ fn vertex(v: Vertex) -> VertexOutput {
) * v.position;
out.clip_position = view.clip_from_view * vec4<f32>(view_pos, 1.0);
out.view_z = -view_pos.z;
out.world_pos = v.position + view.world_position; // MONKEY (p0 fog hook): undo the rebase
#endif
out.uv = v.uv;
out.color = v.color;
@@ -143,24 +158,26 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Scene fog: the world's linear fog (same start and end, planar eye-Z) in gamma space before
// the blend; only its colour follows the per-blend policy in `wow_params.fog.x`.
if (wow_light.fog_color.w > 0.5 && wow_params.fog.x > 0.5) {
let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001);
let factor = clamp((wow_light.fog_params.y - in.view_z) / denom, 0.0, 1.0);
var fog_rgb = wow_light.fog_color.xyz;
if (wow_params.fog.x > 1.5 && wow_params.fog.x < 2.5) { fog_rgb = vec3<f32>(0.0); }
else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3<f32>(1.0); }
else if (wow_params.fog.x > 3.5) { fog_rgb = RAIN_FOG_GREY; }
rgb = mix(fog_rgb, rgb, factor);
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
rgb = fog_hook::apply_fog(rgb, fog_rgb, wow_light.fog_params.xy, in.view_z, in.world_pos,
view.world_position, wow_params.fog.x < 1.5, wow_light.monkey);
}
// Rain's forced fog: grey over the draw's own start/end, whatever the scene fog; grey is
// neutral under Mod2x, so this is the streaks' distance fade.
if (wow_params.fog.y > 0.5) {
let denom = max(wow_params.fog.w - wow_params.fog.z, 0.001);
let factor = clamp((wow_params.fog.w - in.view_z) / denom, 0.0, 1.0);
// MONKEY (p0 fog hook): the draw's own distance fade, always the classic linear law.
let factor = fog_hook::fog_linear(in.view_z, wow_params.fog.zw);
rgb = mix(RAIN_FOG_GREY, rgb, factor);
}
#ifdef BLEND_ADD
// Premultiplied in gamma, so stacked quads sum like the reference's bytes.
return vec4<f32>(rgb * c.a, 0.0);
// MONKEY (post): boost before the additive framebuffer blend, never after the stack.
return vec4<f32>(emissive_hook::emissive_boost(
rgb * c.a, emissive_hook::EMISSIVE_PARTICLE_ADD, wow_light.light_diffuse.w, 1.0), 0.0);
#else
#ifdef BLEND_OPAQUE
return vec4<f32>(rgb, 1.0);
+108 -3
View File
@@ -32,6 +32,7 @@ pub type SkyMaterial = ExtendedMaterial<StandardMaterial, SkyExt>;
/// The five sky stops, zenith to horizon, and the fog colour on one binding (100); `sky.wgsl`'s
/// struct must match this field order.
#[derive(Asset, AsBindGroup, Clone, TypePath)]
#[bind_group_data(SkyFxKey)]
pub struct SkyExt {
/// `SkyColor0`: zenith (90°).
#[uniform(100)]
@@ -55,6 +56,36 @@ pub struct SkyExt {
/// none), `y` the sun azimuth `atan2(sun.z, sun.x)` in radians, `zw` reserved.
#[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.
#[uniform(100)]
pub(crate) fx: Vec4,
/// MONKEY (sky): camera to the visible sun (`xyz`).
#[uniform(100)]
pub(crate) sun: Vec4,
/// MONKEY (sky): the glow colour, gamma (`rgb`).
#[uniform(100)]
pub(crate) glow: Vec4,
/// MONKEY (fog): MonkeyFrame fog rows B-D so the dome horizon uses the world's far colour.
#[uniform(100)]
pub(crate) mf_fog_b: Vec4,
#[uniform(100)]
pub(crate) mf_fog_c: Vec4,
#[uniform(100)]
pub(crate) mf_fog_d: Vec4,
}
/// MONKEY (sky): the pipeline key: Enhanced/High compile `SKY_FX` into `sky.wgsl`; Classic keeps
/// the reference's code with nothing added.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct SkyFxKey {
fx: bool,
}
impl From<&SkyExt> for SkyFxKey {
fn from(e: &SkyExt) -> Self {
Self { fx: e.fx.x >= 0.5 }
}
}
impl MaterialExtension for SkyExt {
@@ -73,8 +104,14 @@ impl MaterialExtension for SkyExt {
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialExtensionKey<Self>,
key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// MONKEY (sky): the Enhanced/High branch.
if key.bind_group_data.fx {
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("SKY_FX".into());
}
}
if let Some(depth) = descriptor.depth_stencil.as_mut() {
depth.depth_write_enabled = false;
}
@@ -100,7 +137,10 @@ impl Plugin for SkyPlugin {
(
// Reads the resolved atmosphere: unordered, it would paint last frame's
// palette, the underwater one on a surfacing frame.
update_sky_colors.in_set(crate::lighting::LightingConsumeSet),
// MONKEY (fix-sky): after the cloud tile, so the glow reads this frame's cover.
update_sky_colors
.in_set(crate::lighting::LightingConsumeSet)
.after(crate::clouds::CloudTick),
// The skybox and submersion gates must read their settled resolves.
apply_sky_visibility
.after(crate::skybox::SkyboxResolve)
@@ -183,6 +223,14 @@ fn setup_sky(
sky4: col([0.78, 0.86, 0.95]),
fog: col([0.55, 0.72, 0.92]),
warp: Vec4::ZERO, // update_sky_colors fills S + sun azimuth each frame
// MONKEY (sky): Classic until update_sky_colors reads the tier.
fx: Vec4::ZERO,
sun: Vec4::Y,
glow: Vec4::ZERO,
// MONKEY (fog): zero rows leave Classic unchanged.
mf_fog_b: Vec4::ZERO,
mf_fog_c: Vec4::ZERO,
mf_fog_d: Vec4::ZERO,
},
});
commands.spawn((
@@ -242,8 +290,15 @@ fn apply_sky_visibility(
}
/// Push the time-of-day sky stops and fog colour (`WowLighting`) into the dome material.
#[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.
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>,
dome: Query<&MeshMaterial3d<SkyMaterial>, With<Sky>>,
mut materials: ResMut<Assets<SkyMaterial>>,
) {
@@ -270,11 +325,26 @@ fn update_sky_colors(
0.0,
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);
// 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 = [
Vec4::from_array(rows[1]),
Vec4::from_array(rows[2]),
Vec4::from_array(rows[3]),
];
benilla_assets::write_gated(
&mut materials,
&handle.0,
|m| {
m.extension.sky0 != sky[0]
m.extension.fx != fx
|| m.extension.sun != sun_v
|| m.extension.glow != glow
|| m.extension.mf_fog_b != mf_fog[0]
|| m.extension.mf_fog_c != mf_fog[1]
|| m.extension.mf_fog_d != mf_fog[2]
|| m.extension.sky0 != sky[0]
|| m.extension.sky1 != sky[1]
|| m.extension.sky2 != sky[2]
|| m.extension.sky3 != sky[3]
@@ -290,6 +360,41 @@ fn update_sky_colors(
m.extension.sky4 = sky[4];
m.extension.fog = fog;
m.extension.warp = warp;
m.extension.fx = fx;
m.extension.sun = sun_v;
m.extension.glow = glow;
m.extension.mf_fog_b = mf_fog[0];
m.extension.mf_fog_c = mf_fog[1];
m.extension.mf_fog_d = mf_fog[2];
},
);
}
/// MONKEY (sky): the dome's Enhanced/High uniforms, `(fx, sun, glow)`; zeros at Classic.
fn sky_fx_inputs(
light: &WowLighting,
quality: crate::sky_fx::SkyQuality,
secs: f32,
clouds: &crate::clouds::CloudCoverage,
) -> (Vec4, Vec4, Vec4) {
if !quality.enhanced() {
return (Vec4::ZERO, Vec4::Y, Vec4::ZERO);
}
let sun = light.celestial_dir.normalize_or(Vec3::Y);
// The cover at the glare point, the same sample the flare's occlusion reads.
let cover = clouds.coverage(sun * 12.0);
let strength = crate::sky_fx::glow_strength(sun.y, cover, light.storm_bcc);
// Warm toward the sun colour, grounded in the fog colour so dusk glows orange, noon pale.
let tint = Vec3::from(light.celestial_tint);
let fog = Vec3::from(light.fog_color);
let glow = fog.lerp(tint, 0.6);
// The night sky follows the star curve, thinned by storm weather.
let night = (light.star_alpha * (1.0 - light.storm_bcc)).clamp(0.0, 1.0);
// 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(q(sun.x), q(sun.y), q(sun.z), 0.0),
Vec4::new(q(glow.x), q(glow.y), q(glow.z), 0.0),
)
}
+129
View File
@@ -0,0 +1,129 @@
//! MONKEY (sky): the sky quality tier and what the Enhanced/High sky reads each frame.
//!
//! `skyQuality` 0 is Classic, the reference's image byte for byte: the dome's piecewise-linear
//! gradient and the kernel's cloud texels, untouched. 1 (Enhanced) turns on the smooth gradient,
//! the sun glow and the procedural night sky in `sky.wgsl`; 2 (High) adds the cloud detail and the
//! sun-lit cloud shading in `cloud.wgsl`. The WGSL lives in `shaders/sky_fx.wgsl`.
//!
//! The High cloud detail and sun march (`sky_fx.wgsl`, `cloud.wgsl`, `clouds/layer.rs`):
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp, Clouds.hpp.
use bevy::prelude::*;
/// 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).
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct SkyQuality(pub u8);
impl SkyQuality {
/// The highest tier.
pub const MAX: u8 = 2;
/// The session override, `$WOW_SKY_QUALITY` clamped to the tiers.
pub fn env_override() -> Option<u8> {
std::env::var("WOW_SKY_QUALITY")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.map(|v| v.min(Self::MAX))
}
pub fn enhanced(self) -> bool {
self.0 >= 1
}
pub fn high(self) -> bool {
self.0 >= 2
}
}
/// The sky's own animation clock (star twinkle, cloud detail drift), seconds wrapped to a day.
/// Frozen in capture mode, at `$WOW_CAPTURE_SKY_T` or 0.
///
/// 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.
#[derive(Resource, Default)]
pub struct SkyClock {
pub secs: f32,
acc: f64,
frozen: Option<f32>,
}
/// 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;
/// 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;
/// The glow's day fade: full once the sun clears the horizon, gone a few degrees under it.
pub(crate) fn glow_day_fade(sun_y: f32) -> f32 {
let t = ((sun_y + 0.10) / 0.16).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// The glow strength: the day fade, dimmed by the cloud cover over the sun (the same coverage the
/// glare's occlusion reads) and by storm weather.
pub(crate) fn glow_strength(sun_y: f32, cover_at_sun: f32, bcc: f32) -> f32 {
GLOW_GAIN * glow_day_fade(sun_y) * (1.0 - 0.8 * cover_at_sun.clamp(0.0, 1.0))
* (1.0 - 0.85 * bcc.clamp(0.0, 1.0))
}
pub(crate) struct SkyFxPlugin;
impl Plugin for SkyFxPlugin {
fn build(&self, app: &mut App) {
// The library `sky.wgsl` and `cloud.wgsl` import; its embedded path is served by
// `shaders::plugin`, this keeps it loaded so the import resolves.
let lib: Handle<Shader> =
bevy::asset::load_embedded_asset!(app, "shaders/sky_fx.wgsl");
std::mem::forget(lib);
let frozen = std::env::var_os("WOW_CAPTURE").map(|_| {
std::env::var("WOW_CAPTURE_SKY_T")
.ok()
.and_then(|v| v.trim().parse::<f32>().ok())
.unwrap_or(0.0)
});
app.insert_resource(SkyClock {
secs: frozen.unwrap_or(0.0),
acc: 0.0,
frozen,
});
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));
}
}
fn tick_sky_clock(time: Res<Time>, mut clock: ResMut<SkyClock>) {
if let Some(t) = clock.frozen {
if clock.secs != t {
clock.secs = t;
}
return;
}
clock.acc = (clock.acc + time.delta_secs_f64()) % SKY_CLOCK_WRAP_S;
clock.secs = clock.acc as f32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glow_fades_out_below_the_horizon_and_under_cloud() {
assert_eq!(glow_strength(-0.2, 0.0, 0.0), 0.0);
assert!((glow_strength(0.5, 0.0, 0.0) - GLOW_GAIN).abs() < 1e-6);
assert!(glow_strength(0.5, 1.0, 0.0) < 0.25 * GLOW_GAIN);
assert!(glow_strength(0.5, 0.0, 1.0) < 0.2 * GLOW_GAIN);
// Monotone across dusk.
let mut prev = 0.0;
for i in 0..40 {
let y = -0.15 + i as f32 * 0.01;
let g = glow_day_fade(y);
assert!(g >= prev);
prev = g;
}
}
}
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
//! MONKEY (skybox): the animated half of the skybox lane. A skybox draws camera-anchored at the far
//! depth ([`crate::skybox`]), so it cannot ride the joint-entity rig: its anchor is written after
//! transform propagation. Its bones are posed here on the CPU instead, each frame, from the armed
//! sequence (animation id 0) and the global sequences, as `0x714260` composes them:
//! `M = parent · T(pivot) · T(t) · R(q) · S(s) · T(−pivot)`. A batch wholly on one bone takes that
//! bone's matrix as its transform; a batch spread over several moving bones is skinned on the CPU
//! (skyboxes are a few thousand vertices).
//!
//! Its texture-transform, colour and alpha tracks run on the same clock through the shared
//! mat-anim table ([`crate::mat_anim_table`]), in rows this module owns, so a skybox flagged
//! `0x1` (sequence 0 across the game day) drives every channel from the day fraction.
use std::sync::Arc;
use bevy::math::Affine3A;
use bevy::prelude::*;
use benilla_assets::coords::{wow_rotation_to_bevy, wow_to_bevy};
/// A key list for one channel: `(seconds, value)` time-ascending, and whether it interpolates.
#[derive(Clone, Debug, Default)]
struct Channel<V> {
keys: Vec<(f32, V)>,
linear: bool,
}
impl<V: Copy> Channel<V> {
/// The value at `t` (seconds inside the channel's own span): held before the first key and past
/// the last, stepped or blended between, as the reference's key search clamps.
fn sample(&self, t: f32, blend: impl Fn(V, V, f32) -> V) -> Option<V> {
let (&(t0, v0), rest) = self.keys.split_first()?;
if rest.is_empty() || t <= t0 {
return Some(v0);
}
let mut k = 0;
while k + 1 < self.keys.len() && self.keys[k + 1].0 <= t {
k += 1;
}
let (ta, va) = self.keys[k];
if !self.linear || k + 1 >= self.keys.len() {
return Some(va);
}
let (tb, vb) = self.keys[k + 1];
let f = if tb > ta { (t - ta) / (tb - ta) } else { 0.0 };
Some(blend(va, vb, f))
}
}
/// A global-sequence channel: keys in seconds over `period`, sampled at the free clock mod it.
#[derive(Clone, Debug, Default)]
struct GlobalChannel<V> {
period: f32,
channel: Channel<V>,
}
/// One bone's channels, Bevy space. A global-sequence channel replaces the sequence one.
#[derive(Clone, Debug, Default)]
struct BoneTracks {
translation: Channel<Vec3>,
rotation: Channel<Quat>,
scale: Channel<Vec3>,
g_translation: Option<GlobalChannel<Vec3>>,
g_rotation: Option<GlobalChannel<Quat>>,
g_scale: Option<GlobalChannel<Vec3>>,
}
impl BoneTracks {
fn moves(&self) -> bool {
!self.translation.keys.is_empty()
|| !self.rotation.keys.is_empty()
|| !self.scale.keys.is_empty()
|| self.g_translation.is_some()
|| self.g_rotation.is_some()
|| self.g_scale.is_some()
}
}
/// A skybox model's rig: the bone tree and the armed sequence's tracks, Bevy space.
#[derive(Debug, Default)]
pub(crate) struct SkyRig {
parents: Vec<i16>,
pivots: Vec<Vec3>,
tracks: Vec<BoneTracks>,
/// The armed sequence's length in seconds and its file slot (the alpha bake's slot index).
pub(crate) duration: f32,
pub(crate) seq_slot: Option<usize>,
looping: bool,
}
fn lerp3(a: Vec3, b: Vec3, f: f32) -> Vec3 {
a.lerp(b, f)
}
fn slerp(a: Quat, b: Quat, f: f32) -> Quat {
a.slerp(b, f)
}
/// The interpolation flag of the `M2Track` at `track` (`interp_type != 0`), linear when unreadable.
fn track_linear(bytes: &[u8], track: usize) -> bool {
bytes
.get(track..track + 2)
.is_none_or(|b| u16::from_le_bytes([b[0], b[1]]) != 0)
}
/// WoW-space scale to Bevy space: [`wow_to_bevy`] permutes the axes, so a scale permutes too.
fn scale_to_bevy(s: [f32; 3]) -> Vec3 {
Vec3::new(s[1], s[2], s[0])
}
impl SkyRig {
/// Parse the rig out of the model bytes; an unparsable or boneless model has an empty rig.
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
let Ok(skeleton) = benilla_formats::parse_m2_skeleton(bytes) else {
return Self::default();
};
let n = skeleton.bones.len();
let mut rig = SkyRig {
parents: skeleton.bones.iter().map(|b| b.parent).collect(),
pivots: skeleton.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect(),
tracks: vec![BoneTracks::default(); n],
duration: 0.0,
seq_slot: None,
looping: true,
};
let bone_ofs = bytes
.get(0x38..0x3c)
.map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
let rec = |i: usize| bone_ofs + i * 0x6c;
// The armed sequence: animation id 0 (`0x70ebd0`), else the file's first.
let anims = benilla_formats::parse_m2_animations(bytes);
if let Some(seq) = anims
.iter()
.find(|a| a.anim_id == 0)
.or_else(|| anims.first())
{
rig.duration = seq.duration;
rig.seq_slot = Some(seq.seq_index);
rig.looping = seq.looping;
for keys in &seq.bones {
let i = usize::from(keys.bone);
let Some(t) = rig.tracks.get_mut(i) else {
continue;
};
t.translation = Channel {
keys: keys
.translation
.iter()
.map(|&(s, v)| (s, wow_to_bevy(v)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x0c),
};
t.rotation = Channel {
keys: keys
.rotation
.iter()
.map(|&(s, q)| (s, wow_rotation_to_bevy(q)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x28),
};
t.scale = Channel {
keys: keys
.scale
.iter()
.map(|&(s, v)| (s, scale_to_bevy(v)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x44),
};
}
}
fn global<T, V>(
c: &Option<benilla_formats::GlobalSeqChannel<T>>,
linear: bool,
conv: impl Fn(T) -> V,
) -> Option<GlobalChannel<V>>
where
T: Copy,
{
let c = c.as_ref()?;
Some(GlobalChannel {
period: c.period_ms as f32 / 1000.0,
channel: Channel {
keys: c
.keys
.iter()
.map(|&(ms, v)| (ms as f32 / 1000.0, conv(v)))
.collect(),
linear,
},
})
}
for g in benilla_formats::parse_m2_global_sequence_bones(bytes) {
let i = usize::from(g.bone);
let Some(t) = rig.tracks.get_mut(i) else {
continue;
};
t.g_translation = global(&g.translation, track_linear(bytes, rec(i) + 0x0c), |v| {
wow_to_bevy(v)
});
t.g_rotation = global(&g.rotation, track_linear(bytes, rec(i) + 0x28), |q| {
wow_rotation_to_bevy(q)
});
t.g_scale = global(&g.scale, track_linear(bytes, rec(i) + 0x44), scale_to_bevy);
}
rig
}
/// Whether any bone has a channel, i.e. posing can move a vertex.
pub(crate) fn animates(&self) -> bool {
self.tracks.iter().any(BoneTracks::moves)
}
/// Whether `bone` or any bone above it has a channel.
pub(crate) fn bone_moves(&self, bone: u16) -> bool {
let mut i = usize::from(bone);
for _ in 0..=self.parents.len() {
let Some(t) = self.tracks.get(i) else {
return false;
};
if t.moves() {
return true;
}
match usize::try_from(self.parents[i]) {
Ok(p) => i = p,
Err(_) => return false,
}
}
false
}
/// The sequence clock for `t` seconds on the scene or day clock: wrapped for a looping
/// sequence, clamped for a one-shot, as the band bake does.
pub(crate) fn band_time(&self, t: f32) -> f32 {
if self.duration <= 0.0 {
0.0
} else if self.looping {
t.rem_euclid(self.duration)
} else {
t.clamp(0.0, self.duration)
}
}
/// Every bone's model-space matrix at band time `band_t` and free clock `gseq_now` seconds.
pub(crate) fn pose(&self, band_t: f32, gseq_now: f64) -> Vec<Affine3A> {
let n = self.parents.len();
let mut out: Vec<Option<Affine3A>> = vec![None; n];
for i in 0..n {
self.resolve(i, band_t, gseq_now, &mut out, 0);
}
out.into_iter()
.map(|m| m.unwrap_or(Affine3A::IDENTITY))
.collect()
}
fn resolve(
&self,
i: usize,
band_t: f32,
gseq_now: f64,
out: &mut Vec<Option<Affine3A>>,
depth: usize,
) -> Affine3A {
if let Some(m) = out[i] {
return m;
}
let local = self.local(i, band_t, gseq_now);
// Bounded by the bone count: the guard against a malformed parent cycle.
let m = match usize::try_from(self.parents[i]) {
Ok(p) if p < out.len() && p != i && depth < out.len() => {
self.resolve(p, band_t, gseq_now, out, depth + 1) * local
}
_ => local,
};
out[i] = Some(m);
m
}
fn local(&self, i: usize, band_t: f32, gseq_now: f64) -> Affine3A {
let t = &self.tracks[i];
if !t.moves() {
return Affine3A::IDENTITY;
}
fn at<V: Copy>(
g: &Option<GlobalChannel<V>>,
c: &Channel<V>,
band_t: f32,
gseq_now: f64,
blend: impl Fn(V, V, f32) -> V,
) -> Option<V> {
match g {
Some(g) if g.period > 0.0 => {
let tt = (gseq_now % f64::from(g.period)) as f32;
g.channel.sample(tt, blend)
}
_ => c.sample(band_t, blend),
}
}
let tr = at(&t.g_translation, &t.translation, band_t, gseq_now, lerp3).unwrap_or(Vec3::ZERO);
let rot = at(&t.g_rotation, &t.rotation, band_t, gseq_now, slerp)
.unwrap_or(Quat::IDENTITY)
.normalize();
let sc = at(&t.g_scale, &t.scale, band_t, gseq_now, lerp3).unwrap_or(Vec3::ONE);
let pivot = self.pivots[i];
Affine3A::from_translation(pivot + tr)
* Affine3A::from_scale_rotation_translation(sc, rot, Vec3::ZERO)
* Affine3A::from_translation(-pivot)
}
}
/// The mat-anim rows a skybox batch owns: its texture transform (translation row, and an affine
/// row for rotation/scale) and its colour track, each seeded at `t = 0` like the shared lane.
#[derive(Default)]
pub(crate) struct SkyMatLane {
pub(crate) uv: Option<(u16, [f32; 2], Arc<benilla_formats::UvAnim>)>,
pub(crate) affine: Option<(u16, AffineLoops)>,
pub(crate) tint: Option<(u16, [f32; 3], Arc<benilla_formats::RgbAnim>)>,
/// Stage 1's translation row (absolute, no seed) and affine row.
pub(crate) stage1_uv: Option<(u16, benilla_formats::UvAnim)>,
pub(crate) stage1_affine: Option<(u16, AffineLoops)>,
}
/// The rotation and scaling channels of a texture transform, slot 0's loops.
pub(crate) struct AffineLoops {
pub(crate) rot: Option<benilla_formats::KeyAnim<[f32; 4]>>,
pub(crate) scale: Option<benilla_formats::KeyAnim<[f32; 2]>>,
}
impl SkyMatLane {
/// Allocate the rows for a batch and write their slots into every material it draws with
/// (`anim_slots.x` UV, `.y` tint, `.z` affine). A full table leaves the batch at its seed.
pub(crate) fn register(
sub: &benilla_formats::RenderSubmesh,
uv: Option<Arc<benilla_formats::UvAnim>>,
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>],
) -> 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));
}
};
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| {
[m.extension.sun_scale.z, m.extension.sun_scale.w]
})
})
.unwrap_or([0.0, 0.0]);
write(materials, &|m| m.extension.anim_slots.x = f32::from(slot));
lane.uv = Some((slot, seed, uv));
}
}
let rot = sub
.uv_rot_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
let scale = sub
.uv_scale_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
if rot.is_some() || scale.is_some() {
if let Some(slot) = table.alloc() {
write(materials, &|m| m.extension.anim_slots.z = f32::from(slot));
lane.affine = Some((slot, AffineLoops { rot, scale }));
}
}
if let Some(tint) = tint {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
.and_then(|id| {
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
[m.extension.tint.x, m.extension.tint.y, m.extension.tint.z]
})
})
.unwrap_or([1.0, 1.0, 1.0]);
write(materials, &|m| m.extension.anim_slots.y = f32::from(slot));
lane.tint = Some((slot, seed, tint));
}
}
// Stage 1: rows only where it moves; row 0 is the identity.
if let Some(st) = &sub.stage1 {
if let Some(uv) = st.uv_anim.clone() {
if let Some(slot) = table.alloc() {
write(materials, &|m| m.extension.stage1.z = f32::from(slot));
lane.stage1_uv = Some((slot, uv));
}
}
if st.uv_rot.is_some() || st.uv_scale.is_some() {
if let Some(slot) = table.alloc() {
write(materials, &|m| m.extension.stage1.w = f32::from(slot));
lane.stage1_affine = Some((
slot,
AffineLoops {
rot: st.uv_rot.clone(),
scale: st.uv_scale.clone(),
},
));
}
}
}
lane
}
pub(crate) fn any(&self) -> bool {
self.uv.is_some()
|| self.affine.is_some()
|| self.tint.is_some()
|| self.stage1_uv.is_some()
|| self.stage1_affine.is_some()
}
/// Write this frame's rows: the delta from each seed, quantized as the shared lane does.
pub(crate) fn tick(
&self,
band_t: f32,
gseq_now: f64,
table: &mut crate::mat_anim_table::MatAnimTable,
) {
if let Some((slot, seed, a)) = &self.uv {
let v = a.sample(a.clock(band_t, gseq_now));
table.set(
*slot,
[
benilla_assets::quantize(v[0], 4096.0) - seed[0],
benilla_assets::quantize(v[1], 4096.0) - seed[1],
0.0,
0.0,
],
);
}
if let Some((slot, loops)) = &self.affine {
let q = loops
.rot
.as_ref()
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
let s = loops
.scale
.as_ref()
.map_or([1.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
table.set(*slot, crate::mat_anim_table::affine_row(q, s));
}
if let Some((slot, a)) = &self.stage1_uv {
let v = a.sample(a.clock(band_t, gseq_now));
table.set(
*slot,
[
benilla_assets::quantize(v[0], 4096.0),
benilla_assets::quantize(v[1], 4096.0),
0.0,
0.0,
],
);
}
if let Some((slot, loops)) = &self.stage1_affine {
let q = loops
.rot
.as_ref()
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
let s = loops
.scale
.as_ref()
.map_or([1.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
table.set(*slot, crate::mat_anim_table::affine_row(q, s));
}
if let Some((slot, seed, a)) = &self.tint {
let c = benilla_assets::quant255(a.sample(a.clock(band_t, gseq_now)));
table.set(
*slot,
[c[0] - seed[0], c[1] - seed[1], c[2] - seed[2], 0.0],
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spin_rig() -> SkyRig {
// One parentless bone at pivot (1,0,0) turning 135° about +Y over 4 s, linear; the quarter
// turn falls at 8/3 s.
SkyRig {
parents: vec![-1],
pivots: vec![Vec3::X],
tracks: vec![BoneTracks {
rotation: Channel {
keys: vec![
(0.0, Quat::IDENTITY),
(4.0, Quat::from_rotation_y(std::f32::consts::FRAC_PI_2 * 1.5)),
],
linear: true,
},
..Default::default()
}],
duration: 4.0,
seq_slot: Some(0),
looping: true,
}
}
#[test]
fn a_bone_turns_about_its_pivot() {
let rig = spin_rig();
let m = rig.pose(rig.band_time(4.0 + 8.0 / 3.0), 0.0)[0];
// A quarter turn about the pivot; the pivot itself stays put.
assert!(m.transform_point3(Vec3::X).abs_diff_eq(Vec3::X, 1e-5));
let p = m.transform_point3(Vec3::new(2.0, 0.0, 0.0));
assert!(p.abs_diff_eq(Vec3::new(1.0, 0.0, -1.0), 1e-5), "{p}");
}
#[test]
fn a_child_composes_onto_its_parent() {
let mut rig = spin_rig();
rig.parents.push(0);
rig.pivots.push(Vec3::new(2.0, 0.0, 0.0));
rig.tracks.push(BoneTracks {
translation: Channel {
keys: vec![(0.0, Vec3::Y)],
linear: true,
},
..Default::default()
});
let pose = rig.pose(8.0 / 3.0, 0.0);
let p = pose[1].transform_point3(Vec3::new(2.0, 0.0, 0.0));
// Lifted by the child, then turned a quarter by the parent.
assert!(p.abs_diff_eq(Vec3::new(1.0, 1.0, -1.0), 1e-5), "{p}");
assert!(rig.bone_moves(1) && rig.animates());
}
#[test]
fn a_trackless_rig_is_the_bind_pose() {
let rig = SkyRig {
parents: vec![-1, 0],
pivots: vec![Vec3::ZERO, Vec3::ONE],
tracks: vec![BoneTracks::default(); 2],
..Default::default()
};
assert!(!rig.animates());
assert!(rig
.pose(1.0, 1.0)
.iter()
.all(|m| *m == Affine3A::IDENTITY));
}
/// The Caverns of Time belts: the general rig reproduces the rigid spin the old lane drew.
#[test]
fn the_caverns_belts_spin_as_the_bone_spin_does() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::Chain::open(&data).expect("open chain");
const SKY: &str = "Environments\\Stars\\CavernsOfTimeSky.m2";
let bytes = chain.read_file(SKY).expect("read the sky");
let rig = SkyRig::from_bytes(&bytes);
let spins = benilla_formats::m2_bone_spins(&bytes);
assert!(!spins.is_empty());
for t in [0.0f32, 3.3, 17.0, 50.0] {
let pose = rig.pose(rig.band_time(t), f64::from(t));
for (bone, spin) in &spins {
let pivot = wow_to_bevy(spin.pivot);
let rot = wow_rotation_to_bevy(spin.sample(t));
let old = Affine3A::from_translation(pivot)
* Affine3A::from_quat(rot)
* Affine3A::from_translation(-pivot);
let p = Vec3::new(10.0, 3.0, -7.0) + pivot;
let a = pose[usize::from(*bone)].transform_point3(p);
let b = old.transform_point3(p);
assert!(a.abs_diff_eq(b, 1e-2), "bone {bone} t {t}: {a} vs {b}");
}
}
}
}
+5 -2
View File
@@ -12,8 +12,9 @@ use super::{
MAX_DIRTY_FRAMES, REBAKE_FRAMES,
};
use super::{
WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_HAS_VC, WORD_INTERIOR, WORD_MATTE,
WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X, WORD_WRAP_Y,
WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_FOLIAGE_WIND, WORD_HAS_VC, WORD_INTERIOR,
WORD_MATTE, WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X,
WORD_WRAP_Y,
};
/// Print the declined-batch census, beside the accepted count, once the counts have sat still for
@@ -208,6 +209,8 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
| (u32::from(item.matte) * WORD_MATTE)
| (u32::from(item.texture.is_some()) * WORD_TEXTURED)
| (u32::from(has_vc) * WORD_HAS_VC)
// MONKEY (wind): only classified alpha-tested tree/bush leaf cards carry this bit.
| (u32::from(item.foliage_wind) * WORD_FOLIAGE_WIND)
// An interior prop is WORD_INTERIOR without WORD_WMO; a slot-less prop keeps the
// exterior law, as on the entity path.
| item.prop.as_ref().map_or(0, |p| {
@@ -100,6 +100,7 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool)
},
crate::model_render::EntityPathWhy("exile"),
crate::interact::PickMesh(b.geometry.clone()),
// MONKEY (fix-wind): a leaf batch's sway marker rides its material (`FOLIAGE_WIND_MARKER`).
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)),
crate::model_fade::DoodadFade {
radius: f.radius,
+89
View File
@@ -76,6 +76,8 @@ const WORD_HAS_VC: u32 = 1 << 27;
// `ShadeSel::Matte`, fixed 1.0: a map doodad never reaches the 2.5 site (`0x69e4ad`). Not
// `WORD_SHADE_LIT`, so lifting the shader's `min(I, 1)` cap leaves this at 1.0.
const WORD_MATTE: u32 = 1 << 28;
// MONKEY (wind): classified alpha-tested tree/bush foliage; bits 30-31 remain free.
const WORD_FOLIAGE_WIND: u32 = 1 << 29;
// An interior M2 prop is WORD_INTERIOR with WORD_WMO clear, the entity shader's `interior_prop =
// flags.z && !flags.x`: SH-probe light from its record slot, interior fog, no live point lights.
@@ -173,6 +175,8 @@ struct GxItem {
prop: Option<GxItemProp>,
/// `Some(uid)` on a fader item: the placement it exiles with.
fader: Option<u32>,
/// MONKEY (wind): an alpha-tested leaf-card batch of a static tree/bush model.
foliage_wind: bool,
}
/// A WMO-prop item's referrer set (an index into [`GxCell::sets`]) and folded SH-probe slot;
@@ -357,6 +361,9 @@ pub enum GxSite<'a> {
instance: Entity,
groups: &'a [u16],
bounds: &'a [benilla_formats::WmoGroupInfo],
/// MONKEY (daylight: district sky rooms): per ABSOLUTE group, a city room connected to
/// the sky by the portal graph (`lighting::district_sky_rooms`); empty for a building.
sky: &'a [bool],
},
/// A WMO doodad prop (B4, decision 1433 — 1418's lane 3, absorbed): the building's
/// instance entity, the referrer set of rooms that name the prop, and the interior
@@ -478,6 +485,10 @@ impl StaticGx {
return false;
}
};
let foliage_wind = foliage_wind_batch(&b.object.label, b.blend, b.wmo.is_some());
if foliage_wind {
log_foliage_classification(&b.object.label);
}
let wmo_key = b.wmo.as_ref().map(|w| w.instance);
let wmo = b.wmo.map(|w| GxItemWmo {
group: w.group,
@@ -591,6 +602,7 @@ impl StaticGx {
wmo,
prop,
fader: fader_uid,
foliage_wind,
});
if !entry.dirty {
entry.dirty_since = self.frame;
@@ -688,6 +700,44 @@ impl StaticGx {
}
}
/// MONKEY (wind): conservative leaf classification. Alpha test excludes trunks/rocks even when a
/// whole model path says tree; the name terms cover vanilla's tree/bush/plant families. Animated
/// doodads never reach this function because `assemble.rs` excludes them before `StaticGx::divert`.
pub(crate) fn foliage_wind_batch(path: &str, blend: ModelBlend, wmo_geometry: bool) -> bool {
if wmo_geometry || blend != ModelBlend::AlphaTest {
return false;
}
let p = path.to_ascii_lowercase();
// Substrings alone otherwise catch StreetLamp, tree huts, dead stumps/logs and painted tree
// facades. Those can carry alpha-tested cards, but moving the whole card is not foliage sway.
if ["street", "treehut", "stump", "log", "facade", "fallen"]
.iter()
.any(|term| p.contains(term))
{
return false;
}
[
"tree", "bush", "shrub", "foliage", "fern", "palm", "plant", "willow", "canopy", "hedge",
"thorn", "cactus", "vine", "reed", "kelp",
]
.iter()
.any(|term| p.contains(term))
}
/// Log each classified model once so false positives can be audited from an ordinary scene load.
fn log_foliage_classification(path: &str) {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let mut seen = SEEN
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if seen.insert(path.to_string()) {
info!("MONKEY wind: classified foliage leaf batches for {path}");
}
}
pub struct StaticGxPlugin;
impl Plugin for StaticGxPlugin {
@@ -831,6 +881,45 @@ mod tests {
assert_eq!(gx.cells.len(), 1);
}
#[test]
fn foliage_classifier_requires_a_leaf_batch_and_a_plant_name() {
assert!(foliage_wind_batch(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01.m2",
ModelBlend::AlphaTest,
false,
));
assert!(foliage_wind_batch(
"World\\Generic\\PassiveDoodads\\Bush\\Bush01.mdx",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01.m2",
ModelBlend::Opaque,
false,
));
assert!(!foliage_wind_batch(
"World\\Stormwind\\StreetLamp01.m2",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Azeroth\\SwampOfSorrow\\PassiveDoodads\\TreeHuts\\LostTreeHuts03.m2",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Tree\\ElwynnLog02.m2",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Wmo\\TreeHouse.wmo",
ModelBlend::AlphaTest,
true,
));
}
#[test]
fn a_fader_divert_registers_its_placement_seed() {
let mut gx = StaticGx::default();
@@ -34,6 +34,11 @@ mod queries;
mod spawn;
mod weld;
pub(crate) mod window;
// MONKEY (daylight: terrain torch casters): resident MCNK chunks as torch cube-map casters.
mod torch_terrain;
pub use torch_terrain::{
append_terrain_torch_triangles, terrain_torch_generation, terrain_torch_generation_near,
};
use collider::{finish_colliders, impassable_wall_data, terrain_collider_data};
use furnish::furnish_tile_cells;
@@ -233,6 +233,44 @@ pub fn spawn_model_entities(
seq_owner,
)
};
// MONKEY (fix-wind): a classified exterior tree/bush leaf batch gets marked copies of both
// materials for its fade-band exile (`FOLIAGE_WIND_MARKER`), so only those entity draws
// sway; the plain pair stays on every other path.
let wind_seed = (!interior
&& !steady_interior_prop
&& crate::static_gx::foliage_wind_batch(&object.label, sub.blend, is_wmo))
.then(|| {
let mut wind_mat = |fade: bool| {
crate::model_render::foliage_model_material(
mat_cache,
materials,
sub.texture.clone(),
sub.blend,
two_sided,
is_wmo,
interior,
sub.emissive,
sub.additive,
fade,
sub.no_depth_write,
sub.no_depth_test,
sub.fog_policy,
sub.env_map,
shade,
batch_order,
sub.uv_anim.as_ref(),
sub.rgb_anim.as_ref(),
sub.wmo_batch,
sub.sidn,
sub.window,
false,
light,
torch,
seq_owner,
)
};
(wind_mat(false), wind_mat(true))
});
// One classification for the census tally and the diverts, so the two cannot drift.
let class = crate::static_merge::BatchClass {
excluded: animated
@@ -311,8 +349,13 @@ pub fn spawn_model_entities(
local_center,
stat_mesh: stat_mesh.clone(),
aabb: *stat_aabb,
cutout: cutout.clone(),
blend: blend.clone(),
// MONKEY (fix-wind): the marked pair for a leaf batch.
cutout: wind_seed
.as_ref()
.map_or_else(|| cutout.clone(), |w| w.0.clone()),
blend: wind_seed
.as_ref()
.map_or_else(|| blend.clone(), |w| w.1.clone()),
});
// A never-fader enters bare, a fader only with its seed.
(class.never_fade || fade_seed.is_some())
@@ -322,6 +365,7 @@ pub fn spawn_model_entities(
instance,
groups,
bounds,
sky,
} if is_wmo && class.merges() =>
{
groups.get(batch_idx).map(|&g| {
@@ -346,7 +390,10 @@ pub fn spawn_model_entities(
// in hand, and the answer rides to the shader as a record bit.
enclosed: benilla_formats::room_claim::enclosed_by_building_shell(
bounds, g,
),
)
// MONKEY (daylight: district sky rooms): …or a city room
// the portal graph connects to the sky.
|| sky.get(usize::from(g)).copied().unwrap_or(false),
class: sub.wmo_batch,
sidn: sub.sidn,
window: sub.window,
@@ -330,6 +330,24 @@ pub(super) fn spawn_loaded_placements(
});
// The portal-cull instance, spawned before the batches: the retained pass keys
// its region on it, and the region lives as long as it does.
// MONKEY (daylight: district sky rooms): which city rooms take the day floor.
let sky_slices = fx::portal_slices(m);
let sky = crate::lighting::district_sky_rooms(
&m.group_bounds,
benilla_formats::PortalGraph {
vertices: &m.portal_vertices,
infos: &m.portal_infos,
refs: &m.portal_refs,
slices: &sky_slices,
},
m.submeshes.iter().zip(m.submesh_group.iter()).map(|(s, g)| {
(
*g,
matches!(s.wmo_batch, Some(benilla_formats::WmoBatchClass::Ext)),
&s.geometry.positions[..],
)
}),
);
let instance = (has_portals || m.wmo_id != 0).then(|| {
commands
.spawn(WmoPortalInstance {
@@ -405,6 +423,7 @@ pub(super) fn spawn_loaded_placements(
// MONKEY (ext-class night law): the MOGI table, so the
// assembler can read each batch's group CLASS + BOX.
bounds: &m.group_bounds,
sky: &sky,
},
)
})
@@ -0,0 +1,147 @@
//! MONKEY (daylight: terrain torch casters): terrain as a CASTER in the torch cube maps.
//!
//! The torch lane's static caster mesh is gathered from the retained scene (`StaticGx`) and the
//! rigid model parts; the ground was never in it, so a hill or a bank between a campfire and the
//! slope behind it never blocked the fire. This module hands the gather the resident MCNK chunks
//! within a fixture's reach, as world-space triangles (hole-masked indices, draw space — the same
//! vertices `collider::terrain_collider_data` welds, so the depth map and the drawn ground agree).
//!
//! Budget: one chunk is 145 vertices / up to 256 triangles; a 48 yd sphere touches at most ~16
//! chunks, so a slot gathers at most ~2.3k vertices and ~4k triangles once, when its static map
//! rebuilds. Chunks are culled by their own box against the sphere, then per triangle.
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::AdtTile;
use bevy::prelude::*;
use super::TerrainStreamer;
/// The terrain vertex bounds of one chunk in WoW axes. A chunk's positions are absolute world
/// yards, so the box is found without the tile index.
fn chunk_box(chunk: &benilla_formats::ChunkMesh) -> Option<(Vec3, Vec3)> {
let mut it = chunk.positions.iter();
let first = Vec3::from(*it.next()?);
let (mut lo, mut hi) = (first, first);
for p in it {
let p = Vec3::from(*p);
lo = lo.min(p);
hi = hi.max(p);
}
Some((lo, hi))
}
fn box_sphere_d2(lo: Vec3, hi: Vec3, c: Vec3) -> f32 {
(c.clamp(lo, hi) - c).length_squared()
}
/// Append every resident terrain triangle within `reach` of `center` (Bevy world space) to the
/// caster buffers, and return how many triangles were added. A triangle is kept when its own
/// box reaches the sphere — cheaper than an exact test and never drops a real occluder.
pub fn append_terrain_torch_triangles(
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
center: Vec3,
reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> u32 {
// Chunk boxes are in WoW axes, so test the sphere there (the conversion is a rigid swizzle).
let c = Vec3::from(benilla_assets::coords::bevy_to_wow(center));
let r2 = reach * reach;
let mut added = 0u32;
for ts in streamer.tiles.values() {
let Some(adt) = adt_tiles.get(&ts.handle) else {
continue;
};
for chunk in &adt.chunks {
let Some((lo, hi)) = chunk_box(chunk) else {
continue;
};
if box_sphere_d2(lo, hi, c) > r2 {
continue;
}
let base = positions.len() as u32;
let mut used = false;
for tri in chunk.indices.as_chunks::<3>().0 {
let (Some(a), Some(b), Some(d)) = (
chunk.positions.get(tri[0] as usize),
chunk.positions.get(tri[1] as usize),
chunk.positions.get(tri[2] as usize),
) else {
continue;
};
let (a, b, d) = (Vec3::from(*a), Vec3::from(*b), Vec3::from(*d));
if box_sphere_d2(a.min(b).min(d), a.max(b).max(d), c) > r2 {
continue;
}
if !used {
positions.extend(chunk.positions.iter().map(|p| wow_to_bevy(*p).to_array()));
used = true;
}
indices.extend(tri.iter().map(|i| base + i));
added += 1;
}
}
}
added
}
/// A cheap stamp of the resident terrain set: which tiles are loaded (decoded) right now. Folded
/// into the torch lane's census and slot fingerprint so a tile streaming in or out re-gathers the
/// fixtures near it. Order-independent.
pub fn terrain_torch_generation(streamer: &TerrainStreamer, adt_tiles: &Assets<AdtTile>) -> u64 {
use std::hash::{Hash, Hasher};
let mut sum = 0u64;
for (key, ts) in &streamer.tiles {
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
ts.handle.id().hash(&mut h);
adt_tiles.contains(&ts.handle).hash(&mut h);
sum = sum.wrapping_add(h.finish());
}
sum
}
/// MONKEY (fix-daylight): [`terrain_torch_generation`] over only the tiles whose footprint
/// reaches the sphere (`center` in Bevy space), so a tile streaming far away does not re-key (and
/// re-render) every settled exterior torch slot. Keys are `(tile_x, tile_y)`: tile_x from world y,
/// tile_y from world x, both counted down from `32 * TILE_SIZE`.
pub fn terrain_torch_generation_near(
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
center: Vec3,
reach: f32,
) -> u64 {
use std::hash::{Hash, Hasher};
let c = Vec3::from(benilla_assets::coords::bevy_to_wow(center));
let t = benilla_formats::TILE_SIZE;
let offset = 32.0 * t;
let mut sum = 0u64;
for (key, ts) in &streamer.tiles {
let (tx, ty) = (key.0 as f32, key.1 as f32);
let (x_hi, y_hi) = (offset - ty * t, offset - tx * t);
let lo = Vec3::new(x_hi - t, y_hi - t, c.z);
let hi = Vec3::new(x_hi, y_hi, c.z);
if box_sphere_d2(lo, hi, c) > reach * reach {
continue;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
ts.handle.id().hash(&mut h);
adt_tiles.contains(&ts.handle).hash(&mut h);
sum = sum.wrapping_add(h.finish());
}
sum
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn box_sphere_distance_is_zero_inside_and_grows_outside() {
let (lo, hi) = (Vec3::ZERO, Vec3::ONE);
assert_eq!(box_sphere_d2(lo, hi, Vec3::splat(0.5)), 0.0);
assert!((box_sphere_d2(lo, hi, Vec3::new(3.0, 0.5, 0.5)) - 4.0).abs() < 1e-6);
}
}

Some files were not shown because too many files have changed in this diff Show More