a body at the waterline draws on both sides, and its gear follows

- a creature spawning or coming into range in water no longer punches a body-shaped hole in the surface at the start of its fade-in: a model crossing its water plane draws on both sides, each half clipped at the waterline (2188)
- the same split covers your own body on login while swimming, stealth and ghost form in the water; Crystal Lake's murlocs streaming in were the scene that showed it (2188)
- helms, shoulders, weapons and hung spell kits clip at their wearer's waterline too, so the fade no longer looks wrong whenever the surface crosses the armour (2190)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 21:06:57 +07:00
co-authored by Claude Fable 5.1
parent 667d7cadb7
commit 61c1d70e50
12 changed files with 917 additions and 93 deletions
+15
View File
@@ -29,6 +29,7 @@ use bevy::image::Image;
use bevy::mesh::MeshVertexBufferLayoutRef;
use bevy::pbr::{
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
MeshPipelineKey,
};
use bevy::prelude::*;
use bevy::render::render_resource::{
@@ -423,6 +424,20 @@ impl MaterialExtension for WowModelExt {
ds.depth_write_enabled = true;
}
}
// The straddle split's waterline clip (decision 2188 — `benilla_world::straddle`): only a
// transparent-pass batch is ever classified against the water plane, so only its
// pipelines carry the clip. Keyed on the blend pass Bevy already specializes on, so it
// mints no pipeline of its own; the fragment clips nothing unless the instance's slot
// says it straddles.
if key
.mesh_key
.intersection(MeshPipelineKey::BLEND_RESERVED_BITS)
== MeshPipelineKey::BLEND_ALPHA
{
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_WATER_CLIP".into());
}
}
let key = &key.bind_group_data;
if key.modulate || key.modulate2x {
// The MULTIPLY blends (decision 0528, byte-verified wow-re `m2-depth-blend-state`):
@@ -187,6 +187,11 @@ struct WowLight {
// Zero region = every batch at its built seed — the studio buffers and deterministic
// captures ride that exactly like the tint table's zero-identity.
matanim: array<vec4<f32>, 2048>,
// The straddle split's waterline (decision 2188; benilla-world straddle.rs mirrors the size),
// on the SAME slot index again: `x` = the plane's world height (Bevy Y), `y` = the side the
// instance's NEAR copy keeps (+1 above, −1 below). `y == 0` is "not straddling" — slot 0,
// every slot nothing wrote, every zeroed studio buffer — so a dry world pays one compare.
water_clip: array<vec2<f32>, 2048>,
palettes: array<vec4<f32>>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -635,6 +640,21 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
discard;
}
}
#ifdef WOW_WATER_CLIP
// THE STRADDLE SPLIT (decision 2188, straddle.rs; wow-re water-frame-straddle.md §2): a
// translucent model crossing its water plane draws on BOTH sides of the water pass — this
// batch on the eye's (near) list, its far twin (FAR_SIDE_MARKER, `clutter_fade.z` bit 11)
// before the water — and each copy keeps only its own half: the reference's `M2UseClipPlanes`
// hardware clip plane at the waterline. `straddle::keeps` is this block's Rust twin.
let water_clip = wow_light.water_clip[(mesh_functions::get_tag(in.instance_index) >> 19u) & 0x7ffu];
if (water_clip.y != 0.0) {
let far_copy = (u32(m.clutter_fade.z) & 2048u) != 0u;
let keep_side = select(water_clip.y, -water_clip.y, far_copy);
if (keep_side * (in.world_position.y - water_clip.x) < 0.0) {
discard;
}
}
#endif
// Rebuild bevy's `VertexOutput` from our extended interstage struct (WowVsOut carries one extra
// interpolant — the per-vertex point term — which the pbr entry point doesn't know about).
// M2 UV animation folds in here (decision 0130 phase 3, wow-re m2-texanim-uv; the full law
+1
View File
@@ -170,6 +170,7 @@ pub mod sky_order;
pub mod skybox;
pub mod static_gx;
pub mod static_merge;
pub mod straddle;
pub mod sun;
pub mod surface;
pub mod terrain_stream;
@@ -264,8 +264,8 @@ pub fn new_shared_light_buffer(device: &RenderDevice) -> SharedLightBuffer {
/// The full byte size of the shared light BUFFER: the per-frame blob ([`LightStd430`] — 19 header
/// rows + the point-light table) PLUS the interior-prop probe region PLUS the skin-palette
/// regions (rig slot table + tint table + rig-origin table + palette rows — decisions
/// 0720/0812/0974) at the tail. **Every buffer bound as
/// regions (rig slot table + tint table + rig-origin table + mat-anim table + straddle clip
/// table + palette rows — decisions 0720/0812/0974/1381/2188) at the tail. **Every buffer bound as
/// `wow_light` must be at least this big** — `wow_model.wgsl` declares the whole layout,
/// and wgpu validates bound size against the shader's struct at draw time. The portrait booth's
/// frozen studio-light buffer sizes itself with this (its table regions stay zeroed ⇒ no scene
+4 -3
View File
@@ -20,8 +20,9 @@
//! deterministic captures (the tick is skipped ⇒ zero deltas ⇒ bit-identical to the old seed
//! frames), and slot exhaustion (no slot ⇒ frozen at seed, never garbage).
//!
//! Region layout: between the rig-origin table and the palette rows (the palette array is
//! runtime-sized, so it must stay last) — `wow_model.wgsl`'s struct mirrors this order.
//! Region layout: between the rig-origin table and the straddle clip table ([`crate::straddle`]);
//! the palette rows stay last (the palette array is runtime-sized) — `wow_model.wgsl`'s struct
//! mirrors this order.
use std::sync::Arc;
@@ -46,7 +47,7 @@ use bevy::render::{Render, RenderApp, RenderSystems};
pub(crate) const MAX_MAT_ANIM_SLOTS: usize = 2048;
/// Byte offset of the mat-anim region inside a `wow_light`-layout buffer: after the rig-origin
/// table, before the palette rows — mirroring `wow_model.wgsl`'s struct order.
/// table, before the straddle clip table — mirroring `wow_model.wgsl`'s struct order.
pub(crate) fn region_offset() -> u64 {
crate::rig_palette::rig_origin_region_offset() + crate::rig_palette::rig_origin_region_bytes()
}
+7 -4
View File
@@ -30,10 +30,13 @@
//! ([`crate::instance_tint`], 0812) — for every part, skinned or not.
//!
//! That asymmetry is why the field is written from the **unit** and not from the part: a unit's
//! boneless geosets, its helm and shoulders, its held items and its billboard cards all carry
//! their wearer's slot, so a tinted unit tints *whole* — the reference's own rule (an
//! attached/chained model inherits the parent CM2's computed colours, `0x714000`). Before 0820 the
//! field was skinning-only, and a dwarf's Stoneform tint stopped at his pauldrons.
//! boneless geosets and its billboard cards carry their wearer's slot, so a tinted unit tints
//! *whole* — the reference's own rule (an attached/chained model inherits the parent CM2's
//! computed colours, `0x714000`). Before 0820 the field was skinning-only, and a dwarf's
//! Stoneform tint stopped at his pauldrons. **Worn gear is the exception:** since 1609 every
//! ordinary item owns a rider slot (0841's welded items a joint rig), so its per-slot state —
//! the tint (`aura_visual`'s chain walk) and the straddle waterline (`crate::straddle`, 2190) —
//! reaches it through the item's `ParentModel` chain, never through a shared slot.
//!
//! Written ONCE at part spawn ([`rig_bits`]); every runtime writer below preserves it by
//! construction (the `with_*` accessors carry bits the writer doesn't own). 11 bits ⇔
+118 -50
View File
@@ -5,6 +5,7 @@
use benilla_formats::{ModelBlend, WmoBatchClass};
use bevy::asset::AssetId;
use bevy::mesh::MeshTag;
use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use bevy::render::render_resource::{Buffer, Face};
@@ -786,11 +787,12 @@ pub struct FarSideOfWater;
/// The grain is the reference's: every batch entity of one model instance carries the instance's
/// own transform (the mesh offsets live in vertex space), so classifying each at its
/// `GlobalTransform` IS the per-model split — one liquid hit at the model's placement, all its
/// batches on one list. Straddlers keep the near-side default per batch (the reference splits
/// those with hardware clip planes, `M2UseClipPlanes` — a deviation this system inherits from
/// 0911 and names, not fixes). WMO translucents never classify (the reference's lists are
/// CM2Scene's; the WMO leg draws its own), and opaque/cutout batches settle by depth like any
/// world geometry.
/// batches on one list. A slot-bearing instance inside the reference's `±r` band is a
/// **straddler** and takes BOTH lists: its batches stay near and gain a far twin, each clipped at
/// the waterline (the reference's `M2UseClipPlanes`; decision 2188, [`crate::straddle`]).
/// Slot-0 content keeps the one-list sign test. WMO translucents never classify (the reference's
/// lists are CM2Scene's; the WMO leg draws its own), and opaque/cutout batches settle by depth
/// like any world geometry.
///
/// **Two lanes, one classification.** Entities whose handle the Visibility authority owns — the
/// `DoodadFade` holders `debug_panel::apply_model_visibility` pins to cutout/blend every frame —
@@ -841,14 +843,20 @@ pub(crate) fn classify_water_side(
Changed<MeshMaterial3d<WowModelMaterial>>,
),
>,
Query<(
Entity,
&GlobalTransform,
Option<&bevy::camera::visibility::RenderLayers>,
&mut MeshMaterial3d<WowModelMaterial>,
Has<crate::model_fade::DoodadFade>,
Has<FarSideOfWater>,
)>,
Query<
(
Entity,
&GlobalTransform,
Option<&bevy::camera::visibility::RenderLayers>,
&mut MeshMaterial3d<WowModelMaterial>,
Has<crate::model_fade::DoodadFade>,
Has<FarSideOfWater>,
Option<&MeshTag>,
Has<crate::straddle::StraddlesWater>,
),
// A straddle twin is the classification's OUTPUT, never its input.
Without<crate::straddle::StraddleTwinOf>,
>,
)>,
moved: Query<
Entity,
@@ -861,6 +869,10 @@ pub(crate) fn classify_water_side(
),
>,
reclaimed: Query<Entity, Changed<crate::wmo_portal::UnitWmoRoom>>,
// The straddle verdict's edge (decision 2188): an instance entering or leaving its water band
// re-classifies its whole subtree — the same fan-down as the room claim.
rebanded: Query<Entity, Changed<crate::straddle::ModelWaterBand>>,
clips: Res<crate::straddle::WaterClips>,
children: Query<&Children>,
mut removed: RemovedComponents<MeshMaterial3d<WowModelMaterial>>,
time: Res<bevy::time::Time>,
@@ -913,6 +925,7 @@ pub(crate) fn classify_water_side(
for item in set.p1().iter_mut() {
classify_part(
&interleave,
&clips,
&mut twins,
&mut materials,
&mut commands,
@@ -947,6 +960,7 @@ pub(crate) fn classify_water_side(
if let Ok(item) = parts.get_mut(e) {
classify_part(
&interleave,
&clips,
&mut twins,
&mut materials,
&mut commands,
@@ -955,10 +969,10 @@ pub(crate) fn classify_water_side(
);
}
}
// A changed claim re-classifies the holder's whole subtree — the claim lives on the unit
// root, the batches are its descendants (a nested holder shadows for its own subtree;
// re-classifying it from here anyway is idempotent).
let mut stack: Vec<Entity> = reclaimed.iter().collect();
// A changed claim — or a changed straddle band (2188) — re-classifies the holder's whole
// subtree: both live on the unit root, the batches are its descendants (a nested holder
// shadows for its own subtree; re-classifying it from here anyway is idempotent).
let mut stack: Vec<Entity> = reclaimed.iter().chain(rebanded.iter()).collect();
while let Some(e) = stack.pop() {
if let Ok(ch) = children.get(e) {
stack.extend(ch.iter());
@@ -966,6 +980,7 @@ pub(crate) fn classify_water_side(
if let Ok(item) = parts.get_mut(e) {
classify_part(
&interleave,
&clips,
&mut twins,
&mut materials,
&mut commands,
@@ -977,23 +992,32 @@ pub(crate) fn classify_water_side(
}
}
/// One transparent-candidate batch as [`classify_part`] sees it: the entity, its transform, its
/// layers, its handle (the channel this lane may swap), whether the Visibility authority owns
/// that handle, whether it is marked far, its tag (the instance slot the straddle verdict is
/// keyed on), and whether it is marked straddling.
type PartItem<'a> = (
Entity,
&'a GlobalTransform,
Option<&'a bevy::camera::visibility::RenderLayers>,
Mut<'a, MeshMaterial3d<WowModelMaterial>>,
bool,
bool,
Option<&'a MeshTag>,
bool,
);
/// One batch entity's classification — the shared body of [`classify_water_side`]'s full and
/// reactive paths. `used` collects the near identities live batches carry (the twin GC's mark);
/// the reactive path passes `None` and leaves the sweep to the next full frame.
fn classify_part(
interleave: &crate::particles::WaterInterleave,
clips: &crate::straddle::WaterClips,
twins: &mut FarSideTwins,
materials: &mut Assets<WowModelMaterial>,
commands: &mut Commands,
used: Option<&mut std::collections::HashSet<AssetId<WowModelMaterial>>>,
(entity, gt, layers, mut mat, authority_owned, marked): (
Entity,
&GlobalTransform,
Option<&bevy::camera::visibility::RenderLayers>,
Mut<MeshMaterial3d<WowModelMaterial>>,
bool,
bool,
),
(entity, gt, layers, mut mat, authority_owned, marked, tag, straddling): PartItem<'_>,
) {
use bevy::camera::visibility::RenderLayers;
// A booth-layered batch belongs to its own camera and scene — no world water applies
@@ -1023,48 +1047,59 @@ fn classify_part(
if !qualifies {
// A marked part that settled back onto its opaque cutout (fade over, aura released)
// stops being a transparent draw at all — its side is nobody's business until it
// feathers again.
// feathers again, and a doubled one stops being doubled.
if marked {
commands.entity(entity).remove::<FarSideOfWater>();
}
if straddling {
commands
.entity(entity)
.remove::<crate::straddle::StraddlesWater>();
}
return;
}
if let Some(used) = used {
used.insert(near.id());
}
let far = crate::particles::far_side_of_water(interleave, Some(entity), gt.translation());
// **The straddle split** (decision 2188, [`crate::straddle`]): an instance inside its water
// band draws on BOTH lists. This batch keeps its NEAR identity — the fragment clips it to the
// eye's half — and `sync_straddle_twins` hangs its far twin beside it, clipped to the other.
// The verdict is the per-slot word the clip itself reads, so a batch is doubled exactly when
// its fragments are halved.
let straddles = tag.is_some_and(|t| clips.straddles(crate::mesh_tag::rig_of(t.0)));
if straddles != straddling {
trace_side(
if straddles { "straddle" } else { "unstraddle" },
entity,
gt,
);
if straddles {
commands
.entity(entity)
.insert(crate::straddle::StraddlesWater);
} else {
commands
.entity(entity)
.remove::<crate::straddle::StraddlesWater>();
}
}
if straddles {
// Built here, where the store is writable — the twin sync only reads the map.
far_twin(twins, materials, &near);
}
let far = !straddles
&& crate::particles::far_side_of_water(interleave, Some(entity), gt.translation());
if far == far_now {
return;
}
// The swap's own trace (`WOW_MOVE_TRACE_TAGS=fx`): which batches crossed the plane this
// frame and which way — the numeric read for a sort question no pixel can answer, and
// naturally sparse (transitions only, never per-frame spam).
if benilla_assets::trace::enabled() {
let p = gt.translation();
benilla_assets::trace::line(
"fx",
&format!(
"{} mesh e={entity} at=[{:.1},{:.1},{:.1}]",
if far { "far-side" } else { "near-side" },
p.x,
p.y,
p.z
),
);
}
trace_side(if far { "far-side" } else { "near-side" }, entity, gt);
if far {
// Build (or fetch) the twin either way — every composing owner needs it live in the
// map before its own pick can resolve it.
let far_h = if let Some(h) = twins.to_far.get(&near.id()) {
h.clone()
} else {
// `qualifies` above already proved the near asset exists.
let twin = far_twin_of(materials.get(near.id()).unwrap());
let h = materials.add(twin);
twins.to_far.insert(near.id(), h.clone());
twins.to_near.insert(h.id(), near.clone());
h
};
let far_h = far_twin(twins, materials, &near);
commands.entity(entity).insert(FarSideOfWater);
if !authority_owned {
mat.0 = far_h;
@@ -1077,6 +1112,39 @@ fn classify_part(
}
}
/// The far twin of `near`, built on first ask. `near` must be a live asset — every caller has
/// just proved it through the classifier's `qualifies`.
fn far_twin(
twins: &mut FarSideTwins,
materials: &mut Assets<WowModelMaterial>,
near: &Handle<WowModelMaterial>,
) -> Handle<WowModelMaterial> {
if let Some(h) = twins.to_far.get(&near.id()) {
return h.clone();
}
let twin = far_twin_of(materials.get(near.id()).unwrap());
let h = materials.add(twin);
twins.to_far.insert(near.id(), h.clone());
twins.to_near.insert(h.id(), near.clone());
h
}
/// `WOW_MOVE_TRACE_TAGS=fx`: one line per side transition of one batch — `far-side`/`near-side`
/// for the one-list law, `straddle`/`unstraddle` for the two-list band.
fn trace_side(what: &str, entity: Entity, gt: &GlobalTransform) {
if !benilla_assets::trace::enabled() {
return;
}
let p = gt.translation();
benilla_assets::trace::line(
"fx",
&format!(
"{what} mesh e={entity} at=[{:.1},{:.1},{:.1}]",
p.x, p.y, p.z
),
);
}
/// Replace the fog-policy bits (4-6) inside a packed `clutter_fade.z` marker word, preserving
/// every other pipeline marker — bits 0-3 AND the Mod/Mod2x multiply markers in bits 7-8
/// (decision 0528). The mask lives here, beside the packer above: the portrait booth's rig twin
+1 -1
View File
@@ -41,7 +41,7 @@ use emit::{emit_local, next_u32, rand01, rand_s11};
use sim::simulate_particles;
// The water-plane interleave classification — shared with the ribbon sim (a trail is one of the
// model's emitters and classifies the same way; `sky_order::FAR_SIDE_BIAS`).
pub(crate) use sim::{far_side_of_water, model_far_side, WaterInterleave};
pub(crate) use sim::{far_side_of_water, model_far_side, water_height, WaterInterleave};
/// This camera is **rate-throttled**: it renders on some frames and skips others *by our own
/// pacing*, while the scene behind it keeps running at full rate. A frame it does not draw is a
+39 -27
View File
@@ -354,40 +354,52 @@ pub(crate) fn far_side_of_water_at(
point_world: Vec3,
r: f32,
) -> bool {
let above = water_height(w, claim_seed, point_world).is_none_or(|d| is_above(d, r));
if w.underwater.0.any() {
above
} else {
!above
}
}
/// How high `point_world` sits over its local water plane — `d = point − surface` (WoW Z, yd;
/// Bevy Y is the same axis) against the nearest **admitted** surface over the point's XY, the
/// claim taken from the nearest ancestor of `claim_seed` with a room verdict (`Unknown` admits
/// both sources, still floor-bounded per pool). `None` = no admitted surface — the reference's
/// `+0x19c == 0`, which every lane reads as the above list.
///
/// The one plane query behind both laws: [`far_side_of_water_at`]'s one-list membership, and
/// the straddle split's two-list band (`crate::straddle`, decision 2188), which also needs the
/// plane's height itself for the clip.
pub(crate) fn water_height(
w: &WaterInterleave,
claim_seed: Option<Entity>,
point_world: Vec3,
) -> Option<f32> {
let wow = benilla_assets::coords::bevy_to_wow(point_world);
// The spatial pre-filter first — these lanes ask per DRAW, and the full `surfaces_at` walk
// at that grain was the 2026-08-03 12-fps regression (`liquid::spatial`). No candidate
// surface over this XY (the dominant dry-land case) is "no admitted surface" under every
// claim, so the room walk below is skipped with the scan.
let candidates = w.index.over(wow[0], wow[1]);
let above = if candidates.is_empty() {
true
} else {
let mut seed = claim_seed;
let mut room = None;
for _ in 0..8 {
let Some(e) = seed else { break };
if let Ok(rm) = w.rooms.get(e) {
room = Some(rm);
break;
}
seed = w.parents.get(e).ok().map(ChildOf::parent);
}
let claim = crate::liquid::unit_claim(room, &w.placements);
let surfaces = candidates.iter().filter_map(|&e| w.water.get(e).ok());
match crate::liquid::surfaces_at(surfaces, wow, claim)
.map(|z| wow[2] - z)
.min_by(|a, b| a.abs().total_cmp(&b.abs()))
{
Some(d) => is_above(d, r),
None => true,
}
};
if w.underwater.0.any() {
above
} else {
!above
if candidates.is_empty() {
return None;
}
let mut seed = claim_seed;
let mut room = None;
for _ in 0..8 {
let Some(e) = seed else { break };
if let Ok(rm) = w.rooms.get(e) {
room = Some(rm);
break;
}
seed = w.parents.get(e).ok().map(ChildOf::parent);
}
let claim = crate::liquid::unit_claim(room, &w.placements);
let surfaces = candidates.iter().filter_map(|&e| w.water.get(e).ok());
crate::liquid::surfaces_at(surfaces, wow, claim)
.map(|z| wow[2] - z)
.min_by(|a, b| a.abs().total_cmp(&b.abs()))
}
/// The mesh lane's wrapper: the sign test at the batch's own transform (r = 0) — the
+19 -6
View File
@@ -88,20 +88,22 @@ pub(crate) fn rig_origin_region_bytes() -> u64 {
/// Byte offset of the palette rows themselves — after the slot table, the per-instance tint table
/// that shares this slot index ([`crate::instance_tint`], decision 0812), the origin table, and
/// the mat-anim delta table ([`crate::mat_anim_table`], decision 1381). The rows stay last
/// because `wow_model.wgsl` declares them as the struct's one runtime-sized array.
/// the mat-anim delta table ([`crate::mat_anim_table`], decision 1381), and the straddle clip
/// table ([`crate::straddle`], decision 2188). The rows stay last because `wow_model.wgsl`
/// declares them as the struct's one runtime-sized array.
pub(crate) fn palette_region_offset() -> u64 {
crate::mat_anim_table::region_offset() + crate::mat_anim_table::region_bytes()
crate::straddle::region_offset() + crate::straddle::region_bytes()
}
/// Total bytes the slot-indexed regions add to every `wow_light`-layout buffer
/// (`lighting::light_blob_bytes`): the rig slot table, the instance-tint table, the origin table,
/// the palette rows.
/// the mat-anim table, the straddle clip table, the palette rows.
pub(crate) fn palette_regions_bytes() -> u64 {
(MAX_RIG_SLOTS * 4) as u64
+ crate::instance_tint::region_bytes()
+ rig_origin_region_bytes()
+ crate::mat_anim_table::region_bytes()
+ crate::straddle::region_bytes()
+ MAX_PALETTE_BONES as u64 * BONE_BYTES
}
@@ -746,6 +748,11 @@ 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);
}
// …and the straddle split's waterline (decision 2188), on the same index for the same reason:
// a recycled slot must not clip the next unit at the last one's water.
if let Some(mut clips) = world.get_resource_mut::<crate::straddle::WaterClips>() {
clips.clear(slot);
}
}
/// On every joint entity (planted by [`spawn_joints`]): the rig root whose palette this
@@ -1435,9 +1442,14 @@ mod tests {
"the mat-anim table follows the origin table (decision 1381)"
);
assert_eq!(
palette_region_offset() - crate::mat_anim_table::region_offset(),
crate::straddle::region_offset() - crate::mat_anim_table::region_offset(),
crate::mat_anim_table::region_bytes(),
"the palette rows follow the mat-anim table"
"the straddle clip table follows the mat-anim table (decision 2188)"
);
assert_eq!(
palette_region_offset() - crate::straddle::region_offset(),
crate::straddle::region_bytes(),
"the palette rows follow the straddle clip table"
);
assert_eq!(
palette_regions_bytes(),
@@ -1445,6 +1457,7 @@ mod tests {
+ crate::instance_tint::region_bytes()
+ rig_origin_region_bytes()
+ crate::mat_anim_table::region_bytes()
+ crate::straddle::region_bytes()
+ (MAX_PALETTE_BONES as u64) * BONE_BYTES
);
// One `vec4` per addressable slot — the shader declares `array<vec4<f32>, 2048>`.
+686
View File
@@ -0,0 +1,686 @@
//! **The straddle split** — a translucent model that crosses its water plane draws on BOTH sides
//! of the water pass, each copy cut at the waterline (decision 2188; wow-re
//! `terrain/scratch/water-frame-straddle.md` §2, byte-VERIFIED).
//!
//! **The reference.** The collector (`0x707680`) dots each model's bound-box centre (instance
//! matrix × `(min+max)/2`) against the plane `{0,0,1,−surfaceZ}` from the model's own liquid hit,
//! once per instance. With `M2UseClipPlanes` granted — the cvar's default `"1"`, caps-gated at
//! `0x7066a6` — it keeps TWO side booleans, `A = d ≥ −r` (the above list) and `B = d ≤ r` (the
//! below list), `r` the world-scaled bound radius. A model inside the `±r` band lands on BOTH
//! lists, its z-fill depth primes ride the same booleans (`0x707ffe`/`0x708048`), and the mesh
//! draw arm brackets each list's copy with a hardware clip plane at the waterline (`0x70baf0`
//! @`0x70c094`–`0x70c103`). The frame interleave draws the eye's far list before the water and the
//! near list after it (0911) — so the half under the surface is painted over by the water, and the
//! half above it paints over the water.
//!
//! **What benilla had.** The mesh lane (0919) shipped the no-clip-planes fallback only — one list
//! per model, a bare sign test at the placement origin — and named the band as a gap. Its worst
//! case was a unit fading in while standing in water (the director's report, 2026-09-11): the
//! appear ramp puts every part on its blend twin, which is exactly when the lane classifies it;
//! feet under the surface sent the WHOLE body to the far list; and because the fade twin and the
//! depth prime both write depth, the part above the water primed its depth before the water drew.
//! A body-shaped hole in the surface, with the head blended over the unwatered lake bed behind it
//! — strongest at the ramp's start, where the body is least opaque.
//!
//! **The shape here — three pieces, one verdict:**
//! - [`band_instances`] — per model instance (a root carrying a [`RigSkin`] slot and a
//! [`WorldUnit`] bound), the reference's two booleans at the transformed bound centre. A
//! straddler's plane goes into the per-slot [`WaterClips`] table — a region of the shared light
//! buffer on the same slot index as the body tint (0812) — and [`ModelWaterBand`] on the root is
//! the edge the classifier fans down from.
//! - `model_render::classify_water_side` reads the SAME table word by the batch's `MeshTag` slot:
//! a straddling batch stays on its NEAR identity and is marked [`StraddlesWater`], and
//! [`sync_straddle_twins`] hangs a child on it carrying the FAR twin — the second list.
//! - `wow_model.wgsl`'s `WOW_WATER_CLIP` block (every transparent-pass pipeline) is the clip plane:
//! a fragment of a straddling instance survives only on its own copy's side — the near copy
//! keeps the eye's half, the far twin (`FAR_SIDE_MARKER`) the other. [`keeps`] is the law's Rust
//! twin.
//!
//! The CPU twin set and the GPU clip read one word, so they cannot disagree: a batch is doubled
//! exactly when its fragments are being halved.
//!
//! **Scope: slot-bearing instances.** The clip is per INSTANCE, and the one per-instance channel
//! the fragment stage has is the rig slot — so the split covers every skinned wire body (units,
//! players, rigged GameObjects), the parts carrying its slot (boneless geosets, billboard cards),
//! and every model CHAINED to it — worn gear and hung spell kits, which own slots of their own
//! since 1609 and take the body's word through their `ParentModel` link (decision 2190). Slot-0
//! content — map doodads, unskinned models — keeps 0919's one-sided fallback; its only
//! translucent episodes are the distance-fade ring's small props.
use std::sync::Arc;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::renderer::RenderQueue;
use bevy::render::{Render, RenderApp, RenderSystems};
use benilla_assets::materials::WowModelMaterial;
use crate::mesh_tag::MAX_RIG_SLOTS;
use crate::model_fade::{ParentModel, MAX_MODEL_CHAIN};
use crate::model_render::FarSideTwins;
use crate::particles::WaterInterleave;
use crate::rig_palette::RigSkin;
use crate::world_unit::WorldUnit;
/// One slot's clip word: `[plane height, near side]` — the waterline's world height (Bevy Y,
/// which is WoW Z) and the side the instance's NEAR copy keeps: `+1` above (a dry eye's near list
/// is the above one), `−1` below (a submerged eye's, `0x4836d6`). A side of `0` is "not
/// straddling" — every slot-0 instance, every slot nothing wrote, and every zeroed studio buffer.
pub type ClipWord = [f32; 2];
/// The "no clip" word. Zero is identity, so a zeroed region is inert by construction: the
/// portrait booths' studio buffers never receive this region's writes and keep it that way.
pub const NO_CLIP: ClipWord = [0.0, 0.0];
/// Bytes per slot in the clip region: two `f32` — `wow_model.wgsl`'s `array<vec2<f32>, 2048>`.
const SLOT_BYTES: u64 = 8;
/// Byte offset of the clip region inside a `wow_light`-layout buffer: after the mat-anim table,
/// before the palette rows (which stay last — the shader's one runtime-sized array).
pub(crate) fn region_offset() -> u64 {
crate::mat_anim_table::region_offset() + crate::mat_anim_table::region_bytes()
}
/// Bytes this region adds to every `wow_light`-layout buffer (16 KB at 2048 slots).
pub(crate) fn region_bytes() -> u64 {
MAX_RIG_SLOTS as u64 * SLOT_BYTES
}
/// The live per-slot clip table, indexed by the instance's `MeshTag` rig slot. `Arc`-shared so
/// the render-world extract is a pointer bump, and generation-stamped so a world where nothing
/// straddles uploads nothing at all.
#[derive(Resource, Clone, ExtractResource)]
pub struct WaterClips {
slots: Arc<Vec<ClipWord>>,
generation: u64,
}
impl Default for WaterClips {
fn default() -> Self {
Self {
slots: Arc::new(vec![NO_CLIP; MAX_RIG_SLOTS]),
generation: 0,
}
}
}
impl WaterClips {
/// Set slot `slot`'s word. Slot 0 is the world's no-rig sentinel and is never written:
/// everything unskinned shares it.
pub(crate) fn set(&mut self, slot: u16, word: ClipWord) {
let i = slot as usize;
if i == 0 || i >= MAX_RIG_SLOTS || self.slots[i] == word {
return;
}
Arc::make_mut(&mut self.slots)[i] = word;
self.generation += 1;
}
/// Back to "not straddling" — called from `RigSkin`'s free hook as well, so a recycled slot
/// can never hand a dead unit's waterline to the next unit that allocates it.
pub(crate) fn clear(&mut self, slot: u16) {
self.set(slot, NO_CLIP);
}
/// This slot's word (`NO_CLIP` for an out-of-range slot) — what a chained model inherits
/// from its wearer.
pub(crate) fn word(&self, slot: u16) -> ClipWord {
self.slots.get(slot as usize).copied().unwrap_or(NO_CLIP)
}
/// Whether `slot`'s instance straddles its water plane — the classifier's read of the very
/// word the fragment clips by.
pub(crate) fn straddles(&self, slot: u16) -> bool {
self.slots.get(slot as usize).is_some_and(|w| w[1] != 0.0)
}
}
/// On a model-instance root: whether its model straddles its water plane this frame. Written by
/// [`band_instances`] (change-gated) and read as a change EDGE — `classify_water_side`
/// re-classifies the holder's subtree when it flips, the same fan-down the room claim takes.
/// Absent reads "no": it is inserted the first time an instance straddles.
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ModelWaterBand {
pub straddles: bool,
}
/// On a transparent batch of a straddling instance: the classifier's verdict that this batch
/// draws on both lists. Sparse; [`sync_straddle_twins`] keeps a far twin alive exactly while it
/// is present.
#[derive(Component)]
pub(crate) struct StraddlesWater;
/// On a straddling batch: its live far-side twin. [`sync_straddle_twins`] is the only writer.
#[derive(Component)]
pub(crate) struct StraddleTwin(Entity);
/// On a twin: the batch it doubles for — the mirror's key, and the marker that keeps a twin out
/// of the classifier's walk (a twin is the classification's output, never its input).
#[derive(Component)]
pub(crate) struct StraddleTwinOf(Entity);
/// The reference's two side booleans for a model whose bound centre sits `d` yd over its water
/// plane, with slack `r`: `(above, below) = (d ≥ −r, d ≤ r)` (`0x7079a2`–`0x707a17`). Both ties
/// are inclusive — `d == −r` keeps A (the emitter lane's `is_above`), `d == r` keeps B (`jp`
/// @`0x7079e2`) — and a NaN keeps neither, which reads as "not straddling".
pub(crate) fn side_booleans(d: f32, r: f32) -> (bool, bool) {
(d >= -r, d <= r)
}
/// How far past the reference's `±r` band a straddler must travel before it stops straddling.
///
/// **Not a fidelity deviation.** A split draw is pixel-identical to the one-list draw for a model
/// wholly on one side of its plane — one copy's half is simply empty — so where the verdict flips
/// only decides what the draw COSTS, never what it shows. The reference recomputes its booleans
/// every frame with no hysteresis because its flip is free (list membership); ours spawns or
/// despawns a twin and swaps the batch's handle. Without this, a murloc bobbing 0.3 yd at the
/// band's edge flipped nearly every frame (the 2188 live leg: 17 transitions on one body, three
/// inside 40 ms).
const STICKY_YD: f32 = 1.0;
/// The straddle verdict with the sticky exit: an instance enters on the reference's band and
/// leaves only once its centre is [`STICKY_YD`] past it. `was` = it straddled last time it was
/// banded.
pub(crate) fn straddles(d: f32, r: f32, was: bool) -> bool {
let slack = if was { r + STICKY_YD } else { r };
side_booleans(d, slack) == (true, true)
}
/// The clip plane's law — the Rust twin of `wow_model.wgsl`'s `WOW_WATER_CLIP` block: does a
/// fragment at world height `y` survive on this copy of a batch? `far_copy` = the copy's material
/// carries `FAR_SIDE_MARKER` (it is the twin on the eye's far list). A `0` side keeps everything,
/// and a fragment exactly on the plane survives on both copies.
pub fn keeps(word: ClipWord, y: f32, far_copy: bool) -> bool {
if word[1] == 0.0 {
return true;
}
let side = if far_copy { -word[1] } else { word[1] };
side * (y - word[0]) >= 0.0
}
/// The waterline a CHAINED model clips at: its nearest body's word, walked up the
/// [`ParentModel`] chain from `start` (the model's parent). `link(k)` answers, for one link, its
/// own palette slot, its parent, and whether it is a body; a broken chain, a body with no slot
/// and a chain past [`MAX_MODEL_CHAIN`] all read "not straddling". Generic over the key so the
/// walk is testable without an ECS.
fn wearer_word<K: Copy>(
start: K,
clips: &WaterClips,
link: impl Fn(K) -> Option<(Option<u16>, Option<K>, bool)>,
) -> ClipWord {
let mut at = start;
for _ in 0..MAX_MODEL_CHAIN {
let Some((slot, up, body)) = link(at) else {
break;
};
if body {
return slot.map_or(NO_CLIP, |s| clips.word(s));
}
match up {
Some(p) => at = p,
None => break,
}
}
NO_CLIP
}
/// The roots [`band_instances`] re-bands on an ordinary frame: an instance whose matrix, bound,
/// slot or room claim moved. The eye crossing the surface and the surface population changing
/// re-band everything.
type BandDirty = (
With<RigSkin>,
With<WorldUnit>,
Or<(
Changed<GlobalTransform>,
Changed<WorldUnit>,
Changed<RigSkin>,
Changed<crate::wmo_portal::UnitWmoRoom>,
)>,
);
/// A model chained to a body — worn gear and the spell kits hung on it — with its own slot.
type ChainedRoot<'a> = (
Entity,
&'a RigSkin,
&'a ParentModel,
Option<Mut<'a, ModelWaterBand>>,
);
/// A chained model whose slot or link is new this frame — it has no word yet, whatever its
/// wearer's did.
type ChainedFresh = (
With<ParentModel>,
Without<WorldUnit>,
Or<(Changed<RigSkin>, Changed<ParentModel>)>,
);
/// One model-instance root as [`band_instances`] sees it.
type BandRoot<'a> = (
Entity,
&'a GlobalTransform,
&'a WorldUnit,
&'a RigSkin,
Option<Mut<'a, ModelWaterBand>>,
);
/// **Band every model instance against its water plane** — the reference's two side booleans
/// per instance, written where both halves of the split read them: the per-slot [`WaterClips`]
/// word (the fragment's clip plane, the classifier's doubling verdict) and [`ModelWaterBand`]
/// on the root (the classifier's re-classify edge).
///
/// **Every model chained to a body inherits the body's word** (decision 2190). Worn gear does not
/// share its wearer's slot: since 1609 every ordinary item rides a rider slot of its own (0841's
/// welded items a joint rig), so a helm, a pair of shoulders or a weapon at the waterline was
/// never banded and kept the one-list fallback — the hole in the water the director still saw
/// when the surface crossed a unit's gear. The body's band is the instance's verdict and its
/// plane is the one water plane under it, and the clip is exact on any copy whatever side it
/// lands, so the gear takes the word verbatim rather than a band of its own.
///
/// Update, after the submersion verdict and before `classify_water_side` — the auto-inserted
/// sync point between them lands a first-time `ModelWaterBand` before the classifier looks, so
/// the clip and the twin set flip on the same frame.
#[allow(clippy::too_many_arguments)]
pub(crate) fn band_instances(
interleave: WaterInterleave,
mut clips: ResMut<WaterClips>,
mut commands: Commands,
mut roots: Query<(
Entity,
&GlobalTransform,
&WorldUnit,
&RigSkin,
Option<&mut ModelWaterBand>,
)>,
dirty: Query<Entity, BandDirty>,
mut chained: Query<
(Entity, &RigSkin, &ParentModel, Option<&mut ModelWaterBand>),
Without<WorldUnit>,
>,
fresh: Query<Entity, ChainedFresh>,
links: Query<(Option<&RigSkin>, Option<&ParentModel>, Has<WorldUnit>)>,
mut eye_was_submerged: Local<Option<bool>>,
) {
let before = clips.generation;
let eye = interleave.eye_submerged();
let full = *eye_was_submerged != Some(eye) || interleave.surfaces_changed();
*eye_was_submerged = Some(eye);
// A dry eye's near list is the above one; a submerged eye's, the below one (`0x4836d6`).
let near_side = if eye { -1.0 } else { 1.0 };
if full {
for root in &mut roots {
band_one(&interleave, &mut clips, &mut commands, near_side, root);
}
} else {
for e in &dirty {
if let Ok(root) = roots.get_mut(e) {
band_one(&interleave, &mut clips, &mut commands, near_side, root);
}
}
}
// The chained pass: every chained model when any body's word moved this frame (the gate is
// the table's own generation — a dry world, and a wet one standing still, skip it whole),
// otherwise only the chains that are new.
let link = |e: Entity| {
links
.get(e)
.ok()
.map(|(skin, up, body)| (skin.map(|s| s.slot), up.map(|p| p.0), body))
};
if clips.generation != before {
for item in &mut chained {
chain_one(&mut clips, &mut commands, &link, item);
}
} else {
for e in &fresh {
if let Ok(item) = chained.get_mut(e) {
chain_one(&mut clips, &mut commands, &link, item);
}
}
}
}
/// One chained model's word — its wearer's, onto its own slot — and its band edge.
fn chain_one(
clips: &mut WaterClips,
commands: &mut Commands,
link: &impl Fn(Entity) -> Option<(Option<u16>, Option<Entity>, bool)>,
(entity, rig, parent, band): ChainedRoot<'_>,
) {
let word = wearer_word(parent.0, clips, link);
clips.set(rig.slot, word);
band_edge(commands, entity, band, word[1] != 0.0);
}
/// Publish an instance's verdict as its [`ModelWaterBand`] edge: change-gated on a live
/// component, inserted the first time it straddles.
fn band_edge(
commands: &mut Commands,
entity: Entity,
band: Option<Mut<ModelWaterBand>>,
straddles: bool,
) {
match band {
Some(mut b) => {
b.set_if_neq(ModelWaterBand { straddles });
}
None if straddles => {
commands.entity(entity).insert(ModelWaterBand { straddles });
}
None => {}
}
}
/// One instance's band — the shared body of [`band_instances`]' full and reactive paths.
fn band_one(
interleave: &WaterInterleave,
clips: &mut WaterClips,
commands: &mut Commands,
near_side: f32,
(entity, gt, unit, rig, band): BandRoot<'_>,
) {
// The reference's point and slack (§2/§6): the bound centre through the instance matrix, the
// radius through its row-0 scale. The game hands the armed idle's authored CAaBox rather than
// the header sphere, so the slack is that box's circumscribed sphere — never smaller than the
// model, and an over-wide band costs only a copy whose half the clip discards whole.
let was = band.as_ref().is_some_and(|b| b.straddles);
let word = unit.bound.and_then(|b| {
let centre = gt.transform_point(Vec3::from(b.center));
let r = gt.affine().matrix3.x_axis.length() * Vec3::from(b.half_extents).length();
let d = crate::particles::water_height(interleave, Some(entity), centre)?;
straddles(d, r, was).then_some([centre.y - d, near_side])
});
clips.set(rig.slot, word.unwrap_or(NO_CLIP));
band_edge(commands, entity, band, word.is_some());
}
/// One straddle candidate as [`sync_straddle_twins`] sees it: the batch, what its twin copies
/// (tag, mesh, handle, cull box and marker, layers), its verdict, and the twin it already has.
type StraddleParts<'w, 's> = Query<
'w,
's,
(
Entity,
&'static MeshTag,
&'static Mesh3d,
&'static MeshMaterial3d<WowModelMaterial>,
Has<StraddlesWater>,
Option<&'static StraddleTwin>,
Option<&'static Aabb>,
Has<NoFrustumCulling>,
Option<&'static RenderLayers>,
),
(
Without<StraddleTwinOf>,
Or<(With<StraddlesWater>, With<StraddleTwin>)>,
),
>;
/// Keep every straddling batch's far twin in sync with its verdict — spawn the twin child when
/// the classifier marks the batch [`StraddlesWater`], despawn it when the mark goes — and mirror
/// the batch's tag (the fade alpha moves every ramp frame; the slot rides it), mesh (a gear
/// redress swaps it in place) and handle (composed down one water rung) onto a live twin.
///
/// PostUpdate, after the depth-prime sync: a zfill twin is a batch like any other here, so its
/// own far twin mirrors the tag the zfill mirror has just written.
pub(crate) fn sync_straddle_twins(
mut commands: Commands,
far: Res<FarSideTwins>,
parts: StraddleParts,
mut twins: Query<(
&StraddleTwinOf,
&mut MeshTag,
&mut Mesh3d,
&mut MeshMaterial3d<WowModelMaterial>,
)>,
) {
for (part, tag, mesh, mat, straddling, twin, aabb, no_cull, layers) in &parts {
match twin {
None if straddling => {
// The classifier builds the far twin before it marks the batch; a miss means the
// batch's handle moved since, and next frame's classification rebuilds it.
let Some(far_h) = far.far_of(&mat.0) else {
continue;
};
let mut t = commands.spawn((
Mesh3d(mesh.0.clone()),
MeshMaterial3d(far_h.clone()),
MeshTag(tag.0),
Transform::IDENTITY,
StraddleTwinOf(part),
ChildOf(part),
));
// The twin culls exactly as its batch does: a skinned part's cull belongs to the
// body root's election, and its own box is the picker's (`attach::dress`).
if let Some(aabb) = aabb {
t.insert(*aabb);
}
if no_cull {
t.insert(NoFrustumCulling);
}
if let Some(layers) = layers {
t.insert(layers.clone());
}
let t = t.id();
commands.entity(part).insert(StraddleTwin(t));
trace("arm", part);
}
Some(t) if !straddling => {
commands.entity(t.0).try_despawn();
commands.entity(part).remove::<StraddleTwin>();
trace("release", part);
}
_ => {}
}
}
for (of, mut tag, mut mesh, mut mat) in &mut twins {
let Ok((_, ptag, pmesh, pmat, ..)) = parts.get(of.0) else {
continue; // batch going away this frame — the child despawns with it
};
if tag.0 != ptag.0 {
tag.0 = ptag.0;
}
if mesh.0 != pmesh.0 {
mesh.0 = pmesh.0.clone();
}
if let Some(far_h) = far.far_of(&pmat.0) {
if mat.0 != *far_h {
mat.0 = far_h.clone();
}
}
}
}
/// The split's instrument (`WOW_MOVE_TRACE=<path>`, tag `fx` — beside the classifier's
/// `straddle`/`far-side` lines): one line per twin edge.
fn trace(what: &str, part: Entity) {
if !benilla_assets::trace::enabled() {
return;
}
benilla_assets::trace::line("fx", &format!("straddle-twin {what} part={part}"));
}
/// Render world (`PrepareResources`): write the whole 16 KB region when anything changed — the
/// shared buffer only. The portrait booths and the glue scene keep their zeroed region: nothing in
/// a studio stands in world water.
fn upload_water_clips(
queue: Res<RenderQueue>,
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
clips: Option<Res<WaterClips>>,
mut last: Local<Option<u64>>,
) {
let (Some(shared), Some(clips)) = (shared, clips) else {
return;
};
if *last == Some(clips.generation) {
return;
}
*last = Some(clips.generation);
queue.write_buffer(
&shared.0,
region_offset(),
bytemuck::cast_slice(clips.slots.as_slice()),
);
}
/// The straddle split's registration (decision 2188).
pub fn plugin(app: &mut App) {
app.init_resource::<WaterClips>()
.add_plugins(ExtractResourcePlugin::<WaterClips>::default())
.add_systems(
Update,
band_instances
.after(crate::liquid::SubmersionVerdict)
.before(crate::model_render::classify_water_side),
)
.add_systems(
PostUpdate,
sync_straddle_twins.after(crate::zfill::sync_zfill_twins),
);
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(
Render,
upload_water_clips.in_set(RenderSystems::PrepareResources),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The band is the reference's: a centre within `r` of the plane on EITHER side straddles,
/// both ties included; beyond it the model sits wholly on one list.
#[test]
fn the_band_is_plus_minus_r_inclusive() {
// A wading unit: centre 0.4 yd over the surface, 1.5 yd of slack — both lists.
assert_eq!(side_booleans(0.4, 1.5), (true, true));
// Waist-deep the other way: centre under the surface, still inside the band.
assert_eq!(side_booleans(-1.2, 1.5), (true, true));
assert_eq!(side_booleans(-1.5, 1.5), (true, true), "d == −r keeps A");
assert_eq!(side_booleans(1.5, 1.5), (true, true), "d == r keeps B");
assert_eq!(side_booleans(1.6, 1.5), (true, false), "wholly above");
assert_eq!(side_booleans(-1.6, 1.5), (false, true), "wholly below");
// No slack is the fallback's sign test, and only the plane itself straddles it.
assert_eq!(side_booleans(0.0, 0.0), (true, true));
assert_eq!(side_booleans(-0.1, 0.0), (false, true));
assert_eq!(
side_booleans(f32::NAN, 1.0),
(false, false),
"NaN never splits"
);
}
/// Entry is the reference's band; exit is sticky by `STICKY_YD`, so a body bobbing at the
/// edge holds its verdict instead of re-spawning its twins every frame.
#[test]
fn the_exit_is_sticky_and_the_entry_is_not() {
let r = 1.2;
assert!(!straddles(-1.5, r, false), "outside the band: no entry");
assert!(straddles(-1.2, r, false), "the band's own edge enters");
assert!(
straddles(-1.5, r, true),
"a straddler bobbing past the edge holds"
);
assert!(straddles(1.5, r, true), "…on either side");
assert!(
straddles(-2.2, r, true),
"the sticky edge itself still holds"
);
assert!(!straddles(-2.3, r, true), "past the sticky edge it leaves");
assert!(!straddles(f32::NAN, r, true), "NaN never splits");
}
/// The clip plane: for a dry eye the near copy keeps the part above the water and the far
/// twin the part below; a submerged eye swaps them; the plane itself survives on both copies,
/// so the two halves meet without a seam.
#[test]
fn each_copy_keeps_its_own_half() {
let dry = [10.0, 1.0];
assert!(keeps(dry, 11.0, false), "the head, on the near copy");
assert!(!keeps(dry, 11.0, true), "…and not again on the far twin");
assert!(keeps(dry, 9.0, true), "the legs, on the far twin");
assert!(
!keeps(dry, 9.0, false),
"…and not over the water on the near copy"
);
assert!(
keeps(dry, 10.0, false) && keeps(dry, 10.0, true),
"the waterline itself"
);
let submerged = [10.0, -1.0];
assert!(
keeps(submerged, 9.0, false),
"under water, the near half is the lower one"
);
assert!(keeps(submerged, 11.0, true));
assert!(!keeps(submerged, 11.0, false));
// Not straddling: nothing is clipped on either copy.
for y in [-100.0, 0.0, 100.0] {
assert!(keeps(NO_CLIP, y, false) && keeps(NO_CLIP, y, true));
}
}
/// Gear and kits take their BODY's word however deep the chain — a glow on a weapon on a unit
/// — and a chain that never reaches a body, or a body with no slot, clips nothing.
#[test]
fn a_chained_model_inherits_its_bodys_waterline() {
let mut clips = WaterClips::default();
clips.set(5, [57.0, 1.0]);
// key → (own slot, parent, is a body)
let chain = |k: u32| match k {
1 => Some((Some(9), Some(2), false)), // weapon glow → weapon
2 => Some((Some(8), Some(3), false)), // weapon (rider slot 8) → unit
3 => Some((Some(5), None, true)), // the unit, straddling on slot 5
4 => Some((None, None, true)), // a body with no rig slot
6 => Some((Some(7), Some(6), false)), // a self-loop: bounded, never a body
_ => None,
};
assert_eq!(
wearer_word(2, &clips, chain),
[57.0, 1.0],
"the weapon's parent"
);
assert_eq!(wearer_word(3, &clips, chain), [57.0, 1.0]);
assert_eq!(wearer_word(1, &clips, chain), [57.0, 1.0], "two links up");
assert_eq!(wearer_word(4, &clips, chain), NO_CLIP, "a slotless body");
assert_eq!(
wearer_word(6, &clips, chain),
NO_CLIP,
"a cycle ends bounded"
);
assert_eq!(wearer_word(99, &clips, chain), NO_CLIP, "a broken chain");
}
/// Every slot-0 draw in the world shares the word: writing it would clip terrain, WMOs and
/// doodads at one unit's waterline, so the setter refuses it — and a dry world never uploads.
#[test]
fn slot_zero_is_never_written_and_only_real_changes_upload() {
let mut c = WaterClips::default();
c.set(0, [1.0, 1.0]);
assert!(!c.straddles(0));
assert_eq!(c.generation, 0);
c.set(7, [12.5, 1.0]);
assert!(c.straddles(7));
assert_eq!(c.generation, 1);
c.set(7, [12.5, 1.0]);
assert_eq!(c.generation, 1, "same word, no upload");
c.clear(7);
assert!(!c.straddles(7));
assert_eq!(c.generation, 2);
c.set(u16::MAX, [1.0, 1.0]);
assert_eq!(c.generation, 2, "an out-of-range slot is dropped");
}
/// The shader declares `array<vec2<f32>, 2048>` between two `vec4` arrays: the region must be
/// one 8-byte word per addressable slot and end on the 16-byte boundary the palette rows need.
#[test]
fn the_region_is_one_vec2_per_slot_on_a_vec4_boundary() {
assert_eq!(region_bytes(), (MAX_RIG_SLOTS * 8) as u64);
assert_eq!(region_offset() % 16, 0);
assert_eq!((region_offset() + region_bytes()) % 16, 0);
}
}
@@ -146,6 +146,11 @@ impl PluginGroup for WorldPlugins {
.add(crate::model_fade::plugin)
.add(crate::model_render::plugin)
.add(crate::zfill::plugin)
// The straddle split (decision 2188): a translucent model crossing its water plane draws
// on both sides of the water pass, each half clipped at the waterline — the band
// verdict, the per-slot clip region and the far-side twins, beside the two lanes
// (water-side twins, depth primes) it composes with.
.add(crate::straddle::plugin)
// Within-map art residency (decision 0793): the dedup caches expire by DISTANCE, so a
// long flight inside one map stops ratcheting. Before `AssetPlugin` only so the census
// resource exists for anything that reads it at startup; it needs no ordering.