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