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