the retained static-world pass ships on by default

- the retained pass pays its churn debts: one shared texture-array pool with each layer encoded once, re-bakes that wait for a quiet window, killed placements leaving the draw runs — VRAM flat at 76 MB parked at Goldshire where it had climbed past 127, GPU 4.2 → 2.0 ms
- a building's furniture joins the pass in its own per-building region, so a prop's arrival never re-bakes the walls — submeshes 28,367 → 7,747 at the Stormwind gates, and Lower Blackrock Spire reads its first win, −0.37 ms
- WOW_STATIC_GX defaults on: negative at all four pins, −1.36 ms walking at Goldshire and −0.76 at the Stormwind gates; WOW_STATIC_GX=0 restores the entity path byte-for-byte

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:58:25 +07:00
co-authored by Claude Fable 5
parent 4510607e91
commit 6f2d7e8a63
11 changed files with 1240 additions and 389 deletions
+66 -15
View File
@@ -12,15 +12,19 @@
// (the Model2.bls order-2 lobe, 0803 — `min(I,1)` cap included, with its recorded caveat),
// the WMO surface lanes (slice 2: MOCV inside the clamp under GL_COLOR_MATERIAL, the
// INT/TRANS/EXT batch-class lanes, the WINDOW midpoint light, SIDN × night on lit lanes,
// ZERO point lights, the authored batch-order clip-z nudge, the interior fog triple), and
// the step-5 fog. Deliberately ABSENT (this lane never carries them): the interior-PROP
// probe/clutter/rig/fade/env-map/highlight/mat-anim lanes and per-instance tint (identity
// for statics).
// ZERO point lights, the authored batch-order clip-z nudge, the interior fog triple), the
// interior-PROP probe lane (B4, decision 1433: the per-item slot rides the record table and
// the probe rows live in the TAIL of the same shared buffer — zero live point lights, the
// group-MOLR lobes are folded into the probe at spawn), the exterior-prop Matte fixed-1.0
// family (B4), and the step-5 fog. Deliberately ABSENT (this lane never carries them): the
// clutter/rig/fade/env-map/highlight/mat-anim lanes and per-instance tint (identity for
// statics).
//
// Population contract (enforced by the collector's divert — `static_gx::StaticGx::divert`):
// never-fade order-free non-animated statics, Opaque/AlphaTest only, no env-map, no depth
// flags; cells additionally `ShadeSel::Lit`/`Shaded` only (the WMO lane never reads the
// selector). Output alpha is pinned 1.0 — every draw here is opaque-intent by admission
// never-fade order-free non-animated statics (interior props are never-fade by law),
// Opaque/AlphaTest only, no env-map, no depth flags; cells additionally
// `ShadeSel::Lit`/`Shaded` only (the WMO lane never reads the selector; the prop lane adds
// `Matte`). Output alpha is pinned 1.0 — every draw here is opaque-intent by admission
// (wow_model.wgsl's bit-3 armor, as a constant).
#import bevy_render::view::View
@@ -49,6 +53,11 @@ struct WowLight {
wmo_fog_params: vec4<f32>,
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
// The interior-prop SH probe table (B4 — lighting::prop_probes, 7 rows per slot, 8192
// slots; wow_model.wgsl owns the layout note). Sits immediately after the point table in
// the shared buffer, so extending the mirrored prefix by one region is all it takes; the
// rig/tint/mat-anim regions beyond it stay unmirrored (statics never read them).
prop_probes: array<vec4<f32>, 57344>,
}
@group(0) @binding(1) var<storage, read> wow_light: WowLight;
@@ -82,6 +91,10 @@ const WORD_CLASS_TRANS: u32 = 33554432u; // 1 << 25 — tint.w == 2 (TRANS batch
const WORD_WINDOW: u32 = 67108864u; // 1 << 26 — sidn.w (WINDOW midpoint light)
const WORD_HAS_VC: u32 = 134217728u; // 1 << 27 — the batch AUTHORED vertex colours
// (the entity shader's VERTEX_COLORS def)
// The prop lane (B4). INTERIOR without WMO = an interior M2 prop — the entity shader's own
// `interior_prop = flags.z && !flags.x` split: probe lighting, interior fog, zero live
// point lights.
const WORD_MATTE: u32 = 268435456u; // 1 << 28 — exterior MODD prop: intensity FIXED 1.0
// Vanilla cutout ref (224/255) — wow_model.wgsl's VANILLA_ALPHA_KEY.
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
@@ -193,8 +206,10 @@ fn vertex(v: GxVertex) -> GxVsOut {
// The ≤3-nearest FFP selection, anchored at the PLACEMENT origin exactly like the entity
// path (the baked per-vertex anchor — 1429's parity note; the blob path coarsened this).
// WMO surfaces take ZERO point lights — the entity path zeroes them in the vertex stage
// (wow-re trace-forensics-abbey-interior-d3d §2: zero on every observed WMO surface).
if ((v.word & WORD_WMO) != 0u) {
// (wow-re trace-forensics-abbey-interior-d3d §2: zero on every observed WMO surface) —
// and so do interior M2 props (B4): their group-MOLR point lobes are folded into the
// per-item SH probe at spawn, the entity path's own vertex-stage zeroing.
if ((v.word & (WORD_WMO | WORD_INTERIOR)) != 0u) {
out.point_lit = vec3<f32>(0.0);
} else {
out.point_lit = point_light_sum(world, v.normal, v.anchor);
@@ -259,6 +274,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
vec3<f32>(0.0),
vec3<f32>(1.0),
);
// The order-2 SH basis products over the fragment normal — shared by the exterior
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
// ATTRIBUTE_COLOR, pre-folded into the sampled base exactly where bevy_pbr folds it
// (`base_color *= in.color` before the texture multiply — commutative, so tex×vc is the
// same bits). **A colour-less batch takes the CONSTANT 1.0, never the interpolated
@@ -339,15 +358,47 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
vec3<f32>(1.0),
);
}
} else if ((in.word & WORD_INTERIOR) != 0u) {
// ---- the interior M2-PROP lane (B4, decision 1433) — wow_model.wgsl's
// interior-prop branch verbatim: the folded per-instance SH probe (MODD ambient +
// the fixed-axis diffuse lobe + the owning group's MOLR lobes, folded ONCE at
// spawn), evaluated over the fragment normal. The slot rides the record table
// (w bits 1..14) where the entity path rode MeshTag bits 6..19. The soft SH wrap
// is the reference's authored response — deliberately NOT a hard max(N·L,0).
// point_lit is zero (vertex stage); inst_tint identity, no highlight, SIDN zero on
// every M2 batch — the combine collapses to folded × clamp(probe eval).
let probe = 7u * ((recs[in.word & 0xffffu].w >> 1u) & 0x1fffu);
let n1 = vec4<f32>(n_lit, 1.0);
let lit_prop = clamp(
vec3<f32>(
dot(wow_light.prop_probes[probe + 0u], n1)
+ dot(wow_light.prop_probes[probe + 3u], quad)
+ wow_light.prop_probes[probe + 6u].x * x2y2,
dot(wow_light.prop_probes[probe + 1u], n1)
+ dot(wow_light.prop_probes[probe + 4u], quad)
+ wow_light.prop_probes[probe + 6u].y * x2y2,
dot(wow_light.prop_probes[probe + 2u], n1)
+ dot(wow_light.prop_probes[probe + 5u], quad)
+ wow_light.prop_probes[probe + 6u].z * x2y2,
),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
let primary = clamp(lit_prop + in.point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
rgb = folded * primary;
} else {
// ---- the exterior ADT-doodad lane (slice 1) ----
// ---- the exterior ADT-doodad lane (slice 1) + the exterior MODD-prop family (B4) --
// The intensity family: statics are `mat_shade` only (no per-instance ramp byte —
// that is an entity concern): lit ground ⇒ mix(2.5,0.5,0)=2.5, MCSH-shadowed ⇒ 0.5;
// then the recorded `min(I,1)` cap (unfaithful, kept in exact sync — 0803 §3).
// that is an entity concern): lit ground ⇒ mix(2.5,0.5,0)=2.5, MCSH-shadowed ⇒ 0.5,
// and the Matte mid-band (an exterior WMO MODD prop) FIXED 1.0 — the 2.5 site is one
// a MODD prop never reaches (§8b); then the recorded `min(I,1)` cap (unfaithful,
// kept in exact sync — 0803 §3; today it makes Matte and Lit read identically, and
// the distinct bit exists so lifting the cap cannot silently split this lane).
let shade_t = select(1.0, 0.0, (in.word & WORD_SHADE_LIT) != 0u);
let intensity = min(mix(2.5, 0.5, shade_t), 1.0);
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
let intensity = min(
select(mix(2.5, 0.5, shade_t), 1.0, (in.word & WORD_MATTE) != 0u),
1.0,
);
let sun_dc = wow_light.grade.yzw * intensity;
let sun_lobe = vec3<f32>(
wow_light.sh_c10_r.w + sun_dc.x
+185 -13
View File
@@ -9,10 +9,13 @@ use bevy::camera::primitives::Aabb;
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::prelude::*;
use super::{render, GxItem, StaticGx, ATTRIBUTE_GX_ANCHOR, ATTRIBUTE_GX_WORD, IDLE_FRAMES};
use super::{
WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_HAS_VC, WORD_INTERIOR, WORD_SHADE_LIT,
WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X, WORD_WRAP_Y,
render, GxItem, StaticGx, ATTRIBUTE_GX_ANCHOR, ATTRIBUTE_GX_WORD, IDLE_FRAMES,
MAX_DIRTY_FRAMES, REBAKE_FRAMES,
};
use super::{
WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_HAS_VC, WORD_INTERIOR, WORD_MATTE,
WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X, WORD_WRAP_Y,
};
/// Bake dirty-and-quiet cells into retained draw data; publish into [`render::GxWorld`].
@@ -20,21 +23,26 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
let _t = super::gx_perf_guard(0);
gx.frame = gx.frame.wrapping_add(1);
let frame = gx.frame;
for i in 0..3 {
for i in 0..5 {
if gx.declined[i] != gx.declined_logged[i] {
let d = gx.declined;
info!(
"static-gx: declined so far — env-map {}, depth-flag {}, shade-family {}",
d[0], d[1], d[2]
"static-gx: declined so far — env-map {}, depth-flag {}, shade-family {}, \
prop-fader {} (batches), prop-no-instance {} (props)",
d[0], d[1], d[2], d[3], d[4]
);
gx.declined_logged = d;
}
}
let StaticGx {
cells, wmos, world, ..
cells,
wmos,
props,
world,
..
} = &mut *gx;
for (&cell, state) in cells.iter_mut() {
if !state.dirty || frame.wrapping_sub(state.last_change) < IDLE_FRAMES {
if !bake_due(state, world.cells.contains_key(&cell), frame) {
continue;
}
state.dirty = false;
@@ -60,7 +68,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
world.cells.insert(cell, baked);
}
for (&instance, state) in wmos.iter_mut() {
if !state.dirty || frame.wrapping_sub(state.last_change) < IDLE_FRAMES {
if !bake_due(state, world.wmos.contains_key(&instance), frame) {
continue;
}
state.dirty = false;
@@ -87,6 +95,53 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
);
world.wmos.insert(instance, baked);
}
// The prop regions (B4): the WMO loop's shape with the referrer SET as the selection
// grain — the sort keeps runs set-homogeneous, and the published draw carries the
// region's set list beside the per-set bounds `bake_cell` accumulated into `groups`.
for (&instance, state) in props.iter_mut() {
if !bake_due(state, world.props.contains_key(&instance), frame) {
continue;
}
state.dirty = false;
if state.items.is_empty() {
world.props.remove(&instance);
continue;
}
state.items.sort_by_key(|i| {
(
(u8::from(i.cutout) << 1) | u8::from(i.two_sided),
i.texture,
i.prop.as_ref().map_or(0, |p| p.set),
)
});
let mut baked = bake_cell(&state.items, &mut meshes);
baked.sets = state.sets.clone();
info!(
"static-gx: props {instance} baked — {} item(s), {} set(s), {} vert(s)",
state.items.len(),
baked.sets.len(),
baked.draws.last().map_or(0, |d| d.vertex_range.end),
);
world.props.insert(instance, baked);
}
}
/// Is this region's bake due? A FIRST bake waits only the short window (fast appearance); a
/// re-bake of a published region waits for the LONG quiet window (B3, decision 1432 — the
/// admission trickle arrives spaced wider than the short window, so B2 re-baked cells once
/// per arrival for minutes; 1431's cost map priced it), with the age cap so a never-quiet
/// region still consolidates.
fn bake_due(state: &super::GxCell, published: bool, frame: u32) -> bool {
if !state.dirty {
return false;
}
let window = if published {
REBAKE_FRAMES
} else {
IDLE_FRAMES
};
frame.wrapping_sub(state.last_change) >= window
|| frame.wrapping_sub(state.dirty_since) >= MAX_DIRTY_FRAMES
}
/// B2 (1431): the bake reassigns item indices — remap each fader placement's kill targets
@@ -149,8 +204,16 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
| (u32::from(item.unlit) * WORD_UNLIT)
| (u32::from(item.fog_off) * WORD_FOG_OFF)
| (u32::from(item.shade_lit) * WORD_SHADE_LIT)
| (u32::from(item.matte) * WORD_MATTE)
| (u32::from(item.texture.is_some()) * WORD_TEXTURED)
| (u32::from(has_vc) * WORD_HAS_VC)
// An interior PROP is WORD_INTERIOR with WORD_WMO clear (B4) — the entity
// shader's own `interior_prop = flags.z && !flags.x` split. A slot-less prop
// (exterior, or probe-table overflow) keeps the exterior law, like the entity
// path's fallback.
| item.prop.as_ref().map_or(0, |p| {
u32::from(p.slot.is_some()) * WORD_INTERIOR
})
| item.wmo.as_ref().map_or(0, |w| {
WORD_WMO
| (u32::from(w.interior) * WORD_INTERIOR)
@@ -194,13 +257,22 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
words.push(word_flags); // layer bits 0..16 resolve render-side (dims live there)
anchors.push(anchor.to_array());
}
if let Some(w) = &item.wmo {
match group_bounds.iter_mut().find(|(g, _, _)| *g == w.group) {
// The selection grain: a WMO item's GROUP, a prop item's referrer SET (B4) — one
// field, because the node's range selection is the same mechanism for both (the
// per-frame bit vector just means "group visible" on one map and "set admitted" on
// the other).
let sel_group = item
.wmo
.as_ref()
.map(|w| w.group)
.or(item.prop.as_ref().map(|p| p.set));
if let Some(g) = sel_group {
match group_bounds.iter_mut().find(|(gb, _, _)| *gb == g) {
Some((_, bmn, bmx)) => {
*bmn = bmn.min(gmn);
*bmx = bmx.max(gmx);
}
None => group_bounds.push((w.group, gmn, gmx)),
None => group_bounds.push((g, gmn, gmx)),
}
}
let start = u32::try_from(indices.len()).expect("gx cell under u32 indices");
@@ -211,9 +283,10 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
cutout: item.cutout,
two_sided: item.two_sided,
vertex_range: base..u32::try_from(positions64.len()).unwrap(),
group: item.wmo.as_ref().map(|w| w.group),
group: sel_group,
order: item.wmo.as_ref().map_or(0, |w| w.order),
sidn: item.wmo.as_ref().map_or([0; 3], |w| w.sidn),
slot: item.prop.as_ref().and_then(|p| p.slot).unwrap_or(0),
});
}
// Recentre for clip-space precision (0974): the shader reconstructs world = v + origin.
@@ -255,6 +328,7 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
)
})
.collect(),
sets: Vec::new(), // prop regions: the flush fills it from the collector's dedup list
killed,
killed_rev: 0,
}
@@ -269,6 +343,35 @@ mod tests {
use benilla_formats::{ModelBlend, RenderSubmesh, WmoBatchClass};
use std::sync::Arc;
/// The bake cadence (B3, decision 1432): first bakes wait only the short window, re-bakes
/// of a published region wait for the long one, and the age cap consolidates a
/// never-quiet region regardless.
#[test]
fn the_bake_waits_short_first_and_long_after() {
let mut state = super::super::GxCell::default();
assert!(!bake_due(&state, false, 100), "clean cells are never due");
state.dirty = true;
state.dirty_since = 0;
state.last_change = 0;
assert!(!bake_due(&state, false, IDLE_FRAMES - 1));
assert!(
bake_due(&state, false, IDLE_FRAMES),
"first bake: short window"
);
assert!(
!bake_due(&state, true, IDLE_FRAMES),
"published: short is not enough"
);
assert!(!bake_due(&state, true, REBAKE_FRAMES - 1));
assert!(
bake_due(&state, true, REBAKE_FRAMES),
"published: long window"
);
// A cell that keeps changing (never quiet) still consolidates at the age cap.
state.last_change = MAX_DIRTY_FRAMES;
assert!(bake_due(&state, true, MAX_DIRTY_FRAMES));
}
/// A quiet cell bakes: one recentred mesh whose draws sort by (bucket, texture), each
/// item's word carrying its index + flags, index ranges contiguous over one index buffer.
#[test]
@@ -467,6 +570,75 @@ mod tests {
assert!(gx.wmos.is_empty());
}
/// A prop region bakes with the referrer SET as the selection grain (B4): draws sort
/// (bucket, texture, set) and carry the set index in `group`, per-set bounds land in
/// `groups`, the interior slot rides the draw, and the word carries
/// INTERIOR-without-WMO for slotted items and MATTE for the exterior family.
#[test]
fn a_prop_region_bakes_set_ranges_and_slots() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let instance = Entity::PLACEHOLDER;
let rooms_a: Arc<[u16]> = Arc::from([3u16].as_slice());
let rooms_b: Arc<[u16]> = Arc::from([5u16, 8].as_slice());
let mk = |groups: &Arc<[u16]>, slot| super::super::GxPropBatch {
instance,
groups: Arc::clone(groups),
slot,
};
// Set B pushed FIRST — the sort must bring set A (index 1? no: dedup order is
// arrival order, so B=0, A=1) ranges together and set-homogeneous.
let mut b = batch(&g, Vec3::new(1.0, 0.0, 1.0), None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.prop = Some(mk(&rooms_b, Some(42)));
assert!(gx.divert(b));
let mut b = batch(&g, Vec3::new(2.0, 0.0, 2.0), None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.prop = Some(mk(&rooms_a, None));
assert!(gx.divert(b));
let mut b = batch(&g, Vec3::new(3.0, 0.0, 3.0), None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.prop = Some(mk(&rooms_b, Some(43)));
assert!(gx.divert(b));
let state = gx.props.get_mut(&instance).expect("the instance's region");
state.items.sort_by_key(|i| {
(
(u8::from(i.cutout) << 1) | u8::from(i.two_sided),
i.texture,
i.prop.as_ref().map_or(0, |p| p.set),
)
});
let mut meshes = Assets::<Mesh>::default();
let baked = bake_cell(&state.items, &mut meshes);
// Set 0 (rooms_b)'s two items sit adjacent; set 1 (rooms_a) follows.
assert_eq!(
baked.draws.iter().map(|d| d.group).collect::<Vec<_>>(),
vec![Some(0), Some(0), Some(1)]
);
assert_eq!(
baked.draws.iter().map(|d| d.slot).collect::<Vec<_>>(),
vec![42, 43, 0]
);
// Per-set bounds for the admission walk.
let mut sets: Vec<u16> = baked.groups.iter().map(|(s, _)| *s).collect();
sets.sort_unstable();
assert_eq!(sets, vec![0, 1]);
// The word: a slotted item is INTERIOR without WMO; a slot-less Matte prop keeps
// the exterior law with the MATTE family bit.
let mesh = meshes.get(&baked.mesh).unwrap();
let Some(bevy::mesh::VertexAttributeValues::Uint32(words)) =
mesh.attribute(ATTRIBUTE_GX_WORD)
else {
panic!("gx word attribute missing")
};
let w_int = words[baked.draws[0].vertex_range.start as usize];
let w_ext = words[baked.draws[2].vertex_range.start as usize];
assert_ne!(w_int & WORD_INTERIOR, 0);
assert_eq!(w_int & WORD_WMO, 0, "a prop item never takes the WMO lane");
assert_eq!(w_ext & WORD_INTERIOR, 0);
assert_ne!(w_ext & WORD_MATTE, 0);
}
/// Authored vertex colours bake RAW into ATTRIBUTE_COLOR and set the HAS_VC bit — the
/// entity mesh's exact carriage (`model.rs` inserts `vertex_colors` untransformed), which
/// is both MOCV's lane and the fix for the slice-1 gap where a doodad's baked constant
+135 -16
View File
@@ -5,6 +5,7 @@
use bevy::prelude::*;
use super::render::GxDoodadVis;
use super::{FaderState, GxFader, StaticGx};
/// Exile hysteresis (yd). Entry into the feather has NO slack — a retained draw at
@@ -71,6 +72,18 @@ fn fade_step(radius: f32, d: f32, current: FadeClass) -> FadeClass {
}
}
/// One referrer set's PVS verdict (B4): a prop is drawn from ANY of the rooms that name it,
/// each bit read fail-open — `WmoGroupVis::drawn_by`'s exact rule at range-selection grain.
/// An EMPTY set is always admitted (an unnamed prop carries no `WmoGroupVis` on the entity
/// path and is never portal-culled; `drawn_by`'s `any()` on empty would read false, which is
/// why the entity path never constructs the component for one).
fn set_admitted(visible: &[bool], rooms: &[u16]) -> bool {
rooms.is_empty()
|| rooms
.iter()
.any(|&g| visible.get(usize::from(g)).copied().unwrap_or(true))
}
/// `WOW_GX_FADE_TRACE=1` — one line per exile transition (the live-probe instrument for the
/// state machine: what crossed, where, into what).
fn fade_trace() -> bool {
@@ -187,13 +200,19 @@ pub(super) fn cull_cells(
let StaticGx {
cells,
wmos,
props,
world,
fade_events,
..
} = &mut *gx;
// Reap dead regions (instance despawned with its placement) — items and published draws.
// Prop regions share the instance lifecycle (B4): the instance dies exactly when the
// placement releases at refs 0, and a WMO placement never hands off (the straddler
// handoff is M2-only), so no owner-tile bookkeeping exists to go stale.
wmos.retain(|e, _| instances.contains(*e));
world.wmos.retain(|e, _| instances.contains(*e));
props.retain(|e, _| instances.contains(*e));
world.props.retain(|e, _| instances.contains(*e));
world.visible.clear();
world.visible_wmos.clear();
let Some((cam_t, proj, frustum)) = cam_view else {
@@ -343,8 +362,17 @@ pub(super) fn cull_cells(
}
}
// The dev doodad toggle, cell-wholesale (see the module doc).
// The own-building exemption, shared by the WMO groups' gate term and the prop sets'
// (B4): the camera's containing placement is not exterior to itself (decision 0784's
// dynamic exemption — a static tag could not be right, the player walks in and out).
let own = claim.0.map(|c| c.room.instance);
// The dev doodad toggle, cell-wholesale (see the module doc). The doodad phase — cells
// AND prop regions (B4; a WMO's furniture is the M2 scene, not the WMO phase) — sorts
// NEAR-FIRST as one list (B3, decision 1432): the 1.12 client's front-to-back band walk
// (32 bands × 33⅓ yd, `terrain.md` 0xc7bd40) at this lane's grain: the node draws the
// list in order, so early-z rejects the far entries' fragments instead of shading them.
if doodads_on {
let mut admitted: Vec<(u32, GxDoodadVis)> = Vec::new();
for (&cell, draw) in &world.cells {
let center = draw.origin + Vec3::from(draw.aabb.center);
let radius = Vec3::from(draw.aabb.half_extents).length();
@@ -370,14 +398,69 @@ pub(super) fn cull_cells(
) {
continue;
}
world.visible.push(cell);
// Distance² is non-negative, so its IEEE bits sort exactly like the value.
admitted.push((
cam_pos.distance_squared(center).to_bits(),
GxDoodadVis::Cell(cell),
));
}
// The prop regions (B4) join the same list: admission per referrer SET — the PVS
// bit ORs over the set's rooms ([`set_admitted`] — `WmoGroupVis::drawn_by`'s
// fail-open read, behind the same panel switch), ∧ frustum ∧ farclip ∧ the exterior
// gate for NAMED sets with the own-building exemption. An empty set (an unnamed
// prop) admits bare and is never gated: 0784's untagged rule — no key, no exemption
// possible, so it is deliberately not gated blind.
for (&entity, draw) in &world.props {
// A region whose instance can't answer this frame keeps last frame's absence —
// one frame of the entity path's own arrival class (the WMO regions' rule).
let Ok(inst) = instances.get(entity) else {
continue;
};
let mut sel = vec![false; draw.sets.len()];
let mut any = false;
for (set, aabb) in &draw.groups {
let Some(rooms) = draw.sets.get(usize::from(*set)) else {
continue;
};
if m.portal_cull && !set_admitted(&inst.visible, rooms) {
continue;
}
let center = draw.origin + Vec3::from(aabb.center);
let radius = Vec3::from(aabb.half_extents).length();
if !crate::view::within_farclip(view.farclip, cam_pos, cam_fwd, center, radius) {
continue;
}
let sphere = bevy::camera::primitives::Sphere {
center: center.into(),
radius,
};
if !frustum.intersects_sphere(&sphere, false) {
continue;
}
if !rooms.is_empty()
&& Some(entity) != own
&& !gate.admits(&GlobalTransform::from_translation(draw.origin), Some(aabb))
{
continue;
}
sel[usize::from(*set)] = true;
any = true;
}
if any {
let center = draw.origin + Vec3::from(draw.aabb.center);
admitted.push((
cam_pos.distance_squared(center).to_bits(),
GxDoodadVis::Prop(entity, sel),
));
}
}
admitted.sort_unstable_by_key(|(d, _)| *d);
world.visible.extend(admitted.into_iter().map(|(_, v)| v));
}
// The WMO regions — the dev WMO toggle wholesale, then per-group admission.
// The WMO regions — the dev WMO toggle wholesale, then per-group admission; regions
// likewise ordered near-first (B3).
if m.kind_visible[crate::model_render::kind_index(crate::model_render::ModelKind::Wmo)] {
// The own-building exemption: the camera's containing placement (decision 0784's
// dynamic exemption — a static tag could not be right, the player walks in and out).
let own = claim.0.map(|c| c.room.instance);
let mut admitted: Vec<(u32, (Entity, Vec<bool>))> = Vec::new();
for (&entity, draw) in &world.wmos {
// A region whose instance can't answer this frame (spawn-command latency) keeps
// last frame's absence — one frame of the entity path's own arrival class.
@@ -421,9 +504,14 @@ pub(super) fn cull_cells(
any = true;
}
if any {
world.visible_wmos.push((entity, sel));
let center = draw.origin + Vec3::from(draw.aabb.center);
admitted.push((cam_pos.distance_squared(center).to_bits(), (entity, sel)));
}
}
admitted.sort_unstable_by_key(|(d, _)| *d);
world
.visible_wmos
.extend(admitted.into_iter().map(|(_, v)| v));
}
// `WOW_GX_CENSUS=1` — the slice-2 agreement instrument (1429: "LBRS-style drawn-count
// agreement"): each selected item IS one would-be submesh entity, so this line reads
@@ -436,12 +524,29 @@ pub(super) fn cull_cells(
&& gx.frame.is_multiple_of(64)
{
let world = &gx.world;
let cell_items: usize = world
.visible
.iter()
.filter_map(|c| world.cells.get(c))
.map(|d| d.draws.len())
.sum();
let mut cell_items = 0usize;
let (mut prop_regions, mut prop_items) = (0usize, 0usize);
for vis in &world.visible {
match vis {
GxDoodadVis::Cell(c) => {
cell_items += world.cells.get(c).map_or(0, |d| d.draws.len());
}
GxDoodadVis::Prop(e, sel) => {
prop_regions += 1;
if let Some(d) = world.props.get(e) {
prop_items += d
.draws
.iter()
.filter(|i| {
i.group.is_some_and(|s| {
sel.get(usize::from(s)).copied().unwrap_or(false)
})
})
.count();
}
}
}
}
let (mut wmo_items, mut wmo_groups) = (0usize, 0usize);
for (e, sel) in &world.visible_wmos {
if let Some(d) = world.wmos.get(e) {
@@ -469,12 +574,14 @@ pub(super) fn cull_cells(
let ev = gx.fade_events;
println!(
"GX_CENSUS cells={}/{} cell_items={cell_items} wmo_regions={}/{} \
wmo_groups={wmo_groups} wmo_items={wmo_items} faders={fs}s/{fx}x/{fg}g \
fade_events={}x/{}s/{}g",
world.visible.len(),
wmo_groups={wmo_groups} wmo_items={wmo_items} \
prop_regions={prop_regions}/{} prop_items={prop_items} \
faders={fs}s/{fx}x/{fg}g fade_events={}x/{}s/{}g",
world.visible.len() - prop_regions,
world.cells.len(),
world.visible_wmos.len(),
world.wmos.len(),
world.props.len(),
ev[0],
ev[1],
ev[2],
@@ -503,6 +610,18 @@ pub(super) fn cull_cells(
mod tests {
use super::*;
/// The set admission mirrors `WmoGroupVis::drawn_by` exactly: any named room visible ⇒
/// drawn, missing bits fail-open, and the empty set (an unnamed prop) always admits.
#[test]
fn set_admission_is_any_of_named_rooms_fail_open() {
let vis = [false, true, false];
assert!(set_admitted(&vis, &[1]));
assert!(set_admitted(&vis, &[0, 1]));
assert!(!set_admitted(&vis, &[0, 2]));
assert!(set_admitted(&vis, &[7]), "a missing bit fails open");
assert!(set_admitted(&vis, &[]), "an unnamed prop is never culled");
}
/// The state machine against the fade law it mirrors: entry edges exact (the retained
/// draw must stop the frame the authority would feather), exit edges sticky by the
/// hysteresis (a camera parked on a band edge cannot flap).
+228 -32
View File
@@ -1,18 +1,20 @@
//! **`WOW_STATIC_GX=1` — the B1 retained-pass prototype** (decision 1429): the never-fade
//! order-free ADT-doodad population leaves bevy_pbr entirely and draws from per-cell retained
//! buffers in one custom render node, cross-material — per-vertex texture-array layers + flag
//! bits where the entity path had one material handle per batch.
//! **The retained static-world pass — ON by default** (decisions 1429–1434;
//! `WOW_STATIC_GX=0` opts out): the static world leaves bevy_pbr entirely and draws from
//! retained buffers in one custom render node, cross-material — per-vertex texture-array
//! layers + flag bits where the entity path had one material handle per batch. Grown in
//! four slices: B1 never-fade doodads + WMO group geometry, B2 faders via the exile
//! protocol, B3 the churn payments (shared pool, re-bake cadence, draw order, kill-aware
//! runs), B4 the WMO-prop absorption; flipped default-on at 1434 after the director's eye
//! passed at the pins.
//!
//! **Prototype, dev-only, correctness-graded** (1429's B1: "correctness, not perf"; legs price
//! B2+, never this). Armed, it replaces lane 1's doodad divert for eligible batches; off (the
//! default), this module registers nothing and costs nothing. The bar is pixel-identity against
//! the entity path (`WOW_STATIC_MERGE=0 WOW_STATIC_GX=0` vs `=1`) at a parked pin — the
//! on-demand A/B harness, not the director's eye (§7: captures are a regression tool).
//! The correctness bar is pixel-identity against the entity path
//! (`WOW_STATIC_MERGE=0 WOW_STATIC_GX=0` vs the armed default) at the capture fixtures —
//! the on-demand A/B harness, not the director's eye (§7: captures are a regression tool).
//!
//! What it deliberately does NOT build (1429 stages them): horizon occlusion, class-aware
//! index baking (items are sorted by `(bucket, texture)` main-world and coalesced into runs
//! render-side; dims and format are only knowable there — BLP images are
//! `RENDER_WORLD`-only), front-to-back region order (B3), and any default flip.
//! `RENDER_WORLD`-only), and any default flip.
//!
//! The visibility terms at this lane's granularity (1429's table): frustum + farclip + the
//! exterior window gate are CPU per-CELL ([`cull_cells`] — the real client's own shape, a CPU
@@ -56,8 +58,14 @@
//! **Known B1 gaps, deliberate** (B2+ owns them): no `presentable()` coupling — an unbaked
//! cell can show a hole for a beat on a cold arrival where the merge publishes
//! `merge_pending` into the reveal cover (1419); dev-instrument identity (inspector/WOW_PICK
//! name nothing — the weld/merge trade, one side table if missed); per-cell texture arrays
//! duplicate shared textures across cells (VRAM; class-aware sharing is a B2 refinement).
//! name nothing — the weld/merge trade, one side table if missed).
//!
//! **B3 (decision 1432): the churn 1431 priced is paid.** Texture arrays are ONE SHARED POOL
//! across every cell and region (dedup by asset id; `pool.rs` owns the design note), layer
//! copies encode exactly once, re-bakes ride the long quiet window ([`REBAKE_FRAMES`] —
//! 1424's consolidate-on-quiet at bake grain), admitted cells and regions draw NEAR-FIRST
//! (the 1.12 band walk's effect at cell grain), and killed items are dropped from the draw
//! runs entirely rather than collapsed in the vertex stage (the WGSL collapse stays as belt).
//!
//! **Slice 2 (1429's second half): WMO group geometry.** An order-free WMO group batch diverts
//! into a region keyed by its placement's [`crate::wmo_portal::WmoPortalInstance`] ENTITY —
@@ -73,6 +81,23 @@
//! classes, SIDN night glow, WINDOW midpoint light, the interior fog triple, ZERO point
//! lights, the authored batch-order clip-z nudge) mirror `wow_model.wgsl` in `static_gx.wgsl`.
//!
//! **B4 (decision 1433): the prop site joins the pass — A's blobs absorbed.** WMO doodad
//! props (1418's lane 3, the population A merged) divert into PROP REGIONS keyed by the same
//! instance entity as the WMO regions but held apart (props trickle in as their M2s load;
//! a shared region would re-bake the building's geometry per arrival — B3's churn class).
//! Admission is per REFERRER SET (the distinct room-sets naming this region's props): the
//! PVS bit ORs over the set exactly like `WmoGroupVis::drawn_by`, ∧ frustum ∧ farclip ∧ the
//! exterior gate with the own-building exemption — an empty set (an unnamed prop) admits
//! bare and is never exterior-gated (0784's untagged rule). Interior props light from their
//! folded SH probe: the slot rides the record table (w bits 1..14) and the shader reads the
//! probe region of the SAME shared light buffer every material binds; live point lights are
//! zero (folded at spawn), fog is the interior triple. Exterior props take the Matte
//! fixed-1.0 sun family ([`WORD_MATTE`]). Declined and censused, not absorbed: exterior
//! FADER props (the exile protocol has no prop shape — they keep the default path's own
//! per-entity fade) and props of instance-less placements (no PVS identity). With B4, the
//! armed lane leaves A's merge only the divert's declined families; the default flip is a
//! later record's question. `WOW_STATIC_GX_PROP=0` is the lane-isolation lever.
//!
//! **Slice-2 verdict (2026-08-18):** floors exactly 0 on all 11 scenarios; the armed A/B
//! reads MAE ≤ 0.003 with 99.9 % of differing pixels at exactly 1/255 and 2–35 px/frame
//! beyond 8 (isolated silhouette-edge pixels) — the arithmetic floor of a baked
@@ -98,15 +123,27 @@ use benilla_formats::{ModelBlend, RenderSubmesh, WmoBatchClass};
mod bake;
mod cull;
mod pool;
mod render;
/// The doodad spatial cell — ¼ ADT tile, the same 133⅓-yd locality key the merge lanes use
/// (`terrain_stream::merge::CELL`; 1413 round 2 proved the locality load-bearing).
const CELL: f32 = 533.333_3 / 4.0;
/// Quiet frames before a dirty cell re-bakes (~¼ s at 60 Hz) — the merge's own close rationale:
/// fast appearance, consolidation once the admission burst has moved on.
/// Quiet frames before a dirty cell's FIRST bake (~¼ s at 60 Hz) — the merge's own close
/// rationale: fast appearance, consolidation once the admission burst has moved on.
const IDLE_FRAMES: u32 = 15;
/// Quiet frames before an already-published cell RE-bakes (~2 s) — 1424's spawn-fast/
/// consolidate-on-quiet shape at bake granularity (B3, decision 1432): the admission trickle
/// arrives in bursts spaced wider than [`IDLE_FRAMES`], so the short window re-baked cells
/// once per arrival for minutes (1431 measured the churn); the long window batches a whole
/// trickle span per re-bake. The cost is arrival latency for content joining an ALREADY-baked
/// cell (distance-trickle admissions, under fog at the cell's range) — the entity path's own
/// arrival class, slower by construction.
const REBAKE_FRAMES: u32 = 120;
/// A dirty cell this old re-bakes even if never quiet (~10 s) — the trickle must not be able
/// to starve a re-bake forever.
const MAX_DIRTY_FRAMES: u32 = 600;
/// Per-vertex packed word: texture-array layer in bits 0..16, flag bits above —
/// see `static_gx.wgsl` (kept in sync by the flush's packing below).
@@ -141,11 +178,24 @@ const WORD_WINDOW: u32 = 1 << 26;
// colourless INT batch takes the no-COLORS combine (plain tex × lit), so the override must
// key on authored colours, not on the lane.
const WORD_HAS_VC: u32 = 1 << 27;
// The prop lane (B4, decision 1433) — an exterior WMO MODD prop's Matte sun family: intensity
// FIXED 1.0 (`ShadeSel::Matte`, the mid-band selector — the ADT 2.5 site is one a MODD prop
// never reaches, §8b). A distinct bit rather than an alias of SHADE_LIT: under the recorded
// `min(I,1)` cap the two read identically today, but the cap is the lane's one unfaithful
// term (0803 §3) and lifting it must not silently split this lane's parity.
const WORD_MATTE: u32 = 1 << 28;
// An INTERIOR M2 prop (B4) is WORD_INTERIOR with WORD_WMO clear — exactly the entity shader's
// `interior_prop = flags.z && !flags.x`: the per-item SH-probe lane (record-table slot),
// interior fog, zero live point lights (the group-MOLR lobes are folded into the probe).
/// Armed? (`WOW_STATIC_GX=1`.) Read once; the plugin registers nothing when off.
/// Armed? **ON by default** (decision 1434: the B chapter's population story is complete —
/// B1–B4 — with pixel parity at the quantization floor, legs negative at all four pins, and
/// the director's eye passed at the pins). `WOW_STATIC_GX=0` is the opt-out lever — the A/B
/// arm for every comparison this chapter still owes; `=1` still reads as an explicit on for
/// anything that predates the flip. Read once; the plugin registers nothing when off.
pub fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_STATIC_GX").as_deref() == Ok("1"))
*ON.get_or_init(|| std::env::var("WOW_STATIC_GX").as_deref() != Ok("0"))
}
/// `WOW_WMO_BIAS=0` — B38's A/B diagnostic, honoured exactly where `model_material` honours
@@ -172,6 +222,14 @@ pub(crate) fn fade_lane_disabled() -> bool {
*OFF.get_or_init(|| matches!(std::env::var("WOW_STATIC_GX_FADE").as_deref(), Ok("0")))
}
/// `WOW_STATIC_GX_PROP=0` — the B4 lane-isolation lever (the family's third): WMO-prop
/// batches refuse wholesale and fall back to the merge/entity path, cells and WMO regions
/// unaffected. The attribution instrument for any prop-lane A/B.
fn prop_lane_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| matches!(std::env::var("WOW_STATIC_GX_PROP").as_deref(), Ok("0")))
}
/// `WOW_GX_PERF=1` — the lane's own cost meter (1431's regression hunt): each armed system
/// accumulates its wall time into [`GX_PERF`], and the cull prints + zeroes the set at
/// ~1 Hz — `GX_PERF flush/cull/publish/prepare/node ms-per-frame`. Main-world and
@@ -231,14 +289,28 @@ struct GxItem {
shade_lit: bool,
wrap_x: bool,
wrap_y: bool,
/// The exterior-prop Matte sun family (B4): intensity fixed 1.0. False everywhere else.
matte: bool,
/// `Some` on a WMO group-geometry item (slice 2) — the per-batch WMO facts the shader's
/// WMO lanes read.
wmo: Option<GxItemWmo>,
/// `Some` on a WMO-prop item (B4, decision 1433) — the referrer-set index + probe slot.
prop: Option<GxItemProp>,
/// `Some(uid)` on a FADER item (B2, decision 1431): the placement this item exiles with.
/// The flush maps post-sort item indices back to the cell's [`GxFader`] through it.
fader: Option<u32>,
}
/// A WMO-prop item's baked facts (B4): which of its region's referrer SETS admits it (the
/// index into [`GxCell::sets`] — the PVS admission ORs over the set's rooms, the entity
/// path's `WmoGroupVis::drawn_by` at range-selection grain), and the interior prop's folded
/// SH-probe slot (`None` = exterior light: an exterior MODD prop, or a probe-table overflow —
/// the entity path's own fallback).
struct GxItemProp {
set: u16,
slot: Option<u16>,
}
/// One fader placement's exile seed + live state (B2, decision 1431): everything needed to
/// respawn the placement's diverted batches as ordinary entities the frame the camera enters
/// its feather band, and to punch its retained items out (the per-item kill bit) while any
@@ -316,6 +388,8 @@ struct GxCell {
items: Vec<GxItem>,
dirty: bool,
last_change: u32,
/// The frame `dirty` last went false→true — [`MAX_DIRTY_FRAMES`]'s clock.
dirty_since: u32,
/// The cell's fader placements (B2), keyed by placement uniqueId. Empty on WMO regions.
faders: bevy::platform::collections::HashMap<u32, GxFader>,
/// The published kill bitmap no longer matches the fader states (a re-bake reassigned
@@ -331,6 +405,10 @@ struct GxCell {
/// The scan's last wholesale verdict for this cell (`None` = mixed/unknown → walk every
/// frame). A repeated all-steady / all-gone verdict skips the per-placement walk.
settled: Option<bool>,
/// The DISTINCT referrer sets of this region's prop items (B4; prop regions only —
/// empty on cells and WMO regions). An item's [`GxItemProp::set`] indexes here; content
/// dedup means props named by the same rooms share one selection bit and one run key.
sets: Vec<Arc<[u16]>>,
}
/// The main-world collector + the published draw set. One resource carries both halves so the
@@ -341,14 +419,19 @@ pub struct StaticGx {
/// The WMO regions (slice 2), keyed by placement instance entity — the same identity the
/// portal PVS is computed on, so range selection needs no translation table.
wmos: bevy::platform::collections::HashMap<Entity, GxCell>,
/// The PROP regions (B4, decision 1433), keyed by the SAME instance entity but held
/// apart from [`Self::wmos`] deliberately: props trickle in over seconds as their M2s
/// load, and sharing the building's region would convert every arrival into a re-bake
/// of the whole building's geometry — the exact churn class B3 paid down.
props: bevy::platform::collections::HashMap<Entity, GxCell>,
/// The published, extracted half — what the render node draws.
pub(crate) world: render::GxWorld,
frame: u32,
/// Declined-batch census (env-map / depth-flag / shade-family refusals), logged once per
/// count change so a silently-thinner population can't masquerade as covered (1429's
/// no-silent-caps note).
declined: [u32; 3],
declined_logged: [u32; 3],
/// Declined-batch census (env-map / depth-flag / shade-family / prop-fader /
/// prop-without-instance refusals), logged once per count change so a silently-thinner
/// population can't masquerade as covered (1429's no-silent-caps note).
declined: [u32; 5],
declined_logged: [u32; 5],
/// Exiled entities whose seed died out from under them (owner release, map clear) —
/// drained and despawned by the scan, which owns the exile lifecycle end to end.
pending_despawn: Vec<Entity>,
@@ -377,11 +460,25 @@ pub struct GxBatch<'a> {
pub shade: ShadeSel,
/// `Some` = a WMO group-geometry batch (slice 2).
pub wmo: Option<GxWmoBatch>,
/// `Some` = a WMO-prop batch (B4, decision 1433).
pub prop: Option<GxPropBatch>,
/// `Some` = a FADER batch (B2, decision 1431): the exile seed. `None` on never-fade
/// batches and the whole WMO lane.
/// batches, the WMO lane, and the prop lane (an exterior fader prop never diverts —
/// see the assemble gate).
pub fade: Option<GxFadeSeed<'a>>,
}
/// The prop half of [`GxBatch`] (B4): the building's instance entity (the region key, the
/// PVS source, and the lifecycle — the instance dies with the placement, `refs` and all,
/// so a prop region needs no owner-tile bookkeeping), the referrer set of rooms naming the
/// prop (empty = unnamed: always admitted, never exterior-gated — 0784's untagged rule),
/// and the interior prop's SH-probe slot.
pub struct GxPropBatch {
pub instance: Entity,
pub groups: Arc<[u16]>,
pub slot: Option<u16>,
}
/// The fader half of [`GxBatch`] (B2): the placement identity + the respawn payload the
/// exile protocol spawns the feather-band entity from. Everything is a handle clone of what
/// the production assembler holds at the divert point, so the exiled entity IS the entity
@@ -416,6 +513,15 @@ pub enum GxSite<'a> {
/// A WMO placement's group geometry: the pre-spawned `WmoPortalInstance` entity + the
/// model's per-batch group map (`WmoModel::submesh_group`, index-parallel with batches).
Wmo { instance: Entity, groups: &'a [u16] },
/// A WMO doodad prop (B4, decision 1433 — 1418's lane 3, absorbed): the building's
/// instance entity, the referrer set of rooms that name the prop, and the interior
/// prop's folded SH-probe slot. Only a placement WITH an instance qualifies (no
/// instance ⇒ no PVS identity ⇒ the merge/entity path, tallied).
Prop {
instance: Entity,
groups: &'a Arc<[u16]>,
slot: Option<u16>,
},
}
/// The WMO half of [`GxBatch`], gathered at the spawn site (`spawn/mod.rs` owns the instance
@@ -453,12 +559,18 @@ impl StaticGx {
if b.wmo.is_some() && wmo_lane_disabled() {
return false; // the lane-isolation lever: WMO batches back to the entity path
}
if b.prop.is_some() && prop_lane_disabled() {
return false; // the B4 lever: prop batches back to the merge/entity path
}
// The shade family gates CELLS only: the WMO lane never reads the selector (the
// entity path passes `Matte` for every WMO batch and lights on the FFP N·L).
let shade_lit = match (&b.wmo, b.shade) {
(Some(_), _) => false,
(None, ShadeSel::Lit) => true,
(None, ShadeSel::Shaded) => false,
// entity path passes `Matte` for every WMO batch and lights on the FFP N·L), and
// the prop lane admits Matte as its own word bit (B4 — an exterior MODD prop's
// fixed-1.0 family; an interior prop also arrives Matte, selector unread).
let (shade_lit, matte) = match (&b.wmo, &b.prop, b.shade) {
(Some(_), _, _) => (false, false),
(None, Some(_), ShadeSel::Matte) => (false, true),
(None, _, ShadeSel::Lit) => (true, false),
(None, _, ShadeSel::Shaded) => (false, false),
_ => {
self.declined[2] += 1;
return false;
@@ -481,9 +593,10 @@ impl StaticGx {
w.batch_order
},
});
let entry = match wmo_key {
Some(instance) => self.wmos.entry(instance).or_default(),
None => {
let entry = match (wmo_key, &b.prop) {
(Some(instance), _) => self.wmos.entry(instance).or_default(),
(None, Some(p)) => self.props.entry(p.instance).or_default(),
(None, None) => {
let cell = (
(b.transform.translation.x / CELL).floor() as i32,
(b.transform.translation.z / CELL).floor() as i32,
@@ -491,6 +604,25 @@ impl StaticGx {
self.cells.entry(cell).or_default()
}
};
// A prop item resolves its referrer set to the region's dedup list (B4): same rooms
// ⇒ same selection bit ⇒ same run key, and the cull tests each distinct set once.
let prop = b.prop.map(|p| {
let set = match entry
.sets
.iter()
.position(|s| s.as_ref() == p.groups.as_ref())
{
Some(i) => i,
None => {
entry.sets.push(p.groups);
entry.sets.len() - 1
}
};
GxItemProp {
set: u16::try_from(set).expect("gx region under u16 referrer sets"),
slot: p.slot,
}
});
// A fader batch (B2) registers its exile seed on the cell's placement entry. Fresh
// placements start Steady; the scan classifies them against the live camera the same
// frame their cell bakes (states run before the bitmap rebuild), so a placement that
@@ -552,16 +684,29 @@ impl StaticGx {
unlit: b.unlit,
fog_off: matches!(b.fog_policy, benilla_formats::FogPolicy::Off),
shade_lit,
matte,
wrap_x: b.geometry.wrap_x,
wrap_y: b.geometry.wrap_y,
wmo,
prop,
fader: fader_uid,
});
if !entry.dirty {
entry.dirty_since = self.frame;
}
entry.dirty = true;
entry.last_change = self.frame;
true
}
/// Tally a prop-site refusal the divert never sees (B4): an exterior FADER prop (the
/// exile protocol has no prop shape — it keeps today's per-entity fade, the default
/// path's own look), or a whole prop on a placement WITHOUT an instance entity (no PVS
/// identity to key a region on). Censused so the declined population is never silent.
pub fn tally_prop_declined(&mut self, no_instance: bool) {
self.declined[if no_instance { 4 } else { 3 }] += 1;
}
/// Drop a dead owner tile's items and mark their cells for re-bake — the unload hook
/// (`terrain_stream`'s release loop calls this beside the straddler handoff; a diverted
/// batch has no entity, so nothing else could ever release it).
@@ -581,6 +726,9 @@ impl StaticGx {
let before = cell.items.len();
cell.items.retain(|i| i.owner != owner);
if cell.items.len() != before {
if !cell.dirty {
cell.dirty_since = frame;
}
cell.dirty = true;
cell.last_change = frame;
}
@@ -634,8 +782,10 @@ impl StaticGx {
}
self.cells.clear();
self.wmos.clear();
self.props.clear();
self.world.cells.clear();
self.world.wmos.clear();
self.world.props.clear();
self.world.visible.clear();
self.world.visible_wmos.clear();
}
@@ -648,7 +798,7 @@ impl Plugin for StaticGxPlugin {
if !enabled() {
return;
}
info!("static-gx: ARMED (WOW_STATIC_GX=1) — the B1 retained-pass prototype (1429)");
info!("static-gx: ARMED (default since 1434; WOW_STATIC_GX=0 opts out) — the retained static-world pass (1429–1434)");
app.init_resource::<StaticGx>().add_systems(
PostUpdate,
// Chained: bake, then this frame's scene walk (after the camera settles), then
@@ -711,6 +861,7 @@ pub(crate) mod testkit {
no_depth_test: false,
shade: ShadeSel::Lit,
wmo: None,
prop: None,
fade: None,
}
}
@@ -746,7 +897,7 @@ mod tests {
let mut b = batch(&g, Vec3::ZERO, None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
assert!(!gx.divert(b));
assert_eq!(gx.declined, [1, 1, 1]);
assert_eq!(gx.declined, [1, 1, 1, 0, 0]);
assert!(gx.divert(batch(&g, Vec3::ZERO, None, ModelBlend::Opaque)));
assert_eq!(gx.cells.len(), 1);
}
@@ -795,6 +946,51 @@ mod tests {
);
}
/// A prop batch (B4) diverts into a region keyed by its INSTANCE entity; props naming
/// the same rooms share one referrer-set index and a distinct set opens a new one; the
/// interior slot and the Matte family ride the item (Matte admits on this lane — still
/// refused on cells).
#[test]
fn a_prop_divert_dedups_referrer_sets() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let instance = Entity::PLACEHOLDER;
let rooms_a: Arc<[u16]> = Arc::from([3u16, 5].as_slice());
let rooms_b: Arc<[u16]> = Arc::from([9u16].as_slice());
let mk = |groups: &Arc<[u16]>, slot| GxPropBatch {
instance,
groups: Arc::clone(groups),
slot,
};
let mut b = batch(&g, Vec3::ZERO, None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.prop = Some(mk(&rooms_a, Some(11)));
assert!(gx.divert(b));
let mut b = batch(&g, Vec3::ONE, None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.prop = Some(mk(&rooms_a, Some(12)));
assert!(gx.divert(b));
let mut b = batch(&g, Vec3::ONE, None, ModelBlend::Opaque);
b.shade = ShadeSel::Shaded;
b.prop = Some(mk(&rooms_b, None));
assert!(gx.divert(b));
assert!(gx.cells.is_empty() && gx.wmos.is_empty());
let region = &gx.props[&instance];
assert_eq!(region.sets.len(), 2, "same rooms share a set");
assert_eq!(region.items.len(), 3);
let sets: Vec<u16> = region
.items
.iter()
.map(|i| i.prop.as_ref().unwrap().set)
.collect();
assert_eq!(sets, vec![0, 0, 1]);
assert_eq!(region.items[0].prop.as_ref().unwrap().slot, Some(11));
assert!(region.items[0].matte, "Matte is the prop lane's own bit");
assert!(!region.items[2].matte, "Shaded stays the 0.5 family");
gx.clear();
assert!(gx.props.is_empty() && gx.world.props.is_empty());
}
/// A dead owner drops its fader seeds with its items, and an exiled placement's entities
/// land on the despawn queue — nothing else knows those entities exist.
#[test]
+241
View File
@@ -0,0 +1,241 @@
//! The shared texture-array pool of the retained pass (B3, decision 1432; split from
//! `render.rs` at the line budget — one concern: texture residency for the whole lane).
//!
//! B2's legs caught the per-cell array design's two driver taxes red-handed in a `sample` of
//! the armed process: every re-bake recreated a cell's whole array set (`AGX::TextureGen4`
//! hot — +154 MB of arrays in a 20-s walk), and the per-cell `pending` copy list was never
//! drained, so the node re-encoded every visible cell's full layer-copy set EVERY FRAME
//! (`checkDependentBlits` hot). The pool fixes both structurally: a texture is assigned one
//! (class, layer) slot for the pool's lifetime — deduped across every cell and region — its
//! layer copy is encoded ONCE (`drain_pending`, the same frame it is queued), and a re-bake
//! touches no texture at all. Classes grow by SIBLING (a full class opens a bigger one
//! beside it, capacity ×4), so an existing array is never replaced and no bind group ever
//! goes stale; the cost is that runs cannot fuse across siblings, which leaves draw counts
//! trivial either way. The pool resets only with the map (`prepare_static_gx` sees both
//! published maps empty).
use bevy::asset::AssetId;
use bevy::image::Image;
use bevy::platform::collections::HashMap;
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderDevice, RenderQueue};
use bevy::render::texture::GpuImage;
/// One class of the pool: every pooled texture of one (size, format, mips) key, as one
/// `texture_2d_array` with fixed capacity.
struct GxPoolClass {
size: Extent3d,
format: TextureFormat,
mips: u32,
array: Texture,
view: TextureView,
capacity: u32,
/// Layers assigned so far (≤ capacity).
members: u32,
/// Layer copies queued but not yet encoded (source texture, destination layer) — drained
/// by [`GxTexturePool::drain_pending`] the SAME frame they are queued, exactly once.
pending: Vec<(Texture, u32)>,
}
/// A new class's starting capacity; siblings of a full key open at ×4 (8 → 32 → 128 → …,
/// clamped to the device layer limit) — geometric growth without ever migrating a layer.
const POOL_BASE_CAPACITY: u32 = 8;
/// The pool resource (render world). Assignment is stable for the pool's lifetime: a texture
/// id maps to one (class, layer) until the map clears.
#[derive(bevy::prelude::Resource, Default)]
pub(super) struct GxTexturePool {
classes: Vec<GxPoolClass>,
assigned: HashMap<AssetId<Image>, (u16, u16)>,
}
impl GxTexturePool {
/// The pool slot for `id`, assigning one (and queueing its layer copy) on first sight.
pub(super) fn assign(
&mut self,
id: AssetId<Image>,
g: &GpuImage,
render_device: &RenderDevice,
) -> (u16, u16) {
if let Some(&slot) = self.assigned.get(&id) {
return slot;
}
let sz = g.texture.size();
let size = Extent3d {
width: sz.width,
height: sz.height,
depth_or_array_layers: 1,
};
let key = (size, g.texture.format(), g.texture.mip_level_count());
let ci = self.class_with_room(key, render_device);
let class = &mut self.classes[ci];
let layer = class.members;
class.members += 1;
class.pending.push((g.texture.clone(), layer));
let slot = (
u16::try_from(ci).expect("gx pool under u16 classes"),
u16::try_from(layer).expect("layer under u16 (device limit is 2048)"),
);
self.assigned.insert(id, slot);
slot
}
/// The slot untextured items bind (the shader never samples them — the TEXTURED bit is
/// clear — but the bind group needs a D2-array view): a 1×1 white class, created once.
pub(super) fn white(
&mut self,
render_device: &RenderDevice,
render_queue: &RenderQueue,
) -> u16 {
let key = (
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
TextureFormat::Rgba8UnormSrgb,
1,
);
if let Some(ci) = self
.classes
.iter()
.position(|c| (c.size, c.format, c.mips) == key)
{
return u16::try_from(ci).unwrap();
}
let ci = self.class_with_room(key, render_device);
let class = &mut self.classes[ci];
class.members = 1; // layer 0: white, written directly (no GpuImage source)
render_queue.write_texture(
class.array.as_image_copy(),
&[255, 255, 255, 255],
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: None,
},
key.0,
);
u16::try_from(ci).unwrap()
}
/// The array view a bind group binds for `class`.
pub(super) fn view(&self, class: u16) -> &TextureView {
&self.classes[usize::from(class)].view
}
pub(super) fn is_empty(&self) -> bool {
self.classes.is_empty()
}
/// Find a key's class with a free layer, or open one (a sibling at ×4 when the key's
/// classes are all full).
fn class_with_room(
&mut self,
key: (Extent3d, TextureFormat, u32),
render_device: &RenderDevice,
) -> usize {
let max_layers = render_device.limits().max_texture_array_layers;
if let Some(ci) = self
.classes
.iter()
.position(|c| (c.size, c.format, c.mips) == key && c.members < c.capacity)
{
return ci;
}
let capacity = self
.classes
.iter()
.filter(|c| (c.size, c.format, c.mips) == key)
.map(|c| c.capacity)
.max()
.map_or(POOL_BASE_CAPACITY, |c| c.saturating_mul(4))
.min(max_layers);
// The VRAM ledger (1431's regression hunt): bytes for the array about to be created —
// block-compressed at their block rate, else 4 B/texel — ×4/3 for mips, over the full
// CAPACITY (what the driver allocates). Pooled, this should go near-flat after the
// first minutes; a climbing ledger is the churn coming back.
if super::gx_perf_enabled() {
let per_layer = match key.1 {
TextureFormat::Bc1RgbaUnorm | TextureFormat::Bc1RgbaUnormSrgb => {
u64::from(key.0.width) * u64::from(key.0.height) / 2
}
TextureFormat::Bc2RgbaUnorm
| TextureFormat::Bc2RgbaUnormSrgb
| TextureFormat::Bc3RgbaUnorm
| TextureFormat::Bc3RgbaUnormSrgb => {
u64::from(key.0.width) * u64::from(key.0.height)
}
_ => u64::from(key.0.width) * u64::from(key.0.height) * 4,
};
let bytes = per_layer * u64::from(capacity) * if key.2 > 1 { 4 } else { 3 } / 3;
super::GX_VRAM.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed);
}
let array = render_device.create_texture(&TextureDescriptor {
label: Some("static_gx_pool_array"),
size: Extent3d {
width: key.0.width,
height: key.0.height,
depth_or_array_layers: capacity,
},
mip_level_count: key.2,
sample_count: 1,
dimension: TextureDimension::D2,
format: key.1,
usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = array.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
self.classes.push(GxPoolClass {
size: key.0,
format: key.1,
mips: key.2,
array,
view,
capacity,
members: 0,
pending: Vec::new(),
});
self.classes.len() - 1
}
/// Encode + submit all queued layer copies (one encoder, one submit, then EMPTY — the
/// whole point; queue submissions are ordered, so the copies land before the frame's
/// render-graph submit).
pub(super) fn drain_pending(
&mut self,
render_device: &RenderDevice,
render_queue: &RenderQueue,
) {
if self.classes.iter().all(|c| c.pending.is_empty()) {
return;
}
let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("static_gx_pool_copies"),
});
for class in &mut self.classes {
for (src, layer) in class.pending.drain(..) {
for mip in 0..class.mips.min(src.mip_level_count()) {
let mut dst = class.array.as_image_copy();
dst.mip_level = mip;
dst.origin.z = layer;
let mut s = src.as_image_copy();
s.mip_level = mip;
encoder.copy_texture_to_texture(
s,
dst,
Extent3d {
width: (class.size.width >> mip).max(1),
height: (class.size.height >> mip).max(1),
depth_or_array_layers: 1,
},
);
}
}
}
render_queue.submit([encoder.finish()]);
}
}
+275 -264
View File
@@ -5,11 +5,15 @@
//!
//! Assembly happens where each fact lives: the MAIN world bakes geometry (it owns the
//! submeshes) but cannot know texture dims/format (BLP images are `RENDER_WORLD`-only), so
//! classing into `texture_2d_array`s happens HERE, per cell, once its members' `GpuImage`s are
//! all resident — the layer copies ride the node's own encoder, before its pass begins. A cell
//! whose textures aren't all loaded yet simply isn't drawn that frame (the entity path streams
//! batches in piecewise; cell-granular appearance is the same arrival class, mostly under the
//! load cover).
//! classing into `texture_2d_array`s happens HERE, once each member's `GpuImage` is resident.
//! A cell whose textures aren't all loaded yet simply isn't drawn that frame (the entity path
//! streams batches in piecewise; cell-granular appearance is the same arrival class, mostly
//! under the load cover).
//!
//! **The arrays are ONE SHARED POOL, not per-cell (B3, decision 1432)** — `pool.rs` owns the
//! design note (the two driver taxes 1431's `sample` caught, and how dedup + drain-once +
//! sibling growth remove them structurally). Here, a re-bake costs a record table and a few
//! bind groups, never a texture.
use bevy::camera::primitives::Aabb;
use bevy::ecs::query::QueryItem;
@@ -52,13 +56,17 @@ pub(crate) struct GxItemDraw {
pub two_sided: bool,
#[allow(dead_code)] // bake-side bookkeeping; the node draws by index range alone
pub vertex_range: Range<u32>,
/// The WMO group this item belongs to (`None` on cell items) — the range-selection key:
/// a run never crosses a group boundary, so the flood's verdict selects whole runs.
/// The range-selection key (`None` on cell items — always drawn): a WMO item's GROUP,
/// or a prop item's referrer-SET index (B4). A run never crosses a selection boundary,
/// so the per-frame verdict selects whole runs.
pub group: Option<u16>,
/// The authored batch order (the coplanar-MOBA clip-z nudge; 0 on cell items).
pub order: u16,
/// The MOMT SIDN night-glow colour (gamma bytes; zero on cell items).
pub sidn: [u8; 3],
/// The interior prop's SH-probe slot (B4; 0 elsewhere — read only under the word's
/// INTERIOR-without-WMO lane). Rides the record table's w column, bits 1..14.
pub slot: u16,
}
/// One baked cell (or WMO region), published by the main-world flush.
@@ -77,9 +85,14 @@ pub(crate) struct GxCellDraw {
/// Bumped by the scan on every bitmap change — the render side syncs the record table's
/// kill column when it sees a revision it hasn't applied.
pub killed_rev: u32,
/// Per-GROUP mesh-local bounds (WMO regions only; empty for cells) — what the cull's
/// per-group admission tests.
/// Per-selection-grain mesh-local bounds (empty for cells): a WMO region's per-GROUP
/// bounds, or a prop region's per-referrer-SET bounds (B4) — what the cull's admission
/// walk tests.
pub groups: Vec<(u16, Aabb)>,
/// A prop region's distinct referrer sets (B4), indexed by the same u16 as `groups` /
/// item selection: the rooms the PVS admission ORs over (empty set = unnamed — admitted
/// bare, never exterior-gated). Empty on cells and WMO regions.
pub sets: Vec<std::sync::Arc<[u16]>>,
}
/// Marks the ONE view the retained pass draws into — the world camera. Without this the node
@@ -98,34 +111,44 @@ fn mark_world_camera(
}
}
/// One admitted entry of the doodad-phase draw list (B4): ADT-doodad cells and WMO-prop
/// regions are the SAME drain phase in the 1.12 order (both are the M2 scene, after the WMO
/// phase), so the cull sorts them near-first TOGETHER — a far cell must not shade before a
/// near building's furniture.
#[derive(Clone)]
pub(crate) enum GxDoodadVis {
Cell((i32, i32)),
/// A prop region + this frame's per-referrer-SET admission bits.
Prop(Entity, Vec<bool>),
}
/// The published half the render world clones each frame (handles + ranges — cheap).
#[derive(Clone, Default, Resource, ExtractResource)]
pub(crate) struct GxWorld {
pub cells: HashMap<(i32, i32), GxCellDraw>,
/// This frame's CPU scene-walk verdict (frustum + farclip + exterior gate, cell-granular).
pub visible: Vec<(i32, i32)>,
/// This frame's doodad-phase draw list, near-first across cells AND prop regions (B4):
/// frustum + farclip + exterior gate at cell/set granularity, PVS per set.
pub visible: Vec<GxDoodadVis>,
/// The WMO regions (slice 2), keyed by placement instance entity.
pub wmos: HashMap<Entity, GxCellDraw>,
/// The prop regions (B4), keyed by the same instance entity as `wmos` (their lifecycle),
/// held apart so prop arrivals never re-bake building geometry.
pub props: HashMap<Entity, GxCellDraw>,
/// This frame's per-group admission per region (indexed by absolute group index): the
/// portal flood's verdict collapsed to CPU range selection — the node draws exactly the
/// runs whose group bit is set.
pub visible_wmos: Vec<(Entity, Vec<bool>)>,
}
/// A texture-dimension class within one cell: one `texture_2d_array` + the members feeding it.
struct GxClassGpu {
array: Texture,
bind_group: BindGroup,
/// Copies still to encode (source GpuImage's texture, destination layer). Drained by the
/// node's encoder on its next run; the cell draws only once every class is fully copied.
pending: Vec<(Texture, u32)>,
mip_count: u32,
size: Extent3d,
}
use super::pool::GxTexturePool;
/// One coalesced draw run: adjacent bake items sharing (class, pipeline bucket, group).
/// One coalesced draw run: adjacent live bake items sharing (bind-group slot, pipeline
/// bucket, group). Killed items are SKIPPED at build time (B3): a run never carries an exiled
/// or gone item, so a far cell of fully-faded faders submits no vertex work at all (the WGSL
/// kill-bit collapse stays as the belt for the same frame's record table).
struct GxRun {
class: usize,
/// Index into the cell's `bind_groups` (NOT a pool class index).
slot: usize,
cutout: bool,
two_sided: bool,
index_range: Range<u32>,
@@ -136,13 +159,18 @@ struct GxRun {
}
/// A cell's assembled GPU state, cached across frames; rebuilt when the bake (mesh handle)
/// changes.
/// changes — which, with the shared pool, costs a record table and a few bind groups, never
/// a texture.
struct GxCellGpu {
mesh: AssetId<Mesh>,
classes: Vec<GxClassGpu>,
/// One bind group per pool class this cell's items touch: (pool class index, group).
bind_groups: Vec<(u16, BindGroup)>,
record_table: Buffer,
#[allow(dead_code)] // held alive for the bind groups that reference it
cell_uniform: Buffer,
/// Per item: its index into `bind_groups` — the run key, kept for kill-driven run
/// rebuilds.
item_slot: Vec<u16>,
runs: Vec<GxRun>,
/// CPU copy of the record table — the kill-bit sync rewrites column 3 and re-uploads.
records: Vec<[u32; 4]>,
@@ -154,6 +182,7 @@ struct GxCellGpu {
struct GxGpuCache {
cells: HashMap<(i32, i32), GxCellGpu>,
wmos: HashMap<Entity, GxCellGpu>,
props: HashMap<Entity, GxCellGpu>,
}
#[derive(Resource)]
@@ -281,6 +310,7 @@ fn vertex_layout() -> VertexBufferLayout {
fn prepare_static_gx(
gx: Res<GxWorld>,
mut cache: ResMut<GxGpuCache>,
mut pool: ResMut<GxTexturePool>,
mut pipes: ResMut<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
@@ -354,6 +384,19 @@ fn prepare_static_gx(
pipes.specialized_for = Some(key);
}
// The map cleared (the main world published an empty set): the pool's assignments point
// at content the world no longer holds — reset it with the cache. Never fires on a mere
// area change; only `StaticGx::clear` empties ALL published maps.
if gx.cells.is_empty() && gx.wmos.is_empty() && gx.props.is_empty() {
if !pool.is_empty() {
*pool = GxTexturePool::default();
}
cache.cells.clear();
cache.wmos.clear();
cache.props.clear();
return;
}
// Drop cache entries whose region vanished or re-baked.
cache
.cells
@@ -361,23 +404,50 @@ fn prepare_static_gx(
cache
.wmos
.retain(|e, gpu| gx.wmos.get(e).is_some_and(|d| d.mesh.id() == gpu.mesh));
cache
.props
.retain(|e, gpu| gx.props.get(e).is_some_and(|d| d.mesh.id() == gpu.mesh));
for cell in &gx.visible {
if cache.cells.contains_key(cell) {
continue;
}
let Some(draw) = gx.cells.get(cell) else {
continue;
};
if let Some(gpu) = assemble_region(
draw,
&pipes,
&pipeline_cache,
&render_device,
&render_queue,
&images,
) {
cache.cells.insert(*cell, gpu);
for vis in &gx.visible {
match vis {
GxDoodadVis::Cell(cell) => {
if cache.cells.contains_key(cell) {
continue;
}
let Some(draw) = gx.cells.get(cell) else {
continue;
};
if let Some(gpu) = assemble_region(
draw,
&mut pool,
&pipes,
&pipeline_cache,
&render_device,
&render_queue,
&images,
) {
cache.cells.insert(*cell, gpu);
}
}
GxDoodadVis::Prop(entity, _) => {
if cache.props.contains_key(entity) {
continue;
}
let Some(draw) = gx.props.get(entity) else {
continue;
};
if let Some(gpu) = assemble_region(
draw,
&mut pool,
&pipes,
&pipeline_cache,
&render_device,
&render_queue,
&images,
) {
cache.props.insert(*entity, gpu);
}
}
}
}
for (entity, _) in &gx.visible_wmos {
@@ -389,6 +459,7 @@ fn prepare_static_gx(
};
if let Some(gpu) = assemble_region(
draw,
&mut pool,
&pipes,
&pipeline_cache,
&render_device,
@@ -398,11 +469,19 @@ fn prepare_static_gx(
cache.wmos.insert(*entity, gpu);
}
}
// Encode this frame's queued layer copies, exactly once (B3 — see the module doc; B2's
// per-cell pending list was never drained and re-encoded every frame).
pool.drain_pending(&render_device, &render_queue);
// The exile kill-bit sync (B2, 1431): when the scan's bitmap revision moved, rewrite the
// record table's kill column and re-upload. One whole-table write per changed cell per
// record table's kill column, re-upload, and REBUILD THE RUNS (B3) so killed items stop
// being submitted at all. One whole-table write + one CPU coalesce per changed cell per
// change frame — band crossings are rare and a table is tens of KB; a cell that changed
// while out of view syncs on re-entry (the revision mismatch persists until applied).
for cell in &gx.visible {
for vis in &gx.visible {
let GxDoodadVis::Cell(cell) = vis else {
continue; // prop regions carry no faders — their bitmap never revs
};
let (Some(gpu), Some(draw)) = (cache.cells.get_mut(cell), gx.cells.get(cell)) else {
continue;
};
@@ -410,85 +489,62 @@ fn prepare_static_gx(
continue;
}
for (i, rec) in gpu.records.iter_mut().enumerate() {
rec[3] = kill_bit(&draw.killed, i);
// Column w carries the probe slot in the high bits (B4) — rewrite only bit 0.
rec[3] = (rec[3] & !1) | kill_bit(&draw.killed, i);
}
render_queue.write_buffer(&gpu.record_table, 0, bytemuck::cast_slice(&gpu.records));
gpu.runs = build_runs(&draw.draws, &gpu.item_slot, &draw.killed);
gpu.killed_applied = draw.killed_rev;
}
}
/// Assemble one region's GPU state (texture classes + arrays, the per-item record table, bind
/// groups, coalesced runs). `None` while any member texture is not yet resident — the region
/// simply isn't drawn that frame (the entity path streams batches in piecewise; this is the
/// same arrival class).
/// Assemble one region's GPU state against the shared pool: pool slots for its textures, the
/// per-item record table, one bind group per touched pool class, coalesced runs. `None` while
/// any member texture is not yet resident — the region simply isn't drawn that frame (the
/// entity path streams batches in piecewise; this is the same arrival class; slots already
/// assigned stay assigned, so the retry finishes cheaper).
fn assemble_region(
draw: &GxCellDraw,
pool: &mut GxTexturePool,
pipes: &GxPipelines,
pipeline_cache: &PipelineCache,
render_device: &RenderDevice,
render_queue: &RenderQueue,
images: &RenderAssets<GpuImage>,
) -> Option<GxCellGpu> {
// Class by (size, format, mips); assign layers DEDUPED by texture — many items share one
// texture (Stormwind's region carries 3,042 items over a few hundred distinct BLPs), and
// per-item layers blew past the D2-array layer limit the moment a city root baked. A
// layer's content depends only on its source texture, so sharing is exact. A class that
// still fills to the device limit overflows into a sibling class with the same key.
let max_layers = render_device.limits().max_texture_array_layers as usize;
type ClassAcc<'a> = (Extent3d, TextureFormat, u32, Vec<&'a GpuImage>);
let mut classes: Vec<ClassAcc<'_>> = Vec::new();
let mut item_class_layer: Vec<(u32, u32)> = vec![(0, 0); draw.draws.len()];
let mut assigned: HashMap<AssetId<Image>, (u32, u32)> = HashMap::default();
for (i, item) in draw.draws.iter().enumerate() {
let Some(tex) = item.texture else { continue };
if let Some(&cl) = assigned.get(&tex) {
item_class_layer[i] = cl;
continue;
}
// Every member texture must be resident to class the region at all.
let g = images.get(tex)?;
let sz = g.texture.size();
let key = (
Extent3d {
width: sz.width,
height: sz.height,
depth_or_array_layers: 1,
},
g.texture.format(),
g.texture.mip_level_count(),
);
let ci = classes
.iter()
.position(|(s, f, m, mem)| {
(*s, *f, *m) == (key.0, key.1, key.2) && mem.len() < max_layers
})
.unwrap_or_else(|| {
classes.push((key.0, key.1, key.2, Vec::new()));
classes.len() - 1
});
let layer = u32::try_from(classes[ci].3.len()).unwrap();
classes[ci].3.push(g);
let cl = (u32::try_from(ci).unwrap(), layer);
assigned.insert(tex, cl);
item_class_layer[i] = cl;
// Resolve every item to a pool (class, layer) — deduped globally by texture id (many
// items share one texture: Stormwind's region carries 3,042 items over a few hundred
// distinct BLPs, and neighbouring cells repeat most of them; per-item layers blew the
// D2-array limit the moment a city root baked, and per-CELL arrays paid the driver churn
// 1431 measured). Untextured items ride the white class (never sampled — TEXTURED clear).
let mut white: Option<u16> = None;
let mut item_class_layer: Vec<(u16, u16)> = Vec::with_capacity(draw.draws.len());
for item in &draw.draws {
item_class_layer.push(match item.texture {
Some(tex) => pool.assign(tex, images.get(tex)?, render_device),
None => {
let w = *white.get_or_insert_with(|| pool.white(render_device, render_queue));
(w, 0)
}
});
}
// The per-item record table: [layer, batch-order nudge, packed SIDN, kill bit] per item —
// the vertex word's low bits index it. Untextured items ride class 0 (never sampled — the
// TEXTURED bit is clear); a region of ONLY untextured items still needs one dummy array.
// Column 3 is the exile kill bit (B2): folded from the published bitmap here, kept in
// sync per frame by `prepare_static_gx`'s revision check — hence COPY_DST.
// The per-item record table: [layer, batch-order nudge, packed SIDN, kill bit + probe
// slot] per item — the vertex word's low bits index it. Column 3's bit 0 is the exile
// kill bit (B2), folded from the published bitmap here and kept in sync by
// `prepare_static_gx`'s revision check (hence COPY_DST); bits 1..14 carry the interior
// prop's SH-probe slot (B4 — 13 bits fits `MAX_PROP_PROBES` exactly).
let records: Vec<[u32; 4]> = draw
.draws
.iter()
.enumerate()
.map(|(i, item)| {
[
item_class_layer[i].1,
u32::from(item_class_layer[i].1),
u32::from(item.order),
u32::from(item.sidn[0])
| (u32::from(item.sidn[1]) << 8)
| (u32::from(item.sidn[2]) << 16),
kill_bit(&draw.killed, i),
kill_bit(&draw.killed, i) | (u32::from(item.slot) << 1),
]
})
.collect();
@@ -502,132 +558,59 @@ fn assemble_region(
contents: bytemuck::cast_slice(&[draw.origin.x, draw.origin.y, draw.origin.z, 0.0f32]),
usage: BufferUsages::UNIFORM,
});
// One bind group per DISTINCT pool class this region touches; items collapse to slots.
let cell_layout = pipeline_cache.get_bind_group_layout(&pipes.cell_layout);
let mut gpu_classes: Vec<GxClassGpu> = Vec::new();
for (size, tex_format, mips, members) in &classes {
// The VRAM meter (1431's regression hunt): approximate bytes for the array about to
// be created — block-compressed at their block rate, else 4 B/texel — ×4/3 for mips.
if super::gx_perf_enabled() {
let per_layer = match tex_format {
TextureFormat::Bc1RgbaUnorm | TextureFormat::Bc1RgbaUnormSrgb => {
u64::from(size.width) * u64::from(size.height) / 2
}
TextureFormat::Bc2RgbaUnorm
| TextureFormat::Bc2RgbaUnormSrgb
| TextureFormat::Bc3RgbaUnorm
| TextureFormat::Bc3RgbaUnormSrgb => u64::from(size.width) * u64::from(size.height),
_ => u64::from(size.width) * u64::from(size.height) * 4,
};
let bytes = per_layer * members.len().max(1) as u64 * if *mips > 1 { 4 } else { 3 } / 3;
super::GX_VRAM.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed);
}
let array = render_device.create_texture(&TextureDescriptor {
label: Some("static_gx_array"),
size: Extent3d {
width: size.width,
height: size.height,
depth_or_array_layers: u32::try_from(members.len().max(1)).unwrap(),
},
mip_level_count: *mips,
sample_count: 1,
dimension: TextureDimension::D2,
format: *tex_format,
usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = array.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
let bind_group = render_device.create_bind_group(
"static_gx_cell",
&cell_layout,
&BindGroupEntries::sequential((
cell_uniform.as_entire_binding(),
record_table.as_entire_binding(),
&view,
&pipes.sampler,
&pipes.sampler_clamp,
)),
);
gpu_classes.push(GxClassGpu {
array,
bind_group,
pending: members
.iter()
.enumerate()
.map(|(layer, g)| (g.texture.clone(), u32::try_from(layer).unwrap()))
.collect(),
mip_count: *mips,
size: *size,
});
let mut bind_groups: Vec<(u16, BindGroup)> = Vec::new();
let mut item_slot: Vec<u16> = Vec::with_capacity(draw.draws.len());
for &(class, _) in &item_class_layer {
let slot = match bind_groups.iter().position(|(c, _)| *c == class) {
Some(s) => s,
None => {
let bg = render_device.create_bind_group(
"static_gx_cell",
&cell_layout,
&BindGroupEntries::sequential((
cell_uniform.as_entire_binding(),
record_table.as_entire_binding(),
pool.view(class),
&pipes.sampler,
&pipes.sampler_clamp,
)),
);
bind_groups.push((class, bg));
bind_groups.len() - 1
}
};
item_slot.push(u16::try_from(slot).expect("gx region under u16 slots"));
}
if gpu_classes.is_empty() {
// All-untextured region: a 1×1 white dummy array so the bind group exists.
let white = render_device.create_texture(&TextureDescriptor {
label: Some("static_gx_white"),
size: Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
render_queue.write_texture(
white.as_image_copy(),
&[255, 255, 255, 255],
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: None,
},
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
let view = white.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
let bind_group = render_device.create_bind_group(
"static_gx_cell",
&cell_layout,
&BindGroupEntries::sequential((
cell_uniform.as_entire_binding(),
record_table.as_entire_binding(),
&view,
&pipes.sampler,
&pipes.sampler_clamp,
)),
);
gpu_classes.push(GxClassGpu {
array: white,
bind_group,
pending: Vec::new(),
mip_count: 1,
size: Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
});
}
// Coalesce adjacent items sharing (class, bucket, group) into draw runs (the bake sorted
// by (bucket, texture[, group]), so repeated textures and same-bucket spans fuse; a WMO
// run never crosses a group boundary — the selection grain).
let runs = build_runs(&draw.draws, &item_slot, &draw.killed);
Some(GxCellGpu {
mesh: draw.mesh.id(),
bind_groups,
record_table,
cell_uniform,
item_slot,
runs,
records,
killed_applied: draw.killed_rev,
})
}
/// Coalesce adjacent LIVE items sharing (slot, bucket, group) into draw runs (the bake sorted
/// by (bucket, texture[, group]), so repeated textures and same-bucket spans fuse; a WMO run
/// never crosses a group boundary — the selection grain). Killed items are skipped whole
/// (B3): their vertices are never submitted, and the kill-bit sync rebuilds the runs on every
/// bitmap revision — a fully-gone cell coalesces to NOTHING.
fn build_runs(draws: &[GxItemDraw], item_slot: &[u16], killed: &[u64]) -> Vec<GxRun> {
let mut runs: Vec<GxRun> = Vec::new();
for (i, item) in draw.draws.iter().enumerate() {
let class = item_class_layer[i].0 as usize;
for (i, item) in draws.iter().enumerate() {
if kill_bit(killed, i) != 0 {
continue;
}
let slot = usize::from(item_slot[i]);
match runs.last_mut() {
Some(r)
if r.class == class
if r.slot == slot
&& r.cutout == item.cutout
&& r.two_sided == item.two_sided
&& r.group == item.group
@@ -636,7 +619,7 @@ fn assemble_region(
r.index_range.end = item.index_range.end;
}
_ => runs.push(GxRun {
class,
slot,
cutout: item.cutout,
two_sided: item.two_sided,
index_range: item.index_range.clone(),
@@ -644,15 +627,7 @@ fn assemble_region(
}),
}
}
Some(GxCellGpu {
mesh: draw.mesh.id(),
classes: gpu_classes,
record_table,
cell_uniform,
runs,
records,
killed_applied: draw.killed_rev,
})
runs
}
/// Record-table column 3: item `i`'s exile kill bit from the published bitmap.
@@ -725,51 +700,38 @@ impl ViewNode for StaticGxNode {
let meshes = world.resource::<RenderAssets<RenderMesh>>();
let allocator = world.resource::<MeshAllocator>();
// Cells draw whole; a WMO region draws only the runs whose group the flood admitted
// this frame (the selection rides beside the gpu state — `None` = draw everything).
// this frame, a prop region only the runs whose referrer SET the walk admitted (the
// selection rides beside the gpu state — `None` = draw everything). WMO regions
// FIRST, then the doodad phase — cells and prop regions in one near-first order
// (B3/B4): the real client's own drain order (1429's byte-true anchor: terrain →
// WMO → … → doodad), and the buildings are the frame's best early-z occluders for
// the doodads behind them.
let mut resolved: Vec<(&GxCellGpu, &GxCellDraw, Option<&Vec<bool>>)> = Vec::new();
for cell in &gx.visible {
if let (Some(gpu), Some(draw)) = (cache.cells.get(cell), gx.cells.get(cell)) {
resolved.push((gpu, draw, None));
}
}
for (entity, sel) in &gx.visible_wmos {
if let (Some(gpu), Some(draw)) = (cache.wmos.get(entity), gx.wmos.get(entity)) {
resolved.push((gpu, draw, Some(sel)));
}
}
if resolved.is_empty() {
return Ok(());
}
// Encode any outstanding layer copies OUTSIDE the pass. `pending` drains through
// interior mutability-free re-borrow: the cache is not mutable here, so copies are
// (re-)encoded from a snapshot; encoding the same copy twice is idempotent (same src →
// same dst), and PrepareResources rebuilds cells only on bake changes, so the window
// is one frame. Simplicity over a drain flag — B2 revisits if it ever shows.
{
let encoder = render_context.command_encoder();
for (gpu, _, _) in &resolved {
for class in &gpu.classes {
for (src, layer) in &class.pending {
for mip in 0..class.mip_count.min(src.mip_level_count()) {
let mut dst = class.array.as_image_copy();
dst.mip_level = mip;
dst.origin.z = *layer;
let mut s = src.as_image_copy();
s.mip_level = mip;
encoder.copy_texture_to_texture(
s,
dst,
Extent3d {
width: (class.size.width >> mip).max(1),
height: (class.size.height >> mip).max(1),
depth_or_array_layers: 1,
},
);
}
for vis in &gx.visible {
match vis {
GxDoodadVis::Cell(cell) => {
if let (Some(gpu), Some(draw)) = (cache.cells.get(cell), gx.cells.get(cell)) {
resolved.push((gpu, draw, None));
}
}
GxDoodadVis::Prop(entity, sel) => {
if let (Some(gpu), Some(draw)) = (cache.props.get(entity), gx.props.get(entity))
{
resolved.push((gpu, draw, Some(sel)));
}
}
}
}
if resolved.is_empty() {
return Ok(());
}
// (Layer copies are encoded + submitted by `prepare_static_gx`'s pool drain, exactly
// once per texture — B3; the node encodes nothing outside its pass anymore.)
// The four bucket pipelines must all be compiled before the first draw (all-or-none:
// a cell drawing only its opaque half would flash cutout content off for a frame).
let mut ready: HashMap<(bool, bool), &RenderPipeline> = HashMap::default();
@@ -818,7 +780,7 @@ impl ViewNode for StaticGxNode {
}
}
pass.set_render_pipeline(ready[&(run.cutout, run.two_sided)]);
pass.set_bind_group(1, &gpu.classes[run.class].bind_group, &[]);
pass.set_bind_group(1, &gpu.bind_groups[run.slot].1, &[]);
pass.draw_indexed(
(islice.range.start + run.index_range.start)
..(islice.range.start + run.index_range.end),
@@ -849,6 +811,7 @@ pub(super) fn build(app: &mut App) {
};
render_app
.init_resource::<GxGpuCache>()
.init_resource::<GxTexturePool>()
.add_systems(bevy::render::RenderStartup, init_pipelines)
.add_systems(
Render,
@@ -874,5 +837,53 @@ pub(super) fn publish_gx_world(gx: Res<super::StaticGx>, mut out: ResMut<GxWorld
out.cells.clone_from(&gx.world.cells);
out.visible.clone_from(&gx.world.visible);
out.wmos.clone_from(&gx.world.wmos);
out.props.clone_from(&gx.world.props);
out.visible_wmos.clone_from(&gx.world.visible_wmos);
}
#[cfg(test)]
mod tests {
use super::*;
fn item(index_range: Range<u32>, cutout: bool) -> GxItemDraw {
GxItemDraw {
index_range,
texture: None,
cutout,
two_sided: false,
vertex_range: 0..0,
group: None,
order: 0,
sidn: [0; 3],
slot: 0,
}
}
/// Runs fuse adjacent live items of one (slot, bucket, group); a killed item is dropped
/// whole and SPLITS the run around it (B3: no vertex work is submitted for killed rows);
/// a slot or bucket change breaks the run; an all-killed region coalesces to nothing.
#[test]
fn runs_fuse_live_items_and_split_at_kills() {
let draws = vec![
item(0..3, false),
item(3..6, false),
item(6..9, false),
item(9..12, true), // bucket change
item(12..15, true),
];
let slots = vec![0, 0, 0, 0, 1]; // the last item binds another pool class
let runs = build_runs(&draws, &slots, &[]);
assert_eq!(runs.len(), 3, "opaque span fused; cutout split by slot");
assert_eq!(runs[0].index_range, 0..9);
assert_eq!(runs[1].index_range, 9..12);
assert!(runs[1].cutout);
assert_eq!(runs[2].slot, 1);
// Kill the middle opaque item: the fused run splits around it.
let runs = build_runs(&draws, &slots, &[0b010u64]);
assert_eq!(runs.len(), 4);
assert_eq!(runs[0].index_range, 0..3);
assert_eq!(runs[1].index_range, 6..9);
// Kill everything: nothing is submitted at all.
assert!(build_runs(&draws, &slots, &[0b11111u64]).is_empty());
}
}
+2 -1
View File
@@ -582,7 +582,8 @@ fn stream_terrain(
batchers: (
ResMut<HullWelds>,
ResMut<StaticMerge>,
// `None` unless `WOW_STATIC_GX=1` (the collector only exists armed — 1429).
// `None` only under `WOW_STATIC_GX=0` (the collector exists whenever the retained
// pass is armed — the default since 1434).
Option<ResMut<crate::static_gx::StaticGx>>,
),
) {
@@ -2,6 +2,13 @@
//! `WOW_STATIC_MERGE=0` opts out. Design 1417, lane order re-ranked by 1418's density
//! verdict; the measured premise is 1413/1416's 44 ns/row tax).
//!
//! **Since 1434 this is the SECOND consolidator in line**: the retained pass
//! (`crate::static_gx`, also default-on) takes the static populations first at the same
//! assemble gate, and what reaches these accumulators under the defaults is its declined
//! families (env-mapped, depth-flagged batches) plus everything under `WOW_STATIC_GX=0`,
//! where this module is again the whole consolidation. The default flip deliberately
//! removed no code here — this lane IS the A/B arm every gx comparison diffs against.
//!
//! 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
@@ -145,10 +145,12 @@ pub fn spawn_model_entities(
&mut super::super::merge::StaticMerge,
super::super::merge::MergeSite<'_>,
)>,
// `Some((collector, site))` on the world-static streamer paths the B1 retained pass may
// take batches from (`WOW_STATIC_GX=1`, decision 1429; [`crate::static_gx`]): ADT
// doodads (slice 1, cell items by owner tile) and WMO group geometry (slice 2, region
// items by portal-instance entity). `None` on the prop and moving paths.
// `Some((collector, site))` on the world-static streamer paths the retained pass may
// take batches from (`WOW_STATIC_GX=1`, decisions 1429/1433; [`crate::static_gx`]):
// ADT doodads (slice 1, cell items by owner tile), WMO group geometry (slice 2, region
// items by portal-instance entity), and WMO props (B4, set-bucketed regions by the same
// instance). `None` on the moving-gameobject path, and on a prop path whose placement
// has no instance entity (no PVS identity — the site tallies those).
mut staticgx: Option<(
&mut crate::static_gx::StaticGx,
crate::static_gx::GxSite<'_>,
@@ -358,20 +360,29 @@ pub fn spawn_model_entities(
.and_then(|(_, site)| site.census_key(batch_idx, merge_mat, &transform));
crate::static_merge::tally(&class, is_wmo, sub.geometry.positions.len(), key);
}
// The retained-pass divert (`WOW_STATIC_GX=1`, decisions 1429/1431): an eligible
// order-free static batch leaves bevy_pbr entirely — no entity, no blob; `static_gx`
// draws it from retained buffers (per-cell for ADT doodads, per-instance
// group-bucketed for WMO group geometry — slice 2). B2 admits DOODAD FADERS too:
// the batch rides in with its exile seed (the placement identity + handle clones of
// this exact bundle), and the scan respawns it through the entity path for the
// feather band. Checked BEFORE the merge divert (armed, this lane owns the
// population lane 1 would have taken); a refused batch — the collector's own
// exclusions or a site/kind mismatch — falls through to the ordinary paths below,
// slot held like every other divert.
// The retained-pass divert (`WOW_STATIC_GX=1`, decisions 1429/1431/1433): an
// eligible order-free static batch leaves bevy_pbr entirely — no entity, no blob;
// `static_gx` draws it from retained buffers (per-cell for ADT doodads,
// per-instance group-bucketed for WMO group geometry — slice 2, per-instance
// set-bucketed for WMO props — B4). B2 admits DOODAD FADERS too: the batch rides
// in with its exile seed (the placement identity + handle clones of this exact
// bundle), and the scan respawns it through the entity path for the feather band.
// Checked BEFORE the merge divert (armed, this lane owns the populations lanes 1+3
// would have taken); a refused batch — the collector's own exclusions or a
// site/kind mismatch — falls through to the ordinary paths below, slot held like
// every other divert. Each site gates its own class: the doodad lane requires the
// seed for faders, the WMO lane is never-fade by construction (∞ radius), and the
// prop lane admits steady interior props + never-fade exterior props while an
// exterior FADER prop stays on the entity path (the exile protocol has no prop
// shape — that keep is today's default look) and is TALLIED, never silent.
if let Some((gx, site)) = staticgx.as_mut() {
let facts =
let facts = if !crate::static_gx::enabled() {
None
} else {
match site {
crate::static_gx::GxSite::Doodad { owner, uid, label } if !is_wmo => {
crate::static_gx::GxSite::Doodad { owner, uid, label }
if !is_wmo && class.merges() && !class.interior_prop =>
{
let fade = (!class.never_fade && !crate::static_gx::fade_lane_disabled())
.then(|| crate::static_gx::GxFadeSeed {
uid: *uid,
@@ -383,9 +394,14 @@ pub fn spawn_model_entities(
cutout: cutout.clone(),
blend: blend.clone(),
});
Some((*owner, None, fade))
// Never-fade admits bare; a fader admits only WITH its seed (the
// fade lane lever empties the seed, sending faders back to the
// entity path).
(class.never_fade || fade.is_some()).then_some((*owner, None, None, fade))
}
crate::static_gx::GxSite::Wmo { instance, groups } if is_wmo => {
crate::static_gx::GxSite::Wmo { instance, groups }
if is_wmo && class.merges() =>
{
groups.get(batch_idx).map(|&g| {
(
(0, 0), // WMO items release by instance death, never by tile
@@ -399,35 +415,52 @@ pub fn spawn_model_entities(
batch_order,
}),
None,
None,
)
})
}
crate::static_gx::GxSite::Prop {
instance,
groups,
slot,
} if !is_wmo && class.merges() => {
if steady_interior_prop || class.never_fade {
Some((
(0, 0), // prop items release by instance death too
None,
Some(crate::static_gx::GxPropBatch {
instance: *instance,
groups: std::sync::Arc::clone(groups),
slot: *slot,
}),
None,
))
} else {
gx.tally_prop_declined(false); // an exterior fader prop
None
}
}
_ => None,
};
if let Some((owner, wmo, fade)) = facts {
if crate::static_gx::enabled()
&& class.merges()
&& !class.interior_prop
// Never-fade admits bare; a fader admits only WITH its seed (the fade
// lane lever empties the seed, sending faders back to the entity path).
&& (class.never_fade || fade.is_some())
&& gx.divert(crate::static_gx::GxBatch {
geometry: &sub.geometry,
transform,
owner,
texture: sub.texture.clone(),
blend: sub.blend,
two_sided,
unlit: sub.emissive,
fog_policy: sub.fog_policy,
env_map: sub.env_map,
no_depth_write: sub.no_depth_write,
no_depth_test: sub.no_depth_test,
shade,
wmo,
fade,
})
{
}
};
if let Some((owner, wmo, prop, fade)) = facts {
if gx.divert(crate::static_gx::GxBatch {
geometry: &sub.geometry,
transform,
owner,
texture: sub.texture.clone(),
blend: sub.blend,
two_sided,
unlit: sub.emissive,
fog_policy: sub.fog_policy,
env_map: sub.env_map,
no_depth_write: sub.no_depth_write,
no_depth_test: sub.no_depth_test,
shade,
wmo,
prop,
fade,
}) {
continue;
}
}
@@ -67,7 +67,8 @@ type SpawnTables<'w> = (
ResMut<'w, HullWelds>,
ResMut<'w, crate::mega_static::MegaStaticPending>,
ResMut<'w, StaticMerge>,
// `None` unless `WOW_STATIC_GX=1` (the resource only exists armed — decision 1429).
// `None` only under `WOW_STATIC_GX=0` (the resource exists whenever the retained pass
// is armed — the default since 1434).
Option<ResMut<'w, crate::static_gx::StaticGx>>,
);
@@ -700,7 +701,25 @@ pub(super) fn spawn_loaded_placements(
slot: interior_slot,
},
)),
None, // static-gx is the ADT-doodad lane; WMO props never feed it
// The prop retained-pass site (B4, decision 1433): region keyed by the
// building's instance entity — the PVS identity AND the lifecycle. No
// instance ⇒ no region key ⇒ the merge/entity path, tallied per prop so
// the declined population is never silent.
match (portal_instance, staticgx.as_deref_mut()) {
(Some(instance), Some(gx)) => Some((
gx,
crate::static_gx::GxSite::Prop {
instance,
groups: &d.groups,
slot: interior_slot,
},
)),
(None, Some(gx)) => {
gx.tally_prop_declined(true);
None
}
_ => None,
},
);
// The slot frees itself when the prop despawns (streaming out) — the component hook
// returns it to the table whoever does the despawn. A prop whose every batch
+4 -3
View File
@@ -256,9 +256,10 @@ 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_STATIC_GX=1` — the B1 retained-pass prototype (decision 1429; dev-only, the
// module doc owns the caveats). Registers NOTHING when off — the divert sites see
// `None` for its resource and the render graph never gains the node.
// The retained static-world pass — ON by default (1429–1434; `WOW_STATIC_GX=0`
// opts out; the module doc owns the design). Registers NOTHING when opted out —
// the divert sites see `None` for its resource and the render graph never gains
// the node.
app.add_plugins(crate::static_gx::StaticGxPlugin);
// `WOW_MERGE_CENSUS=1` — the 1417 population census (module doc): tally rides the
// assembler, this is just the quiet-timer printer.