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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user