the static world gets a retained draw pass, dark behind a flag

- behind WOW_STATIC_GX=1, never-fade doodads leave the per-entity renderer for one cross-material draw per 133-yard cell, pixel-identical to the entity path at the 1/255 floor
- WMO group geometry joins the pass with the portal flood as CPU range selection — in Lower Blackrock Spire the entity lane's WMO draws drop 33 → 0, no leak
- the fading doodads ride it too: a doodad inside its feather band is punched out of the retained draw and respawned as an ordinary entity, so the fade is the current look by construction
- correctness banked, performance not yet: the first legs read +2.36 ms at the Stormwind gates and +7.27 parked at Goldshire — per-cell texture arrays rebuilt on every re-bake are the priced cost, WOW_GX_PERF=1 meters the lane

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:56:36 +07:00
co-authored by Claude Fable 5
parent 4080d4feff
commit 4510607e91
15 changed files with 3408 additions and 45 deletions
@@ -640,6 +640,19 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// The same farmhouse at MIDNIGHT — the one WMO fixture where the SIDN night fraction
// (`grade.x`) is 1.0, so the MOMT 0x10 window glow is live pixels instead of a ×0 term.
// Added for the static-gx slice-2 parity sweep (1429): every other WMO scenario sits at
// noon or 19:30, both before the 20:30 ramp, and a wrong SIDN mirror would have passed
// every diff unseen.
Scenario {
name: "house-north-midnight",
map: MAP_AZEROTH,
eye: HOUSE_EYE,
look: [-9389.1, 71.2, 58.0],
minute: 0,
ui: None,
},
// Inside the Lion's Pride Inn KITCHEN (its hearth carries the building's strongest MOCV-alpha
// bake, α≈100 at the firebox — group-local (-32.9, 1.5, 2), world ≈ (-9461.7, -8.4, 58) per
// the real MODF: uid 71414 on tile 31,49, origin (-9464.25, 24.39, 56.53), rot -97°). The
+3 -2
View File
@@ -286,8 +286,9 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut anim_table,
card_owner,
// Never diverted into the WOW_MEGA_STATIC bracket (nor 1417's production
// merge): these props parent under a MOVING gameobject, and a merged blob
// bakes world transforms.
// merge, nor 1429's static-gx): these props parent under a MOVING gameobject,
// and every divert lane bakes world transforms.
None,
None,
None,
);
+1
View File
@@ -161,6 +161,7 @@ pub mod schedule;
mod shaders;
pub mod sky;
pub mod sky_order;
pub mod static_gx;
pub mod static_merge;
pub mod sun;
pub mod surface;
+34
View File
@@ -90,6 +90,23 @@ pub fn doodad_fade_alpha(radius: f32, horiz_dist: f32) -> f32 {
(1.0 - (d - start) / range).clamp(0.0, 1.0)
}
/// The fade-band edges for a fader of bounding-sphere `radius`: `(near, far)` horizontal
/// CENTRE distances — [`doodad_fade_alpha`] is `1.0` at `d ≤ near`, `0.0` at `d ≥ far`, and
/// feathers between. `None` for a never-fader. The static-gx exile scan (B2, decision 1431)
/// classifies placements on these edges, derived from the SAME bucket table the per-frame
/// alpha evaluates — one source, so the two verdicts can never disagree.
pub fn fade_band(radius: f32) -> Option<(f32, f32)> {
if radius > NEVER_FADE_RADIUS {
return None;
}
let (_, start, range) = BUCKETS
.iter()
.copied()
.find(|(max_r, _, _)| radius <= *max_r)
.unwrap_or((NEVER_FADE_RADIUS, 150.0, 50.0));
Some((start + radius, start + radius + range))
}
/// The faithful per-object **appear / spawn fade** (`wow-5875-re` object-layer/`appear-fade`): a CGObject
/// — every streamed unit, GameObject, and player — ramps its render alpha `α = t³` over **2 s wall-clock**
/// when it first becomes visible, then latches opaque. This is the *temporal* sibling of [`DoodadFade`]'s
@@ -1008,6 +1025,23 @@ mod tests {
assert_eq!(doodad_fade_alpha(7.0001, 195.0), 1.0);
}
/// The property the gx exile scan (B2, 1431) rests on: outside the band edges — with the
/// scan's own 1-yd hysteresis of margin — the alpha is EXACTLY the constant the retained
/// pass assumes (1.0 steady / 0.0 gone), and strictly between them inside. A band table
/// duplicated into the scan is the drift this test forbids: `fade_band` derives from the
/// same `BUCKETS` row `doodad_fade_alpha` evaluates.
#[test]
fn fade_band_edges_agree_with_the_alpha_they_summarize() {
for radius in [0.0, 0.3, 0.5, 1.7, 2.5, 4.0, 7.0] {
let (near, far) = fade_band(radius).expect("faders carry a band");
assert_eq!(doodad_fade_alpha(radius, near - 1.0), 1.0, "r={radius}");
assert_eq!(doodad_fade_alpha(radius, far + 1.0), 0.0, "r={radius}");
let mid = doodad_fade_alpha(radius, (near + far) / 2.0);
assert!(mid > 0.0 && mid < 1.0, "r={radius} mid={mid}");
}
assert_eq!(fade_band(7.01), None, "never-faders have no band");
}
#[test]
fn small_props_fade_40_to_50() {
// radius ≤ 0.5: band on d = dist − radius. Use radius 0.0 so d == dist for clean goldens.
+5 -4
View File
@@ -36,6 +36,7 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/celestial.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ffx_glow.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wow_effect.wgsl");
bevy::asset::embedded_asset!(app, "shaders/static_gx.wgsl");
}
#[cfg(test)]
@@ -76,9 +77,9 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
// 6 since decision 1264 retired `wmo_skybox.wgsl`: a WMO skybox draws on the shared model
// lane, whose forced-far-depth branch lives in `benilla_assets`' `wow_model.wgsl` and is
// pinned by that crate's own `the_sky_lane_forces_the_far_depth`.
assert_eq!(found, 6, "the engine's shader set changed size");
// 6 since decision 1264 retired `wmo_skybox.wgsl` (a WMO skybox draws on the shared
// model lane, whose forced-far-depth branch lives in `benilla_assets`'
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass).
assert_eq!(found, 7, "the engine's shader set changed size");
}
}
@@ -0,0 +1,397 @@
// The B1 retained static pass (decision 1429; `static_gx/mod.rs`): the never-fade order-free
// static-world law of `wow_model.wgsl`, re-expressed over retained buffers — per-vertex
// texture-array layer + flag bits (+ a per-item record) where the entity path had one
// material per batch. Slice 1 = exterior ADT doodads; slice 2 adds WMO group geometry.
//
// **The law here MIRRORS `benilla_assets/shaders/wow_model.wgsl` and must track it** (the
// terrain.wgsl precedent: naga_oil cannot import functions that reference another module's
// bindings, so the shared pieces are copied with keep-in-sync notes at both ends). Mirrored
// pieces, in order: the `WowLight` per-frame PREFIX (the buffer is bigger — probe/palette
// regions ride the tail; binding a prefix-shaped struct against the same buffer is exactly
// what terrain.wgsl does), `wow_normalize`, `point_light_sum`, the exterior-doodad sun family
// (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).
//
// 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
// (wow_model.wgsl's bit-3 armor, as a constant).
#import bevy_render::view::View
@group(0) @binding(0) var<uniform> view: View;
// The shared global light's per-frame PREFIX (lighting::global_light; the full layout lives in
// wow_model.wgsl — keep field order in sync with BOTH).
struct WowLight {
light_ambient: vec4<f32>,
light_diffuse: vec4<f32>,
light_sun: vec4<f32>,
light_spec: vec4<f32>,
fog_color: vec4<f32>,
fog_params: vec4<f32>,
sh_c10_r: vec4<f32>,
sh_c10_g: vec4<f32>,
sh_c10_b: vec4<f32>,
sh_c13_r: vec4<f32>,
sh_c13_g: vec4<f32>,
sh_c13_b: vec4<f32>,
sh_c16: vec4<f32>,
_water: array<vec4<f32>, 4>,
grade: vec4<f32>,
wmo_fog_color: vec4<f32>,
wmo_fog_params: vec4<f32>,
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
}
@group(0) @binding(1) var<storage, read> wow_light: WowLight;
// Per-cell state (static_gx/render.rs `cell_layout`).
struct GxCell {
origin: vec4<f32>, // xyz = the bake's recentring origin (0974): world = vertex + origin
}
@group(1) @binding(0) var<uniform> cell: GxCell;
// The per-ITEM record table, indexed by the vertex word's low bits:
// x = texture-array layer, y = the WMO authored batch order (the clip-z nudge; 0 on cells —
// an exact no-op), z = the MOMT SIDN colour packed r|g<<8|b<<16 (gamma bytes), w = free.
@group(1) @binding(1) var<storage, read> recs: array<vec4<u32>>;
@group(1) @binding(2) var tex_array: texture_2d_array<f32>;
// Repeat and clamp variants of the BLP model-albedo sampler (linear tri-filtered, aniso 8 —
// exact parity with the entity path's per-image samplers; render.rs owns the rationale).
@group(1) @binding(3) var samp_repeat: sampler;
@group(1) @binding(4) var samp_clamp: sampler;
// Vertex word bits — keep in sync with static_gx/mod.rs WORD_*.
const WORD_WRAP_X: u32 = 65536u; // 1 << 16
const WORD_WRAP_Y: u32 = 131072u; // 1 << 17
const WORD_UNLIT: u32 = 262144u; // 1 << 18
const WORD_FOG_OFF: u32 = 524288u; // 1 << 19
const WORD_SHADE_LIT: u32 = 1048576u; // 1 << 20
const WORD_TEXTURED: u32 = 2097152u; // 1 << 21
// The WMO lane (slice 2) — the entity path's per-material facts as bits:
const WORD_WMO: u32 = 4194304u; // 1 << 22 — model_flags.x (the WMO surface laws)
const WORD_INTERIOR: u32 = 8388608u; // 1 << 23 — model_flags.z (interior group)
const WORD_CLASS_INT: u32 = 16777216u; // 1 << 24 — tint.w == 1 (INT batch)
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)
// Vanilla cutout ref (224/255) — wow_model.wgsl's VANILLA_ALPHA_KEY.
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
// ---- mirrored law (wow_model.wgsl) ----
fn wow_normalize(v: vec3<f32>) -> vec3<f32> {
let l2 = dot(v, v);
return select(vec3<f32>(0.0), normalize(v), l2 > 1e-12);
}
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
let d2 = dot(dv, dv);
if (d2 > pos_range.w * pos_range.w) {
continue;
}
if (d2 < sd[0]) {
sd[2] = sd[1]; sel[2] = sel[1];
sd[1] = sd[0]; sel[1] = sel[0];
sd[0] = d2; sel[0] = i;
} else if (d2 < sd[1]) {
sd[2] = sd[1]; sel[2] = sel[1];
sd[1] = d2; sel[1] = i;
} else if (d2 < sd[2]) {
sd[2] = d2; sel[2] = i;
}
}
var sum = vec3<f32>(0.0);
for (var s = 0u; s < 3u; s = s + 1u) {
if (sd[s] > 9.9e29) {
break;
}
let pos_range = wow_light.points[2u * sel[s]];
let to_light = pos_range.xyz - P;
let d = length(to_light);
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
}
return sum;
}
// ---- the pass ----
struct GxVertex {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) word: u32,
@location(4) anchor: vec3<f32>,
// MOCV / the baked constant tint (white where the batch authors none — WORD_HAS_VC says
// which; the entity path's ATTRIBUTE_COLOR, interpolated exactly like it).
@location(5) color: vec4<f32>,
}
struct GxVsOut {
@builtin(position) position: vec4<f32>,
@location(0) world_position: vec4<f32>,
@location(1) world_normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) @interpolate(flat) word: u32,
@location(4) point_lit: vec3<f32>,
@location(5) color: vec4<f32>,
}
@vertex
fn vertex(v: GxVertex) -> GxVsOut {
var out: GxVsOut;
// The exile kill bit (B2, 1431 — record column w, bit 0): a punched-out item's vertices
// all collapse to one constant point, so every triangle is zero-area and the rasterizer
// drops it — no fragments, no state change, one u32 write flipped it. Triangles never
// span items (the bake is item-contiguous), so a partial collapse cannot exist.
if ((recs[v.word & 0xffffu].w & 1u) != 0u) {
out.position = vec4<f32>(0.0, 0.0, 2.0, 1.0);
out.world_position = vec4<f32>(0.0);
out.world_normal = vec3<f32>(0.0, 1.0, 0.0);
out.uv = vec2<f32>(0.0);
out.word = v.word;
out.point_lit = vec3<f32>(0.0);
out.color = vec4<f32>(1.0);
return out;
}
// Camera-relative clip (0974/wow_model.wgsl): both addends are small — the recentred
// vertex and (cell origin − camera).
let p_cam = v.position + (cell.origin.xyz - view.world_position);
let world = v.position + cell.origin.xyz;
out.world_position = vec4<f32>(world, 1.0);
let view_rot = mat3x3<f32>(
view.view_from_world[0].xyz,
view.view_from_world[1].xyz,
view.view_from_world[2].xyz,
);
out.position = view.clip_from_view * vec4<f32>(view_rot * p_cam, 1.0);
// WMO authored batch order (the per-item record's y; wow_model.wgsl's `m.sun_scale.y`
// nudge verbatim): a later coplanar MOBA batch must WIN the reverse-Z GreaterEqual test
// in any draw order — load-bearing here, where the (bucket, texture, group) sort erases
// authored order by design. 0 on every cell item ⇒ ×1.0, an exact no-op.
out.position.z *= 1.0 + f32(recs[v.word & 0xffffu].y) * 1.1920929e-7;
out.world_normal = v.normal;
out.uv = v.uv;
out.word = v.word;
out.color = v.color;
// 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) {
out.point_lit = vec3<f32>(0.0);
} else {
out.point_lit = point_light_sum(world, v.normal, v.anchor);
}
return out;
}
@fragment
fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// The hard farclip wall — per-pixel planar eye-Z, same plane as the entity path.
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
if (wow_light.fog_params.w > 0.0 && eye_z > wow_light.fog_params.w) {
discard;
}
// Sample: the item's array layer through the sampler matching its wrap flags. Both-repeat
// and both-clamp ride real samplers (exact parity); the rare MIXED batch keeps the repeat
// sampler plus the half-texel inset clamp on its clamped axis (0763's silhouette clamp,
// per axis). Both samples are taken unconditionally — `textureSample` needs uniform
// control flow for its derivatives — and the select is per fragment.
var base = vec4<f32>(1.0);
let wrap_x = (in.word & WORD_WRAP_X) != 0u;
let wrap_y = (in.word & WORD_WRAP_Y) != 0u;
if ((in.word & WORD_TEXTURED) != 0u) {
let layer = i32(recs[in.word & 0xffffu].x);
let dims = vec2<f32>(textureDimensions(tex_array).xy);
let inset = 0.5 / dims;
var uv_mixed = in.uv;
if (!wrap_x) {
uv_mixed.x = clamp(uv_mixed.x, inset.x, 1.0 - inset.x);
}
if (!wrap_y) {
uv_mixed.y = clamp(uv_mixed.y, inset.y, 1.0 - inset.y);
}
let c_repeat = textureSample(tex_array, samp_repeat, in.uv, layer);
let c_clamp = textureSample(tex_array, samp_clamp, in.uv, layer);
let c_mixed = textureSample(tex_array, samp_repeat, uv_mixed, layer);
if (wrap_x && wrap_y) {
base = c_repeat;
} else if (!wrap_x && !wrap_y) {
base = c_clamp;
} else {
base = c_mixed;
}
}
#ifdef GX_CUTOUT
if (base.a < VANILLA_ALPHA_KEY) {
discard;
}
#endif
// Lighting — the exterior-doodad lane verbatim (wow_model.wgsl steps 7+, 0803's lobe):
// both faces light from the SAME submitted normal (the FFP never enables
// GL_LIGHT_MODEL_TWO_SIDE). The entity shader's `select(-n, n, is_front)` exists to UNDO
// bevy_pbr's double-sided back-face negation; this pipeline never negated, so the raw
// baked normal IS the submitted normal and any select here would INTRODUCE the flip the
// entity path removes (the first capture A/B lit every back-facing canopy card from the
// wrong side — the felwood heatmap's bright clusters).
let n_lit = wow_normalize(in.world_normal);
let L = -normalize(wow_light.light_sun.xyz);
let ndotl = max(dot(n_lit, L), 0.0);
let lit_nl = clamp(
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
// 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
// white**: the entity path's fold literally does not exist without its VERTEX_COLORS
// shader-def, and GPU interpolation of a constant attribute is NOT exact — a barycentric
// sum lands at 1.0±ε, and `base × 0.99999994` re-rounds ~half of all textured pixels one
// byte down. That was the ±1/255 film over 300k pixels of house-south with the WMO lane
// OFF — the attribution sweep's cells-only leg is what separated it from the WMO bake's
// own (f64-fixed) quantization film.
let has_vc = (in.word & WORD_HAS_VC) != 0u;
let vc = select(vec4<f32>(1.0), in.color, has_vc);
let folded = select(base.rgb, base.rgb * vc.rgb, has_vc);
var rgb: vec3<f32>;
if ((in.word & WORD_WMO) != 0u) {
// ---- the WMO surface lanes (slice 2) — wow_model.wgsl's is_wmo branch verbatim ----
let interior = (in.word & WORD_INTERIOR) != 0u;
let class_int = (in.word & WORD_CLASS_INT) != 0u;
let class_trans = (in.word & WORD_CLASS_TRANS) != 0u;
let trans_a = vc.a; // 1.0 where no MOCV authored — the entity path's default
// WINDOW (MOMT 0x20) — interior drawer only: GL_LIGHT0 swapped to the brighter
// Direct/Ambient midpoint pair, ambient +16/255 saturating (0x6d37e0).
let window_mid = 0.5 * (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb);
let lit_window = clamp(
window_mid + vec3<f32>(16.0 / 255.0) + window_mid * ndotl,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
let lit_int_base = select(lit_nl, lit_window, (in.word & WORD_WINDOW) != 0u);
// The interior BATCH-CLASS lanes (trace-forensics-abbey-interior-d3d §2): INT = unlit
// (the bake IS the room's light), TRANS = the per-vertex MOCV-alpha lit↔bake lerp,
// EXT = plain lit_nl. Exterior groups take lit_nl at sun-scale 1 (prog 198/VS 151 —
// no terrain shade, no SH lobe).
var lit_wmo_interior = vec3<f32>(1.0);
if (class_trans) {
lit_wmo_interior = mix(vec3<f32>(1.0), lit_int_base, trans_a);
} else if (!class_int) {
lit_wmo_interior = lit_int_base;
}
let lit_wmo = select(lit_nl, lit_wmo_interior, interior);
// SIDN night glow (MOMT 0x10): the authored emissive × the live night fraction, an
// EMISSION term — inside the clamped sum, never MOCV-multiplied; dead on the unlit
// INT lane, TRANS-weighted by the lit-pass alpha (wmo-interior-night-light §4).
let rec = recs[in.word & 0xffffu];
let sidn_rgb = vec3<f32>(
f32(rec.z & 0xffu),
f32((rec.z >> 8u) & 0xffu),
f32((rec.z >> 16u) & 0xffu),
) / 255.0;
var sidn_w = 1.0;
if (interior) {
if (class_trans) {
sidn_w = trans_a;
} else if (class_int) {
sidn_w = 0.0;
}
}
let sidn_e = sidn_rgb * (wow_light.grade.x * sidn_w);
// GL_COLOR_MATERIAL: MOCV multiplies the lit terms INSIDE the clamp (point term is
// zero on WMO surfaces, kept in the sum for exact mirroring), emission adds beside.
let primary = clamp(
vc.rgb * (lit_wmo + in.point_lit) + sidn_e,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
// The entity algebra EXACTLY: bevy pre-folds vc into base and the shader divides it
// back out — (tex×vc)/max(vc, 1/255) is not tex in floats, so simplifying would
// break the pixel-identity bar.
let tex_rgb = folded / max(vc.rgb, vec3<f32>(1.0 / 255.0));
rgb = tex_rgb * primary;
if (interior && class_int && has_vc) {
// INT self-illumination: tex·MOCV·(1 + 4·MOCV.a), only the final [0,1] clamp —
// the client's own interior pixel shader (literal 4.0). Gated on AUTHORED
// colours: a colour-less INT batch takes the entity path's no-COLORS combine
// (plain tex × lit), which the white-vc algebra above already reproduces.
rgb = clamp(
tex_rgb * vc.rgb * (1.0 + 4.0 * trans_a),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
}
} else {
// ---- the exterior ADT-doodad lane (slice 1) ----
// 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).
let shade_t = select(1.0, 0.0, (in.word & WORD_SHADE_LIT) != 0u);
let intensity = min(mix(2.5, 0.5, shade_t), 1.0);
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
let sun_dc = wow_light.grade.yzw * intensity;
let sun_lobe = vec3<f32>(
wow_light.sh_c10_r.w + sun_dc.x
+ intensity
* (dot(wow_light.sh_c10_r.xyz, n_lit) + dot(wow_light.sh_c13_r, quad)
+ wow_light.sh_c16.x * x2y2),
wow_light.sh_c10_g.w + sun_dc.y
+ intensity
* (dot(wow_light.sh_c10_g.xyz, n_lit) + dot(wow_light.sh_c13_g, quad)
+ wow_light.sh_c16.y * x2y2),
wow_light.sh_c10_b.w + sun_dc.z
+ intensity
* (dot(wow_light.sh_c10_b.xyz, n_lit) + dot(wow_light.sh_c13_b, quad)
+ wow_light.sh_c16.z * x2y2),
);
let lit_doodad = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
let lit = select(lit_nl, lit_doodad, wow_light.light_sun.w > 0.5);
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
// tint, when authored) modulates the clamped result. Statics: inst_tint identity,
// no highlight, no SIDN.
let primary = clamp(lit + in.point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
rgb = folded * primary;
}
// Unlit fullbright (M2 UNLIT 0x01 / WMO UNLIT on an exterior-group batch): the albedo —
// texture × the folded vertex colour — with lighting off; no emission terms reach it.
if ((in.word & WORD_UNLIT) != 0u) {
rgb = folded;
}
// Step-5 fog (planar eye-Z; policy: Scene, or Off via the render-flag bit — the
// Black/White/Grey families belong to blends this lane excludes by admission). Interior
// WMO surfaces fog with the INTERIOR triple — the room keeps its warm MFOG haze
// (round-6 Q-I: one flag gates walls, pools and props alike).
var fog_color = wow_light.fog_color;
var fog_span = wow_light.fog_params.xy;
if ((in.word & WORD_INTERIOR) != 0u) {
fog_color = wow_light.wmo_fog_color;
fog_span = wow_light.wmo_fog_params.xy;
}
if (fog_color.w > 0.5 && (in.word & WORD_FOG_OFF) == 0u) {
let denom = max(fog_span.y - fog_span.x, 0.001);
let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
rgb = mix(fog_color.xyz, rgb, factor);
}
// Raw gamma out (0161's lane); alpha pinned 1.0 — opaque-intent by admission.
return vec4<f32>(rgb, 1.0);
}
+503
View File
@@ -0,0 +1,503 @@
//! The bake half of the retained pass (split from `mod.rs` at the 1,000-line budget): the
//! flush walks dirty-and-quiet cells/regions and [`bake_cell`] turns their items into one
//! recentred mesh + per-item draw list. The f64 position leg's rationale lives on the
//! function.
use benilla_assets::coords::wow_to_bevy;
use bevy::asset::RenderAssetUsages;
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,
};
/// Bake dirty-and-quiet cells into retained draw data; publish into [`render::GxWorld`].
pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Assets<Mesh>>) {
let _t = super::gx_perf_guard(0);
gx.frame = gx.frame.wrapping_add(1);
let frame = gx.frame;
for i in 0..3 {
if gx.declined[i] != gx.declined_logged[i] {
let d = gx.declined;
info!(
"static-gx: declined so far — env-map {}, depth-flag {}, shade-family {}",
d[0], d[1], d[2]
);
gx.declined_logged = d;
}
}
let StaticGx {
cells, wmos, world, ..
} = &mut *gx;
for (&cell, state) in cells.iter_mut() {
if !state.dirty || frame.wrapping_sub(state.last_change) < IDLE_FRAMES {
continue;
}
state.dirty = false;
if state.items.is_empty() {
world.cells.remove(&cell);
continue;
}
// Sort for render-side run coalescing: same bucket + same texture ⇒ adjacent, so the
// node draws one range per run instead of one per item (class-aware baking is a B2
// refinement — dims/format live render-side only).
state
.items
.sort_by_key(|i| ((u8::from(i.cutout) << 1) | u8::from(i.two_sided), i.texture));
remap_fader_items(state);
let baked = bake_cell(&state.items, &mut meshes);
info!(
"static-gx: cell ({},{}) baked — {} item(s), {} vert(s)",
cell.0,
cell.1,
state.items.len(),
baked.draws.last().map_or(0, |d| d.vertex_range.end),
);
world.cells.insert(cell, baked);
}
for (&instance, state) in wmos.iter_mut() {
if !state.dirty || frame.wrapping_sub(state.last_change) < IDLE_FRAMES {
continue;
}
state.dirty = false;
if state.items.is_empty() {
world.wmos.remove(&instance);
continue;
}
// The WMO sort adds the GROUP inside (bucket, texture): a run must be group-
// homogeneous — the flood selects ranges per group — and same-texture groups still
// sit adjacent so the coalescer fuses across items within one group.
state.items.sort_by_key(|i| {
(
(u8::from(i.cutout) << 1) | u8::from(i.two_sided),
i.texture,
i.wmo.as_ref().map_or(0, |w| w.group),
)
});
let baked = bake_cell(&state.items, &mut meshes);
info!(
"static-gx: wmo {instance} baked — {} item(s), {} group(s), {} vert(s)",
state.items.len(),
baked.groups.len(),
baked.draws.last().map_or(0, |d| d.vertex_range.end),
);
world.wmos.insert(instance, baked);
}
}
/// B2 (1431): the bake reassigns item indices — remap each fader placement's kill targets
/// to the post-sort order and mark the published bitmap stale. The scan runs right after
/// the flush in the same chain, so the rebuilt bitmap rides the SAME frame's publish: a
/// cell never draws with bits from a previous bake's indices.
fn remap_fader_items(state: &mut super::GxCell) {
if state.faders.is_empty() {
return;
}
for f in state.faders.values_mut() {
f.items.clear();
}
for (idx, item) in state.items.iter().enumerate() {
if let Some(uid) = item.fader {
if let Some(f) = state.faders.get_mut(&uid) {
f.items
.push(u16::try_from(idx).expect("gx cell under u16 items"));
}
}
}
state.bits_stale = true;
}
/// Build one cell's (or WMO region's) mesh (recentred — 0974's precision split; the node
/// pushes the origin) and its per-item draw list, plus per-GROUP bounds for a WMO region
/// (the cull's per-group admission tests them; empty for cells).
fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw {
use bevy::math::DVec3;
// World positions accumulate in f64 and round to f32 only AFTER recentring. Baking
// `transform_point` in f32 quantized every vertex to the ULP of its ±9,000-yd world
// coordinate (~0.0005 yd) BEFORE the recentre could save it — 0974's exact defect,
// reintroduced at bake time. Indoors a pixel spans ~0.001 yd, so that was a half-pixel
// shift of every interpolant: the inn A/B's ±1/255 film over 800k pixels, while the
// tram — a map whose placements sit near the origin — matched at exactly 0. In f64 the
// 9,000-magnitude intermediate is exact to ~1e-12 and the subtraction hands the f32
// vertex only its SMALL recentred value; what remains against the entity path is the
// GPU's own rotate-at-local-magnitude rounding, which no bake can undercut.
let mut positions64: Vec<DVec3> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut colors: Vec<[f32; 4]> = Vec::new();
let mut words: Vec<u32> = Vec::new();
let mut anchors: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut draws: Vec<render::GxItemDraw> = Vec::new();
let (mut mn, mut mx) = (DVec3::splat(f64::MAX), DVec3::splat(f64::MIN));
// Per-group world bounds (WMO regions): group index → (min, max).
let mut group_bounds: Vec<(u16, DVec3, DVec3)> = Vec::new();
for (item_idx, item) in items.iter().enumerate() {
let sub = &item.geometry;
let base = u32::try_from(positions64.len()).expect("gx cell under u32 vertices");
// Low 16 bits: the ITEM index — the render side's record table resolves it to a
// texture-array layer (+ the WMO per-item record; dims/format are render-side
// knowledge — see render.rs).
let has_vc = sub.vertex_colors.len() == sub.positions.len();
let word_flags = u32::try_from(item_idx).expect("gx cell under u16 items")
| (u32::from(item.wrap_x) * WORD_WRAP_X)
| (u32::from(item.wrap_y) * WORD_WRAP_Y)
| (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.texture.is_some()) * WORD_TEXTURED)
| (u32::from(has_vc) * WORD_HAS_VC)
| item.wmo.as_ref().map_or(0, |w| {
WORD_WMO
| (u32::from(w.interior) * WORD_INTERIOR)
| (u32::from(w.class_lane == 1) * WORD_CLASS_INT)
| (u32::from(w.class_lane == 2) * WORD_CLASS_TRANS)
| (u32::from(w.window) * WORD_WINDOW)
});
let anchor = item.transform.translation;
let rot = item.transform.rotation;
// Position/normal baking mirrors the placement transform's algebra (scale, rotate,
// translate) with the position leg in f64 (see the header note); the normal rides
// rotation alone (uniform placement scale preserves direction), authored zero
// normals kept zero for the shader's `wow_normalize` DC collapse (1268).
let rot64 = rot.as_dquat();
let scale64 = item.transform.scale.as_dvec3();
let t64 = item.transform.translation.as_dvec3();
let has_normals = sub.normals.len() == sub.positions.len();
let (mut gmn, mut gmx) = (DVec3::splat(f64::MAX), DVec3::splat(f64::MIN));
for (vi, p) in sub.positions.iter().enumerate() {
let w = rot64 * (wow_to_bevy(*p).as_dvec3() * scale64) + t64;
mn = mn.min(w);
mx = mx.max(w);
gmn = gmn.min(w);
gmx = gmx.max(w);
positions64.push(w);
let n = if has_normals {
(rot * wow_to_bevy(sub.normals[vi])).normalize_or_zero()
} else {
Vec3::Y
};
normals.push(n.to_array());
uvs.push(*sub.uvs.get(vi).unwrap_or(&[0.0, 0.0]));
// MOCV / the baked constant tint, exactly as the entity mesh carries
// ATTRIBUTE_COLOR (`model.rs` inserts `vertex_colors` raw); white where the batch
// authors none — bit-identical through every lane by the WORD_HAS_VC contract.
colors.push(if has_vc {
sub.vertex_colors[vi]
} else {
[1.0, 1.0, 1.0, 1.0]
});
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) {
Some((_, bmn, bmx)) => {
*bmn = bmn.min(gmn);
*bmx = bmx.max(gmx);
}
None => group_bounds.push((w.group, gmn, gmx)),
}
}
let start = u32::try_from(indices.len()).expect("gx cell under u32 indices");
indices.extend(sub.indices.iter().map(|i| base + i));
draws.push(render::GxItemDraw {
index_range: start..u32::try_from(indices.len()).unwrap(),
texture: item.texture,
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),
order: item.wmo.as_ref().map_or(0, |w| w.order),
sidn: item.wmo.as_ref().map_or([0; 3], |w| w.sidn),
});
}
// Recentre for clip-space precision (0974): the shader reconstructs world = v + origin.
// The origin is the f32 ROUNDING of the f64 centre, and the subtraction runs in f64
// against that exact value — the vertex absorbs every bit the origin's rounding lost, so
// the only f32 quantization anywhere is of the SMALL recentred coordinate.
let center = (mn + mx) * 0.5;
let origin = center.as_vec3();
let origin64 = origin.as_dvec3();
let positions: Vec<[f32; 3]> = positions64
.iter()
.map(|w| (*w - origin64).as_vec3().to_array())
.collect();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::RENDER_WORLD,
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
mesh.insert_attribute(ATTRIBUTE_GX_WORD, words);
mesh.insert_attribute(ATTRIBUTE_GX_ANCHOR, anchors);
mesh.insert_indices(Indices::U32(indices));
// The kill bitmap opens all-zero (nothing killed); a cell with faders is marked
// `bits_stale` by the flush, so the scan overwrites this before the same frame's publish.
let killed = vec![0u64; draws.len().div_ceil(64)];
render::GxCellDraw {
mesh: meshes.add(mesh),
origin,
aabb: Aabb::from_min_max((mn - origin64).as_vec3(), (mx - origin64).as_vec3()),
draws,
groups: group_bounds
.into_iter()
.map(|(g, bmn, bmx)| {
(
g,
Aabb::from_min_max((bmn - origin64).as_vec3(), (bmx - origin64).as_vec3()),
)
})
.collect(),
killed,
killed_rev: 0,
}
}
#[cfg(test)]
mod tests {
use super::super::testkit::{batch, tri};
use super::super::GxWmoBatch;
use super::*;
use crate::model_render::ShadeSel;
use benilla_formats::{ModelBlend, RenderSubmesh, WmoBatchClass};
use std::sync::Arc;
/// 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]
fn a_quiet_cell_bakes_sorted_contiguous_draws() {
let mut gx = StaticGx::default();
let g = tri([10.0, 0.0, 10.0]);
// A cutout item pushed FIRST must sort after the two opaque items.
let mut cut = batch(&g, Vec3::new(5.0, 0.0, 5.0), None, ModelBlend::AlphaTest);
cut.unlit = true;
assert!(gx.divert(cut));
assert!(gx.divert(batch(
&g,
Vec3::new(6.0, 0.0, 6.0),
None,
ModelBlend::Opaque
)));
assert!(gx.divert(batch(
&g,
Vec3::new(7.0, 0.0, 7.0),
None,
ModelBlend::Opaque
)));
let mut meshes = Assets::<Mesh>::default();
let state = gx.cells.get_mut(&(0, 0)).unwrap();
state
.items
.sort_by_key(|i| ((u8::from(i.cutout) << 1) | u8::from(i.two_sided), i.texture));
let baked = bake_cell(&state.items, &mut meshes);
assert_eq!(baked.draws.len(), 3);
assert!(!baked.draws[0].cutout && !baked.draws[1].cutout);
assert!(baked.draws[2].cutout, "the cutout item sorted last");
// Contiguity: each draw's range starts where the previous ended.
assert_eq!(baked.draws[0].index_range, 0..3);
assert_eq!(baked.draws[1].index_range, 3..6);
assert_eq!(baked.draws[2].index_range, 6..9);
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")
};
// Item indices ride the low bits in bake order; the cutout item (baked last, index 2)
// carries its UNLIT flag.
assert_eq!(words[0] & 0xffff, 0);
assert_eq!(words[3] & 0xffff, 1);
assert_eq!(words[6] & 0xffff, 2);
assert_ne!(words[6] & WORD_UNLIT, 0);
assert_eq!(words[0] & WORD_UNLIT, 0);
// Recentring (0974's split): the mesh-local bound centres exactly on zero by
// construction, and the origin carries the world offset (nonzero here — the items
// stand away from the world origin).
assert!(Vec3::from(baked.aabb.center).length() < 1e-4);
assert!(baked.origin.length() > 1.0);
}
/// B2: after the bake's sort scatters a placement's batches through the item order, the
/// remap hands each fader exactly its own post-sort indices — the kill bits land on the
/// placement that crossed the band, never a neighbour.
#[test]
fn the_remap_names_each_faders_post_sort_items() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let seed = |uid: u32| super::super::GxFadeSeed {
uid,
label: "x",
radius: 0.4,
local_center: Vec3::ZERO,
stat_mesh: Handle::default(),
aabb: None,
cutout: Handle::default(),
blend: Handle::default(),
};
// Placement 1: a cutout batch (sorts LAST) + an opaque batch (sorts first)…
let mut b = batch(&g, Vec3::new(1.0, 0.0, 1.0), None, ModelBlend::AlphaTest);
b.fade = Some(seed(1));
assert!(gx.divert(b));
let mut b = batch(&g, Vec3::new(1.0, 0.0, 1.0), None, ModelBlend::Opaque);
b.fade = Some(seed(1));
assert!(gx.divert(b));
// …a never-fade opaque batch between them, and placement 2's opaque batch.
assert!(gx.divert(batch(
&g,
Vec3::new(2.0, 0.0, 2.0),
None,
ModelBlend::Opaque
)));
let mut b = batch(&g, Vec3::new(3.0, 0.0, 3.0), None, ModelBlend::Opaque);
b.fade = Some(seed(2));
assert!(gx.divert(b));
let state = gx.cells.get_mut(&(0, 0)).unwrap();
state
.items
.sort_by_key(|i| ((u8::from(i.cutout) << 1) | u8::from(i.two_sided), i.texture));
remap_fader_items(state);
assert!(state.bits_stale, "the same-frame scan rebuilds the bitmap");
// The cutout batch sorted last (index 3); placement 1 owns one opaque slot + it.
let f1 = &state.faders[&1].items;
let f2 = &state.faders[&2].items;
assert_eq!(f1.len(), 2);
assert!(f1.contains(&3), "the cutout batch sorted to the tail");
assert_eq!(f2.len(), 1);
assert!(!f2.iter().any(|i| f1.contains(i)), "no shared kill targets");
// The never-fade item belongs to nobody.
let claimed: usize = f1.len() + f2.len();
assert_eq!(state.items.len() - claimed, 1);
}
/// A WMO batch diverts into a region keyed by its INSTANCE entity (never a cell), the
/// shade refusal does not apply to it (the entity path passes `Matte` for every WMO
/// batch), and the baked region carries the slice-2 facts: group-homogeneous draws in
/// (bucket, texture, group) order, per-group bounds, the WMO word bits, and the per-item
/// order/SIDN records.
#[test]
fn a_wmo_batch_diverts_by_instance_and_bakes_group_ranges() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let instance = Entity::PLACEHOLDER;
let wmo = |group: u16, class: Option<WmoBatchClass>, interior: bool| GxWmoBatch {
instance,
group,
interior,
class,
sidn: Some([10, 20, 30]),
window: true,
batch_order: group + 1,
};
// An INT batch of group 2, pushed FIRST — Matte shade must NOT refuse it…
let mut b = batch(&g, Vec3::new(1.0, 0.0, 1.0), None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.wmo = Some(wmo(2, Some(WmoBatchClass::Int), true));
assert!(gx.divert(b));
// …a TRANS batch of group 1 (same bucket/texture — the sort must bring it first)…
let mut b = batch(&g, Vec3::new(2.0, 0.0, 2.0), None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.wmo = Some(wmo(1, Some(WmoBatchClass::Trans), true));
assert!(gx.divert(b));
// …and an exterior-law batch of group 2 again (fuses with the first after the sort).
let mut b = batch(&g, Vec3::new(3.0, 0.0, 3.0), None, ModelBlend::Opaque);
b.shade = ShadeSel::Matte;
b.wmo = Some(wmo(2, None, false));
assert!(gx.divert(b));
assert!(gx.cells.is_empty(), "WMO items never land in cells");
let state = gx.wmos.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.wmo.as_ref().map_or(0, |w| w.group),
)
});
let mut meshes = Assets::<Mesh>::default();
let baked = bake_cell(&state.items, &mut meshes);
// Group 1 sorted ahead of group 2; ranges contiguous over one index buffer.
assert_eq!(
baked.draws.iter().map(|d| d.group).collect::<Vec<_>>(),
vec![Some(1), Some(2), Some(2)]
);
assert_eq!(baked.draws[0].index_range, 0..3);
assert_eq!(baked.draws[2].index_range, 6..9);
// Per-group bounds for the cull's admission walk.
let mut groups: Vec<u16> = baked.groups.iter().map(|(g, _)| *g).collect();
groups.sort_unstable();
assert_eq!(groups, vec![1, 2]);
// The per-item records: authored order + SIDN ride the draw.
assert_eq!(baked.draws[0].order, 2); // group 1's batch_order = group + 1
assert_eq!(baked.draws[0].sidn, [10, 20, 30]);
// The word bits: WMO everywhere; TRANS/INT class lanes; WINDOW; no vertex colours.
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_trans = words[baked.draws[0].vertex_range.start as usize];
let w_int = words[baked.draws[1].vertex_range.start as usize];
let w_ext = words[baked.draws[2].vertex_range.start as usize];
for w in [w_trans, w_int, w_ext] {
assert_ne!(w & WORD_WMO, 0);
assert_ne!(w & WORD_WINDOW, 0);
assert_eq!(w & WORD_HAS_VC, 0, "the fixture authors no colours");
}
assert_ne!(w_trans & WORD_CLASS_TRANS, 0);
assert_ne!(w_int & WORD_CLASS_INT, 0);
assert_eq!(w_ext & (WORD_CLASS_INT | WORD_CLASS_TRANS), 0);
assert_ne!(w_trans & WORD_INTERIOR, 0);
assert_eq!(w_ext & WORD_INTERIOR, 0);
// White default colours where none are authored (the bit-identity contract).
let Some(bevy::mesh::VertexAttributeValues::Float32x4(colors)) =
mesh.attribute(Mesh::ATTRIBUTE_COLOR)
else {
panic!("gx colour attribute missing")
};
assert_eq!(colors[0], [1.0, 1.0, 1.0, 1.0]);
// clear() empties the WMO side too.
gx.clear();
assert!(gx.wmos.is_empty());
}
/// 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
/// tint was silently dropped.
#[test]
fn authored_vertex_colours_bake_raw_and_set_the_bit() {
let mut gx = StaticGx::default();
let g = Arc::new(RenderSubmesh {
positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
normals: vec![[0.0, 0.0, 1.0]; 3],
uvs: vec![[0.0, 0.0]; 3],
indices: vec![0, 1, 2],
vertex_colors: vec![[0.25, 0.5, 0.75, 0.5]; 3],
..Default::default()
});
assert!(gx.divert(batch(&g, Vec3::ZERO, None, ModelBlend::Opaque)));
let mut meshes = Assets::<Mesh>::default();
let baked = bake_cell(&gx.cells[&(0, 0)].items, &mut meshes);
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")
};
assert_ne!(words[0] & WORD_HAS_VC, 0);
assert_eq!(words[0] & WORD_WMO, 0, "a cell item takes no WMO lane");
let Some(bevy::mesh::VertexAttributeValues::Float32x4(colors)) =
mesh.attribute(Mesh::ATTRIBUTE_COLOR)
else {
panic!("gx colour attribute missing")
};
assert_eq!(colors[1], [0.25, 0.5, 0.75, 0.5]);
}
}
+531
View File
@@ -0,0 +1,531 @@
//! The per-frame CPU scene walk of the retained pass (split from `mod.rs` at the
//! 1,000-line budget) — the seven-term collapse at cell/group granularity, the dead-region
//! reap, the fader exile scan (B2, decision 1431), and the `WOW_GX_CENSUS` agreement
//! instrument.
use bevy::prelude::*;
use super::{FaderState, GxFader, StaticGx};
/// Exile hysteresis (yd). Entry into the feather has NO slack — a retained draw at
/// `alpha < 1` is wrong the frame it happens — but both exits (re-admission to Steady, the
/// far-side despawn to Gone) require the camera this far past the crossing, so a camera
/// parked exactly on a band edge cannot flap entities into and out of existence.
const FADE_HYST: f32 = 1.0;
/// Where the exile state machine sits, as a value (the entity list stays on
/// [`FaderState`]).
#[derive(Clone, Copy, PartialEq, Debug)]
enum FadeClass {
Steady,
Feather,
Gone,
}
impl GxFader {
fn class(&self) -> FadeClass {
match self.state {
FaderState::Steady => FadeClass::Steady,
FaderState::Exiled { .. } => FadeClass::Feather,
FaderState::Gone => FadeClass::Gone,
}
}
}
/// The exile step: where a fader at horizontal centre-distance `d` should sit, given where
/// it is now. Classifies on [`crate::model_fade::doodad_fade_alpha`] itself — the SAME
/// function the entity authority evaluates per frame — so the two lanes cannot disagree at
/// the entry edges; the exits shift the *sample point* by [`FADE_HYST`] instead of keeping a
/// second band table.
fn fade_step(radius: f32, d: f32, current: FadeClass) -> FadeClass {
use crate::model_fade::doodad_fade_alpha as alpha;
match current {
FadeClass::Steady => {
let a = alpha(radius, d);
if a >= 1.0 {
FadeClass::Steady
} else if a > 0.0 {
FadeClass::Feather
} else {
FadeClass::Gone
}
}
FadeClass::Feather => {
if alpha(radius, d + FADE_HYST) >= 1.0 {
FadeClass::Steady
} else if alpha(radius, d - FADE_HYST) <= 0.0 {
FadeClass::Gone
} else {
FadeClass::Feather
}
}
FadeClass::Gone => {
if alpha(radius, d + FADE_HYST) >= 1.0 {
FadeClass::Steady
} else if alpha(radius, d) > 0.0 {
FadeClass::Feather
} else {
FadeClass::Gone
}
}
}
}
/// `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 {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_GX_FADE_TRACE").is_some())
}
/// Spawn one exiled placement as ordinary entities — the production assembler's own bundle
/// for a static, non-billboard, exterior doodad batch (`assemble.rs`), from handle clones
/// captured at divert time. The spawn flushes after `CheckVisibility` (the plugin's
/// ordering law), so the entity first draws NEXT frame — seeded with the values the
/// authority will confirm (blend twin + the live fade alpha in `MeshTag`); the authority
/// owns it from the next `Update` on. The explicit `GlobalTransform`/`InheritedVisibility`
/// are belts: next frame's propagation computes both before anything draws, but a fresh
/// root should never sit a frame on defaults that place it at the origin, hidden.
fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool) -> Vec<Entity> {
let mut ents = Vec::with_capacity(f.batches.len());
for b in &f.batches {
let mut e = commands.spawn((
Mesh3d(b.stat_mesh.clone()),
MeshMaterial3d(if alpha < 1.0 {
b.blend.clone()
} else {
b.cutout.clone()
}),
f.transform,
// A placement is a world root, so Transform IS the global (see the fn doc).
GlobalTransform::from(f.transform),
crate::model_render::ModelPart {
kind: crate::model_render::ModelKind::Doodad,
blend: b.blend_mode,
},
crate::interact::PickMesh(b.geometry.clone()),
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)),
crate::model_fade::DoodadFade {
radius: f.radius,
local_center: f.local_center,
cutout: b.cutout.clone(),
blend: b.blend.clone(),
},
crate::exterior_cull::ExteriorScene,
crate::interact::WorldObject {
kind: crate::model_render::ModelKind::Doodad,
label: f.label.to_string(),
id: f.uid,
detail: "static-gx exile".into(),
},
));
if admitted {
e.insert(bevy::camera::visibility::InheritedVisibility::VISIBLE);
} else {
e.insert((
Visibility::Hidden,
bevy::camera::visibility::InheritedVisibility::HIDDEN,
));
}
if let Some(aabb) = b.aabb {
e.insert((aabb, bevy::camera::visibility::NoAutoAabb));
}
ents.push(e.id());
}
if fade_trace() {
println!("GX_FADE_SPAWN uid={} ents={ents:?}", f.uid);
}
ents
}
/// The CPU scene walk (1429: "nothing needs a GPU-side visibility bitset") — frustum, the
/// farclip wall, and the exterior window gate, each at cell granularity, published as this
/// frame's visible list. Coarser than the entity path's per-submesh tests by design; the gate
/// admits at Aabb granularity either way, so cell-whole is conservative (overdraw, never a
/// hole).
///
/// Slice 2 adds the WMO half: per-GROUP admission over each retained region — the portal
/// PVS bit (fail-open, honouring the panel's `portal_cull` A/B switch) ∧ frustum ∧ farclip
/// ∧ the exterior gate with the own-building exemption (the camera's containing placement is
/// not exterior to itself — `CameraInteriorClaim`, the same term the entity authority reads).
/// This is ALSO where a dead region is reaped: the instance entity despawns with its
/// placement, and that death — not the owner tile's — is the WMO release law (straddler
/// handoffs keep the placement alive under a new owner).
///
/// B2 adds the fader exile scan: per cell, a cheap ring test (the camera's distance interval
/// to the cell's fader centres against the union fade band) skips settled cells wholesale;
/// a straddling cell walks its placements through [`fade_step`]. A placement entering the
/// feather respawns as ordinary entities that first draw next frame, and its kill bits ARM
/// on that same next frame — the retained item dies in the rendered frame the entity
/// appears (the overlap protocol; see [`FaderState`]). A re-admit reverses both in ONE
/// frame: the despawn lands before extract and the cleared bit rides the same publish.
#[allow(clippy::too_many_arguments)]
pub(super) fn cull_cells(
mut commands: Commands,
mut gx: ResMut<StaticGx>,
debug: Res<crate::dev_state::DebugState>,
view: Res<crate::view::ViewDistance>,
cam: Query<
(
&GlobalTransform,
&Projection,
&bevy::camera::primitives::Frustum,
),
With<crate::view::WorldCamera>,
>,
windows: Res<crate::wmo_portal::ExteriorWindows>,
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
) {
let _t = super::gx_perf_guard(1);
// Drain the lifecycle queue first: exiled entities whose seed died out from under them
// (owner release, map clear) — this scan owns the exile lifecycle end to end.
for e in gx.pending_despawn.drain(..) {
commands.entity(e).try_despawn();
}
let cam_view = cam.iter().next();
let StaticGx {
cells,
wmos,
world,
fade_events,
..
} = &mut *gx;
// Reap dead regions (instance despawned with its placement) — items and published draws.
wmos.retain(|e, _| instances.contains(*e));
world.wmos.retain(|e, _| instances.contains(*e));
world.visible.clear();
world.visible_wmos.clear();
let Some((cam_t, proj, frustum)) = cam_view else {
return;
};
let cam_pos = cam_t.translation();
let cam_fwd = Vec3::from(cam_t.forward());
let gate = crate::exterior_cull::ExteriorGate::build(&windows, Some((cam_t, proj)));
let m = &debug.models;
let doodads_on =
m.kind_visible[crate::model_render::kind_index(crate::model_render::ModelKind::Doodad)];
// ---- The fader exile scan (B2, 1431) ----
let cam_xz = Vec2::new(cam_pos.x, cam_pos.z);
for (&key, cell) in cells.iter_mut() {
if cell.faders.is_empty() {
continue;
}
let Some((mn, mx)) = cell.fader_bounds else {
continue;
};
// The camera's distance interval to the fader-centre rect (2D), against the cell's
// band union — with the hysteresis margin folded in, so a wholesale verdict implies
// every per-placement verdict and one walk under it settles the whole cell.
let dmin = cam_xz.distance(cam_xz.clamp(mn, mx));
let corner = Vec2::new(
if (cam_xz.x - mn.x).abs() > (cam_xz.x - mx.x).abs() {
mn.x
} else {
mx.x
},
if (cam_xz.y - mn.y).abs() > (cam_xz.y - mx.y).abs() {
mn.y
} else {
mx.y
},
);
let dmax = cam_xz.distance(corner);
let (near_min, far_max) = cell.ring;
let verdict = if dmax < near_min - FADE_HYST {
Some(true) // every placement steady, with margin
} else if dmin > far_max + FADE_HYST {
Some(false) // every placement gone, with margin
} else {
None // the ring straddles the cell — walk it
};
if verdict.is_some() && verdict == cell.settled && !cell.bits_stale {
continue;
}
// The cell-granular admission the exile seed borrows (the walk re-derives the exact
// per-entity verdict next Update; this only bridges the spawn frame).
let admitted = doodads_on
&& world.cells.get(&key).is_none_or(|d| {
gate.admits(&GlobalTransform::from_translation(d.origin), Some(&d.aabb))
});
let mut bits_changed = cell.bits_stale;
let mut unarmed_left = false;
for f in cell.faders.values_mut() {
// The overlap protocol's second half: LAST frame's exile spawns flushed at the
// end of that frame's PostUpdate and first draw THIS frame — arm their kill bits
// now, so the retained item dies in the same rendered frame the entity appears.
if let FaderState::Exiled { armed, .. } = &mut f.state {
if !*armed {
*armed = true;
bits_changed = true;
}
}
let d = Vec2::new(f.center.x, f.center.z).distance(cam_xz);
let old = f.class();
let new = fade_step(f.radius, d, old);
if new == old {
continue;
}
let was_killed = matches!(
f.state,
FaderState::Exiled { armed: true, .. } | FaderState::Gone
);
if let FaderState::Exiled { ents, .. } =
std::mem::replace(&mut f.state, FaderState::Steady)
{
for e in ents {
commands.entity(e).try_despawn();
}
}
f.state = match new {
FadeClass::Steady => {
fade_events[1] += 1;
FaderState::Steady
}
FadeClass::Gone => {
fade_events[2] += 1;
FaderState::Gone
}
FadeClass::Feather => {
fade_events[0] += 1;
let alpha = crate::model_fade::doodad_fade_alpha(f.radius, d);
// Fresh spawns stay UNARMED this frame (their entities don't draw until
// next frame — killing now would hole the handoff)… unless the fader was
// already killed as Gone: those bits stay set, nothing to wait for.
let armed = old == FadeClass::Gone;
unarmed_left |= !armed;
FaderState::Exiled {
ents: spawn_exile(&mut commands, f, alpha, admitted),
armed,
}
}
};
let now_killed = matches!(
f.state,
FaderState::Exiled { armed: true, .. } | FaderState::Gone
);
if was_killed != now_killed {
bits_changed = true;
}
if fade_trace() {
println!(
"GX_FADE cell=({},{}) uid={} {:?}->{:?} d={:.1} r={:.2}",
key.0, key.1, f.uid, old, new, d, f.radius
);
}
}
// An unarmed exile must be revisited next frame whatever the ring says.
cell.settled = if unarmed_left { None } else { verdict };
if bits_changed {
if let Some(draw) = world.cells.get_mut(&key) {
// Rebuild whole from the states — cheap, and immune to index drift across
// re-bakes (the flush refreshed `items` and set `bits_stale` this frame).
draw.killed.iter_mut().for_each(|w| *w = 0);
for f in cell.faders.values() {
let killed = match f.state {
FaderState::Steady | FaderState::Exiled { armed: false, .. } => false,
FaderState::Exiled { armed: true, .. } | FaderState::Gone => true,
};
if !killed {
continue;
}
for &i in &f.items {
let i = usize::from(i);
if let Some(w) = draw.killed.get_mut(i / 64) {
*w |= 1u64 << (i % 64);
}
}
}
draw.killed_rev = draw.killed_rev.wrapping_add(1);
}
cell.bits_stale = false;
}
}
// The dev doodad toggle, cell-wholesale (see the module doc).
if doodads_on {
for (&cell, draw) in &world.cells {
let center = draw.origin + Vec3::from(draw.aabb.center);
let radius = Vec3::from(draw.aabb.half_extents).length();
if !crate::view::within_farclip(view.farclip, cam_pos, cam_fwd, center, radius) {
continue;
}
// The camera's own frustum (bevy's `update_frusta` output — the same planes its
// per-entity cull tests), against the cell's world bound.
let sphere = bevy::camera::primitives::Sphere {
center: center.into(),
radius,
};
if !frustum.intersects_sphere(&sphere, false) {
continue;
}
// ADT doodads are exterior scene (spawn tags them per submesh): from inside a WMO
// they draw only through a portal window. `admits` takes the same (transform,
// aabb) shape the per-entity term feeds it; cell granularity is conservative
// (overdraw, no hole).
if !gate.admits(
&GlobalTransform::from_translation(draw.origin),
Some(&draw.aabb),
) {
continue;
}
world.visible.push(cell);
}
}
// The WMO regions — the dev WMO toggle wholesale, then per-group admission.
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);
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.
let Ok(inst) = instances.get(entity) else {
continue;
};
let max_group = draw.groups.iter().map(|(g, _)| *g).max().unwrap_or(0);
let mut sel = vec![false; usize::from(max_group) + 1];
let mut any = false;
for (group, aabb) in &draw.groups {
// The portal PVS bit — the SAME fail-open read `WmoGroupVis::drawn_by` takes,
// behind the same panel switch.
if m.portal_cull
&& !inst
.visible
.get(usize::from(*group))
.copied()
.unwrap_or(true)
{
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;
}
// Another building is exterior scene; the camera's own is exempt (0784).
if Some(entity) != own
&& !gate.admits(&GlobalTransform::from_translation(draw.origin), Some(aabb))
{
continue;
}
sel[usize::from(*group)] = true;
any = true;
}
if any {
world.visible_wmos.push((entity, sel));
}
}
}
// `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
// directly against the entity path's VIS_CENSUS/`drawn=` at the same pin (gx ≥ entity is
// the expected granularity coarsening; gx ≫ entity is a portal leak). ~1 Hz, env-gated.
// B2 adds the fader tallies (steady/exiled/gone) + the lifetime transition counters
// (exiles/readmits/gones) — the churn instrument for the exile protocol.
static CENSUS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *CENSUS.get_or_init(|| std::env::var_os("WOW_GX_CENSUS").is_some())
&& 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 wmo_items, mut wmo_groups) = (0usize, 0usize);
for (e, sel) in &world.visible_wmos {
if let Some(d) = world.wmos.get(e) {
wmo_items += d
.draws
.iter()
.filter(|i| {
i.group
.is_some_and(|g| sel.get(usize::from(g)).copied().unwrap_or(false))
})
.count();
}
wmo_groups += sel.iter().filter(|b| **b).count();
}
let (mut fs, mut fx, mut fg) = (0usize, 0usize, 0usize);
for cell in gx.cells.values() {
for f in cell.faders.values() {
match f.state {
FaderState::Steady => fs += 1,
FaderState::Exiled { .. } => fx += 1,
FaderState::Gone => fg += 1,
}
}
}
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(),
world.cells.len(),
world.visible_wmos.len(),
world.wmos.len(),
ev[0],
ev[1],
ev[2],
);
}
if super::gx_perf_enabled() && gx.frame.is_multiple_of(64) {
use std::sync::atomic::Ordering::Relaxed;
let ms: Vec<f64> = super::GX_PERF
.iter()
.map(|a| a.swap(0, Relaxed) as f64 / 64.0 / 1.0e6)
.collect();
println!(
"GX_PERF ms/frame flush={:.3} cull={:.3} publish={:.3} prepare={:.3} node={:.3} \
arrays_mb={}",
ms[0],
ms[1],
ms[2],
ms[3],
ms[4],
super::GX_VRAM.load(Relaxed) / (1024 * 1024)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 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).
#[test]
fn the_exile_step_enters_exact_and_exits_sticky() {
// radius 0 → band 40..50 (the small-prop bucket; golden in model_fade's tests).
let r = 0.0;
// Entry: steady holds through the band start, feathers strictly past it.
assert_eq!(fade_step(r, 39.0, FadeClass::Steady), FadeClass::Steady);
assert_eq!(fade_step(r, 40.0, FadeClass::Steady), FadeClass::Steady);
assert_eq!(fade_step(r, 40.5, FadeClass::Steady), FadeClass::Feather);
// Direct steady→gone (a teleport past the band end).
assert_eq!(fade_step(r, 60.0, FadeClass::Steady), FadeClass::Gone);
// Exit to steady needs the hysteresis: at d=40.5 the +1yd probe still feathers.
assert_eq!(fade_step(r, 40.5, FadeClass::Feather), FadeClass::Feather);
assert_eq!(fade_step(r, 39.0, FadeClass::Feather), FadeClass::Steady);
// Exit to gone likewise sticky on the far edge.
assert_eq!(fade_step(r, 50.5, FadeClass::Feather), FadeClass::Feather);
assert_eq!(fade_step(r, 51.5, FadeClass::Feather), FadeClass::Gone);
// Gone re-enters the feather the moment alpha is nonzero again…
assert_eq!(fade_step(r, 49.9, FadeClass::Gone), FadeClass::Feather);
assert_eq!(fade_step(r, 50.0, FadeClass::Gone), FadeClass::Gone);
// …and jumps straight to steady only with the margin (a teleport close).
assert_eq!(fade_step(r, 38.9, FadeClass::Gone), FadeClass::Steady);
}
}
+855
View File
@@ -0,0 +1,855 @@
//! **`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.
//!
//! **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).
//!
//! 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.
//!
//! 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
//! scene walk feeding retained draws); fade is the EXILE PROTOCOL (B2, decision 1431 — see
//! below); portal PVS and the billboard-owner term never apply to ADT doodads; the dev
//! kind/blend toggles are honoured cell-wholesale via [`crate::dev_state::DebugState`]'s
//! doodad toggle (a prototype coarsening — the entity path toggles per blend class).
//!
//! **B2 (decision 1431): faders ride the retained pass via the exile protocol.** The pass
//! draws only fade = 1 content: a fader placement diverts with an exile SEED (uid + handle
//! clones of the production bundle), and [`cull::cull_cells`]'s per-frame scan classifies it
//! on `doodad_fade_alpha` — the same function the entity authority evaluates. Steady = drawn
//! retained, no entity rows; Exiled (0 < α < 1) = every item takes the record-table KILL BIT
//! (the vertex stage collapses it to a zero-area point) and the placement respawns as
//! ordinary entities, so the feather is the entity path's own look by construction; Gone
//! (α = 0) = killed, nothing spawned anywhere. Exits carry 1 yd hysteresis; entry none.
//! The frame discipline is the OVERLAP PROTOCOL (1421's pop class, prevented structurally):
//! an exile's entities flush at `PostUpdate`'s end and first draw NEXT frame, and the kill
//! bit waits for them (`FaderState::Exiled::armed`) — the retained item dies in the same
//! rendered frame the entity appears; a re-admit clears the bit and despawns in one frame
//! (the despawn lands before extract, the cleared bit rides the same publish). Never order
//! this chain's commands to flush mid-`PostUpdate` — the plugin's `chain_ignore_deferred`
//! note records the bevy_pbr panic that forbids it. `WOW_STATIC_GX_FADE=0` is the fader
//! lane-isolation lever; `WOW_GX_FADE_TRACE=1` logs transitions; the census carries
//! `faders=`/`fade_events=`.
//!
//! Exclusions the collector refuses (each falls through to the ordinary merge/entity path, and
//! the flush logs a one-line census so a declined population is never silent): env-mapped
//! batches (`texture_unit_lookup > 2` — view-generated UVs), the depth-flag oddities
//! (`no_depth_write`/`no_depth_test` — pipeline-state per batch), and the `ShadeSel::Matte`/
//! `Rig` families (exterior WMO MODD props ride the prop site, not this lane).
//!
//! **Slice-1 verdict (the first capture A/B, 2026-08-18):** against a selfcheck floor of
//! exactly 0, the armed sweep reads MAE 0.000 on all four scenarios with 13–65 pixels per
//! 5.8-Mpx frame differing by exactly 1/255 (float-order residue of the baked-transform
//! arithmetic; zero pixels beyond 8) — pixel-identical to the quantization limit, ~1,500
//! retained cell bakes participating. Two desk-found parity terms live in render.rs/the WGSL:
//! the aniso-8 samplers, and the two-sided normal law (the entity shader's `select` UNDOES a
//! bevy negation this pipeline never had — copying it flipped every back-facing canopy card).
//!
//! **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).
//!
//! **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 —
//! the PVS identity — with draws bucketed per (pipeline bucket × texture × GROUP), so the
//! portal flood's per-frame verdict becomes CPU range selection over retained buffers (the
//! design's load-bearing collapse: the client's own per-group draw records). Per-group
//! admission each frame = the PVS bit (fail-open, honouring the `portal_cull` A/B switch) ∧
//! frustum ∧ farclip ∧ the exterior window gate with the own-building exemption — the same
//! seven-term collapse as cells, at group granularity. Region lifecycle follows the INSTANCE
//! entity's death, never the owner tile: a straddler handoff keeps the placement (and its
//! instance) alive under a new owner, and tile-keyed release would have dropped the retained
//! geometry out from under it. The WMO lighting lanes (MOCV inside the clamp, INT/TRANS batch
//! 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`.
//!
//! **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
//! re-expression, not a lane error; the tram (near-origin placements) matches at exactly 0.
//! LBRS agreement: entity-lane WMO drops 33 → 0 drawn while the retained pass selects 2
//! groups / 52 items, stable over the 300-frame window — the flood as range selection, no
//! leak. Three desk-found laws this slice: texture-array layers DEDUPE by texture (per-item
//! layers blew the 2,048 D2-array limit the moment Stormwind baked); the bake's position leg
//! runs in f64 (`bake.rs` owns the note — the f32 world intermediate was 0974's defect
//! reintroduced, half a pixel indoors); and a colour-less batch multiplies by CONSTANT 1.0,
//! never the interpolated white (GPU interpolation of a constant is 1.0±ε and re-rounded
//! ~half of all textured pixels one byte down). `WOW_STATIC_GX_WMO=0` is the lane-isolation
//! lever that attributed the residue; `WOW_GX_CENSUS=1` is the agreement instrument; the
//! `house-north-midnight` fixture is the one capture where SIDN is live pixels.
use bevy::mesh::MeshVertexAttribute;
use bevy::prelude::*;
use bevy::render::render_resource::VertexFormat;
use std::sync::Arc;
use crate::model_render::ShadeSel;
use benilla_formats::{ModelBlend, RenderSubmesh, WmoBatchClass};
mod bake;
mod cull;
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.
const IDLE_FRAMES: u32 = 15;
/// 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).
pub const ATTRIBUTE_GX_WORD: MeshVertexAttribute =
MeshVertexAttribute::new("Gx_Word", 988_101, VertexFormat::Uint32);
/// Per-vertex point-light anchor: the PLACEMENT origin (world space) — the entity path anchors
/// its ≤3-nearest selection at the instance origin (`wow_model.wgsl`), and baking it per vertex
/// keeps exact parity where the merged-blob path coarsened to the blob origin.
pub const ATTRIBUTE_GX_ANCHOR: MeshVertexAttribute =
MeshVertexAttribute::new("Gx_Anchor", 988_102, VertexFormat::Float32x3);
const WORD_WRAP_X: u32 = 1 << 16;
const WORD_WRAP_Y: u32 = 1 << 17;
const WORD_UNLIT: u32 = 1 << 18;
const WORD_FOG_OFF: u32 = 1 << 19;
const WORD_SHADE_LIT: u32 = 1 << 20;
const WORD_TEXTURED: u32 = 1 << 21;
// The WMO lane (slice 2) — mirrors of the entity path's per-material facts:
// model_flags.x (the WMO surface laws: MOCV inside the clamp, zero point lights)…
const WORD_WMO: u32 = 1 << 22;
// …model_flags.z (interior group: the INT/TRANS/EXT batch-class lanes + the interior fog
// triple)…
const WORD_INTERIOR: u32 = 1 << 23;
// …tint.w == 1 / == 2 (the batch's MOBA class lane; EXT is both bits clear)…
const WORD_CLASS_INT: u32 = 1 << 24;
const WORD_CLASS_TRANS: u32 = 1 << 25;
// …sidn.w (the MOMT WINDOW midpoint light)…
const WORD_WINDOW: u32 = 1 << 26;
// …and "this batch AUTHORED vertex colours": the entity shader's VERTEX_COLORS shader-def.
// The gx layout always carries COLOR (white default), and white is bit-identical through
// every lane EXCEPT the INT ×(1+4·MOCV.a) self-illumination override — the entity path's
// 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;
/// Armed? (`WOW_STATIC_GX=1`.) Read once; the plugin registers nothing when off.
pub fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_STATIC_GX").as_deref() == Ok("1"))
}
/// `WOW_WMO_BIAS=0` — B38's A/B diagnostic, honoured exactly where `model_material` honours
/// it: the authored batch-order nudge bakes as zero.
fn wmo_bias_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")))
}
/// `WOW_STATIC_GX_WMO=0` — the slice-2 lane-isolation lever: the WMO half of the divert
/// refuses wholesale (batches fall to the entity path), cells unaffected. Built for the
/// parity sweep's attribution question — "is this dust the WMO film or a doodad regression?"
/// — and kept: any future A/B on one lane needs exactly this.
fn wmo_lane_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| matches!(std::env::var("WOW_STATIC_GX_WMO").as_deref(), Ok("0")))
}
/// `WOW_STATIC_GX_FADE=0` — the B2 lane-isolation lever (the WMO lever's twin, decision
/// 1431): fader batches fall back to the entity path wholesale; never-fade cells and WMO
/// regions unaffected. The attribution instrument for any fader-lane A/B.
pub(crate) fn fade_lane_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| matches!(std::env::var("WOW_STATIC_GX_FADE").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
/// render-world sites share the table (atomics; the two threads never contend on cadence).
pub(crate) fn gx_perf_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_GX_PERF").is_some())
}
/// Lifetime bytes of texture-array VRAM created by `assemble_region` (never decremented —
/// a high-water ledger of what the per-cell arrays cost; the cache drop path frees the
/// textures but the meter's question is how much this design ALLOCATES).
pub(crate) static GX_VRAM: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Nanosecond accumulators: flush, cull, publish, prepare, node.
pub(crate) static GX_PERF: [std::sync::atomic::AtomicU64; 5] = [
std::sync::atomic::AtomicU64::new(0),
std::sync::atomic::AtomicU64::new(0),
std::sync::atomic::AtomicU64::new(0),
std::sync::atomic::AtomicU64::new(0),
std::sync::atomic::AtomicU64::new(0),
];
/// Scope guard timing one armed site into [`GX_PERF`] slot `i` — one line at the top of
/// each measured fn; a no-op branch when the meter is off.
pub(crate) struct GxPerfGuard(Option<(usize, std::time::Instant)>);
impl Drop for GxPerfGuard {
fn drop(&mut self) {
if let Some((i, t0)) = self.0 {
GX_PERF[i].fetch_add(
u64::try_from(t0.elapsed().as_nanos()).unwrap_or(u64::MAX),
std::sync::atomic::Ordering::Relaxed,
);
}
}
}
pub(crate) fn gx_perf_guard(i: usize) -> GxPerfGuard {
GxPerfGuard(gx_perf_enabled().then(|| (i, std::time::Instant::now())))
}
/// One diverted batch, held until its cell bakes (and retained after, so an owner-tile unload
/// or a later admission can re-bake the cell without the entity path's help).
struct GxItem {
geometry: Arc<RenderSubmesh>,
transform: Transform,
/// The owner TILE — read only by [`StaticGx::release_owner`], i.e. only for cell items.
/// A WMO region's lifecycle keys on its instance entity instead (see `cull::cull_cells`).
owner: (i32, i32),
texture: Option<AssetId<bevy::image::Image>>,
/// Kept as a live handle so the render world's `GpuImage` can never be dropped from under
/// the baked cell (the id alone holds nothing).
_texture_handle: Option<Handle<bevy::image::Image>>,
cutout: bool,
two_sided: bool,
unlit: bool,
fog_off: bool,
shade_lit: bool,
wrap_x: bool,
wrap_y: bool,
/// `Some` on a WMO group-geometry item (slice 2) — the per-batch WMO facts the shader's
/// WMO lanes read.
wmo: Option<GxItemWmo>,
/// `Some(uid)` on a FADER item (B2, decision 1431): the placement this item exiles with.
/// The flush maps post-sort item indices back to the cell's [`GxFader`] through it.
fader: Option<u32>,
}
/// 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
/// state but Steady holds. Lives in its cell, keyed by placement uniqueId; released with the
/// owner tile exactly like the items.
struct GxFader {
owner: (i32, i32),
uid: u32,
/// The placement's model label (`WorldObject` identity for the exiled entities).
label: std::sync::Arc<str>,
transform: Transform,
radius: f32,
/// Model-local fade-sphere centre; `transform` maps it to [`Self::center`].
local_center: Vec3,
/// World-space fade-sphere centre — the scan measures horizontal distance to THIS, the
/// same point `apply_model_visibility` measures to (`FUN_006952a0`'s transformed centre).
center: Vec3,
/// Band edges from [`crate::model_fade::fade_band`] — the cell ring test's terms. The
/// state machine itself classifies on `doodad_fade_alpha` directly (one source).
near: f32,
far: f32,
/// One seed per diverted batch, in divert order.
batches: Vec<GxFaderBatch>,
/// This placement's item indices in the CURRENT bake (post-sort; refreshed by every
/// flush). Empty until the cell first bakes after the placement diverts.
items: Vec<u16>,
state: FaderState,
}
/// One diverted batch's respawn payload — handle clones of exactly what the production
/// assembler would have spawned (`assemble.rs`'s ordinary-part bundle), so the feather-band
/// look is the entity path's own by construction.
struct GxFaderBatch {
stat_mesh: Handle<Mesh>,
aabb: Option<bevy::camera::primitives::Aabb>,
cutout: Handle<benilla_assets::materials::WowModelMaterial>,
blend: Handle<benilla_assets::materials::WowModelMaterial>,
blend_mode: ModelBlend,
geometry: Arc<RenderSubmesh>,
}
/// The exile state machine (B2). Steady = retained draws it (fade 1, no entity); Exiled =
/// the placement lives as ordinary entities (the feather is the entity path's own look),
/// kill bits set once `armed`; Gone = kill bits set, nothing drawn (fully faded — the
/// entity path culls at alpha 0, this lane never spawns the rows at all).
///
/// `armed` is the overlap protocol's one-frame delay: a fresh exile's entities flush at the
/// end of `PostUpdate` (see the plugin's `chain_ignore_deferred` note) and first draw NEXT
/// frame — the scan arms the kill bit on that same next frame, so the retained item dies in
/// the rendered frame the entity appears. Killing on the spawn frame would open a one-frame
/// hole: 1421's pop class.
enum FaderState {
Steady,
Exiled { ents: Vec<Entity>, armed: bool },
Gone,
}
/// A WMO item's baked facts (see [`GxWmoBatch`] for provenance).
struct GxItemWmo {
group: u16,
interior: bool,
/// The batch-class lane exactly as `model_render` packs `tint.w`: 0 = EXT law, 1 = INT,
/// 2 = TRANS — non-zero only on an interior group's batches.
class_lane: u8,
sidn: [u8; 3],
window: bool,
/// The authored batch order for the clip-z nudge (`sun_scale.y`'s mirror); zeroed when
/// `WOW_WMO_BIAS=0` exactly like `model_material`.
order: u16,
}
/// A cell's collected items + bake state.
#[derive(Default)]
struct GxCell {
items: Vec<GxItem>,
dirty: bool,
last_change: u32,
/// The cell's fader placements (B2), keyed by placement uniqueId. Empty on WMO regions.
faders: bevy::platform::collections::HashMap<u32, GxFader>,
/// The published kill bitmap no longer matches the fader states (a re-bake reassigned
/// item indices, or a state transitioned) — the scan rebuilds it whole and bumps the
/// draw's revision.
bits_stale: bool,
/// XZ bounds over the faders' sphere centres (the scan's cheap ring test); rebuilt on
/// insert/release.
fader_bounds: Option<(Vec2, Vec2)>,
/// `(min near, max far)` over the cell's faders — the ring test's band union. Only read
/// while `faders` is non-empty.
ring: (f32, f32),
/// The scan's last wholesale verdict for this cell (`None` = mixed/unknown → walk every
/// frame). A repeated all-steady / all-gone verdict skips the per-placement walk.
settled: Option<bool>,
}
/// The main-world collector + the published draw set. One resource carries both halves so the
/// divert (spawn), the flush, the unload hook and the cull all see one consistent state.
#[derive(Resource, Default)]
pub struct StaticGx {
cells: bevy::platform::collections::HashMap<(i32, i32), GxCell>,
/// The WMO regions (slice 2), keyed by placement instance entity — the same identity the
/// portal PVS is computed on, so range selection needs no translation table.
wmos: bevy::platform::collections::HashMap<Entity, GxCell>,
/// The 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],
/// Exiled entities whose seed died out from under them (owner release, map clear) —
/// drained and despawned by the scan, which owns the exile lifecycle end to end.
pending_despawn: Vec<Entity>,
/// Lifetime exile-event counters (spawns, re-admits, gone-despawns) — the census line's
/// churn instrument.
fade_events: [u32; 3],
}
/// The divert hook's per-call facts (everything is in scope at `assemble.rs`'s gate).
pub struct GxBatch<'a> {
pub geometry: &'a Arc<RenderSubmesh>,
pub transform: Transform,
/// The owner tile (cell items' release key; unread for WMO items — their region follows
/// the instance entity's lifetime).
pub owner: (i32, i32),
pub texture: Option<Handle<bevy::image::Image>>,
pub blend: ModelBlend,
pub two_sided: bool,
pub unlit: bool,
pub fog_policy: benilla_formats::FogPolicy,
pub env_map: bool,
pub no_depth_write: bool,
pub no_depth_test: bool,
/// Unread on the WMO lane (the entity path passes `ShadeSel::Matte` for every WMO batch
/// and its material never reads the selector) — the shade refusal applies to cells only.
pub shade: ShadeSel,
/// `Some` = a WMO group-geometry batch (slice 2).
pub wmo: Option<GxWmoBatch>,
/// `Some` = a FADER batch (B2, decision 1431): the exile seed. `None` on never-fade
/// batches and the whole WMO lane.
pub fade: Option<GxFadeSeed<'a>>,
}
/// 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
/// path's bundle.
pub struct GxFadeSeed<'a> {
/// The placement uniqueId — one exile unit per placement (all its batches cross the band
/// together; they share one fade sphere).
pub uid: u32,
/// The placement's model label (dev-instrument identity on the exiled entities).
pub label: &'a str,
/// Bounding-sphere radius (yd, placement-scaled) — selects the fade band.
pub radius: f32,
/// Model-local bbox centre; the fade distance is measured to its world image.
pub local_center: Vec3,
pub stat_mesh: Handle<Mesh>,
pub aabb: Option<bevy::camera::primitives::Aabb>,
pub cutout: Handle<benilla_assets::materials::WowModelMaterial>,
pub blend: Handle<benilla_assets::materials::WowModelMaterial>,
}
/// Where the divert hook is standing (`spawn/mod.rs` builds it, `assemble.rs` threads it):
/// the site decides which population an eligible batch joins and which lifecycle key it
/// takes — cells release by owner tile, WMO regions by instance death.
pub enum GxSite<'a> {
/// A world-static ADT-doodad placement. `uid`/`label` feed the fader seed (B2) — the
/// exile unit is the placement, and its exiled entities carry its instrument identity.
Doodad {
owner: (i32, i32),
uid: u32,
label: &'a str,
},
/// 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] },
}
/// The WMO half of [`GxBatch`], gathered at the spawn site (`spawn/mod.rs` owns the instance
/// entity; `assemble.rs` zips the per-batch group index out of `submesh_group`).
pub struct GxWmoBatch {
/// The placement's `WmoPortalInstance` entity — the region key AND the PVS source.
pub instance: Entity,
/// Absolute group index within the building (the PVS bit this batch's draws select on).
pub group: u16,
/// This batch's group is a true interior (`MOGI & 0x48 == 0`) — `RenderSubmesh::interior`.
pub interior: bool,
/// The MOBA batch class (INT/TRANS/EXT) — the lighting-lane selector on interior groups.
pub class: Option<WmoBatchClass>,
/// The MOMT SIDN night-glow colour.
pub sidn: Option<[u8; 3]>,
/// The MOMT WINDOW midpoint-light flag.
pub window: bool,
/// The authored batch order (batch index + 1) — the coplanar-MOBA clip-z nudge.
pub batch_order: u16,
}
impl StaticGx {
/// Take one eligible batch instead of spawning it (or merging it). Returns `false` — and
/// tallies why — when the batch's facts fall outside the prototype's lane; the caller then
/// falls through to the ordinary path, exactly like a refused merge divert.
pub fn divert(&mut self, b: GxBatch<'_>) -> bool {
if b.env_map {
self.declined[0] += 1;
return false;
}
if b.no_depth_write || b.no_depth_test {
self.declined[1] += 1;
return false;
}
if b.wmo.is_some() && wmo_lane_disabled() {
return false; // the lane-isolation lever: WMO batches back to the 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,
_ => {
self.declined[2] += 1;
return false;
}
};
let wmo_key = b.wmo.as_ref().map(|w| w.instance);
let wmo = b.wmo.map(|w| GxItemWmo {
group: w.group,
interior: w.interior,
class_lane: match (w.interior, w.class) {
(true, Some(WmoBatchClass::Int)) => 1,
(true, Some(WmoBatchClass::Trans)) => 2,
_ => 0,
},
sidn: w.sidn.unwrap_or([0; 3]),
window: w.window,
order: if wmo_bias_disabled() {
0
} else {
w.batch_order
},
});
let entry = match wmo_key {
Some(instance) => self.wmos.entry(instance).or_default(),
None => {
let cell = (
(b.transform.translation.x / CELL).floor() as i32,
(b.transform.translation.z / CELL).floor() as i32,
);
self.cells.entry(cell).or_default()
}
};
// 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
// streams in beyond its band never draws retained for even one frame.
let fader_uid = b.fade.as_ref().map(|f| f.uid);
if let Some(seed) = b.fade {
// A never-fade radius cannot reach here (the assemble gate builds a seed only
// for `!class.never_fade`), but a MAX band is the honest fallback: it reads
// "always steady", which IS never-fade semantics.
let (near, far) =
crate::model_fade::fade_band(seed.radius).unwrap_or((f32::MAX, f32::MAX));
let is_new = !entry.faders.contains_key(&seed.uid);
let fader = entry.faders.entry(seed.uid).or_insert_with(|| GxFader {
owner: b.owner,
uid: seed.uid,
label: std::sync::Arc::from(seed.label),
transform: b.transform,
radius: seed.radius,
local_center: seed.local_center,
center: b.transform.transform_point(seed.local_center),
near,
far,
batches: Vec::new(),
items: Vec::new(),
state: FaderState::Steady,
});
fader.batches.push(GxFaderBatch {
stat_mesh: seed.stat_mesh,
aabb: seed.aabb,
cutout: seed.cutout,
blend: seed.blend,
blend_mode: b.blend,
geometry: b.geometry.clone(),
});
if is_new {
// A later batch of the same placement shares its sphere and band — the
// ring/bounds caches move only when a placement joins.
let c = Vec2::new(fader.center.x, fader.center.z);
entry.fader_bounds = Some(match entry.fader_bounds {
Some((mn, mx)) => (mn.min(c), mx.max(c)),
None => (c, c),
});
entry.ring = if entry.faders.len() == 1 {
(near, far)
} else {
(entry.ring.0.min(near), entry.ring.1.max(far))
};
entry.settled = None;
}
}
entry.items.push(GxItem {
geometry: b.geometry.clone(),
transform: b.transform,
owner: b.owner,
texture: b.texture.as_ref().map(Handle::id),
_texture_handle: b.texture,
cutout: b.blend == ModelBlend::AlphaTest && std::env::var("WOW_NO_ALPHATEST").is_err(),
two_sided: b.two_sided,
unlit: b.unlit,
fog_off: matches!(b.fog_policy, benilla_formats::FogPolicy::Off),
shade_lit,
wrap_x: b.geometry.wrap_x,
wrap_y: b.geometry.wrap_y,
wmo,
fader: fader_uid,
});
entry.dirty = true;
entry.last_change = self.frame;
true
}
/// 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).
///
/// **Cells only, deliberately.** A WMO region's items follow the placement's INSTANCE
/// entity (reaped in `cull::cull_cells` when it despawns): a straddler handoff keeps the
/// placement alive under a NEW owner tile, and dropping its retained geometry on the old
/// tile's death would blank a living building.
pub fn release_owner(&mut self, owner: (i32, i32)) {
let frame = self.frame;
let Self {
cells,
pending_despawn,
..
} = self;
for cell in cells.values_mut() {
let before = cell.items.len();
cell.items.retain(|i| i.owner != owner);
if cell.items.len() != before {
cell.dirty = true;
cell.last_change = frame;
}
// The fader seeds ride the same owner key. An exiled placement's entities are
// this lane's own spawns (nothing else knows them), so its death queues them;
// the scan drains the queue with `Commands` next pass.
let faders_before = cell.faders.len();
cell.faders.retain(|_, f| {
if f.owner != owner {
return true;
}
if let FaderState::Exiled { ents, .. } = &mut f.state {
pending_despawn.append(ents);
}
false
});
if cell.faders.len() != faders_before {
cell.fader_bounds = cell
.faders
.values()
.map(|f| Vec2::new(f.center.x, f.center.z))
.fold(None, |acc: Option<(Vec2, Vec2)>, c| {
Some(acc.map_or((c, c), |(mn, mx)| (mn.min(c), mx.max(c))))
});
cell.ring = cell
.faders
.values()
.fold((f32::MAX, 0.0), |(n, x), f| (n.min(f.near), x.max(f.far)));
cell.settled = None;
cell.bits_stale = true;
}
}
}
/// Map drop: everything goes (the streamer's `drop_streamed_world` — same staleness law as
/// the weld/merge accumulators beside this). Exiled entities queue for despawn — the map
/// transition despawns the placement lane's entities through the placement release, but
/// the exiles are this lane's own.
pub fn clear(&mut self) {
let Self {
cells,
pending_despawn,
..
} = self;
for cell in cells.values_mut() {
for f in cell.faders.values_mut() {
if let FaderState::Exiled { ents, .. } = &mut f.state {
pending_despawn.append(ents);
}
}
}
self.cells.clear();
self.wmos.clear();
self.world.cells.clear();
self.world.wmos.clear();
self.world.visible.clear();
self.world.visible_wmos.clear();
}
}
pub struct StaticGxPlugin;
impl Plugin for StaticGxPlugin {
fn build(&self, app: &mut App) {
if !enabled() {
return;
}
info!("static-gx: ARMED (WOW_STATIC_GX=1) — the B1 retained-pass prototype (1429)");
app.init_resource::<StaticGx>().add_systems(
PostUpdate,
// Chained: bake, then this frame's scene walk (after the camera settles), then
// publish the extractable snapshot.
//
// `.after(CheckVisibility)` is LOAD-BEARING (B2, 1431): the scan SPAWNS exile
// entities via `Commands`, and a `PostUpdate` spawner that runs before the
// visibility check can have its queued commands flushed by ANY other system
// pair's auto sync point sitting before `CheckVisibility` (this app already has
// two: `exterior_cull` and `billboard` both order `.before(CheckVisibility)`
// with deferred params). A spawn surfacing there is VISIBLE this frame while
// bevy_pbr's per-entity specialization tick — recorded by
// `check_entities_needing_specialization`, which may already have run — arrives
// only next frame, and `specialize_material_meshes` UNWRAPS the tick of every
// visible entity: the render app panics (`bevy_pbr-0.18.1/material.rs:1061`,
// reproduced and pinned with a vendored diagnostic — the gap entities were
// exactly this scan's exiles, one frame each). Running after `CheckVisibility`
// makes the hazard structural nonsense: however early some sync point flushes
// our spawns, they cannot be seen by a visibility check that already ran — the
// entity is invisible its first frame, ticked and visible together the next,
// which is precisely the overlap protocol's arm delay (see `cull.rs`).
// `chain_ignore_deferred` keeps this chain from minting sync points of its own;
// `CheckVisibility` is itself after `UpdateFrusta`, so the frustum read is this
// frame's camera (B1 left that ambiguous — a latent fixed by the same pin).
(
bake::flush_static_gx,
cull::cull_cells,
render::publish_gx_world,
)
.chain_ignore_deferred()
.after(bevy::transform::TransformSystems::Propagate)
.after(bevy::camera::visibility::VisibilitySystems::CheckVisibility),
);
render::build(app);
}
}
#[cfg(test)]
pub(crate) mod testkit {
//! Shared test fixtures for the collector/bake test modules.
use super::*;
pub fn batch(
geometry: &Arc<RenderSubmesh>,
at: Vec3,
texture: Option<Handle<bevy::image::Image>>,
blend: ModelBlend,
) -> GxBatch<'_> {
GxBatch {
geometry,
transform: Transform::from_translation(at),
owner: (0, 0),
texture,
blend,
two_sided: false,
unlit: false,
fog_policy: benilla_formats::FogPolicy::Scene,
env_map: false,
no_depth_write: false,
no_depth_test: false,
shade: ShadeSel::Lit,
wmo: None,
fade: None,
}
}
pub fn tri(at: [f32; 3]) -> Arc<RenderSubmesh> {
Arc::new(RenderSubmesh {
positions: vec![at, [at[0] + 1.0, at[1], at[2]], [at[0], at[1] + 1.0, at[2]]],
normals: vec![[0.0, 0.0, 1.0]; 3],
uvs: vec![[0.0, 0.0]; 3],
indices: vec![0, 1, 2],
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::testkit::{batch, tri};
use super::*;
/// The collector refuses exactly the recorded exclusion families — and says so in the
/// census — while an eligible batch diverts (1429's no-silent-caps note).
#[test]
fn the_divert_declines_the_excluded_families() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let mut b = batch(&g, Vec3::ZERO, None, ModelBlend::Opaque);
b.env_map = true;
assert!(!gx.divert(b));
let mut b = batch(&g, Vec3::ZERO, None, ModelBlend::Opaque);
b.no_depth_write = true;
assert!(!gx.divert(b));
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!(gx.divert(batch(&g, Vec3::ZERO, None, ModelBlend::Opaque)));
assert_eq!(gx.cells.len(), 1);
}
/// A fader batch (B2) registers its exile seed on the cell — placement-keyed, band from
/// the fade law, items tagged with the uid — while a never-fade batch stays bare. The
/// same placement's second batch joins the SAME seed (one exile unit).
#[test]
fn a_fader_divert_registers_its_placement_seed() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let mk_seed = || GxFadeSeed {
uid: 77,
label: "World\\test\\fence.m2",
radius: 0.4,
local_center: Vec3::ZERO,
stat_mesh: Handle::default(),
aabb: None,
cutout: Handle::default(),
blend: Handle::default(),
};
let mut a = batch(&g, Vec3::new(5.0, 0.0, 5.0), None, ModelBlend::Opaque);
a.fade = Some(mk_seed());
assert!(gx.divert(a));
let mut b = batch(&g, Vec3::new(5.0, 0.0, 5.0), None, ModelBlend::AlphaTest);
b.fade = Some(mk_seed());
assert!(gx.divert(b));
assert!(gx.divert(batch(
&g,
Vec3::new(6.0, 0.0, 6.0),
None,
ModelBlend::Opaque
)));
let cell = &gx.cells[&(0, 0)];
assert_eq!(cell.faders.len(), 1, "one placement, one exile unit");
let f = &cell.faders[&77];
assert_eq!(f.batches.len(), 2);
// Band from the fade law's own table: radius 0.4 → 40.4..50.4.
assert!((f.near - 40.4).abs() < 1e-4 && (f.far - 50.4).abs() < 1e-4);
assert!(matches!(f.state, FaderState::Steady));
assert_eq!(cell.ring, (f.near, f.far));
assert_eq!(
cell.items.iter().filter(|i| i.fader == Some(77)).count(),
2,
"both fader items carry the uid; the never-fade item stays bare"
);
}
/// 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]
fn a_dead_owner_queues_its_exiles() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let mut a = batch(&g, Vec3::ZERO, None, ModelBlend::Opaque);
a.owner = (1, 1);
a.fade = Some(GxFadeSeed {
uid: 9,
label: "x",
radius: 0.4,
local_center: Vec3::ZERO,
stat_mesh: Handle::default(),
aabb: None,
cutout: Handle::default(),
blend: Handle::default(),
});
assert!(gx.divert(a));
let ghost = Entity::PLACEHOLDER;
gx.cells
.get_mut(&(0, 0))
.unwrap()
.faders
.get_mut(&9)
.unwrap()
.state = FaderState::Exiled {
ents: vec![ghost],
armed: true,
};
gx.release_owner((1, 1));
let cell = &gx.cells[&(0, 0)];
assert!(cell.faders.is_empty());
assert_eq!(gx.pending_despawn, vec![ghost]);
}
/// A dead owner's items leave their cells and the re-bake drops to the survivors — the
/// unload hook's contract (a diverted batch has no entity; nothing else can release it).
#[test]
fn a_dead_owner_leaves_the_cells() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
let mut a = batch(&g, Vec3::ZERO, None, ModelBlend::Opaque);
a.owner = (1, 1);
assert!(gx.divert(a));
let mut b = batch(&g, Vec3::ONE, None, ModelBlend::Opaque);
b.owner = (2, 2);
assert!(gx.divert(b));
gx.frame += IDLE_FRAMES; // silence any later-change guard
gx.release_owner((1, 1));
let state = &gx.cells[&(0, 0)];
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].owner, (2, 2));
assert!(state.dirty, "the survivor cell re-bakes");
gx.clear();
assert!(gx.cells.is_empty() && gx.world.cells.is_empty());
}
}
@@ -0,0 +1,878 @@
//! The render-world half of the B1 retained pass (see `mod.rs`; decision 1429): extraction of
//! the published cell set, per-cell GPU assembly (texture-array classes + the item→layer
//! table), the pipeline family, and the draw node between the main opaque and transparent
//! passes.
//!
//! 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).
use bevy::camera::primitives::Aabb;
use bevy::ecs::query::QueryItem;
use bevy::image::Image;
use bevy::mesh::VertexBufferLayout;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::mesh::allocator::MeshAllocator;
use bevy::render::mesh::RenderMesh;
use bevy::render::render_asset::RenderAssets;
use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
};
use bevy::render::render_resource::binding_types::{
sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer, uniform_buffer_sized,
};
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::texture::GpuImage;
use bevy::render::view::{
ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms,
};
use bevy::render::{Render, RenderSystems};
use bevy::shader::ShaderDefVal;
use std::ops::Range;
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::core_pipeline::core_3d::CORE_3D_DEPTH_FORMAT;
/// One baked item's draw facts (index-parallel with the bake order; the vertex word's low bits
/// carry this item's index, which the record table resolves to an array layer + the WMO
/// per-item record).
#[derive(Clone)]
pub(crate) struct GxItemDraw {
pub index_range: Range<u32>,
pub texture: Option<AssetId<Image>>,
pub cutout: bool,
pub two_sided: bool,
#[allow(dead_code)] // bake-side bookkeeping; the node draws by index range alone
pub vertex_range: Range<u32>,
/// The WMO group this item belongs to (`None` on cell items) — the range-selection key:
/// a run never crosses a group boundary, so the flood's verdict selects whole runs.
pub group: Option<u16>,
/// The authored batch order (the coplanar-MOBA clip-z nudge; 0 on cell items).
pub order: u16,
/// The MOMT SIDN night-glow colour (gamma bytes; zero on cell items).
pub sidn: [u8; 3],
}
/// One baked cell (or WMO region), published by the main-world flush.
#[derive(Clone)]
pub(crate) struct GxCellDraw {
pub mesh: Handle<Mesh>,
/// The recentring origin (0974's precision split): shader world = vertex + origin.
pub origin: Vec3,
/// Mesh-local bound (recentred); world bound = origin + this.
pub aabb: Aabb,
pub draws: Vec<GxItemDraw>,
/// The exile kill bitmap (B2, 1431): bit *i* set ⇒ item *i* is punched out of the
/// retained draw (its placement is feathering as ordinary entities, or fully faded).
/// Rebuilt in place by the main-world scan; all-zero on WMO regions.
pub killed: Vec<u64>,
/// 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.
pub groups: Vec<(u16, Aabb)>,
}
/// Marks the ONE view the retained pass draws into — the world camera. Without this the node
/// would run for EVERY Core3d view, including the portrait-booth bakes, and paint world cells
/// into a portrait with the booth's view matrices (the cull list is the world camera's).
#[derive(Component, Clone, Copy, Default, ExtractComponent)]
pub(crate) struct StaticGxView;
/// Insert the marker on the world camera (idempotent — the camera can respawn).
fn mark_world_camera(
mut commands: Commands,
cam: Query<Entity, (With<crate::view::WorldCamera>, Without<StaticGxView>)>,
) {
for e in &cam {
commands.entity(e).insert(StaticGxView);
}
}
/// 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)>,
/// The WMO regions (slice 2), keyed by placement instance entity.
pub wmos: HashMap<Entity, GxCellDraw>,
/// This frame's per-group admission per region (indexed by absolute group index): the
/// portal flood's verdict collapsed to CPU range selection — the node draws exactly the
/// runs whose group bit is set.
pub visible_wmos: Vec<(Entity, Vec<bool>)>,
}
/// A texture-dimension class within one cell: one `texture_2d_array` + the members feeding it.
struct GxClassGpu {
array: Texture,
bind_group: BindGroup,
/// Copies still to encode (source GpuImage's texture, destination layer). Drained by the
/// node's encoder on its next run; the cell draws only once every class is fully copied.
pending: Vec<(Texture, u32)>,
mip_count: u32,
size: Extent3d,
}
/// One coalesced draw run: adjacent bake items sharing (class, pipeline bucket, group).
struct GxRun {
class: usize,
cutout: bool,
two_sided: bool,
index_range: Range<u32>,
/// The WMO group every item in this run belongs to (`None` = a cell run, always drawn) —
/// the bake sorts group inside (bucket, texture), so runs are group-homogeneous by
/// construction and the flood's per-group verdict selects whole runs.
group: Option<u16>,
}
/// A cell's assembled GPU state, cached across frames; rebuilt when the bake (mesh handle)
/// changes.
struct GxCellGpu {
mesh: AssetId<Mesh>,
classes: Vec<GxClassGpu>,
record_table: Buffer,
#[allow(dead_code)] // held alive for the bind groups that reference it
cell_uniform: Buffer,
runs: Vec<GxRun>,
/// CPU copy of the record table — the kill-bit sync rewrites column 3 and re-uploads.
records: Vec<[u32; 4]>,
/// The `killed_rev` this table last uploaded.
killed_applied: u32,
}
#[derive(Resource, Default)]
struct GxGpuCache {
cells: HashMap<(i32, i32), GxCellGpu>,
wmos: HashMap<Entity, GxCellGpu>,
}
#[derive(Resource)]
struct GxPipelines {
view_layout: BindGroupLayoutDescriptor,
cell_layout: BindGroupLayoutDescriptor,
sampler: Sampler,
sampler_clamp: Sampler,
/// Keyed `(cutout, two_sided)`; specialized for the world view's (samples, format) pair —
/// re-specialized if that pair ever changes (a window move across displays).
pipelines: HashMap<(bool, bool), CachedRenderPipelineId>,
specialized_for: Option<(u32, TextureFormat)>,
}
fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
let view_layout = BindGroupLayoutDescriptor::new(
"static_gx_view_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX_FRAGMENT,
(
uniform_buffer::<ViewUniform>(true),
storage_buffer_read_only_sized(false, None),
),
),
);
let cell_layout = BindGroupLayoutDescriptor::new(
"static_gx_cell_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX_FRAGMENT,
(
// origin (xyz) + pad
uniform_buffer_sized(false, Some(std::num::NonZero::new(16).unwrap())),
// item → texture-array layer
storage_buffer_read_only_sized(false, None),
texture_2d_array(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
sampler(SamplerBindingType::Filtering),
),
),
);
// TWO samplers per array — repeat and clamp, both matching the BLP loader's model albedo
// sampler exactly (linear tri-filtered, ANISOTROPY 8 — `blp.rs`; the aniso is load-bearing
// for parity, oblique minification reads visibly softer without it). The shader selects by
// the vertex word's wrap bits: a shared array cannot carry per-layer address modes. The
// rare MIXED-wrap batch (repeat one axis, clamp the other) keeps the repeat sampler plus
// the shader's half-texel inset clamp on its clamped axis — an approximation confined to
// that class (decision 0763's silhouette concern, honoured per axis).
let make = |label: &'static str, mode: AddressMode| {
render_device.create_sampler(&SamplerDescriptor {
label: Some(label),
min_filter: FilterMode::Linear,
mag_filter: FilterMode::Linear,
mipmap_filter: FilterMode::Linear,
address_mode_u: mode,
address_mode_v: mode,
anisotropy_clamp: 8,
..Default::default()
})
};
commands.insert_resource(GxPipelines {
view_layout,
cell_layout,
sampler: make("static_gx_repeat", AddressMode::Repeat),
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
pipelines: HashMap::default(),
specialized_for: None,
});
}
/// The pipeline-key query: the world view's (samples, format) inputs (the marker keeps booth
/// views out of it).
type GxViewKey = (
&'static ExtractedView,
&'static Msaa,
&'static ViewTarget,
&'static StaticGxView,
);
/// The fixed interleaved vertex layout the bake authors — **attribute-ID order**, which is
/// how Bevy interleaves a mesh's buffer: position (0), normal (1), uv (2), COLOR (5 — MOCV /
/// the baked constant tint, white default), then the custom word + anchor (988_101/988_102).
/// Kept in sync with `bake_cell` and `static_gx.wgsl`.
fn vertex_layout() -> VertexBufferLayout {
VertexBufferLayout {
array_stride: 64,
step_mode: VertexStepMode::Vertex,
attributes: vec![
VertexAttribute {
format: VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
VertexAttribute {
format: VertexFormat::Float32x3,
offset: 12,
shader_location: 1,
},
VertexAttribute {
format: VertexFormat::Float32x2,
offset: 24,
shader_location: 2,
},
VertexAttribute {
format: VertexFormat::Float32x4,
offset: 32,
shader_location: 5,
},
VertexAttribute {
format: VertexFormat::Uint32,
offset: 48,
shader_location: 3,
},
VertexAttribute {
format: VertexFormat::Float32x3,
offset: 52,
shader_location: 4,
},
],
}
}
/// (Re-)specialize the four pipelines for the world view's (samples, format), and assemble
/// visible cells' GPU state: classes, arrays, layer table, bind groups, runs.
#[allow(clippy::too_many_arguments)]
fn prepare_static_gx(
gx: Res<GxWorld>,
mut cache: ResMut<GxGpuCache>,
mut pipes: ResMut<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
asset_server: Res<AssetServer>,
images: Res<RenderAssets<GpuImage>>,
views: Query<GxViewKey>,
) {
let _t = super::gx_perf_guard(3);
// The world view's pipeline key (the marker keeps booth views out of it).
let Some((view, msaa, _, _)) = views.iter().next() else {
return;
};
let format = if view.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
};
let key = (msaa.samples(), format);
if pipes.specialized_for != Some(key) {
let shader: Handle<Shader> =
asset_server.load("embedded://benilla_world/shaders/static_gx.wgsl");
pipes.pipelines.clear();
for cutout in [false, true] {
for two_sided in [false, true] {
let mut defs = vec![];
if cutout {
defs.push(ShaderDefVal::from("GX_CUTOUT"));
}
let id = pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some(
format!("static_gx c{} t{}", u8::from(cutout), u8::from(two_sided)).into(),
),
layout: vec![pipes.view_layout.clone(), pipes.cell_layout.clone()],
vertex: VertexState {
shader: shader.clone(),
shader_defs: defs.clone(),
entry_point: Some("vertex".into()),
buffers: vec![vertex_layout()],
},
fragment: Some(FragmentState {
shader: shader.clone(),
shader_defs: defs,
entry_point: Some("fragment".into()),
targets: vec![Some(ColorTargetState {
format,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
primitive: PrimitiveState {
cull_mode: (!two_sided).then_some(Face::Back),
..Default::default()
},
depth_stencil: Some(DepthStencilState {
format: CORE_3D_DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: CompareFunction::GreaterEqual,
stencil: StencilState::default(),
bias: DepthBiasState::default(),
}),
multisample: MultisampleState {
count: msaa.samples(),
..Default::default()
},
..default()
});
pipes.pipelines.insert((cutout, two_sided), id);
}
}
pipes.specialized_for = Some(key);
}
// Drop cache entries whose region vanished or re-baked.
cache
.cells
.retain(|c, gpu| gx.cells.get(c).is_some_and(|d| d.mesh.id() == gpu.mesh));
cache
.wmos
.retain(|e, gpu| gx.wmos.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 (entity, _) in &gx.visible_wmos {
if cache.wmos.contains_key(entity) {
continue;
}
let Some(draw) = gx.wmos.get(entity) else {
continue;
};
if let Some(gpu) = assemble_region(
draw,
&pipes,
&pipeline_cache,
&render_device,
&render_queue,
&images,
) {
cache.wmos.insert(*entity, gpu);
}
}
// 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
// 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 {
let (Some(gpu), Some(draw)) = (cache.cells.get_mut(cell), gx.cells.get(cell)) else {
continue;
};
if gpu.killed_applied == draw.killed_rev {
continue;
}
for (i, rec) in gpu.records.iter_mut().enumerate() {
rec[3] = kill_bit(&draw.killed, i);
}
render_queue.write_buffer(&gpu.record_table, 0, bytemuck::cast_slice(&gpu.records));
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).
fn assemble_region(
draw: &GxCellDraw,
pipes: &GxPipelines,
pipeline_cache: &PipelineCache,
render_device: &RenderDevice,
render_queue: &RenderQueue,
images: &RenderAssets<GpuImage>,
) -> Option<GxCellGpu> {
// Class by (size, format, mips); assign layers DEDUPED by texture — many items share one
// texture (Stormwind's region carries 3,042 items over a few hundred distinct BLPs), and
// per-item layers blew past the D2-array layer limit the moment a city root baked. A
// layer's content depends only on its source texture, so sharing is exact. A class that
// still fills to the device limit overflows into a sibling class with the same key.
let max_layers = render_device.limits().max_texture_array_layers as usize;
type ClassAcc<'a> = (Extent3d, TextureFormat, u32, Vec<&'a GpuImage>);
let mut classes: Vec<ClassAcc<'_>> = Vec::new();
let mut item_class_layer: Vec<(u32, u32)> = vec![(0, 0); draw.draws.len()];
let mut assigned: HashMap<AssetId<Image>, (u32, u32)> = HashMap::default();
for (i, item) in draw.draws.iter().enumerate() {
let Some(tex) = item.texture else { continue };
if let Some(&cl) = assigned.get(&tex) {
item_class_layer[i] = cl;
continue;
}
// Every member texture must be resident to class the region at all.
let g = images.get(tex)?;
let sz = g.texture.size();
let key = (
Extent3d {
width: sz.width,
height: sz.height,
depth_or_array_layers: 1,
},
g.texture.format(),
g.texture.mip_level_count(),
);
let ci = classes
.iter()
.position(|(s, f, m, mem)| {
(*s, *f, *m) == (key.0, key.1, key.2) && mem.len() < max_layers
})
.unwrap_or_else(|| {
classes.push((key.0, key.1, key.2, Vec::new()));
classes.len() - 1
});
let layer = u32::try_from(classes[ci].3.len()).unwrap();
classes[ci].3.push(g);
let cl = (u32::try_from(ci).unwrap(), layer);
assigned.insert(tex, cl);
item_class_layer[i] = cl;
}
// 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.
let records: Vec<[u32; 4]> = draw
.draws
.iter()
.enumerate()
.map(|(i, item)| {
[
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),
]
})
.collect();
let record_table = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("static_gx_records"),
contents: bytemuck::cast_slice(&records),
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
});
let cell_uniform = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("static_gx_cell"),
contents: bytemuck::cast_slice(&[draw.origin.x, draw.origin.y, draw.origin.z, 0.0f32]),
usage: BufferUsages::UNIFORM,
});
let cell_layout = pipeline_cache.get_bind_group_layout(&pipes.cell_layout);
let mut gpu_classes: Vec<GxClassGpu> = Vec::new();
for (size, tex_format, mips, members) in &classes {
// The VRAM meter (1431's regression hunt): approximate bytes for the array about to
// be created — block-compressed at their block rate, else 4 B/texel — ×4/3 for mips.
if super::gx_perf_enabled() {
let per_layer = match tex_format {
TextureFormat::Bc1RgbaUnorm | TextureFormat::Bc1RgbaUnormSrgb => {
u64::from(size.width) * u64::from(size.height) / 2
}
TextureFormat::Bc2RgbaUnorm
| TextureFormat::Bc2RgbaUnormSrgb
| TextureFormat::Bc3RgbaUnorm
| TextureFormat::Bc3RgbaUnormSrgb => u64::from(size.width) * u64::from(size.height),
_ => u64::from(size.width) * u64::from(size.height) * 4,
};
let bytes = per_layer * members.len().max(1) as u64 * if *mips > 1 { 4 } else { 3 } / 3;
super::GX_VRAM.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed);
}
let array = render_device.create_texture(&TextureDescriptor {
label: Some("static_gx_array"),
size: Extent3d {
width: size.width,
height: size.height,
depth_or_array_layers: u32::try_from(members.len().max(1)).unwrap(),
},
mip_level_count: *mips,
sample_count: 1,
dimension: TextureDimension::D2,
format: *tex_format,
usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = array.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
let bind_group = render_device.create_bind_group(
"static_gx_cell",
&cell_layout,
&BindGroupEntries::sequential((
cell_uniform.as_entire_binding(),
record_table.as_entire_binding(),
&view,
&pipes.sampler,
&pipes.sampler_clamp,
)),
);
gpu_classes.push(GxClassGpu {
array,
bind_group,
pending: members
.iter()
.enumerate()
.map(|(layer, g)| (g.texture.clone(), u32::try_from(layer).unwrap()))
.collect(),
mip_count: *mips,
size: *size,
});
}
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 mut runs: Vec<GxRun> = Vec::new();
for (i, item) in draw.draws.iter().enumerate() {
let class = item_class_layer[i].0 as usize;
match runs.last_mut() {
Some(r)
if r.class == class
&& r.cutout == item.cutout
&& r.two_sided == item.two_sided
&& r.group == item.group
&& r.index_range.end == item.index_range.start =>
{
r.index_range.end = item.index_range.end;
}
_ => runs.push(GxRun {
class,
cutout: item.cutout,
two_sided: item.two_sided,
index_range: item.index_range.clone(),
group: item.group,
}),
}
}
Some(GxCellGpu {
mesh: draw.mesh.id(),
classes: gpu_classes,
record_table,
cell_uniform,
runs,
records,
killed_applied: draw.killed_rev,
})
}
/// Record-table column 3: item `i`'s exile kill bit from the published bitmap.
fn kill_bit(killed: &[u64], i: usize) -> u32 {
u32::from(
killed
.get(i / 64)
.is_some_and(|w| w & (1u64 << (i % 64)) != 0),
)
}
/// The per-frame view bind group (group 0): bevy's view uniform + the shared light buffer —
/// the SAME `wow_shared_light` storage every material binds (1429: identical lighting by
/// construction).
#[derive(Resource)]
struct GxViewBind(BindGroup);
fn prepare_view_bind(
mut commands: Commands,
pipes: Res<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
view_uniforms: Res<ViewUniforms>,
light: Option<Res<crate::lighting::SharedLightBuffer>>,
) {
let (Some(view_binding), Some(light)) = (view_uniforms.uniforms.binding(), light) else {
return;
};
let layout = pipeline_cache.get_bind_group_layout(&pipes.view_layout);
commands.insert_resource(GxViewBind(render_device.create_bind_group(
"static_gx_view",
&layout,
&BindGroupEntries::sequential((view_binding, light.0.as_entire_binding())),
)));
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct StaticGxLabel;
#[derive(Default)]
struct StaticGxNode;
impl ViewNode for StaticGxNode {
type ViewQuery = (
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ViewUniformOffset,
// The world camera only — a booth bake must never receive world cells (see the marker).
&'static StaticGxView,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
(target, depth, view_offset, _marker): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let _t = super::gx_perf_guard(4);
let gx = world.resource::<GxWorld>();
if gx.visible.is_empty() && gx.visible_wmos.is_empty() {
return Ok(());
}
let cache = world.resource::<GxGpuCache>();
let pipes = world.resource::<GxPipelines>();
let pipeline_cache = world.resource::<PipelineCache>();
let Some(view_bind) = world.get_resource::<GxViewBind>() else {
return Ok(());
};
let meshes = world.resource::<RenderAssets<RenderMesh>>();
let allocator = world.resource::<MeshAllocator>();
// Cells draw whole; a WMO region draws only the runs whose group the flood admitted
// this frame (the selection rides beside the gpu state — `None` = draw everything).
let mut resolved: Vec<(&GxCellGpu, &GxCellDraw, Option<&Vec<bool>>)> = Vec::new();
for cell in &gx.visible {
if let (Some(gpu), Some(draw)) = (cache.cells.get(cell), gx.cells.get(cell)) {
resolved.push((gpu, draw, None));
}
}
for (entity, sel) in &gx.visible_wmos {
if let (Some(gpu), Some(draw)) = (cache.wmos.get(entity), gx.wmos.get(entity)) {
resolved.push((gpu, draw, Some(sel)));
}
}
if resolved.is_empty() {
return Ok(());
}
// Encode any outstanding layer copies OUTSIDE the pass. `pending` drains through
// interior mutability-free re-borrow: the cache is not mutable here, so copies are
// (re-)encoded from a snapshot; encoding the same copy twice is idempotent (same src →
// same dst), and PrepareResources rebuilds cells only on bake changes, so the window
// is one frame. Simplicity over a drain flag — B2 revisits if it ever shows.
{
let encoder = render_context.command_encoder();
for (gpu, _, _) in &resolved {
for class in &gpu.classes {
for (src, layer) in &class.pending {
for mip in 0..class.mip_count.min(src.mip_level_count()) {
let mut dst = class.array.as_image_copy();
dst.mip_level = mip;
dst.origin.z = *layer;
let mut s = src.as_image_copy();
s.mip_level = mip;
encoder.copy_texture_to_texture(
s,
dst,
Extent3d {
width: (class.size.width >> mip).max(1),
height: (class.size.height >> mip).max(1),
depth_or_array_layers: 1,
},
);
}
}
}
}
}
// 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();
for (k, id) in &pipes.pipelines {
match pipeline_cache.get_render_pipeline(*id) {
Some(p) => {
ready.insert(*k, p);
}
None => return Ok(()),
}
}
let depth_attachment = depth.get_attachment(StoreOp::Store);
let color_attachment = target.get_color_attachment();
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("static_gx"),
color_attachments: &[Some(color_attachment)],
depth_stencil_attachment: Some(depth_attachment),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_bind_group(0, &view_bind.0, &[view_offset.offset]);
for (gpu, draw, sel) in &resolved {
let Some(mesh) = meshes.get(draw.mesh.id()) else {
continue;
};
let (Some(vslice), Some(islice)) = (
allocator.mesh_vertex_slice(&draw.mesh.id()),
allocator.mesh_index_slice(&draw.mesh.id()),
) else {
continue;
};
let index_format = match &mesh.buffer_info {
bevy::render::mesh::RenderMeshBufferInfo::Indexed { index_format, .. } => {
*index_format
}
bevy::render::mesh::RenderMeshBufferInfo::NonIndexed => continue,
};
pass.set_vertex_buffer(0, vslice.buffer.slice(..));
pass.set_index_buffer(islice.buffer.slice(..), index_format);
for run in &gpu.runs {
// The PVS range selection (1429's collapse): a WMO run draws iff its group's
// admission bit is set this frame; a cell run always draws.
if let (Some(sel), Some(group)) = (sel, run.group) {
if !sel.get(usize::from(group)).copied().unwrap_or(false) {
continue;
}
}
pass.set_render_pipeline(ready[&(run.cutout, run.two_sided)]);
pass.set_bind_group(1, &gpu.classes[run.class].bind_group, &[]);
pass.draw_indexed(
(islice.range.start + run.index_range.start)
..(islice.range.start + run.index_range.end),
i32::try_from(vslice.range.start).unwrap_or(0),
0..1,
);
}
}
Ok(())
}
}
/// Wire the render half (called by the plugin only when armed).
pub(super) fn build(app: &mut App) {
// (The shader registers in `crate::shaders` with the other engine WGSL — `embedded_asset!`
// derives its path from the CALLING file, so registering here would mis-prefix it.)
// The main-world half lives inside `StaticGx`; `publish_gx_world` (registered by the
// plugin, chained after the scene walk) mirrors it into this standalone resource for
// `ExtractResourcePlugin` to clone.
app.add_plugins((
ExtractResourcePlugin::<GxWorld>::default(),
ExtractComponentPlugin::<StaticGxView>::default(),
));
app.init_resource::<GxWorld>();
app.add_systems(Update, mark_world_camera);
let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) else {
return;
};
render_app
.init_resource::<GxGpuCache>()
.add_systems(bevy::render::RenderStartup, init_pipelines)
.add_systems(
Render,
(
prepare_static_gx.in_set(RenderSystems::PrepareResources),
prepare_view_bind.in_set(RenderSystems::PrepareBindGroups),
),
)
.add_render_graph_node::<ViewNodeRunner<StaticGxNode>>(Core3d, StaticGxLabel)
.add_render_graph_edges(
Core3d,
(
Node3d::MainOpaquePass,
StaticGxLabel,
Node3d::MainTransparentPass,
),
);
}
/// Copy the collector's published half into the extractable resource.
pub(super) fn publish_gx_world(gx: Res<super::StaticGx>, mut out: ResMut<GxWorld>) {
let _t = super::gx_perf_guard(2);
out.cells.clone_from(&gx.world.cells);
out.visible.clone_from(&gx.world.visible);
out.wmos.clone_from(&gx.world.wmos);
out.visible_wmos.clone_from(&gx.world.visible_wmos);
}
+26 -5
View File
@@ -579,9 +579,14 @@ fn stream_terrain(
mut load_progress: Option<ResMut<WorldLoadProgress>>,
// Bundled for the same 16-param reason as `asset_stores`: the two stream-batch accumulators
// the map-change drop must clear.
batchers: (ResMut<HullWelds>, ResMut<StaticMerge>),
batchers: (
ResMut<HullWelds>,
ResMut<StaticMerge>,
// `None` unless `WOW_STATIC_GX=1` (the collector only exists armed — 1429).
Option<ResMut<crate::static_gx::StaticGx>>,
),
) {
let (mut welds, mut static_merge) = batchers;
let (mut welds, mut static_merge, mut staticgx) = batchers;
let (mut materials, mut meshes, wdts, _time, mut activity) = asset_stores;
let (current_map, map_catalog) = location;
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
@@ -614,6 +619,7 @@ fn stream_terrain(
placements,
&mut welds,
&mut static_merge,
staticgx.as_deref_mut(),
&mut activity,
);
state.map_dir = Some(dir.clone());
@@ -744,6 +750,11 @@ fn stream_terrain(
&t.placements,
merge::merge_enabled(),
);
// The B1 retained cells (1429): a diverted batch has no entity and no blob, so the
// dead owner's items leave here or never — the cells re-bake without them.
if let Some(gx) = staticgx.as_deref_mut() {
gx.release_owner(c);
}
}
}
@@ -1073,6 +1084,7 @@ fn drop_streamed_world(
placements: &mut Placements,
welds: &mut HullWelds,
merge: &mut StaticMerge,
staticgx: Option<&mut crate::static_gx::StaticGx>,
activity: &mut StreamActivity,
) {
for ((_tx, _ty), t) in state.tiles.drain() {
@@ -1094,6 +1106,10 @@ fn drop_streamed_world(
// Same staleness argument for the merge accumulators (1417) and the census tallies: both
// describe the world just dropped, and tile keys repeat across maps.
merge.clear();
// …and for the B1 retained cells (1429): cell keys repeat across maps too.
if let Some(gx) = staticgx {
gx.clear();
}
crate::static_merge::reset();
}
@@ -1121,11 +1137,15 @@ fn release_world(
mut forms: ResMut<crate::model_forms::ModelForms>,
mut progress: Option<ResMut<WorldLoadProgress>>,
mut activity: ResMut<StreamActivity>,
// Bundled for the same reason as `stream_terrain`'s pair: the two stream-batch
// Bundled for the same reason as `stream_terrain`'s pair: the stream-batch
// accumulators the world drop must clear (and clippy's argument-count line).
batchers: (ResMut<HullWelds>, ResMut<StaticMerge>),
batchers: (
ResMut<HullWelds>,
ResMut<StaticMerge>,
Option<ResMut<crate::static_gx::StaticGx>>,
),
) {
let (mut welds, mut static_merge) = batchers;
let (mut welds, mut static_merge, mut staticgx) = batchers;
if state.tiles.is_empty() && state.map_dir.is_none() {
return;
}
@@ -1143,6 +1163,7 @@ fn release_world(
&mut placements,
&mut welds,
&mut static_merge,
staticgx.as_deref_mut(),
&mut activity,
);
state.map_dir = None;
@@ -564,10 +564,12 @@ pub(super) fn flush_static_merge(
/// sort centre lands beyond a per-entity fader, the blob draws first and its translucent
/// pixels depth-kill the entity behind them (the director's popping lamppost; 1422's
/// centre-sort only shrank the class, it cannot eliminate it). Under the default merge
/// faders spawn per-entity — the configuration the director's eye
/// passed. The engineered path to win lane 2 back (sort-near bias on a blob-only twin
/// variant, the depth test resolving order against depth-writing content) is 1423's
/// follow-up.
/// faders spawn per-entity — the configuration the director's eye passed. 1423's recorded
/// re-entry (sort-near on a blob twin) was REFUTED before build (1428): the blob's
/// opaque-intent pixels ride the same near-sorted transparent draw and erase any
/// non-depth-writing card in front of them, O(1) and permanent. Lane 2 stays parked; the
/// question folds into option B's retained draw, which bins opaque-intent content properly
/// by construction.
pub(crate) fn merge_faders_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_MERGE_FADERS").is_some())
@@ -145,6 +145,14 @@ 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.
mut staticgx: Option<(
&mut crate::static_gx::StaticGx,
crate::static_gx::GxSite<'_>,
)>,
// Returns the spawned entities + the per-batch map + what the anim host armed for this
// placement, when the model animates: the joint set (bone-indexed) the emitter spawn rides
// its host bone off (0130 phase 4), and the FILE sequence slot its variation roll landed
@@ -350,6 +358,80 @@ 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.
if let Some((gx, site)) = staticgx.as_mut() {
let facts =
match site {
crate::static_gx::GxSite::Doodad { owner, uid, label } if !is_wmo => {
let fade = (!class.never_fade && !crate::static_gx::fade_lane_disabled())
.then(|| crate::static_gx::GxFadeSeed {
uid: *uid,
label,
radius,
local_center,
stat_mesh: stat_mesh.clone(),
aabb: *stat_aabb,
cutout: cutout.clone(),
blend: blend.clone(),
});
Some((*owner, None, fade))
}
crate::static_gx::GxSite::Wmo { instance, groups } if is_wmo => {
groups.get(batch_idx).map(|&g| {
(
(0, 0), // WMO items release by instance death, never by tile
Some(crate::static_gx::GxWmoBatch {
instance: *instance,
group: g,
interior,
class: sub.wmo_batch,
sidn: sub.sidn,
window: sub.window,
batch_order,
}),
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,
})
{
continue;
}
}
}
// The production consolidation divert (`WOW_STATIC_MERGE=1`, 1417/1418): an order-free
// static doodad batch — faders included, their fade rides the baked sphere through the
// shader's WOW_MERGED_FADE lane — merges into its site's blob instead of spawning. Its
@@ -67,6 +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).
Option<ResMut<'w, crate::static_gx::StaticGx>>,
);
#[allow(clippy::too_many_arguments)]
@@ -95,7 +97,8 @@ pub(super) fn spawn_loaded_placements(
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut activity, focus, mut forms, mut welds, mut mega, mut merge) = tables;
let (mut probes, mut activity, focus, mut forms, mut welds, mut mega, mut merge, mut staticgx) =
tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -165,6 +168,9 @@ pub(super) fn spawn_loaded_placements(
};
let (radius, center) = m2_fade(&m.bounds, p.transform.scale.x);
let anim_bound = m2_anim_bound(&m.bounds);
// Built here (not at the tag site below) so the gx fader seed can carry
// the placement identity (B2, 1431); moved into `tag_world_object` after.
let label = handle_label(h);
let SpawnedModel {
entities: mut ents,
by_batch,
@@ -193,6 +199,18 @@ pub(super) fn spawn_loaded_placements(
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
Some((&mut *merge, MergeSite::Doodad { owner: p.owner })),
// The retained-pass collector (1429/1431) — ADT doodads are its
// lane; uid + label seed the fader exile identity (B2).
staticgx.as_deref_mut().map(|gx| {
(
gx,
crate::static_gx::GxSite::Doodad {
owner: p.owner,
uid: unique_id,
label: &label,
},
)
}),
);
// ADT doodads are exterior scene: from inside a WMO they draw only through a
// portal window (`0x683700`, fed solely by the per-window walk `0x682fa0` — see
@@ -266,7 +284,7 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
&ents,
ModelKind::Doodad,
handle_label(h),
label,
unique_id,
format!("emitters: {}", m.emitters.len()),
);
@@ -304,6 +322,40 @@ pub(super) fn spawn_loaded_placements(
// Hoisted above the spawn: the merge site needs it (a portal-gated building's
// blobs take `WmoGroupVis`), and the instance logic below reuses it.
let has_portals = !m.portal_refs.is_empty() && !m.portal_infos.is_empty();
// The per-placement portal-cull instance, spawned BEFORE the batches so the
// static-gx divert can key its retained region on it (slice 2 of 1429 — the
// region's lifecycle IS this entity's). The group-vis tagging still runs
// after the spawn, off `by_batch`; see the instance block below for why a
// portal-less-but-named building spawns one too.
let instance = (has_portals || m.wmo_id != 0).then(|| {
commands
.spawn(WmoPortalInstance {
handle: h.clone(),
world_from_local: p.transform.compute_affine(),
name_set: p.name_set,
visible: vec![true; m.group_nav.len()],
liquid_visited: vec![false; m.group_nav.len()],
// The MOGP `groupLiquid` override, resolved once at spawn: 13
// groups in the whole archive declare "this room is wholly
// submerged" in place of carrying a liquid grid, and 5 of them are
// placed — the Deeprun Tram's two flooded sections, the Prison
// Oubliette, the MD crypt and the mountain cave (decision 1000).
flooded: m
.group_nav
.iter()
.map(|g| {
(g.group_liquid != benilla_formats::NO_GROUP_LIQUID)
.then(|| {
benilla_formats::LiquidKind::from_nibble(
(g.group_liquid & 0xf) as u8,
)
})
.flatten()
})
.collect(),
})
.id()
});
let SpawnedModel {
entities: mut ents,
by_batch,
@@ -339,6 +391,20 @@ pub(super) fn spawn_loaded_placements(
portal_gated: has_portals,
},
)),
// Slice 2 (1429): WMO group geometry diverts into a retained region
// keyed by the instance entity — only a placement WITH an instance
// qualifies (no instance ⇒ no PVS identity ⇒ entity path).
instance.and_then(|i| {
staticgx.as_deref_mut().map(|gx| {
(
gx,
crate::static_gx::GxSite::Wmo {
instance: i,
groups: &m.submesh_group,
},
)
})
}),
);
if let Some((target, r)) = fade_near_target() {
let pos = p.transform.translation;
@@ -379,34 +445,7 @@ pub(super) fn spawn_loaded_placements(
// gets tagged; a portal-less prop keeps all groups visible but still spawns the
// instance when it carries a WMOAreaTable identity — the interior down-ray
// (`wmo_portal::track_current_interior`) needs it. See `crate::wmo_portal`.
if has_portals || m.wmo_id != 0 {
let instance = commands
.spawn(WmoPortalInstance {
handle: h.clone(),
world_from_local: p.transform.compute_affine(),
name_set: p.name_set,
visible: vec![true; m.group_nav.len()],
liquid_visited: vec![false; m.group_nav.len()],
// The MOGP `groupLiquid` override, resolved once at spawn: 13
// groups in the whole archive declare "this room is wholly
// submerged" in place of carrying a liquid grid, and 5 of them are
// placed — the Deeprun Tram's two flooded sections, the Prison
// Oubliette, the MD crypt and the mountain cave (decision 1000).
flooded: m
.group_nav
.iter()
.map(|g| {
(g.group_liquid != benilla_formats::NO_GROUP_LIQUID)
.then(|| {
benilla_formats::LiquidKind::from_nibble(
(g.group_liquid & 0xf) as u8,
)
})
.flatten()
})
.collect(),
})
.id();
if let Some(instance) = instance {
if has_portals {
// One shared single-group key per group — geometry belongs to exactly
// one room, and sharing keeps the tag one allocation per group rather
@@ -661,6 +700,7 @@ pub(super) fn spawn_loaded_placements(
slot: interior_slot,
},
)),
None, // static-gx is the ADT-doodad lane; WMO props never feed it
);
// The slot frees itself when the prop despawns (streaming out) — the component hook
// returns it to the table whoever does the despawn. A prop whose every batch
@@ -256,6 +256,10 @@ impl Plugin for WorldFoundation {
// the flag off nothing ever diverts, the buffer stays empty, and the flush early-outs.
app.init_resource::<crate::mega_static::MegaStaticPending>();
app.add_systems(bevy::app::Update, crate::mega_static::flush_mega_static);
// `WOW_STATIC_GX=1` — the B1 retained-pass prototype (decision 1429; dev-only, the
// module doc owns the caveats). Registers NOTHING when off — the divert sites see
// `None` for its resource and the render graph never gains the node.
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.
if crate::static_merge::census_enabled() {