Files
benilla-octo/crates/benilla-world/src/model_render/visibility.rs
T
SamandClaude Fable 5.1 bc1a2428dd the loading screen lets nothing through, and the micro buttons go stock
- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990)
- the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987)
- hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989)
- a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:01:38 +07:00

686 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The world-model visibility apply system. Each frame it shows/hides every doodad/WMO/creature
//! submesh from the panel's layer/type toggles AND the faithful far-clip wall-cull (nearest-point of
//! the mesh AABB along the camera-forward axis, same plane as the per-pixel wall) plus the small-prop
//! distance fade. The model-`Visibility` authority, with one *ordered* override: the self-avatar
//! first-person hide (`player::apply_self_model_fade`) runs after this set ([`super::ModelVisSet`])
//! and wins on the self body submeshes. Split from the panel UI/state in `super`.
use bevy::camera::primitives::Aabb;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use super::{blend_index, kind_index, ModelKind, ModelPart};
use crate::dev_state::DebugState;
use crate::model_fade::{doodad_fade_alpha, DoodadFade};
use crate::view::ViewDistance;
use crate::view::WorldCamera;
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance};
use benilla_assets::materials::WowModelMaterial;
/// Show/hide each model submesh from the layer/type toggles **and** the faithful draw-distance cull.
/// The toggles are a dev inspector; the distance test is real fidelity — in 1.12 every doodad/WMO
/// **hard-pops** out past the `farclip` radius from the camera (no fade, no distance-LOD; the cull tests
/// each placement against the `cameraPos ± farclip` AABB). We approximate
/// that AABB with a radial distance to the placement origin, which reads the same in-frame.
///
/// Runs every frame (the camera moves, so this can't be snapshot-gated like a pure toggle), but only
/// **writes** `Visibility` when a submesh's decision actually flips — so the steady-state cost is one
/// squared-distance compare per submesh and no change-detection churn.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn apply_model_visibility(
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<
(
Ref<GlobalTransform>,
Ref<Projection>,
Option<Ref<Transform>>,
),
With<WorldCamera>,
>,
// The two edges the per-part skip below cannot read off its own row: a building streaming
// in (its portal set is new), and any owner's inherited verdict flipping (a card follows it).
// `Changed`, not `Added`: the PVS writer sets the tick only on a real change of the
// visible set, and that set moves when a building's asset lands frames after its spawn.
new_portals: Query<(), Changed<WmoPortalInstance>>,
owner_flips: Query<(), Changed<InheritedVisibility>>,
// A part whose far-side mark was REMOVED reads as never marked; the classifier's full walk
// can strip it under a still camera (review 2026-09-04), and the strip must un-skip it.
mut unmarked: RemovedComponents<super::FarSideOfWater>,
// The per-frame WMO portal PVS (computed by `crate::wmo_portal`), read here so the cull composes
// with the toggles + far-clip in this single Visibility authority rather than fighting it.
instances: Query<&WmoPortalInstance>,
// …and, for exactly the same reason, the exterior-scene window gate (decision 0784). It used to
// write `Visibility` itself in `PostUpdate`, i.e. AFTER this system — so on every object it
// admitted it silently undid the toggles, the far-clip cull and the portal PVS, including
// outdoors, where it wrote `Inherited` over all of them every single frame.
windows: Res<crate::wmo_portal::ExteriorWindows>,
// The camera's OWN building is not exterior scene to itself: the reference draws the containing
// map object through the interior portal walk, never through the per-window exterior populate
// (`[0xc7b748]` is the branch, not a participant). Without this exemption, standing inside
// Stratholme would window-cull Stratholme's own walls.
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
// The water-plane classification (`model_render::classify_water_side`) — read-only here: this
// system owns the DoodadFade handle pick, so the far-side axis composes INTO that pick (the
// marker + the twin map) instead of a second writer re-swapping the handle it just wrote —
// the exact fight decision 0025's one-authority law exists to prevent.
far_twins: Res<super::FarSideTwins>,
// Every entity that can OWN a billboard card — the model root a card belongs to, or a joint of
// its rig, both of which carry the root's propagated verdict. Read-only, and for the same
// one-authority reason as the two above: a card is a world root, so nothing carries its owner's
// hide to it, and the mirror that used to live in `billboard::face_billboards` was ordered too
// late to survive (decision 1409).
card_owners: Query<&InheritedVisibility>,
mut q: Query<(
Entity,
&ModelPart,
Ref<GlobalTransform>,
&mut Visibility,
Option<&DoodadFade>,
Option<&mut MeshTag>,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
Option<&Aabb>,
Option<Ref<WmoGroupVis>>,
Option<Ref<crate::doodad_anim::MatAnim>>,
Has<crate::exterior_cull::ExteriorScene>,
Option<Ref<super::FarSideOfWater>>,
Option<&crate::billboard::BillboardCard>,
)>,
// The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a
// `WmoGroupVis` like every other piece of their group but no `ModelPart` — they are not model
// submeshes and take none of the toggle/far-clip/fade rules above. A second QUERY, deliberately
// not a second SYSTEM: decision 0025 wants one `Visibility` authority, and this keeps it
// (decision 0689 — a culled room's water must go with the room, same as its furniture).
mut group_only: Query<
(
&WmoGroupVis,
&mut Visibility,
&GlobalTransform,
Option<&Aabb>,
Has<crate::exterior_cull::ExteriorScene>,
Option<&mut MeshTag>,
),
Without<ModelPart>,
>,
) {
let m = &debug.models;
// The (single) world Camera3d; the egui overlay is a Camera2d. None before it spawns → no distance
// cull that frame (toggles still apply).
let cam_view = cam.iter().next();
let cam_t = cam_view.as_ref().map(|(t, _, _)| t);
// Every whole-scene input of a part's verdict, still since last frame. With those still, a
// part whose own row is still too would come out of the walk below with the same
// `Visibility`, tag and material it already carries — so it skips the walk (decision 1979's
// floor: ~8.7 k resident parts re-verdicted on every still frame at the Stormwind pin).
// Both the propagated frame and the seat's own write: this system runs before propagation,
// so a teleport frame's move is only visible on the local `Transform` (see doodad_anim).
let scene_still = cam_view.as_ref().is_some_and(|(t, p, l)| {
!t.is_changed() && !p.is_changed() && !l.as_ref().is_some_and(|l| l.is_changed())
}) && !debug.is_changed()
&& !view.is_changed()
&& !windows.is_changed()
&& !claim.is_changed()
&& !far_twins.is_changed()
&& new_portals.is_empty()
&& owner_flips.is_empty();
let cam_pos = cam_t.map(|t| t.translation());
let cam_fwd = cam_t.map(|t| Vec3::from(t.forward()));
// The exterior gate, built ONCE for the whole walk (it is a handful of 6-plane frusta) and then
// asked per submesh below — the same value `crate::exterior_cull` asks for the objects it owns.
let gate = crate::exterior_cull::ExteriorGate::build(
&windows,
cam_view.as_ref().map(|(t, p, _)| (&**t, &**p)),
);
// The placement the camera is standing in, exempt from its own window gate (see the param).
let own_instance = claim.0.map(|c| c.room.instance);
// Parallel: this walks EVERY model submesh in residency — ~100k in a city — and at that N a
// serial walk alone blew half the 16.7 ms budget (the Stormwind fps hunt). Every write below
// is change-gated, so the steady state is a pure read fan-out.
let unmarked: bevy::platform::collections::HashSet<Entity> = unmarked.read().collect();
let unmarked = &unmarked;
q.par_iter_mut().for_each(
|(
entity,
part,
xf,
mut vis,
fade,
tag,
mat,
aabb,
group_vis,
mat_anim,
exterior,
far_side,
card,
)| {
if scene_still
&& !unmarked.contains(&entity)
&& !xf.is_changed()
&& !group_vis.as_ref().is_some_and(|g| g.is_changed())
&& !mat_anim.as_ref().is_some_and(|a| a.is_changed())
&& !far_side.as_ref().is_some_and(|f| f.is_changed())
{
return;
}
let far_side = far_side.is_some();
let (group_vis, mat_anim) = (group_vis.as_deref(), mat_anim.as_deref());
let toggled_on =
m.kind_visible[kind_index(part.kind)] && m.blend_visible[blend_index(part.blend)];
// `farclip` is the *world-doodad/WMO* draw distance only. Creatures/GameObjects come from the
// server's own visibility stream (already range-limited) and have separate unit-draw rules, so
// they're not culled here.
let distance_culled = matches!(part.kind, ModelKind::Doodad | ModelKind::Wmo);
let pos = xf.translation();
// Cull by the NEAREST point of the submesh's bounding sphere, not its origin: an object
// straddling the hard far-clip wall stays drawn so the per-pixel wall (terrain/wow_model.wgsl)
// DISSOLVES it through the boundary, instead of the whole thing popping when its origin crosses
// the wall (the "snaps at the centre" artifact — worst for big trees/buildings). `Aabb` is the
// mesh bound (local); transform to world + uniform placement scale. Absent for a frame after
// spawn ⇒ fall back to origin distance.
let in_range = match (distance_culled, cam_pos, cam_fwd) {
(false, _, _) | (_, None, _) | (_, _, None) => true,
(true, Some(c), Some(fwd)) => {
let (center, radius) = match aabb {
Some(a) => (
xf.transform_point(Vec3::from(a.center)),
Vec3::from(a.half_extents).length()
* xf.affine().matrix3.x_axis.length(),
),
None => (pos, 0.0),
};
// Planar depth along the camera-forward axis (the SAME coordinate the per-pixel wall
// uses) of the bound's nearest point — so the cull and the wall agree and the object
// dissolves through the boundary with no pop, even off-centre. The rule itself lives
// in `view::within_farclip`; the particle draw-set gate reads the same one, because
// an emitter outliving its own doodad past the wall is exactly what B39 reported.
crate::view::within_farclip(view.farclip, c, fwd, center, radius)
}
};
// Faithful size-bucketed per-object distance fade (`model_fade::doodad_fade_alpha`, the
// reference's `FUN_00683f80`). Only doodads/WMOs carry `DoodadFade`. Distance is measured to the
// model's bounding-sphere CENTRE (origin + the transformed bbox-centre offset), in the
// **horizontal** ground plane (Bevy XZ) — both verified against `FUN_006952a0`/`FUN_00683f80`.
let fade_alpha = match (fade, cam_pos) {
(Some(f), Some(c)) => {
let center = xf.transform_point(f.local_center);
let (dx, dz) = (center.x - c.x, center.z - c.z);
doodad_fade_alpha(f.radius, (dx * dx + dz * dz).sqrt())
}
_ => 1.0,
};
// WMO portal visibility: a group the camera can't reach through any portal is hidden — the
// faithful cull (decision 0031), computed per-frame by `crate::wmo_portal` and ANDed in here so
// it composes with the toggles + far-clip instead of a second Visibility writer. A submesh with
// no `WmoGroupVis` (every non-WMO entity, and a portal-less WMO) is never portal-culled; the
// panel's `portal_cull` switch disables it wholesale for an A/B against the old "draw every
// group" look.
let portal_visible = !m.portal_cull
|| group_vis.is_none_or(|gv| {
instances
.get(gv.instance)
.ok()
.is_none_or(|inst| gv.drawn_by(inst))
});
// …and, off the SAME per-frame flood, which fog triple this piece's batch is pushed
// with: the client's per-group `[0xca7f00]` (wow-re round-6 Q-I — the group drawer
// `0x6b5190` and the group-doodad drawer `0x6b62e0` push the interior triple only under
// it; everything else inherits `push_fog`'s scene triple). `None` for every non-WMO
// entity, which leaves `INTERIOR_FOG_BIT` to the entity classifier — a unit's fog is
// staged by the unit's OWN classification, not by the room's gate.
let room_fog = group_vis.map(|gv| {
instances
.get(gv.instance)
.is_ok_and(|inst| gv.interior_fogged_by(inst))
});
// The batch's animated material-alpha factor (decision 0130 phase 2): the sampled
// colour-alpha × transparency-weight loop, `1.0` for the untracked majority. Multiplied
// into the tag below, and into the cull here — the real client skips a batch whose
// combined alpha is ≤ 0 before even reading its blend mode (wow-re `m2-alpha-combine-cull`),
// so a flicker track at 0 hides the batch outright.
let mat_factor = mat_anim.map_or(1.0, |m| m.current);
// The exterior-scene window gate (decision 0774's law, decision 0784's placement):
// standing in a WMO interior, exterior content draws only through a portal window. ANDed
// in here rather than written by a second system, so it composes with everything above
// instead of overwriting it. Untagged content (units, GameObjects) is exempt by the
// carved law; the camera's own building is exempt because it is not exterior to itself.
let own_building = group_vis.is_some_and(|gv| Some(gv.instance) == own_instance);
let exterior_ok = !exterior || own_building || gate.admits(&xf, aabb);
// **A billboard card draws only while the model it is a batch OF draws** (decision
// 1409). A card is split out to a world root because its transform belongs to the
// billboard system, so it inherits nothing — and every hide that reaches its model
// through the scene graph (the exterior-scene election on a net root, a transport's
// off-map leg) reaches the card not at all. That is how an Orgrimmar bonfire's glow
// card went on burning over its own culled wood.
//
// The owner's `InheritedVisibility` is last propagate's — one frame of lag, the same
// accepted class as the interior law's. **Only on entity-lane cards**: a world-placement
// card is tagged `ExteriorScene` and `exterior_ok` above already gates it with the rest
// of its placement, while its owner is a joint of its own placement rig, which is never
// hidden apart from the model this system is already hiding whole.
// Fails OPEN on an unreadable owner: an owner that has gone is `face_billboards`'
// despawn case, and deciding a card's draw on absent information is how the world
// flickers.
let owner_hidden = !exterior
&& card
.and_then(crate::billboard::BillboardCard::follows)
.is_some_and(|o| matches!(card_owners.get(o), Ok(v) if !v.get()));
// `Inherited` (not `Visible`) so child submeshes still respect a hidden parent root. A fully
// faded doodad (`fade_alpha == 0`) is culled just like an out-of-range one.
let desired = if toggled_on
&& in_range
&& fade_alpha > 0.0
&& mat_factor > 0.0
&& portal_visible
&& exterior_ok
&& !owner_hidden
{
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != desired {
*vis = desired;
}
// Push the fade alpha to the shader (per-instance `MeshTag` alpha field — `wow_model.wgsl`
// multiplies the cutout alpha by it) and swap to the blend material variant while feathering,
// back to the cutout once opaque. Write only on change so steady doodads (`fade == 1.0`,
// already on the cutout material) cost nothing and don't re-batch every frame.
//
// The alpha field is written for `DoodadFade` holders (the fade composes `mat_factor` in)
// AND for every other non-unit-lane `MatAnim`: the parts that own the channel outright
// (`drives_tag` — spell-effect parts, which have no fade) and the pinned no-fade lane —
// the lit interior props. The latter used to be skipped ("the tag is a packed colour"),
// which was true before the 0355 re-lane but stale after it: the probe-slot payload
// keeps bits 0..=15 as the alpha field precisely so `with_alpha` composes with the slot.
// Skipping them dropped a batch's authored dimming constant entirely — the Undercity
// throne room's LD_lightshaft01 (weights const 0.10/0.05, the reference's near-invisible
// haze) blasted at full brightness (bug B30). Only the unit lane stays out:
// `entities::apply_unit_mat_alpha` owns that compose, ordered against the interior
// classifier and the appear-fade.
// The two tag fields this system owns, written in ONE read-modify-write so they
// compose instead of racing: the fade alpha, and the room's interior-fog bit.
if let Some(mut tag) = tag {
let mut bits = tag.0;
if fade.is_some() || mat_anim.is_some_and(|m| !m.composes_unit_tag()) {
// Glow cards render at AUTHORED brightness (decision 0159 — the dimmer knob died
// with the faithful FFXGlow pass; the square-law is what keeps halos in check).
let alpha = fade_alpha * mat_factor;
// `with_alpha` handles the `MeshTag == 0` opaque-sentinel (a *visible* glow card
// dimmed to exactly 0 must not flip to full bright); a distance-faded doodad
// (`fade_alpha == 0`) is Hidden anyway, so ≈0 bits there are equally fine.
// Conventions: `crate::mesh_tag`.
bits = crate::mesh_tag::with_alpha(bits, alpha);
}
if let Some(on) = room_fog {
bits = crate::mesh_tag::with_interior_fog(bits, on);
}
if tag.0 != bits {
tag.0 = bits;
}
}
if let Some(f) = fade {
if let Some(mut mat) = mat {
let base = if fade_alpha < 1.0 {
&f.blend
} else {
&f.cutout
};
// A feathering doodad on the eye's far side of the water plane takes the far
// twin of the SAME pick (the water-plane interleave, `sky_order::FAR_SIDE_BIAS`)
// — the classification is the marker's, the pick stays this system's. The
// cutout never composes: an opaque draw settles against the water by depth. A
// map miss = the twin isn't built yet (classify runs on transparent draws
// only); keep the base and pick it up next frame.
let want = if far_side && fade_alpha < 1.0 {
far_twins.far_of(base).unwrap_or(base)
} else {
base
};
if mat.0 != *want {
mat.0 = want.clone();
}
}
}
},
);
// The group-only audience — a building's MLIQ surfaces. Serial: a building has a handful of
// liquid surfaces, not the ~100k submeshes above — and change-gated like every write here.
//
// The gate is the **ever-visited latch**, not this frame's PVS: the client's render-record
// persistence (wow-re `wmo-record-persistence.md` @`00a766f6`) draws a visited MLIQ group's
// liquid every frame with no portal re-check for the rest of the world session — the Great
// Forge's walkway-level pool stays put when its group drops out of the flood (B65). An index
// past the latch fails OPEN (portal-less props never latch and must always draw).
for (gv, mut vis, xf, aabb, exterior, tag) in &mut group_only {
let portal_ok = !m.portal_cull
|| instances.get(gv.instance).ok().is_none_or(|inst| {
gv.groups
.iter()
.any(|&g| inst.liquid_visited.get(g as usize).copied().unwrap_or(true))
});
// …and the same exterior-window term as the submesh walk: another building's canal is
// exterior content, the canal of the building you are standing in is not.
let exterior_ok = !exterior || Some(gv.instance) == own_instance || gate.admits(xf, aabb);
// **A building's water rides the building's own toggle** — the reference's WMO liquid drain
// `0x684cd0` gates on `[0xc7b2a4] & 0x100`, the "Map objects" bit, NOT on the `0x1000000`
// "Water" bit the three ADT drains test (wow-re `terrain/scratch/liquid-window-gate.md` §5,
// decision 1657). Our `ModelKind::Wmo` toggle is that bit, and until now it hid a building
// and left its pool hanging in the air — which is also the shape a mis-placed pool takes, so
// the one instrument for telling those apart was itself producing the symptom.
//
// ADT liquid must NOT take this term and does not: it is `apply_exterior_cull`'s, which
// composes no toggles. The asymmetry is the reference's own, and it is per-*bit*, not an
// oversight to tidy up.
let toggled_on = m.kind_visible[kind_index(ModelKind::Wmo)];
let want = if portal_ok && exterior_ok && toggled_on {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != want {
*vis = want;
}
// …and the pool's fog lane, off the same per-frame flood as the room's walls (decision
// 1787). The reference's WMO liquid pass re-submits the interior fog block under the SAME
// `[0xca7f00]` as the geometry pass (wow-re `fog-env-state` §5's 6-site census), so a room
// and its water can never disagree; `liquid.wgsl` ANDs this with the surface's own static
// interior class. Note the term is the FLOOD's bit, not the ever-visited latch above: a
// pool the latch keeps drawing after its room left the PVS wears the scene fog, which is
// what the client's per-group toggle does with a group it is not currently walking.
if let Some(mut tag) = tag {
let on = instances
.get(gv.instance)
.is_ok_and(|inst| gv.interior_fogged_by(inst));
let bits = crate::mesh_tag::with_interior_fog(tag.0, on);
if tag.0 != bits {
tag.0 = bits;
}
}
}
}
/// `WOW_VIS_TRACE=<label-substring>` — **watch one model's placements decide, frame by frame.**
///
/// The `VIS_CENSUS` line counts what is drawn; it cannot answer "that thing vanished when I turned
/// — who dropped it?". Two different systems can hide a model submesh and they fail in opposite
/// directions:
///
/// - **`vis=Hidden`** — [`apply_model_visibility`] above said no: a toggle, the far-clip wall, a
/// fully-faded doodad, an `A ≤ 0` material track, the portal PVS or the exterior window gate.
/// - **`vis=Inherited inh=false`** — *this* part said yes and an **ancestor** said no. For a body
/// part that is the exterior-scene election (decision 1270), which is decided once on the net
/// root and inherits down; it is also how a transport hides its deck. Without this field the
/// line was indistinguishable from the frustum case below — the election arrived after the
/// trace did, and a body vanishing for the right reason would have read as a bound bug.
/// - **`vis=Inherited inh=true view=false`** — everything of ours admitted it and **Bevy's frustum
/// cull** dropped it, which means the entity's `Aabb` does not describe what it draws.
///
/// That second line is the whole of decisions 1259 and 1261 — an animated placement whose bound was
/// its bind pose, and then the same bound silently recomputed at the twin swap — and both were
/// diagnosed by reading code because nothing could be asked. `bound=` is printed in **world space**
/// beside the camera, so "the box is nowhere near the bird" is visible directly rather than inferred.
///
/// Off (the env var unset) this system is one `Option` check per frame and no query iteration.
/// Throttled to [`VIS_TRACE_HZ`]; matching is a case-insensitive substring of the `WorldObject`
/// label (a model path), so `WOW_VIS_TRACE=bird01` follows every bird in residency.
#[derive(Resource)]
pub struct VisTrace {
needle: String,
next_at: f32,
}
/// Trace lines per second — enough to watch a verdict flip under a camera swing, sparse enough that
/// a dozen placements don't bury the log.
const VIS_TRACE_HZ: f32 = 4.0;
impl VisTrace {
/// `None` unless `WOW_VIS_TRACE` names a substring — the whole cost of the instrument when off.
pub(crate) fn from_env() -> Option<Self> {
std::env::var("WOW_VIS_TRACE")
.ok()
.filter(|s| !s.is_empty())
.map(|needle| Self {
needle: needle.to_ascii_lowercase(),
next_at: 0.0,
})
}
}
/// What the trace reads per submesh: its identity, where it is, both verdicts, and the bound the
/// second of them tested.
type TracedModels<'w, 's> = Query<
'w,
's,
(
Entity,
&'static crate::interact::WorldObject,
&'static GlobalTransform,
&'static Visibility,
&'static InheritedVisibility,
// `Option`: a chain-only node (anim host root — `crate::vis_chain`) has no sweep row;
// it must still appear in the trace rather than silently vanish from the instrument.
Option<&'static ViewVisibility>,
Option<&'static Aabb>,
// Which lane the row is: a world-root billboard CARD inherits nothing, so `inh=true` on it
// means only "no parent", never "my model is drawn" — the one row where the second field
// must not be read as the owner's verdict (decision 1409).
Has<crate::billboard::BillboardCard>,
),
>;
/// The trace's printer — see [`VisTrace`]. Runs after the authority so `vis` is this frame's
/// verdict, and reads `ViewVisibility` (last frame's frustum result, which is the freshest a
/// same-frame reader can have) to separate our gates from the cull.
pub(super) fn trace_model_visibility(
time: Res<Time>,
trace: Option<ResMut<VisTrace>>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
q: TracedModels,
) {
let Some(mut trace) = trace else { return };
let now = time.elapsed_secs();
if now < trace.next_at {
return;
}
trace.next_at = now + 1.0 / VIS_TRACE_HZ;
let cam_t = cam.iter().next();
let (cam_pos, cam_fwd) = cam_t.map_or((Vec3::ZERO, Vec3::Z), |t| {
(t.translation(), Vec3::from(t.forward()))
});
for (e, object, xf, vis, inherited, view, aabb, card) in &q {
if !object.label.to_ascii_lowercase().contains(&trace.needle) {
continue;
}
// World-space bound: the entity transform applied to the local box, which is exactly what
// Bevy's cull tests — so a bound that has parted company with the geometry shows up here as
// a centre nowhere near what the eye sees.
let (centre, radius) = match aabb {
Some(a) => (
xf.transform_point(Vec3::from(a.center)),
Vec3::from(a.half_extents).length() * xf.affine().matrix3.x_axis.length(),
),
None => (xf.translation(), f32::NAN),
};
let depth = (centre - cam_pos).dot(cam_fwd);
println!(
"VIS_TRACE {e} {label} vis={vis:?} inh={inh} view={view} card={card} \
origin=[{ox:.1},{oy:.1},{oz:.1}] \
bound=[{cx:.1},{cy:.1},{cz:.1}] r={radius:.1} depth={depth:.1} \
cam=[{px:.1},{py:.1},{pz:.1}]",
label = object.label,
inh = inherited.get(),
// "-" = chain-only node, no sweep row (never drawn, so no per-view verdict exists).
view = view.map_or("-".into(), |v| v.get().to_string()),
ox = xf.translation().x,
oy = xf.translation().y,
oz = xf.translation().z,
cx = centre.x,
cy = centre.y,
cz = centre.z,
px = cam_pos.x,
py = cam_pos.y,
pz = cam_pos.z,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::billboard::BillboardCard;
use benilla_assets::BillboardInfo;
use benilla_formats::{BillboardKind, ModelBlend};
/// **A billboard card draws only while the model it is a batch of draws** (decision 1409).
///
/// A card is split out to a world root — its transform belongs to the billboard system — so
/// nothing carries its owner's hide to it. `billboard::face_billboards` used to mirror that
/// hide, and its own unit test proved it wrote `Hidden`; the write was thrown away every frame
/// because `BillboardPlace` is ordered only `before(CheckVisibility)`, so it landed after the
/// propagation that would have consumed it and this system overwrote it with `Inherited` in
/// the next `Update`. In Orgrimmar that was a bonfire's glow card burning over its own culled
/// wood. The term lives here now, where nothing runs after it.
#[test]
fn a_card_follows_its_owners_verdict() {
let mut app = App::new();
app.init_resource::<DebugState>()
.init_resource::<ViewDistance>()
.init_resource::<crate::wmo_portal::ExteriorWindows>()
.init_resource::<crate::wmo_portal::CameraInteriorClaim>()
.init_resource::<super::super::FarSideTwins>()
.add_systems(Update, apply_model_visibility);
let owner = app.world_mut().spawn(InheritedVisibility::VISIBLE).id();
let info = BillboardInfo {
bone: 0,
pivot: Vec3::ZERO,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
};
let card = app
.world_mut()
.spawn((
ModelPart {
kind: ModelKind::GameObject,
blend: ModelBlend::Blend,
},
GlobalTransform::IDENTITY,
Visibility::Inherited,
BillboardCard::following(&info, owner),
))
.id();
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"a visible owner leaves its card drawing"
);
app.world_mut()
.entity_mut(owner)
.insert(InheritedVisibility::HIDDEN);
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Hidden,
"a hidden owner takes its card with it"
);
app.world_mut()
.entity_mut(owner)
.insert(InheritedVisibility::VISIBLE);
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"a re-shown owner restores its card"
);
// …and the owner going away must NOT blank the card: that is `face_billboards`' despawn
// case, and deciding a draw on absent information is how the world flickers.
app.world_mut().entity_mut(owner).despawn();
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"an unreadable owner fails OPEN"
);
}
/// **A building's pool rides the building's own toggle** (decision 1657) — and ADT liquid must
/// not, which is the half that makes this a fidelity fact rather than a tidy-up.
///
/// The reference gates its WMO liquid drain `0x684cd0` on `[0xc7b2a4] & 0x100`, the "Map
/// objects" bit, while the three ADT drains test the separate `0x1000000` "Water" bit (wow-re
/// `terrain/scratch/liquid-window-gate.md` §5). `ModelKind::Wmo` is our "Map objects" bit, and
/// without this term switching buildings off left every canal, fountain and dungeon pool
/// hanging in mid-air — which happens to be exactly what a mis-placed pool looks like, so the
/// one instrument for telling a water bug from a wall bug was manufacturing the symptom.
#[test]
fn a_wmo_pool_rides_the_map_objects_toggle() {
let mut app = App::new();
app.init_resource::<DebugState>()
.init_resource::<ViewDistance>()
.init_resource::<crate::wmo_portal::ExteriorWindows>()
.init_resource::<crate::wmo_portal::CameraInteriorClaim>()
.init_resource::<super::super::FarSideTwins>()
.add_systems(Update, apply_model_visibility);
// A portal-less prop's pool: `WmoGroupVis` with no `ModelPart` (the `group_only` audience),
// and an instance that carries no latch, so `portal_ok` fails open and the toggle is the
// only term in play.
let instance = app.world_mut().spawn(()).id();
let pool = app
.world_mut()
.spawn((
crate::wmo_portal::WmoGroupVis {
instance,
groups: std::sync::Arc::from([0u16].as_slice()),
},
GlobalTransform::IDENTITY,
Visibility::Inherited,
))
.id();
app.update();
assert_eq!(
*app.world().entity(pool).get::<Visibility>().unwrap(),
Visibility::Inherited,
"buildings on: the canal draws"
);
app.world_mut()
.resource_mut::<DebugState>()
.models
.kind_visible[kind_index(ModelKind::Wmo)] = false;
app.update();
assert_eq!(
*app.world().entity(pool).get::<Visibility>().unwrap(),
Visibility::Hidden,
"buildings off: the water goes with the building, not on without it"
);
// The control: the toggle that is NOT the Map-objects bit must not reach a pool. Doodads
// off leaves the canal alone — a single `kind_visible` bool ANDed carelessly would take it.
app.world_mut()
.resource_mut::<DebugState>()
.models
.kind_visible[kind_index(ModelKind::Wmo)] = true;
app.world_mut()
.resource_mut::<DebugState>()
.models
.kind_visible[kind_index(ModelKind::Doodad)] = false;
app.update();
assert_eq!(
*app.world().entity(pool).get::<Visibility>().unwrap(),
Visibility::Inherited,
"the doodad toggle is a different bit and must not touch a building's water"
);
}
}