diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index 2d09e520..e1fb0a42 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -56,7 +56,7 @@ pub enum ParticleShape { /// How a particle batch blends (file `blendingType` @ +0x28 = the M2/EGxBlend enum). Additive is the /// flame/glow case (the campfire is `4`); the rest fold to alpha for now (mod/mod2x are rare for the /// props we render and will get their own path when a model needs them). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ParticleBlend { /// `3` NoAlphaAdd / `4` Add — `(SRC_ALPHA, ONE)`. Flames, glows, embers. No depth write/sort. Add, diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index 3182ad05..287d0e20 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -155,3 +155,151 @@ fn hiding_a_frame_does_not_reopen_the_gate() { "visibility does not move rects, so the gate stays closed" ); } + +/// The hover re-enter loop must be FREE while the content is unchanged. +/// +/// `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` every frame while the tooltip is the +/// button's own — faithful, and UNTHROTTLED in 1.12 (its `updateTooltip` throttle is commented +/// out; `BagFrame.xml`'s `BenillaBagSlot_OnUpdate` ships the same loop). So a bag hover clears and +/// rebuilds the SAME tooltip 60×/sec, and the engine must absorb that: identical content re-derives +/// an identical model, so the measure cache re-validates on its content key and the gate stays shut. +/// +/// This is the regression gate for the live report (a bag hover cost +10 CPU ms/frame, ~2 full +/// arena solves + a re-shape of every line, every frame): `clear_content` wiped +/// `RegionData::measured` — an invalidation the content-hash key already performs — so every +/// re-enter re-measured every line, and the non-empty measure list forced a second full resolve +/// that the gate could never close over. +/// +/// It drives the app's real per-frame order (`ui_script::extract::drive_script`): Lua tick → +/// resolve → measure round-trip → resolve. +#[test] +fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local owner = CreateFrame("Button", "Slot") + owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40) + tt = CreateFrame("GameTooltip", "TT") + -- One bag-slot OnEnter: the clear (SetOwner) + the content rebuild, verbatim in shape. + function reenter() + TT:SetOwner(Slot, "ANCHOR_RIGHT") + TT:AddLine("Small Shield", 0, 1, 0) + TT:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1) + TT:AddLine("85 Armor") + -- A WRAP-flagged line: `clear_content` still drops the wrap-pinned width, which feeds + -- the measure key, so the re-pin at append time has to restore it byte-identically or + -- this line alone re-shapes forever (the real item tooltip's long green "Use:" line). + TT:AddLine("Restores 243 health over 21 sec.", 0, 1, 0, 1) + TT:AddLine("Durability 45 / 45") + TT:Show() + end + "#, + ) + .expect("setup"); + + // The host's font engine: every distinct string has one deterministic size. Counts every + // string it is asked to shape, so the test can assert on shaping work directly. + let sizes: &[(&str, f32, f32)] = &[ + ("Small Shield", 80.0, 14.0), + ("Shield", 50.0, 12.0), + ("Off Hand", 45.0, 12.0), + ("85 Armor", 60.0, 12.0), + ("Restores 243 health over 21 sec.", 118.0, 24.0), + ("Durability 45 / 45", 96.0, 12.0), + ]; + // One frame of `drive_script`: tick (the re-enter) → resolve → measure round-trip → resolve. + // Returns how many strings the font engine was asked to shape this frame. + let frame = |s: &mut UiScript| -> usize { + s.run("reenter()").expect("re-enter"); + s.resolve(); + let reqs = s.fontstrings_needing_measure(); + let shaped = reqs.len(); + if !reqs.is_empty() { + let answers: Vec<(u32, f32, f32, u64)> = reqs + .iter() + .map(|r| { + let (w, h) = sizes + .iter() + .find(|(t, _, _)| *t == r.text) + .map(|&(_, w, h)| (w, h)) + .unwrap_or_else(|| panic!("unexpected string measured: {:?}", r.text)); + (r.id, w, h, r.key) + }) + .collect(); + s.set_measured_text(&answers); + s.resolve(); + } + shaped + }; + + // Settle: the first frames legitimately shape the five strings and re-solve as the auto-size + // pre-pass converges on the fresh measures. + for _ in 0..4 { + frame(&mut s); + } + + // Steady state: the hover is parked on an item whose tooltip has not changed. + let solves_before = solves(&s); + let mut shaped = 0; + for _ in 0..10 { + shaped += frame(&mut s); + } + + assert_eq!( + shaped, 0, + "a re-enter with identical content must re-shape NO text: the measure cache keys on \ + content, so rebuilding the same lines re-validates it" + ); + assert_eq!( + solves(&s), + solves_before, + "a re-enter with identical content must not reopen the layout gate" + ); + + // The gate is shut because nothing MOVED — not because the tooltip went stale. Changed content + // must still re-measure exactly its own line, and a measure that resizes the auto-sized plate + // (this one overtakes the double line as the widest) must reopen the gate. + s.run( + r#"function reenter() + TT:SetOwner(Slot, "ANCHOR_RIGHT") + TT:AddLine("Small Shield", 0, 1, 0) + TT:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1) + TT:AddLine("85 Armor") + TT:AddLine("Restores 243 health over 21 sec.", 0, 1, 0, 1) + TT:AddLine("Durability 44 / 45") + TT:Show() + end"#, + ) + .expect("damage the shield"); + // Wider than the 120 the double line contributes, so the plate itself has to grow. + let sizes: &[(&str, f32, f32)] = &[("Durability 44 / 45", 150.0, 12.0)]; + s.run("reenter()").expect("re-enter"); + s.resolve(); + let reqs = s.fontstrings_needing_measure(); + assert_eq!( + reqs.len(), + 1, + "only the line whose text changed re-measures, got {:?}", + reqs.iter().map(|r| &r.text).collect::>() + ); + assert_eq!(reqs[0].text, "Durability 44 / 45"); + let answers: Vec<(u32, f32, f32, u64)> = reqs + .iter() + .map(|r| { + let (w, h) = sizes + .iter() + .find(|(t, _, _)| *t == r.text) + .map(|&(_, w, h)| (w, h)) + .expect("known string"); + (r.id, w, h, r.key) + }) + .collect(); + s.set_measured_text(&answers); + s.resolve(); + assert!( + solves(&s) > solves_before, + "changed content must reopen the gate" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index aad1d519..6af46419 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -276,9 +276,24 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { if let Some(d) = model.region_data.get_mut(&rh) { d.text = None; d.hidden = true; - d.measured = None; + // `measured` is deliberately KEPT. It is a cache whose validity gate is its own + // content hash ([`RegionData::measure_key`] over text/font/wrap/outline), so wiping + // it here invalidates nothing the key would not — while destroying the one thing that + // makes the hover re-enter loop affordable. That loop + // (`ContainerFrameItemButton_OnUpdate`, unthrottled in 1.12 and shipped verbatim as + // `BagFrame.xml`'s `BenillaBagSlot_OnUpdate`) clears and rebuilds the SAME content + // every frame: keeping the cache lets each rebuilt line re-validate against its own + // key, so nothing re-shapes, the measure list stays empty, its forced second resolve + // never happens, and the layout gate's fingerprint — which exists precisely to + // "absorb idempotent writes for free" (layout.rs) — closes over the whole loop. + // Wiping it cost +10 CPU ms/frame on a live bag hover (measured 13.2 → 23.9 cpu_ms, + // `WOW_CAPTURE=ui-bag` vs `ui-tooltip`); the gate is + // `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`. + // // A wrap-pinned width (the layout pass's wrap consumer) is per-content — the next - // content re-derives it. + // content re-derives it. It feeds the measure key, so `append_line`'s wrap re-pin is + // what keeps a wrap-flagged line's key stable across the clear (gated by the same + // test, whose stack carries one). d.size = None; } } diff --git a/crates/benilla/assets/shaders/wow_effect.wgsl b/crates/benilla/assets/shaders/wow_effect.wgsl new file mode 100644 index 00000000..e9cf66fb --- /dev/null +++ b/crates/benilla/assets/shaders/wow_effect.wgsl @@ -0,0 +1,160 @@ +// WoW effect-lane shader — the shared effect stream's own pipeline (decisions 0732 P1/P2, +// 0733: particles, ribbons, the decal family, water foam, precipitation). The fragment IS +// wow_particle.wgsl's combine, freed of the StandardMaterial shell: the whole product is +// authored gamma bytes — texture (Rgba8Unorm, deliberately NOT decoded on sample, the house +// invariant) × the raw authored track colour riding the vertex colour. Multiplying them in +// gamma space IS the vanilla math; the output is raw gamma — the buffer holds bytes end-to-end +// and the frame's one decode lives in the FFXGlow combine (GAMMA LANE, 0161). +// +// Blend variants (pipeline shader defs, one per EffectBlend — 0733 §4): +// - BLEND_ADD: the premultiplied-alpha fold in GAMMA space (rgb ×= α, α = 0 under the +// (One, 1−srcα) blend state = pure addition) — the reference adds `src_g·α` bytes; +// premultiplying after a linear conversion inflates every soft edge by α^(1/2.2) +// (decision 0160, the fat glow-disc family). +// - BLEND_ALPHA: straight (rgb, α) under standard alpha blending. +// - BLEND_OPAQUE: (rgb, 1) with blending off — the alpha-ignoring shape the material path's +// AlphaMode::Opaque produced. +// - BLEND_MULTIPLY: premultiplied (rgb·α, α) under (Dst, 1−srcα) — `dst·lerp(1, rgb, α)`, the +// blob shadow's modulate-with-fade (and ModelBlend::Mod at α = 1). +// - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst` — rain's state; reads no alpha. + +#import bevy_render::view::View + +// The shared global light/fog buffer (`lighting::global_light`) — the SAME layout every world +// shader mirrors (see wow_model.wgsl's declaration, the canonical copy). This lane reads only +// the fog rows; the rest is layout ballast. +struct WowLight { + light_ambient: vec4, + light_diffuse: vec4, + light_sun: vec4, + light_spec: vec4, + fog_color: vec4, // rgb row-7 fog (gamma); w = enable (>0.5) + fog_params: vec4, // x=start y=end z=linear-lighting A/B flag w=farclip wall + sh_c10_r: vec4, + sh_c10_g: vec4, + sh_c10_b: vec4, + sh_c13_r: vec4, + sh_c13_g: vec4, + sh_c13_b: vec4, + sh_c16: vec4, + _water: array, 4>, + grade: vec4, +}; + +@group(0) @binding(0) var view: View; +@group(1) @binding(0) var effect_texture: texture_2d; +@group(1) @binding(1) var effect_sampler: sampler; +@group(1) @binding(2) var wow_light: WowLight; +// Per-draw params (a dynamic offset into the canonical trio): x = the fog COLOUR policy +// (0 = off — the "unfogged" file flag 0x8; 1 = scene — the ordinary day-night fog; 2 = BLACK — +// an Add-blend emitter fades under a veil instead of gaining grey, the same per-blend fog +// table M2 batches take, `0x70baf0`). y = forced-fog mode with zw = its start/end (the rain +// lanes — unused until precipitation joins the lane in slice P2, kept so the struct is P2's +// already). +@group(1) @binding(3) var wow_ext_params: vec4; + +// The rain pass's forced fog colour: 0x80808080 → grey (render-state 0x0d). +const RAIN_FOG_GREY: vec3 = vec3(0.50196078, 0.50196078, 0.50196078); + +struct Vertex { + // CAMERA-RELATIVE (0733 §2): prepare rebased the stream against this view's own + // world_position, so the big-coordinate cancellation never reaches the matrix product — + // absolute-coordinate verts through `clip_from_world` shear thin geometry apart far from + // the origin (the precip module's empirically-learned law at ~9000 yd). + @location(0) position: vec3, + @location(1) uv: vec2, + @location(2) color: vec4, // raw authored gamma RGBA; α is the blend weight +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, + // View-space depth (+ in front of the camera) — the fog/farclip planar eye-Z, exact + // because the camera-relative view transform is pure rotation. + @location(2) view_z: f32, +}; + +@vertex +fn vertex(v: Vertex) -> VertexOutput { + var out: VertexOutput; + // view_from_world = [R | −R·cam]; for a cam-relative point the translation column is + // exactly the subtraction prepare already did, so only the rotation remains. + let view_pos = mat3x3( + view.view_from_world[0].xyz, + view.view_from_world[1].xyz, + view.view_from_world[2].xyz, + ) * v.position; + out.clip_position = view.clip_from_view * vec4(view_pos, 1.0); + out.uv = v.uv; + out.color = v.color; + out.view_z = -view_pos.z; + return out; +} + +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + // `$WOW_PARTICLE_FLAT` (B16 instrument): solid magenta, no inputs — see render.rs. +#ifdef WOW_PARTICLE_FLAT + return vec4(1.0, 0.0, 1.0, 1.0); +#else + // HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl for the law. The + // reference has ONE projection far plane clipping the whole detailed world including the + // effect family; ours sits at ~3000 yd so every world shader emulates the wall per-pixel. + // Missing it here was bug B39. No pop: the scene fog end is `min(fog_end, farclip)`, so a + // quad reaching the wall has already faded to the fog colour — toward BLACK for an + // Add-blend emitter, i.e. to nothing under `One` blend. + if (wow_light.fog_params.w > 0.0 && in.view_z > wow_light.fog_params.w) { + discard; + } + // The gamma-space product — texture bytes × raw authored vertex colour, exactly what the + // reference multiplies. + let c = textureSample(effect_texture, effect_sampler, in.uv) * in.color; + var rgb = c.rgb; + // The scene day-night fog — byte-verified ON the particle path (wow-re + // part-scene-multipliers.md): the reference runs the SAME per-vertex linear fog as world + // geometry — same start/end, same day-night colour, applied in gamma space BEFORE blend, + // with NO additive special-case. The blend equation then does the whole night split by + // itself. Missing fog was the "night smoke glows" bug. (Planar eye-Z, like terrain fog.) + if (wow_light.fog_color.w > 0.5 && wow_ext_params.x > 0.5) { + let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); + let factor = clamp((wow_light.fog_params.y - in.view_z) / denom, 0.0, 1.0); + // The fog COLOUR follows the policy in wow_ext_params.x (see its declaration). + var fog_rgb = wow_light.fog_color.xyz; + if (wow_ext_params.x > 1.5 && wow_ext_params.x < 2.5) { fog_rgb = vec3(0.0); } + else if (wow_ext_params.x > 2.5 && wow_ext_params.x < 3.5) { fog_rgb = vec3(1.0); } + else if (wow_ext_params.x > 3.5) { fog_rgb = RAIN_FOG_GREY; } + rgb = mix(fog_rgb, rgb, factor); + } + // The FORCED fog (rain weather): grey-0.5 over the params' own start/end, regardless of + // the scene fog state — under Mod2x the grey is neutral, so this IS the streak/patter + // distance fade (rf-weather-render Q3). + if (wow_ext_params.y > 0.5) { + let denom = max(wow_ext_params.w - wow_ext_params.z, 0.001); + let factor = clamp((wow_ext_params.w - in.view_z) / denom, 0.0, 1.0); + rgb = mix(RAIN_FOG_GREY, rgb, factor); + } +#ifdef BLEND_ADD + // GAMMA LANE (0161): gamma-premultiplied, raw out — N stacked faint quads SUM in gamma + // like the reference's bytes. α = 0 turns (One, 1−srcα) into pure addition. + return vec4(rgb * c.a, 0.0); +#else +#ifdef BLEND_OPAQUE + return vec4(rgb, 1.0); +#else +#ifdef BLEND_MULTIPLY + // The modulate family: premultiplied for bevy's Multiply state — `dst·lerp(1, rgb, α)` + // (the blob shadow's fade-riding darken; `ModelBlend::Mod` at α = 1). + return vec4(rgb * c.a, c.a); +#else +#ifdef BLEND_MOD2X + // `(Dst, Src)` reads no alpha (0528's no-alpha law). + return vec4(rgb, 1.0); +#else + return vec4(rgb, c.a); +#endif +#endif +#endif +#endif +#endif +} diff --git a/crates/benilla/assets/shaders/wow_particle.wgsl b/crates/benilla/assets/shaders/wow_particle.wgsl deleted file mode 100644 index bd6fc8f2..00000000 --- a/crates/benilla/assets/shaders/wow_particle.wgsl +++ /dev/null @@ -1,133 +0,0 @@ -// WoW particle/ribbon combine — the effect family's output stage, sibling of wow_model.wgsl's. -// -// The whole quad product is authored gamma bytes: the texture (Rgba8Unorm — deliberately NOT -// decoded on sample, the house invariant) × the raw authored track colour riding ATTRIBUTE_COLOR. -// Multiplying them in gamma space IS the vanilla math; the output is raw gamma — the buffer -// holds bytes end-to-end and the frame's one decode lives in the FFXGlow combine (GAMMA LANE, -// 0161; supersedes 0152's per-shader output decode). -// -// Why StandardMaterial couldn't do this: its unlit path assumes the sampled texture is already -// linear, so the texture term skipped the decode and showed gamma-BRIGHTENED — the bonfire's -// dark authored smoke reading pale and "a bit too thick" even after the vertex colours were -// fixed (0150). Alpha is a blend weight — never encoded, passes straight through. -// -// `main_pass_post_lighting_processing` supplies the Add-mode premultiply (rgb ×= a, a = 0 under -// the PREMULTIPLY_ALPHA def) exactly as the StandardMaterial fragment did — without it, additive -// quads would darken the scene behind them under the (One, 1−srcα) blend state. - -#import bevy_pbr::{ - pbr_fragment::pbr_input_from_standard_material, - pbr_functions::main_pass_post_lighting_processing, - forward_io::{VertexOutput, FragmentOutput}, - mesh_view_bindings::view, -} - -// The shared global light/fog buffer (`lighting::global_light`) — the SAME layout every world -// shader mirrors (see wow_model.wgsl's declaration, the canonical copy). Particles read only the -// fog rows; the rest is layout ballast. -struct WowLight { - light_ambient: vec4, - light_diffuse: vec4, - light_sun: vec4, - light_spec: vec4, - fog_color: vec4, // rgb row-7 fog (gamma); w = enable (>0.5) - fog_params: vec4, // x=start y=end z=linear-lighting A/B flag w=farclip wall - sh_c10_r: vec4, - sh_c10_g: vec4, - sh_c10_b: vec4, - sh_c13_r: vec4, - sh_c13_g: vec4, - sh_c13_b: vec4, - sh_c16: vec4, - _water: array, 4>, - grade: vec4, -}; -@group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; -// Per-material params: x = the fog COLOUR policy (0.0 = off — water foam, its reference render -// state sets FOG off; 1.0 = scene — the ordinary day-night fog, particles/ribbons on -// opaque/alpha-key/alpha blend; 2.0/3.0/4.0 = BLACK/WHITE/GREY — the same per-blend fog table M2 -// batches take, wow-re rf-weather-emission-timeline ROUND 4: an Add-blend emitter fogs toward -// black so it fades under a veil instead of gaining grey). y = FORCED-fog mode (the rain weather -// pass, byte state 0x0a/0x0b/0x0d): fog toward grey-0.5 over the fixed z..w window, ignoring the -// scene fog entirely — under the rain pass's Mod2x blend grey-0.5 is NEUTRAL (2·0.5·dst = dst), so -// this forced fog IS the streak's distance fade (wow-re rf-weather-render.md). zw = start/end. -@group(#{MATERIAL_BIND_GROUP}) @binding(91) var wow_ext_params: vec4; - -// The rain pass's forced fog colour: 0x80808080 → (0.502, 0.502, 0.502) grey (render-state 0x0d). -const RAIN_FOG_GREY: vec3 = vec3(0.50196078, 0.50196078, 0.50196078); - -@fragment -fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput { - // `$WOW_PARTICLE_FLAT` (B16 instrument): solid magenta, no inputs — see material.rs. -#ifdef WOW_PARTICLE_FLAT - var flat_out: FragmentOutput; - flat_out.color = vec4(1.0, 0.0, 1.0, 1.0); - return flat_out; -#else - // HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl for the law. The reference - // has ONE projection far plane and it clips the whole detailed world: terrain, models, liquid AND - // the effect family, which is drawn by the same scene pass through the same matrix. benilla - // emulates that plane per-pixel (our projection far sits at ~3000 yd so the coarse WDL horizon can - // draw behind the wall) — and every world shader took the emulation except this one. That gap IS - // bug B39: campfires, braziers and portal effects kept drawing at any distance, over a horizon - // where the terrain beneath them had already been discarded by the very same wall. - // - // Ribbons ride this shader too (`ribbons.rs` shares `WowParticleMaterial`), so trails are walled - // by the same discard. `fog_params.w` = farclip (0 ⇒ disabled). No pop: the scene fog end is - // `min(fog_end, farclip)`, so a quad reaching the wall has already faded to the fog colour — - // toward BLACK for an Add-blend emitter (`wow_ext_params.x`), i.e. to nothing under `One` blend. - if (wow_light.fog_params.w > 0.0) { - let clip_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; - if (clip_z > wow_light.fog_params.w) { - discard; - } - } - var pbr_input = pbr_input_from_standard_material(in, is_front); - // base_color = white base × tex (gamma bytes, Unorm) × vertex colour (raw authored track - // values) — the gamma-space product, exactly what the reference multiplies. - let c = pbr_input.material.base_color; - var rgb = c.rgb; - // The scene day-night fog — byte-verified ON the particle path (wow-re - // part-scene-multipliers.md, their 3620c287): the reference synthesizes an M2 material per - // emitter and runs the SAME per-vertex linear fog as world geometry — same start/end, same - // day-night colour, applied in gamma space BEFORE blend, with NO additive special-case. The - // blend equation then does the whole night split by itself: alpha smoke fogs toward the - // near-black night sky (the faint dark smudge) while additive flame still adds full bright. - // Missing fog was the "night smoke glows" bug. (Planar eye-Z, like terrain/model fog.) - if (wow_light.fog_color.w > 0.5 && wow_ext_params.x > 0.5) { - let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; - let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); - let factor = clamp((wow_light.fog_params.y - eye_z) / denom, 0.0, 1.0); - // The fog COLOUR follows the policy in wow_ext_params.x (see its declaration above): scene - // by default, BLACK/WHITE/GREY for an Add/Mod/Mod2x-blend emitter. - var fog_rgb = wow_light.fog_color.xyz; - if (wow_ext_params.x > 1.5 && wow_ext_params.x < 2.5) { fog_rgb = vec3(0.0); } - else if (wow_ext_params.x > 2.5 && wow_ext_params.x < 3.5) { fog_rgb = vec3(1.0); } - else if (wow_ext_params.x > 3.5) { fog_rgb = RAIN_FOG_GREY; } - rgb = mix(fog_rgb, rgb, factor); - } - // The FORCED fog (rain weather): grey-0.5 over the material's own start/end, regardless of - // the scene fog state — the Mod2x streak/splash distance fade. - if (wow_ext_params.y > 0.5) { - let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; - let denom = max(wow_ext_params.w - wow_ext_params.z, 0.001); - let factor = clamp((wow_ext_params.w - eye_z) / denom, 0.0, 1.0); - rgb = mix(RAIN_FOG_GREY, rgb, factor); - } - var out: FragmentOutput; - // ADD-mode quads (flames, sparks — the PREMULTIPLY_ALPHA def is set by the pipeline for - // AlphaMode::Add): fold the alpha weight into the colour in GAMMA space — the reference adds - // `src_g·α` bytes; premultiplying after the linear conversion (Bevy's stock helper) inflates - // every soft edge by α^(1/2.2) (decision 0160, the fat glow-disc family). Alpha handed to the - // helper as 1.0 so its own (linear) premultiply is a no-op and the blend adds our value. -#ifdef PREMULTIPLY_ALPHA - // GAMMA LANE (0161): gamma-premultiplied, raw out — N stacked faint quads now SUM in gamma - // like the reference's bytes (the wagon/pyre glow-ball stacking, the daylight flame cores). - let final_color = vec4(rgb * c.a, 1.0); -#else - let final_color = vec4(rgb, c.a); -#endif - out.color = main_pass_post_lighting_processing(pbr_input, final_color); - return out; -#endif -} diff --git a/crates/benilla/src/blob_shadow.rs b/crates/benilla/src/blob_shadow.rs index 89428d7c..188c43df 100644 --- a/crates/benilla/src/blob_shadow.rs +++ b/crates/benilla/src/blob_shadow.rs @@ -1,6 +1,6 @@ //! The **unit blob shadow** — the soft dark oval under every unit (the player, every NPC, every //! creature), the reference's per-frame shadow pass rebuilt on the shared surface-decal projector -//! ([`crate::decal`]). +//! ([`crate::decal`]), drawn on the shared effect stream (0733). //! //! **The byte-verified mechanism** (wow-re `unit-blob-shadow.md`, a §5 cross-check; the "cloud //! shadow" label on `0x6d7920` was corrected — it IS the unit shadow draw): @@ -26,42 +26,48 @@ //! draws, six bit-stable box sizes, HumanMale 0.9134 × 1.0805 yd permanently, Walk/Run extents //! never appear). Full extents, no missing half/scale factor — the standing size IS the law. //! - **Appearance**: multiplicative darken — `GL_DST_COLOR/GL_ZERO` with the fade riding the -//! combine, which is exactly Bevy's [`AlphaMode::Multiply`] (`dst × lerp(1, src, α)`). Vertex -//! diffuse is **white** with α = the model's fade alpha (`[model+0x180]` — spawn/despawn fades + -//! the self first-person fade ride into the shadow); the darkness lives in the texture RGB. -//! Unlit, no fog, no depth write. The texture loads as the default `WorldArt` `Rgba8Unorm`, so -//! the modulate multiplies raw bytes in the gamma lane — the reference's own arithmetic (0161). +//! combine, which is exactly the lane's `EffectBlend::Multiply` (`dst × lerp(1, src, α)`). +//! Vertex diffuse is **white** with α = the model's fade alpha (`[model+0x180]` — spawn/despawn +//! fades + the self first-person fade ride into the shadow); the darkness lives in the texture +//! RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt` +//! `Rgba8Unorm`, so the modulate multiplies raw bytes in the gamma lane — the reference's own +//! arithmetic (0161). //! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are //! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays //! that role here. No dead/mount/kind test exists on the draw path; **which** objects register //! for shadows is an open RE item (`HANDOFF(-> object-layer)`) — v1 policy: every Player/Unit //! entity with a built animated model (GameObjects/doodads excluded). //! -//! One decal entity per unit, its mesh rebuilt only when the inputs move ([`ShadowKey`]) — an idle -//! unit costs a key compare per frame. +//! One shadow record per unit, its projected triangles rebuilt only when the inputs move +//! ([`ShadowKey`]) and pushed onto the effect stream every shown frame — an idle unit costs a +//! key compare plus one memcpy of its cached slice. (The stream has no per-draw frustum cull; +//! an off-screen shadow's triangles are vertex-clipped GPU-side — dozens of ~50-vert slices, +//! below any ledger line.) use avian3d::prelude::Collider; use benilla_assets::ModelAnimations; use benilla_protocol::EntityKind; -use bevy::camera::primitives::Aabb; use bevy::ecs::entity::{EntityHashMap, EntityHashSet}; use bevy::prelude::*; use crate::collision::GroundDecalSurface; use crate::creature_anim::AnimData; -use crate::decal::{project_decal, seed_mesh, DecalFrame}; +use crate::decal::{project_decal, DecalFrame}; use crate::model_fade::{fade_alpha, RenderFade}; use crate::net::{NetEntity, SelfPlayer}; -use crate::player::CameraControl; +use crate::particles::buffer::{ + begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex, +}; +use crate::player::{CameraControl, WorldCamera}; use crate::schedule::WorldStage; /// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale /// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground. const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp"; -/// Rasterizer depth bias — the same coplanarity fix as the ring's (`RING_DEPTH_BIAS`, see the -/// rationale there; the reference's own knob is the `shadowBias` cvar, default 0.1 → a polygon -/// offset). Half the ring's bias, so where both decals stack the ring wins the depth tie -/// deterministically. +/// The shadow's ladder rung — sort bias AND rasterizer depth-bias constant (the coplanarity fix, +/// same mechanism as the ring's `RING_DEPTH_BIAS`; the reference's own knob is the `shadowBias` +/// cvar, default 0.1 → a polygon offset). Half the ring's, so where both decals stack the ring +/// draws later deterministically. const SHADOW_DEPTH_BIAS: f32 = 4096.0; /// The byte clamp on the animation box: each corner component is clamped INTO ±5 yd pre-scale /// (`0x6992c0` MAX(−5) / `0x699250` MIN(+5) — a cap on huge authored boxes, never a floor). @@ -70,14 +76,14 @@ const BOX_CLAMP: f32 = 5.0; /// the no-op exit — no shadow. const DEGENERATE_EPS: f32 = 2.384e-7; -/// One unit's shadow decal (a top-level entity — the mesh is world-space, so it must not inherit -/// the owner's transform). Despawned when the owner goes. +/// One unit's shadow record (a top-level entity — no render components; the cached projection +/// rides the effect stream). Despawned when the owner goes. #[derive(Component)] struct BlobShadow { owner: Entity, } -/// Last frame's rebuild inputs — the mesh is re-projected only when one moves. `surfaces` counts +/// Last frame's rebuild inputs — the projection is redone only when one moves. `surfaces` counts /// the [`GroundDecalSurface`] colliders: a tile streaming in under a *standing* unit changes it, /// re-arming the rebuild its stillness would otherwise skip. #[derive(Component, Default)] @@ -91,12 +97,15 @@ struct ShadowKey { shown: bool, } -/// The one shared shadow material (white base × the blob texture, multiplicative) + its texture -/// handle (kept so the census can report the image's load state — a texture that never arrives -/// blocks the whole material from rendering, silently). +/// The cached projection: world-space effect triangles (white × the ramp/fade alpha), pushed +/// onto the stream every shown frame, rebuilt on [`ShadowKey`] change. Empty = hidden. +#[derive(Component, Default)] +struct ShadowVerts(Vec); + +/// The one shadow texture (kept so the census can report the image's load state — a texture +/// that never arrives withholds every shadow draw at the render-side residency gate, silently). #[derive(Resource)] -struct ShadowMaterial { - material: Handle, +struct ShadowAssets { texture: Handle, } @@ -104,48 +113,33 @@ pub(crate) struct BlobShadowPlugin; impl Plugin for BlobShadowPlugin { fn build(&self, app: &mut App) { - app.add_systems(Startup, setup_material).add_systems( - Update, - (sync_shadows, update_shadows) - .chain() - // After net motion + input: the decal follows this frame's unit transforms. - .after(WorldStage::Input), - ); + app.add_systems(Startup, setup_shadow_assets) + .add_systems( + Update, + (sync_shadows, update_shadows) + .chain() + // After net motion + input: the decal follows this frame's unit transforms. + .after(WorldStage::Input), + ) + // The stream push: after the frame's stream clear (the caches were rebuilt in + // `Update`, so this is a pure copy). + .add_systems(PostUpdate, push_shadows.after(begin_effect_frame)); } } -/// Build the shared multiply material once. `AlphaMode::Multiply` premultiplies in-shader and -/// blends `dst × src + dst × (1−α)` = `dst × lerp(1, srcRGB, α)` — the reference's -/// `GL_DST_COLOR/GL_ZERO` modulate with the fade riding the combine (see module docs). -fn setup_material( - mut commands: Commands, - mut materials: ResMut>, - asset_server: Res, -) { +/// Load the shadow disc once. +fn setup_shadow_assets(mut commands: Commands, asset_server: Res) { let texture = asset_server.load::(SHADOW_TEXTURE); - let material = materials.add(StandardMaterial { - base_color: Color::WHITE, - base_color_texture: Some(texture.clone()), - // Unlit (the reference's shadow pass: lighting off, fog off) — the darkening is pure - // framebuffer arithmetic, independent of time-of-day. - unlit: true, - alpha_mode: AlphaMode::Multiply, - cull_mode: None, - depth_bias: SHADOW_DEPTH_BIAS, - ..default() - }); - commands.insert_resource(ShadowMaterial { material, texture }); + commands.insert_resource(ShadowAssets { texture }); } -/// Keep one shadow decal per eligible unit: spawn for new Player/Unit entities whose model has +/// Keep one shadow record per eligible unit: spawn for new Player/Unit entities whose model has /// built (an animated model — [`ModelAnimations`] arrives with it), despawn orphans (owner /// destroyed / streamed out). The registration *policy* is the open RE item; this is the v1 set /// (see module docs). #[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline fn sync_shadows( mut commands: Commands, - mut meshes: ResMut>, - material: Option>, // A mount child never gets its own decal: its `Transform` is parent-relative (a shadow // keyed on it would project at the world origin) — the mounted composite casts ONE shadow, // the unit's, which reads the mount's box while mounted (`update_shadows`, decision 0441). @@ -158,9 +152,6 @@ fn sync_shadows( >, shadows: Query<(Entity, &BlobShadow)>, ) { - let Some(material) = material else { - return; - }; let mut shadowed = EntityHashSet::default(); for (entity, shadow) in &shadows { // Owner gone or no longer eligible (model torn down) → the decal goes with it. @@ -177,23 +168,19 @@ fn sync_shadows( commands.spawn(( BlobShadow { owner }, ShadowKey::default(), - Mesh3d(meshes.add(seed_mesh())), - MeshMaterial3d(material.material.clone()), - Transform::default(), - Visibility::Hidden, + ShadowVerts::default(), )); } } -/// Re-project each shadow whose inputs moved; hide it when the box degenerates, the fade reaches -/// zero, or no receiving surface is in the box (the reference's no-ground gate). +/// Re-project each shadow whose inputs moved; clear it when the box degenerates, the fade +/// reaches zero, or no receiving surface is in the box (the reference's no-ground gate). #[allow(clippy::too_many_arguments, clippy::type_complexity)] fn update_shadows( time: Res