diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index c7a14ba9..04a343d7 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -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, point_count: vec4, points: array, 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, 57344>, } @group(0) @binding(1) var 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(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 { vec3(0.0), vec3(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(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 { vec3(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(n_lit, 1.0); + let lit_prop = clamp( + vec3( + 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(0.0), + vec3(1.0), + ); + let primary = clamp(lit_prop + in.point_lit, vec3(0.0), vec3(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(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( wow_light.sh_c10_r.w + sun_dc.x diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs index 4589888c..13550532 100644 --- a/crates/benilla-world/src/static_gx/bake.rs +++ b/crates/benilla-world/src/static_gx/bake.rs @@ -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, mut meshes: ResMut, mut meshes: ResMut, mut meshes: ResMut 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) -> 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) -> 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) -> 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) -> 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::::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![Some(0), Some(0), Some(1)] + ); + assert_eq!( + baked.draws.iter().map(|d| d.slot).collect::>(), + vec![42, 43, 0] + ); + // Per-set bounds for the admission walk. + let mut sets: Vec = 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 diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs index 2231f1d5..ef533535 100644 --- a/crates/benilla-world/src/static_gx/cull.rs +++ b/crates/benilla-world/src/static_gx/cull.rs @@ -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))> = 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). diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs index 163eb5b2..f6493820 100644 --- a/crates/benilla-world/src/static_gx/mod.rs +++ b/crates/benilla-world/src/static_gx/mod.rs @@ -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 = 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 = 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, + /// `Some` on a WMO-prop item (B4, decision 1433) — the referrer-set index + probe slot. + prop: Option, /// `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, } +/// 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, +} + /// 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, 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, /// 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, + /// 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>, } /// 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, + /// 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, /// 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, @@ -377,11 +460,25 @@ pub struct GxBatch<'a> { pub shade: ShadeSel, /// `Some` = a WMO group-geometry batch (slice 2). pub wmo: Option, + /// `Some` = a WMO-prop batch (B4, decision 1433). + pub prop: Option, /// `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>, } +/// 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, +} + /// 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, + }, } /// 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::().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 = 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] diff --git a/crates/benilla-world/src/static_gx/pool.rs b/crates/benilla-world/src/static_gx/pool.rs new file mode 100644 index 00000000..499556a4 --- /dev/null +++ b/crates/benilla-world/src/static_gx/pool.rs @@ -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, + assigned: HashMap, (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, + 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()]); + } +} diff --git a/crates/benilla-world/src/static_gx/render.rs b/crates/benilla-world/src/static_gx/render.rs index bd8067c6..01eeb918 100644 --- a/crates/benilla-world/src/static_gx/render.rs +++ b/crates/benilla-world/src/static_gx/render.rs @@ -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, - /// 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, /// 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>, } /// 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), +} + /// 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, /// The WMO regions (slice 2), keyed by placement instance entity. pub wmos: HashMap, + /// 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, /// 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)>, } -/// 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, @@ -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, - classes: Vec, + /// 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, runs: Vec, /// 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, + props: HashMap, } #[derive(Resource)] @@ -281,6 +310,7 @@ fn vertex_layout() -> VertexBufferLayout { fn prepare_static_gx( gx: Res, mut cache: ResMut, + mut pool: ResMut, mut pipes: ResMut, pipeline_cache: Res, render_device: Res, @@ -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, ) -> Option { - // 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> = Vec::new(); - let mut item_class_layer: Vec<(u32, u32)> = vec![(0, 0); draw.draws.len()]; - let mut assigned: HashMap, (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 = 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 = 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 = 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 { let mut runs: Vec = 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::>(); let allocator = world.resource::(); // 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>)> = 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::() + .init_resource::() .add_systems(bevy::render::RenderStartup, init_pipelines) .add_systems( Render, @@ -874,5 +837,53 @@ pub(super) fn publish_gx_world(gx: Res, mut out: ResMut, 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()); + } +} diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index 3b4d8db7..c094a5aa 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -582,7 +582,8 @@ fn stream_terrain( batchers: ( ResMut, ResMut, - // `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>, ), ) { diff --git a/crates/benilla-world/src/terrain_stream/merge.rs b/crates/benilla-world/src/terrain_stream/merge.rs index 2231f2ee..40cc6476 100644 --- a/crates/benilla-world/src/terrain_stream/merge.rs +++ b/crates/benilla-world/src/terrain_stream/merge.rs @@ -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 diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index 60c4b7fa..6aa26166 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -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; } } diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 6570273f..26baa932 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -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>, ); @@ -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 diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index 00230c20..eca8e24c 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -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::(); 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.