the static merge defaults on, and the fence pop is gone

- every run now merges the static world: never-fade doodads and interior props bake into per-cell blobs, −0.42 ms at the Stormwind gates and ~−0.2 at Goldshire; WOW_STATIC_MERGE=0 opts out
- the fading doodads stay individual — one translucent draw per cell depth-killed whatever feathered behind it, the lamppost popping in behind a Goldshire fence — and WOW_MERGE_FADERS=1 keeps the dev lane
- merged blobs sort at their own centre instead of the world origin, and their pipeline variants pre-warm so no blob compiles live mid-walk and steps the fade
- roaming converges on the parked shape: a quiet cell re-bakes its fragments into one blob, and a doodad straddling two tiles hands off to a loaded neighbour when its owner tile unloads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:55:03 +07:00
co-authored by Claude Fable 5
parent 352b90a1ae
commit 4080d4feff
7 changed files with 805 additions and 67 deletions
@@ -414,6 +414,31 @@ pub(super) fn spawn_menagerie(
}
}
// The MERGED-BLOB layouts (`WOW_STATIC_MERGE`, 1417–1420): the static layouts plus the baked
// per-vertex fade sphere, and plus the interior-prop probe slot — four more strides
// (48/52, and their vertex-coloured 64/68), each a distinct pipeline family via the
// `WOW_MERGED_FADE`/`WOW_MERGED_SLOT` shader defs `specialize` keys on the layout. Built
// through the REAL blob mesh builder (`merged_static_mesh_faded`) so the warm layout can't
// drift from production. World camera only: a blob never reaches a portrait booth and never
// far-twins (no `DoodadFade`, so `classify_water_side` never swaps it). Missing these was a
// live compile — a director-felt stall — on the first blob of each family to enter view.
for colors in [false, true] {
for slot in [false, true] {
let part = std::sync::Arc::new(warm_quad(colors, false));
let (mesh, mn, mx, _center) = benilla_assets::merged_static_mesh_faded(
&[(part, Transform::IDENTITY)],
&[Vec4::new(0.0, 0.0, 0.0, 1.0)],
slot.then_some(&[0u32][..]),
);
let aabb = Some(bevy::camera::primitives::Aabb::from_min_max(mn, mx));
let mesh = meshes.add(mesh);
for mat in &mats {
spawn_model_rig(commands, cam, None, &mesh, &aabb, false, mat);
count += 1;
}
}
}
// The WMO-skybox rows (decision 1264): the STATIC PLAIN layout only, and only on the world
// camera. `wmo_sky::build_skybox` inserts POSITION + NORMAL + UV_0 — `layouts[0]` exactly — a
// skybox is never skinned (the asteroid belts' bones are a deferral, not a shipped lane), never
+39 -7
View File
@@ -812,30 +812,41 @@ pub(crate) fn build_global_bones(
/// same attribute recipe as [`submesh_to_static_mesh`], concatenated, with each part's placement
/// transform applied to positions and its rotation to normals (placements scale uniformly, so
/// directions survive). Parts lacking vertex colours pad white when any part has them — one
/// attribute set must cover the whole concatenation. Returns the mesh and its baked min/max for
/// the caller's Aabb.
/// attribute set must cover the whole concatenation.
///
/// **The mesh is recentred on its union-AABB centre** and that world centre is returned last:
/// the caller places the entity AT the centre (`Transform::from_translation`), and the returned
/// min/max are mesh-LOCAL for its `Aabb`. A world-baked mesh on `Transform::IDENTITY` sorts the
/// entity at the WORLD ORIGIN in Bevy's transparent phase — ~9 400 yd out, drawn first among
/// ALL transparent content with the twin's depth-write ON, so a feathering blob's translucent
/// pixels depth-killed every transparent entity drawn after it (a per-entity fader behind a
/// 20 %-alpha merged fence vanished outright, popping in only when the sightline cleared the
/// segment — decision 1422). Centre-sort restores the same location-sort semantics every
/// per-entity draw has (whose sort key is its placement origin).
pub fn merged_static_mesh(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
) -> (Mesh, Vec3, Vec3) {
) -> (Mesh, Vec3, Vec3, Vec3) {
merged_static_mesh_impl(parts, None)
}
/// [`merged_static_mesh`] plus the per-vertex [`ATTRIBUTE_WOW_FADE_SPHERE`] (one sphere per
/// PART, replicated onto each of its vertices) — the production blob build (1418). `slots`
/// (index-parallel with `parts` when `Some`) additionally bakes each part's SH-probe slot as
/// [`ATTRIBUTE_WOW_MERGED_SLOT`] — the interior-prop lane.
/// [`ATTRIBUTE_WOW_MERGED_SLOT`] — the interior-prop lane. The spheres stay WORLD-space —
/// the shader compares them against the camera, never against mesh-local positions — so the
/// recentring (see [`merged_static_mesh`]) does not touch them.
pub fn merged_static_mesh_faded(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
spheres: &[Vec4],
slots: Option<&[u32]>,
) -> (Mesh, Vec3, Vec3) {
) -> (Mesh, Vec3, Vec3, Vec3) {
merged_static_mesh_impl(parts, Some((spheres, slots)))
}
fn merged_static_mesh_impl(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
extras: Option<(&[Vec4], Option<&[u32]>)>,
) -> (Mesh, Vec3, Vec3) {
) -> (Mesh, Vec3, Vec3, Vec3) {
let any_colors = parts
.iter()
.any(|(s, _)| s.vertex_colors.len() == s.positions.len());
@@ -909,7 +920,28 @@ fn merged_static_mesh_impl(
}
}
mesh.insert_indices(Indices::U32(indices));
(mesh, mn, mx)
// Recentre on the union-AABB centre (see `merged_static_mesh`'s doc): positions become
// mesh-local, min/max follow, the centre goes back to the caller for the entity Transform.
// Guard the empty accumulation (no parts) — a MAX/MIN sentinel centre would explode.
// (`mn > mx` iff the vertex loop never ran; `positions` has been moved into the mesh.)
let center = if mn.x > mx.x {
Vec3::ZERO
} else {
(mn + mx) * 0.5
};
if center != Vec3::ZERO {
let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) =
mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION)
else {
unreachable!("positions were just inserted as Float32x3");
};
for p in pos.iter_mut() {
p[0] -= center.x;
p[1] -= center.y;
p[2] -= center.z;
}
}
(mesh, mn - center, mx - center, center)
}
#[cfg(test)]
+2 -2
View File
@@ -105,11 +105,11 @@ pub fn flush_mega_static(
.into_iter()
.map(|p| (p.geometry, p.transform))
.collect();
let (mesh, mn, mx) = merged_static_mesh(&geo);
let (mesh, mn, mx, center) = merged_static_mesh(&geo);
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(mat),
Transform::IDENTITY,
Transform::from_translation(center),
ModelPart { kind, blend },
MeshTag(alpha_bits(1.0)),
Aabb::from_min_max(mn, mx),
+204 -1
View File
@@ -490,6 +490,14 @@ impl Plugin for TerrainPlugin {
// would leave three systems walking empty queries to prove it every frame.
.run_if(crate::schedule::world_is_live),
)
// The blob-visibility dump (`WOW_BLOB_VIS=1`) — a dev instrument, registered only
// when asked for; see `merge::log_blob_vis`.
.add_systems(
Update,
merge::log_blob_vis
.run_if(merge::blob_vis_enabled)
.run_if(crate::schedule::world_is_live),
)
// The model-forms furnisher (decision 0834) runs UNGATED, unlike the chain above: the
// glue screens' character preview requests forms pre-world (`update_display_models`
// serves the glue stage too), and a world-live gate would starve it forever. Ordered
@@ -725,9 +733,17 @@ fn stream_terrain(
if let Some(t) = state.tiles.remove(&c) {
despawn_tile_owned(&mut commands, &t);
activity.tiles_dropped += 1;
for uid in t.placements {
for &uid in &t.placements {
release_placement(&mut commands, placements, uid, &mut activity);
}
handoff_straddlers(
&mut commands,
&state.tiles,
placements,
c,
&t.placements,
merge::merge_enabled(),
);
}
}
@@ -1222,6 +1238,69 @@ fn sync_interior_volumes(placements: Res<Placements>, mut vols: ResMut<WmoReside
);
}
/// **1417's owner-handoff re-emit.** A straddler's merged batches and welded hulls live with
/// its OWNER tile (`Placement::owner`), so an owner unloading out from under a placement that
/// a loaded neighbour still references left a permanent hole: the blob and hull died with the
/// owner's tile, and a later re-cross of the owner only bumps refs (`register_doodad`) — the
/// re-cross defect. Hand the placement to a still-loaded referrer instead and queue a respawn:
/// the spawner re-runs the whole assembler under the new owner — the merge divert and the hull
/// weld both read `p.owner` at spawn — so 1369's matching weld gap heals in the same stroke.
/// The per-entity churn is invisible (the unload line sits past every fade band), and the
/// respawned batches open as cell singletons that fold in on the next quiet window (1424's
/// recon). M2 map doodads only: a WMO's prop blobs ride the placement's own entity list, and
/// without the merge a surviving placement never lost anything (its entities are its own).
fn handoff_straddlers(
commands: &mut Commands,
tiles: &HashMap<(i32, i32), TileState>,
placements: &mut Placements,
dead: (i32, i32),
uids: &[u32],
merge_on: bool,
) {
if !merge_on {
return;
}
let mut handed = 0u32;
for &uid in uids {
let Some(p) = placements.by_id.get_mut(&uid) else {
continue; // refs hit zero — released outright, nothing survives to hand off
};
if p.owner != dead || !matches!(p.model, ModelHandle::M2(_)) {
continue;
}
// A straddler's other referrer shares its MDDF row across a tile seam, so it is an
// 8-neighbour of the dead owner; the full scan is a fallback for data that defies that.
let new_owner = [-1i32, 0, 1]
.iter()
.flat_map(|dx| [-1i32, 0, 1].map(|dy| (dead.0 + dx, dead.1 + dy)))
.find(|c| *c != dead && tiles.get(c).is_some_and(|t| t.placements.contains(&uid)))
.or_else(|| {
tiles
.iter()
.find(|(_, t)| t.placements.contains(&uid))
.map(|(c, _)| *c)
});
let Some(new_owner) = new_owner else {
warn!("straddler handoff: uid {uid} holds refs but no loaded tile references it");
continue;
};
p.owner = new_owner;
if p.spawned {
for e in p.entities.drain(..) {
commands.entity(e).try_despawn();
}
p.spawned = false;
}
handed += 1;
}
if handed > 0 {
info!(
"straddler handoff: {handed} placement(s) re-owned off dead tile ({},{})",
dead.0, dead.1
);
}
}
/// A tile referencing this placement unloaded: drop one ref, despawning its entities (render submeshes
/// + the avian collider entity) at zero — the collider rides the placement's entity lifecycle.
fn release_placement(
@@ -1329,3 +1408,127 @@ mod focus_tests {
);
}
}
#[cfg(test)]
mod straddler_tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
fn tile_listing(uids: &[u32]) -> TileState {
TileState {
handle: Default::default(),
entity: None,
material: None,
next_cell: 0,
furnished: false,
placements: uids.to_vec(),
liquid: Vec::new(),
wall: None,
clutter: Vec::new(),
welds: Vec::new(),
merged: Vec::new(),
}
}
fn straddler(owner: (i32, i32), entities: Vec<Entity>) -> Placement {
Placement {
model: ModelHandle::M2(Default::default()),
transform: Transform::IDENTITY,
entities,
spawned: true,
doodad_set: 0,
name_set: 0,
doodads: Vec::new(),
portal_instance: None,
refs: 1,
owner,
}
}
fn run_handoff(world: &mut World, dead: (i32, i32), uids: Vec<u32>, merge_on: bool) {
world
.run_system_once(
move |mut commands: Commands,
streamer: Res<TerrainStreamer>,
mut placements: ResMut<Placements>| {
handoff_straddlers(
&mut commands,
&streamer.tiles,
&mut placements,
dead,
&uids,
merge_on,
);
},
)
.unwrap();
}
/// The owner dies while a neighbour still lists the placement: ownership moves to the
/// neighbour and the placement is queued for respawn (entities down, `spawned` cleared) so
/// the assembler re-emits its merged batches and hulls under the new owner.
#[test]
fn a_dead_owner_hands_its_straddler_to_a_loaded_referrer() {
let mut world = World::new();
let doodad_ent = world.spawn_empty().id();
let mut streamer = TerrainStreamer::default();
streamer.tiles.insert((3, 5), tile_listing(&[7]));
let mut placements = Placements::default();
placements
.by_id
.insert(7, straddler((3, 4), vec![doodad_ent]));
world.insert_resource(streamer);
world.insert_resource(placements);
run_handoff(&mut world, (3, 4), vec![7], true);
let placements = world.resource::<Placements>();
let p = placements.by_id.get(&7).unwrap();
assert_eq!(
p.owner,
(3, 5),
"ownership must move to the loaded referrer"
);
assert!(!p.spawned, "the placement must queue for respawn");
assert!(p.entities.is_empty());
assert!(
world.get_entity(doodad_ent).is_err(),
"the stale entities must despawn ahead of the respawn"
);
}
/// A placement the dead tile merely referenced (owner elsewhere) is untouched, and the
/// whole pass is a no-op without the merge — a surviving placement never lost anything.
#[test]
fn non_owned_and_merge_off_placements_stay_untouched() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let mut streamer = TerrainStreamer::default();
streamer.tiles.insert((3, 5), tile_listing(&[7, 8]));
let mut placements = Placements::default();
placements.by_id.insert(7, straddler((3, 5), vec![e1])); // owned by the SURVIVOR
placements.by_id.insert(8, straddler((3, 4), vec![e2])); // owned by the dead tile
world.insert_resource(streamer);
world.insert_resource(placements);
// Merge off: nothing moves, even for the dead tile's own straddler.
run_handoff(&mut world, (3, 4), vec![7, 8], false);
{
let placements = world.resource::<Placements>();
assert_eq!(placements.by_id.get(&8).unwrap().owner, (3, 4));
assert!(placements.by_id.get(&8).unwrap().spawned);
}
// Merge on: uid 8 hands off, uid 7 (not owned by the dead tile) is untouched.
run_handoff(&mut world, (3, 4), vec![7, 8], true);
let placements = world.resource::<Placements>();
let p7 = placements.by_id.get(&7).unwrap();
assert_eq!(p7.owner, (3, 5));
assert!(
p7.spawned,
"a placement the dead tile did not own keeps its entities"
);
let p8 = placements.by_id.get(&8).unwrap();
assert_eq!(p8.owner, (3, 5));
assert!(!p8.spawned);
assert!(world.get_entity(e1).is_ok());
assert!(world.get_entity(e2).is_err());
}
}
+457 -56
View File
@@ -1,20 +1,21 @@
//! **`WOW_STATIC_MERGE=1` — the production static-world consolidation, doodad lanes** (design
//! 1417, lane order re-ranked by 1418's density verdict; the measured premise is 1413/1416's
//! 44 ns/row tax).
//! **The production static-world consolidation, doodad lanes — ON by default** (1426;
//! `WOW_STATIC_MERGE=0` opts out. Design 1417, lane order re-ranked by 1418's density
//! verdict; the measured premise is 1413/1416's 44 ns/row tax).
//!
//! The assembler diverts every ADT-doodad batch that is fully static (the bracket's anim
//! exclusions), **order-free** (`Opaque`/`AlphaTest`, not additive: 0858's law that authored
//! draw order exists only on transparent-pass batches) and not an interior-slot prop, into
//! this buffer; the flush bakes each `(owner tile, 133⅓-yd cell, material)` group into ONE
//! mesh entity with placement transforms baked into the vertices. **Faders merge too** (1418
//! — they are the dense population, 4.5 batches per blob at the SW pin): each vertex carries
//! its placement's fade sphere ([`benilla_assets::ATTRIBUTE_WOW_FADE_SPHERE`]) and
//! mesh entity with placement transforms baked into the vertices. **The fader lane (lane 2)
//! is dev-opt-in only (`WOW_MERGE_FADERS=1`, decision 1423)**: each vertex carries its
//! placement's fade sphere ([`benilla_assets::ATTRIBUTE_WOW_FADE_SPHERE`]) and
//! `wow_model.wgsl`'s `WOW_MERGED_FADE` lane computes the faithful fade curve per vertex —
//! alpha in-shader, `Hidden` as a clip-space collapse at zero. A fader blob draws on its BLEND
//! TWIN permanently (1420): the reference's own fading render state, whose output at fade 1.0
//! is pixel-identical to the steady cutout — so the feather is the reference's smooth
//! translucent ramp, per placement, with no material swap and no dither (1419's ordered-dither
//! stand-in quantized the ramp to 16 visible steps — the director's report killed it).
//! alpha in-shader, `Hidden` as a clip-space collapse at zero — on the BLEND TWIN permanently
//! (1420), the reference's own fading render state. The lane is correct per PIXEL but wrong
//! per PHASE: one transparent draw with one sort key spanning a cell of depths, depth-write
//! on, depth-kills per-entity faders behind its translucent pixels whenever the cell's sort
//! centre lands beyond them (the director's popping lamppost — 1422 shrank the class,
//! 1423 pulled the lane; the sort-near re-entry design is 1423's follow-up).
//!
//! The cell key preserves the frustum-cull locality the bracket's round 1 proved load-bearing
//! (+1.38 without it, −0.93 with); the owner tile buys the weld's whole lifetime story — the
@@ -27,17 +28,24 @@
//! entity-level lane. [`MergeSite::Wmo`] survives only to feed the census predictor.
//!
//! The close rule is the weld's (1369), not the bracket's wall clock: vertex cap + idle-frame
//! tail, one quiet frame in normal play, [`MERGE_IDLE_FRAMES`] under the arrival cover.
//! tail — [`MERGE_IDLE_FRAMES`] (a quarter second) in settled play, ONE quiet frame under the
//! arrival cover, where the settle release waits on the backlog (`merge_pending` is a
//! `presentable()` term) and a fixed tail would push every world entry longer for nothing.
//! Play-streaming then RE-CONSOLIDATES: distance admission trickles a cell's doodads in over
//! the whole approach, gapped in seconds, so in-play closes shred a cell into fragments
//! (Goldshire: 214/445 singleton blobs — 1421's census; its "1-frame tail" attribution was a
//! misread, the in-play tail was already ¼ s and no sane tail bridges an approach). Fragments
//! spawn fast — appearance latency is the tail's job — and once a cell's key has been quiet
//! for [`RECONSOLIDATE_IDLE_FRAMES`] its fragments re-bake into one blob per vertex cap and
//! the originals retire after an upload cushion ([`RETIRE_FRAMES`]).
//! Dead-owner accumulators are discarded, and the whole buffer clears on map drop for the same
//! tile-keys-repeat-across-maps reason the weld's does.
//!
//! **Known v1 gaps, deliberate** (each named in 1417, none reachable at a parked measuring
//! pin): a tile-straddling doodad's blob dies with its owner tile and nothing re-emits it on
//! the owner's reload while a neighbour still holds the placement (1369's re-cross gap, shared;
//! the owner-handoff re-emit is the recorded fix and lands before any default-on). And the
//! flush is not yet counted into the settle release the way `HullWelds::unflushed` is — under
//! the cover the idle tail is frames, not seconds, so the reveal race is narrow; still a
//! before-default-on item.
//! The straddler gap (1417's lifecycle §) is CLOSED: when an owner tile unloads out from under
//! a placement a loaded neighbour still references, `handoff_straddlers` (terrain_stream.rs)
//! re-owns the placement to that referrer and queues a respawn, so its batches re-divert here
//! under the new owner — and its hulls re-weld, closing 1369's matching collider gap whenever
//! the merge is on.
use std::collections::HashMap;
use std::sync::Arc;
@@ -61,11 +69,25 @@ use crate::wmo_portal::WmoGroupVis;
/// single oversized batch closes its blob immediately, same as an oversized hull.
const MERGE_MAX_VERTS: usize = 65_536;
/// Quiet frames that close a live accumulator under the arrival cover; in normal play ONE
/// quiet frame closes it, so a crossing's new tile blobs appear without a visible hold (the
/// weld's exact rule — its comment owns the reasoning).
/// Quiet frames that close a live accumulator in settled play — a quarter second, because a
/// budget-paced burst's gaps are frames, not seconds. Under the arrival cover ONE quiet frame
/// closes it instead: the settle release waits on the merge backlog, and a fixed tail would
/// push every world entry longer for nothing (the weld's exact rule — its comment owns the
/// reasoning).
const MERGE_IDLE_FRAMES: u32 = 15;
/// Quiet frames before a settled cell's fragments re-bake into one blob (~3 s at 60 Hz): long
/// enough that the approach's distance-admission trickle has moved past the cell, short enough
/// that the fragment population stays transient. This is why the in-play tail can stay a
/// quarter second: fast appearance first, consolidation once the cell goes quiet.
const RECONSOLIDATE_IDLE_FRAMES: u32 = 180;
/// Frames a replaced fragment keeps drawing after its consolidated successor spawns — the
/// successor's mesh-upload cushion. The merged lanes are order-free (`Opaque`/`AlphaTest`), so
/// the overlap double-draw is invisible; a gap instead (a despawn before the upload lands)
/// would flicker the cell off, the exact defect class the 1421–1423 hunt closed.
const RETIRE_FRAMES: u32 = 10;
/// The doodad spatial cell, ¼ of an ADT tile — the bracket's measured 133⅓-yd locality key
/// (`mega_static`'s comment records the round-1 failure without it).
const CELL: f32 = 533.333_3 / 4.0;
@@ -90,6 +112,26 @@ impl MergeAcc {
fn ready(&self, frame: u32, idle_frames: u32) -> bool {
self.verts >= MERGE_MAX_VERTS || frame.wrapping_sub(self.last_add) >= idle_frames
}
fn source(&self) -> BlobSource<'_> {
BlobSource {
parts: &self.parts,
spheres: &self.spheres,
slots: &self.slots,
blend: self.blend,
kind: self.kind,
}
}
}
/// One blob bake's input — an accumulator's view at close, or a re-consolidated cell's
/// concatenation. Index-parallel slices; `slots` is empty on every non-interior lane.
struct BlobSource<'a> {
parts: &'a [(Arc<RenderSubmesh>, Transform)],
spheres: &'a [Vec4],
slots: &'a [u32],
blend: ModelBlend,
kind: ModelKind,
}
/// Where a diverted batch belongs — built once per placement by the spawn driver, consumed per
@@ -148,6 +190,34 @@ impl MergeSite<'_> {
/// (owner tile, 133⅓-yd cell, material) — a doodad blob's identity.
type DoodadKey = ((i32, i32), (i32, i32), Handle<WowModelMaterial>);
/// One spawned doodad blob retained with its bake input, so a shredded cell can re-bake.
struct SettledBlob {
entity: Entity,
parts: Vec<(Arc<RenderSubmesh>, Transform)>,
spheres: Vec<Vec4>,
verts: usize,
}
/// A key's spawned blobs — the re-consolidation ledger's entry. `dirty` arms one evaluation
/// after the next quiet window; a declined evaluation (nothing to win under the cap) does not
/// re-run until a new fragment lands.
struct SettledCell {
blobs: Vec<SettledBlob>,
blend: ModelBlend,
kind: ModelKind,
/// [`StaticMerge::frame`] at the last fragment spawn — the quiet clock.
last_add: u32,
dirty: bool,
}
/// A replaced fragment drawing out its successor's upload cushion (see [`RETIRE_FRAMES`]).
struct Retiring {
entity: Entity,
owner: (i32, i32),
/// [`StaticMerge::frame`] when the successor spawned.
since: u32,
}
/// (placement uid, referrer-set, material) — a prop blob's identity. The `Arc<[u16]>` hashes
/// by CONTENT, so two props named by the same rooms share a blob and distinct sets never can.
type PropKey = (u32, Arc<[u16]>, Handle<WowModelMaterial>);
@@ -160,6 +230,11 @@ pub struct StaticMerge {
frame: u32,
doodads: HashMap<DoodadKey, MergeAcc>,
props: HashMap<PropKey, MergeAcc>,
/// Spawned doodad blobs retained per key with their bake inputs — the re-consolidation
/// ledger (see the module header: play-streaming's distance admission shreds a cell).
settled: HashMap<DoodadKey, SettledCell>,
/// Replaced fragments still drawing out their successor's upload cushion.
retiring: Vec<Retiring>,
/// Running totals since the last drain report (1417's VRAM honesty line): blobs spawned,
/// batches baked into them, vertices BAKED (every placement a copy) vs the vertices the
/// members' SHARED assets hold (each distinct geometry once — Arc identity), so the log
@@ -172,10 +247,13 @@ pub struct StaticMerge {
reported: bool,
}
/// Is lane 1 armed? Read once; the assembler divert and the flush both key on it.
/// Is the consolidation armed? Read once; the assembler divert and the flush both key on it.
/// **ON by default** (1426: both recorded blockers closed — 1424 fragmentation, 1425 straddler
/// handoff — and the director's eye passed lanes 1+3 at both pins). `WOW_STATIC_MERGE=0` is
/// the opt-out lever; `=1` still reads as an explicit on for anything that predates the flip.
pub fn merge_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_STATIC_MERGE").as_deref() == Ok("1"))
*ON.get_or_init(|| std::env::var("WOW_STATIC_MERGE").as_deref() != Ok("0"))
}
impl StaticMerge {
@@ -266,6 +344,8 @@ impl StaticMerge {
pub(super) fn clear(&mut self) {
self.doodads.clear();
self.props.clear();
self.settled.clear();
self.retiring.clear();
self.blobs = 0;
self.batches = 0;
self.baked_verts = 0;
@@ -296,24 +376,40 @@ pub(super) fn flush_static_merge(
let frame = merge.frame;
let idle = if focus.paced { MERGE_IDLE_FRAMES } else { 1 };
let mut blobs = 0u64;
merge.doodads.retain(|key, acc| {
let Some(tile) = streamer.tiles.get_mut(&key.0) else {
return false;
};
if !acc.ready(frame, idle) {
return true;
}
blobs += 1;
tile.merged.push(spawn_blob(
&mut commands,
&mut meshes,
&key.2,
acc,
None,
true,
));
false
});
{
let StaticMerge {
doodads, settled, ..
} = &mut *merge;
doodads.retain(|key, acc| {
let Some(tile) = streamer.tiles.get_mut(&key.0) else {
return false;
};
if !acc.ready(frame, idle) {
return true;
}
blobs += 1;
let entity = spawn_blob(&mut commands, &mut meshes, &key.2, acc.source(), None, true);
tile.merged.push(entity);
// Retain the bake input: a later admission wave under this key makes the cell a
// re-consolidation candidate once it goes quiet.
let cell = settled.entry(key.clone()).or_insert_with(|| SettledCell {
blobs: Vec::new(),
blend: acc.blend,
kind: acc.kind,
last_add: frame,
dirty: true,
});
cell.blobs.push(SettledBlob {
entity,
parts: std::mem::take(&mut acc.parts),
spheres: std::mem::take(&mut acc.spheres),
verts: acc.verts,
});
cell.last_add = frame;
cell.dirty = true;
false
});
}
merge.props.retain(|key, acc| {
let Some(p) = placements.by_id.get_mut(&key.0) else {
return false;
@@ -337,12 +433,107 @@ pub(super) fn flush_static_merge(
&mut commands,
&mut meshes,
&key.2,
acc,
acc.source(),
vis,
exterior,
));
false
});
// Re-consolidate settled cells (the module header owns the why): once a key has been quiet
// for the window and holds two or more fragments, re-bake them into one blob per vertex cap
// and retire the originals after the upload cushion. First-fit over whole fragments; only a
// strictly smaller shape is worth the churn.
let mut recon = (0u64, 0usize, 0usize); // (cells, fragments before, blobs after)
{
let StaticMerge {
settled, retiring, ..
} = &mut *merge;
settled.retain(|key, cell| {
let Some(tile) = streamer.tiles.get_mut(&key.0) else {
// The owner died and took the entities with it; the ledger follows.
return false;
};
if !cell.dirty || frame.wrapping_sub(cell.last_add) < RECONSOLIDATE_IDLE_FRAMES {
return true;
}
cell.dirty = false;
let mut splits = vec![0usize];
let mut open = 0usize;
for (i, b) in cell.blobs.iter().enumerate() {
if open > 0 && open + b.verts > MERGE_MAX_VERTS {
splits.push(i);
open = 0;
}
open += b.verts;
}
if splits.len() >= cell.blobs.len() {
return true;
}
let old = std::mem::take(&mut cell.blobs);
recon.0 += 1;
recon.1 += old.len();
recon.2 += splits.len();
splits.push(old.len());
for w in splits.windows(2) {
let members = &old[w[0]..w[1]];
let parts: Vec<_> = members
.iter()
.flat_map(|b| b.parts.iter().cloned())
.collect();
let spheres: Vec<_> = members
.iter()
.flat_map(|b| b.spheres.iter().copied())
.collect();
let entity = spawn_blob(
&mut commands,
&mut meshes,
&key.2,
BlobSource {
parts: &parts,
spheres: &spheres,
slots: &[],
blend: cell.blend,
kind: cell.kind,
},
None,
true,
);
tile.merged.push(entity);
cell.blobs.push(SettledBlob {
entity,
verts: members.iter().map(|b| b.verts).sum(),
parts,
spheres,
});
}
for b in &old {
retiring.push(Retiring {
entity: b.entity,
owner: key.0,
since: frame,
});
}
true
});
}
if recon.0 > 0 {
info!(
"static-merge: reconsolidated {} cell(s): {} fragments → {} blob(s)",
recon.0, recon.1, recon.2
);
}
// Retire replaced fragments once the successor's upload cushion has passed. A dead owner
// already despawned the entity with its tile — drop the record without touching it.
merge.retiring.retain(|r| {
if frame.wrapping_sub(r.since) < RETIRE_FRAMES {
return true;
}
if let Some(tile) = streamer.tiles.get_mut(&r.owner) {
tile.merged.retain(|e| *e != r.entity);
commands.entity(r.entity).despawn();
}
false
});
merge.blobs += blobs;
if blobs > 0 {
merge.reported = false;
@@ -367,7 +558,84 @@ pub(super) fn flush_static_merge(
}
}
/// One closed accumulator → one blob entity carrying exactly what its members carried minus
/// `WOW_MERGE_FADERS=1` — the fader lane (lane 2) is OPT-IN dev-only since decision 1423:
/// a fader blob is one transparent draw with ONE sort key covering a 133⅓-yd cell of content
/// at DIFFERENT depths, drawn with the twin's faithful depth-write — and whenever the cell's
/// sort centre lands beyond a per-entity fader, the blob draws first and its translucent
/// pixels depth-kill the entity behind them (the director's popping lamppost; 1422's
/// centre-sort only shrank the class, it cannot eliminate it). Under the default merge
/// faders spawn per-entity — the configuration the director's eye
/// passed. The engineered path to win lane 2 back (sort-near bias on a blob-only twin
/// variant, the depth test resolving order against depth-writing content) is 1423's
/// follow-up.
pub(crate) fn merge_faders_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_MERGE_FADERS").is_some())
}
/// `WOW_BLOB_VIS=1` — dump every merged blob's live visibility verdict every 2 s, plus any
/// entity whose `WorldObject::id` is listed in `WOW_BLOB_VIS_UID` (comma-separated). The
/// instrument for a "this popped in/out" report: run parked at the report's two points and
/// diff which row flips (`vis`/`inh`/`view`), which separates a positional cull flip from a
/// temporal one (a live pipeline compile, a late bake).
pub(crate) fn blob_vis_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_BLOB_VIS").is_some())
}
#[allow(clippy::type_complexity)]
pub(crate) fn log_blob_vis(
time: Res<Time>,
mut last: Local<f32>,
q: Query<(
Entity,
&WorldObject,
&GlobalTransform,
Option<&Aabb>,
&Visibility,
&InheritedVisibility,
&ViewVisibility,
)>,
) {
static UIDS: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
let uids = UIDS.get_or_init(|| {
std::env::var("WOW_BLOB_VIS_UID")
.map(|s| s.split(',').filter_map(|v| v.trim().parse().ok()).collect())
.unwrap_or_default()
});
let now = time.elapsed_secs();
if now - *last < 2.0 {
return;
}
*last = now;
for (e, obj, xf, aabb, vis, inh, view) in &q {
if obj.label != "static-merge" && !uids.contains(&obj.id) {
continue;
}
let c = aabb.map_or(xf.translation(), |a| {
xf.transform_point(Vec3::from(a.center))
});
let h = aabb.map_or(Vec3::ZERO, |a| Vec3::from(a.half_extents));
eprintln!(
"[blob-vis] t={now:.1} {e} {} #{} [{}] c=({:.0},{:.0},{:.0}) h=({:.0},{:.0},{:.0}) \
vis={vis:?} inh={} view={}",
obj.label,
obj.id,
obj.detail,
c.x,
c.y,
c.z,
h.x,
h.y,
h.z,
inh.get(),
view.get(),
);
}
}
/// One blob bake (a closed accumulator, or a re-consolidated cell's chunk) → one blob entity
/// carrying exactly what its members carried minus
/// the per-placement machinery the shader lane now owns (1418): no `DoodadFade` (the baked
/// fade spheres drive `WOW_MERGED_FADE`; `MeshTag` stays at opaque), no `PickMesh` (nameable,
/// not pickable — the weld's identity rule, 0929), `ExteriorScene` (every member had it), the
@@ -376,16 +644,24 @@ fn spawn_blob(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mat: &Handle<WowModelMaterial>,
acc: &mut MergeAcc,
src: BlobSource<'_>,
vis: Option<WmoGroupVis>,
exterior: bool,
) -> Entity {
let parts = std::mem::take(&mut acc.parts);
let spheres = std::mem::take(&mut acc.spheres);
let slots = std::mem::take(&mut acc.slots);
let BlobSource {
parts,
spheres,
slots,
blend,
kind,
} = src;
let n = parts.len();
let (mesh, mn, mx) =
merged_static_mesh_faded(&parts, &spheres, (!slots.is_empty()).then_some(&slots[..]));
// `center` is the blob's world position and therefore its TRANSPARENT-PHASE SORT KEY (the
// mesh is baked blob-local around it — decision 1422). On `Transform::IDENTITY` a fader
// blob sorted at the world origin: drawn first among all transparent content, its
// depth-write killing every transparent entity behind its translucent pixels.
let (mesh, mn, mx, center) =
merged_static_mesh_faded(parts, spheres, (!slots.is_empty()).then_some(slots));
// An interior blob's tag keeps the members' INTERIOR_FOG staging bit (the slot half of the
// payload is dead under WOW_MERGED_SLOT — the vertices carry it).
let tag = if slots.is_empty() {
@@ -396,16 +672,13 @@ fn spawn_blob(
let mut blob = commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(mat.clone()),
Transform::IDENTITY,
ModelPart {
kind: acc.kind,
blend: acc.blend,
},
Transform::from_translation(center),
ModelPart { kind, blend },
MeshTag(tag),
Aabb::from_min_max(mn, mx),
NoAutoAabb,
WorldObject {
kind: acc.kind,
kind,
label: "static-merge".into(),
id: 0,
detail: format!("{n} batches merged"),
@@ -690,6 +963,134 @@ mod tests {
assert_eq!(app.world().entities().len(), before);
}
fn flush_n(app: &mut App, n: u32) {
for _ in 0..n {
app.world_mut().run_system_once(flush_static_merge).unwrap();
}
}
fn tile_merged(app: &App, tile: (i32, i32)) -> Vec<Entity> {
app.world()
.resource::<TerrainStreamer>()
.tiles
.get(&tile)
.unwrap()
.merged
.clone()
}
/// Play-streaming's admission trickle closes a cell in fragments; once the key goes quiet
/// they re-bake into ONE blob, the originals drawing out the upload cushion before retiring.
#[test]
fn a_settled_cell_reconsolidates_its_fragments() {
let mut app = test_app();
app.world_mut().resource_mut::<ViewFocus>().paced = true;
app.world_mut()
.resource_mut::<TerrainStreamer>()
.tiles
.insert((3, 4), blank_tile());
// Two admission waves, each closing on the idle tail → two fragment blobs.
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(3, 4),
Vec3::new(1.0, 0.0, 1.0),
3,
);
flush_n(&mut app, MERGE_IDLE_FRAMES);
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(3, 4),
Vec3::new(2.0, 0.0, 2.0),
3,
);
flush_n(&mut app, MERGE_IDLE_FRAMES);
let fragments = tile_merged(&app, (3, 4));
assert_eq!(fragments.len(), 2, "two waves must close two fragments");
// The quiet window passes: the successor spawns while the fragments keep drawing.
flush_n(&mut app, RECONSOLIDATE_IDLE_FRAMES);
assert_eq!(
tile_merged(&app, (3, 4)).len(),
3,
"the fragments must cover the successor's upload cushion"
);
// The cushion passes: the fragments retire, the consolidated blob remains.
flush_n(&mut app, RETIRE_FRAMES);
let after = tile_merged(&app, (3, 4));
assert_eq!(after.len(), 1);
assert!(!fragments.contains(&after[0]));
for e in fragments {
assert!(
app.world().get_entity(e).is_err(),
"a retired fragment must despawn"
);
}
// The consolidated mesh carries both placements — per-vertex fade spheres for 3+3 verts.
let mesh3d = app.world().get::<Mesh3d>(after[0]).unwrap().0.clone();
let meshes = app.world().resource::<Assets<Mesh>>();
let spheres = meshes
.get(&mesh3d)
.unwrap()
.attribute(ATTRIBUTE_WOW_FADE_SPHERE)
.unwrap();
assert_eq!(spheres.len(), 6);
}
/// Two fragments already at the vertex cap re-bake into the same count — no win, so the
/// evaluation declines, keeps the originals, and does not run again until a new fragment.
#[test]
fn reconsolidation_declines_when_the_cap_leaves_no_win() {
let mut app = test_app();
app.world_mut().resource_mut::<ViewFocus>().paced = true;
app.world_mut()
.resource_mut::<TerrainStreamer>()
.tiles
.insert((0, 0), blank_tile());
for _ in 0..2 {
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(0, 0),
Vec3::ZERO,
MERGE_MAX_VERTS,
);
// The cap closes each immediately — two fragments under one key.
flush_n(&mut app, 1);
}
let fragments = tile_merged(&app, (0, 0));
assert_eq!(fragments.len(), 2);
flush_n(&mut app, RECONSOLIDATE_IDLE_FRAMES + RETIRE_FRAMES);
assert_eq!(
tile_merged(&app, (0, 0)),
fragments,
"capped fragments must stay exactly as they were"
);
}
/// A dead owner drops its settled ledger without touching entities — the tile's own unload
/// despawned them, and a second despawn would be a bug.
#[test]
fn a_dead_owner_drops_the_settled_ledger() {
let mut app = test_app();
app.world_mut().resource_mut::<ViewFocus>().paced = true;
app.world_mut()
.resource_mut::<TerrainStreamer>()
.tiles
.insert((5, 5), blank_tile());
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(5, 5),
Vec3::ZERO,
3,
);
flush_n(&mut app, MERGE_IDLE_FRAMES);
assert_eq!(app.world().resource::<StaticMerge>().settled.len(), 1);
app.world_mut()
.resource_mut::<TerrainStreamer>()
.tiles
.remove(&(5, 5));
flush_n(&mut app, 1);
assert!(app.world().resource::<StaticMerge>().settled.is_empty());
}
/// The vertex cap closes an accumulator without waiting for the idle tail.
#[test]
fn vert_cap_closes_immediately() {
@@ -358,6 +358,13 @@ pub fn spawn_model_entities(
if let Some((merge, site)) = merge.as_mut() {
if crate::terrain_stream::merge::merge_enabled()
&& class.merges()
// The fader lane is OPT-IN (`WOW_MERGE_FADERS=1`, decision 1423): a fader —
// neither never-fade nor a steady interior prop — spawns per-entity by
// default; a cell-granular transparent draw with depth-write depth-kills
// per-entity faders behind its translucent pixels (the popping lamppost).
&& (class.never_fade
|| steady_interior_prop
|| crate::terrain_stream::merge::merge_faders_enabled())
&& merge.divert(
site,
batch_idx,
@@ -167,8 +167,8 @@ pub(super) fn spawn_loaded_placements(
let anim_bound = m2_anim_bound(&m.bounds);
let SpawnedModel {
entities: mut ents,
by_batch,
host,
..
} = spawn_model_entities(
&mut commands,
mat_cache,
@@ -270,6 +270,21 @@ pub(super) fn spawn_loaded_placements(
unique_id,
format!("emitters: {}", m.emitters.len()),
);
if let Some((target, r)) = fade_near_target() {
let pos = p.transform.translation;
let d = bevy::math::Vec2::new(pos.x, pos.z).distance(target);
if d <= r {
log_fade_near(
"doodad",
&handle_label(h),
unique_id,
pos,
d,
(radius, p.transform.scale.x),
&by_batch,
);
}
}
ents
}
ModelHandle::Wmo(h) => {
@@ -325,6 +340,21 @@ pub(super) fn spawn_loaded_placements(
},
)),
);
if let Some((target, r)) = fade_near_target() {
let pos = p.transform.translation;
let d = bevy::math::Vec2::new(pos.x, pos.z).distance(target);
if d <= r {
log_fade_near(
"wmo",
&handle_label(h),
unique_id,
pos,
d,
(f32::INFINITY, p.transform.scale.x),
&by_batch,
);
}
}
// A world WMO placement is exterior scene too (`0x6856c0`, fed by the same
// per-window populate `0x682fa0`): from inside one building, another building
// draws only through a portal window. 0774 left this ungated because these
@@ -834,6 +864,46 @@ fn resolve_wmo_doodads(
out
}
/// `WOW_FADE_NEAR="x,y,r"` — the "name that popping doodad" instrument: a WoW server-coordinate
/// point (the same numbers `.go xyz` takes, director-verbatim) plus a horizontal radius in yards.
/// Every placement spawning within `r` yd of the point logs its model path, fade radius (which
/// selects the `model_fade` band), scale, and how many of its batches the static merge diverted —
/// so a "this doodad pops in at .go X Y Z" report converts into the placement's actual fade
/// inputs and its lane (merged fader / per-entity / WMO) in one parked probe run, no camera work.
fn fade_near_target() -> Option<(bevy::math::Vec2, f32)> {
static T: std::sync::OnceLock<Option<(bevy::math::Vec2, f32)>> = std::sync::OnceLock::new();
*T.get_or_init(|| {
let s = std::env::var("WOW_FADE_NEAR").ok()?;
let mut it = s.split(',').map(|v| v.trim().parse::<f32>().ok());
let (x, y, r) = (it.next()??, it.next()??, it.next()??);
let b = wow_to_bevy([x, y, 0.0]);
Some((bevy::math::Vec2::new(b.x, b.z), r))
})
}
/// One `[fade-near]` line for a placement inside the [`fade_near_target`] circle — shared by the
/// doodad and WMO arms so the two print comparably. `radius` is the fade input (∞ for a WMO);
/// `diverted` of `batches` went to the static merge (the lane readout).
fn log_fade_near(
kind: &str,
label: &str,
unique_id: u32,
pos: Vec3,
d: f32,
(radius, scale): (f32, f32),
by_batch: &[Option<Entity>],
) {
let diverted = by_batch.iter().filter(|e| e.is_none()).count();
eprintln!(
"[fade-near] {kind} {label} uid={unique_id} d={d:.1} radius={radius:.2} scale={scale:.2} \
batches={} diverted={diverted} pos=({:.1},{:.1},{:.1})",
by_batch.len(),
pos.x,
pos.y,
pos.z,
);
}
/// A model handle's source path as a readable label for the object inspector (the asset path without
/// the `mpq://` source prefix). Empty if the handle carries no path.
/// A placement's model-forms build priority (decision 0834): its Chebyshev tile distance to the