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:
pkuzic
2026-10-10 23:33:33 +01:00
co-authored by Claude Opus 5.5
parent e72e12ddc5
commit 8d5c76887c
6 changed files with 575 additions and 10 deletions
@@ -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
);
}
}
+2
View File
@@ -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
+22 -4
View File
@@ -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>`.
+297 -5
View File
@@ -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.