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