MONKEY (wet): weather/wetness.rs -> MonkeyFrame wet row + wet_hook.wgsl library
Wetness ramps up while it rains (~90 s to soaked) and dries over ~4 min; ripple clock; rainSurfaces resource; env WOW_RAIN_SURFACES / WOW_WETNESS / WOW_WET_T capture overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dff6556867
commit
88ad3cdad5
@@ -34,6 +34,8 @@ pub fn register_shaders(app: &mut App) {
|
||||
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 (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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#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<f32>,
|
||||
// Gloss weight for `wet_sheen`: 0 dry, ~1 a soaked top face, up to ~1.6 in a terrain puddle.
|
||||
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 wet0 = mf.wet_a;
|
||||
var o: WetSurface;
|
||||
o.albedo = albedo;
|
||||
o.boost = 0.0;
|
||||
if (wet0.y <= 0.0 || exposure <= 0.0) {
|
||||
return o;
|
||||
}
|
||||
let w = wet_amount(n, exposure, wet0.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.
|
||||
fn wet_puddles(s: WetSurface, n: vec3<f32>, world_pos: vec3<f32>, mf: MonkeyFrame) -> WetSurface {
|
||||
let wet0 = mf.wet_a;
|
||||
var o = s;
|
||||
if (wet0.y <= 0.0) {
|
||||
return o;
|
||||
}
|
||||
let xz = world_pos.xz;
|
||||
let field = 0.65 * wet_noise(xz * 0.11) + 0.35 * wet_noise(xz * 0.43 + 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, wet0.y));
|
||||
let puddle = smoothstep(edge, edge + 0.06, field) * level * smoothstep(0.35, 0.8, wet0.y);
|
||||
o.albedo = o.albedo * (1.0 - 0.4 * puddle);
|
||||
o.boost = max(o.boost, 1.6 * 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);
|
||||
}
|
||||
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);
|
||||
return boost * (sun_rgb * (0.9 * spec * shadow) + sky_rgb * (0.28 * fres));
|
||||
}
|
||||
@@ -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::<WeatherMessage>()
|
||||
.add_systems(Update, weather_tick.in_set(WeatherTick));
|
||||
precip::register(app);
|
||||
wetness::register(app); // MONKEY (wet)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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=<s>` 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<f32> {
|
||||
std::env::var(key).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// The session-only overrides, read once.
|
||||
struct EnvOverrides {
|
||||
on: Option<bool>,
|
||||
wetness: Option<f32>,
|
||||
ripple_t: Option<f32>,
|
||||
}
|
||||
|
||||
fn env_overrides() -> &'static EnvOverrides {
|
||||
static ENV: std::sync::OnceLock<EnvOverrides> = 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<Time<Real>>,
|
||||
state: Res<WeatherState>,
|
||||
cvar: Res<RainSurfaces>,
|
||||
mut wet: ResMut<Wetness>,
|
||||
mut frame: ResMut<MonkeyFrame>,
|
||||
) {
|
||||
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::<RainSurfaces>()
|
||||
.init_resource::<Wetness>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user