From dff6556867bd4e1342bccbc5edd73f7567525d7c Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 08:37:39 +0100
Subject: [PATCH 1/4] MONKEY (p0): WGSL fixes found by the world-scene run
naga_oil refuses a composable-module identifier that naga's namer would
rewrite (a trailing digit gets a '_'), which failed every pipeline that
imports monkey_frame / enhanced_water (models, WMOs, liquid invisible).
MonkeyFrame rows are renamed fog_a..fog_d, wind_a/b, wet_a (same order and
layout), and enhanced_water's padding member _sh_c16 becomes _sh_quad.
Co-Authored-By: Claude Opus 5.5
---
LIGHTING.md | 14 +++++++-------
.../src/shaders/enhanced_water.wgsl | 14 +++++++-------
crates/benilla-assets/src/shaders/fog_hook.wgsl | 6 +++---
.../benilla-assets/src/shaders/monkey_frame.wgsl | 16 +++++++++-------
.../benilla-world/src/lighting/monkey_frame.rs | 16 ++++++++--------
5 files changed, 34 insertions(+), 32 deletions(-)
diff --git a/LIGHTING.md b/LIGHTING.md
index bb879614..6a3a2d69 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -65,13 +65,13 @@ is its own module, documented in `WATER.md`.
| row | x | y | z | w |
|---|---|---|---|---|
- | `fog0` | height_fog_density | height_fog_height | height_fog_falloff | curve_blend |
- | `fog1` | sun_fog r | g | b | sun_fog_strength |
- | `fog2` | end_fog r | g | b | end_fog_distance |
- | `fog3` | fog_model (0 classic, 1 modern) | sun_fog_angle | 0 | 0 |
- | `wind0` | dir_x | dir_y | speed | gust |
- | `wind1` | time_s | sway_strength | grass_strength | tree_strength |
- | `wet0` | rain_rate | wetness | ripple_time_s | snow |
+ | `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, 1 modern) | sun_fog_angle | 0 | 0 |
+ | `wind_a` | dir_x | dir_y | speed | gust |
+ | `wind_b` | time_s | 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 |
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index 924d5f88..39d774e1 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -54,13 +54,13 @@ struct WaterParams {
const MONKEY_ROW: u32 = 533u;
fn water_monkey() -> monkey_frame::MonkeyFrame {
var m: monkey_frame::MonkeyFrame;
- m.fog0 = water_light[MONKEY_ROW + 0u];
- m.fog1 = water_light[MONKEY_ROW + 1u];
- m.fog2 = water_light[MONKEY_ROW + 2u];
- m.fog3 = water_light[MONKEY_ROW + 3u];
- m.wind0 = water_light[MONKEY_ROW + 4u];
- m.wind1 = water_light[MONKEY_ROW + 5u];
- m.wet0 = water_light[MONKEY_ROW + 6u];
+ 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;
diff --git a/crates/benilla-assets/src/shaders/fog_hook.wgsl b/crates/benilla-assets/src/shaders/fog_hook.wgsl
index e931ccf9..6730fbd8 100644
--- a/crates/benilla-assets/src/shaders/fog_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/fog_hook.wgsl
@@ -13,7 +13,7 @@
// `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.fog3.x` (`MonkeyFrame.fog_model`,
+// 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.
#import benilla::monkey_frame::MonkeyFrame
@@ -39,9 +39,9 @@ fn fog_sample(
mf: MonkeyFrame,
) -> vec4 {
let classic = vec4(fog_rgb, fog_linear(eye_z, span));
- if (mf.fog3.x > 0.5) {
+ if (mf.fog_d.x > 0.5) {
// FOG lane: the modern model (height / sun / end-colour / curve fog) goes here, reading
- // mf.fog0..fog3. Until it lands, the modern arm is the classic law.
+ // mf.fog_a..fog_d. Until it lands, the modern arm is the classic law.
return classic;
}
return classic;
diff --git a/crates/benilla-assets/src/shaders/monkey_frame.wgsl b/crates/benilla-assets/src/shaders/monkey_frame.wgsl
index ea1780b3..97cbeba8 100644
--- a/crates/benilla-assets/src/shaders/monkey_frame.wgsl
+++ b/crates/benilla-assets/src/shaders/monkey_frame.wgsl
@@ -8,14 +8,16 @@
//
// 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 a composable-module identifier that
+// naga's namer would rewrite (`fog0` -> `fog0_`), so the rows are `fog_a..fog_d`, `wind_a/b`, `wet_a`.
struct MonkeyFrame {
- fog0: vec4, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend
- fog1: vec4, // sun_fog_rgb (gamma 0..1), sun_fog_strength
- fog2: vec4, // end_fog_rgb (gamma 0..1), end_fog_distance (yd)
- fog3: vec4, // fog_model (0 classic, 1 modern), sun_fog_angle (cos), 0, 0
- wind0: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1
- wind1: vec4, // time_s, sway_strength, grass_strength, tree_strength
- wet0: vec4, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
+ fog_a: vec4, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend
+ fog_b: vec4, // sun_fog_rgb (gamma 0..1), sun_fog_strength
+ fog_c: vec4, // end_fog_rgb (gamma 0..1), end_fog_distance (yd)
+ fog_d: vec4, // fog_model (0 classic, 1 modern), sun_fog_angle (cos), 0, 0
+ wind_a: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1
+ wind_b: vec4, // time_s, sway_strength, grass_strength, tree_strength
+ wet_a: vec4, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
misc: vec4, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), 0
benders: array, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live
}
diff --git a/crates/benilla-world/src/lighting/monkey_frame.rs b/crates/benilla-world/src/lighting/monkey_frame.rs
index 3ca56f7d..c583ec98 100644
--- a/crates/benilla-world/src/lighting/monkey_frame.rs
+++ b/crates/benilla-world/src/lighting/monkey_frame.rs
@@ -14,13 +14,13 @@
//!
//! | row | xyz / x,y,z | w |
//! |---|---|---|
-//! | 0 `fog0` | height_fog_density, height_fog_height, height_fog_falloff | curve_blend |
-//! | 1 `fog1` | sun_fog_rgb | sun_fog_strength |
-//! | 2 `fog2` | end_fog_rgb | end_fog_distance |
-//! | 3 `fog3` | fog_model (0 classic, 1 modern), sun_fog_angle, 0 | 0 |
-//! | 4 `wind0` | dir_x, dir_y, speed | gust |
-//! | 5 `wind1` | time_s, sway_strength, grass_strength | tree_strength |
-//! | 6 `wet0` | rain_rate, wetness, ripple_time_s | snow |
+//! | 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, 1 modern), sun_fog_angle, 0 | 0 |
+//! | 4 `wind_a` | dir_x, dir_y, speed | gust |
+//! | 5 `wind_b` | time_s, 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 |
//!
@@ -38,7 +38,7 @@ 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, `fog3.x`.
+/// 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.
From 88ad3cdad54c40dc8244f0ee4656b7d28e244bf3 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 10:20:40 +0100
Subject: [PATCH 2/4] 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
---
crates/benilla-assets/src/materials.rs | 2 +
.../benilla-assets/src/shaders/wet_hook.wgsl | 93 ++++++++++
crates/benilla-world/src/weather/mod.rs | 3 +
crates/benilla-world/src/weather/wetness.rs | 168 ++++++++++++++++++
4 files changed, 266 insertions(+)
create mode 100644 crates/benilla-assets/src/shaders/wet_hook.wgsl
create mode 100644 crates/benilla-world/src/weather/wetness.rs
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index a2254aa8..0e7b04d8 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -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");
}
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..9a4032b2
--- /dev/null
+++ b/crates/benilla-assets/src/shaders/wet_hook.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,
+ // 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, 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 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(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 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, 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);
+ return boost * (sun_rgb * (0.9 * spec * shadow) + sky_rgb * (0.28 * fres));
+}
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