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>, self_cursor: usize, other_cursor: usize, + plugin_pool: Vec>, // MONKEY: separate bounded plugin pool. + plugin_cursor: usize, // MONKEY: never advances either stock cursor. units: EntityHashMap, } @@ -132,6 +165,8 @@ impl Default for WaterFoam { pool: (0..POOL_SIZE).map(|_| None).collect(), self_cursor: 0, other_cursor: 0, + plugin_pool: (0..PLUGIN_POOL_SIZE).map(|_| None).collect(), // MONKEY: plugin capacity. + plugin_cursor: 0, // MONKEY: plugin cursor. units: EntityHashMap::default(), } } @@ -510,6 +545,88 @@ fn emit_water_foam( foam.units.retain(|_, uf| uf.active); } +// MONKEY: validate external requests on the unit's own water, then reuse the stock patch builder. +fn emit_plugin_foam( + mut requests: MessageReader, + time: Res