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>, + state: Res, + cvar: Res, + mut wet: ResMut, + mut frame: ResMut, +) { + let env = env_overrides(); + let rain = if state.effect_kind == WeatherKind::Rain { + state.effect_density.clamp(0.0, 1.0) + } else { + 0.0 + }; + wet.step(rain, time.delta_secs()); + let on = env.on.unwrap_or(cvar.0); + let (rate, wetness, ripple) = if on { + ( + rain, + env.wetness.unwrap_or(wet.wetness), + env.ripple_t.unwrap_or(wet.ripple_time_s), + ) + } else { + (0.0, 0.0, 0.0) + }; + // Write only on change, so change detection on the frame stays meaningful. + if frame.rain_rate != rate || frame.wetness != wetness || frame.ripple_time_s != ripple { + frame.rain_rate = rate; + frame.wetness = wetness; + frame.ripple_time_s = ripple; + frame.snow = 0.0; + } +} + +pub(super) fn register(app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Update, wetness_tick.after(WeatherTick)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_rain_soaks_in_about_ninety_seconds_and_dries_in_four_minutes() { + let mut w = Wetness::default(); + for _ in 0..(89 * 10) { + w.step(1.0, 0.1); + } + assert!(w.wetness > 0.95 && w.wetness < 1.0, "{}", w.wetness); + for _ in 0..(20 * 10) { + w.step(1.0, 0.1); + } + assert_eq!(w.wetness, 1.0); + for _ in 0..(120 * 10) { + w.step(0.0, 0.1); + } + assert!((w.wetness - 0.5).abs() < 0.01, "half dry at 2 min: {}", w.wetness); + for _ in 0..(121 * 10) { + w.step(0.0, 0.1); + } + assert_eq!(w.wetness, 0.0); + } + + #[test] + fn a_drizzle_never_quite_soaks() { + let mut w = Wetness::default(); + for _ in 0..(600 * 10) { + w.step(0.2, 0.1); + } + assert!((w.wetness - 0.64).abs() < 1e-4, "{}", w.wetness); + } + + #[test] + fn the_ripple_clock_wraps() { + let mut w = Wetness::default(); + w.ripple_time_s = RIPPLE_WRAP_S - 0.05; + w.step(0.0, 0.1); + assert!(w.ripple_time_s < 0.1); + } +}