Merge graphics-programme (Everwood graphics programme) into main
This commit is contained in:
+110
-5
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms),
|
||||
);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)?,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+739
-159
File diff suppressed because it is too large
Load Diff
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user