material animation stops touching assets: one shared table

- a drawn animated material — scrolling texture, pulsing tint — is never mutated again: the animation rides delta rows in a shared region of the light buffer, folded branch-free in the shader, with zero as identity
- the churn that came with mutating assets dies by construction — no per-material modified ratchet, no far-twin re-inserts — and capture parity is bit-exact
- the portal flood's answer goes compare-then-write, clearing the ground for a static-visibility lane
- the single-threaded PostUpdate probe answered: cheaper on mean cpu, parked on a tail-latency class, kept as a documented lever

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 16:58:39 +07:00
co-authored by Claude Fable 5
parent 4ff2a8ebcf
commit 970cac46c2
17 changed files with 520 additions and 72 deletions
@@ -138,6 +138,10 @@ fn fx_part_material(
};
let t0 = anim.sample(0.0);
mat.extension.tint = Vec4::new(t0[0], t0[1], t0[2], 1.0);
// The clone must leave the shared mat-anim table (decision 1381): its tint is ticked per
// INSTANCE right here (the whole point of the clone), and a carried world slot would add the
// shared delta on top — a double animation the old asset-mutating lane could never produce.
mat.extension.anim_slots = Vec4::ZERO;
let handle = wow_materials.add(mat);
tint_reg.0.insert(handle.id(), (anim.clone(), now));
handle
@@ -188,6 +188,7 @@ pub(super) fn spawn_wmo_gameobject_props(
mut mats: benilla_world::model_render::M2BatchMaterials,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
mut probes: ResMut<PropProbes>,
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
@@ -278,6 +279,7 @@ pub(super) fn spawn_wmo_gameobject_props(
Some((m, now)),
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
card_owner,
);
commands.entity(entity).add_children(&ents);
+9
View File
@@ -257,6 +257,15 @@ pub fn run(build: BuildId) -> AppExit {
s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded);
});
}
// `WOW_ST_POSTUPDATE=1` — 1366's named open probe: PostUpdate on the single-threaded
// executor, an EXPERIMENT lever only. The expectation is a REGRESSION (propagation and
// visibility are real parallel bands there, not flag-serialized non-Send work); the lever
// exists so the expectation gets measured instead of assumed.
if std::env::var_os("WOW_ST_POSTUPDATE").is_some() {
app.edit_schedule(PostUpdate, |s| {
s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded);
});
}
// …and one startup line says which build produced this log. Registered HERE, beside the stamp
// it prints, rather than in `preflight` where it sat until decision 1179: **which build is
// this** is the first thing a bug report from someone else's machine has to establish, and a
+9 -1
View File
@@ -209,6 +209,14 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// count. The items were always crossing; the measurement improved. That is the only kind of raise
/// that is not a retreat — a raise for a NEW leak is the failure this number exists to catch.
///
/// And 156 → 157: `mat_anim_table::MatAnimTable`, a PUBLISH by 1164's test (decision 1381). The
/// mat-anim delta table replaced per-frame material mutation, and registration must happen where
/// materials are BUILT — which for WMO GameObject props (transport interiors) is a game-side
/// spawner (`entities::wmo_props`) that already threads the two registries this table serves
/// (`UvAnimMaterials`/`TintAnimMaterials`, published members of the same family). The resource
/// that allocates the slots is the smallest honest addition beside them; hiding it would mean
/// a game-side material registering without a slot and silently freezing at its seed.
///
/// And 155 → 156: `doodad_anim::DoodadAnimHost`, a PUBLISH by 1164's test (decision 1365). The
/// doodad joint collapse put placed doodads on the collapsed-rig lane (`RigPose`), which made
/// them visible to the game's animation-LOD gate — whose park marker the engine's own doodad
@@ -229,7 +237,7 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// input, which is game-side; a one-field resource is the smallest honest expression of that. The
/// alternative was ordering a game system against an engine system, which crosses this same line
/// *and* publishes a schedule point to do it.
const CEILING: usize = 156;
const CEILING: usize = 157;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
+9
View File
@@ -199,6 +199,15 @@ pub struct WowModelExt {
/// (ambient +16/255) — the warm pane seen from inside a building.
#[uniform(100)]
pub sidn: Vec4,
/// The shared mat-anim TABLE slots (decision 1381): `x` = the UV-scroll slot + 1, `y` = the
/// animated-tint slot + 1 — `0` = not table-animated, and the shader uses the static lanes
/// (`sun_scale.zw` / `tint.xyz`) exactly as before. Baked when the batch registers its
/// sampler; the per-frame samples live in the shared light buffer's `matanim` region, so an
/// animating material is never mutated again (no per-frame `Modified`, no bind-group
/// rebuild, no whole-population `AssetChanged` walks — B131's chain, severed at the root).
/// `zw` free.
#[uniform(100)]
pub anim_slots: Vec4,
/// **The shared global light** (`lighting::global_light`): one storage buffer every material reads,
/// updated once/frame in place — replaces the per-material light/fog/SH uniforms (ambient/diffuse/
/// sun/spec + fog + the 7 Model2.bls SH-probe coeffs) the old `apply_wow_lighting` re-pushed every
@@ -103,6 +103,11 @@ struct ModelParams {
// midpoint pair — ambient AND diffuse = (Direct + Ambient)/2, ambient +16/255 — the warm pane
// seen from inside a building (derivation 0x6d37e0, byte-verified).
sidn: vec4<f32>,
// The shared mat-anim TABLE slots (decision 1381): x = the UV-scroll slot, y = the animated-
// tint slot — 0 (the pinned-zero identity row) for every static material, so the folds below
// add the row unconditionally, branch-free. The rows are DELTAS from the built seeds
// (sun_scale.zw / tint.xyz), which stay exactly as built; zw free.
anim_slots: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> m: ModelParams;
@@ -182,6 +187,12 @@ struct WowLight {
// back as `origin − camera`, so a skinned vertex is never expressed as an absolute world
// coordinate in f32 — which is what the ~1 mm/frame character shimmer was.
rig_origin: array<vec4<f32>, 2048>,
// The mat-anim delta table (decision 1381; mat_anim_table.rs mirrors the size): row 0 is the
// pinned-zero identity every static material's anim_slots = 0 reads; a live row is the drawn
// 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>,
palettes: array<vec4<f32>>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -569,7 +580,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
if ((u32(m.clutter_fade.z) & 4096u) != 0u) {
vo.uv = in.uv;
} else {
vo.uv = in.uv + m.sun_scale.zw;
vo.uv = in.uv + m.sun_scale.zw + wow_light.matanim[u32(m.anim_slots.x)].xy;
}
#endif
#ifdef VERTEX_UVS_B
@@ -891,7 +902,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
// tint the vertex colours carry for constant tracks, so it folds at the same point.
let albedo = base.rgb * m.tint.rgb;
let albedo = base.rgb * (m.tint.rgb + wow_light.matanim[u32(m.anim_slots.y)].xyz);
// Unlit fullbright (model_flags.w): M2 UNLIT (0x01) glass/glow cards, or WMO UNLIT on an
// exterior-group batch (`tex × white` — the inn's always-lit outside panes). Wins over the lit
// path; faithfully receives NO emission terms (lighting is off, so GL_EMISSION is dead there).
@@ -586,6 +586,7 @@ impl WorldAssets {
sun_scale: Vec4::new(1.0, 0.0, 0.0, 0.0),
tint: Vec4::ONE, // clutter never carries an animated M2Color tint (w: not a WMO batch)
sidn: Vec4::ZERO, // clutter is never SIDN/WINDOW glass (WMO-only)
anim_slots: Vec4::ZERO,
// The shared global light (light/fog/SH come from here, updated in place each frame).
light_buf: self.shared_light.clone(),
},
+216 -45
View File
@@ -235,14 +235,74 @@ pub fn sample_mat_anim(
#[derive(bevy::prelude::Component)]
pub struct AnimMatPart;
/// 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 slot: u16,
pub seed: [f32; 2],
}
impl UvAnimEntry {
/// The slot's delta row for `now`: the quantized sample minus the built seed — the shader
/// 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);
[
benilla_assets::quantize(uv[0], 4096.0) - self.seed[0],
benilla_assets::quantize(uv[1], 4096.0) - self.seed[1],
0.0,
0.0,
]
}
}
#[derive(Resource, Default)]
pub struct UvAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
std::sync::Arc<benilla_formats::UvAnim>,
UvAnimEntry,
>,
);
/// Register material `id` for the per-frame UV scroll: allocate its table slot, remember the
/// built seed, and bake the slot index into the material's `anim_slots.x` — the ONE material
/// write this lane ever makes (spawn-frame, where the asset is Modified anyway). A full table
/// (never seen below ~500 resident animated materials) skips registration: the batch stays
/// frozen at its built seed — a degraded look, never a wrong pixel.
pub fn register_uv(
reg: &mut UvAnimMaterials,
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>,
) {
if reg.0.contains_key(&id) {
return;
}
let Some(slot) = table.alloc() else {
bevy::log::warn_once!("mat-anim table full — a UV-scroll batch stays at its seed");
return;
};
let Some(mat) = materials.get_mut(id) else {
table.free(slot);
return;
};
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,
},
);
}
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
/// ([`UvAnimMaterials`]) and the RGB tint ([`TintAnimMaterials`]) together, because they share the
/// draw scan below. Skipped entirely in captures (materials keep their t = 0 seed — constants still
@@ -278,7 +338,8 @@ pub(super) fn tick_anim_materials(
real: Res<Time<bevy::time::Real>>,
mut uv_reg: ResMut<UvAnimMaterials>,
mut tint_reg: ResMut<TintAnimMaterials>,
mut materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
materials: Res<Assets<benilla_assets::materials::WowModelMaterial>>,
mut table: ResMut<crate::mat_anim_table::MatAnimTable>,
parts: Query<
(
&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>,
@@ -309,52 +370,32 @@ pub(super) fn tick_anim_materials(
}
}
let now = time.elapsed_secs();
// `contains` on the skip path, never `get_mut`: the entry must survive while its material does
// (that is the eviction predicate — an entry dies with its material, not with its visibility),
// and an immutable probe is what keeps a skipped material *un*-Modified.
uv_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let uv = anim.sample(now);
// The write gate (`write_gated`'s reasoning, 1375): a `get_mut` deref alone marks the
// asset Modified — a uniform re-upload, a bind-group rebuild on the non-bindless Metal
// path, and the whole-population `AssetChanged` walks — so an unchanged value must be
// decided on the immutable view and never deref. Quantized because the input drifts
// continuously: 1/4096 of a texture repeat is below what any face can show.
let (zq, wq) = (
benilla_assets::quantize(uv[0], 4096.0),
benilla_assets::quantize(uv[1], 4096.0),
);
match materials.get(*id) {
None => return false, // material unloaded with its cache — drop the entry
Some(m) if m.extension.sun_scale.z == zq && m.extension.sun_scale.w == wq => {
return true;
}
Some(_) => {}
}
let Some(mat) = materials.get_mut(*id) else {
// The samples land in the shared table as DELTAS from each entry's built seed (decision
// 1381) — the material asset is never touched, so there is no per-frame `Modified`, no
// bind-group rebuild, no `AssetChanged` walk, and no far-twin re-insert (the twin's clone
// carries the same slot and seed, so it scrolls in phase off the same row). Quantized
// because the input drifts continuously: 1/4096 of a texture repeat (1/255 for tint) is
// 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).
uv_reg.0.retain(|id, entry| {
if !materials.contains(*id) {
table.free(entry.slot);
return false;
};
mat.extension.sun_scale.z = zq;
mat.extension.sun_scale.w = wq;
}
if drawn.contains(id) {
table.set(entry.slot, entry.delta(now));
}
true
});
tint_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let rgb = benilla_assets::quant255(anim.sample(now));
let tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
match materials.get(*id) {
None => return false,
Some(m) if m.extension.tint == tint => return true,
Some(_) => {}
}
let Some(mat) = materials.get_mut(*id) else {
tint_reg.0.retain(|id, entry| {
if !materials.contains(*id) {
table.free(entry.slot);
return false;
};
mat.extension.tint = tint;
}
if drawn.contains(id) {
table.set(entry.slot, entry.delta(now));
}
true
});
}
@@ -396,10 +437,140 @@ 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.
/// 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 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));
[
rgb[0] - self.seed[0],
rgb[1] - self.seed[1],
rgb[2] - self.seed[2],
0.0,
]
}
}
#[derive(Resource, Default)]
pub struct TintAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
std::sync::Arc<benilla_formats::RgbAnim>,
TintAnimEntry,
>,
);
/// [`register_uv`]'s tint twin: slot into `anim_slots.y`, seed from the built `tint.xyz`.
pub fn register_tint(
reg: &mut TintAnimMaterials,
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>,
) {
if reg.0.contains_key(&id) {
return;
}
let Some(slot) = table.alloc() else {
bevy::log::warn_once!("mat-anim table full — a tint batch stays at its seed");
return;
};
let Some(mat) = materials.get_mut(id) else {
table.free(slot);
return;
};
let seed = [
mat.extension.tint.x,
mat.extension.tint.y,
mat.extension.tint.z,
];
mat.extension.anim_slots.y = f32::from(slot);
reg.0.insert(
id,
TintAnimEntry {
anim: anim.clone(),
slot,
seed,
},
);
}
#[cfg(test)]
mod delta_tests {
use super::*;
fn uv_loop() -> std::sync::Arc<benilla_formats::UvAnim> {
std::sync::Arc::new(benilla_formats::UvAnim {
period: 2.0,
step: false,
wrap: true,
gseq: false,
keys: vec![(0.0, [0.1, 0.2]), (1.0, [0.5, 0.6]), (2.0, [0.1, 0.2])],
})
}
/// The delta law (decision 1381): a row is the quantized live sample minus the BUILT seed,
/// so the shader's `seed + row` shows exactly the number the old asset-mutating lane wrote.
/// At t = 0 that is the quantized seed — the same value the old tick's first frame produced.
#[test]
fn the_delta_reproduces_the_old_absolute_write() {
let anim = uv_loop();
let seed = anim.sample(0.0);
let entry = UvAnimEntry {
anim: anim.clone(),
slot: 3,
seed,
};
for t in [0.0_f32, 0.35, 1.0, 1.7] {
let d = entry.delta(t);
let s = anim.sample(t);
let old = [
benilla_assets::quantize(s[0], 4096.0),
benilla_assets::quantize(s[1], 4096.0),
];
assert_eq!(
seed[0] + d[0],
old[0],
"t={t}: shader fold == old write (u)"
);
assert_eq!(
seed[1] + d[1],
old[1],
"t={t}: shader fold == old write (v)"
);
assert_eq!(d[2], 0.0);
assert_eq!(d[3], 0.0);
}
}
/// The tint twin, same law at the display's 1/255 step.
#[test]
fn the_tint_delta_reproduces_the_old_absolute_write() {
let anim = std::sync::Arc::new(benilla_formats::RgbAnim {
period: 1.0,
step: false,
wrap: true,
gseq: false,
keys: vec![(0.0, [1.0, 0.5, 0.25]), (1.0, [1.0, 0.5, 0.25])],
});
let seed = {
let s = benilla_assets::quant255(anim.sample(0.0));
[s[0], s[1], s[2]]
};
let entry = TintAnimEntry {
anim: anim.clone(),
slot: 4,
seed,
};
let d = entry.delta(0.4);
let old = benilla_assets::quant255(anim.sample(0.4));
for i in 0..3 {
assert_eq!(seed[i] + d[i], old[i], "channel {i}");
}
}
}
+2 -1
View File
@@ -40,7 +40,8 @@ mod mat_anim;
pub(crate) use lazy::{LazyRig, SkinnedTwin};
use mat_anim::tick_anim_materials;
pub use mat_anim::{
playing_seq, sample_mat_anim, AnimMatPart, MatAnim, TintAnimMaterials, UvAnimMaterials,
playing_seq, register_tint, register_uv, sample_mat_anim, AnimMatPart, MatAnim,
TintAnimMaterials, UvAnimMaterials,
};
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
+1
View File
@@ -146,6 +146,7 @@ pub mod interior;
pub mod lighting;
pub mod liquid;
pub mod map_proj;
pub mod mat_anim_table;
pub mod mesh_tag;
pub mod model_fade;
pub mod model_forms;
+201
View File
@@ -0,0 +1,201 @@
//! The shared **mat-anim table** (decision 1381) — the per-frame samples of every UV-scroll /
//! tint-animated batch material, delivered through the `wow_light` buffer instead of by mutating
//! the material asset.
//!
//! **Why.** `doodad_anim::tick_anim_materials` used to write each drawn animated material's
//! sampled offset into the asset every frame (`sun_scale.zw` / `tint`). Every such write is an
//! `AssetEvent::Modified`: a uniform re-upload plus, on the Metal non-bindless path, a bind-group
//! rebuild (~57 µs per material — B131's measurement), plus the whole-population `AssetChanged`
//! walks it arms (`mark_meshes_as_changed_if_their_materials_changed`, the per-material-type
//! specialization checks), plus the far-twin mirror's whole-material re-insert
//! (`model_render::classify_water_side`). 1375 bounded the *scan* and gated *unchanged* writes;
//! this module removes the mutation itself — the remaining half of 1370's finding.
//!
//! **The encoding: rows are DELTAS from the built seed, and zero is identity.** A registered
//! material keeps its build-time `sun_scale.zw = sample(0.0)` seed (and its `tint` first-key)
//! untouched for ever; the shader adds `matanim[slot]` on top, with **row 0 pinned to zero** so
//! `anim_slots = 0` — every static material — reads a no-op without a branch. Zero-as-identity is
//! [`crate::instance_tint`]'s own argument, and it buys the same three properties here: the
//! portrait booths' studio buffers (zeroed region ⇒ the seed pose, the static studio look),
//! deterministic captures (the tick is skipped ⇒ zero deltas ⇒ bit-identical to the old seed
//! frames), and slot exhaustion (no slot ⇒ frozen at seed, never garbage).
//!
//! Region layout: between the rig-origin table and the palette rows (the palette array is
//! runtime-sized, so it must stay last) — `wow_model.wgsl`'s struct mirrors this order.
use std::sync::Arc;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::renderer::RenderQueue;
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;
/// 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.
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).
pub(crate) fn region_bytes() -> u64 {
(MAX_MAT_ANIM_SLOTS * 16) as u64
}
/// The live delta table. `Arc`-shared so the render-world extract is a pointer bump, and
/// generation-stamped so a scene with nothing animated in view uploads nothing at all
/// ([`crate::instance_tint`]'s pattern, verbatim).
#[derive(Resource, Clone, ExtractResource)]
pub struct MatAnimTable {
rows: Arc<Vec<[f32; 4]>>,
generation: u64,
/// Slot allocator: the next never-used slot, and the freed ones. Slot 0 is never handed out.
next: u16,
free: Vec<u16>,
}
impl Default for MatAnimTable {
fn default() -> Self {
Self {
rows: Arc::new(vec![[0.0; 4]; MAX_MAT_ANIM_SLOTS]),
generation: 0,
next: 1,
free: Vec::new(),
}
}
}
impl MatAnimTable {
/// Allocate a slot (1-based; 0 is the identity row). `None` when the table is full — the
/// caller then simply doesn't register, and the batch stays frozen at its built seed: a
/// degraded look only a >511-material session could see, never a wrong pixel.
pub(crate) fn alloc(&mut self) -> Option<u16> {
if let Some(slot) = self.free.pop() {
return Some(slot);
}
if (self.next as usize) < MAX_MAT_ANIM_SLOTS {
let slot = self.next;
self.next += 1;
Some(slot)
} else {
None
}
}
/// Free a slot when its registry entry dies (the material was unloaded): the row zeroes —
/// back to identity — so the next allocation can never inherit a dead batch's delta.
pub(crate) fn free(&mut self, slot: u16) {
self.set(slot, [0.0; 4]);
self.free.push(slot);
}
/// Write slot `slot`'s delta row; a same-value write costs nothing (the tick's quantized
/// samples make equality the common case on slow loops). Slot 0 — the shared identity — is
/// refused: writing it would scroll every static batch in the world at once.
pub(crate) fn set(&mut self, slot: u16, row: [f32; 4]) {
let i = slot as usize;
if i == 0 || i >= MAX_MAT_ANIM_SLOTS || self.rows[i] == row {
return;
}
Arc::make_mut(&mut self.rows)[i] = row;
self.generation += 1;
}
/// This slot's current row (test support — the render path reads the uploaded region).
#[cfg(test)]
pub(crate) fn get(&self, slot: u16) -> [f32; 4] {
self.rows.get(slot as usize).copied().unwrap_or([0.0; 4])
}
}
/// Render world (`PrepareResources`): write the whole 8 KB region when anything changed, gated on
/// the generation — a scene with no drawn animated material (most of the world) never touches the
/// queue. Whole-region writes for the same reason `instance_tint` chose them: the table is small
/// and the write is one queue call.
fn upload_mat_anim(
queue: Res<RenderQueue>,
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
table: Option<Res<MatAnimTable>>,
mut last: Local<Option<u64>>,
) {
let (Some(shared), Some(table)) = (shared, table) else {
return;
};
if *last == Some(table.generation) {
return;
}
*last = Some(table.generation);
queue.write_buffer(
&shared.0,
region_offset(),
bytemuck::cast_slice(table.rows.as_slice()),
);
}
pub fn plugin(app: &mut App) {
app.init_resource::<MatAnimTable>()
.add_plugins(ExtractResourcePlugin::<MatAnimTable>::default());
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(
Render,
upload_mat_anim.in_set(RenderSystems::PrepareResources),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Slot 0 is the identity row every static material in the world reads — the setter refuses
/// it, and freeing can never zero it "again" into a generation bump.
#[test]
fn slot_zero_is_never_written() {
let mut t = MatAnimTable::default();
t.set(0, [1.0, 2.0, 3.0, 4.0]);
assert_eq!(t.get(0), [0.0; 4]);
assert_eq!(t.generation, 0, "and it costs no upload");
}
/// The allocator hands out 1.., recycles freed slots, and a freed slot's row is back to
/// identity before anyone can inherit it.
#[test]
fn alloc_free_recycles_and_zeroes() {
let mut t = MatAnimTable::default();
let a = t.alloc().unwrap();
assert_eq!(a, 1);
t.set(a, [0.25, -0.5, 0.0, 0.0]);
assert_eq!(t.get(a), [0.25, -0.5, 0.0, 0.0]);
t.free(a);
assert_eq!(t.get(a), [0.0; 4], "freed row is identity");
assert_eq!(t.alloc().unwrap(), a, "freed slot recycles first");
}
/// Generation moves only on real content changes — the upload gate's whole premise.
#[test]
fn the_generation_tracks_real_changes_only() {
let mut t = MatAnimTable::default();
let s = t.alloc().unwrap();
assert_eq!(t.generation, 0, "allocation alone uploads nothing");
t.set(s, [0.1, 0.2, 0.0, 0.0]);
assert_eq!(t.generation, 1);
t.set(s, [0.1, 0.2, 0.0, 0.0]);
assert_eq!(t.generation, 1, "same row, no upload");
}
/// Exhaustion answers `None` (the caller skips registration — frozen at seed), and freed
/// slots make it recoverable.
#[test]
fn exhaustion_is_none_and_recoverable() {
let mut t = MatAnimTable::default();
let slots: Vec<u16> = std::iter::from_fn(|| t.alloc()).collect();
assert_eq!(slots.len(), MAX_MAT_ANIM_SLOTS - 1, "slot 0 is reserved");
assert!(t.alloc().is_none());
t.free(slots[7]);
assert_eq!(t.alloc(), Some(slots[7]));
}
}
+9 -4
View File
@@ -455,6 +455,9 @@ pub fn model_material(
if window { 1.0 } else { 0.0 },
)
},
// Static until a sampler registers this material (decision 1381) — then the
// table slot is baked in exactly once.
anim_slots: Vec4::ZERO,
light_buf: light.clone(),
},
});
@@ -612,6 +615,7 @@ pub fn zfill_material(
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
sidn: Vec4::ZERO,
anim_slots: Vec4::ZERO,
light_buf: light.clone(),
},
});
@@ -804,10 +808,11 @@ pub(crate) fn classify_water_side(
mut removed: RemovedComponents<MeshMaterial3d<WowModelMaterial>>,
mut eye_was_submerged: Local<Option<bool>>,
) {
// A texanim/tint ticker mutates the NEAR asset in place every drawn frame
// (`doodad_anim::tick_anim_materials`); mirror the edit into the live twin, or a far-side
// waterfall batch freezes its scroll the moment it classifies far. Our own twin writes touch
// only far ids (never `to_far` keys), so this can't feed back.
// Mirror any near-asset edit into the live twin. Since decision 1381 the texanim/tint lane
// animates through the shared table (the twin's clone carries the same slot and seed, so it
// scrolls in phase with no mirror needed), which leaves this listener idle in the steady
// state — it now catches only genuine one-off near edits (a debug repaint, a future lane).
// Our own twin writes touch only far ids (never `to_far` keys), so this can't feed back.
let edited: Vec<AssetId<WowModelMaterial>> = near_edits
.read()
.filter_map(|ev| match ev {
+14 -7
View File
@@ -81,16 +81,16 @@ pub(crate) fn rig_origin_region_offset() -> u64 {
}
/// Bytes the origin table adds (32 KB at 2048 slots).
fn rig_origin_region_bytes() -> u64 {
pub(crate) fn rig_origin_region_bytes() -> u64 {
MAX_RIG_SLOTS as u64 * 16
}
/// Byte offset of the palette rows themselves — after the slot table, the per-instance tint table
/// that shares this slot index ([`crate::instance_tint`], decision 0812), and the origin table.
/// The rows stay last because `wow_model.wgsl` declares them as the struct's one runtime-sized
/// array.
/// that shares this slot index ([`crate::instance_tint`], decision 0812), the origin table, and
/// the mat-anim delta table ([`crate::mat_anim_table`], decision 1381). The rows stay last
/// because `wow_model.wgsl` declares them as the struct's one runtime-sized array.
pub(crate) fn palette_region_offset() -> u64 {
rig_origin_region_offset() + rig_origin_region_bytes()
crate::mat_anim_table::region_offset() + crate::mat_anim_table::region_bytes()
}
/// Total bytes the slot-indexed regions add to every `wow_light`-layout buffer
@@ -100,6 +100,7 @@ pub(crate) fn palette_regions_bytes() -> u64 {
(MAX_RIG_SLOTS * 4) as u64
+ crate::instance_tint::region_bytes()
+ rig_origin_region_bytes()
+ crate::mat_anim_table::region_bytes()
+ MAX_PALETTE_BONES as u64 * BONE_BYTES
}
@@ -1096,15 +1097,21 @@ mod tests {
"the rig-origin table follows the tint table"
);
assert_eq!(
palette_region_offset() - rig_origin_region_offset(),
crate::mat_anim_table::region_offset() - rig_origin_region_offset(),
rig_origin_region_bytes(),
"the palette rows follow the origin table"
"the mat-anim table follows the origin table (decision 1381)"
);
assert_eq!(
palette_region_offset() - crate::mat_anim_table::region_offset(),
crate::mat_anim_table::region_bytes(),
"the palette rows follow the mat-anim table"
);
assert_eq!(
palette_regions_bytes(),
(MAX_RIG_SLOTS * 4) as u64
+ crate::instance_tint::region_bytes()
+ rig_origin_region_bytes()
+ crate::mat_anim_table::region_bytes()
+ (MAX_PALETTE_BONES as u64) * BONE_BYTES
);
// One `vec4` per addressable slot — the shader declares `array<vec4<f32>, 2048>`.
@@ -107,6 +107,9 @@ pub fn spawn_model_entities(
// `rgb_anim` loop registers its material here so `tick_anim_materials` re-samples the
// shared tint each frame.
tint_reg: &mut crate::doodad_anim::TintAnimMaterials,
// The shared delta table both registries slot into (decision 1381) — registration allocates
// here and bakes the slot into the material.
anim_table: &mut crate::mat_anim_table::MatAnimTable,
// `Some(anchor)` for a prop spawned ON a streamed entity (the WMO-gameobject path): a boneless
// model's billboard cards FOLLOW this anchor (`BillboardCard::following` — the entity-path law,
// decision 0153) instead of baking a world pivot, so they track the moving owner and
@@ -267,17 +270,14 @@ pub fn spawn_model_entities(
// 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) {
uv_reg.0.entry(cutout.id()).or_insert_with(|| anim.clone());
uv_reg.0.entry(blend.id()).or_insert_with(|| anim.clone());
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);
}
// …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) {
tint_reg
.0
.entry(cutout.id())
.or_insert_with(|| anim.clone());
tint_reg.0.entry(blend.id()).or_insert_with(|| anim.clone());
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);
}
// `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);
@@ -86,6 +86,7 @@ pub(super) fn spawn_loaded_placements(
time: Res<Time>,
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
mut anim_table: ResMut<crate::mat_anim_table::MatAnimTable>,
// Nested to stay inside Bevy's 16-element system-param tuple limit: the prop-probe table +
// the stream-trace counters + the live/settling state the landing cap reads + the
// model-forms cache (decision 0834).
@@ -181,6 +182,7 @@ pub(super) fn spawn_loaded_placements(
Some((m, now)),
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
);
// ADT doodads are exterior scene: from inside a WMO they draw only through a
@@ -295,6 +297,7 @@ pub(super) fn spawn_loaded_placements(
None, // WMO group geometry is not an M2 — its doodad props animate below
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
);
// A world WMO placement is exterior scene too (`0x6856c0`, fed by the same
@@ -586,6 +589,7 @@ pub(super) fn spawn_loaded_placements(
Some((m, now)),
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
);
// The slot frees itself when the prop despawns (streaming out) — the component hook
+15 -5
View File
@@ -355,11 +355,15 @@ fn compute_wmo_pvs(
};
let groups = model.group_nav.len();
// A WMO with no portal graph (single-group props, doors) is never culled — keep all groups on.
// Written through the change gate below like the flood's own answer: this branch used to
// deref-mut every portal-less prop every frame, which marked the whole population changed
// and would defeat any future `Changed<WmoPortalInstance>` run condition (the 1364 item-2
// precondition) before it was born.
if model.portal_refs.is_empty() || model.portal_infos.is_empty() {
if inst.visible.len() != groups {
inst.visible = vec![true; groups];
} else {
inst.visible.iter_mut().for_each(|v| *v = true);
let held = inst.bypass_change_detection();
if held.visible.len() != groups || held.visible.iter().any(|v| !*v) {
held.visible = vec![true; groups];
inst.set_changed();
}
continue;
}
@@ -379,7 +383,9 @@ fn compute_wmo_pvs(
let mut log = dump_text
.is_some()
.then(|| TraceLog::new(model, eye_local, terrain_local));
inst.visible = compute_pvs_traced(
// Compare-then-write (1362's shape): a parked camera's flood answers the same set every
// frame, and writing it anyway re-marked every instance changed — see the branch above.
let fresh = compute_pvs_traced(
model,
eye_local,
terrain_local,
@@ -387,6 +393,10 @@ fn compute_wmo_pvs(
&world_from_local,
&mut (&mut tap, &mut log),
);
if inst.bypass_change_detection().visible != fresh {
inst.bypass_change_detection().visible = fresh;
inst.set_changed();
}
if let (Some(text), Some(log)) = (&mut dump_text, &log) {
text.push_str(&log.text);
text.push_str(&format!("visible: {:?}\n\n", inst.visible));
@@ -96,6 +96,10 @@ impl PluginGroup for WorldPlugins {
// The per-instance body tint (decision 0812), on the same slot index as that palette:
// the aura state kit's CharProc-1 colour, in its own region of the shared light buffer.
.add(crate::instance_tint::plugin)
// The mat-anim delta table (decision 1381), one region over: the per-frame samples of
// every UV/tint-animated batch material, so animating a waterfall never mutates its
// material asset again.
.add(crate::mat_anim_table::plugin)
// The M2 render lane's three own plugins (decision 1163, stage zero). All three used to
// be registered by `EntitiesPlugin` — the entity streamer — which is why booting the
// engine without the game left the model-`Visibility` authority reading a