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