diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index a88f3f6c..9ea3bae5 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -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 diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 6ca09ccb..930cdddf 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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, 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, 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, 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)] diff --git a/crates/benilla-world/src/mega_static.rs b/crates/benilla-world/src/mega_static.rs index ed701b16..9468356b 100644 --- a/crates/benilla-world/src/mega_static.rs +++ b/crates/benilla-world/src/mega_static.rs @@ -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), diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index d6bb8dd1..6e6769b1 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -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, mut vols: ResMut, + 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) -> 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, merge_on: bool) { + world + .run_system_once( + move |mut commands: Commands, + streamer: Res, + mut placements: ResMut| { + 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::(); + 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::(); + 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::(); + 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()); + } +} diff --git a/crates/benilla-world/src/terrain_stream/merge.rs b/crates/benilla-world/src/terrain_stream/merge.rs index 8eee64b7..e26668d4 100644 --- a/crates/benilla-world/src/terrain_stream/merge.rs +++ b/crates/benilla-world/src/terrain_stream/merge.rs @@ -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, 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); + +/// One spawned doodad blob retained with its bake input, so a shredded cell can re-bake. +struct SettledBlob { + entity: Entity, + parts: Vec<(Arc, Transform)>, + spheres: Vec, + 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, + 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); @@ -160,6 +230,11 @@ pub struct StaticMerge { frame: u32, doodads: HashMap, props: HashMap, + /// 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, + /// Replaced fragments still drawing out their successor's upload cushion. + retiring: Vec, /// 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 = 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 = 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 = 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