the static world merges into per-cell blobs, behind a flag

- behind WOW_STATIC_MERGE=1, never-fade doodads, fading doodads and interior props bake into one mesh per 133-yard cell and material — −0.99 ms at the Stormwind gates, a wash in Lower Blackrock Spire, GPU flat
- the merged faders fade in-shader on the reference's own fading draw state, so the ramp is as smooth as an individual doodad's
- interior props carry their light-probe slot per vertex, and the arrival cover waits for every unbaked blob so a reveal never shows a hole
- a merge census (WOW_MERGE_CENSUS=1) prices each population before it is built — WMO group geometry merges nothing and stays individual

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:53:51 +07:00
co-authored by Claude Fable 5
parent a822d99bd4
commit aab81acc33
15 changed files with 1311 additions and 70 deletions
+10 -4
View File
@@ -61,7 +61,7 @@ use benilla_world::model_render::ShadeSel;
use benilla_world::particles;
use benilla_world::terrain_stream::{
build_collider_task, fold_interior_probe, m2_anim_bound, m2_fade, placement_collider_data,
point_light, spawn_model_entities, PendingCollider, PropLobeLight,
point_light, spawn_model_entities, PendingCollider, PropLobeLight, SpawnedModel,
};
use super::{GameObjects, ModelHandle, VisualAttached};
@@ -260,7 +260,11 @@ pub(super) fn spawn_wmo_gameobject_props(
}
slot
});
let (ents, host) = spawn_model_entities(
let SpawnedModel {
entities: ents,
host,
..
} = spawn_model_entities(
&mut commands,
mat_cache,
materials,
@@ -281,8 +285,10 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut tint_reg,
&mut anim_table,
card_owner,
// Never diverted into the WOW_MEGA_STATIC bracket: these props parent under a
// MOVING gameobject, and a merged blob bakes world transforms.
// Never diverted into the WOW_MEGA_STATIC bracket (nor 1417's production
// merge): these props parent under a MOVING gameobject, and a merged blob
// bakes world transforms.
None,
None,
);
commands.entity(entity).add_children(&ents);
+3 -1
View File
@@ -467,12 +467,14 @@ fn drive_loading_screen(
screen.last_wait_log = now;
info!(
"loading screen: waiting {:.1}s — {}/{} resident, {} placements pending, \
{} colliders pending, awaiting_snap={}, settling={}, warm={}, ui_pending={}",
{} colliders pending, {} merges pending, awaiting_snap={}, settling={}, \
warm={}, ui_pending={}",
now - screen.active_since,
progress.ready,
progress.total,
progress.placements_pending,
progress.colliders_pending,
progress.merge_pending,
screen.awaiting_snap,
player_settling,
warm.satisfied(),
+9 -3
View File
@@ -105,7 +105,7 @@ pub(super) fn release_post_snap_hold(
mut player: ResMut<Player>,
progress: Option<Res<WorldLoadProgress>>,
time: Res<Time>,
mut last_counters: Local<Option<[usize; 4]>>,
mut last_counters: Local<Option<[usize; 5]>>,
net_cmds: Option<Res<crate::net::NetCommands>>,
) {
let Some(p) = progress else { return };
@@ -129,7 +129,13 @@ pub(super) fn release_post_snap_hold(
// Did the stream move since last frame? Any counter changing — a tile spawned, a placement
// up, a collider queued or attached — is the destination still arriving. Tracked every frame
// (not just while settling) so the first settling frame compares against a real baseline.
let counters = [p.ready, p.total, p.colliders_pending, p.placements_pending];
let counters = [
p.ready,
p.total,
p.colliders_pending,
p.merge_pending,
p.placements_pending,
];
let progressed = last_counters.replace(counters) != Some(counters);
if !player.settling {
return;
@@ -145,7 +151,7 @@ pub(super) fn release_post_snap_hold(
// still up. Only a stream that has made NO progress for the whole budget — missing data, dead
// IO — can time out now, which is the case the backstop was always for.
let now = time.elapsed_secs();
if p.scene_ready && p.colliders_pending == 0 && !player.world_stale && focus_matches {
if p.presentable() && !player.world_stale && focus_matches {
player.end_settle(true, now);
} else if player.world_stale || progressed {
player.settle_deadline = now + SETTLE_TIMEOUT;
+5 -4
View File
@@ -32,10 +32,11 @@ pub use world_assets::*;
mod model;
pub use model::{
bone_target_id, merged_static_mesh, submesh_to_skinned_mesh, submesh_to_static_mesh, AnimClip,
BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, ModelJoint,
ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack,
ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
bone_target_id, merged_static_mesh, merged_static_mesh_faded, submesh_to_skinned_mesh,
submesh_to_static_mesh, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations,
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_JOINT_INDEX,
ATTRIBUTE_WOW_JOINT_WEIGHT, ATTRIBUTE_WOW_MERGED_SLOT,
};
mod adt;
mod terrain;
+36
View File
@@ -270,6 +270,42 @@ impl MaterialExtension for WowModelExt {
attrs.push(crate::ATTRIBUTE_WOW_JOINT_WEIGHT.at_shader_location(11));
descriptor.vertex.buffers = vec![layout.0.get_layout(&attrs)?];
}
// The merged fader blob (decisions 1418/1420): a mesh carrying the per-vertex fade
// sphere compiles the WOW_MERGED_FADE path — the faithful doodad fade curve computed
// per vertex (folded into the tag fade the fragment already consumes) and a clip-space
// collapse at zero (the Hidden channel). The blob's material is its blend TWIN, so the
// feather is the reference's own translucent ramp. Same rebuild pattern as the joints
// above; the two attribute sets never co-occur (merged blobs never skin).
if layout.0.contains(crate::ATTRIBUTE_WOW_FADE_SPHERE) {
descriptor.vertex.shader_defs.push("WOW_MERGED_FADE".into());
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_MERGED_FADE".into());
}
let mut attrs = Vec::with_capacity(6);
for (attr, loc) in [
(Mesh::ATTRIBUTE_POSITION, 0),
(Mesh::ATTRIBUTE_NORMAL, 1),
(Mesh::ATTRIBUTE_UV_0, 2),
(Mesh::ATTRIBUTE_UV_1, 3),
(Mesh::ATTRIBUTE_TANGENT, 4),
(Mesh::ATTRIBUTE_COLOR, 5),
] {
if layout.0.contains(attr) {
attrs.push(attr.at_shader_location(loc));
}
}
attrs.push(crate::ATTRIBUTE_WOW_FADE_SPHERE.at_shader_location(12));
// The interior-prop half (1418 lane 3): the baked SH-probe slot replaces the
// per-entity MeshTag payload.
if layout.0.contains(crate::ATTRIBUTE_WOW_MERGED_SLOT) {
descriptor.vertex.shader_defs.push("WOW_MERGED_SLOT".into());
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_MERGED_SLOT".into());
}
attrs.push(crate::ATTRIBUTE_WOW_MERGED_SLOT.at_shader_location(13));
}
descriptor.vertex.buffers = vec![layout.0.get_layout(&attrs)?];
}
// M2 render flags 0x10 (no depth-write) / 0x08 (no depth-test), per batch. Default: write depth
// (LEQUAL) like the real client — including transparent batches, which fixes the bleed-through.
if let Some(ds) = descriptor.depth_stencil.as_mut() {
+63 -1
View File
@@ -224,6 +224,29 @@ pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute =
bevy::render::render_resource::VertexFormat::Float32x4,
);
/// A merged fader blob's per-vertex fade sphere (`center.xyz` world-space, `w` = fade radius —
/// decision 1418): every vertex of one baked placement carries the placement's sphere, and
/// `wow_model.wgsl` computes the faithful `doodad_fade_alpha` from it per vertex (the erode
/// lane), replacing the per-entity MeshTag/twin/Hidden fade channels a merged draw cannot
/// express. Own attribute id for the same reason as the joints above; shader location 12.
pub const ATTRIBUTE_WOW_FADE_SPHERE: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_FadeSphere",
988_540_919,
bevy::render::render_resource::VertexFormat::Float32x4,
);
/// A merged interior-prop blob's per-vertex SH-probe slot (decision 1418 lane 3): the slot
/// index every vertex of one baked placement carries, replacing the per-entity `MeshTag`
/// payload (`wow_model.wgsl`'s `WOW_MERGED_SLOT` reads it instead of the tag bits). Shader
/// location 13.
pub const ATTRIBUTE_WOW_MERGED_SLOT: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_MergedSlot",
988_540_920,
bevy::render::render_resource::VertexFormat::Uint32,
);
/// The app-facing **static** mesh build (decision 0834): geometry only, `RENDER_WORLD`-only
/// usages — the render world takes the vertex buffers at extract and the main world keeps no
/// copy. Consumers must pair it with an explicit `Aabb` computed at build time (the exterior
@@ -793,6 +816,25 @@ pub(crate) fn build_global_bones(
/// the caller's Aabb.
pub fn merged_static_mesh(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
) -> (Mesh, 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.
pub fn merged_static_mesh_faded(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
spheres: &[Vec4],
slots: Option<&[u32]>,
) -> (Mesh, 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) {
let any_colors = parts
.iter()
@@ -802,8 +844,10 @@ pub fn merged_static_mesh(
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut colors: Vec<[f32; 4]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut fade: Vec<[f32; 4]> = Vec::new();
let mut slot_verts: Vec<u32> = Vec::new();
let (mut mn, mut mx) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
for (sub, transform) in parts {
for (i, (sub, transform)) in parts.iter().enumerate() {
let base = u32::try_from(positions.len()).expect("merged mesh under u32 vertices");
for p in &sub.positions {
let w = transform.transform_point(wow_to_bevy(*p));
@@ -834,6 +878,15 @@ pub fn merged_static_mesh(
));
}
}
if let Some((spheres, slots)) = extras {
fade.extend(std::iter::repeat_n(
spheres[i].to_array(),
sub.positions.len(),
));
if let Some(slots) = slots {
slot_verts.extend(std::iter::repeat_n(slots[i], sub.positions.len()));
}
}
indices.extend(sub.indices.iter().map(|i| base + i));
}
let mut mesh = Mesh::new(
@@ -846,6 +899,15 @@ pub fn merged_static_mesh(
if any_colors {
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
}
if let Some((_, slots)) = extras {
mesh.insert_attribute(ATTRIBUTE_WOW_FADE_SPHERE, fade);
if slots.is_some() {
mesh.insert_attribute(
ATTRIBUTE_WOW_MERGED_SLOT,
bevy::mesh::VertexAttributeValues::Uint32(slot_verts),
);
}
}
mesh.insert_indices(Indices::U32(indices));
(mesh, mn, mx)
}
@@ -226,6 +226,15 @@ struct WowVsOut {
// the VERTEX, like the reference FFP — the tessellation-scale smoothing IS the authored look
// (wide floor pools, dim hoods). The fragment folds in the live gain and the saturating clamp.
@location(8) point_lit: vec3<f32>,
#ifdef WOW_MERGED_FADE
// The merged fader blob's per-placement fade alpha (decision 1418), computed in the vertex
// stage from the baked fade sphere. Constant across a placement's vertices, so plain
// interpolation reproduces it exactly.
@location(9) merged_fade: f32,
#endif
#ifdef WOW_MERGED_SLOT
@location(10) @interpolate(flat) merged_slot: u32,
#endif
}
// The dynamic point-light term at a world-space point (decisions 0016/0273/0278, selection 0285) —
@@ -323,6 +332,29 @@ fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
// attributes to the buffer layout when the mesh carries `ATTRIBUTE_WOW_JOINT_INDEX` — decision
// 0720; Bevy's `forward_io::Vertex` only declares joints under its own SKINNED path, which no
// benilla mesh triggers anymore).
#ifdef WOW_MERGED_FADE
// The faithful per-object doodad fade curve (`model_fade::doodad_fade_alpha`, exact
// `FUN_00683f80` constants): horizontal-plane distance, `d = dist − radius`, size-bucketed
// band, `1 − (d − start)/range` clamped. `radius > 7` never fades (never-fade members of a
// merged blob bake their true radius and land here).
fn merged_fade_alpha(radius: f32, horiz_dist: f32) -> f32 {
if (radius > 7.0) {
return 1.0;
}
var start = 150.0;
var range = 50.0;
if (radius <= 0.5) {
start = 40.0;
range = 10.0;
} else if (radius <= 2.5) {
start = 100.0;
range = 25.0;
}
let d = horiz_dist - radius;
return clamp(1.0 - (d - start) / range, 0.0, 1.0);
}
#endif
struct WowVertex {
@builtin(instance_index) instance_index: u32,
#ifdef VERTEX_POSITIONS
@@ -344,6 +376,15 @@ struct WowVertex {
@location(10) joint_indices: vec4<u32>,
@location(11) joint_weights: vec4<f32>,
#endif
#ifdef WOW_MERGED_FADE
// The baked placement fade sphere (decision 1418): `xyz` world center, `w` fade radius.
@location(12) fade_sphere: vec4<f32>,
#endif
#ifdef WOW_MERGED_SLOT
// The baked interior-prop SH-probe slot (1418 lane 3) — replaces the MeshTag payload the
// per-entity lane carries.
@location(13) merged_slot: u32,
#endif
}
#ifdef WOW_RIG_SKIN
@@ -473,6 +514,20 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
// but as uniform DATA: as pipeline state it made every batch index its own pipeline, and a
// first city sight synchronously compiled ~3000 of them on the render thread (decision 0837).
out.position.z *= 1.0 + m.sun_scale.y * 1.1920929e-7;
#ifdef WOW_MERGED_FADE
// The merged fader lane (decision 1418). Alpha channel: the faithful curve, per vertex.
// Hidden channel: a fully-faded placement collapses its clip position past the far plane —
// its triangles never rasterize, the shader-side equivalent of `Visibility::Hidden` at
// fade 0 (the CPU authority never sees inside a blob).
let fade_d = distance(view.world_position.xz, vertex.fade_sphere.xz);
out.merged_fade = merged_fade_alpha(vertex.fade_sphere.w, fade_d);
if (out.merged_fade <= 0.0) {
out.position = vec4<f32>(0.0, 0.0, 2.0, 1.0);
}
#ifdef WOW_MERGED_SLOT
out.merged_slot = vertex.merged_slot;
#endif
#endif
#endif
#ifdef VERTEX_UVS_A
@@ -629,6 +684,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
let f = clamp((m.clutter_fade.y - d) / max(m.clutter_fade.y - m.clutter_fade.x, 0.001), 0.0, 1.0);
base_color.a = base_color.a * f;
}
// Faithful per-object WORLD-DOODAD distance fade (`FUN_00683f80`/`model_fade.rs`): the fade alpha
// (1.0 = opaque) rides in the per-instance `MeshTag`; tag 0 (clutter/WMO) ⇒ 1.0 no-op.
// VERIFIED reference behaviour (`RECONCILE-fade-render-state.md`): a fading doodad is the SAME draw
@@ -664,7 +720,15 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// probe AND its alpha ramp — and a skinned part keeps its rig slot through either.
let fade_tag = raw_tag & 0x3fffffffu;
let alpha6 = f32(fade_tag & 0x3fu) / 63.0;
let obj_fade = select(alpha6, 1.0, fade_tag == 0u);
var obj_fade = select(alpha6, 1.0, fade_tag == 0u);
#ifdef WOW_MERGED_FADE
// The merged per-vertex fade composes exactly where the per-entity tag fade did (1420):
// every downstream consumer — `faded_alpha`, the multiply-lerp, the additive scale, and
// the bit-10 re-discard on UNFADED texel alpha — sees the same algebra an individual
// fading doodad produced, so a fader blob on its blend twin feathers exactly like the
// reference (and a steady member at fade 1.0 is pixel-identical to the cutout it left).
obj_fade = obj_fade * in.merged_fade;
#endif
// The per-instance body TINT (instance_tint.rs, decision 0812) — the aura state kit's CharProc 1:
// an aura painting the whole model one colour (ghost pale blue, poison green, Frostbolt blue).
// Indexed by the SAME rig slot the vertex stage skins from (bits 19-29), so it needs no tag bits
@@ -872,7 +936,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// slot. Evaluated here per fragment over the same basis — note the SH lobe's soft wrap (side-on
// ≈ 0.088·C) is the reference's authored response, deliberately NOT a hard max(N·L,0).
// Units/GameObjects never reach this lane (base CGLight — plain day/night ×1.0, §8/§9).
#ifdef WOW_MERGED_SLOT
// A merged interior-prop blob (1418 lane 3): the slot is baked per vertex — the tag's
// payload bits belong to the whole blob and carry only fog/alpha.
let probe = 7u * in.merged_slot;
#else
let probe = 7u * ((fade_tag >> 6u) & 0x1fffu);
#endif
let lit_m2_interior = clamp(
vec3<f32>(
dot(wow_light.prop_probes[probe + 0u], n1)
+1
View File
@@ -161,6 +161,7 @@ pub mod schedule;
mod shaders;
pub mod sky;
pub mod sky_order;
pub mod static_merge;
pub mod sun;
pub mod surface;
pub mod terrain_stream;
+167
View File
@@ -0,0 +1,167 @@
//! **`WOW_MERGE_CENSUS=1` — the production-merge population census** (decision 1417: "the
//! census comes first").
//!
//! Tallies every batch the streamer walks through `spawn_model_entities` into the population
//! classes 1417's lane-1 predicate carves — anim-excluded / interior prop / WMO vs doodad ×
//! order-free vs transparent × never-fade vs fader — and prints the table once the stream has
//! been quiet for two seconds. The point is to read lane 1's yield (and every later lane's
//! go/no-go) off a live pin instead of a prior: 0922's ~40% transparent share and the
//! bracket's 21k-row delta are the numbers this either confirms or replaces.
//!
//! Statics + a quiet-timer logger, not plumbed state: the tally site is deep in the assembler
//! (which already threads eleven parameters), and this is a dev reading at a parked pin, not
//! a production mechanism — the same trade `WOW_HIT_COST` made. Wall-clock quiet is likewise
//! fine HERE (contrast the bracket's same-shaped flush timer, which 1417 retires for
//! production in favour of the weld's close rule).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use bevy::prelude::*;
/// Is the census armed? One read; the assembler tally and the logger both key on it.
pub fn census_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_MERGE_CENSUS").as_deref() == Ok("1"))
}
/// The classes, in the lane-1 predicate's own short-circuit order (1417). Indices are the
/// atomics' layout — append, never reorder.
const CLASS_NAMES: [&str; 9] = [
"excluded: anim/billboard/mat-anim",
"interior prop",
"wmo order-free never-fade", // measured 1:1 — never merges (1418)
"wmo transparent",
"wmo FINITE-FADE (anomaly)", // WMOs enroll at radius = INFINITY; nonzero here = investigate
"doodad order-free never-fade", // merges
"doodad order-free fader", // merges (1418 — the dense class)
"doodad transparent never-fade",
"doodad transparent fader",
];
const MERGE_LANES: [usize; 3] = [1, 5, 6];
static ROWS: [AtomicU64; 9] = [const { AtomicU64::new(0) }; 9];
static VERTS: [AtomicU64; 9] = [const { AtomicU64::new(0) }; 9];
/// Distinct would-be merge keys per class — each class's PREDICTED blob count under its
/// lane's key (built from [`MergeSite::census_key`]), so a lane's honest row reduction
/// (rows − blobs) is read off the table before the lane is built. The lane-1 smoke run is the
/// estimator's validation anchor: 6,892 measured blobs at the SW pin. Spawn-time only, so a
/// mutex is fine.
static KEYS: std::sync::OnceLock<Mutex<[std::collections::HashSet<u64>; 9]>> =
std::sync::OnceLock::new();
fn keys() -> &'static Mutex<[std::collections::HashSet<u64>; 9]> {
KEYS.get_or_init(|| Mutex::new(std::array::from_fn(|_| std::collections::HashSet::new())))
}
/// Zero the tallies. Called from the map-change teardown (`drop_streamed_world`): the counters
/// are cumulative and never decrement on tile unload, so without this a rig that logs in on one
/// map and teleports to another prints the SUM of both worlds — the first LBRS read did exactly
/// that (its doodad rows matched Stormwind's to the digit, because they WERE Stormwind's).
pub fn reset() {
for a in ROWS.iter().chain(VERTS.iter()) {
a.store(0, Ordering::Relaxed);
}
for set in keys().lock().unwrap().iter_mut() {
set.clear();
}
}
/// One batch, classified exactly as 1417's lane-1 predicate sees it — built once per batch by
/// the assembler and consumed by BOTH the census tally and the production divert, so the
/// number the census prints and the population the merge takes can never drift apart.
/// `excluded` is the bracket's anim predicate (anim host / billboard / alpha- uv- rgb-anim /
/// per-seq material); `order_free` is `Opaque|AlphaTest && !additive` (0858's law: order
/// information exists only on transparent-pass batches); `never_fade` is
/// `radius > NEVER_FADE_RADIUS` (WMO group geometry's ∞ radius lands there by construction).
pub struct BatchClass {
pub excluded: bool,
pub interior_prop: bool,
pub order_free: bool,
pub never_fade: bool,
}
impl BatchClass {
/// The merge predicate (1417 lane 1, widened twice by 1418: faders when the fade channels
/// moved in-shader, interior props when the probe slot did — what remains here is "static
/// and order-free"; per-SITE refusal (WMO group geometry) lives in `divert`).
pub fn merges(&self) -> bool {
!self.excluded && self.order_free
}
}
pub fn tally(class: &BatchClass, is_wmo: bool, verts: usize, key: Option<u64>) {
let &BatchClass {
excluded,
interior_prop,
order_free,
never_fade,
} = class;
let class = if excluded {
0
} else if interior_prop {
1
} else if is_wmo {
match (never_fade, order_free) {
(false, _) => 4,
(true, true) => 2,
(true, false) => 3,
}
} else {
match (order_free, never_fade) {
(true, true) => 5,
(true, false) => 6,
(false, true) => 7,
(false, false) => 8,
}
};
ROWS[class].fetch_add(1, Ordering::Relaxed);
VERTS[class].fetch_add(verts as u64, Ordering::Relaxed);
if let Some(key) = key {
keys().lock().unwrap()[class].insert(key);
}
}
/// Print the table once the stream has been quiet for 2 s; re-arm on any new tally (a fresh
/// tile crossing prints a fresh table).
pub fn log_merge_census(time: Res<Time>, mut prev: Local<(u64, f32, bool)>) {
let total: u64 = ROWS.iter().map(|a| a.load(Ordering::Relaxed)).sum();
let (last, moved_at, printed) = &mut *prev;
if total != *last {
*last = total;
*moved_at = time.elapsed_secs();
*printed = false;
return;
}
if total == 0 || *printed || time.elapsed_secs() - *moved_at < 2.0 {
return;
}
*printed = true;
let mut merge_rows = 0u64;
let keys = keys().lock().unwrap();
for (i, name) in CLASS_NAMES.iter().enumerate() {
let rows = ROWS[i].load(Ordering::Relaxed);
let verts = VERTS[i].load(Ordering::Relaxed);
let blobs = keys[i].len() as u64;
let mark = if MERGE_LANES.contains(&i) {
" <- merges"
} else {
""
};
// `blobs` = distinct would-be merge keys; `net` = the rows the lane would actually
// delete (rows − blobs) — the number the 44 ns/row derivative multiplies. Prop-class
// keys omit the referrer-set/probe-slot axes, so their net is an optimistic BOUND.
info!(
"[merge-census] {name:<34} rows={rows:>7} kverts={:>8} blobs={blobs:>6} net=-{:>6}{mark}",
verts / 1000,
rows.saturating_sub(blobs)
);
if MERGE_LANES.contains(&i) {
merge_rows += rows;
}
}
info!(
"[merge-census] merge lanes: {merge_rows} of {total} rows ({:.1}%)",
100.0 * merge_rows as f64 / total as f64
);
}
+38 -5
View File
@@ -40,12 +40,14 @@ use benilla_assets::{m2_url, wmo_url};
mod collider;
mod furnish;
pub mod merge;
mod queries;
mod spawn;
mod weld;
use collider::{finish_colliders, impassable_wall_data, terrain_collider_data};
use furnish::furnish_tile_cells;
use merge::{flush_static_merge, StaticMerge};
use spawn::prop_light::WmoDoodadInst;
use spawn::spawn_loaded_placements;
use weld::{flush_hull_welds, HullWelds};
@@ -57,7 +59,7 @@ pub use spawn::prop_light::{fold_interior_probe, PropLobeLight};
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
pub use collider::{build_collider_task, placement_collider_data, PendingCollider};
pub use spawn::{m2_anim_bound, m2_fade, point_light, spawn_model_entities};
pub use spawn::{m2_anim_bound, m2_fade, point_light, spawn_model_entities, SpawnedModel};
// The position queries + area authority (their home is `queries`; paths stay `terrain_stream::X`).
use queries::update_current_area;
pub use queries::{
@@ -144,6 +146,10 @@ struct TileState {
/// The welded map-doodad hull colliders this tile owns (decision 1369): batches of the hulls
/// whose placements registered here first, despawned with the tile like [`Self::wall`].
welds: Vec<Entity>,
/// The merged static-render blobs this tile owns (decision 1417, `WOW_STATIC_MERGE`):
/// per-(cell × material) bakes of the doodads whose placements registered here first,
/// despawned with the tile exactly like [`Self::welds`].
merged: Vec<Entity>,
}
/// Doodad/WMO placements spawned **once** and shared across the tiles that reference them — the client's
@@ -214,6 +220,11 @@ pub struct WorldLoadProgress {
pub scene_ready: bool,
/// Outstanding collider attaches (decision 0610's queue) — published by `finish_colliders`.
pub colliders_pending: usize,
/// Unflushed static-merge accumulators (1418, `WOW_STATIC_MERGE`) — published by
/// `flush_static_merge`. Merged world geometry that has not yet baked is a hole the reveal
/// must not show; same conservative semantics as the weld backlog inside
/// [`Self::colliders_pending`]. Always `0` with the lever off.
pub merge_pending: usize,
/// Focus-neighbourhood placements not yet spawned (the wait instrument's term).
pub placements_pending: usize,
/// **Which tile these facts are about** — the focus tile the streamer computed this frame.
@@ -246,7 +257,7 @@ impl WorldLoadProgress {
/// The world at the focus is presentable — the scene term plus a quiet collider queue. What
/// both consumers (the screen clear here, the settle release in the streamer) key on.
pub fn presentable(&self) -> bool {
self.scene_ready && self.colliders_pending == 0
self.scene_ready && self.colliders_pending == 0 && self.merge_pending == 0
}
}
@@ -448,6 +459,7 @@ impl Plugin for TerrainPlugin {
.init_resource::<StreamActivity>()
.init_resource::<Placements>()
.init_resource::<HullWelds>()
.init_resource::<StaticMerge>()
.init_resource::<crate::model_forms::ModelForms>()
.init_resource::<CurrentArea>()
// **The streaming chain lives in `WorldStage::Stream`** — the load-bearing half of the
@@ -467,6 +479,7 @@ impl Plugin for TerrainPlugin {
furnish_tile_cells,
spawn_loaded_placements,
flush_hull_welds,
flush_static_merge,
sync_interior_volumes,
)
.chain()
@@ -556,8 +569,11 @@ fn stream_terrain(
// reason as `asset_stores`.
location: (Option<Res<CurrentMap>>, Option<Res<MapCatalogRes>>),
mut load_progress: Option<ResMut<WorldLoadProgress>>,
mut welds: ResMut<HullWelds>,
// Bundled for the same 16-param reason as `asset_stores`: the two stream-batch accumulators
// the map-change drop must clear.
batchers: (ResMut<HullWelds>, ResMut<StaticMerge>),
) {
let (mut welds, mut static_merge) = batchers;
let (mut materials, mut meshes, wdts, _time, mut activity) = asset_stores;
let (current_map, map_catalog) = location;
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
@@ -589,6 +605,7 @@ fn stream_terrain(
&mut state,
placements,
&mut welds,
&mut static_merge,
&mut activity,
);
state.map_dir = Some(dir.clone());
@@ -752,6 +769,7 @@ fn stream_terrain(
wall: None,
clutter: Vec::new(),
welds: Vec::new(),
merged: Vec::new(),
},
);
}
@@ -1038,6 +1056,7 @@ fn drop_streamed_world(
state: &mut TerrainStreamer,
placements: &mut Placements,
welds: &mut HullWelds,
merge: &mut StaticMerge,
activity: &mut StreamActivity,
) {
for ((_tx, _ty), t) in state.tiles.drain() {
@@ -1056,6 +1075,10 @@ fn drop_streamed_world(
// map's tiles are requested (and their `TileState`s inserted) on this very frame, so a stale
// accumulator could weld the previous map's geometry into a same-numbered fresh tile.
welds.clear();
// Same staleness argument for the merge accumulators (1417) and the census tallies: both
// describe the world just dropped, and tile keys repeat across maps.
merge.clear();
crate::static_merge::reset();
}
/// Expire the placement material dedup by **distance** (decision 0793) — the within-map half of
@@ -1082,8 +1105,11 @@ fn release_world(
mut forms: ResMut<crate::model_forms::ModelForms>,
mut progress: Option<ResMut<WorldLoadProgress>>,
mut activity: ResMut<StreamActivity>,
mut welds: ResMut<HullWelds>,
// Bundled for the same reason as `stream_terrain`'s pair: the two stream-batch
// accumulators the world drop must clear (and clippy's argument-count line).
batchers: (ResMut<HullWelds>, ResMut<StaticMerge>),
) {
let (mut welds, mut static_merge) = batchers;
if state.tiles.is_empty() && state.map_dir.is_none() {
return;
}
@@ -1100,6 +1126,7 @@ fn release_world(
&mut state,
&mut placements,
&mut welds,
&mut static_merge,
&mut activity,
);
state.map_dir = None;
@@ -1116,7 +1143,13 @@ fn despawn_tile_owned(commands: &mut Commands, t: &TileState) {
for e in t.entity.into_iter().chain(t.wall) {
commands.entity(e).try_despawn();
}
for &e in t.liquid.iter().chain(&t.clutter).chain(&t.welds) {
for &e in t
.liquid
.iter()
.chain(&t.clutter)
.chain(&t.welds)
.chain(&t.merged)
{
commands.entity(e).try_despawn();
}
}
@@ -0,0 +1,712 @@
//! **`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 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
//! `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).
//!
//! 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
//! blob lands in `TileState::merged` and despawns with its tile.
//!
//! **WMO group geometry never diverts (1418's verdict):** `batch_order` is a `MatKey` axis, so
//! every WMO batch already owns a unique material handle — under the correct
//! `(uid, group, material)` key the measured merge is EXACTLY 1:1, zero rows saved. The WMO
//! share of the frame belongs to option B's cross-material retained draw, not to any
//! 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.
//! 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.
use std::collections::HashMap;
use std::sync::Arc;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoAutoAabb;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::merged_static_mesh_faded;
use benilla_formats::{ModelBlend, RenderSubmesh};
use crate::interact::WorldObject;
use crate::mesh_tag::alpha_bits;
use crate::model_render::{ModelKind, ModelPart};
use crate::wmo_portal::WmoGroupVis;
/// Vertex cap per blob: bounds any one bake + upload, and keeps a blob's cull bound a
/// neighbourhood rather than a zone (the weld's `WELD_MAX_TRIS` argument, in render units). A
/// 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).
const MERGE_IDLE_FRAMES: u32 = 15;
/// 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;
/// One accumulating blob: shared geometry + placement transforms + per-placement fade
/// spheres (index-parallel with `parts`), baked at flush.
struct MergeAcc {
parts: Vec<(Arc<RenderSubmesh>, Transform)>,
spheres: Vec<Vec4>,
/// Interior-prop accs only (index-parallel with `parts`): each part's SH-probe slot, baked
/// per vertex at flush. Empty on every other lane — homogeneous per key by construction,
/// because the interior flag is a material axis and the material is in the key.
slots: Vec<u32>,
verts: usize,
blend: ModelBlend,
kind: ModelKind,
/// [`StaticMerge::frame`] at the last append — the idle clock.
last_add: u32,
}
impl MergeAcc {
fn ready(&self, frame: u32, idle_frames: u32) -> bool {
self.verts >= MERGE_MAX_VERTS || frame.wrapping_sub(self.last_add) >= idle_frames
}
}
/// Where a diverted batch belongs — built once per placement by the spawn driver, consumed per
/// batch by the assembler's divert.
pub enum MergeSite<'a> {
/// An ADT map doodad: owned by its first-registering tile (the weld's ownership).
Doodad { owner: (i32, i32) },
/// WMO group geometry: owned by its placement; `groups` is the asset's per-submesh group
/// index table (index-parallel with the submeshes the assembler iterates).
Wmo {
uid: u32,
groups: &'a [u16],
portal_gated: bool,
},
/// A WMO doodad prop (1418 lane 3): owned by its placement, keyed by the referrer-set of
/// rooms that name it (`groups` — the blob takes the same set-valued `WmoGroupVis` its
/// members carried) and, for an interior prop, carrying the per-prop SH-probe slot the
/// bake writes per vertex.
Prop {
uid: u32,
groups: &'a Arc<[u16]>,
slot: Option<u16>,
},
}
impl MergeSite<'_> {
/// The would-be merge key of one batch under this site, hashed — the census's blob-count
/// predictor (each lane's expected blob count = distinct keys in its class). Every site's
/// key here mirrors its real divert key exactly.
pub fn census_key(
&self,
batch_idx: usize,
mat: &Handle<WowModelMaterial>,
transform: &Transform,
) -> Option<u64> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
match self {
MergeSite::Doodad { owner } => {
let cell = (
(transform.translation.x / CELL).floor() as i32,
(transform.translation.z / CELL).floor() as i32,
);
(0u8, owner, cell, mat.id()).hash(&mut h);
}
MergeSite::Wmo { uid, groups, .. } => {
(1u8, uid, groups.get(batch_idx)?, mat.id()).hash(&mut h);
}
MergeSite::Prop { uid, groups, .. } => {
(2u8, uid, groups, mat.id()).hash(&mut h);
}
}
Some(h.finish())
}
}
/// (owner tile, 133⅓-yd cell, material) — a doodad blob's identity.
type DoodadKey = ((i32, i32), (i32, i32), Handle<WowModelMaterial>);
/// (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>);
/// The in-flight merge accumulators. Same lifecycle discipline as [`super::weld::HullWelds`]:
/// fed by the spawn chain, drained one chain-step later, cleared with the world it describes.
#[derive(Resource, Default)]
pub struct StaticMerge {
/// Flush-system tick, the idle clock. Wrapping u32 — only ever read as a difference.
frame: u32,
doodads: HashMap<DoodadKey, MergeAcc>,
props: HashMap<PropKey, MergeAcc>,
/// 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
/// states the duplication factor the desk estimate guessed at ~3×.
blobs: u64,
batches: u64,
baked_verts: u64,
shared_verts: u64,
seen_geometry: std::collections::HashSet<usize>,
reported: bool,
}
/// Is lane 1 armed? Read once; the assembler divert and the flush both key on it.
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"))
}
impl StaticMerge {
/// Take one mergeable batch into its accumulator. `fade_sphere` = the placement's world
/// fade center + radius, baked per vertex at flush (a never-fader carries its true radius
/// and the shader's `> 7` arm pins it opaque). `false` = this site never merges (WMO group
/// geometry and props — 1418's verdict / the referrer-set key) — the caller spawns the
/// batch individually, the fail-open arm.
#[allow(clippy::too_many_arguments)]
pub(super) fn divert(
&mut self,
site: &MergeSite<'_>,
_batch_idx: usize,
mat: &Handle<WowModelMaterial>,
geometry: &Arc<RenderSubmesh>,
transform: Transform,
fade_sphere: Vec4,
blend: ModelBlend,
kind: ModelKind,
) -> bool {
let frame = self.frame;
let acc = match site {
MergeSite::Doodad { owner } => {
let cell = (
(transform.translation.x / CELL).floor() as i32,
(transform.translation.z / CELL).floor() as i32,
);
self.doodads
.entry((*owner, cell, mat.clone()))
.or_insert_with(|| MergeAcc {
parts: Vec::new(),
spheres: Vec::new(),
slots: Vec::new(),
verts: 0,
blend,
kind,
last_add: frame,
})
}
MergeSite::Prop { uid, groups, slot } => {
let acc = self
.props
.entry((*uid, Arc::clone(groups), mat.clone()))
.or_insert_with(|| MergeAcc {
parts: Vec::new(),
spheres: Vec::new(),
slots: Vec::new(),
verts: 0,
blend,
kind,
last_add: frame,
});
if let Some(slot) = slot {
acc.slots.push(u32::from(*slot));
}
// The material's interior axis makes a key all-interior or all-exterior; a
// ragged slot list would misindex the per-vertex bake, so it is a hard error.
debug_assert!(acc.slots.is_empty() || acc.slots.len() == acc.parts.len() + 1);
acc
}
// WMO group geometry never merges: measured 1:1 under its correct key (1418 —
// batch_order rides MatKey). The site exists for the census predictor.
MergeSite::Wmo { .. } => return false,
};
acc.spheres.push(fade_sphere);
let verts = geometry.positions.len();
if self
.seen_geometry
.insert(Arc::as_ptr(geometry) as *const () as usize)
{
self.shared_verts += verts as u64;
}
self.baked_verts += verts as u64;
self.batches += 1;
acc.parts.push((geometry.clone(), transform));
acc.verts += verts;
acc.last_add = frame;
true
}
/// Accumulators not yet baked — the reveal gate's term (`WorldLoadProgress::merge_pending`;
/// the weld's `unflushed` argument, on the render side). An overcount only delays a
/// release, never wrongs one.
pub(super) fn unflushed(&self) -> usize {
self.doodads.len() + self.props.len()
}
pub(super) fn clear(&mut self) {
self.doodads.clear();
self.props.clear();
self.blobs = 0;
self.batches = 0;
self.baked_verts = 0;
self.shared_verts = 0;
self.seen_geometry.clear();
self.reported = true;
}
}
/// Close ready accumulators into blob entities and hand each to its owner tile
/// (`TileState::merged` — despawned with the tile, like the welds). A dead owner discards its
/// accumulator: spawning a blob nothing owns is a leak (the weld's rule, and its reachability
/// argument — the owner died past the unload line).
///
/// Runs in the Stream chain right after `flush_hull_welds`, for the weld's own reason: the
/// frame's appends see the flush at a deterministic point and the owner lookups race nothing.
pub(super) fn flush_static_merge(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
merge: ResMut<StaticMerge>,
mut streamer: ResMut<super::TerrainStreamer>,
mut placements: ResMut<super::Placements>,
focus: Res<super::ViewFocus>,
mut progress: Option<ResMut<super::WorldLoadProgress>>,
) {
let merge = merge.into_inner();
merge.frame = merge.frame.wrapping_add(1);
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
});
merge.props.retain(|key, acc| {
let Some(p) = placements.by_id.get_mut(&key.0) else {
return false;
};
if !acc.ready(frame, idle) {
return true;
}
blobs += 1;
// The blob takes exactly the vis/tagging its members had (spawn/mod.rs's prop site):
// the set-valued `WmoGroupVis` + `ExteriorScene` when the building has an instance and
// rooms name the prop; untagged otherwise (no key ⇒ no exemption possible — 0784).
let vis = (!key.1.is_empty())
.then_some(p.portal_instance)
.flatten()
.map(|instance| WmoGroupVis {
instance,
groups: Arc::clone(&key.1),
});
let exterior = vis.is_some();
p.entities.push(spawn_blob(
&mut commands,
&mut meshes,
&key.2,
acc,
vis,
exterior,
));
false
});
merge.blobs += blobs;
if blobs > 0 {
merge.reported = false;
}
// Publish the backlog for the reveal gate (this system sits in the Stream chain, so the
// consumers read this frame's depth — the weld's publish discipline).
if let Some(progress) = progress.as_mut() {
progress.merge_pending = merge.unflushed();
}
// The drain report (1417's VRAM honesty line), once per settled wave: what the merge took
// and what the transform-baking duplication actually costs against the shared assets.
if !merge.reported && merge.doodads.is_empty() && merge.props.is_empty() {
merge.reported = true;
info!(
"static-merge: {} blobs from {} batches; baked {}kv vs {}kv shared ({:.2}x duplication)",
merge.blobs,
merge.batches,
merge.baked_verts / 1000,
merge.shared_verts / 1000,
merge.baked_verts as f64 / merge.shared_verts.max(1) as f64
);
}
}
/// One closed accumulator → 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
/// union `Aabb` (authored: `NoAutoAabb`).
fn spawn_blob(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mat: &Handle<WowModelMaterial>,
acc: &mut MergeAcc,
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 n = parts.len();
let (mesh, mn, mx) =
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() {
alpha_bits(1.0)
} else {
crate::mesh_tag::probe_bits(0)
};
let mut blob = commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(mat.clone()),
Transform::IDENTITY,
ModelPart {
kind: acc.kind,
blend: acc.blend,
},
MeshTag(tag),
Aabb::from_min_max(mn, mx),
NoAutoAabb,
WorldObject {
kind: acc.kind,
label: "static-merge".into(),
id: 0,
detail: format!("{n} batches merged"),
},
));
if exterior {
blob.insert(crate::exterior_cull::ExteriorScene);
}
if let Some(vis) = vis {
blob.insert(vis);
}
blob.id()
}
#[cfg(test)]
mod tests {
use super::super::{ModelHandle, Placement, Placements, TerrainStreamer, TileState, ViewFocus};
use super::*;
use benilla_assets::{ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_MERGED_SLOT};
use bevy::app::TaskPoolPlugin;
use bevy::ecs::system::RunSystemOnce;
fn geometry(verts: usize) -> Arc<RenderSubmesh> {
Arc::new(RenderSubmesh {
positions: vec![[0.0, 0.0, 0.0]; verts],
..Default::default()
})
}
fn test_app() -> App {
let mut app = App::new();
app.add_plugins(TaskPoolPlugin::default());
app.init_resource::<Assets<Mesh>>();
app.init_resource::<StaticMerge>();
app.init_resource::<TerrainStreamer>();
app.init_resource::<super::super::Placements>();
app.init_resource::<ViewFocus>();
app
}
fn blank_tile() -> TileState {
TileState {
handle: Default::default(),
entity: None,
material: None,
next_cell: 0,
furnished: false,
placements: Vec::new(),
liquid: Vec::new(),
wall: None,
clutter: Vec::new(),
welds: Vec::new(),
merged: Vec::new(),
}
}
fn divert_doodad(merge: &mut StaticMerge, owner: (i32, i32), at: Vec3, verts: usize) {
let mat = Handle::<WowModelMaterial>::default();
assert!(merge.divert(
&MergeSite::Doodad { owner },
0,
&mat,
&geometry(verts),
Transform::from_translation(at),
Vec4::new(at.x, at.y, at.z, 1.5),
ModelBlend::Opaque,
ModelKind::Doodad,
));
}
/// Two doodads in the same cell on the same material accumulate into ONE blob; a third in
/// a different cell opens a second accumulator (the locality key round 1 proved out).
#[test]
fn cell_key_partitions_doodad_accumulators() {
let mut merge = StaticMerge::default();
divert_doodad(&mut merge, (0, 0), Vec3::new(1.0, 0.0, 1.0), 3);
divert_doodad(&mut merge, (0, 0), Vec3::new(2.0, 0.0, 2.0), 3);
divert_doodad(&mut merge, (0, 0), Vec3::new(CELL + 1.0, 0.0, 1.0), 3);
assert_eq!(merge.doodads.len(), 2);
let joint = merge
.doodads
.values()
.find(|a| a.parts.len() == 2)
.expect("same-cell parts share an accumulator");
assert_eq!(joint.verts, 6);
assert_eq!(joint.spheres.len(), 2);
}
/// WMO group geometry never diverts (1418's 1:1 verdict) — the site exists for the census
/// predictor only.
#[test]
fn wmo_site_refuses_the_divert() {
let mut merge = StaticMerge::default();
let mat = Handle::<WowModelMaterial>::default();
assert!(!merge.divert(
&MergeSite::Wmo {
uid: 7,
groups: &[4, 9],
portal_gated: true,
},
0,
&mat,
&geometry(3),
Transform::IDENTITY,
Vec4::new(0.0, 0.0, 0.0, f32::INFINITY),
ModelBlend::Opaque,
ModelKind::Wmo,
));
assert!(merge.doodads.is_empty() && merge.props.is_empty());
}
fn blank_placement(portal_instance: Option<Entity>) -> Placement {
Placement {
model: ModelHandle::M2(Default::default()),
transform: Transform::IDENTITY,
entities: Vec::new(),
spawned: true,
doodad_set: 0,
name_set: 0,
doodads: Vec::new(),
portal_instance,
refs: 1,
owner: (0, 0),
}
}
/// An interior-prop blob (1418 lane 3) lands in its placement's entity list carrying the
/// set-valued room key, the per-vertex probe slots, and the INTERIOR_FOG-staged tag.
#[test]
fn interior_prop_blob_carries_rooms_and_baked_slots() {
let mut app = test_app();
app.world_mut().resource_mut::<ViewFocus>().paced = false;
let instance = app.world_mut().spawn_empty().id();
app.world_mut()
.resource_mut::<Placements>()
.by_id
.insert(7, blank_placement(Some(instance)));
let rooms: Arc<[u16]> = Arc::from([3u16, 5].as_slice());
{
let mut merge = app.world_mut().resource_mut::<StaticMerge>();
let mat = Handle::<WowModelMaterial>::default();
for slot in [11u16, 12] {
assert!(merge.divert(
&MergeSite::Prop {
uid: 7,
groups: &rooms,
slot: Some(slot),
},
0,
&mat,
&geometry(3),
Transform::IDENTITY,
Vec4::new(0.0, 0.0, 0.0, f32::INFINITY),
ModelBlend::Opaque,
ModelKind::Doodad,
));
}
}
app.world_mut().run_system_once(flush_static_merge).unwrap();
app.world_mut().run_system_once(flush_static_merge).unwrap();
let placements = app.world().resource::<Placements>();
let owned = placements.by_id.get(&7).unwrap().entities.clone();
assert_eq!(owned.len(), 1);
let blob = owned[0];
let vis = app.world().get::<WmoGroupVis>(blob).unwrap();
assert_eq!(vis.instance, instance);
assert_eq!(&*vis.groups, &[3, 5]);
// The tag keeps the interior-fog staging bit with the slot half dead (probe 0).
let tag = app.world().get::<MeshTag>(blob).unwrap();
assert_eq!(tag.0, crate::mesh_tag::probe_bits(0));
let mesh3d = app.world().get::<Mesh3d>(blob).unwrap().0.clone();
let meshes = app.world().resource::<Assets<Mesh>>();
let mesh = meshes.get(&mesh3d).unwrap();
// 3 verts per part, slots 11 then 12 replicated per vertex.
match mesh.attribute(ATTRIBUTE_WOW_MERGED_SLOT).unwrap() {
bevy::mesh::VertexAttributeValues::Uint32(v) => {
assert_eq!(v, &[11, 11, 11, 12, 12, 12]);
}
other => panic!("slot attribute has the wrong format: {other:?}"),
}
}
/// An EXTERIOR prop blob (no slots) bakes no slot attribute, and a prop no room names
/// takes no vis key and no exterior tag (the untagged-not-gated-blind rule, 0784).
#[test]
fn exterior_and_unnamed_prop_blobs_stay_plain() {
let mut app = test_app();
app.world_mut().resource_mut::<ViewFocus>().paced = false;
app.world_mut()
.resource_mut::<Placements>()
.by_id
.insert(9, blank_placement(None));
let rooms: Arc<[u16]> = Arc::from([].as_slice());
{
let mut merge = app.world_mut().resource_mut::<StaticMerge>();
let mat = Handle::<WowModelMaterial>::default();
assert!(merge.divert(
&MergeSite::Prop {
uid: 9,
groups: &rooms,
slot: None,
},
0,
&mat,
&geometry(3),
Transform::IDENTITY,
Vec4::new(0.0, 0.0, 0.0, 4.0),
ModelBlend::Opaque,
ModelKind::Doodad,
));
}
app.world_mut().run_system_once(flush_static_merge).unwrap();
app.world_mut().run_system_once(flush_static_merge).unwrap();
let placements = app.world().resource::<Placements>();
let owned = placements.by_id.get(&9).unwrap().entities.clone();
assert_eq!(owned.len(), 1);
let blob = owned[0];
assert!(app.world().get::<WmoGroupVis>(blob).is_none());
assert!(app
.world()
.get::<crate::exterior_cull::ExteriorScene>(blob)
.is_none());
let mesh3d = app.world().get::<Mesh3d>(blob).unwrap().0.clone();
let meshes = app.world().resource::<Assets<Mesh>>();
let mesh = meshes.get(&mesh3d).unwrap();
assert!(mesh.attribute(ATTRIBUTE_WOW_MERGED_SLOT).is_none());
assert!(mesh.attribute(ATTRIBUTE_WOW_FADE_SPHERE).is_some());
}
/// The idle tail closes a quiet doodad accumulator into a blob owned by its tile, with the
/// authored bound, the per-vertex fade spheres, and no per-entity fade enrollment.
#[test]
fn idle_tail_closes_a_doodad_blob_onto_its_tile() {
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());
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(3, 4),
Vec3::new(5.0, 0.0, 5.0),
3,
);
for _ in 0..(MERGE_IDLE_FRAMES - 1) {
app.world_mut().run_system_once(flush_static_merge).unwrap();
}
assert_eq!(app.world().resource::<StaticMerge>().doodads.len(), 1);
app.world_mut().run_system_once(flush_static_merge).unwrap();
assert!(app.world().resource::<StaticMerge>().doodads.is_empty());
let streamer = app.world().resource::<TerrainStreamer>();
let owned = streamer.tiles.get(&(3, 4)).unwrap().merged.clone();
assert_eq!(owned.len(), 1);
let blob = owned[0];
assert!(app.world().get::<NoAutoAabb>(blob).is_some());
assert!(app
.world()
.get::<crate::model_fade::DoodadFade>(blob)
.is_none());
// The baked mesh carries one fade sphere per vertex — the WOW_MERGED_FADE contract.
let mesh3d = app.world().get::<Mesh3d>(blob).unwrap().0.clone();
let meshes = app.world().resource::<Assets<Mesh>>();
let mesh = meshes.get(&mesh3d).unwrap();
let spheres = mesh.attribute(ATTRIBUTE_WOW_FADE_SPHERE).unwrap();
assert_eq!(spheres.len(), 3);
}
/// A dead owner discards the accumulator — no blob, no leak (the weld's rule).
#[test]
fn dead_owner_discards_the_accumulator() {
let mut app = test_app();
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(9, 9),
Vec3::ZERO,
3,
);
let before = app.world().entities().len();
app.world_mut().run_system_once(flush_static_merge).unwrap();
assert!(app.world().resource::<StaticMerge>().doodads.is_empty());
assert_eq!(app.world().entities().len(), before);
}
/// The vertex cap closes an accumulator without waiting for the idle tail.
#[test]
fn vert_cap_closes_immediately() {
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());
divert_doodad(
&mut app.world_mut().resource_mut::<StaticMerge>(),
(0, 0),
Vec3::ZERO,
MERGE_MAX_VERTS,
);
app.world_mut().run_system_once(flush_static_merge).unwrap();
let streamer = app.world().resource::<TerrainStreamer>();
assert_eq!(streamer.tiles.get(&(0, 0)).unwrap().merged.len(), 1);
}
}
@@ -56,6 +56,22 @@ impl PlacementHost {
}
}
/// What [`spawn_model_entities`] spawned.
pub struct SpawnedModel {
/// The caller's despawn list — everything this placement owns as world roots (anim host,
/// parts, world-root cards; owner-following cards manage their own lifecycle and stay out).
pub entities: Vec<Entity>,
/// Index-parallel with the `submeshes` slice: the entity each batch spawned, `None` where
/// no entity exists (a missing render form, or the `WOW_MEGA_STATIC` divert). This exists
/// because POSITION IS A CONTRACT: the WMO path zips per-batch group indices against what
/// was spawned, and a skipped batch must hold its slot rather than shift every later batch
/// onto the wrong group — which is exactly what the bracket's bare `continue` did to the
/// surviving entities of a diverted building (found by 1417's constraint map).
pub by_batch: Vec<Option<Entity>>,
/// The anim host, when the model animates (see [`PlacementHost`]).
pub host: Option<PlacementHost>,
}
/// Spawn a model's submeshes at `transform` with the full doodad/WMO component set. Returns the spawned
/// entities (for refcounted despawn).
///
@@ -122,11 +138,18 @@ pub fn spawn_model_entities(
// ([`crate::mega_static`] — an EXPERIMENT lever; its module doc owns the caveats). `None`
// on the moving-prop path (a merged blob cannot ride a gameobject).
mut mega: Option<&mut crate::mega_static::MegaStaticPending>,
// Returns the spawned entities + what the anim host armed for this placement, when the model
// animates: the joint set (bone-indexed) the emitter spawn rides its host bone off (0130 phase
// 4), and the FILE sequence slot its variation roll landed on, which the emitters' rate/gate
// tracks must be sampled against (decision 0760).
) -> (Vec<Entity>, Option<PlacementHost>) {
// `Some` on the world-static streamer paths lane 1 covers (ADT doodads + WMO group
// geometry): the production merge buffer and this placement's merge site (decision 1417,
// `WOW_STATIC_MERGE`). `None` on the WMO-prop and moving-gameobject paths.
mut merge: Option<(
&mut super::super::merge::StaticMerge,
super::super::merge::MergeSite<'_>,
)>,
// Returns the spawned entities + the per-batch map + what the anim host armed for this
// placement, when the model animates: the joint set (bone-indexed) the emitter spawn rides
// its host bone off (0130 phase 4), and the FILE sequence slot its variation roll landed
// on, which the emitters' rate/gate tracks must be sampled against (decision 0760).
) -> SpawnedModel {
let kind = if is_wmo {
ModelKind::Wmo
} else {
@@ -161,6 +184,9 @@ pub fn spawn_model_entities(
// The parts the lazy-rig wake will promote static → skinned (decision 0863) — the ones that
// got a [`crate::doodad_anim::SkinnedTwin`] below.
let mut lazy_parts: Vec<Entity> = Vec::new();
// The per-batch slot map (see [`SpawnedModel::by_batch`]) — every `continue` in the loop
// below leaves a `None` in place instead of shifting later batches.
let mut by_batch: Vec<Option<Entity>> = vec![None; submeshes.len()];
for (batch_idx, sub) in submeshes.iter().enumerate() {
// The batch's app-built render form (decision 0834). Callers gate spawning on the forms
// being complete, so a miss here is a broken contract — skip the batch rather than panic.
@@ -240,34 +266,6 @@ pub fn spawn_model_entities(
light,
seq_owner,
);
// The `WOW_MEGA_STATIC` divert (the consolidation bracket): a fully static batch — no
// anim host, not a billboard card, no per-entity material animation — skips its entity
// entirely; the merge flush draws it inside one blob per MATERIAL, which preserves every
// material semantic by construction (the handle is already deduped over blend, shade,
// batch order, fog policy...). Shared-table UV/tint loops would merge fine too (the
// table drives the material, not the entity), excluded anyway to keep the bracket's
// divert predicate trivially auditable.
if let Some(mega) = mega.as_mut() {
if crate::mega_static::enabled()
&& !animated
&& sub.billboard.is_none()
&& sub.alpha_anim.is_none()
&& seq_owner.is_none()
&& sub.uv_anim.is_none()
&& sub.rgb_anim.is_none()
{
mega.parts.push((
cutout.clone(),
crate::mega_static::PendingPart {
geometry: sub.geometry.clone(),
transform,
blend: sub.blend,
kind,
},
));
continue;
}
}
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
// this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack
// ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so no
@@ -312,6 +310,96 @@ pub fn spawn_model_entities(
seq_owner,
)
};
// The 1417 batch classification — ONE source for both the census tally and the
// production divert below, so the population the census prints and the population the
// merge takes can never drift apart.
let class = crate::static_merge::BatchClass {
excluded: animated
|| sub.billboard.is_some()
|| sub.alpha_anim.is_some()
|| seq_owner.is_some()
|| sub.uv_anim.is_some()
|| sub.rgb_anim.is_some(),
interior_prop: interior_probe,
order_free: matches!(sub.blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
&& !sub.additive,
never_fade: radius > crate::model_fade::NEVER_FADE_RADIUS,
};
// A merged FADER blob lives permanently in the reference's own fading render state —
// the blend twin (blend on, cutout ref on unfaded alpha, depth-write forced on) — with
// per-vertex alpha; at fade 1.0 that state is pixel-identical to the steady cutout, so
// nothing changes while it is steady and the feather is the reference's smooth
// translucent ramp (1420, superseding 1419's ordered-dither erode, which quantized the
// ramp to 16 visible steps — the director's report). Never-faders keep the cutout and
// the opaque bins; a steady interior prop never fades at all, so it keeps the cutout
// AND bakes an infinite sphere (its members carry no `DoodadFade` — the merged path
// must not invent one).
let merge_mat = if class.never_fade || steady_interior_prop {
&cutout
} else {
&blend
};
let merge_sphere = if steady_interior_prop {
Vec4::new(0.0, 0.0, 0.0, f32::INFINITY)
} else {
Vec4::from((transform.transform_point(local_center), radius))
};
if crate::static_merge::census_enabled() {
let key = merge
.as_ref()
.and_then(|(_, site)| site.census_key(batch_idx, merge_mat, &transform));
crate::static_merge::tally(&class, is_wmo, sub.geometry.positions.len(), key);
}
// The production consolidation divert (`WOW_STATIC_MERGE=1`, 1417/1418): an order-free
// static doodad batch — faders included, their fade rides the baked sphere through the
// shader's WOW_MERGED_FADE lane — merges into its site's blob instead of spawning. Its
// `by_batch` slot stays `None`; a refused divert (a WMO/prop site — 1418's verdict)
// falls through and spawns individually.
if let Some((merge, site)) = merge.as_mut() {
if crate::terrain_stream::merge::merge_enabled()
&& class.merges()
&& merge.divert(
site,
batch_idx,
merge_mat,
&sub.geometry,
transform,
merge_sphere,
sub.blend,
kind,
)
{
continue;
}
}
// The `WOW_MEGA_STATIC` divert (the consolidation bracket): a fully static batch — no
// anim host, not a billboard card, no per-entity material animation — skips its entity
// entirely; the merge flush draws it inside one blob per MATERIAL, which preserves every
// material semantic by construction (the handle is already deduped over blend, shade,
// batch order, fog policy...). Shared-table UV/tint loops would merge fine too (the
// table drives the material, not the entity), excluded anyway to keep the bracket's
// divert predicate trivially auditable.
if let Some(mega) = mega.as_mut() {
if crate::mega_static::enabled()
&& !animated
&& sub.billboard.is_none()
&& sub.alpha_anim.is_none()
&& seq_owner.is_none()
&& sub.uv_anim.is_none()
&& sub.rgb_anim.is_none()
{
mega.parts.push((
cutout.clone(),
crate::mega_static::PendingPart {
geometry: sub.geometry.clone(),
transform,
blend: sub.blend,
kind,
},
));
continue;
}
}
// Register both variants' materials for the per-frame UV scroll (idempotent per material —
// every instance of the batch lands on the same deduped handle).
// A period-0 loop is a constant the material seed already wrote (`sample(0.0)` IS its
@@ -484,6 +572,7 @@ pub fn spawn_model_entities(
}
(entity, local_center)
};
by_batch[batch_idx] = Some(entity);
// Animated material alpha (decision 0130 phase 2): the rare batch whose colour-alpha/weight
// tracks animate (fire flicker) or constantly dim gets its per-instance sampler; the
// visibility authority composes the value into the render-alpha tag + the A ≤ 0 cull.
@@ -577,7 +666,11 @@ pub fn spawn_model_entities(
let anchors = h.finish(commands);
PlacementHost { anchors, root, arm }
});
(out, placement_host)
SpawnedModel {
entities: out,
by_batch,
host: placement_host,
}
}
/// The `Aabb` a placed submesh is culled with: its build-time bind-pose bound, widened by the
@@ -7,7 +7,7 @@ mod assemble;
mod fx;
pub(crate) mod prop_light;
pub use assemble::spawn_model_entities;
pub use assemble::{spawn_model_entities, SpawnedModel};
pub use fx::point_light;
use fx::{
emitter_fade, spawn_emitters_for, spawn_lights_for, spawn_ribbons_for, spawn_wmo_lights_for,
@@ -39,6 +39,7 @@ use super::collider::{
build_collider_task, doodad_bodies_disabled, doodad_hulls_bare, placement_collider_data,
PendingCollider,
};
use super::merge::{MergeSite, StaticMerge};
use super::weld::{hull_weld_disabled, HullWelds};
use super::{
doodad_ground_shade, ModelHandle, Placements, ShadeResolve, TerrainStreamer, SPAWN_BUDGET,
@@ -65,6 +66,7 @@ type SpawnTables<'w> = (
ResMut<'w, ModelForms>,
ResMut<'w, HullWelds>,
ResMut<'w, crate::mega_static::MegaStaticPending>,
ResMut<'w, StaticMerge>,
);
#[allow(clippy::too_many_arguments)]
@@ -93,7 +95,7 @@ pub(super) fn spawn_loaded_placements(
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut activity, focus, mut forms, mut welds, mut mega) = tables;
let (mut probes, mut activity, focus, mut forms, mut welds, mut mega, mut merge) = tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -163,7 +165,11 @@ pub(super) fn spawn_loaded_placements(
};
let (radius, center) = m2_fade(&m.bounds, p.transform.scale.x);
let anim_bound = m2_anim_bound(&m.bounds);
let (mut ents, host) = spawn_model_entities(
let SpawnedModel {
entities: mut ents,
host,
..
} = spawn_model_entities(
&mut commands,
mat_cache,
materials,
@@ -186,6 +192,7 @@ pub(super) fn spawn_loaded_placements(
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
Some((&mut *merge, MergeSite::Doodad { owner: p.owner })),
);
// ADT doodads are exterior scene: from inside a WMO they draw only through a
// portal window (`0x683700`, fed solely by the per-window walk `0x682fa0` — see
@@ -279,7 +286,14 @@ pub(super) fn spawn_loaded_placements(
}
// WMOs carry no authored bounds → never size-fade (∞ radius), rely on the far-clip.
// (`m2: None` ⇒ no anim host, so the joint half of the return is always empty.)
let (mut ents, _) = spawn_model_entities(
// Hoisted above the spawn: the merge site needs it (a portal-gated building's
// blobs take `WmoGroupVis`), and the instance logic below reuses it.
let has_portals = !m.portal_refs.is_empty() && !m.portal_infos.is_empty();
let SpawnedModel {
entities: mut ents,
by_batch,
..
} = spawn_model_entities(
&mut commands,
mat_cache,
materials,
@@ -302,6 +316,14 @@ pub(super) fn spawn_loaded_placements(
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
Some((
&mut *merge,
MergeSite::Wmo {
uid: unique_id,
groups: &m.submesh_group,
portal_gated: has_portals,
},
)),
);
// A world WMO placement is exterior scene too (`0x6856c0`, fed by the same
// per-window populate `0x682fa0`): from inside one building, another building
@@ -319,14 +341,14 @@ pub(super) fn spawn_loaded_placements(
.entity(*e)
.insert(crate::exterior_cull::ExteriorScene);
}
// Portal visibility + interior tracking: tie every group submesh entity (the `ents`
// here are exactly the submeshes, in order, before colliders/lights are appended
// below) back to one per-placement instance that holds the placement transform + the
// per-frame visible set. The cull is per-group, so a building with a portal graph
// Portal visibility + interior tracking: tie every group submesh entity (the
// `by_batch` map is index-parallel with the submeshes — the structural form of
// what used to be a positional promise on `ents`, which the mega divert's
// `continue` silently broke) back to one per-placement instance that holds the
// placement transform + the per-frame visible set. The cull is per-group, so a building with a portal graph
// gets tagged; a portal-less prop keeps all groups visible but still spawns the
// instance when it carries a WMOAreaTable identity — the interior down-ray
// (`wmo_portal::track_current_interior`) needs it. See `crate::wmo_portal`.
let has_portals = !m.portal_refs.is_empty() && !m.portal_infos.is_empty();
if has_portals || m.wmo_id != 0 {
let instance = commands
.spawn(WmoPortalInstance {
@@ -362,7 +384,8 @@ pub(super) fn spawn_loaded_placements(
let group_key: Vec<Arc<[u16]>> = (0..m.group_nav.len() as u16)
.map(|g| Arc::from([g].as_slice()))
.collect();
for (&entity, &group) in ents.iter().zip(&m.submesh_group) {
for (&entity, &group) in by_batch.iter().zip(&m.submesh_group) {
let Some(entity) = entity else { continue };
let Some(groups) = group_key.get(group as usize).cloned() else {
continue;
};
@@ -381,9 +404,8 @@ pub(super) fn spawn_loaded_placements(
}
// The building's embedded MLIQ liquid (Stormwind's canals + fountains, the
// Ironforge lava, dungeon pools): one flat animated surface per group with water,
// spawned at the placement transform on the shared per-kind material. Appended
// AFTER the portal-instance zip above, which requires `ents` to still be exactly the
// submeshes in order.
// spawned at the placement transform on the shared per-kind material. (The group
// zip above reads `by_batch`, so appends to `ents` no longer threaten it.)
// Spawned a group at a time so each surface can take that group's cull key: a
// pool belongs to the room it sits in, and a culled room's lava must go with it
// (decision 0689 — the same defect as the props, on the same building).
@@ -572,7 +594,11 @@ pub(super) fn spawn_loaded_placements(
slot
}
};
let (mut ents, host) = spawn_model_entities(
let SpawnedModel {
entities: mut ents,
host,
..
} = spawn_model_entities(
&mut commands,
mat_cache,
materials,
@@ -595,11 +621,31 @@ pub(super) fn spawn_loaded_placements(
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
// The prop merge site (1418 lane 3): keyed by the rooms that name the prop,
// slot baked per vertex for the interior lane.
Some((
&mut *merge,
MergeSite::Prop {
uid: unique_id,
groups: &d.groups,
slot: interior_slot,
},
)),
);
// The slot frees itself when the prop despawns (streaming out) — the component hook
// returns it to the table whoever does the despawn.
if let (Some(slot), Some(&first)) = (interior_slot, ents.first()) {
commands.entity(first).insert(PropProbeSlot(slot));
// returns it to the table whoever does the despawn. A prop whose every batch
// diverted into a merge blob (1418 lane 3) spawns a bare carrier row for the hook:
// the blob aggregates many props and cannot own any single slot's lifetime.
if let Some(slot) = interior_slot {
let owner = match ents.first() {
Some(&first) => first,
None => {
let carrier = commands.spawn_empty().id();
ents.push(carrier);
carrier
}
};
commands.entity(owner).insert(PropProbeSlot(slot));
}
// Portal-cull the prop with the rooms that name it — the reference commits a WMO's
// doodads per VISIBLE group (`0x695aa0` over the group's MODR refs, from the
@@ -240,6 +240,7 @@ mod tests {
wall: None,
clutter: Vec::new(),
welds: Vec::new(),
merged: Vec::new(),
}
}
@@ -256,6 +256,11 @@ impl Plugin for WorldFoundation {
// the flag off nothing ever diverts, the buffer stays empty, and the flush early-outs.
app.init_resource::<crate::mega_static::MegaStaticPending>();
app.add_systems(bevy::app::Update, crate::mega_static::flush_mega_static);
// `WOW_MERGE_CENSUS=1` — the 1417 population census (module doc): tally rides the
// assembler, this is just the quiet-timer printer.
if crate::static_merge::census_enabled() {
app.add_systems(bevy::app::Update, crate::static_merge::log_merge_census);
}
// `WOW_UPLOAD_BUDGET=<MB>` — cap the render app's per-frame GPU asset upload (bevy's
// `RenderAssetBytesPerFrame`, unlimited by default; only assets implementing `byte_len`
// participate, which covers meshes and images — exactly the streaming payload). An