the lava bubbles move again, and the bonfire keeps its wood
- Blackrock Mountain's lava bubbles animate at last: their sprite's flip-book channel was baked from a sequence they never play — UV and tint loops now bake per sequence, and a placement whose sequences disagree gets its own material - a bonfire's logs were vanishing while its flame burned on: a body planted just under its own floor now claims the room above it, and the flame card and fire sound follow the body's cull instead of outliving it - a WMO prop's animation host stops being cull-tested as an object in its own right - WOW_TILE_RADIUS's doc now states its real default, 2, and why Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,10 @@ pub(super) fn spawn_menagerie(
|
||||
false,
|
||||
false, // warms the WORLD lane; the sky lane warms with its own model
|
||||
light,
|
||||
// The pipeline menagerie warms the SHARED batch material; the
|
||||
// per-placement lane (1408) builds an identical pipeline, so a clone
|
||||
// needs no row of its own here.
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -198,6 +202,10 @@ pub(super) fn spawn_menagerie(
|
||||
false,
|
||||
false, // warms the WORLD lane; the sky lane warms with its own model
|
||||
light,
|
||||
// The pipeline menagerie warms the SHARED batch material; the
|
||||
// per-placement lane (1408) builds an identical pipeline, so a clone
|
||||
// needs no row of its own here.
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -248,6 +256,7 @@ pub(super) fn spawn_menagerie(
|
||||
false,
|
||||
true, // the sky lane
|
||||
light,
|
||||
None, // the shared lane — see the note above
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -289,6 +298,7 @@ pub(super) fn spawn_menagerie(
|
||||
false,
|
||||
false, // warms the WORLD lane; the sky lane warms with its own model
|
||||
light,
|
||||
None, // the shared lane — see the note above
|
||||
);
|
||||
if let Some(m) = materials.get(&plain) {
|
||||
let mut m = m.clone();
|
||||
@@ -337,6 +347,7 @@ pub(super) fn spawn_menagerie(
|
||||
false,
|
||||
false, // warms the WORLD lane; the sky lane warms with its own model
|
||||
light,
|
||||
None, // the shared lane — see the note above
|
||||
);
|
||||
if let Some(m) = materials.get(&h) {
|
||||
let mut m = m.clone();
|
||||
@@ -735,7 +746,9 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
|
||||
welded_billboard: false,
|
||||
alpha_anim: None,
|
||||
uv_anim: None,
|
||||
uv_seq: None,
|
||||
rgb_anim: None,
|
||||
rgb_seq: None,
|
||||
wmo_batch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,9 @@ impl AssetLoader for M2ModelLoader {
|
||||
billboard: billboard_info(&sub), // glow cards / chains face the camera
|
||||
alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new),
|
||||
uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new),
|
||||
uv_seq: sub.uv_seq.clone().map(std::sync::Arc::new),
|
||||
rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new),
|
||||
rgb_seq: sub.rgb_seq.clone().map(std::sync::Arc::new),
|
||||
wmo_batch: None, // M2 batches have no MOBA section
|
||||
ground_quad: sub.ground_quad(), // flat ground-ring quads → the fx decal lane
|
||||
geometry: std::sync::Arc::new(sub),
|
||||
|
||||
@@ -123,12 +123,19 @@ pub struct ModelSubmesh {
|
||||
/// texture transform and all WMO batches. The `Arc` doubles as the material-dedup identity:
|
||||
/// every instance of a loaded model shares this allocation.
|
||||
pub uv_anim: Option<std::sync::Arc<benilla_formats::UvAnim>>,
|
||||
/// The batch's UV loop **per file sequence slot**, `Some` only where the slots disagree — the
|
||||
/// batches for which [`Self::uv_anim`]'s single loop is structurally unable to be right,
|
||||
/// because which loop applies depends on the sequence the *instance* is playing (decision
|
||||
/// 1408). Like [`Self::uv_anim`], the `Arc` doubles as a material-dedup identity.
|
||||
pub uv_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
|
||||
/// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell
|
||||
/// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped
|
||||
/// at parse (`benilla-formats`): the render side seeds the material's tint at the first key —
|
||||
/// pixel-identical to the old static bake — and the material-animating lanes tick it per
|
||||
/// frame. `None` for constant/keyless tints (the vertex bake).
|
||||
pub rgb_anim: Option<std::sync::Arc<benilla_formats::RgbAnim>>,
|
||||
/// The tint twin of [`Self::uv_seq`], on the same rule.
|
||||
pub rgb_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>>,
|
||||
/// The batch's MOBA section for WMO group batches ([`benilla_formats::WmoBatchClass`] — an
|
||||
/// interior group's per-batch lighting law: INT = unlit `tex × MOCV`, TRANS = the MOCV-alpha
|
||||
/// lit↔bake lerp, EXT = the exterior day/night law). `None` for every M2 batch.
|
||||
|
||||
@@ -192,7 +192,7 @@ struct WowLight {
|
||||
// batch's sampled UV-scroll delta (xy, added to sun_scale.zw) or tint delta (xyz, added to
|
||||
// tint.rgb). 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>, 512>,
|
||||
matanim: array<vec4<f32>, 2048>,
|
||||
palettes: array<vec4<f32>>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
|
||||
|
||||
@@ -664,7 +664,9 @@ impl AssetLoader for WmoModelLoader {
|
||||
billboard: None, // WMO geometry isn't billboarded
|
||||
alpha_anim: None, // WMO batches carry no M2 colour/weight tracks
|
||||
uv_anim: None, // …nor texture transforms
|
||||
rgb_anim: None, // …nor M2Color tints
|
||||
uv_seq: None, // …so no per-sequence set either (1408)
|
||||
rgb_anim: None,
|
||||
rgb_seq: None, // …nor M2Color tints
|
||||
wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law
|
||||
ground_quad: None, // the fx decal lane is M2-only
|
||||
geometry: std::sync::Arc::new(sub),
|
||||
|
||||
@@ -601,7 +601,13 @@ impl WorldAssets {
|
||||
/// `tile_radius` for the fog range. Inserted by [`AssetPlugin`] alongside [`WorldAssets`].
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
pub struct RenderConfig {
|
||||
/// Tiles loaded in each direction around the player (`$WOW_TILE_RADIUS`, default 1).
|
||||
/// Tiles loaded in each direction around the player (`$WOW_TILE_RADIUS`, default **2** — the
|
||||
/// 5×5 block whose *worst* case (standing on a tile line) still reaches two full tiles
|
||||
/// ≈ 1066 yd, clear of the 777 yd `farclip` wall, so terrain is resident before the wall
|
||||
/// reveals it). `1` is the perf knob: a 3×3 block, ~2.8× smaller working set, reaching only
|
||||
/// 533 yd in the worst case — the band from there to the wall has neither detailed terrain nor
|
||||
/// the WDL backdrop (whose near plane sits at `farclip − 33`), so it shows only where the
|
||||
/// zone's own fog end does not already hide it.
|
||||
pub tile_radius: u32,
|
||||
/// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8).
|
||||
pub tex_tiles: f32,
|
||||
|
||||
@@ -311,6 +311,41 @@ enum Command {
|
||||
/// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted.
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes
|
||||
/// to a **per-placement material** — the ones whose UV loop or M2Color RGB tint loop is not the
|
||||
/// same in every FILE sequence slot, so no material shared by every instance can be right for
|
||||
/// them (decision 1408). The texture-transform twin of `partslotscan`.
|
||||
///
|
||||
/// It asks the **bake**, not a copy of it: `RenderSubmesh::uv_seq` / `rgb_seq` are `Some`
|
||||
/// exactly when `SeqLoops::uniform()` refused the shared lane, so the census and the runtime
|
||||
/// cannot disagree by construction. It used to transcribe the sampler instead — it was written
|
||||
/// to SIZE this fix, before the fix existed — and once the fix landed the twin drifted from it:
|
||||
/// the transcription compared periods and value EXTENTS at 1e-3, which calls
|
||||
/// `Spells\AdrenalineRush_Cast_Base` the same loop in both slots, where the bake's own tail key
|
||||
/// differs by a full 1.0 (slot 0 ends blue, slot 1 returns to red).
|
||||
///
|
||||
/// Per (batch, channel): **SHARED** — the set is `None`, the slots agree, nothing changes for
|
||||
/// it (with the sub-count that actually animates on that lane) — and **PER-PLACEMENT**, the set
|
||||
/// is `Some`. The latter splits by WHY the slots disagree, which is the question of whether
|
||||
/// `uniform()`'s exact float equality is *earning* the routing it does: **DEAD-0** (slot 0
|
||||
/// bakes nothing while a later slot animates — the B98 shape, `BlackrockStatueLavaBubble.m2`
|
||||
/// keying its whole UV flipbook inside file slot 1, a 50/50 variation of animation id 0, so the
|
||||
/// slot-0 bake returns `None` and every placement is frozen for ever), **WRAP-ONLY** and
|
||||
/// **KEYS-EPSILON** — the over-application buckets, where the slots hold the same authored loop
|
||||
/// and are split only by the wrap flag or by sub-epsilon noise in a rebased key — and
|
||||
/// **REAL-DIFFERS** (genuinely different loops, or slot 0 alive against a dead later slot).
|
||||
/// Over the shipped corpus both over-application buckets are **empty**, so nothing is on the
|
||||
/// per-placement lane that a coarser comparison would have left off it.
|
||||
///
|
||||
/// Each bucket closes with its content family and a named worst-hit tail (batches, file
|
||||
/// sequence slots). `World\` is the only family the per-placement lane reaches at all — a
|
||||
/// placed doodad or WMO prop; `Creature\`/`Spells\` and the `World\Goober\` GameObject
|
||||
/// displays resolve their own sequence through the entity lane — so a bucket living outside it
|
||||
/// costs nothing whatever it says.
|
||||
Uvslotscan {
|
||||
/// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted.
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is
|
||||
/// authored **entirely outside the bone tracks**: sequences exist, per-sequence consumers exist
|
||||
/// (particle emitters, ribbons, material alpha/colour, UV transforms), and not one sequence
|
||||
@@ -692,6 +727,7 @@ fn main() -> Result<()> {
|
||||
Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?,
|
||||
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
|
||||
Command::Partslotscan { prefix } => scan::partslotscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Uvslotscan { prefix } => scan::uvslotscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Seqclockscan { prefix } => scan::seqclockscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Uvwrapscan { prefix } => scan::uvwrapscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Envmapscan { prefix } => scan::envmapscan(&mut chain, prefix.as_deref())?,
|
||||
|
||||
@@ -156,46 +156,6 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Capitalize an `Item\ObjectComponents\<sub>` path component for consistent family-key display
|
||||
/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/
|
||||
/// `Weapon` all collapse to `Weapon`).
|
||||
fn title_case(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level content-family bucket for an internal M2 path — `m2lightscan`'s summary
|
||||
/// dimension for "how much content is affected, and of what kind". Derived from the path's first
|
||||
/// one or two components, case-insensitively. `World\Goober\` (GameObject displays) splits from
|
||||
/// plain `World\` (ADT-placed doodads / WMO props) because only the latter is on benilla's
|
||||
/// current M2-light spawn path (`crate::terrain_stream::spawn::fx::spawn_lights_for`); everything
|
||||
/// not otherwise named folds into `other`.
|
||||
fn family_of(name: &str) -> String {
|
||||
let comps: Vec<&str> = name.split('\\').collect();
|
||||
let low = |s: &str| s.to_ascii_lowercase();
|
||||
match comps.first().map(|s| low(s)).as_deref() {
|
||||
Some("creature") => "Creature\\".to_string(),
|
||||
Some("character") => "Character\\".to_string(),
|
||||
Some("spells") => "Spells\\".to_string(),
|
||||
Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => {
|
||||
match comps.get(2) {
|
||||
Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)),
|
||||
None => "Item\\ObjectComponents\\".to_string(),
|
||||
}
|
||||
}
|
||||
Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => {
|
||||
"World\\Goober\\".to_string()
|
||||
}
|
||||
Some("world") => "World\\".to_string(),
|
||||
_ => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many rows of the closing colour tally print (the rest are counted, never silently dropped).
|
||||
const TALLY_ROWS: usize = 20;
|
||||
|
||||
@@ -230,8 +190,8 @@ struct FamilyStats {
|
||||
/// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end,
|
||||
/// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility
|
||||
/// key) that keeps a light dark (§9.4). The closing summary is the real deliverable: totals, a
|
||||
/// breakdown by top-level content family ([`family_of`]) — benilla only spawns these lights for
|
||||
/// ADT-placed doodads and WMO props today, so this answers how much of the entity path
|
||||
/// breakdown by top-level content family ([`super::family_of`]) — benilla only spawns these
|
||||
/// lights for ADT-placed doodads and WMO props today, so this answers how much of the entity path
|
||||
/// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse
|
||||
/// colour×intensity tally ([`hue_tag`]).
|
||||
pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
@@ -265,7 +225,7 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
total_point += point_count;
|
||||
total_dark += dark_count;
|
||||
|
||||
let fam = families.entry(family_of(&name)).or_default();
|
||||
let fam = families.entry(super::family_of(&name)).or_default();
|
||||
fam.models += 1;
|
||||
fam.point_lights += point_count;
|
||||
fam.dark += dark_count;
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
//!
|
||||
//! Blend modes (`blendscan`), per-sequence batch visibility through the alpha combine
|
||||
//! (`alphascan`), sampler address modes and the UVs that need them (`uvwrapscan`, `texmodescan`),
|
||||
//! and generated-texcoord environment stages (`envmapscan`).
|
||||
//! generated-texcoord environment stages (`envmapscan`), and the batches whose animated
|
||||
//! texture-transform / tint loop is not the same in every sequence slot, which the bake routes to
|
||||
//! a per-placement material (`uvslotscan`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use benilla_formats::Chain;
|
||||
use benilla_formats::{Chain, KeyAnim, SeqLoops};
|
||||
|
||||
/// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors
|
||||
/// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per
|
||||
@@ -396,3 +398,490 @@ pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The two per-batch channels the bake resolves **per file sequence slot** — the pair
|
||||
/// `models::m2_batches` hands the runtime as `uv_seq` / `rgb_seq`. Both are `C3Vector` tracks, so
|
||||
/// one classifier serves both; only how many components survive the bake differs.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Channel {
|
||||
/// The texture transform's **translation** track (`tex_anim::bake_uv_seqs`), read off
|
||||
/// `RenderSubmesh::uv_seq` and its shared-lane twin `uv_anim`.
|
||||
Uv,
|
||||
/// The M2Color **RGB** tint track (`mat_anim::bake_rgb_seqs`), read off `rgb_seq` / `rgb_anim`.
|
||||
Rgb,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Uv => "UV ",
|
||||
Self::Rgb => "RGB",
|
||||
}
|
||||
}
|
||||
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Uv => "UV (texture-transform translation)",
|
||||
Self::Rgb => "RGB (M2Color tint)",
|
||||
}
|
||||
}
|
||||
|
||||
/// The field name the report quotes, so a reader can grep the struct the numbers came from.
|
||||
fn field(self) -> &'static str {
|
||||
match self {
|
||||
Self::Uv => "uv_seq",
|
||||
Self::Rgb => "rgb_seq",
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis names for the value-range cells — one per component the bake keeps. The UV pair stays
|
||||
/// `x`/`y`: the bake carries the track's raw components and how the reference maps them onto
|
||||
/// U/V is still under RE (`tex_anim`'s module doc).
|
||||
fn axes(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Uv => &["x", "y"],
|
||||
Self::Rgb => &["r", "g", "b"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart two baked loops may be and still be the **same authored function**. Tight on
|
||||
/// purpose: this is the noise floor of a `u32` millisecond key rebased through an `f32` divide, not
|
||||
/// an authoring tolerance — a disagreement anyone could see is orders of magnitude larger.
|
||||
const KEY_EPS: f32 = 1e-4;
|
||||
|
||||
/// Why one batch's slots disagree — the sub-classification of every batch the bake routed to the
|
||||
/// per-placement lane, i.e. of every set whose [`SeqLoops::uniform`] came back `None`.
|
||||
///
|
||||
/// The question these four answer is whether that verdict is **earned**. `uniform()` compares
|
||||
/// `KeyAnim` by `PartialEq` — exact float equality on the clock flags, the period and every key —
|
||||
/// so two slots holding the same authored loop can still be split by a single flag or by
|
||||
/// sub-epsilon noise in a rebased timestamp. `Dead0` and `RealDiffers` are real divergence, where
|
||||
/// one material per placement is the only right answer; `WrapOnly` and `KeysEpsilon` are batches a
|
||||
/// coarser comparison would have left on the shared lane, and their count IS the over-application.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Why {
|
||||
/// Slot 0 bakes nothing while a later slot bakes a loop — the B98 shape, the founding case, and
|
||||
/// the only class a slot-0-pinned bake could not render at all.
|
||||
Dead0,
|
||||
/// Every slot bakes the same loop except [`KeyAnim::wrap`]: the sequences carrying it disagree
|
||||
/// on their own loop flag, so one band clamps at its tail where another wraps. Visible only at
|
||||
/// the very end of a one-shot band — and never at all on a placed doodad, which loops.
|
||||
WrapOnly,
|
||||
/// Every slot agrees to within [`KEY_EPS`] on every number and on every flag — the slots differ
|
||||
/// only by float noise in the rebased key times, and exact `PartialEq` is the whole reason they
|
||||
/// are here.
|
||||
KeysEpsilon,
|
||||
/// Genuinely different loops: a different key count, a different interpolation or clock law,
|
||||
/// numbers apart by more than noise — or slot 0 alive against a later slot that holds still,
|
||||
/// DEAD-0's mirror.
|
||||
RealDiffers,
|
||||
}
|
||||
|
||||
impl Why {
|
||||
const ALL: [Self; 4] = [
|
||||
Self::Dead0,
|
||||
Self::WrapOnly,
|
||||
Self::KeysEpsilon,
|
||||
Self::RealDiffers,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Dead0 => "DEAD-0",
|
||||
Self::WrapOnly => "WRAP-ONLY",
|
||||
Self::KeysEpsilon => "KEYS-EPSILON",
|
||||
Self::RealDiffers => "REAL-DIFFERS",
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-line reading of the bucket, printed beside its counts.
|
||||
fn blurb(self) -> &'static str {
|
||||
match self {
|
||||
Self::Dead0 => {
|
||||
"slot 0 bakes nothing while a later slot animates — frozen for ever on the \
|
||||
shared lane, and the population the per-placement lane was built for"
|
||||
}
|
||||
Self::WrapOnly => {
|
||||
"the slots bake the same loop and disagree only on the WRAP flag — one band \
|
||||
clamps at its tail where another wraps"
|
||||
}
|
||||
Self::KeysEpsilon => {
|
||||
"the slots agree on every number to within 1e-4 — sub-epsilon noise in the \
|
||||
rebased keys, split by exact float equality"
|
||||
}
|
||||
Self::RealDiffers => {
|
||||
"genuinely different loops (or slot 0 alive against a dead later slot): no single \
|
||||
shared loop can serve every sequence"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One batch-channel's read of the bake.
|
||||
struct Verdict {
|
||||
why: Why,
|
||||
/// The largest numeric disagreement between slot 0 and any other slot. `0.0` means the slots
|
||||
/// are bit-identical — which, for a set the bake kept, means only a *flag* differs.
|
||||
delta: f32,
|
||||
/// The report line's detail cell.
|
||||
detail: String,
|
||||
}
|
||||
|
||||
/// The largest absolute disagreement between two baked loops over everything a consumer reads: the
|
||||
/// period, and every key's time and value. `None` when they are not the same **shape** at all — a
|
||||
/// different key count or a different interpolation/clock law is a different function, not noise.
|
||||
fn loop_delta<V: AsRef<[f32]>>(a: &KeyAnim<V>, b: &KeyAnim<V>) -> Option<f32> {
|
||||
if a.step != b.step || a.gseq != b.gseq || a.keys.len() != b.keys.len() {
|
||||
return None;
|
||||
}
|
||||
let mut d = (a.period - b.period).abs();
|
||||
for ((ta, va), (tb, vb)) in a.keys.iter().zip(&b.keys) {
|
||||
d = d.max((ta - tb).abs());
|
||||
for (x, y) in va.as_ref().iter().zip(vb.as_ref()) {
|
||||
d = d.max((x - y).abs());
|
||||
}
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// `[.L]` — one character per **file** sequence slot: `L` where the slot bakes a loop, `.` where
|
||||
/// its key window moves nothing. Slot 0 is leftmost, so a leading `.` reads as the B98 shape at a
|
||||
/// glance. Long tables (a creature's fifty-odd sequences) are truncated to a readable head.
|
||||
fn slot_cell<V>(slots: &[Option<KeyAnim<V>>]) -> String {
|
||||
const HEAD: usize = 28;
|
||||
let bits: String = slots
|
||||
.iter()
|
||||
.map(|s| if s.is_some() { 'L' } else { '.' })
|
||||
.collect();
|
||||
let live = slots.iter().filter(|s| s.is_some()).count();
|
||||
if bits.len() <= HEAD {
|
||||
format!("[{bits}]")
|
||||
} else {
|
||||
format!("[{}…] {live}/{} live", &bits[..HEAD], slots.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// `[Wc.]` — the per-slot clock law, the other half of what `PartialEq` compares: `W` wrap, `c`
|
||||
/// clamp, `.` dead. The whole content of a WRAP-ONLY row.
|
||||
fn wrap_cell<V>(slots: &[Option<KeyAnim<V>>]) -> String {
|
||||
slots
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
Some(l) if l.wrap => 'W',
|
||||
Some(_) => 'c',
|
||||
None => '.',
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// `1.500s wrap 28k x[+0.000..+0.938] y[…]` — one baked loop as its consumer sees it: the clock,
|
||||
/// the size, and the range it sweeps on every component the bake keeps.
|
||||
fn loop_cell<V: AsRef<[f32]>>(l: &KeyAnim<V>, ch: Channel) -> String {
|
||||
let range = ch
|
||||
.axes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(c, axis)| {
|
||||
let (lo, hi) = l
|
||||
.keys
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(lo, hi), (_, v)| {
|
||||
let x = v.as_ref()[c];
|
||||
(lo.min(x), hi.max(x))
|
||||
});
|
||||
format!("{axis}[{lo:+.3}..{hi:+.3}]")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!(
|
||||
"{:>6.3}s {} {:>3}k {range}",
|
||||
l.period,
|
||||
if l.wrap { "wrap " } else { "clamp" },
|
||||
l.keys.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read one batch-channel's per-slot set exactly as the runtime does — [`SeqLoops::slots`], the
|
||||
/// same values [`SeqLoops::uniform`] compared — and say why they disagree.
|
||||
fn classify<V: AsRef<[f32]> + PartialEq>(set: &SeqLoops<V>, ch: Channel) -> Verdict {
|
||||
let slots = set.slots();
|
||||
let cell = slot_cell(slots);
|
||||
// A set exists only when SOME slot bakes, so a dead slot 0 is by construction a dead slot 0
|
||||
// beside a live later one — no second test needed.
|
||||
let Some(base) = slots.first().and_then(Option::as_ref) else {
|
||||
let (s, live) = slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(s, l)| Some((s, l.as_ref()?)))
|
||||
.expect("a set exists only when some slot bakes a loop");
|
||||
return Verdict {
|
||||
why: Why::Dead0,
|
||||
delta: 0.0,
|
||||
detail: format!("{cell} slot 0 dead, slot {s} {}", loop_cell(live, ch)),
|
||||
};
|
||||
};
|
||||
let (mut delta, mut wrap) = (0.0f32, false);
|
||||
for (s, slot) in slots.iter().enumerate().skip(1) {
|
||||
let Some(l) = slot.as_ref() else {
|
||||
return Verdict {
|
||||
why: Why::RealDiffers,
|
||||
delta,
|
||||
detail: format!("{cell} slot 0 {} vs slot {s} dead", loop_cell(base, ch),),
|
||||
};
|
||||
};
|
||||
// Name WHAT diverges, not merely that something did: a value-range cell hides a timing
|
||||
// difference (same extremes, different key times) behind identical-looking brackets, and
|
||||
// that is exactly the ambiguity this scan exists to remove.
|
||||
let reason = match loop_delta(base, l) {
|
||||
Some(d) if d <= KEY_EPS => {
|
||||
delta = delta.max(d);
|
||||
wrap |= l.wrap != base.wrap;
|
||||
continue;
|
||||
}
|
||||
Some(d) => format!("keys apart by {d:.1e}"),
|
||||
None => "a different key count or clock law".to_string(),
|
||||
};
|
||||
return Verdict {
|
||||
why: Why::RealDiffers,
|
||||
delta,
|
||||
detail: format!(
|
||||
"{cell} slot 0 {} vs slot {s} {} — {reason}",
|
||||
loop_cell(base, ch),
|
||||
loop_cell(l, ch),
|
||||
),
|
||||
};
|
||||
}
|
||||
let why = if wrap {
|
||||
Why::WrapOnly
|
||||
} else {
|
||||
Why::KeysEpsilon
|
||||
};
|
||||
Verdict {
|
||||
why,
|
||||
delta,
|
||||
detail: format!(
|
||||
"{cell} {} clock {} delta {delta:.1e}",
|
||||
loop_cell(base, ch),
|
||||
wrap_cell(slots),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel corpus counters. Model counts are per bucket and count a model once if **any** of
|
||||
/// its batches lands there, so they overlap rather than partition.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
/// The set is `None`: every slot bakes the same loop, so the batch keeps the shared lane.
|
||||
shared_b: u32,
|
||||
/// …and of those, the ones that actually animate there (`uv_anim`/`rgb_anim` with `period > 0`).
|
||||
shared_live_b: u32,
|
||||
shared_live_m: u32,
|
||||
/// The set is `Some` — batches and models routed to the per-placement lane, by [`Why`].
|
||||
per_b: [u32; 4],
|
||||
per_m: [u32; 4],
|
||||
/// Models with at least one per-placement batch on this channel, whatever the reason.
|
||||
per_m_any: u32,
|
||||
/// WRAP-ONLY batches whose slots are **bit-identical** apart from the flag; the rest carry
|
||||
/// sub-epsilon key noise on top of it.
|
||||
wrap_exact: u32,
|
||||
}
|
||||
|
||||
/// One model's per-placement batches in one (channel, bucket) — the row the family census and the
|
||||
/// worst-hit tail are both built from.
|
||||
struct Hit {
|
||||
name: String,
|
||||
ch: Channel,
|
||||
why: Why,
|
||||
batches: u32,
|
||||
/// The model's **file** sequence slot count, straight off the set the bake built.
|
||||
slots: usize,
|
||||
}
|
||||
|
||||
/// How many rows of the worst-hit tail print (the rest are counted, never silently dropped).
|
||||
const TAIL_ROWS: usize = 25;
|
||||
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes to a
|
||||
/// **per-placement material** because their UV / tint loop is not the same in every file sequence
|
||||
/// slot — see the `Uvslotscan` command doc for the why.
|
||||
///
|
||||
/// It reads the bake's own verdict (`RenderSubmesh::uv_seq` / `rgb_seq`, `Some` exactly when
|
||||
/// [`SeqLoops::uniform`] refused the shared lane) rather than re-deriving it: this scan used to
|
||||
/// carry a transcribed sampler because the fix it was sizing did not exist yet, and once it did the
|
||||
/// twin drifted from it — the very failure `idleslotscan`'s rule names ("beside the parse, so the
|
||||
/// census asks the renderer's own question instead of a hand-copied twin that can drift from it").
|
||||
/// Every number below is therefore what the runtime sees, and the four PER-PLACEMENT buckets say
|
||||
/// how much of it `uniform()`'s exact float equality is *earning*.
|
||||
pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
let names = super::m2_names(chain, prefix)?;
|
||||
let (mut scanned, mut batches) = (0u32, 0u64);
|
||||
let mut tally: [Tally; 2] = Default::default();
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
|
||||
for name in names {
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
|
||||
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else {
|
||||
continue;
|
||||
};
|
||||
scanned += 1;
|
||||
batches += subs.len() as u64;
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
// This model's own contribution, folded into the corpus tally once per channel below:
|
||||
// (shared, shared-and-live) batch counts, per-bucket batch counts, and the file sequence
|
||||
// slot count — which only a batch carrying a set can report, because the set IS the table.
|
||||
let mut shared = [(0u32, 0u32); 2];
|
||||
let mut per = [[0u32; 4]; 2];
|
||||
let mut slots = [0usize; 2];
|
||||
for (bi, sub) in subs.iter().enumerate() {
|
||||
for ch in [Channel::Uv, Channel::Rgb] {
|
||||
let (verdict, live, n) = match ch {
|
||||
Channel::Uv => (
|
||||
sub.uv_seq.as_ref().map(|s| classify(s, ch)),
|
||||
sub.uv_anim.as_ref().is_some_and(|a| a.period > 0.0),
|
||||
sub.uv_seq.as_ref().map_or(0, |s| s.slots().len()),
|
||||
),
|
||||
Channel::Rgb => (
|
||||
sub.rgb_seq.as_ref().map(|s| classify(s, ch)),
|
||||
sub.rgb_anim.as_ref().is_some_and(|a| a.period > 0.0),
|
||||
sub.rgb_seq.as_ref().map_or(0, |s| s.slots().len()),
|
||||
),
|
||||
};
|
||||
let c = ch as usize;
|
||||
let Some(v) = verdict else {
|
||||
shared[c].0 += 1;
|
||||
shared[c].1 += u32::from(live);
|
||||
continue;
|
||||
};
|
||||
per[c][v.why as usize] += 1;
|
||||
slots[c] = n;
|
||||
if v.why == Why::WrapOnly && v.delta == 0.0 {
|
||||
tally[c].wrap_exact += 1;
|
||||
}
|
||||
lines.push(format!(
|
||||
" {} batch {bi:>3} {:<12} {}",
|
||||
ch.label(),
|
||||
v.why.label(),
|
||||
v.detail
|
||||
));
|
||||
}
|
||||
}
|
||||
for ch in [Channel::Uv, Channel::Rgb] {
|
||||
let c = ch as usize;
|
||||
let t = &mut tally[c];
|
||||
t.shared_b += shared[c].0;
|
||||
t.shared_live_b += shared[c].1;
|
||||
t.shared_live_m += u32::from(shared[c].1 > 0);
|
||||
t.per_m_any += u32::from(per[c].iter().any(|&n| n > 0));
|
||||
for w in Why::ALL {
|
||||
let n = per[c][w as usize];
|
||||
if n > 0 {
|
||||
t.per_b[w as usize] += n;
|
||||
t.per_m[w as usize] += 1;
|
||||
hits.push(Hit {
|
||||
name: name.clone(),
|
||||
ch,
|
||||
why: w,
|
||||
batches: n,
|
||||
slots: slots[c],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if !lines.is_empty() {
|
||||
println!(
|
||||
"{name} ({} file sequence slots)",
|
||||
slots.iter().copied().max().unwrap_or(0)
|
||||
);
|
||||
for l in &lines {
|
||||
println!("{l}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("=== summary === {scanned} model(s) parsed, {batches} render batch(es)");
|
||||
for ch in [Channel::Uv, Channel::Rgb] {
|
||||
let t = &tally[ch as usize];
|
||||
let per_total: u32 = t.per_b.iter().sum();
|
||||
println!();
|
||||
println!(
|
||||
"=== {} === {} batch-channel(s)",
|
||||
ch.title(),
|
||||
u64::from(t.shared_b) + u64::from(per_total)
|
||||
);
|
||||
println!(
|
||||
" SHARED {:>7} batch(es) — `{}` is None: every slot bakes the same loop, so \
|
||||
the batch keeps the per-material lane it has always had",
|
||||
t.shared_b,
|
||||
ch.field()
|
||||
);
|
||||
println!(
|
||||
" of those {:>7} batch(es) / {:>4} model(s) carry a LIVE loop there \
|
||||
(period > 0); the rest animate nothing at all",
|
||||
t.shared_live_b, t.shared_live_m
|
||||
);
|
||||
println!(
|
||||
" PER-PLACEMENT {:>7} batch(es) / {:>4} model(s) — `{}` is Some: `uniform()` \
|
||||
refused the shared lane, so the world streamer builds this batch a material per \
|
||||
placement",
|
||||
per_total,
|
||||
t.per_m_any,
|
||||
ch.field()
|
||||
);
|
||||
for w in Why::ALL {
|
||||
println!(
|
||||
" {:<13} {:>5} batch(es) / {:>4} model(s) — {}",
|
||||
w.label(),
|
||||
t.per_b[w as usize],
|
||||
t.per_m[w as usize],
|
||||
w.blurb()
|
||||
);
|
||||
}
|
||||
if t.per_b[Why::WrapOnly as usize] > 0 {
|
||||
println!(
|
||||
" ({} of the WRAP-ONLY batches are bit-identical apart from the flag; the \
|
||||
rest carry sub-epsilon key noise on top of it)",
|
||||
t.wrap_exact
|
||||
);
|
||||
}
|
||||
// Where each bucket's population LIVES, and which models carry most of it. `World\` is a
|
||||
// placed doodad or WMO prop — the only lane the per-placement material reaches — so a
|
||||
// bucket that is mostly `Creature\`/`Spells\` costs nothing today whatever it says.
|
||||
for w in Why::ALL {
|
||||
let rows: Vec<&Hit> = hits
|
||||
.iter()
|
||||
.filter(|h| h.ch == ch && h.why == w)
|
||||
.collect::<Vec<_>>();
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
println!();
|
||||
println!(" --- {} --- {} model(s)", w.label(), rows.len());
|
||||
let mut fams: BTreeMap<String, (u32, u32)> = BTreeMap::new();
|
||||
for h in &rows {
|
||||
let e = fams.entry(super::family_of(&h.name)).or_default();
|
||||
e.0 += 1;
|
||||
e.1 += h.batches;
|
||||
}
|
||||
for (fam, (m, b)) in &fams {
|
||||
println!(" {fam:<28} {m:>4} model(s) {b:>5} batch(es)");
|
||||
}
|
||||
let mut tail = rows;
|
||||
tail.sort_by(|a, b| b.batches.cmp(&a.batches).then_with(|| a.name.cmp(&b.name)));
|
||||
for h in tail.iter().take(TAIL_ROWS) {
|
||||
println!(
|
||||
" {:>4} batch(es) {:>3} slots {}",
|
||||
h.batches, h.slots, h.name
|
||||
);
|
||||
}
|
||||
if let Some(rest) = tail.len().checked_sub(TAIL_ROWS).filter(|n| *n > 0) {
|
||||
println!(" … and {rest} more (top {TAIL_ROWS} shown)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! - [`world`] — placed content: a WMO root's own tables, an ADT block's placements.
|
||||
//! - [`lighting`] — what lights a model: the WMO prop lanes, M2 light blocks, terrain shadow.
|
||||
//! - [`geometry`] — what geometry a model draws: billboards, geosets, flat ground quads.
|
||||
//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages.
|
||||
//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages,
|
||||
//! the batches whose UV/tint loop differs between sequence slots.
|
||||
//! - [`particles`] — particle and ribbon emitters, and the features the corpus authors.
|
||||
//! - [`skeleton`] — the bone tree and the attachment table that addresses it.
|
||||
//! - [`sequence`] — which sequence an arm plays, and what breaks when it is the wrong one.
|
||||
@@ -29,7 +30,7 @@ mod world;
|
||||
|
||||
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan, normalscan};
|
||||
pub use lighting::{darkpropscan, m2lightscan, shadeat};
|
||||
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvwrapscan};
|
||||
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uvwrapscan};
|
||||
pub use particles::{
|
||||
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
|
||||
};
|
||||
@@ -87,3 +88,48 @@ fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<String>> {
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Capitalize an `Item\ObjectComponents\<sub>` path component for consistent family-key display
|
||||
/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/
|
||||
/// `Weapon` all collapse to `Weapon`).
|
||||
fn title_case(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level content-family bucket for an internal M2 path — the summary dimension for "how
|
||||
/// much content is affected, and of what KIND", shared by [`lighting::m2lightscan`] and
|
||||
/// [`material::uvslotscan`]. Derived from the path's first one or two components,
|
||||
/// case-insensitively.
|
||||
///
|
||||
/// The split that earns its keep is `World\` versus everything else: a `World\` model is a placed
|
||||
/// doodad or WMO prop, which reaches the screen through the shared-material lanes (the M2-light
|
||||
/// spawn, the `UvAnimMaterials`/`MatAnimTable` registries), while a `Creature\`/`Spells\` one
|
||||
/// reaches an entirely different consumer. `World\Goober\` (GameObject displays) splits off again
|
||||
/// because it is the hosted lane, not the ADT/WMO one; everything not otherwise named folds into
|
||||
/// `other`.
|
||||
pub(super) fn family_of(name: &str) -> String {
|
||||
let comps: Vec<&str> = name.split('\\').collect();
|
||||
let low = |s: &str| s.to_ascii_lowercase();
|
||||
match comps.first().map(|s| low(s)).as_deref() {
|
||||
Some("creature") => "Creature\\".to_string(),
|
||||
Some("character") => "Character\\".to_string(),
|
||||
Some("spells") => "Spells\\".to_string(),
|
||||
Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => {
|
||||
match comps.get(2) {
|
||||
Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)),
|
||||
None => "Item\\ObjectComponents\\".to_string(),
|
||||
}
|
||||
}
|
||||
Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => {
|
||||
"World\\Goober\\".to_string()
|
||||
}
|
||||
Some("world") => "World\\".to_string(),
|
||||
_ => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,9 +200,9 @@ pub use models::{
|
||||
wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes,
|
||||
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
|
||||
BoneSpin, CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
|
||||
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
|
||||
M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim,
|
||||
RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim,
|
||||
GlobalSeqBone, GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds,
|
||||
M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim,
|
||||
RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, UvAnim,
|
||||
WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight,
|
||||
WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS,
|
||||
};
|
||||
|
||||
@@ -219,6 +219,68 @@ pub(super) fn sample_window<V: Lerp>(
|
||||
/// `gseq_durations` is the model's global-sequence table (ms); `seq` the sequence to bake for
|
||||
/// (see [`SeqSlot`]). A `gseq`-tagged track ignores `seq` entirely — it runs on the global
|
||||
/// sequence's own free clock, the same loop in every animation.
|
||||
/// One baked loop **per file sequence slot** — the shape a channel needs when its track is keyed
|
||||
/// differently in different sequences, which the reference reads as a matter of course (it samples
|
||||
/// the *playing* sequence's own key window every frame, `ranges[seqSlot]`; wow-re `eval.md` FN1
|
||||
/// `0x713d50`) and benilla's UV/tint lanes did not.
|
||||
///
|
||||
/// Its whole reason to exist is [`Self::uniform`]. The UV and tint loops are consumed through a
|
||||
/// registry keyed by MATERIAL, which is shared by every instance of a batch and so has no sequence
|
||||
/// to key on — a design that is exactly right while every slot bakes the same loop, and silently
|
||||
/// wrong when they don't. `uniform()` is the question "may this batch keep the shared lane?", asked
|
||||
/// once at bake time instead of assumed: `Some` ⇒ yes, and the consumer carries one loop as before;
|
||||
/// `None` ⇒ the batch's animation depends on which sequence its instance is playing, so it needs a
|
||||
/// per-instance consumer (decision 1408).
|
||||
///
|
||||
/// The corpus population is small and real (`benilla-extract uvslotscan`): 22 of the 32 multi-slot
|
||||
/// UV batch-channels ship a **dead slot 0** beside a live later slot — the BRM lava bubbles keying
|
||||
/// their whole flipbook inside the 50 %-weighted variation 1, bug B98.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SeqLoops<V> {
|
||||
/// One entry per **file** sequence slot, `None` where that slot's key window moves nothing.
|
||||
per_seq: Vec<Option<KeyAnim<V>>>,
|
||||
}
|
||||
|
||||
impl<V: PartialEq> SeqLoops<V> {
|
||||
/// Build from one entry per file sequence slot; `None` when no slot animates at all (the
|
||||
/// overwhelming majority — the caller then carries no set).
|
||||
pub fn new(per_seq: Vec<Option<KeyAnim<V>>>) -> Option<Self> {
|
||||
per_seq
|
||||
.iter()
|
||||
.any(Option::is_some)
|
||||
.then_some(Self { per_seq })
|
||||
}
|
||||
|
||||
/// The one loop every slot bakes to, or `None` when the slots disagree — including the
|
||||
/// asymmetric case a dead slot 0 makes (`None` in one slot, a loop in another), which is the
|
||||
/// whole population this type was added for.
|
||||
pub fn uniform(&self) -> Option<&KeyAnim<V>> {
|
||||
let first = self.per_seq.first()?;
|
||||
self.per_seq
|
||||
.iter()
|
||||
.all(|s| s == first)
|
||||
.then_some(first.as_ref())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// This batch's loop in sequence slot `seq` — slot 0 when unknown or out of range, the same
|
||||
/// degrade [`super::mat_anim::AlphaAnim::seq`] makes and for the same reason (a consumer with
|
||||
/// no live sequence is one that arms slot 0 and never leaves it).
|
||||
pub fn seq(&self, seq: Option<usize>) -> Option<&KeyAnim<V>> {
|
||||
seq.and_then(|i| self.per_seq.get(i))
|
||||
.or_else(|| self.per_seq.first())?
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Every slot's loop, file order — the census instrument's read
|
||||
/// (`benilla-extract uvslotscan`), which asks this type rather than a hand-rolled twin of the
|
||||
/// sampler (the `idleslotscan` rule: an instrument that re-implements the gate can drift from
|
||||
/// it).
|
||||
pub fn slots(&self) -> &[Option<KeyAnim<V>>] {
|
||||
&self.per_seq
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
|
||||
track: &M2Track<T>,
|
||||
gseq_durations: &[u32],
|
||||
@@ -313,3 +375,70 @@ pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
|
||||
keys: baked,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn loop_of(period: f32, keys: &[(f32, f32)]) -> KeyAnim<f32> {
|
||||
KeyAnim {
|
||||
period,
|
||||
step: false,
|
||||
wrap: true,
|
||||
gseq: false,
|
||||
keys: keys.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole point of the type: **an asymmetric pair is not uniform.** A dead slot 0 beside a
|
||||
/// live slot 1 is the shape 22 of the corpus's 32 multi-slot UV batch-channels ship, and the
|
||||
/// shared-material lane must be refused for it — a `uniform()` that only compared the *present*
|
||||
/// loops would wave the BRM lava bubbles straight back through (bug B98).
|
||||
#[test]
|
||||
fn a_dead_slot_zero_beside_a_live_slot_is_not_uniform() {
|
||||
let set = SeqLoops::new(vec![None, Some(loop_of(3.3, &[(0.0, 0.0), (1.0, 0.6)]))])
|
||||
.expect("one slot animates");
|
||||
assert!(set.uniform().is_none());
|
||||
assert!(set.seq(Some(0)).is_none(), "slot 0 is the dead hold");
|
||||
assert!(set.seq(Some(1)).is_some(), "slot 1 is the animation");
|
||||
}
|
||||
|
||||
/// The population the shared lane was built for, and keeps: every slot the same loop. `uniform`
|
||||
/// hands back that loop, so the caller carries one and nothing changes.
|
||||
#[test]
|
||||
fn matching_slots_collapse_to_the_one_shared_loop() {
|
||||
let l = loop_of(1.0, &[(0.0, 0.0), (1.0, 1.0)]);
|
||||
let set = SeqLoops::new(vec![Some(l.clone()), Some(l.clone())]).expect("animates");
|
||||
assert_eq!(set.uniform(), Some(&l));
|
||||
}
|
||||
|
||||
/// Two loops that differ are refused even though both are live — the DIFFERS class, where the
|
||||
/// slot-0 pin renders a real but *wrong* animation rather than a frozen one
|
||||
/// (`Deterrence_State_Base` tints red→blue in Stand and green→red in Hold).
|
||||
#[test]
|
||||
fn differing_live_slots_are_not_uniform_either() {
|
||||
let set = SeqLoops::new(vec![
|
||||
Some(loop_of(1.0, &[(0.0, 0.0), (1.0, 1.0)])),
|
||||
Some(loop_of(2.0, &[(0.0, 1.0), (2.0, 0.0)])),
|
||||
])
|
||||
.expect("animates");
|
||||
assert!(set.uniform().is_none());
|
||||
}
|
||||
|
||||
/// Nothing anywhere ⇒ no set at all: the caller carries nothing, as it always has for the
|
||||
/// ~98.6 % of batches with no texture transform.
|
||||
#[test]
|
||||
fn a_set_with_no_live_slot_is_no_set() {
|
||||
assert!(SeqLoops::<f32>::new(vec![None, None]).is_none());
|
||||
}
|
||||
|
||||
/// An out-of-range or unknown sequence degrades to slot 0 — the same rule `AlphaAnim::seq`
|
||||
/// makes, so a consumer with no live sequence reads what a slot-0-arming consumer would.
|
||||
#[test]
|
||||
fn an_unknown_sequence_degrades_to_slot_zero() {
|
||||
let l = loop_of(1.0, &[(0.0, 0.0), (1.0, 1.0)]);
|
||||
let set = SeqLoops::new(vec![Some(l.clone()), None]).expect("animates");
|
||||
assert_eq!(set.seq(None), Some(&l));
|
||||
assert_eq!(set.seq(Some(9)), Some(&l));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,13 +568,22 @@ pub fn parse_m2_render_submeshes(
|
||||
// The batch's UV-animation loop (decision 0130 phase 3): batch combo → texAnimLookup →
|
||||
// translation track, on the same clocks. Carried on the submesh; the renderer applies it.
|
||||
let uv_anim = tex_anim::bake_uv_anim(model, batch.texture_transform_combo_index, seq0_slot);
|
||||
// …and the per-sequence set, carried ONLY when the slots disagree — the shared-material
|
||||
// registry cannot key on a sequence, so a batch whose loop depends on which one is playing
|
||||
// needs a per-instance consumer instead (decision 1408). `uniform()` asks that once here
|
||||
// rather than leaving it assumed; every batch whose slots agree keeps `uv_anim` alone and
|
||||
// the lane it has always taken.
|
||||
let uv_seq = tex_anim::bake_uv_seqs(model, batch.texture_transform_combo_index, &seq_slots)
|
||||
.filter(|set| set.uniform().is_none());
|
||||
// The batch's animated RGB tint (the M2Color colour track's runtime half): only a
|
||||
// time-varying track bakes — a `Some` here *replaces* the static vertex tint below (a
|
||||
// spell effect's white-hot flash cooling to red would otherwise freeze on its first key).
|
||||
let rgb_anim = model
|
||||
.color_rgb_tracks
|
||||
.get(batch.color_index as usize)
|
||||
.and_then(|t| mat_anim::bake_rgb_anim(t, &model.global_sequences, seq0_slot));
|
||||
let rgb_track = model.color_rgb_tracks.get(batch.color_index as usize);
|
||||
let rgb_anim =
|
||||
rgb_track.and_then(|t| mat_anim::bake_rgb_anim(t, &model.global_sequences, seq0_slot));
|
||||
let rgb_seq = rgb_track
|
||||
.and_then(|t| mat_anim::bake_rgb_seqs(t, &model.global_sequences, &seq_slots))
|
||||
.filter(|set| set.uniform().is_none());
|
||||
// M2 billboard bones: a batch's vertices ride billboard bones (glow cards, chains) that the
|
||||
// real client re-orients to the camera each frame — **each bone independently, about its own
|
||||
// pivot**. One batch often packs several such cards: a candelabra's candle glows share a single
|
||||
@@ -706,7 +715,9 @@ pub fn parse_m2_render_submeshes(
|
||||
}
|
||||
sub.alpha_anim = alpha_anim.clone(); // every billboard-split group shares the batch's loops
|
||||
sub.uv_anim = uv_anim.clone();
|
||||
sub.uv_seq = uv_seq.clone();
|
||||
sub.rgb_anim = rgb_anim.clone();
|
||||
sub.rgb_seq = rgb_seq.clone();
|
||||
out.push(sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use benilla_m2::{M2ScalarTrack, M2Vec3Track};
|
||||
|
||||
use super::key_anim::{bake_track, KeyAnim, SeqSlot};
|
||||
use super::key_anim::{bake_track, KeyAnim, SeqLoops, SeqSlot};
|
||||
|
||||
/// One baked scalar loop, seconds — see [`KeyAnim`]. The alpha channels' instantiation.
|
||||
pub type ScalarAnim = KeyAnim<f32>;
|
||||
@@ -159,6 +159,24 @@ pub(super) fn bake_rgb_anim(
|
||||
bake_track(track, gseq_durations, seq, |v| v, |_| true, |_| true)
|
||||
}
|
||||
|
||||
/// The per-sequence form of [`bake_rgb_anim`] — same shape, same reason as
|
||||
/// [`super::tex_anim::bake_uv_seqs`]. The tint channel is pinned to slot 0 by the same line and
|
||||
/// breaks the same way: `uvslotscan` finds `Spells\\Deterrence_State_Base.m2` tinting
|
||||
/// **red→blue in Stand and green→red in Hold**, so the slot-0 pin renders a *wrong colour* there,
|
||||
/// not merely a frozen one (decision 1408).
|
||||
pub(super) fn bake_rgb_seqs(
|
||||
track: &M2Vec3Track,
|
||||
gseq_durations: &[u32],
|
||||
slots: &[SeqSlot],
|
||||
) -> Option<SeqLoops<[f32; 3]>> {
|
||||
SeqLoops::new(
|
||||
slots
|
||||
.iter()
|
||||
.map(|&slot| bake_track(track, gseq_durations, Some(slot), |v| v, |_| true, |_| true))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -34,6 +34,7 @@ pub use anim_summary::*;
|
||||
pub use bounds::*;
|
||||
pub use collision::*;
|
||||
pub(crate) use key_anim::{bake_track, SeqSlot};
|
||||
pub use key_anim::{KeyAnim, SeqLoops};
|
||||
pub use m2_batches::*;
|
||||
pub use mat_anim::{AlphaAnim, AlphaSeq, RgbAnim, ScalarAnim};
|
||||
pub use records::*;
|
||||
@@ -102,7 +103,9 @@ fn remap_submesh(
|
||||
welded_billboard: false, // set by the M2 path from the separability gate (0839)
|
||||
alpha_anim: None, // set by the M2 path from the batch's colour/weight tracks
|
||||
uv_anim: None, // set by the M2 path from the batch's texture transform
|
||||
uv_seq: None, // …and its per-sequence set, when the slots disagree (1408)
|
||||
rgb_anim: None, // set by the M2 path from the batch's colour RGB track
|
||||
rgb_seq: None, // …ditto (1408)
|
||||
wmo_batch: None, // set by the WMO path from the MOGP batch-section counts
|
||||
env_map: false, // set by the M2 path from texture_unit_lookup[texCoordSet] > 2
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use benilla_m2::M2Model;
|
||||
|
||||
use super::key_anim::{bake_track, KeyAnim, SeqSlot};
|
||||
use super::key_anim::{bake_track, KeyAnim, SeqLoops, SeqSlot};
|
||||
|
||||
/// One baked UV-offset loop, seconds — see [`KeyAnim`]. The texture-transform translation
|
||||
/// channel's instantiation: values are the track's raw `(x, y)`.
|
||||
@@ -57,6 +57,39 @@ pub(super) fn bake_uv_anim(
|
||||
)
|
||||
}
|
||||
|
||||
/// Bake the batch's UV-offset loop **for every file sequence slot** — the per-sequence form of
|
||||
/// [`bake_uv_anim`], and the answer to the question that function's own doc used to settle by
|
||||
/// measurement ("every UV-animating model measured is a scroll that runs the same in every
|
||||
/// sequence"). `benilla-extract uvslotscan` re-ran that measurement and it is false: 22 of the 32
|
||||
/// multi-slot UV batch-channels in the 1.12 corpus have a **dead slot 0** beside a live later slot.
|
||||
///
|
||||
/// `None` when no slot animates at all. The caller keeps [`bake_uv_anim`]'s slot-0 loop for the
|
||||
/// shared-material lane and reaches for this only when [`SeqLoops::uniform`] declines (decision
|
||||
/// 1408).
|
||||
pub(super) fn bake_uv_seqs(
|
||||
model: &M2Model,
|
||||
combo_index: u16,
|
||||
slots: &[SeqSlot],
|
||||
) -> Option<SeqLoops<[f32; 2]>> {
|
||||
let ti = *model.texture_transform_lookup.get(combo_index as usize)?;
|
||||
let t = model.texture_transforms.get(ti as usize)?;
|
||||
SeqLoops::new(
|
||||
slots
|
||||
.iter()
|
||||
.map(|&slot| {
|
||||
bake_track(
|
||||
&t.translation,
|
||||
&model.global_sequences,
|
||||
Some(slot),
|
||||
|v| [v[0], v[1]],
|
||||
is_zero,
|
||||
is_zero,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! ([`BoneScaleAnim`]). No parsing lives here — just the shapes [`m2_batches`](super::m2_batches) and
|
||||
//! [`wmo`](super::wmo) both build.
|
||||
|
||||
use super::key_anim::SeqLoops;
|
||||
use super::mat_anim::{AlphaAnim, RgbAnim};
|
||||
use super::tex_anim::UvAnim;
|
||||
|
||||
@@ -479,6 +480,13 @@ pub struct RenderSubmesh {
|
||||
/// translation track baked by [`tex_anim`](super::tex_anim) — the raw `(x, y)` offset over the loop
|
||||
/// clock. `None` for the ~98.6% of models with no texture transform, and for all WMO batches.
|
||||
pub uv_anim: Option<UvAnim>,
|
||||
/// The batch's UV loop **per file sequence slot**, carried ONLY when the slots disagree — i.e.
|
||||
/// when [`Self::uv_anim`]'s single loop cannot be right for every instance, because which loop
|
||||
/// applies depends on which sequence that instance is playing (decision 1408, bug B98: the BRM
|
||||
/// lava bubbles key their whole flipbook inside the 50 %-weighted variation 1, so slot 0 — the
|
||||
/// only slot the shared-material registry can read — is a dead hold). `None` for every batch
|
||||
/// whose slots agree, which is the shared lane unchanged.
|
||||
pub uv_seq: Option<SeqLoops<[f32; 2]>>,
|
||||
/// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell
|
||||
/// effect's white-hot flash cooling to red): baked by [`mat_anim`](super::mat_anim). When
|
||||
/// `Some`, the static vertex-colour tint is **skipped** for this batch (the two would
|
||||
@@ -486,6 +494,8 @@ pub struct RenderSubmesh {
|
||||
/// key and animated where the lane runs material animation. `None` for constant/keyless tints
|
||||
/// (the static vertex bake, decision 0029) — the overwhelming majority.
|
||||
pub rgb_anim: Option<RgbAnim>,
|
||||
/// The tint twin of [`Self::uv_seq`], on the same rule and for the same reason.
|
||||
pub rgb_seq: Option<SeqLoops<[f32; 3]>>,
|
||||
/// The batch's MOBA section for WMO group batches ([`WmoBatchClass`] — TRANS / INT / EXT, the
|
||||
/// per-class lighting law of an interior group). `None` for every M2 batch.
|
||||
pub wmo_batch: Option<WmoBatchClass>,
|
||||
@@ -586,7 +596,9 @@ impl Default for RenderSubmesh {
|
||||
welded_billboard: false,
|
||||
alpha_anim: None,
|
||||
uv_anim: None,
|
||||
uv_seq: None,
|
||||
rgb_anim: None,
|
||||
rgb_seq: None,
|
||||
wmo_batch: None,
|
||||
env_map: false,
|
||||
}
|
||||
|
||||
@@ -119,7 +119,9 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() {
|
||||
welded_billboard: false,
|
||||
alpha_anim: None,
|
||||
uv_anim: None,
|
||||
uv_seq: None,
|
||||
rgb_anim: None,
|
||||
rgb_seq: None,
|
||||
wmo_batch: None,
|
||||
env_map: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The BRM lava bubbles' animation is frozen (bug B98, decision 1408) — and the asset says why in
|
||||
//! one number: **every one of this model's animated channels is keyed inside file sequence slot 1,
|
||||
//! and slot 1 is a 50 %-weighted variation of animation id 0.**
|
||||
//!
|
||||
//! `BlackrockStatueLavaBubble.m2` is placed 15 times inside `Blackrock.wmo`. It authors two
|
||||
//! sequences, both animation id 0 with frequencies 16384/16383 — so the reference re-rolls between
|
||||
//! them every play-window (wow-re `doodad-anim-host.md` §5, decision 0768) and two placements are
|
||||
//! on different slots at the same instant. Slot 0 is a dead hold: its bone-scale window is two
|
||||
//! identity keys and its texture-transform window is two zero keys. Slot 1 is the whole animation:
|
||||
//! the bubbles swell 1.0 → 2.785 while the sprite's V offset flips by 0.605.
|
||||
//!
|
||||
//! benilla baked the UV loop against slot 0 and only slot 0, through a registry keyed by MATERIAL —
|
||||
//! shared by every instance of the batch, so there is no sequence to key on. On this model that
|
||||
//! bakes to `None`: no registration, no table row, no scroll, on every placement, for ever. This
|
||||
//! file pins both halves of that — the dead slot 0 and the live slot 1 — so the per-sequence set
|
||||
//! can never silently collapse back to one loop.
|
||||
//!
|
||||
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
|
||||
|
||||
use benilla_formats::{open_chain, parse_m2_animations, parse_m2_render_submeshes};
|
||||
|
||||
const BUBBLE: &str =
|
||||
"World\\KhazModan\\Blackrock\\PassiveDoodads\\BlackrockLavaBubbles\\BlackrockStatueLavaBubble.m2";
|
||||
|
||||
#[test]
|
||||
fn the_lava_bubble_keys_its_whole_uv_flipbook_in_variation_one() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||||
let bytes = chain
|
||||
.read_file(BUBBLE)
|
||||
.expect("the bubble M2 is in the chain");
|
||||
|
||||
// 1. Two sequences, both animation id 0 — a variation chain, not a state machine. The roll is
|
||||
// per placement and per window, so no single slot can be "the" one to bake.
|
||||
let anims = parse_m2_animations(&bytes);
|
||||
assert_eq!(anims.len(), 2, "two sequences");
|
||||
assert!(
|
||||
anims.iter().all(|a| a.anim_id == 0),
|
||||
"both are animation id 0 — one variation chain"
|
||||
);
|
||||
assert!(
|
||||
anims.iter().all(|a| a.frequency > 15_000),
|
||||
"…and both are genuinely rollable (≈50/50), so placements DIVERGE: {:?}",
|
||||
anims.iter().map(|a| a.frequency).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 2. The bones say it first: slot 0 holds bind pose, slot 1 swells past 2.7×.
|
||||
let peak = |slot: usize| {
|
||||
anims[slot]
|
||||
.bones
|
||||
.iter()
|
||||
.flat_map(|b| b.scale.iter().map(|&(_, s)| s[0]))
|
||||
.fold(0.0f32, f32::max)
|
||||
};
|
||||
assert!((peak(0) - 1.0).abs() < 1e-3, "slot 0 is a rest hold");
|
||||
assert!(peak(1) > 2.5, "slot 1 is the swell (peak {})", peak(1));
|
||||
|
||||
// 3. …and the UV channel agrees, which is the half benilla renders. Every batch carries a
|
||||
// per-sequence set — the bake's own "the slots disagree, this batch cannot take the shared
|
||||
// lane" verdict — whose slot 0 is empty and whose slot 1 moves.
|
||||
let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("parse");
|
||||
assert_eq!(subs.len(), 5, "five bubbles, five batches");
|
||||
for (i, sub) in subs.iter().enumerate() {
|
||||
let set = sub
|
||||
.uv_seq
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("batch {i} carries a per-sequence UV set"));
|
||||
assert!(
|
||||
set.uniform().is_none(),
|
||||
"batch {i}: the slots disagree — that IS the verdict"
|
||||
);
|
||||
assert!(
|
||||
set.slots()[0].is_none(),
|
||||
"batch {i}: slot 0's window never moves the UVs — the dead bake"
|
||||
);
|
||||
let live = set.slots()[1]
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("batch {i}: slot 1 carries the flipbook"));
|
||||
let v_span = live
|
||||
.keys
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(lo, hi), &(_, v)| {
|
||||
(lo.min(v[1]), hi.max(v[1]))
|
||||
});
|
||||
assert!(
|
||||
v_span.1 - v_span.0 > 0.5,
|
||||
"batch {i}: the V offset flips a whole sprite row (span {:?})",
|
||||
v_span
|
||||
);
|
||||
}
|
||||
|
||||
// 4. And the pre-1408 read — the shared lane's slot-0 bake — is `None` on every batch. This is
|
||||
// the defect stated as an assertion: nothing to register, so nothing ever scrolled.
|
||||
assert!(
|
||||
subs.iter().all(|s| s.uv_anim.is_none()),
|
||||
"the slot-0 bake yields nothing on any batch — the frozen sprite B98 reported"
|
||||
);
|
||||
}
|
||||
@@ -182,6 +182,10 @@ impl BillboardCard {
|
||||
/// (the self-avatar fade; the light node's shade push in `entity_shade`) can only recognise the
|
||||
/// model's own cards by testing this against the entities it walked. `None` = a fixed terrain
|
||||
/// doodad's card, whose shade rides its material selector instead.
|
||||
///
|
||||
/// It is also the entity whose `InheritedVisibility` decides whether an entity-lane card draws
|
||||
/// at all — a world root inherits nothing, so its model's hide has to be *read*, by the one
|
||||
/// `Visibility` authority (`model_render::visibility`, decision 1409).
|
||||
pub fn follows(&self) -> Option<Entity> {
|
||||
self.follow
|
||||
}
|
||||
@@ -523,24 +527,26 @@ pub struct BillboardPlace;
|
||||
/// despawned when the owner is gone (streamed out, unequipped, died). Runs in [`BillboardPlace`]
|
||||
/// (post-propagation), so it writes `GlobalTransform` directly alongside `Transform` — cards
|
||||
/// write ABSOLUTE world transforms and live at the root/identity, so the direct write is exact.
|
||||
#[allow(clippy::type_complexity)] // the owner pose + visibility read, commented inline
|
||||
///
|
||||
/// It does NOT own a card's `Visibility` — [`crate::model_render::visibility`] does, for every
|
||||
/// card as for every other `ModelPart` (decision 0025). This system used to mirror a hidden owner
|
||||
/// here as well, and that write was **dead**: [`BillboardPlace`] is only ordered
|
||||
/// `before(CheckVisibility)`, not before `VisibilityPropagate`, so the `Hidden` it wrote landed
|
||||
/// after the propagation that would have consumed it and was overwritten in the next frame's
|
||||
/// `Update` by the authority — for a year, a GameObject's glow card went on burning over its own
|
||||
/// culled model (decision 1409).
|
||||
#[allow(clippy::type_complexity)] // the card tuple, commented inline
|
||||
pub(crate) fn face_billboards(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
cam: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
owners: Query<
|
||||
(&GlobalTransform, Option<&InheritedVisibility>),
|
||||
(Without<WorldCamera>, Without<BillboardCard>),
|
||||
>,
|
||||
owners: Query<&GlobalTransform, (Without<WorldCamera>, Without<BillboardCard>)>,
|
||||
mut cards: Query<
|
||||
(
|
||||
Entity,
|
||||
&mut BillboardCard,
|
||||
&mut Transform,
|
||||
&mut GlobalTransform,
|
||||
&mut Visibility,
|
||||
// Whether the exterior-scene cull owns this card's `Visibility` — see the mirror below.
|
||||
Has<crate::exterior_cull::ExteriorScene>,
|
||||
// Read for the trace probe below (the tag's low bits are the card's render alpha).
|
||||
Option<&bevy::mesh::MeshTag>,
|
||||
),
|
||||
@@ -554,10 +560,10 @@ pub(crate) fn face_billboards(
|
||||
// for every billboard; never a per-pivot aim).
|
||||
let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up());
|
||||
let elapsed_ms = time.elapsed().as_millis() as u32;
|
||||
for (entity, mut card, mut tf, mut global, mut visibility, gated, tag) in &mut cards {
|
||||
for (entity, mut card, mut tf, mut global, tag) in &mut cards {
|
||||
if let Some(owner) = card.follow {
|
||||
match owners.get(owner) {
|
||||
Ok((gt, vis)) => {
|
||||
Ok(gt) => {
|
||||
let pivot = card.local_pivot;
|
||||
card.re_place(gt.compute_transform(), pivot);
|
||||
// The card-provenance trace (`WOW_MOVE_TRACE`, ~2 Hz): where each following
|
||||
@@ -581,31 +587,6 @@ pub(crate) fn face_billboards(
|
||||
),
|
||||
);
|
||||
}
|
||||
// A card is visually part of its owner — mirror a HIDDEN owner, because the
|
||||
// card is a world root and inherits nothing. The live case: the sea-crossing
|
||||
// transport's off-map leg hides the boat subtree (`tick_transports`); without
|
||||
// the mirror a deck lantern's glow keeps rendering at the other continent's
|
||||
// coordinates. (The owner's inherited visibility is last propagate's — one
|
||||
// frame of lag on a minutes-long hide.)
|
||||
//
|
||||
// **Not on a card the exterior-scene cull owns** (decision 0784). One component,
|
||||
// one authority (0025): a world-placement card is tagged
|
||||
// [`crate::exterior_cull::ExteriorScene`] with the rest of its model, and both
|
||||
// systems run in the same unordered post-propagation window — so this write
|
||||
// silently undid the cull's `Hidden` and the card drew straight through a sealed
|
||||
// room's wall. Nothing is lost by standing down: such a card's owner is a joint of
|
||||
// its own placement rig, which is never hidden apart from the model the cull is
|
||||
// already hiding whole. The transport case above is an entity-lane card, untagged,
|
||||
// and still mirrors.
|
||||
if !gated {
|
||||
let want = match vis {
|
||||
Some(v) if !v.get() => Visibility::Hidden,
|
||||
_ => Visibility::Inherited,
|
||||
};
|
||||
if *visibility != want {
|
||||
*visibility = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
commands.entity(entity).despawn();
|
||||
@@ -763,27 +744,10 @@ mod tests {
|
||||
Vec3::new(5.0, 1.7, 0.0),
|
||||
"owner translation + authored pivot — not the model origin"
|
||||
);
|
||||
// The hidden-owner mirror: the card is a world root and inherits nothing, so a hidden
|
||||
// owner (the sea-crossing transport's off-map leg) must hide it explicitly — and a
|
||||
// re-shown owner must bring it back.
|
||||
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 hides its card"
|
||||
);
|
||||
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"
|
||||
);
|
||||
// The hidden-owner half of this test used to live here, asserting a `Visibility` write
|
||||
// this system made and the real schedule then threw away — a green test pinning a dead
|
||||
// write (decision 1409). The law is unchanged and now lives with the one authority that
|
||||
// can enforce it: `model_render::visibility::a_card_follows_its_owners_verdict`.
|
||||
app.world_mut().entity_mut(owner).despawn();
|
||||
app.update();
|
||||
assert!(
|
||||
|
||||
@@ -235,11 +235,43 @@ pub fn sample_mat_anim(
|
||||
#[derive(bevy::prelude::Component)]
|
||||
pub struct AnimMatPart;
|
||||
|
||||
/// **Which loop a registered material animates on** — and the whole of decision 1408.
|
||||
///
|
||||
/// A registry keyed by MATERIAL is shared by every instance of a batch, so it has no sequence to
|
||||
/// key on. That is exactly right while every sequence bakes the same loop, and structurally unable
|
||||
/// to be right when they don't: the BRM lava bubbles key their whole flipbook inside a 50 %-weighted
|
||||
/// variation, and their 15 placements re-roll independently every ~3.3 s (decision 0768), so at any
|
||||
/// instant some are on slot 0 and some on slot 1. One shared row cannot serve both.
|
||||
///
|
||||
/// So a batch whose slots disagree — 22 batch-channels across **6 models**, corpus-wide
|
||||
/// (`benilla-extract uvslotscan`) — takes a material of its own **per placement**, keyed by its
|
||||
/// anim host in [`crate::model_render::MatKey`], and this entry remembers that host so the sample
|
||||
/// rides the sequence the host is actually playing. Everything else keeps the shared material and
|
||||
/// the shared clock, untouched.
|
||||
///
|
||||
/// Per-placement materials rather than a per-instance row in the shared table: the row would have
|
||||
/// to be addressed from the shader, and every per-instance channel there is spoken for
|
||||
/// (`MeshTag`'s 32 bits are fully allocated; the instance slot is the lazily-allocated,
|
||||
/// pressure-reaped palette slot). Against a measured population of six models it buys a new GPU
|
||||
/// region and a shader branch to save a handful of draw calls on small atmospheric props, and the
|
||||
/// clones are bounded by the same distance evictor every other material has (`art_scope`,
|
||||
/// decision 0785). Revisit if the population ever grows.
|
||||
pub enum UvLoop {
|
||||
/// Every sequence bakes the same loop: the shared material, on the free-running shared clock.
|
||||
Shared(std::sync::Arc<benilla_formats::UvAnim>),
|
||||
/// The slots disagree: this material belongs to ONE placement, and the loop is whichever slot
|
||||
/// `host` is playing right now.
|
||||
PerSeq {
|
||||
seqs: std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>,
|
||||
host: Entity,
|
||||
},
|
||||
}
|
||||
|
||||
/// One registered UV-scroll material: its sampler, its table slot, and the built seed the delta
|
||||
/// rows are measured from (the material's own `sun_scale.zw`, which is never mutated again —
|
||||
/// decision 1381).
|
||||
pub struct UvAnimEntry {
|
||||
pub anim: std::sync::Arc<benilla_formats::UvAnim>,
|
||||
pub anim: UvLoop,
|
||||
pub slot: u16,
|
||||
pub seed: [f32; 2],
|
||||
}
|
||||
@@ -249,8 +281,21 @@ impl UvAnimEntry {
|
||||
/// adds it back onto `sun_scale.zw` (decision 1381's encoding). Quantized exactly as the
|
||||
/// old asset-mutating lane quantized its absolute writes, so the first live frame shows the
|
||||
/// same number the old path would have written.
|
||||
pub(crate) fn delta(&self, now: f32) -> [f32; 4] {
|
||||
let uv = self.anim.sample(now);
|
||||
///
|
||||
/// `playing` is the per-placement lane's live `(sequence slot, clip time)`, resolved by the
|
||||
/// caller from [`Self::host`]; a host that has gone (despawned mid-frame) or a sequence with no
|
||||
/// loop samples the identity, i.e. the batch sits at its built seed — the same degrade a full
|
||||
/// table gives.
|
||||
pub(crate) fn delta(&self, now: f32, gseq_now: f64, playing: Option<(usize, f32)>) -> [f32; 4] {
|
||||
let uv = match &self.anim {
|
||||
UvLoop::Shared(anim) => anim.sample(now),
|
||||
UvLoop::PerSeq { seqs, .. } => playing
|
||||
.and_then(|(seq, band_t)| {
|
||||
seqs.seq(Some(seq))
|
||||
.map(|l| l.sample(l.clock(band_t, gseq_now)))
|
||||
})
|
||||
.unwrap_or([0.0, 0.0]),
|
||||
};
|
||||
[
|
||||
benilla_assets::quantize(uv[0], 4096.0) - self.seed[0],
|
||||
benilla_assets::quantize(uv[1], 4096.0) - self.seed[1],
|
||||
@@ -260,6 +305,37 @@ impl UvAnimEntry {
|
||||
}
|
||||
}
|
||||
|
||||
impl UvAnimEntry {
|
||||
/// The placement whose sequence this entry rides, or `None` on the shared lane.
|
||||
fn host(&self) -> Option<Entity> {
|
||||
match &self.anim {
|
||||
UvLoop::Shared(_) => None,
|
||||
UvLoop::PerSeq { host, .. } => Some(*host),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TintAnimEntry {
|
||||
/// [`UvAnimEntry::host`]'s twin.
|
||||
fn host(&self) -> Option<Entity> {
|
||||
match &self.anim {
|
||||
TintLoop::Shared(_) => None,
|
||||
TintLoop::PerSeq { host, .. } => Some(*host),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The anim hosts a per-placement entry reads its sequence from — [`playing_seq`] behind a query,
|
||||
/// so [`tick_anim_materials`] can resolve one per entry without borrowing the world twice.
|
||||
pub type SeqHosts<'w, 's> = Query<'w, 's, (&'static AnimationPlayer, &'static ModelAnimations)>;
|
||||
|
||||
/// The playing sequence slot + clip time of one anim host, or `None` if the entry is on the shared
|
||||
/// lane, the host is gone, or it has no sequence at all.
|
||||
fn host_seq(hosts: &SeqHosts, host: Option<Entity>) -> Option<(usize, f32)> {
|
||||
let (player, anims) = hosts.get(host?).ok()?;
|
||||
playing_seq(player, anims)
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct UvAnimMaterials(
|
||||
pub std::collections::HashMap<
|
||||
@@ -278,7 +354,7 @@ pub fn register_uv(
|
||||
table: &mut crate::mat_anim_table::MatAnimTable,
|
||||
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
|
||||
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
|
||||
anim: &std::sync::Arc<benilla_formats::UvAnim>,
|
||||
anim: UvLoop,
|
||||
) {
|
||||
if reg.0.contains_key(&id) {
|
||||
return;
|
||||
@@ -293,14 +369,14 @@ pub fn register_uv(
|
||||
};
|
||||
let seed = [mat.extension.sun_scale.z, mat.extension.sun_scale.w];
|
||||
mat.extension.anim_slots.x = f32::from(slot);
|
||||
reg.0.insert(
|
||||
id,
|
||||
UvAnimEntry {
|
||||
anim: anim.clone(),
|
||||
slot,
|
||||
seed,
|
||||
},
|
||||
);
|
||||
if let UvLoop::PerSeq { host, .. } = &anim {
|
||||
// The breadcrumb for the lane that has no other tell: a per-placement material is invisible
|
||||
// in every count (it is one more material, one more row), so "did the bubbles take the new
|
||||
// lane at all" would otherwise be a question only the eye could answer. One line per
|
||||
// registration, at debug (decision 1408).
|
||||
bevy::log::debug!("mat-anim: per-placement UV lane armed for host {host} (slot {slot})");
|
||||
}
|
||||
reg.0.insert(id, UvAnimEntry { anim, slot, seed });
|
||||
}
|
||||
|
||||
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
|
||||
@@ -348,6 +424,9 @@ pub(super) fn tick_anim_materials(
|
||||
With<AnimMatPart>,
|
||||
>,
|
||||
twins: Res<crate::model_render::FarSideTwins>,
|
||||
// The per-placement lane's sequence source (decision 1408): an entry registered `PerSeq` asks
|
||||
// its own anim host which slot it is playing, instead of reading the shared clock.
|
||||
hosts: SeqHosts,
|
||||
mut drawn: Local<
|
||||
bevy::platform::collections::HashSet<AssetId<benilla_assets::materials::WowModelMaterial>>,
|
||||
>,
|
||||
@@ -378,13 +457,17 @@ pub(super) fn tick_anim_materials(
|
||||
// below what any face can show — and `MatAnimTable::set` skips same-value writes, so a slow
|
||||
// loop uploads nothing most frames. Eviction is unchanged: an entry dies with its material,
|
||||
// and its slot zeroes back to identity ([`crate::mat_anim_table`]'s free law).
|
||||
let gseq_now = f64::from(now);
|
||||
uv_reg.0.retain(|id, entry| {
|
||||
if !materials.contains(*id) {
|
||||
table.free(entry.slot);
|
||||
return false;
|
||||
}
|
||||
if drawn.contains(id) {
|
||||
table.set(entry.slot, entry.delta(now));
|
||||
table.set(
|
||||
entry.slot,
|
||||
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
|
||||
);
|
||||
}
|
||||
true
|
||||
});
|
||||
@@ -394,7 +477,10 @@ pub(super) fn tick_anim_materials(
|
||||
return false;
|
||||
}
|
||||
if drawn.contains(id) {
|
||||
table.set(entry.slot, entry.delta(now));
|
||||
table.set(
|
||||
entry.slot,
|
||||
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
|
||||
);
|
||||
}
|
||||
true
|
||||
});
|
||||
@@ -437,17 +523,40 @@ fn matanim_off(time: &Time<bevy::time::Real>) -> bool {
|
||||
/// doodad's ambient loop). Spell-effect instances need real per-instance phase instead (one cast
|
||||
/// = one 0.9 s pulse), so the effect lane clones its materials and ticks them on the instance
|
||||
/// clock (`entities::spell_fx`), never through this registry.
|
||||
/// [`UvLoop`]'s RGB twin, on the same rule and for the same reason — `uvslotscan` finds the tint
|
||||
/// channel pinned to slot 0 by the same line, and `Spells\\Deterrence_State_Base.m2` tinting
|
||||
/// **red→blue in Stand and green→red in Hold**, where the pin renders a *wrong colour* rather than
|
||||
/// a frozen one.
|
||||
pub enum TintLoop {
|
||||
Shared(std::sync::Arc<benilla_formats::RgbAnim>),
|
||||
PerSeq {
|
||||
seqs: std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>,
|
||||
host: Entity,
|
||||
},
|
||||
}
|
||||
|
||||
/// One registered tint material — [`UvAnimEntry`]'s RGB twin (seed = the built `tint.xyz`).
|
||||
pub struct TintAnimEntry {
|
||||
pub anim: std::sync::Arc<benilla_formats::RgbAnim>,
|
||||
pub anim: TintLoop,
|
||||
pub slot: u16,
|
||||
pub seed: [f32; 3],
|
||||
}
|
||||
|
||||
impl TintAnimEntry {
|
||||
/// [`UvAnimEntry::delta`]'s RGB twin (1/255 quantization, the display's own step).
|
||||
pub(crate) fn delta(&self, now: f32) -> [f32; 4] {
|
||||
let rgb = benilla_assets::quant255(self.anim.sample(now));
|
||||
/// [`UvAnimEntry::delta`]'s RGB twin (1/255 quantization, the display's own step). The
|
||||
/// per-placement lane's identity is WHITE — the tint is a multiplier, so an unresolvable host
|
||||
/// must leave the batch at its built seed, not black it out.
|
||||
pub(crate) fn delta(&self, now: f32, gseq_now: f64, playing: Option<(usize, f32)>) -> [f32; 4] {
|
||||
let rgb = match &self.anim {
|
||||
TintLoop::Shared(anim) => anim.sample(now),
|
||||
TintLoop::PerSeq { seqs, .. } => playing
|
||||
.and_then(|(seq, band_t)| {
|
||||
seqs.seq(Some(seq))
|
||||
.map(|l| l.sample(l.clock(band_t, gseq_now)))
|
||||
})
|
||||
.unwrap_or([1.0, 1.0, 1.0]),
|
||||
};
|
||||
let rgb = benilla_assets::quant255(rgb);
|
||||
[
|
||||
rgb[0] - self.seed[0],
|
||||
rgb[1] - self.seed[1],
|
||||
@@ -471,7 +580,7 @@ pub fn register_tint(
|
||||
table: &mut crate::mat_anim_table::MatAnimTable,
|
||||
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
|
||||
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
|
||||
anim: &std::sync::Arc<benilla_formats::RgbAnim>,
|
||||
anim: TintLoop,
|
||||
) {
|
||||
if reg.0.contains_key(&id) {
|
||||
return;
|
||||
@@ -490,14 +599,7 @@ pub fn register_tint(
|
||||
mat.extension.tint.z,
|
||||
];
|
||||
mat.extension.anim_slots.y = f32::from(slot);
|
||||
reg.0.insert(
|
||||
id,
|
||||
TintAnimEntry {
|
||||
anim: anim.clone(),
|
||||
slot,
|
||||
seed,
|
||||
},
|
||||
);
|
||||
reg.0.insert(id, TintAnimEntry { anim, slot, seed });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -522,12 +624,12 @@ mod delta_tests {
|
||||
let anim = uv_loop();
|
||||
let seed = anim.sample(0.0);
|
||||
let entry = UvAnimEntry {
|
||||
anim: anim.clone(),
|
||||
anim: UvLoop::Shared(anim.clone()),
|
||||
slot: 3,
|
||||
seed,
|
||||
};
|
||||
for t in [0.0_f32, 0.35, 1.0, 1.7] {
|
||||
let d = entry.delta(t);
|
||||
let d = entry.delta(t, f64::from(t), None);
|
||||
let s = anim.sample(t);
|
||||
let old = [
|
||||
benilla_assets::quantize(s[0], 4096.0),
|
||||
@@ -548,6 +650,48 @@ mod delta_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **B98** (decision 1408): the per-placement lane samples the slot the placement's own host is
|
||||
/// playing — not slot 0, and not a shared clock.
|
||||
///
|
||||
/// The BRM lava bubble's shape, exactly: slot 0 bakes to nothing (a dead hold) and slot 1
|
||||
/// carries the flipbook. Reading slot 0, as the shared registry must, is the frozen sprite the
|
||||
/// report named; reading the host's live slot is the fix. The unresolved case — a host
|
||||
/// despawned mid-frame — must land on the UV identity, i.e. the built seed, never a jump.
|
||||
#[test]
|
||||
fn the_per_placement_lane_reads_its_hosts_sequence() {
|
||||
let seqs = std::sync::Arc::new(
|
||||
benilla_formats::SeqLoops::new(vec![
|
||||
None, // slot 0: the dead hold the shared lane is pinned to
|
||||
Some(benilla_formats::UvAnim {
|
||||
period: 4.0,
|
||||
step: true,
|
||||
wrap: true,
|
||||
gseq: false,
|
||||
keys: vec![(0.0, [0.0, 0.0]), (2.0, [0.0, 0.605])],
|
||||
}),
|
||||
])
|
||||
.expect("slot 1 animates"),
|
||||
);
|
||||
let entry = UvAnimEntry {
|
||||
anim: UvLoop::PerSeq {
|
||||
seqs,
|
||||
host: Entity::from_raw_u32(7).expect("a valid test entity"),
|
||||
},
|
||||
slot: 5,
|
||||
seed: [0.0, 0.0],
|
||||
};
|
||||
// On slot 1, past its step key: the whole V flip shows.
|
||||
let d = entry.delta(0.0, 0.0, Some((1, 3.0)));
|
||||
assert!(
|
||||
(d[1] - 0.605).abs() < 1e-3,
|
||||
"the flipbook's V offset: {d:?}"
|
||||
);
|
||||
// On slot 0 there is no loop at all — the identity, i.e. the built seed.
|
||||
assert_eq!(entry.delta(0.0, 0.0, Some((0, 3.0))), [0.0; 4]);
|
||||
// …and an unresolvable host degrades to the same identity, never to a shared-clock sample.
|
||||
assert_eq!(entry.delta(99.0, 99.0, None), [0.0; 4]);
|
||||
}
|
||||
|
||||
/// The tint twin, same law at the display's 1/255 step.
|
||||
#[test]
|
||||
fn the_tint_delta_reproduces_the_old_absolute_write() {
|
||||
@@ -563,11 +707,11 @@ mod delta_tests {
|
||||
[s[0], s[1], s[2]]
|
||||
};
|
||||
let entry = TintAnimEntry {
|
||||
anim: anim.clone(),
|
||||
anim: TintLoop::Shared(anim.clone()),
|
||||
slot: 4,
|
||||
seed,
|
||||
};
|
||||
let d = entry.delta(0.4);
|
||||
let d = entry.delta(0.4, 0.4, None);
|
||||
let old = benilla_assets::quant255(anim.sample(0.4));
|
||||
for i in 0..3 {
|
||||
assert_eq!(seed[i] + d[i], old[i], "channel {i}");
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) use lazy::{LazyRig, SkinnedTwin};
|
||||
use mat_anim::tick_anim_materials;
|
||||
pub use mat_anim::{
|
||||
playing_seq, register_tint, register_uv, sample_mat_anim, AnimMatPart, MatAnim,
|
||||
TintAnimMaterials, UvAnimMaterials,
|
||||
TintAnimMaterials, TintLoop, UvAnimMaterials, UvLoop,
|
||||
};
|
||||
|
||||
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
|
||||
|
||||
@@ -32,8 +32,18 @@ use bevy::render::{Render, RenderApp, RenderSystems};
|
||||
|
||||
/// Slots in the table, row 0 included (row 0 is the pinned-zero identity every static material
|
||||
/// reads). B131 observed material-anim residency grow to ~250 entries on a long single-map
|
||||
/// session; 512 leaves that headroom ×2 and costs 8 KB.
|
||||
pub(crate) const MAX_MAT_ANIM_SLOTS: usize = 512;
|
||||
/// session, which is what sized the original 512.
|
||||
///
|
||||
/// **Raised to 2048 by decision 1408**, and measured rather than guessed: the per-placement lane
|
||||
/// gives a batch whose UV/tint loop differs between sequences one row *per placement*, and a live
|
||||
/// probe into Upper Blackrock Spire armed **145 rows within half a second of world entry** — 22
|
||||
/// placements of one 5-batch prop, each batch taking a row for its cutout material and another for
|
||||
/// its blend twin. The old ceiling would have exhausted in one dungeon, and exhaustion is silent by
|
||||
/// design (the batch stays at its built seed), i.e. it would have re-frozen exactly the bubbles the
|
||||
/// decision unfroze. The row count is inherent to the fix, not to how it is addressed — a
|
||||
/// per-instance row in a shared material would need the same 220. 2048 costs 32 KB per
|
||||
/// `wow_light`-layout buffer.
|
||||
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.
|
||||
@@ -41,7 +51,7 @@ pub(crate) fn region_offset() -> u64 {
|
||||
crate::rig_palette::rig_origin_region_offset() + crate::rig_palette::rig_origin_region_bytes()
|
||||
}
|
||||
|
||||
/// Bytes this region adds to every `wow_light`-layout buffer (8 KB at 512 slots).
|
||||
/// Bytes this region adds to every `wow_light`-layout buffer (32 KB at 2048 slots).
|
||||
pub(crate) fn region_bytes() -> u64 {
|
||||
(MAX_MAT_ANIM_SLOTS * 16) as u64
|
||||
}
|
||||
|
||||
@@ -111,6 +111,19 @@ pub struct MatKey {
|
||||
/// batch — `CavernsOfTimeSky` and Elwynn share no texture today, but nothing enforces that and
|
||||
/// the collision would be a world doodad silently drawn at the far plane.
|
||||
sky_depth: bool,
|
||||
/// **This material belongs to one placement, not to the batch** — `None` for the shared
|
||||
/// material every instance of a model reuses, which is every batch but one measured class.
|
||||
///
|
||||
/// The animated-material registries ([`crate::doodad_anim::UvAnimMaterials`] and its tint twin)
|
||||
/// are keyed by MATERIAL, so a batch whose animated loop depends on **which sequence its
|
||||
/// instance is playing** cannot share one and be right: the BRM lava bubbles key their whole
|
||||
/// flipbook inside a 50 %-weighted variation of animation id 0, and their 15 placements re-roll
|
||||
/// independently every ~3.3 s (decision 0768), so at any instant some are on slot 0 and some on
|
||||
/// slot 1. Keying the material by the placement's anim host gives each its own row and its own
|
||||
/// sequence (decision 1408); the population is 22 batch-channels across 6 models
|
||||
/// (`benilla-extract uvslotscan`), and the clones expire on the same distance evictor as every
|
||||
/// other material ([`scope_model_materials`], decision 0785).
|
||||
instance: Option<Entity>,
|
||||
}
|
||||
|
||||
/// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity
|
||||
@@ -215,6 +228,11 @@ pub fn model_material(
|
||||
// and the skybox sort rung instead of the ordinary batch-order eps. See [`MatKey::sky_depth`].
|
||||
sky_depth: bool,
|
||||
light: &Buffer,
|
||||
// The ONE placement this material belongs to, or `None` for the shared batch material every
|
||||
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
|
||||
// the sequence its instance is playing (decision 1408): the animated-material registries are
|
||||
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
|
||||
instance: Option<Entity>,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
let key = MatKey {
|
||||
light: light.id(),
|
||||
@@ -239,6 +257,7 @@ pub fn model_material(
|
||||
window,
|
||||
zfill: false,
|
||||
sky_depth,
|
||||
instance,
|
||||
};
|
||||
if let Some(h) = cache.fetch(&key) {
|
||||
return h;
|
||||
@@ -583,6 +602,9 @@ pub fn zfill_material(
|
||||
// A depth-prime twin exists to write depth for a fading model; the sky writes none, so no
|
||||
// skybox batch ever has one (`M2BatchMaterials::skybox` builds no twins at all).
|
||||
sky_depth: false,
|
||||
// A z-fill twin carries no animated channel at all (no texture, no uv/rgb anim), so it can
|
||||
// never be the per-placement lane — it stays the one shared depth prime per batch.
|
||||
instance: None,
|
||||
};
|
||||
if let Some(h) = cache.fetch(&key) {
|
||||
return h;
|
||||
|
||||
@@ -164,6 +164,7 @@ impl M2BatchMaterials<'_> {
|
||||
false,
|
||||
true, // the sky lane
|
||||
&light,
|
||||
None, // one shared sky material — no per-sequence channel to key on
|
||||
))
|
||||
}
|
||||
|
||||
@@ -319,6 +320,7 @@ impl M2BatchMaterials<'_> {
|
||||
false, // …nor the WINDOW flag
|
||||
false, // a character composite is never a skybox
|
||||
&light,
|
||||
None, // a composite sheet carries no animated UV/tint channel at all
|
||||
)
|
||||
};
|
||||
Some(BatchVariants {
|
||||
@@ -427,6 +429,11 @@ impl M2BatchMaterials<'_> {
|
||||
// asks for, so it cannot be one more variant built from `sub` here.
|
||||
false,
|
||||
light,
|
||||
// The ENTITY lane's batches (units, GameObjects, held items): shared per batch, as
|
||||
// ever. The per-placement lane is the world streamer's alone (decision 1408) — every
|
||||
// affected model is a placed `World\…` prop, and an entity already resolves its
|
||||
// sequence through `MatAnim::host`.
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ pub(super) fn apply_model_visibility(
|
||||
// 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<(
|
||||
&ModelPart,
|
||||
&GlobalTransform,
|
||||
@@ -61,6 +67,7 @@ pub(super) fn apply_model_visibility(
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
Has<crate::exterior_cull::ExteriorScene>,
|
||||
Has<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
|
||||
@@ -94,7 +101,20 @@ pub(super) fn apply_model_visibility(
|
||||
// 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.
|
||||
q.par_iter_mut().for_each(
|
||||
|(part, xf, mut vis, fade, tag, mat, aabb, group_vis, mat_anim, exterior, far_side)| {
|
||||
|(
|
||||
part,
|
||||
xf,
|
||||
mut vis,
|
||||
fade,
|
||||
tag,
|
||||
mat,
|
||||
aabb,
|
||||
group_vis,
|
||||
mat_anim,
|
||||
exterior,
|
||||
far_side,
|
||||
card,
|
||||
)| {
|
||||
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
|
||||
@@ -170,6 +190,26 @@ pub(super) fn apply_model_visibility(
|
||||
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
|
||||
@@ -178,6 +218,7 @@ pub(super) fn apply_model_visibility(
|
||||
&& mat_factor > 0.0
|
||||
&& portal_visible
|
||||
&& exterior_ok
|
||||
&& !owner_hidden
|
||||
{
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
@@ -332,6 +373,10 @@ type TracedModels<'w, 's> = Query<
|
||||
&'static InheritedVisibility,
|
||||
&'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>,
|
||||
),
|
||||
>;
|
||||
|
||||
@@ -354,7 +399,7 @@ pub(super) fn trace_model_visibility(
|
||||
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) in &q {
|
||||
for (e, object, xf, vis, inherited, view, aabb, card) in &q {
|
||||
if !object.label.to_ascii_lowercase().contains(&trace.needle) {
|
||||
continue;
|
||||
}
|
||||
@@ -370,7 +415,7 @@ pub(super) fn trace_model_visibility(
|
||||
};
|
||||
let depth = (centre - cam_pos).dot(cam_fwd);
|
||||
println!(
|
||||
"VIS_TRACE {e} {label} vis={vis:?} inh={inh} view={view} \
|
||||
"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}]",
|
||||
@@ -389,3 +434,84 @@ pub(super) fn trace_model_visibility(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ pub(super) fn update_model_particles(
|
||||
false,
|
||||
false, // an effect model is never a skybox
|
||||
&light.0,
|
||||
None, // a shard's material is shared by the whole emitter
|
||||
);
|
||||
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
|
||||
// particle is one of its owner's emitters exactly like the quad cloud beside
|
||||
|
||||
@@ -203,6 +203,35 @@ fn drive_child(
|
||||
/// building are gated by (0689/1289). One `SystemParam` because they are read together, at one
|
||||
/// place, and Bevy caps a system at 16 parameters — which `simulate_particles` already sits at, so
|
||||
/// the portal query joins this bundle rather than becoming a seventeenth.
|
||||
/// The **owner reads** — one bundle for the two questions this sim asks of an emitter's owner
|
||||
/// (a joint, a unit root, a placement submesh; never an emitter or child-draw entity, so both
|
||||
/// members are provably disjoint from the `&mut GlobalTransform` writes below): *where is it* and
|
||||
/// *is its model being drawn*. A `SystemParam` rather than two parameters because
|
||||
/// [`simulate_particles`] sits at Bevy's 16-param ceiling, and because the pair is one concept —
|
||||
/// the owner's frame — that no caller should be able to half-answer.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct Owners<'w, 's> {
|
||||
transforms:
|
||||
Query<'w, 's, &'static GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
|
||||
visibility: Query<'w, 's, &'static InheritedVisibility>,
|
||||
}
|
||||
|
||||
impl Owners<'_, '_> {
|
||||
/// The owner's live world position — read every frame, never from a stored anchor: a frozen
|
||||
/// emitter stops refreshing its anchor, so gating on the stored one would latch a moving owner
|
||||
/// out of existence the first time it crossed the wall.
|
||||
fn at(&self, owner: Entity) -> Option<Vec3> {
|
||||
self.transforms.get(owner).ok().map(|gt| gt.translation())
|
||||
}
|
||||
|
||||
/// Is the owner's model NOT being drawn this frame — the entity lane's draw-set term (decision
|
||||
/// 1409). Last propagate's verdict (one frame of lag on a hide that holds while the model is
|
||||
/// culled), and an owner we cannot read is not hidden: it falls through to the drain path.
|
||||
fn hidden(&self, owner: Entity) -> bool {
|
||||
matches!(self.visibility.get(owner), Ok(v) if !v.get())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct SceneGates<'w, 's> {
|
||||
view: Res<'w, crate::view::ViewDistance>,
|
||||
@@ -385,9 +414,7 @@ pub(super) fn simulate_particles(
|
||||
interleave: WaterInterleave,
|
||||
mut commands: Commands,
|
||||
cam: Query<(Entity, &GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
|
||||
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
|
||||
// both `&mut GlobalTransform` queries below.
|
||||
transforms: Query<&GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
|
||||
owners: Owners,
|
||||
// MODEL-particle instance entities ([`super::model`]): the draw-set gate hides them with
|
||||
// their frozen emitter.
|
||||
mut child_draws: Query<
|
||||
@@ -570,11 +597,26 @@ pub(super) fn simulate_particles(
|
||||
// frozen emitter stops refreshing its anchor, so gating on the stored one would latch a
|
||||
// moving owner out of existence the first time it crossed the wall.
|
||||
let subject = match emitter.owner {
|
||||
Some(o) => transforms.get(o).ok().map(|gt| gt.translation()),
|
||||
Some(o) => owners.at(o),
|
||||
None => Some(emitter.anchor_pos),
|
||||
};
|
||||
// **…and the owner's own draw verdict** (decision 1409). An emitter entity is a world
|
||||
// root — `spawn_emitter` parents it to nothing, because its cloud is anchored, not
|
||||
// carried — so every hide that reaches its model through the scene graph reaches the
|
||||
// emitter not at all. The reference has no such gap: it ticks a model's particles
|
||||
// inside that model's animate step, which runs only for models the frame draws. Ours
|
||||
// ran regardless, which is a culled GameObject's fire still burning over bare ground —
|
||||
// the Orgrimmar bonfires, flame and smoke intact with the wood gone. The world lane's
|
||||
// `EmitterFade` says this in its own vocabulary (terms 4/5); the entity lane has no
|
||||
// fade sphere to say it with, and its owner's propagated verdict is the same answer.
|
||||
//
|
||||
// `InheritedVisibility` is last propagate's — one frame of lag on a verdict that holds
|
||||
// for as long as the model is culled — and an owner we cannot read falls through to the
|
||||
// drain path below, exactly like a vanished one.
|
||||
let owner_hidden = emitter.owner.is_some_and(|o| owners.hidden(o));
|
||||
if let Some(at) = subject {
|
||||
if !crate::view::within_farclip(farclip, cam_pos, cam_fwd, at, 0.0) {
|
||||
if owner_hidden || !crate::view::within_farclip(farclip, cam_pos, cam_fwd, at, 0.0)
|
||||
{
|
||||
if !emitter.gated {
|
||||
emitter.gated = true;
|
||||
for slot in &emitter.model_instances {
|
||||
@@ -629,7 +671,7 @@ pub(super) fn simulate_particles(
|
||||
// no anchor exists — a joint's transform is not the instance matrix, so that leg keeps
|
||||
// the sign-test fallback (`water_gt = None`).
|
||||
let water_model = (*anchor).or(*owner);
|
||||
let water_gt = (*anchor).and_then(|e| transforms.get(e).ok().copied());
|
||||
let water_gt = (*anchor).and_then(|e| owners.transforms.get(e).ok().copied());
|
||||
let water_bound = *water_bound;
|
||||
*age += dt;
|
||||
// Anchored mode (see [`Particle`]): positions are emitter-relative, so tracking a moving
|
||||
@@ -643,7 +685,7 @@ pub(super) fn simulate_particles(
|
||||
// emitter despawns itself. An instant despawn here popped every live particle of a
|
||||
// fireball's trail at the moment of impact.
|
||||
if let Some(o) = *owner {
|
||||
match transforms.get(o) {
|
||||
match owners.transforms.get(o) {
|
||||
Ok(gt) => *placement = gt.compute_transform(),
|
||||
Err(_) => {
|
||||
*owner = None;
|
||||
@@ -694,7 +736,7 @@ pub(super) fn simulate_particles(
|
||||
// The live attach rotation `A(t)` (attached models only — see the field doc). A vanished
|
||||
// attach entity keeps the last rotation: the pool drains in its final frame.
|
||||
if let Some(a) = *attach {
|
||||
if let Ok(gt) = transforms.get(a) {
|
||||
if let Ok(gt) = owners.transforms.get(a) {
|
||||
let (_, rot, _) = gt.to_scale_rotation_translation();
|
||||
*attach_rot = rot;
|
||||
}
|
||||
@@ -713,7 +755,7 @@ pub(super) fn simulate_particles(
|
||||
// one while the pool drains. A whole-model owner keeps anchor == owner — identical math.
|
||||
match *anchor {
|
||||
Some(a) => {
|
||||
if let Ok(gt) = transforms.get(a) {
|
||||
if let Ok(gt) = owners.transforms.get(a) {
|
||||
*anchor_pos = gt.translation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,19 @@ pub fn spawn_model_entities(
|
||||
// distance-fades with its doodad (below — the halo must cull when the lamp does), so it
|
||||
// keeps its fade twin and its `DoodadFade`.
|
||||
let steady_interior_prop = interior_probe && sub.billboard.is_none();
|
||||
// **Does this batch need a material of its own?** (decision 1408.) A batch whose UV or tint
|
||||
// loop differs between sequences cannot share one: the registries are keyed by material,
|
||||
// so a shared one has no instance to ask which sequence is playing — and these placements
|
||||
// re-roll their variation independently every window (0768), so at any instant they are on
|
||||
// different slots. Keying the material by this placement's anim host gives each its own
|
||||
// registry entry, its own table row, and its own sequence. Everything else — every batch in
|
||||
// the world but a measured 28 on this lane, 49 corpus-wide of 24103 (`uvslotscan`) — keeps
|
||||
// the shared material it always had.
|
||||
//
|
||||
// No host ⇒ no sequence to read ⇒ share, and stay at the built seed: a per-sequence batch
|
||||
// on a model the content gate declined is exactly today's frozen behaviour, not a new one.
|
||||
let per_seq = sub.uv_seq.is_some() || sub.rgb_seq.is_some();
|
||||
let seq_owner = per_seq.then_some(rig_root).flatten();
|
||||
let cutout = model_material(
|
||||
mat_cache,
|
||||
materials,
|
||||
@@ -220,6 +233,7 @@ pub fn spawn_model_entities(
|
||||
sub.window,
|
||||
false, // the world streamer never spawns a skybox
|
||||
light,
|
||||
seq_owner,
|
||||
);
|
||||
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
|
||||
// this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack
|
||||
@@ -262,6 +276,7 @@ pub fn spawn_model_entities(
|
||||
sub.window,
|
||||
false, // the world streamer never spawns a skybox
|
||||
light,
|
||||
seq_owner,
|
||||
)
|
||||
};
|
||||
// Register both variants' materials for the per-frame UV scroll (idempotent per material —
|
||||
@@ -269,15 +284,57 @@ pub fn spawn_model_entities(
|
||||
// A period-0 loop is a constant the material seed already wrote (`sample(0.0)` IS its
|
||||
// forever value) — registering it would buy a per-frame re-write of the same number
|
||||
// (1375), so only a real loop enters the registry.
|
||||
if let Some(anim) = sub.uv_anim.as_ref().filter(|a| a.period > 0.0) {
|
||||
crate::doodad_anim::register_uv(uv_reg, anim_table, materials, cutout.id(), anim);
|
||||
crate::doodad_anim::register_uv(uv_reg, anim_table, materials, blend.id(), anim);
|
||||
// The per-placement lane first: its set replaces the shared loop outright, and it only
|
||||
// arms when this placement actually has a host to read a sequence from.
|
||||
if let (Some(seqs), Some(host)) = (sub.uv_seq.as_ref(), seq_owner) {
|
||||
for id in [cutout.id(), blend.id()] {
|
||||
crate::doodad_anim::register_uv(
|
||||
uv_reg,
|
||||
anim_table,
|
||||
materials,
|
||||
id,
|
||||
crate::doodad_anim::UvLoop::PerSeq {
|
||||
seqs: seqs.clone(),
|
||||
host,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if let Some(anim) = sub.uv_anim.as_ref().filter(|a| a.period > 0.0) {
|
||||
for id in [cutout.id(), blend.id()] {
|
||||
crate::doodad_anim::register_uv(
|
||||
uv_reg,
|
||||
anim_table,
|
||||
materials,
|
||||
id,
|
||||
crate::doodad_anim::UvLoop::Shared(anim.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// …and for the per-frame tint re-sample (the animated M2Color RGB, same shared clock —
|
||||
// the same invisible seq-band phase divergence as the UV scroll, recorded there).
|
||||
if let Some(anim) = sub.rgb_anim.as_ref().filter(|a| a.period > 0.0) {
|
||||
crate::doodad_anim::register_tint(tint_reg, anim_table, materials, cutout.id(), anim);
|
||||
crate::doodad_anim::register_tint(tint_reg, anim_table, materials, blend.id(), anim);
|
||||
if let (Some(seqs), Some(host)) = (sub.rgb_seq.as_ref(), seq_owner) {
|
||||
for id in [cutout.id(), blend.id()] {
|
||||
crate::doodad_anim::register_tint(
|
||||
tint_reg,
|
||||
anim_table,
|
||||
materials,
|
||||
id,
|
||||
crate::doodad_anim::TintLoop::PerSeq {
|
||||
seqs: seqs.clone(),
|
||||
host,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if let Some(anim) = sub.rgb_anim.as_ref().filter(|a| a.period > 0.0) {
|
||||
for id in [cutout.id(), blend.id()] {
|
||||
crate::doodad_anim::register_tint(
|
||||
tint_reg,
|
||||
anim_table,
|
||||
materials,
|
||||
id,
|
||||
crate::doodad_anim::TintLoop::Shared(anim.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// `MeshTag` is the per-instance lighting/fade scalar: an interior prop carries its SH-probe
|
||||
// slot index here (the shader evaluates the folded probe instead of the sky base);
|
||||
@@ -406,6 +463,7 @@ pub fn spawn_model_entities(
|
||||
// unmarked one.
|
||||
if sub.uv_anim.as_ref().is_some_and(|a| a.period > 0.0)
|
||||
|| sub.rgb_anim.as_ref().is_some_and(|a| a.period > 0.0)
|
||||
|| seq_owner.is_some()
|
||||
{
|
||||
commands
|
||||
.entity(entity)
|
||||
|
||||
@@ -602,8 +602,15 @@ pub(super) fn spawn_loaded_placements(
|
||||
// visible-group walk `0x698720`), so furniture never outlives its room (decision 0689)
|
||||
// and a prop several rooms name is drawn from ANY of them. Every submesh spawned above
|
||||
// is this one prop, so they all share the one key.
|
||||
//
|
||||
// The anim-host ROOT is skipped, for the reason the ADT site skips it (0784): it is a
|
||||
// joint hierarchy, not geometry — no `ModelPart`, no `Aabb` — so it lands in the cull's
|
||||
// fail-open arm and inflates every "objects tested" count without ever being drawn. The
|
||||
// ADT lane has excluded it since 0784; this lane did not, which made the same root a
|
||||
// silent passenger of the WMO prop path's counts.
|
||||
let anim_root = host.as_ref().map(|h| h.root);
|
||||
if let (Some(instance), false) = (portal_instance, d.groups.is_empty()) {
|
||||
for &entity in &ents {
|
||||
for &entity in ents.iter().filter(|e| Some(**e) != anim_root) {
|
||||
commands.entity(entity).insert((
|
||||
WmoGroupVis {
|
||||
instance,
|
||||
|
||||
@@ -292,13 +292,15 @@ pub(super) fn track_unit_interiors(
|
||||
streamer: Res<TerrainStreamer>,
|
||||
adt_tiles: Res<Assets<AdtTile>>,
|
||||
residency: Res<crate::interior::WmoResidency>,
|
||||
mut units: Query<
|
||||
(Entity, &GlobalTransform, Option<&mut UnitWmoRoom>),
|
||||
With<crate::world_unit::WorldUnit>,
|
||||
>,
|
||||
mut units: Query<(
|
||||
Entity,
|
||||
&GlobalTransform,
|
||||
&crate::world_unit::WorldUnit,
|
||||
Option<&mut UnitWmoRoom>,
|
||||
)>,
|
||||
) {
|
||||
let generation = residency.generation();
|
||||
for (entity, transform, claim) in &mut units {
|
||||
for (entity, transform, body, claim) in &mut units {
|
||||
// World position, not the local one: a mounted unit's `Transform` is seat-relative
|
||||
// (0441), and its room is decided where it actually stands.
|
||||
let pos = transform.translation();
|
||||
@@ -313,7 +315,18 @@ pub(super) fn track_unit_interiors(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let room = room_at(&wmos, &instances, &streamer, &adt_tiles, pos);
|
||||
// The under-floor fallback's reach for THIS body: the height of its own bound's centre
|
||||
// above its origin, floored at [`ROOM_UNDER_FLOOR_TOLERANCE`]. A placed object's origin is
|
||||
// wherever the artist put it *inside its own model*, so the middle of that model is a point
|
||||
// the body genuinely occupies — self-scaling, and it cannot reach a floor the body does not
|
||||
// already straddle. The flat tolerance alone left Orgrimmar's Onyxia trophy post (origin
|
||||
// 3.25 yd under group 133's floor) still reading outdoors.
|
||||
let reach = body
|
||||
.bound
|
||||
.map(|b| transform.transform_point(Vec3::from(b.center)).y - pos.y)
|
||||
.unwrap_or(0.0)
|
||||
.max(ROOM_UNDER_FLOOR_TOLERANCE);
|
||||
let room = room_at(&wmos, &instances, &streamer, &adt_tiles, pos, reach);
|
||||
let next = UnitWmoRoom {
|
||||
room,
|
||||
at: pos,
|
||||
@@ -334,8 +347,28 @@ pub(super) fn track_unit_interiors(
|
||||
}
|
||||
}
|
||||
|
||||
/// The FLOOR on how far above its own origin a body's room claim looks for the surface it stands
|
||||
/// on, when the position cast found nothing at all below it ([`room_at`]'s second pass). The reach
|
||||
/// itself is per body — the height of its own bound's centre, see [`track_unit_interiors`] — and
|
||||
/// this is what a body with no bound yet, or a squat one, gets.
|
||||
///
|
||||
/// **A placed object's origin is wherever the artist put it, and for anything planted in the ground
|
||||
/// that is UNDER the surface** — a firepit's origin sits in the pit, a signpost's at the buried foot
|
||||
/// of its post. A ray down from such an origin passes beneath the floor the object stands on and
|
||||
/// finds nothing, so the body reads "outdoors" in the middle of a building. Orgrimmar: 32 of 133
|
||||
/// streamed bodies at once, measured 0.45–1.16 yd under their floor face (decision 1409).
|
||||
///
|
||||
/// Two yards covers that spread with margin and cannot reach the storey above — no room we draw is
|
||||
/// under two yards tall. A **fallback, not a lift**: it runs only where the position cast found no
|
||||
/// face at all, so it can turn "outdoors" into a room and can never move a body between two rooms it
|
||||
/// was already choosing from. The primary pass keeps [`POSITION_PROBE_LIFT`] and the buried-terrain
|
||||
/// race that constant was chosen for.
|
||||
pub(crate) const ROOM_UNDER_FLOOR_TOLERANCE: f32 = 2.0;
|
||||
|
||||
/// The WMO room a world position stands in — the placement + group of the nearest **face** under it,
|
||||
/// faces-only ([`area_down_ray`], EXTERIOR `0x8` excluded, terrain raced), first claim wins.
|
||||
/// faces-only ([`area_down_ray`], EXTERIOR `0x8` excluded, terrain raced), first claim wins; on a
|
||||
/// total miss, a second pass from `reach` higher catches a body whose origin is authored below the
|
||||
/// surface it stands on ([`ROOM_UNDER_FLOOR_TOLERANCE`]).
|
||||
///
|
||||
/// The shared body of the per-unit claim; the same shape as [`track_area_interior`]'s loop, returning
|
||||
/// the placement identity instead of the `WMOAreaTable` keys.
|
||||
@@ -345,8 +378,22 @@ fn room_at(
|
||||
streamer: &TerrainStreamer,
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
feet_world: Vec3,
|
||||
reach: f32,
|
||||
) -> Option<WmoRoom> {
|
||||
let probe_world = feet_world + Vec3::Y * POSITION_PROBE_LIFT;
|
||||
room_cast(wmos, instances, streamer, adt_tiles, feet_world, 0.0)
|
||||
.or_else(|| room_cast(wmos, instances, streamer, adt_tiles, feet_world, reach))
|
||||
}
|
||||
|
||||
/// One faces-only room cast from `feet_world + rise` — the shared body of [`room_at`]'s two passes.
|
||||
fn room_cast(
|
||||
wmos: &Assets<WmoModel>,
|
||||
instances: &Query<(Entity, &WmoPortalInstance)>,
|
||||
streamer: &TerrainStreamer,
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
feet_world: Vec3,
|
||||
rise: f32,
|
||||
) -> Option<WmoRoom> {
|
||||
let probe_world = feet_world + Vec3::Y * (POSITION_PROBE_LIFT + rise);
|
||||
let terrain = terrain_height_under(streamer, adt_tiles, probe_world);
|
||||
for (entity, inst) in instances {
|
||||
let Some(model) = wmos.get(&inst.handle) else {
|
||||
|
||||
@@ -717,6 +717,43 @@ mod tests {
|
||||
area_down_ray(tris, &bounds, &grids, nav, eye, terrain_z, EXTERIOR)
|
||||
}
|
||||
|
||||
/// **A body planted UNDER the floor it stands on is invisible to the position cast, and the
|
||||
/// under-floor fallback is what finds it** (decision 1409). Orgrimmar's bonfires, braziers,
|
||||
/// meat racks and shop signs are authored 0.45–1.16 yd below the group floor they sit in — a
|
||||
/// ray down from the origin passes beneath that floor and reports open ground, which the
|
||||
/// exterior-scene election reads as "outdoors" and hides. `room_at`'s second pass re-casts from
|
||||
/// `ROOM_UNDER_FLOOR_TOLERANCE` higher; this pins both halves of why that works: the cast from
|
||||
/// the origin really does miss, the lifted one really does find the same floor, and the lift is
|
||||
/// far enough to cover the measured spread and no further.
|
||||
#[test]
|
||||
fn an_under_floor_origin_is_found_only_by_the_lifted_cast() {
|
||||
let floor = [quad(0.0, 20.0)];
|
||||
let nav = [nav(0, [-20.0, -20.0, 0.0], [20.0, 20.0, 0.0], 0, 0)];
|
||||
let lift = super::super::interior::POSITION_PROBE_LIFT;
|
||||
let tol = super::super::interior::ROOM_UNDER_FLOOR_TOLERANCE;
|
||||
// The worst measured burial (Orgrimmar bonfire 177026, 1.16 yd under group 134's floor).
|
||||
for buried in [0.45_f32, 1.16] {
|
||||
let feet = -buried;
|
||||
assert_eq!(
|
||||
area_ray(&floor, &nav, [0.0, 0.0, feet + lift], None),
|
||||
None,
|
||||
"the position cast must miss a floor above the origin ({buried} yd)"
|
||||
);
|
||||
assert_eq!(
|
||||
area_ray(&floor, &nav, [0.0, 0.0, feet + lift + tol], None),
|
||||
Some(0),
|
||||
"the under-floor fallback must find it ({buried} yd)"
|
||||
);
|
||||
}
|
||||
// …and the lift is a tolerance, not a licence: a floor further above than it stays unclaimed,
|
||||
// which is what keeps the storey above out of reach.
|
||||
assert_eq!(
|
||||
area_ray(&floor, &nav, [0.0, 0.0, -(tol + 1.0) + lift + tol], None),
|
||||
None,
|
||||
"a floor beyond the tolerance must stay unclaimed"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The column index never changes a down-ray verdict.** A dungeon-shaped face set (a big
|
||||
/// stack of small floor quads at varying heights, spread over several groups) answered with
|
||||
/// the per-group column index must equal the answer from the plain linear scan, column for
|
||||
|
||||
Reference in New Issue
Block a user