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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ⇔
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user