From 8d5c76887c674f9cb4c3161b90d0e2d6816add8f Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 10 Oct 2026 01:41:31 +0100
Subject: [PATCH] MONKEY (gfx seams): per-rig wet surface row + plugin water
foam spawn
Seam W: InstanceSurfaceParams (whole_wet, lower_wet, waterline_y, feather) per rig
slot, a 2048-row table in the light buffer, applied in wow_model.wgsl through the
existing wet_surface/wet_sheen (max with rain wetness, opaque/alpha-test + fade
twins only). Zero row = stock image. Self-contained: cherry-pickable to lighting.
Seam F: WaterFoamSpawn message; plugin foam records live in their own 96-record
pool so they never evict or throttle the stock unit wake/ring records.
Co-Authored-By: Claude Opus 5.5
---
.../benilla-assets/src/shaders/wow_model.wgsl | 23 +-
crates/benilla-world/src/instance_surface.rs | 231 ++++++++++++++
crates/benilla-world/src/lib.rs | 2 +
crates/benilla-world/src/rig_palette.rs | 26 +-
crates/benilla-world/src/water_fx/mod.rs | 302 +++++++++++++++++-
crates/benilla-world/src/world_plugins.rs | 1 +
6 files changed, 575 insertions(+), 10 deletions(-)
create mode 100644 crates/benilla-world/src/instance_surface.rs
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 27fe87bb..0d7b0207 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -156,6 +156,8 @@ struct WowLight {
// Per rig slot, the straddle waterline (size mirrored in straddle.rs): x = its world height
// (Bevy Y), y = the side the near copy keeps (+1 above, −1 below, 0 not straddling).
water_clip: array, 2048>,
+ // MONKEY: whole wetness, lower wetness, Bevy waterline Y, feather Y; zero is inert.
+ rig_surface: array, 2048>,
palettes: array>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight;
@@ -1785,8 +1787,27 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
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,
+ var wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, // MONKEY: retained wetness may replace rain.
select(1.0, 0.0, wet_dry) * wet_open, wow_light.monkey);
+ // MONKEY: character parts share their slot even when unskinned; wetness survives indoor shelter.
+ let surface_slot = (fade_tag >> 19u) & 0x7ffu;
+ let surface = wow_light.rig_surface[surface_slot];
+ let feather = max(surface.w, 0.01);
+ let instance_wet = max(clamp(surface.x, 0.0, 1.0), clamp(surface.y, 0.0, 1.0)
+ * (1.0 - smoothstep(surface.z - feather, surface.z + feather, in.world_position.y)));
+ // MONKEY: opaque/alpha-test only (Bevy alpha-mode bits 29-31: 0 opaque, 1 mask), plus the
+ // feather twin of such a batch (`model_flags.y`; an authored-Blend batch is its own twin), so a
+ // fading or translucent unit keeps its wet body instead of popping dry mid-feather.
+ let surface_alpha_mode = pbr_input.material.flags >> 29u;
+ let surface_pass = surface_alpha_mode <= 1u || m.model_flags.y > 0.5;
+ if (surface_slot != 0u && instance_wet > 0.0 && surface_pass && !is_wmo && !is_emissive
+ && !is_mod && !is_mod2x && (u32(m.clutter_fade.z) & 4u) == 0u) {
+ var surface_frame = wow_light.monkey;
+ // MONKEY: max of exposed rain and retained wetness, one darken/gloss application.
+ let rain_wet = select(clamp(wow_light.monkey.wet_a.y, 0.0, 1.0) * wet_open, 0.0, wet_dry);
+ surface_frame.wet_a.y = max(rain_wet, instance_wet);
+ wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, 1.0, surface_frame);
+ }
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),
diff --git a/crates/benilla-world/src/instance_surface.rs b/crates/benilla-world/src/instance_surface.rs
new file mode 100644
index 00000000..18b13467
--- /dev/null
+++ b/crates/benilla-world/src/instance_surface.rs
@@ -0,0 +1,231 @@
+// MONKEY: generic per-rig surface rows; zero leaves the stock model shader unchanged.
+use std::sync::Arc;
+
+use bevy::prelude::*;
+use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
+use bevy::render::renderer::RenderQueue;
+use bevy::render::{Render, RenderApp, RenderSystems};
+
+use crate::mesh_tag::MAX_RIG_SLOTS;
+use crate::rig_palette::RigPaletteMirrors;
+
+// MONKEY: one vec4 per slot, after water clips and before the runtime palette tail.
+pub(crate) fn region_offset() -> u64 {
+ crate::straddle::region_offset() + crate::straddle::region_bytes()
+}
+
+pub(crate) fn region_bytes() -> u64 {
+ MAX_RIG_SLOTS as u64 * 16
+}
+
+/// MONKEY: `(whole_body_wet, lower_body_wet, waterline_bevy_y, mask_feather_y)`.
+/// Wetness is clamped to 0..1, feather to at least 0.01 yd. Invalid/nonfinite rows clear the slot.
+/// Slot 0 is the no-rig sentinel. The resource is shared with the render world on extraction.
+#[derive(Resource, Clone, ExtractResource)]
+pub struct InstanceSurfaceParams {
+ slots: Arc>,
+ generation: u64,
+}
+
+impl Default for InstanceSurfaceParams {
+ fn default() -> Self {
+ Self {
+ slots: Arc::new(vec![[0.0; 4]; MAX_RIG_SLOTS]),
+ generation: 0,
+ }
+ }
+}
+
+impl InstanceSurfaceParams {
+ /// MONKEY: set a live rig slot's surface row; invalid slots are ignored.
+ pub fn set(&mut self, slot: u16, row: Vec4) {
+ let i = slot as usize;
+ if i == 0 || i >= MAX_RIG_SLOTS {
+ return;
+ }
+ // MONKEY: reject finite extremes that cannot form distinct, finite smoothstep edges.
+ let feather = row.w.max(0.01);
+ let (lo, hi) = (row.z - feather, row.z + feather);
+ let row = if !row.is_finite()
+ || (row.x <= 0.0 && row.y <= 0.0)
+ || !lo.is_finite()
+ || !hi.is_finite()
+ || !(hi - lo).is_finite()
+ || lo >= hi
+ {
+ [0.0; 4]
+ } else {
+ [row.x.clamp(0.0, 1.0), row.y.clamp(0.0, 1.0), row.z, feather]
+ };
+ if self.slots[i] != row {
+ Arc::make_mut(&mut self.slots)[i] = row;
+ self.generation = self.generation.wrapping_add(1);
+ }
+ }
+
+ /// MONKEY: clear on rig removal as well as when the caller disables its surface effect.
+ pub fn clear(&mut self, slot: u16) {
+ self.set(slot, Vec4::ZERO);
+ }
+
+ /// MONKEY: read the sanitized row; invalid slots return the inert row.
+ pub fn get(&self, slot: u16) -> Vec4 {
+ Vec4::from_array(self.slots.get(slot as usize).copied().unwrap_or([0.0; 4]))
+ }
+}
+
+// MONKEY: generation-gated upload to the world, glue booth and portrait/pane buffers.
+fn upload_instance_surface(
+ queue: Res,
+ shared: Option>,
+ mirrors: Option>,
+ surface: Res,
+ mut last: Local>,
+) {
+ let targets: Vec<_> = shared
+ .iter()
+ .map(|s| &s.0)
+ .chain(mirrors.iter().flat_map(|m| m.0.values()))
+ .collect();
+ last.retain(|(id, _)| targets.iter().any(|b| b.id() == *id));
+ for buffer in targets {
+ let stamp = (buffer.id(), surface.generation);
+ if last.contains(&stamp) {
+ continue;
+ }
+ queue.write_buffer(
+ buffer,
+ region_offset(),
+ bytemuck::cast_slice(surface.slots.as_slice()),
+ );
+ last.retain(|(id, _)| *id != buffer.id());
+ last.push(stamp);
+ }
+}
+
+// MONKEY: register the independent renderer seam alongside the other slot tables.
+pub(crate) fn plugin(app: &mut App) {
+ app.init_resource::()
+ .add_plugins(ExtractResourcePlugin::::default());
+ if let Some(render) = app.get_sub_app_mut(RenderApp) {
+ render.add_systems(
+ Render,
+ upload_instance_surface.in_set(RenderSystems::PrepareResources),
+ );
+ }
+}
+
+// MONKEY: pin the zero path, bounds, sanitization and slot recycling API.
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn zero_is_inert_and_invalid_slots_are_ignored() {
+ let mut surface = InstanceSurfaceParams::default();
+ surface.set(7, Vec4::ZERO);
+ surface.clear(7);
+ surface.set(0, Vec4::ONE);
+ surface.set(u16::MAX, Vec4::ONE);
+ assert_eq!(surface.generation, 0);
+ assert!(surface.slots.iter().all(|r| *r == [0.0; 4]));
+ assert_eq!(surface.get(u16::MAX), Vec4::ZERO);
+ }
+
+ // MONKEY: the Rust region order must match `WowLight`'s declaration order in the shader:
+ // the straddle clips, then the vec4 surface rows (16-byte aligned), then the runtime palettes.
+ #[test]
+ fn the_shader_declares_the_surface_rows_between_clips_and_palettes() {
+ let src = include_str!("../../benilla-assets/src/shaders/wow_model.wgsl");
+ let clip = src
+ .find("water_clip: array, 2048>,")
+ .expect("clip table");
+ let surface = src
+ .find("rig_surface: array, 2048>,")
+ .expect("surface table");
+ let palettes = src
+ .find("palettes: array>,")
+ .expect("palette tail");
+ assert!(clip < surface && surface < palettes);
+ assert_eq!(MAX_RIG_SLOTS, 2048);
+ assert_eq!(region_offset() % 16, 0, "vec4 rows need 16-byte alignment");
+ assert_eq!(crate::straddle::region_offset() % 16, 0);
+ }
+
+ #[test]
+ fn set_clear_and_extract_preserve_independent_slots() {
+ let mut surface = InstanceSurfaceParams::default();
+ let row = Vec4::new(0.5, 1.0, 12.0, 0.2);
+ surface.set(7, row);
+ let extracted = surface.clone();
+ surface.set(8, row);
+ surface.set(7, row);
+ assert_eq!(surface.generation, 2);
+ surface.clear(7);
+ assert_eq!(surface.get(7), Vec4::ZERO);
+ assert_eq!(surface.get(8), row);
+ assert_eq!(extracted.get(7), row);
+ surface.clear(7);
+ assert_eq!(surface.generation, 3);
+ }
+
+ #[test]
+ fn bad_rows_cannot_upload_nan_or_unbounded_wetness() {
+ let mut surface = InstanceSurfaceParams::default();
+ surface.set(7, Vec4::new(2.0, -1.0, 12.0, -0.1));
+ assert_eq!(surface.get(7), Vec4::new(1.0, 0.0, 12.0, 0.01));
+ surface.set(7, Vec4::new(1.0, 1.0, f32::NAN, 1.0));
+ assert_eq!(surface.get(7), Vec4::ZERO);
+ surface.set(7, Vec4::new(1.0, 1.0, 0.0, f32::MAX));
+ assert_eq!(surface.get(7), Vec4::ZERO);
+ surface.set(7, Vec4::new(1.0, 1.0, f32::MAX, 0.01));
+ assert_eq!(surface.get(7), Vec4::ZERO);
+ }
+
+ // MONKEY: removing or replacing RigSkin clears the slot before allocator reuse.
+ #[test]
+ fn rig_removal_clears_surface_before_slot_reuse() {
+ use crate::rig_palette::{RigPalettes, RigSkin};
+ let mut world = World::new();
+ world.init_resource::();
+ world.init_resource::();
+ let skin = RigSkin::allocate_bones(
+ &mut world.resource_mut::(),
+ 1,
+ Handle::default(),
+ )
+ .unwrap();
+ let slot = skin.slot;
+ world
+ .resource_mut::()
+ .set(slot, Vec4::ONE);
+ let entity = world.spawn(skin).id();
+ world.entity_mut(entity).remove::();
+ assert_eq!(
+ world.resource::().get(slot),
+ Vec4::ZERO
+ );
+ let replacement = RigSkin::allocate_bones(
+ &mut world.resource_mut::(),
+ 1,
+ Handle::default(),
+ )
+ .unwrap();
+ assert_eq!(replacement.slot, slot);
+ world.entity_mut(entity).insert(replacement);
+ world
+ .resource_mut::()
+ .set(slot, Vec4::ONE);
+ let next = RigSkin::allocate_bones(
+ &mut world.resource_mut::(),
+ 1,
+ Handle::default(),
+ )
+ .unwrap();
+ world.entity_mut(entity).insert(next);
+ assert_eq!(
+ world.resource::().get(slot),
+ Vec4::ZERO
+ );
+ }
+}
diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs
index 2b4d05df..651519be 100644
--- a/crates/benilla-world/src/lib.rs
+++ b/crates/benilla-world/src/lib.rs
@@ -119,6 +119,7 @@ pub mod ffx_glow;
pub mod final_pass;
pub mod frame_pace;
pub mod ground_fx;
+pub mod instance_surface; // MONKEY: independent per-rig wet-surface renderer seam.
pub mod instance_tint;
pub mod interact;
pub mod interior;
@@ -161,6 +162,7 @@ pub mod thread_qos;
pub mod view;
pub mod vis_chain;
pub mod water_fx;
+pub use water_fx::{WaterFoamKind, WaterFoamSpawn}; // MONKEY: plugin foam messages.
pub mod wdl;
pub mod weather;
pub mod wind; // MONKEY (wind): shared stateless gust/veer field and foliage quality gate
diff --git a/crates/benilla-world/src/rig_palette.rs b/crates/benilla-world/src/rig_palette.rs
index 36df4050..a7e7bf3c 100644
--- a/crates/benilla-world/src/rig_palette.rs
+++ b/crates/benilla-world/src/rig_palette.rs
@@ -41,10 +41,11 @@ pub(crate) fn rig_origin_region_bytes() -> u64 {
MAX_RIG_SLOTS as u64 * 16
}
-/// Byte offset of the palette rows, after the slot, tint, origin, mat-anim and straddle-clip
+/// MONKEY: palette rows follow the slot, tint, origin, mat-anim, clip and surface
/// tables: last, because `wow_model.wgsl` declares them as the struct's one runtime-sized array.
pub(crate) fn palette_region_offset() -> u64 {
- crate::straddle::region_offset() + crate::straddle::region_bytes()
+ // MONKEY: include the zero-default surface table before the palette tail.
+ crate::instance_surface::region_offset() + crate::instance_surface::region_bytes()
}
/// Total bytes the slot-indexed regions add to every `wow_light`-layout buffer.
@@ -54,6 +55,7 @@ pub(crate) fn palette_regions_bytes() -> u64 {
+ rig_origin_region_bytes()
+ crate::mat_anim_table::region_bytes()
+ crate::straddle::region_bytes()
+ + crate::instance_surface::region_bytes() // MONKEY: includes every LightBlob booth/portrait allocation.
+ MAX_PALETTE_BONES as u64 * BONE_BYTES
}
@@ -599,6 +601,12 @@ fn free_rig_skin(mut world: DeferredWorld, ctx: HookContext) {
if let Some(mut tints) = world.get_resource_mut::() {
tints.clear(slot);
}
+ // MONKEY: recycled rig slots must not inherit retained wetness.
+ if let Some(mut surface) =
+ world.get_resource_mut::()
+ {
+ surface.clear(slot);
+ }
// And the straddle waterline, for the same reason.
if let Some(mut clips) = world.get_resource_mut::() {
clips.clear(slot);
@@ -1228,9 +1236,18 @@ mod tests {
"the straddle clip table follows the mat-anim table"
);
assert_eq!(
- palette_region_offset() - crate::straddle::region_offset(),
+ crate::instance_surface::region_offset() - crate::straddle::region_offset(), // MONKEY: new table.
crate::straddle::region_bytes(),
- "the palette rows follow the straddle clip table"
+ "the surface table follows the straddle clip table" // MONKEY: layout fence.
+ );
+ // MONKEY: vec4 surface rows directly precede the runtime palette array.
+ assert_eq!(
+ palette_region_offset() - crate::instance_surface::region_offset(),
+ crate::instance_surface::region_bytes()
+ );
+ assert_eq!(
+ crate::instance_surface::region_bytes(),
+ (MAX_RIG_SLOTS * 16) as u64
);
assert_eq!(
palette_regions_bytes(),
@@ -1239,6 +1256,7 @@ mod tests {
+ rig_origin_region_bytes()
+ crate::mat_anim_table::region_bytes()
+ crate::straddle::region_bytes()
+ + crate::instance_surface::region_bytes() // MONKEY: full buffer size.
+ (MAX_PALETTE_BONES as u64) * BONE_BYTES
);
// One `vec4` a slot: the shader declares `array, 2048>`.
diff --git a/crates/benilla-world/src/water_fx/mod.rs b/crates/benilla-world/src/water_fx/mod.rs
index 4748dabb..50727885 100644
--- a/crates/benilla-world/src/water_fx/mod.rs
+++ b/crates/benilla-world/src/water_fx/mod.rs
@@ -35,8 +35,9 @@ use crate::view::WorldCamera;
use crate::world_unit::{ViewerUnit, WorldUnit};
use benilla_assets::{AssetSet, WorldAssets};
-use params::{foam_params, foam_uv, rand01, record_alpha, record_size, WadeState};
+// MONKEY: stock and plugin emission share the record parameters and drawing math.
use params::{foam_gain, swim_ramp, tread_params, FoamLook, TREAD_INTERVAL};
+use params::{foam_params, foam_uv, rand01, record_alpha, record_size, FoamParams, WadeState};
use params::{wake_cooldown, RING_INTERVAL};
/// The ring and wake stencils, loaded raw: the alpha is the shape and the near-black RGB the
@@ -49,6 +50,36 @@ const WAKE_TEXTURE: &str = "xtextures/splash/wake.blp";
const POOL_SIZE: usize = 128;
const SELF_SLOTS: usize = 32;
+// MONKEY: external emissions own their capacity and never touch stock records/cooldowns.
+const PLUGIN_POOL_SIZE: usize = 96;
+// MONKEY: bound the spatial-index/geometry walk even for malformed finite plugin parameters.
+const PLUGIN_MAX_EXTENT: f32 = 64.0;
+
+/// MONKEY: foam stencil category; splash uses the ring stencil with caller-supplied dynamics.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WaterFoamKind {
+ Ring,
+ Wake,
+ Splash,
+}
+
+/// MONKEY: one caller-authored foam patch on loaded water. Heading is WoW radians; sizes
+/// are half-widths in yards, growth is yd/s, lifetime is seconds, peak alpha is 0..1.
+/// `subject` selects a unit's liquid claim; `None` accepts either ADT or WMO water.
+/// Invalid/nonfinite requests and dry points are dropped. Capacity is independent of stock foam.
+/// MONKEY: the final half-width must not exceed 64 yd to bound patch construction.
+#[derive(Message, Debug, Clone, Copy)]
+pub struct WaterFoamSpawn {
+ pub subject: Option,
+ pub center_bevy: Vec3,
+ pub kind: WaterFoamKind,
+ pub heading: f32,
+ pub start_size: f32,
+ pub growth: f32,
+ pub lifetime: f32,
+ pub peak_alpha: f32,
+}
+
/// The step-in and step-out one-shot depth as a fraction of the unit's own collision height
/// (`0x6030c0`, CMovement+0xb4), latched either way: about 0.81 yd for a human.
const ONESHOT_DEPTH_FRAC: f32 = 0.4;
@@ -123,6 +154,8 @@ struct WaterFoam {
pool: Vec