diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 08507d5a..df83291d 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -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`): diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 183dce09..6f78406a 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -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, 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, 2048>, palettes: array>, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var 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 diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 42cdc53e..065408a1 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -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; diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 788ed5ce..2f1f0fa6 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -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 diff --git a/crates/benilla-world/src/mat_anim_table.rs b/crates/benilla-world/src/mat_anim_table.rs index 2ed9c73c..087f93b7 100644 --- a/crates/benilla-world/src/mat_anim_table.rs +++ b/crates/benilla-world/src/mat_anim_table.rs @@ -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() } diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs index 680404f8..df4c2bb9 100644 --- a/crates/benilla-world/src/mesh_tag.rs +++ b/crates/benilla-world/src/mesh_tag.rs @@ -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 ⇔ diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index 62b086ae..bfef3fee 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -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>, ), >, - Query<( - Entity, - &GlobalTransform, - Option<&bevy::camera::visibility::RenderLayers>, - &mut MeshMaterial3d, - Has, - Has, - )>, + Query< + ( + Entity, + &GlobalTransform, + Option<&bevy::camera::visibility::RenderLayers>, + &mut MeshMaterial3d, + Has, + Has, + Option<&MeshTag>, + Has, + ), + // A straddle twin is the classification's OUTPUT, never its input. + Without, + >, )>, moved: Query< Entity, @@ -861,6 +869,10 @@ pub(crate) fn classify_water_side( ), >, reclaimed: Query>, + // 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>, + clips: Res, children: Query<&Children>, mut removed: RemovedComponents>, time: Res, @@ -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 = 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 = 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>, + 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, commands: &mut Commands, used: Option<&mut std::collections::HashSet>>, - (entity, gt, layers, mut mat, authority_owned, marked): ( - Entity, - &GlobalTransform, - Option<&bevy::camera::visibility::RenderLayers>, - Mut>, - 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::(); } + if straddling { + commands + .entity(entity) + .remove::(); + } 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::(); + } + } + 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, + near: &Handle, +) -> Handle { + 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 diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index c8b39d8c..01dbe8e5 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -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 diff --git a/crates/benilla-world/src/particles/sim.rs b/crates/benilla-world/src/particles/sim.rs index 22884d89..4dbd10be 100644 --- a/crates/benilla-world/src/particles/sim.rs +++ b/crates/benilla-world/src/particles/sim.rs @@ -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, + point_world: Vec3, +) -> Option { 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 diff --git a/crates/benilla-world/src/rig_palette.rs b/crates/benilla-world/src/rig_palette.rs index c8179bd3..5a723b45 100644 --- a/crates/benilla-world/src/rig_palette.rs +++ b/crates/benilla-world/src/rig_palette.rs @@ -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::() { 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::() { + 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, 2048>`. diff --git a/crates/benilla-world/src/straddle.rs b/crates/benilla-world/src/straddle.rs new file mode 100644 index 00000000..82c28da7 --- /dev/null +++ b/crates/benilla-world/src/straddle.rs @@ -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, 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>, + 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( + start: K, + clips: &WaterClips, + link: impl Fn(K) -> Option<(Option, Option, 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, + With, + Or<( + Changed, + Changed, + Changed, + Changed, + )>, +); + +/// 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>, +); + +/// A chained model whose slot or link is new this frame — it has no word yet, whatever its +/// wearer's did. +type ChainedFresh = ( + With, + Without, + Or<(Changed, Changed)>, +); + +/// One model-instance root as [`band_instances`] sees it. +type BandRoot<'a> = ( + Entity, + &'a GlobalTransform, + &'a WorldUnit, + &'a RigSkin, + Option>, +); + +/// **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, + mut commands: Commands, + mut roots: Query<( + Entity, + &GlobalTransform, + &WorldUnit, + &RigSkin, + Option<&mut ModelWaterBand>, + )>, + dirty: Query, + mut chained: Query< + (Entity, &RigSkin, &ParentModel, Option<&mut ModelWaterBand>), + Without, + >, + fresh: Query, + links: Query<(Option<&RigSkin>, Option<&ParentModel>, Has)>, + mut eye_was_submerged: Local>, +) { + 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, Option, 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>, + 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, + Has, + Option<&'static StraddleTwin>, + Option<&'static Aabb>, + Has, + Option<&'static RenderLayers>, + ), + ( + Without, + Or<(With, With)>, + ), +>; + +/// 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, + parts: StraddleParts, + mut twins: Query<( + &StraddleTwinOf, + &mut MeshTag, + &mut Mesh3d, + &mut MeshMaterial3d, + )>, +) { + 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::(); + 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=`, 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, + shared: Option>, + clips: Option>, + mut last: Local>, +) { + 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::() + .add_plugins(ExtractResourcePlugin::::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, 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); + } +} diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index cd456151..4ba16db0 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -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.