the walking tax banded, and the minimap stops rewriting the world
- a panning UI batch rides its Transform instead of rewriting its mesh, minimap tiles emit unclipped (the mask shader already zeroes the corners), and the blip layer's origin snaps to half a logical pixel — steady UI mesh writes while moving drop from ~183/s to 10–14/s, and three minimap quads stop arming change detection over ~40k world meshes - the classify twin GC comes off the despawn edge: a part despawn schedules the full walk behind a 2 s deadline instead of running it that frame — 12–22 full walks a second while walking, gone - the walking tax measured and banded at Goldshire: parked 10.7 → moving 14.1 ms/frame of process CPU, ~+2.5 after the two fixes above - WOW_PART_CHURN and WOW_MESH_HOLDERS count part spawn/despawn frames and name which entities hold a churning mesh; the capture rig's `at:` pin learns a facing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@
|
||||
//! | `gear:<t>` | `.character premade gear <t>` | SEC_BASIC_ADMIN 4 | name or entry; levels up + equips |
|
||||
//! | `spec:<t>` | `.character premade spec <t>` | SEC_BASIC_ADMIN 4 | resets talents, learns the tree |
|
||||
//! | `at:<name>` | `.tele <name>` | SEC_TICKETMASTER 2 | 997 named rows in `game_tele` |
|
||||
//! | `at:m,x,y,z` | `.go xyz x y z m` | SEC_TICKETMASTER 2 | |
|
||||
//! | `at:m,x,y,z[,o]` | `.go xyz x y z m` (`.go xyzo` with `o`) | SEC_TICKETMASTER 2 | pin the facing when a motion probe walks |
|
||||
//! | `gm:on\|off` | `.gm on\|off` | SEC_GAMEMASTER 3 | see 0649 on why `off` matters |
|
||||
//!
|
||||
//! Probe accounts are gmlevel **6**, so every one of these lands. Pass `gear:?` (or `spec:?`) to
|
||||
@@ -506,15 +506,21 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
|
||||
}
|
||||
if let Some(at) = &spec.at {
|
||||
steps.push(match parse_point(at) {
|
||||
Some((map, x, y, z)) => format!(".go xyz {x} {y} {z} {map}"),
|
||||
Some((map, x, y, z, Some(o))) => format!(".go xyzo {x} {y} {z} {o} {map}"),
|
||||
Some((map, x, y, z, None)) => format!(".go xyz {x} {y} {z} {map}"),
|
||||
None => format!(".tele {at}"),
|
||||
});
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// `map,x,y,z` → the `.go xyz` argument order (`x y z map`). Anything else is a `game_tele` name.
|
||||
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32)> {
|
||||
/// `map,x,y,z[,o]` → `.go xyz x y z map`, or `.go xyzo x y z o map` when a facing is given
|
||||
/// (VERIFIED vmangos `Chat.cpp:408`: `xyzo`, SEC_TICKETMASTER like `xyz`). Anything else is a
|
||||
/// `game_tele` name. The facing is load-bearing exactly when a motion probe walks from the
|
||||
/// pin: `W` follows the BODY's facing, and each probe character keeps whatever facing it last
|
||||
/// had — an unpinned facing sent probe0 and probe4 down different routes from the same point,
|
||||
/// which invalidated a cross-slot A/B before 1462's sitting caught it.
|
||||
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
|
||||
let mut parts = at.split(',').map(str::trim);
|
||||
let map = parts.next()?.parse().ok()?;
|
||||
let (x, y, z) = (
|
||||
@@ -522,7 +528,11 @@ fn parse_point(at: &str) -> Option<(i32, f32, f32, f32)> {
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
);
|
||||
parts.next().is_none().then_some((map, x, y, z))
|
||||
let o = match parts.next() {
|
||||
Some(o) => Some(o.parse().ok()?),
|
||||
None => None,
|
||||
};
|
||||
parts.next().is_none().then_some((map, x, y, z, o))
|
||||
}
|
||||
|
||||
fn rig_char_name(spec: &RigSpec) -> Option<String> {
|
||||
@@ -743,9 +753,14 @@ mod tests {
|
||||
build_steps(&point, false),
|
||||
vec![".go xyz -1277.5 124 131.2 1"]
|
||||
);
|
||||
let faced = RigSpec::parse("at:0,-9399.03,10.41,59.83,0.0").unwrap();
|
||||
assert_eq!(
|
||||
build_steps(&faced, false),
|
||||
vec![".go xyzo -9399.03 10.41 59.83 0 0"]
|
||||
);
|
||||
assert_eq!(parse_point("ThunderBluff"), None);
|
||||
assert_eq!(parse_point("1,2,3"), None); // three numbers is not a map + point
|
||||
assert_eq!(parse_point("1,2,3,4,5"), None);
|
||||
assert_eq!(parse_point("1,2,3,4,5,6"), None); // and six is past even a faced point
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -517,7 +517,11 @@ fn emit_minimap(
|
||||
z_key: slot.z,
|
||||
texture: Some(handle.clone()),
|
||||
color: [tint[0], tint[1], tint[2], slot.alpha],
|
||||
clip: Some(slot.rect),
|
||||
// No CPU clip (1463): the mask shader already zeroes everything outside
|
||||
// `mask_rect` (`ui_quad.wgsl`'s `inside` test), and clipping a PANNING tile
|
||||
// re-cut its quad every frame — constant positions, churning UVs — which is
|
||||
// exactly the shape the batcher's pan gate cannot ride on a `Transform`.
|
||||
// Unclipped, the tile is a pure translation and never rewrites its mesh.
|
||||
mask: mask.clone(),
|
||||
..default()
|
||||
});
|
||||
@@ -552,6 +556,14 @@ fn emit_minimap(
|
||||
}
|
||||
let win = window.iter().next();
|
||||
let cursor = win.and_then(|w| w.cursor_position());
|
||||
// The player's pan term, quantized to a half-logical-pixel grid (one device px at 2×):
|
||||
// every blip offset — and the rim arrows' bearing — derives from `wx`/`wy`, so the whole
|
||||
// blip layer steps together a few times a second instead of re-emitting sub-pixel-shifted
|
||||
// quads every frame while walking (1463; a 0.07 px/frame slide on a 16 px icon is not a
|
||||
// visible motion, but each slide rewrote the batch mesh and armed the world's
|
||||
// `AssetChanged` scans). The blips' world positions stay exact — only the shared pan
|
||||
// origin snaps.
|
||||
let q = 0.5 / blip_px_per_yd;
|
||||
blips::BlipCtx {
|
||||
center,
|
||||
side,
|
||||
@@ -559,8 +571,8 @@ fn emit_minimap(
|
||||
radius_yd: (side * 0.5) / blip_px_per_yd,
|
||||
z: slot.z,
|
||||
alpha: slot.alpha,
|
||||
wx,
|
||||
wy,
|
||||
wx: (wx / q).round() * q,
|
||||
wy: (wy / q).round() * q,
|
||||
wz: wow[2],
|
||||
cursor,
|
||||
// The same point in UI space (y-up, ÷s through the 0582/0584 seam — the tooltip's
|
||||
|
||||
@@ -295,6 +295,101 @@ pub(super) fn count_mesh_events(
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_PART_CHURN=1`: per-second M2-part churn. `rm_frames` is the count of frames with ≥1
|
||||
/// `MeshMaterial3d<WowModelMaterial>` removal — exactly the predicate that promotes
|
||||
/// `classify_water_side` to its full walk (0930's twin-GC mark), so a regime reading ~60 here
|
||||
/// full-walks every frame. `mat_mod` counts `WowModelMaterial` asset Modified events — the
|
||||
/// global asset-changed tick that wakes every `AssetChanged` scan.
|
||||
pub(super) fn count_part_churn(
|
||||
added: Query<(), Added<MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>>,
|
||||
mut removed: RemovedComponents<MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
|
||||
mut mat_events: MessageReader<
|
||||
bevy::asset::AssetEvent<benilla_assets::materials::WowModelMaterial>,
|
||||
>,
|
||||
time: Res<Time>,
|
||||
mut acc: Local<(f32, u32, u32, u32, u32)>,
|
||||
) {
|
||||
let (last, add_n, rm_n, rm_frames, mat_mod) = &mut *acc;
|
||||
*add_n += added.iter().count() as u32;
|
||||
let rm = removed.read().count() as u32;
|
||||
*rm_n += rm;
|
||||
if rm > 0 {
|
||||
*rm_frames += 1;
|
||||
}
|
||||
*mat_mod += mat_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, bevy::asset::AssetEvent::Modified { .. }))
|
||||
.count() as u32;
|
||||
if time.elapsed_secs() - *last >= 1.0 {
|
||||
eprintln!(
|
||||
"[part-churn] parts +{add_n} -{rm_n} rm_frames={rm_frames}/s mat_modified={mat_mod}/s"
|
||||
);
|
||||
(*add_n, *rm_n, *rm_frames, *mat_mod) = (0, 0, 0, 0);
|
||||
*last = time.elapsed_secs();
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_MESH_HOLDERS=1`: once a second, the set of mesh asset ids Modified in the last second
|
||||
/// and the component signature of every entity holding one — [`count_mesh_events`] counts and
|
||||
/// samples the ids, this names the writer by its holder's archetype (an id alone says nothing).
|
||||
/// Exclusive, like [`arch_census`], for the same reason: the signature needs the live archetype.
|
||||
pub(super) fn mesh_holders(
|
||||
world: &mut World,
|
||||
mut cursor: Local<Option<bevy::ecs::message::MessageCursor<bevy::asset::AssetEvent<Mesh>>>>,
|
||||
mut acc: Local<(f32, std::collections::HashSet<bevy::asset::AssetId<Mesh>>)>,
|
||||
) {
|
||||
let messages = world.resource::<bevy::ecs::message::Messages<bevy::asset::AssetEvent<Mesh>>>();
|
||||
let cursor = cursor.get_or_insert_with(|| messages.get_cursor());
|
||||
let (last, ids) = &mut *acc;
|
||||
ids.extend(cursor.read(messages).filter_map(|e| match e {
|
||||
bevy::asset::AssetEvent::Modified { id } => Some(*id),
|
||||
_ => None,
|
||||
}));
|
||||
let now = world.resource::<Time>().elapsed_secs();
|
||||
if now - *last < 1.0 {
|
||||
return;
|
||||
}
|
||||
*last = now;
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let short = |full: &str| -> String {
|
||||
let base = full.split('<').next().unwrap_or(full);
|
||||
let segs: Vec<&str> = base.split("::").collect();
|
||||
segs[segs.len().saturating_sub(2)..].join("::")
|
||||
};
|
||||
let mut found = 0usize;
|
||||
let mut holders = world.query::<(Entity, &Mesh3d)>();
|
||||
let matches: Vec<Entity> = holders
|
||||
.iter(world)
|
||||
.filter(|(_, m)| ids.contains(&m.0.id()))
|
||||
.map(|(e, _)| e)
|
||||
.take(6)
|
||||
.collect();
|
||||
for e in matches {
|
||||
found += 1;
|
||||
let sig: Vec<String> = world
|
||||
.entity(e)
|
||||
.archetype()
|
||||
.components()
|
||||
.iter()
|
||||
.filter_map(|&c| world.components().get_info(c))
|
||||
.map(|i| short(&i.name().to_string()))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"[mesh-holders] {e} holds a modified mesh: {}",
|
||||
sig.join("+")
|
||||
);
|
||||
}
|
||||
if found == 0 {
|
||||
eprintln!(
|
||||
"[mesh-holders] {} modified id(s), no Mesh3d holder (2d or unowned)",
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
ids.clear();
|
||||
}
|
||||
|
||||
/// When the one-shot archetype census fires (seconds of `Time` elapsed; `f32::MAX` = spent).
|
||||
#[derive(Resource)]
|
||||
pub(super) struct ArchCensusAt(pub(super) f32);
|
||||
|
||||
@@ -99,6 +99,17 @@ impl Plugin for PerfPlugin {
|
||||
if std::env::var_os("WOW_MESH_EVENTS").is_some() {
|
||||
app.add_systems(Update, census::count_mesh_events);
|
||||
}
|
||||
// `WOW_PART_CHURN=1` — the moving-regime premise counters (1461): how many frames per
|
||||
// second despawn an M2 part (= `classify_water_side` full-walk promotions), and how
|
||||
// often model materials fire Modified (= the `AssetChanged` scan wake-ups).
|
||||
if std::env::var_os("WOW_PART_CHURN").is_some() {
|
||||
app.add_systems(Update, census::count_part_churn);
|
||||
}
|
||||
// `WOW_MESH_HOLDERS=1` — who OWNS the meshes `WOW_MESH_EVENTS` counts? Prints each
|
||||
// holder's archetype signature once a second (1461's writer hunt).
|
||||
if std::env::var_os("WOW_MESH_HOLDERS").is_some() {
|
||||
app.add_systems(Last, census::mesh_holders);
|
||||
}
|
||||
// `WOW_CAM_CHANGED=1` — is the world camera's transform bit-stable at a parked pin?
|
||||
// The premise counter for gating the per-submesh visibility sweep on frame-stable
|
||||
// camera inputs: if the controller rewrites an equal transform every frame, change
|
||||
|
||||
@@ -656,10 +656,18 @@ impl Run {
|
||||
struct BatchPools {
|
||||
entities: Vec<Entity>,
|
||||
meshes: Vec<Handle<Mesh>>,
|
||||
/// Each slot's last-written content — the per-run skip gate (decision 1361): a slot whose
|
||||
/// run is bit-identical to what its pooled mesh already holds is not rewritten, so one
|
||||
/// animating quad no longer drags every batch through the GPU mesh allocator.
|
||||
/// Each slot's last-written MESH content (base positions, before [`Self::offsets`]) — the
|
||||
/// per-run skip gate (decision 1361): a slot whose run is bit-identical to what its pooled
|
||||
/// mesh already holds is not rewritten, so one animating quad no longer drags every batch
|
||||
/// through the GPU mesh allocator.
|
||||
stored: Vec<StoredRun>,
|
||||
/// Each slot's current XY translation from its stored base, carried by the batch entity's
|
||||
/// `Transform` instead of the mesh (decision 1463): a run that only *panned* — the minimap
|
||||
/// tile was ~74% of all moving-regime rebuild triggers — moves without an `Assets<Mesh>`
|
||||
/// write, because ONE Modified event per frame arms `AssetChanged` probes over every
|
||||
/// `Mesh3d` row in the scene (1370's all-or-nothing fast path, ~0.8 ms/frame at 1461's
|
||||
/// Goldshire pin).
|
||||
offsets: Vec<Vec2>,
|
||||
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
|
||||
}
|
||||
|
||||
@@ -676,6 +684,55 @@ struct StoredRun {
|
||||
key: MatKey,
|
||||
}
|
||||
|
||||
impl StoredRun {
|
||||
/// The candidate's uniform XY offset from this base: `Some(d)` when every vertex is this
|
||||
/// run's vertex plus one constant delta and everything else is bit-equal — `Some(ZERO)` is
|
||||
/// exactly the old bit-identical skip case, so this subsumes 1361's gate. `None` means the
|
||||
/// run genuinely changed shape and must be rebaked. The comparison is exact float
|
||||
/// arithmetic on purpose: a miss only falls back to the rewrite path, so a widget whose
|
||||
/// pan doesn't survive `b + d == n` bit-exactly loses the optimization, never correctness.
|
||||
/// Why [`Self::translation_from`] missed — the pan-gate diagnostic (`WOW_UI_DIFF=1`).
|
||||
fn translation_miss_reason(&self, new: &StoredRun) -> &'static str {
|
||||
if self.key != new.key {
|
||||
"key"
|
||||
} else if self.z_bits != new.z_bits {
|
||||
"z"
|
||||
} else if self.positions.len() != new.positions.len() {
|
||||
"len"
|
||||
} else if self.indices != new.indices {
|
||||
"indices"
|
||||
} else if self.uvs != new.uvs {
|
||||
"uvs"
|
||||
} else if self.colors != new.colors {
|
||||
"colors"
|
||||
} else {
|
||||
"delta-nonuniform"
|
||||
}
|
||||
}
|
||||
|
||||
fn translation_from(&self, new: &StoredRun) -> Option<Vec2> {
|
||||
if self.key != new.key
|
||||
|| self.z_bits != new.z_bits
|
||||
|| self.positions.len() != new.positions.len()
|
||||
|| self.positions.is_empty()
|
||||
|| self.indices != new.indices
|
||||
|| self.uvs != new.uvs
|
||||
|| self.colors != new.colors
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let d = Vec2::new(
|
||||
new.positions[0][0] - self.positions[0][0],
|
||||
new.positions[0][1] - self.positions[0][1],
|
||||
);
|
||||
self.positions
|
||||
.iter()
|
||||
.zip(&new.positions)
|
||||
.all(|(b, n)| n[0] == b[0] + d.x && n[1] == b[1] + d.y && n[2] == b[2])
|
||||
.then_some(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// A material's full identity: texture, blend/shape flags, mask texture + mask rect (as bits, so
|
||||
/// NaN/-0.0 can't split cache entries byte-equal materials would share).
|
||||
type MatKey = (
|
||||
@@ -696,8 +753,10 @@ fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// The skip gate must forget with them (decision 1361): a retired slot's entity is gone, so
|
||||
// "content unchanged" must not skip the respawn when the UI returns.
|
||||
// "content unchanged" must not skip the respawn when the UI returns. The pan gate's
|
||||
// offsets ride the same lifecycle (they index the same slots).
|
||||
pools.stored.clear();
|
||||
pools.offsets.clear();
|
||||
}
|
||||
|
||||
/// Per frame: when the BASE lane flagged a change ([`UiQuads::dirty`]) or the APPEND lane's
|
||||
@@ -962,10 +1021,54 @@ fn rebuild_ui_mesh(
|
||||
z_bits: z.to_bits(),
|
||||
key,
|
||||
};
|
||||
if pools.stored.get(used) == Some(&stored) {
|
||||
// The pan gate (1463): a run that matches its slot's base up to one constant XY delta
|
||||
// moves on the batch entity's `Transform` — no mesh write, no `AssetChanged` arming.
|
||||
// `Some(ZERO)` is the bit-identical case (1361's old skip gate), where even the
|
||||
// `Transform` write is skipped unless a previous pan is being undone.
|
||||
let pan = pools
|
||||
.stored
|
||||
.get(used)
|
||||
.and_then(|prev| prev.translation_from(&stored).map(|d| (d, prev.z_bits)));
|
||||
if let Some((d, z_bits)) = pan {
|
||||
if pools.offsets[used] != d {
|
||||
pools.offsets[used] = d;
|
||||
if let Some(&entity) = pools.entities.get(used) {
|
||||
commands.entity(entity).insert(Transform::from_xyz(
|
||||
d.x,
|
||||
d.y,
|
||||
f32::from_bits(z_bits),
|
||||
));
|
||||
}
|
||||
}
|
||||
used += 1;
|
||||
continue;
|
||||
}
|
||||
// Pan-gate miss diagnostic (`WOW_UI_DIFF=1`, ≤3 lines/s so a startup burst can't
|
||||
// exhaust it): names the check that sent this slot to the rewrite path.
|
||||
if *UI_DIFF {
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
static SHOWN: AtomicU32 = AtomicU32::new(0);
|
||||
static LAST_SEC: AtomicU64 = AtomicU64::new(0);
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
let sec = START
|
||||
.get_or_init(std::time::Instant::now)
|
||||
.elapsed()
|
||||
.as_secs();
|
||||
if LAST_SEC.swap(sec, Ordering::Relaxed) != sec {
|
||||
SHOWN.store(0, Ordering::Relaxed);
|
||||
}
|
||||
if SHOWN.fetch_add(1, Ordering::Relaxed) < 3 {
|
||||
let why = pools
|
||||
.stored
|
||||
.get(used)
|
||||
.map_or("no-prev", |p| p.translation_miss_reason(&stored));
|
||||
eprintln!(
|
||||
"[ui-pan] slot {used} rewrite: {why} (quads={}, key.tex={:?})",
|
||||
stored.positions.len() / 4,
|
||||
stored.key.0
|
||||
);
|
||||
}
|
||||
}
|
||||
// MAIN_WORLD too (not RENDER_WORLD-only): the pool rewrites this asset in place next
|
||||
// rebuild, so the main-world copy must survive extraction.
|
||||
let mut mesh = Mesh::new(
|
||||
@@ -981,6 +1084,13 @@ fn rebuild_ui_mesh(
|
||||
} else {
|
||||
pools.stored.push(stored);
|
||||
}
|
||||
// A rebaked slot's mesh holds absolute positions again — its pan resets with it (the
|
||||
// rewrite arm below writes `Transform::from_xyz(0, 0, z)`).
|
||||
if pools.offsets.len() > used {
|
||||
pools.offsets[used] = Vec2::ZERO;
|
||||
} else {
|
||||
pools.offsets.push(Vec2::ZERO);
|
||||
}
|
||||
let mesh_handle = match pools.meshes.get(used) {
|
||||
Some(handle) => {
|
||||
// `WOW_FREEZE_UI_MESH=1` — never rewrite a pooled batch mesh in place (the UI
|
||||
@@ -1048,6 +1158,7 @@ fn rebuild_ui_mesh(
|
||||
}
|
||||
pools.meshes.truncate(used);
|
||||
pools.stored.truncate(used);
|
||||
pools.offsets.truncate(used);
|
||||
}
|
||||
|
||||
/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both
|
||||
|
||||
@@ -787,12 +787,22 @@ pub struct FarSideOfWater;
|
||||
/// surfaces, and the eye's side — so a *clean* batch is skipped, and only the changed set
|
||||
/// re-classifies (~2.6k of ~40k parts on a measured Stormwind frame; the full sweep cost 2.2 ms).
|
||||
/// The frame promotes to the full walk — byte-identical to the old per-frame behaviour — whenever
|
||||
/// a global term moves: the eye crosses the surface (every verdict inverts), surfaces stream
|
||||
/// in/out, or any part despawned (the twin GC below needs its full mark; between full frames a
|
||||
/// pair orphaned by a handle overwrite — a fade settling to its cutout — lingers harmlessly, kept
|
||||
/// fresh by the mirror above, until the next edge sweeps it). The room-claim trigger fans DOWN:
|
||||
/// the claim lives on the unit root, the batches are its descendants, and a claim can change with
|
||||
/// the unit standing still (a building streaming in under it re-claims at the same spot).
|
||||
/// a global term that changes VERDICTS moves: the eye crosses the surface (every verdict
|
||||
/// inverts) or surfaces stream in/out. A part *despawn* changes no live verdict — it only
|
||||
/// obligates the twin GC's exact mark eventually — so it schedules a full walk behind
|
||||
/// [`GC_DEADLINE_SECS`] instead of promoting the same frame (decision 1462: walking streams
|
||||
/// parts out on 12–22 frames/s, and each promotion was a full re-classify of every part —
|
||||
/// lane B of 1461's walking tax; between full frames a pair orphaned by a handle overwrite —
|
||||
/// a fade settling to its cutout — lingers harmlessly, kept fresh by the mirror above, until
|
||||
/// the next edge sweeps it, and a GC-due twin lingers the same way). The room-claim trigger
|
||||
/// fans DOWN: the claim lives on the unit root, the batches are its descendants, and a claim
|
||||
/// can change with the unit standing still (a building streaming in under it re-claims at the
|
||||
/// same spot).
|
||||
/// How long a despawn-obligated twin GC may wait for its full walk (1462). Bounds how long an
|
||||
/// orphaned twin pair can outlive its last user — a handful of small material clones — while a
|
||||
/// walking regime's continuous despawn stream costs ~0.5 full walks/s instead of 12–22.
|
||||
const GC_DEADLINE_SECS: f32 = 2.0;
|
||||
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(crate) fn classify_water_side(
|
||||
interleave: crate::particles::WaterInterleave,
|
||||
@@ -834,7 +844,9 @@ pub(crate) fn classify_water_side(
|
||||
reclaimed: Query<Entity, Changed<crate::wmo_portal::UnitWmoRoom>>,
|
||||
children: Query<&Children>,
|
||||
mut removed: RemovedComponents<MeshMaterial3d<WowModelMaterial>>,
|
||||
time: Res<bevy::time::Time>,
|
||||
mut eye_was_submerged: Local<Option<bool>>,
|
||||
mut gc_due: Local<Option<f32>>,
|
||||
) {
|
||||
// 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
|
||||
@@ -858,12 +870,22 @@ pub(crate) fn classify_water_side(
|
||||
}
|
||||
}
|
||||
let eye = interleave.eye_submerged();
|
||||
// `last()` drains the reader — `next()` would leave the tail readable and promote NEXT frame
|
||||
// to a spurious second full walk.
|
||||
let now = time.elapsed_secs();
|
||||
// `last()` drains the reader — `next()` would leave the tail readable and re-arm NEXT frame
|
||||
// as a spurious second deadline. The earliest despawn holds the deadline (`get_or_insert`):
|
||||
// a steady despawn stream must not push the GC out forever.
|
||||
if removed.read().last().is_some() {
|
||||
gc_due.get_or_insert(now + GC_DEADLINE_SECS);
|
||||
}
|
||||
let full = *eye_was_submerged != Some(eye)
|
||||
|| interleave.surfaces_changed()
|
||||
|| removed.read().last().is_some();
|
||||
|| gc_due.is_some_and(|due| now >= due);
|
||||
*eye_was_submerged = Some(eye);
|
||||
if full {
|
||||
// Any full walk runs the GC's exact mark, whatever triggered it — a pending deadline
|
||||
// is satisfied, not merely postponed.
|
||||
*gc_due = None;
|
||||
}
|
||||
|
||||
if full {
|
||||
// The full walk — every part, plus the twin GC's exact mark-and-sweep.
|
||||
|
||||
Reference in New Issue
Block a user