diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index ef177afd..c9ca3045 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -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 diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index 0dd1e4c2..6f31c533 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -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, ); diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index d48f7533..677c76e3 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -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; diff --git a/crates/benilla-world/src/model_fade.rs b/crates/benilla-world/src/model_fade.rs index 28f4b5b0..3ffbe259 100644 --- a/crates/benilla-world/src/model_fade.rs +++ b/crates/benilla-world/src/model_fade.rs @@ -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. diff --git a/crates/benilla-world/src/shaders.rs b/crates/benilla-world/src/shaders.rs index 3fc215e9..4384b628 100644 --- a/crates/benilla-world/src/shaders.rs +++ b/crates/benilla-world/src/shaders.rs @@ -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"); } } diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl new file mode 100644 index 00000000..c7a14ba9 --- /dev/null +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -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 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, + light_diffuse: vec4, + light_sun: vec4, + light_spec: vec4, + fog_color: vec4, + fog_params: vec4, + sh_c10_r: vec4, + sh_c10_g: vec4, + sh_c10_b: vec4, + sh_c13_r: vec4, + sh_c13_g: vec4, + sh_c13_b: vec4, + sh_c16: vec4, + _water: array, 4>, + grade: vec4, + wmo_fog_color: vec4, + wmo_fog_params: vec4, + point_count: vec4, + points: array, 512>, +} +@group(0) @binding(1) var wow_light: WowLight; + +// Per-cell state (static_gx/render.rs `cell_layout`). +struct GxCell { + origin: vec4, // xyz = the bake's recentring origin (0974): world = vertex + origin +} +@group(1) @binding(0) var 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 recs: array>; +@group(1) @binding(2) var tex_array: texture_2d_array; +// 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) -> vec3 { + let l2 = dot(v, v); + return select(vec3(0.0), normalize(v), l2 > 1e-12); +} + +fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { + let count = u32(wow_light.point_count.x); + var sel = array(0u, 0u, 0u); + var sd = array(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(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, + @location(1) normal: vec3, + @location(2) uv: vec2, + @location(3) word: u32, + @location(4) anchor: vec3, + // 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, +} + +struct GxVsOut { + @builtin(position) position: vec4, + @location(0) world_position: vec4, + @location(1) world_normal: vec3, + @location(2) uv: vec2, + @location(3) @interpolate(flat) word: u32, + @location(4) point_lit: vec3, + @location(5) color: vec4, +} + +@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(0.0, 0.0, 2.0, 1.0); + out.world_position = vec4(0.0); + out.world_normal = vec3(0.0, 1.0, 0.0); + out.uv = vec2(0.0); + out.word = v.word; + out.point_lit = vec3(0.0); + out.color = vec4(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(world, 1.0); + let view_rot = mat3x3( + 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(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(0.0); + } else { + out.point_lit = point_light_sum(world, v.normal, v.anchor); + } + return out; +} + +@fragment +fn fragment(in: GxVsOut) -> @location(0) vec4 { + // The hard farclip wall — per-pixel planar eye-Z, same plane as the entity path. + let eye_z = -(view.view_from_world * vec4(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(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(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(0.0), + vec3(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(1.0), in.color, has_vc); + let folded = select(base.rgb, base.rgb * vc.rgb, has_vc); + var rgb: vec3; + 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(16.0 / 255.0) + window_mid * ndotl, + vec3(0.0), + vec3(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(1.0); + if (class_trans) { + lit_wmo_interior = mix(vec3(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(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(0.0), + vec3(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(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(0.0), + vec3(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(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( + 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(0.0), vec3(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(0.0), vec3(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(rgb, 1.0); +} diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs new file mode 100644 index 00000000..4589888c --- /dev/null +++ b/crates/benilla-world/src/static_gx/bake.rs @@ -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, mut meshes: ResMut>) { + 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) -> 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 = 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 = Vec::new(); + let mut anchors: Vec<[f32; 3]> = Vec::new(); + let mut indices: Vec = Vec::new(); + let mut draws: Vec = 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::::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, 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::::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![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 = 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::::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]); + } +} diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs new file mode 100644 index 00000000..2231f1d5 --- /dev/null +++ b/crates/benilla-world/src/static_gx/cull.rs @@ -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 = 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 { + 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, + debug: Res, + view: Res, + cam: Query< + ( + &GlobalTransform, + &Projection, + &bevy::camera::primitives::Frustum, + ), + With, + >, + windows: Res, + claim: Res, + 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 = 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 = 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); + } +} diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs new file mode 100644 index 00000000..163eb5b2 --- /dev/null +++ b/crates/benilla-world/src/static_gx/mod.rs @@ -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 = 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 = 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 = 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 = 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 = 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, + 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>, + /// 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>, + 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, + /// `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, +} + +/// 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, + 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, + /// 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, + 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, + aabb: Option, + cutout: Handle, + blend: Handle, + blend_mode: ModelBlend, + geometry: Arc, +} + +/// 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, 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, + dirty: bool, + last_change: u32, + /// The cell's fader placements (B2), keyed by placement uniqueId. Empty on WMO regions. + faders: bevy::platform::collections::HashMap, + /// The published kill bitmap no longer matches the fader states (a re-bake reassigned + /// 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, +} + +/// 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, + /// 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, + /// 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, + 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>, + 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, + /// `Some` = a FADER batch (B2, decision 1431): the exile seed. `None` on never-fade + /// batches and the whole WMO lane. + pub fade: Option>, +} + +/// The fader half of [`GxBatch`] (B2): the placement identity + the respawn payload the +/// exile protocol spawns the feather-band entity from. Everything is a handle clone of what +/// the production assembler holds at the divert point, so the exiled entity IS the entity +/// 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, + pub aabb: Option, + pub cutout: Handle, + pub blend: Handle, +} + +/// 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, + /// 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::().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, + at: Vec3, + texture: Option>, + 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 { + 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()); + } +} diff --git a/crates/benilla-world/src/static_gx/render.rs b/crates/benilla-world/src/static_gx/render.rs new file mode 100644 index 00000000..bd8067c6 --- /dev/null +++ b/crates/benilla-world/src/static_gx/render.rs @@ -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, + pub texture: Option>, + pub cutout: bool, + pub two_sided: bool, + #[allow(dead_code)] // bake-side bookkeeping; the node draws by index range alone + pub vertex_range: Range, + /// The WMO group this item belongs to (`None` on cell items) — the range-selection key: + /// a run never crosses a group boundary, so the flood's verdict selects whole runs. + pub group: Option, + /// The authored batch order (the coplanar-MOBA clip-z nudge; 0 on cell items). + pub order: u16, + /// The MOMT SIDN night-glow colour (gamma bytes; zero on cell items). + pub sidn: [u8; 3], +} + +/// One baked cell (or WMO region), published by the main-world flush. +#[derive(Clone)] +pub(crate) struct GxCellDraw { + pub mesh: Handle, + /// 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, + /// 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, + /// 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, Without)>, +) { + 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, + /// This frame's per-group admission per region (indexed by absolute group index): the + /// portal flood's verdict collapsed to CPU range selection — the node draws exactly the + /// runs whose group bit is set. + pub visible_wmos: Vec<(Entity, Vec)>, +} + +/// A texture-dimension class within one cell: one `texture_2d_array` + the members feeding it. +struct GxClassGpu { + array: Texture, + bind_group: BindGroup, + /// Copies still to encode (source GpuImage's texture, destination layer). Drained by the + /// node's encoder on its next run; the cell draws only once every class is fully copied. + pending: Vec<(Texture, u32)>, + mip_count: u32, + size: Extent3d, +} + +/// One coalesced draw run: adjacent bake items sharing (class, pipeline bucket, group). +struct GxRun { + class: usize, + cutout: bool, + two_sided: bool, + index_range: Range, + /// 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, +} + +/// A cell's assembled GPU state, cached across frames; rebuilt when the bake (mesh handle) +/// changes. +struct GxCellGpu { + mesh: AssetId, + classes: Vec, + record_table: Buffer, + #[allow(dead_code)] // held alive for the bind groups that reference it + cell_uniform: Buffer, + runs: Vec, + /// 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, +} + +#[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) { + let view_layout = BindGroupLayoutDescriptor::new( + "static_gx_view_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::VERTEX_FRAGMENT, + ( + uniform_buffer::(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, + mut cache: ResMut, + mut pipes: ResMut, + pipeline_cache: Res, + render_device: Res, + render_queue: Res, + asset_server: Res, + images: Res>, + views: Query, +) { + 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 = + 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, +) -> Option { + // Class by (size, format, mips); assign layers DEDUPED by texture — many items share one + // texture (Stormwind's region carries 3,042 items over a few hundred distinct BLPs), and + // per-item layers blew past the D2-array layer limit the moment a city root baked. A + // layer's content depends only on its source texture, so sharing is exact. A class that + // still fills to the device limit overflows into a sibling class with the same key. + let max_layers = render_device.limits().max_texture_array_layers as usize; + type ClassAcc<'a> = (Extent3d, TextureFormat, u32, Vec<&'a GpuImage>); + let mut classes: Vec> = Vec::new(); + let mut item_class_layer: Vec<(u32, u32)> = vec![(0, 0); draw.draws.len()]; + let mut assigned: HashMap, (u32, u32)> = HashMap::default(); + for (i, item) in draw.draws.iter().enumerate() { + let Some(tex) = item.texture else { continue }; + if let Some(&cl) = assigned.get(&tex) { + item_class_layer[i] = cl; + continue; + } + // Every member texture must be resident to class the region at all. + let g = images.get(tex)?; + let sz = g.texture.size(); + let key = ( + Extent3d { + width: sz.width, + height: sz.height, + depth_or_array_layers: 1, + }, + g.texture.format(), + g.texture.mip_level_count(), + ); + let ci = classes + .iter() + .position(|(s, f, m, mem)| { + (*s, *f, *m) == (key.0, key.1, key.2) && mem.len() < max_layers + }) + .unwrap_or_else(|| { + classes.push((key.0, key.1, key.2, Vec::new())); + classes.len() - 1 + }); + let layer = u32::try_from(classes[ci].3.len()).unwrap(); + classes[ci].3.push(g); + let cl = (u32::try_from(ci).unwrap(), layer); + assigned.insert(tex, cl); + item_class_layer[i] = cl; + } + // 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 = 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 = 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, + pipeline_cache: Res, + render_device: Res, + view_uniforms: Res, + light: Option>, +) { + 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::(); + if gx.visible.is_empty() && gx.visible_wmos.is_empty() { + return Ok(()); + } + let cache = world.resource::(); + let pipes = world.resource::(); + let pipeline_cache = world.resource::(); + let Some(view_bind) = world.get_resource::() else { + return Ok(()); + }; + let meshes = world.resource::>(); + let allocator = world.resource::(); + // Cells draw whole; a WMO region draws only the runs whose group the flood admitted + // this frame (the selection rides beside the gpu state — `None` = draw everything). + let mut resolved: Vec<(&GxCellGpu, &GxCellDraw, Option<&Vec>)> = Vec::new(); + for cell in &gx.visible { + if let (Some(gpu), Some(draw)) = (cache.cells.get(cell), gx.cells.get(cell)) { + resolved.push((gpu, draw, None)); + } + } + for (entity, sel) in &gx.visible_wmos { + if let (Some(gpu), Some(draw)) = (cache.wmos.get(entity), gx.wmos.get(entity)) { + resolved.push((gpu, draw, Some(sel))); + } + } + if resolved.is_empty() { + return Ok(()); + } + // Encode any outstanding layer copies OUTSIDE the pass. `pending` drains through + // interior mutability-free re-borrow: the cache is not mutable here, so copies are + // (re-)encoded from a snapshot; encoding the same copy twice is idempotent (same src → + // same dst), and PrepareResources rebuilds cells only on bake changes, so the window + // is one frame. Simplicity over a drain flag — B2 revisits if it ever shows. + { + let encoder = render_context.command_encoder(); + for (gpu, _, _) in &resolved { + for class in &gpu.classes { + for (src, layer) in &class.pending { + for mip in 0..class.mip_count.min(src.mip_level_count()) { + let mut dst = class.array.as_image_copy(); + dst.mip_level = mip; + dst.origin.z = *layer; + let mut s = src.as_image_copy(); + s.mip_level = mip; + encoder.copy_texture_to_texture( + s, + dst, + Extent3d { + width: (class.size.width >> mip).max(1), + height: (class.size.height >> mip).max(1), + depth_or_array_layers: 1, + }, + ); + } + } + } + } + } + // 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::::default(), + ExtractComponentPlugin::::default(), + )); + app.init_resource::(); + 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::() + .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::>(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, mut out: ResMut) { + 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); +} diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index 6e6769b1..3b4d8db7 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -579,9 +579,14 @@ fn stream_terrain( mut load_progress: Option>, // Bundled for the same 16-param reason as `asset_stores`: the two stream-batch accumulators // the map-change drop must clear. - batchers: (ResMut, ResMut), + batchers: ( + ResMut, + ResMut, + // `None` unless `WOW_STATIC_GX=1` (the collector only exists armed — 1429). + Option>, + ), ) { - 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, mut progress: Option>, mut activity: ResMut, - // 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, ResMut), + batchers: ( + ResMut, + ResMut, + Option>, + ), ) { - 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; diff --git a/crates/benilla-world/src/terrain_stream/merge.rs b/crates/benilla-world/src/terrain_stream/merge.rs index e26668d4..2231f2ee 100644 --- a/crates/benilla-world/src/terrain_stream/merge.rs +++ b/crates/benilla-world/src/terrain_stream/merge.rs @@ -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 = std::sync::OnceLock::new(); *ON.get_or_init(|| std::env::var_os("WOW_MERGE_FADERS").is_some()) diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index a64d386a..60c4b7fa 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -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 diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index a079285d..6570273f 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -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>, ); #[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 diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index a8fe0439..00230c20 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -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::(); 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() {