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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e72e12ddc5
commit
8d5c76887c
@@ -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<vec2<f32>, 2048>,
|
||||
// MONKEY: whole wetness, lower wetness, Bevy waterline Y, feather Y; zero is inert.
|
||||
rig_surface: array<vec4<f32>, 2048>,
|
||||
palettes: array<vec4<f32>>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> 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),
|
||||
|
||||
@@ -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<Vec<[f32; 4]>>,
|
||||
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<RenderQueue>,
|
||||
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
|
||||
mirrors: Option<Res<RigPaletteMirrors>>,
|
||||
surface: Res<InstanceSurfaceParams>,
|
||||
mut last: Local<Vec<(bevy::render::render_resource::BufferId, u64)>>,
|
||||
) {
|
||||
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::<InstanceSurfaceParams>()
|
||||
.add_plugins(ExtractResourcePlugin::<InstanceSurfaceParams>::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<vec2<f32>, 2048>,")
|
||||
.expect("clip table");
|
||||
let surface = src
|
||||
.find("rig_surface: array<vec4<f32>, 2048>,")
|
||||
.expect("surface table");
|
||||
let palettes = src
|
||||
.find("palettes: array<vec4<f32>>,")
|
||||
.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::<RigPalettes>();
|
||||
world.init_resource::<InstanceSurfaceParams>();
|
||||
let skin = RigSkin::allocate_bones(
|
||||
&mut world.resource_mut::<RigPalettes>(),
|
||||
1,
|
||||
Handle::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let slot = skin.slot;
|
||||
world
|
||||
.resource_mut::<InstanceSurfaceParams>()
|
||||
.set(slot, Vec4::ONE);
|
||||
let entity = world.spawn(skin).id();
|
||||
world.entity_mut(entity).remove::<RigSkin>();
|
||||
assert_eq!(
|
||||
world.resource::<InstanceSurfaceParams>().get(slot),
|
||||
Vec4::ZERO
|
||||
);
|
||||
let replacement = RigSkin::allocate_bones(
|
||||
&mut world.resource_mut::<RigPalettes>(),
|
||||
1,
|
||||
Handle::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(replacement.slot, slot);
|
||||
world.entity_mut(entity).insert(replacement);
|
||||
world
|
||||
.resource_mut::<InstanceSurfaceParams>()
|
||||
.set(slot, Vec4::ONE);
|
||||
let next = RigSkin::allocate_bones(
|
||||
&mut world.resource_mut::<RigPalettes>(),
|
||||
1,
|
||||
Handle::default(),
|
||||
)
|
||||
.unwrap();
|
||||
world.entity_mut(entity).insert(next);
|
||||
assert_eq!(
|
||||
world.resource::<InstanceSurfaceParams>().get(slot),
|
||||
Vec4::ZERO
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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::<crate::instance_tint::InstanceTints>() {
|
||||
tints.clear(slot);
|
||||
}
|
||||
// MONKEY: recycled rig slots must not inherit retained wetness.
|
||||
if let Some(mut surface) =
|
||||
world.get_resource_mut::<crate::instance_surface::InstanceSurfaceParams>()
|
||||
{
|
||||
surface.clear(slot);
|
||||
}
|
||||
// And the straddle waterline, for the same reason.
|
||||
if let Some(mut clips) = world.get_resource_mut::<crate::straddle::WaterClips>() {
|
||||
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<vec4<f32>, 2048>`.
|
||||
|
||||
@@ -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<Entity>,
|
||||
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<Option<FoamRecord>>,
|
||||
self_cursor: usize,
|
||||
other_cursor: usize,
|
||||
plugin_pool: Vec<Option<FoamRecord>>, // MONKEY: separate bounded plugin pool.
|
||||
plugin_cursor: usize, // MONKEY: never advances either stock cursor.
|
||||
units: EntityHashMap<UnitFoam>,
|
||||
}
|
||||
|
||||
@@ -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<WaterFoamSpawn>,
|
||||
time: Res<Time>,
|
||||
assets: Option<Res<FoamAssets>>,
|
||||
mut foam: ResMut<WaterFoam>,
|
||||
water: Query<(Entity, &WaterChunkInfo, &FoamPatch)>,
|
||||
index: Res<WaterIndex>,
|
||||
rooms: Query<&crate::wmo_portal::UnitWmoRoom>,
|
||||
placements: crate::liquid::RoomPlacements,
|
||||
) {
|
||||
for request in requests.read() {
|
||||
if assets.is_none()
|
||||
|| !request.center_bevy.is_finite()
|
||||
|| ![
|
||||
request.heading,
|
||||
request.start_size,
|
||||
request.growth,
|
||||
request.lifetime,
|
||||
request.peak_alpha,
|
||||
]
|
||||
.into_iter()
|
||||
.all(f32::is_finite)
|
||||
|| request.start_size <= 0.0
|
||||
|| request.growth < 0.0
|
||||
|| request.lifetime <= 0.0
|
||||
|| request.peak_alpha <= 0.0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let final_size = request.start_size + request.growth * request.lifetime;
|
||||
if !final_size.is_finite() || final_size > PLUGIN_MAX_EXTENT {
|
||||
continue;
|
||||
}
|
||||
let wow = bevy_to_wow(request.center_bevy);
|
||||
let claim = request
|
||||
.subject
|
||||
.map_or(crate::liquid::LiquidClaim::Unknown, |e| {
|
||||
crate::liquid::unit_claim(rooms.get(e).ok(), &placements)
|
||||
});
|
||||
let host = index
|
||||
.over(wow[0], wow[1])
|
||||
.iter()
|
||||
.filter_map(|&e| {
|
||||
let (_, info, _) = water.get(e).ok()?;
|
||||
let hit = crate::liquid::liquid_at(std::iter::once(info), wow, claim)?;
|
||||
if hit.kind.is_fullbright() {
|
||||
return None;
|
||||
}
|
||||
info.water_z_at(wow[0], wow[1]).map(|z| (e, z))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
if host.is_none() {
|
||||
continue;
|
||||
}
|
||||
let p = FoamParams {
|
||||
size0: request.start_size,
|
||||
growth: request.growth,
|
||||
lifetime: request.lifetime,
|
||||
peak: request.peak_alpha.clamp(0.0, 1.0),
|
||||
ring: request.kind != WaterFoamKind::Wake,
|
||||
};
|
||||
let WaterFoam {
|
||||
plugin_pool,
|
||||
plugin_cursor,
|
||||
..
|
||||
} = &mut *foam;
|
||||
let mut alloc = |rec| {
|
||||
plugin_pool[alloc_slot(plugin_cursor, 0, PLUGIN_POOL_SIZE)] = Some(rec);
|
||||
};
|
||||
emit_record(
|
||||
&mut alloc,
|
||||
&index,
|
||||
&water,
|
||||
[wow[0], wow[1]],
|
||||
&p,
|
||||
request.heading,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ages the pool and pushes one additive draw per (chunk, category) with live records: static
|
||||
/// positions, the texgen at `size(t)` and white times the alpha ramp.
|
||||
fn push_water_foam(
|
||||
@@ -530,7 +647,11 @@ fn push_water_foam(
|
||||
let enhanced = quality.0 >= 1;
|
||||
let gain = foam_gain(enhanced);
|
||||
|
||||
for slot in &mut foam.pool {
|
||||
// MONKEY: age both pools without lending plugin emissions any stock storage.
|
||||
let WaterFoam {
|
||||
pool, plugin_pool, ..
|
||||
} = &mut *foam;
|
||||
for slot in pool.iter_mut().chain(plugin_pool.iter_mut()) {
|
||||
if slot.as_ref().is_some_and(|r| now - r.born >= r.lifetime) {
|
||||
*slot = None;
|
||||
}
|
||||
@@ -538,7 +659,8 @@ fn push_water_foam(
|
||||
|
||||
// One contiguous draw per (chunk, category).
|
||||
let mut groups: HashMap<(Entity, bool), Vec<usize>> = HashMap::default();
|
||||
for (i, rec) in foam.pool.iter().enumerate() {
|
||||
for (i, rec) in foam.pool.iter().chain(foam.plugin_pool.iter()).enumerate() {
|
||||
// MONKEY: both pools draw.
|
||||
let Some(rec) = rec else { continue };
|
||||
if chunks_alive.get(rec.chunk).is_err() {
|
||||
continue; // chunk streamed out; the record ages out silently
|
||||
@@ -550,7 +672,14 @@ fn push_water_foam(
|
||||
let mut centroid = Vec3::ZERO;
|
||||
let mut n = 0u32;
|
||||
for i in records {
|
||||
let rec = foam.pool[i].as_ref().expect("grouped above");
|
||||
// MONKEY: combined indices keep stock records first and plugin storage separate.
|
||||
let rec = if i < POOL_SIZE {
|
||||
&foam.pool[i]
|
||||
} else {
|
||||
&foam.plugin_pool[i - POOL_SIZE]
|
||||
}
|
||||
.as_ref()
|
||||
.expect("grouped above");
|
||||
let size = record_size(rec.size0, rec.growth, rec.born, now);
|
||||
let alpha = record_alpha(rec.peak, rec.lifetime, rec.born, now);
|
||||
for v in &rec.verts {
|
||||
@@ -621,13 +750,22 @@ pub(crate) struct WaterFxPlugin;
|
||||
|
||||
impl Plugin for WaterFxPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, setup_water_fx.after(AssetSet::Open))
|
||||
app.add_message::<WaterFoamSpawn>() // MONKEY: public external foam ingress.
|
||||
.add_systems(Startup, setup_water_fx.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
Update,
|
||||
emit_water_foam
|
||||
.in_set(WaterFoamSet)
|
||||
.in_set(WorldStage::Present),
|
||||
)
|
||||
// MONKEY: separate emitter runs after stock emission, in the public foam ordering set.
|
||||
.add_systems(
|
||||
Update,
|
||||
emit_plugin_foam
|
||||
.after(emit_water_foam)
|
||||
.in_set(WaterFoamSet)
|
||||
.in_set(WorldStage::Present),
|
||||
)
|
||||
// The push runs after the frame's clear; emission ran in Update.
|
||||
.add_systems(PostUpdate, push_water_foam.after(begin_effect_frame));
|
||||
// MONKEY (swim waves) — the bodies' half: bobbing on the ocean's vertex swell. It shares
|
||||
@@ -641,6 +779,160 @@ impl Plugin for WaterFxPlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// MONKEY: test the public message ingress, real wet-cell lookup, and independent allocation.
|
||||
#[test]
|
||||
fn plugin_spawns_drop_dry_points_and_cannot_evict_or_throttle_stock() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<WaterFoamSpawn>()
|
||||
.init_resource::<Time>()
|
||||
.init_resource::<WaterIndex>()
|
||||
.init_resource::<WaterFoam>()
|
||||
.insert_resource(FoamAssets {
|
||||
ring: Handle::default(),
|
||||
wake: Handle::default(),
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
(crate::liquid::maintain_water_index, emit_plugin_foam).chain(),
|
||||
);
|
||||
let info = WaterChunkInfo::new(
|
||||
crate::liquid::LiquidSource::AdtChunk,
|
||||
benilla_formats::LiquidKind::Still,
|
||||
[2, 2],
|
||||
vec![
|
||||
[0.0, 0.0, 5.0],
|
||||
[10.0, 0.0, 5.0],
|
||||
[0.0, 10.0, 5.0],
|
||||
[10.0, 10.0, 5.0],
|
||||
],
|
||||
vec![true],
|
||||
);
|
||||
let chunk = app.world_mut().spawn((info, FoamPatch)).id();
|
||||
// MONKEY: a dry hole inside an indexed chunk and magma are also non-water controls.
|
||||
for (offset, kind, wet) in [
|
||||
(20.0, benilla_formats::LiquidKind::Still, false),
|
||||
(30.0, benilla_formats::LiquidKind::Magma, true),
|
||||
] {
|
||||
let positions = (0..3)
|
||||
.flat_map(|j| (0..3).map(move |i| [offset + i as f32 * 5.0, j as f32 * 5.0, 5.0]))
|
||||
.collect();
|
||||
app.world_mut().spawn((
|
||||
WaterChunkInfo::new(
|
||||
crate::liquid::LiquidSource::AdtChunk,
|
||||
kind,
|
||||
[3, 3],
|
||||
positions,
|
||||
if wet {
|
||||
vec![true; 4]
|
||||
} else {
|
||||
vec![true, false, false, true]
|
||||
},
|
||||
),
|
||||
FoamPatch,
|
||||
));
|
||||
}
|
||||
let subject = app.world_mut().spawn_empty().id();
|
||||
{
|
||||
let mut foam = app.world_mut().resource_mut::<WaterFoam>();
|
||||
foam.self_cursor = 3;
|
||||
foam.other_cursor = 5;
|
||||
let mut unit = UnitFoam::new(42);
|
||||
unit.ready = 123.0;
|
||||
foam.units.insert(subject, unit);
|
||||
for (i, slot) in foam.pool.iter_mut().enumerate() {
|
||||
*slot = Some(FoamRecord {
|
||||
center: [i as f32, 0.0],
|
||||
heading: 0.0,
|
||||
size0: 1.0,
|
||||
growth: 0.0,
|
||||
lifetime: 100.0,
|
||||
peak: 0.25,
|
||||
born: 0.0,
|
||||
ring: true,
|
||||
verts: vec![Vec3::ONE],
|
||||
chunk,
|
||||
});
|
||||
}
|
||||
}
|
||||
let request = WaterFoamSpawn {
|
||||
subject: Some(subject),
|
||||
center_bevy: wow_to_bevy([2.0, 2.0, 4.9]),
|
||||
kind: WaterFoamKind::Ring,
|
||||
heading: 0.5,
|
||||
start_size: 0.5,
|
||||
growth: 0.25,
|
||||
lifetime: 1.0,
|
||||
peak_alpha: 0.75,
|
||||
};
|
||||
app.world_mut().write_message(WaterFoamSpawn {
|
||||
center_bevy: wow_to_bevy([50.0, 50.0, 5.0]),
|
||||
..request
|
||||
});
|
||||
// MONKEY: dry cells, non-water liquids and invalid requests consume no plugin slots.
|
||||
for x in [27.0, 32.0] {
|
||||
app.world_mut().write_message(WaterFoamSpawn {
|
||||
subject: None,
|
||||
center_bevy: wow_to_bevy([x, 2.0, 5.0]),
|
||||
..request
|
||||
});
|
||||
}
|
||||
for invalid in [
|
||||
WaterFoamSpawn {
|
||||
heading: f32::NAN,
|
||||
..request
|
||||
},
|
||||
WaterFoamSpawn {
|
||||
start_size: PLUGIN_MAX_EXTENT + 1.0,
|
||||
..request
|
||||
},
|
||||
WaterFoamSpawn {
|
||||
lifetime: 0.0,
|
||||
..request
|
||||
},
|
||||
] {
|
||||
app.world_mut().write_message(invalid);
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(app.world().resource::<WaterFoam>().plugin_cursor, 0);
|
||||
assert!(app
|
||||
.world()
|
||||
.resource::<WaterFoam>()
|
||||
.plugin_pool
|
||||
.iter()
|
||||
.all(Option::is_none));
|
||||
for i in 0..PLUGIN_POOL_SIZE + 5 {
|
||||
app.world_mut().write_message(WaterFoamSpawn {
|
||||
heading: i as f32,
|
||||
kind: [
|
||||
WaterFoamKind::Ring,
|
||||
WaterFoamKind::Wake,
|
||||
WaterFoamKind::Splash,
|
||||
][i % 3],
|
||||
..request
|
||||
});
|
||||
}
|
||||
app.update();
|
||||
let foam = app.world().resource::<WaterFoam>();
|
||||
assert_eq!(foam.plugin_pool.len(), PLUGIN_POOL_SIZE);
|
||||
assert_eq!(foam.plugin_pool.iter().flatten().count(), PLUGIN_POOL_SIZE);
|
||||
assert_eq!(foam.plugin_cursor, 5);
|
||||
assert_eq!(
|
||||
foam.plugin_pool[0].as_ref().unwrap().heading,
|
||||
PLUGIN_POOL_SIZE as f32
|
||||
);
|
||||
assert!(!foam.plugin_pool[1].as_ref().unwrap().ring);
|
||||
assert!(foam.plugin_pool[2].as_ref().unwrap().ring);
|
||||
assert_eq!((foam.self_cursor, foam.other_cursor), (3, 5));
|
||||
assert_eq!(foam.units[&subject].ready, 123.0);
|
||||
assert_eq!(foam.units.len(), 1);
|
||||
for (i, rec) in foam.pool.iter().enumerate() {
|
||||
let rec = rec.as_ref().unwrap();
|
||||
assert_eq!(rec.center, [i as f32, 0.0]);
|
||||
assert_eq!(rec.verts, vec![Vec3::ONE]);
|
||||
assert_eq!(rec.peak, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty or stale index means no foam anywhere, which no helper-level test catches.
|
||||
#[test]
|
||||
fn the_emitter_finds_its_water_through_the_index() {
|
||||
|
||||
@@ -64,6 +64,7 @@ impl PluginGroup for WorldPlugins {
|
||||
.add(crate::rig_anim::plugin)
|
||||
// The per-instance body tint (a state kit's CharProc-1 colour), on the palette's slot.
|
||||
.add(crate::instance_tint::plugin)
|
||||
.add(crate::instance_surface::plugin) // MONKEY: zero-default surface slot table.
|
||||
// The mat-anim table: each frame's UV and tint samples, so no material is mutated.
|
||||
.add(crate::mat_anim_table::plugin)
|
||||
// The M2 render lane: render alpha, water far-side twins and depth-prime twins.
|
||||
|
||||
Reference in New Issue
Block a user