diff --git a/LIGHTING.md b/LIGHTING.md
index e230a2de..6fbddf3e 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -30,6 +30,7 @@ behind each constant.
| 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; phase 1 has no rain-occlusion map, so shelter is classified only by interior/unlit state and surface facing | 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`, `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/` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 2d5ac9ea..98a9e123 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -530,6 +530,8 @@
skyDither = "Sky Dithering",
-- MONKEY (fog)
fogModel = "Modern Fog",
+ -- MONKEY (wet)
+ rainSurfaces = "Wet Surfaces in Rain",
-- MONKEY (wind)
foliageWind = "Foliage Wind",
windGrass = "Grass",
@@ -657,6 +659,12 @@
.. "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. "
@@ -705,6 +713,8 @@
skyDither = "Дизеринг неба",
-- MONKEY (fog)
fogModel = "Современный туман",
+ -- MONKEY (wet)
+ rainSurfaces = "Мокрые поверхности в дождь",
-- MONKEY (wind)
foliageWind = "Ветер листвы",
windGrass = "Трава",
@@ -842,6 +852,12 @@
.. "так что земля растворяется в горизонте. Дальность тумана в каждой зоне "
.. "прежняя; за пределом дальности обзора оригинала туман отступает вместе с ней."
.. "\n\nВыключено — туман оригинального клиента. Почти ничего не стоит.",
+ -- MONKEY (wet)
+ RAIN_SURFACES =
+ "Дождь постепенно намачивает землю, камень и дерево под открытым небом: они темнеют, "
+ .. "на ровной земле собираются лужи, мокрые поверхности блестят, а капли расходятся "
+ .. "кругами по воде. После дождя всё высыхает за несколько минут. Выключено — вид "
+ .. "оригинального клиента. Почти ничего не стоит и работает, только пока всё мокрое.",
-- MONKEY (wind)
FOLIAGE_WIND =
"Трава гнётся двумя бегущими волнами ветра и расступается вокруг персонажей. "
@@ -915,6 +931,8 @@
"RowSkyDither",
-- MONKEY (fog)
"RowFogModel",
+ -- MONKEY (wet)
+ "RowRainSurfaces",
-- MONKEY (wind): appended for conflict-free lane merges.
"RowFoliageWind",
},
@@ -3037,12 +3055,24 @@
"BENILLA_TOOLTIP_FOG_MODEL")
-
-
+
+
+
+ OptionsRow_OnLoad(self, "rainSurfaces",
+ BENILLA_ADVGFX.rainSurfaces,
+ "BENILLA_TOOLTIP_RAIN_SURFACES")
+
+
+
+
+
+
+
+
OptionsRow_OnLoad(self, "foliageWind",
BENILLA_ADVGFX.foliageWind,
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 6c6b66f7..63167ad2 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -1627,6 +1627,23 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
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 (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 9c49e5f8..7282bd80 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -952,6 +952,13 @@ pub(crate) const REGISTERED: &[Registered] = &[
"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",
+ ),
ours(
"waterQuality",
"1",
@@ -3791,6 +3798,9 @@ mod tests {
app.init_resource::();
// MONKEY (fog)
app.init_resource::();
+ // MONKEY (wind)
+ app.init_resource::();
+ app.init_resource::(); // MONKEY (wet)
app.add_observer(crate::monkey_gfx::on_cvar);
},
|app| {
diff --git a/crates/benilla-app/src/monkey_gfx.rs b/crates/benilla-app/src/monkey_gfx.rs
index 26d0e4b4..4ba49e2a 100644
--- a/crates/benilla-app/src/monkey_gfx.rs
+++ b/crates/benilla-app/src/monkey_gfx.rs
@@ -7,11 +7,13 @@
//! | `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(
@@ -20,6 +22,7 @@ pub(crate) fn on_cvar(
mut foliage_wind: ResMut,
// MONKEY (fog)
mut fog_model: ResMut,
+ mut rain: ResMut, // MONKEY (wet)
) {
match ev.key().as_str() {
"skydither" => {
@@ -43,6 +46,13 @@ pub(crate) fn on_cvar(
}
// 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;
+ }
+ }
_ => {}
}
}
@@ -56,6 +66,7 @@ impl Plugin for MonkeyGfxPlugin {
app.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::() // MONKEY (wet)
.add_observer(on_cvar);
}
}
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 5176f2fe..5c418e5a 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -38,6 +38,8 @@ pub fn register_shaders(app: &mut App) {
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");
}
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index 39d774e1..303d27a2 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -245,8 +245,58 @@ fn water_gerstner(p: vec2, t: f32, shore: f32) -> vec4 {
// 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.
-fn rain_ripple_normal(world_xz: vec2, time: f32) -> vec2 {
- return vec2(0.0);
+// 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_hash2(p: vec2) -> vec2 {
+ var q = fract(vec3(p.xyx) * vec3(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_xz: vec2, footprint: f32, distance: f32, room: bool) -> vec2 {
+ 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(0.0);
+ }
+ let reach = (1.0 - smoothstep(35.0, 60.0, distance)) * (1.0 - smoothstep(0.05, 0.14, footprint));
+ if reach <= 0.0 {
+ return vec2(0.0);
+ }
+ var grad = vec2(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(i), f32(j));
+ let seed = cell + vec2(f32(layer) * 57.0, f32(layer) * 113.0);
+ let h = ripple_hash2(seed);
+ let h2 = ripple_hash2(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;
+ let roll = ripple_hash2(seed + floor(cycle) * 7.31).x;
+ if roll > rain * 0.85 {
+ continue;
+ }
+ let phase = fract(cycle);
+ let centre = (cell + 0.5 + (h2 - 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 {
@@ -538,7 +588,8 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec
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 + rain_ripple_normal(in.world_position.xz, t);
+ let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xz, footprint,
+ length(eye_pos), in.room_fog != 0u && water.lane.w > 0.5);
var n = normalize(vec3(-surf_grad.x, 1.0, -surf_grad.y));
if dot(n, to_view) < 0.0 { n = -n; }
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index d4345ae6..9bc38773 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -22,6 +22,8 @@
#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;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array;
@@ -755,9 +757,19 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// 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 (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, 1.0,
+ wow_light.monkey), n_lit, in.world_position.xyz, 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(0.0), vec3(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(0.0), vec3(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
diff --git a/crates/benilla-assets/src/shaders/wet_hook.wgsl b/crates/benilla-assets/src/shaders/wet_hook.wgsl
new file mode 100644
index 00000000..cb108adf
--- /dev/null
+++ b/crates/benilla-assets/src/shaders/wet_hook.wgsl
@@ -0,0 +1,97 @@
+#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
+
+struct WetSurface {
+ albedo: vec3,
+ // 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, 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, n: vec3, world_pos: vec3, 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(0.299, 0.587, 0.114));
+ let sat = max(mix(vec3(luma), albedo, 1.0 + 0.3 * w), vec3(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 {
+ let q = fract(p * vec2(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 {
+ 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(1.0, 0.0)), u.x),
+ mix(wet_hash(i + vec2(0.0, 1.0)), wet_hash(i + vec2(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.
+fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, mf: MonkeyFrame) -> WetSurface {
+ let wet_row = mf.wet_a;
+ var o = s;
+ if (wet_row.y <= 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);
+ 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, to_view: vec3, to_light: vec3,
+ sun_rgb: vec3, sky_rgb: vec3, shadow: f32) -> vec3 {
+ if (boost <= 0.0) {
+ return vec3(0.0);
+ }
+ let h = normalize(to_light + to_view);
+ 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));
+}
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 4da4afdb..744502bb 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -24,6 +24,8 @@
#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.
@@ -1733,6 +1735,18 @@ 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;
+ let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, select(1.0, 0.0, wet_dry),
+ 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(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`,
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 7144918e..dba4e6bb 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -18,6 +18,8 @@
#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).
@@ -1985,6 +1987,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
if ((in.word & WORD_UNLIT) != 0u) {
rgb = folded;
}
+ // 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,
+ select(1.0, 0.0, (in.word & (WORD_INTERIOR | WORD_UNLIT)) != 0u), 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(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).
diff --git a/crates/benilla-world/src/weather/mod.rs b/crates/benilla-world/src/weather/mod.rs
index d2a1e144..09548546 100644
--- a/crates/benilla-world/src/weather/mod.rs
+++ b/crates/benilla-world/src/weather/mod.rs
@@ -16,6 +16,8 @@ use bevy::prelude::*;
use crate::dev_state::DebugState;
mod precip;
+mod wetness; // MONKEY (wet): surface wetness + the rain-ripple clock -> MonkeyFrame wet0
+pub use wetness::{RainSurfaces, Wetness};
/// Rain's forced-fog window, read by the effect lane's `EffectFog::Rain` row.
pub(crate) use precip::{RAIN_FOG_END, RAIN_FOG_START};
@@ -309,6 +311,7 @@ impl Plugin for WeatherPlugin {
.add_message::()
.add_systems(Update, weather_tick.in_set(WeatherTick));
precip::register(app);
+ wetness::register(app); // MONKEY (wet)
}
}
diff --git a/crates/benilla-world/src/weather/wetness.rs b/crates/benilla-world/src/weather/wetness.rs
new file mode 100644
index 00000000..caa9c4e2
--- /dev/null
+++ b/crates/benilla-world/src/weather/wetness.rs
@@ -0,0 +1,168 @@
+//! MONKEY (wet): surface wetness from the zone weather, published in the MonkeyFrame `wet_a` row
+//! (`rain_rate, wetness, ripple_time_s, snow`).
+//!
+//! - `rain_rate` is channel A's effect density while the effect is rain (already ramped ~10 s by
+//! the weather channel), else 0.
+//! - `wetness` rises while it rains (full in about 90 s at full rain, slower in a drizzle) and
+//! dries after it stops (about 4 min from soaked). It runs on real time, like the channels.
+//! - `ripple_time_s` is the rain-ripple clock (wraps every [`RIPPLE_WRAP_S`] s; the shader's ring
+//! rates are chosen so the wrap is seamless).
+//! - `snow` is reserved (0).
+//!
+//! The `rainSurfaces` cvar ([`RainSurfaces`]) gates the whole row: off writes zeros, and every
+//! shader reader treats a zero row as "dry", so the image is unchanged.
+//!
+//! Capture knobs: `WOW_RAIN_SURFACES=0|1` overrides the cvar; `WOW_WETNESS=<0..1>` pins the
+//! wetness (a capture cannot wait minutes for the ramp); `WOW_WET_T=` pins the ripple clock.
+
+use bevy::prelude::*;
+
+use super::{WeatherKind, WeatherState, WeatherTick};
+use crate::lighting::MonkeyFrame;
+
+/// The `rainSurfaces` cvar: 0 Off (no wet surfaces, no rain ripples), 1 On. benilla-app's
+/// settings bridge (`monkey_gfx.rs`) writes it.
+#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
+pub struct RainSurfaces(pub bool);
+
+impl Default for RainSurfaces {
+ fn default() -> Self {
+ Self(true)
+ }
+}
+
+/// Seconds from dry to soaked under full rain.
+const WET_UP_S: f32 = 90.0;
+/// Seconds from soaked to dry once the rain stops.
+const DRY_S: f32 = 240.0;
+/// The ripple clock's wrap. The shader's ring rates are multiples of 1/`RIPPLE_WRAP_S`, so every
+/// ring's phase is continuous across the wrap.
+pub const RIPPLE_WRAP_S: f32 = 1000.0;
+
+/// The wetness integrator's state.
+#[derive(Resource, Default, Debug, Clone, Copy)]
+pub struct Wetness {
+ pub wetness: f32,
+ pub ripple_time_s: f32,
+}
+
+impl Wetness {
+ /// One real-time step. `rain` is the rain rate 0..1 (0 when not raining).
+ fn step(&mut self, rain: f32, dt: f32) {
+ if rain > 0.0 {
+ // A drizzle wets slower and never quite soaks.
+ let target = 0.55 + 0.45 * rain.min(1.0);
+ let rate = (0.35 + 0.65 * rain.min(1.0)) / WET_UP_S;
+ if self.wetness < target {
+ self.wetness = (self.wetness + rate * dt).min(target);
+ } else {
+ self.wetness = (self.wetness - dt / DRY_S).max(target);
+ }
+ } else {
+ self.wetness = (self.wetness - dt / DRY_S).max(0.0);
+ }
+ self.ripple_time_s = (self.ripple_time_s + dt) % RIPPLE_WRAP_S;
+ }
+}
+
+fn env_f32(key: &str) -> Option {
+ std::env::var(key).ok()?.trim().parse().ok()
+}
+
+/// The session-only overrides, read once.
+struct EnvOverrides {
+ on: Option,
+ wetness: Option,
+ ripple_t: Option,
+}
+
+fn env_overrides() -> &'static EnvOverrides {
+ static ENV: std::sync::OnceLock = std::sync::OnceLock::new();
+ ENV.get_or_init(|| EnvOverrides {
+ on: env_f32("WOW_RAIN_SURFACES").map(|v| v >= 0.5),
+ wetness: env_f32("WOW_WETNESS").map(|v| v.clamp(0.0, 1.0)),
+ ripple_t: env_f32("WOW_WET_T"),
+ })
+}
+
+fn wetness_tick(
+ time: Res