every effect rides one stream, and the bag tooltip costs nothing
- Particles, ribbon trails, blob shadows, the selection ring, ground decals, water foam and the weather all push their quads into one shared vertex stream now — one buffer, batched draws, indices built per frame in draw order. ~1,100 emitters at the crowded dungeon spot stopped being ~1,100 mesh assets the engine reallocated every frame, no material asset is ever mutated for an effect again, and adjacent draws merge like the original's own batches (its measured shape: 793 quads in 23 draws). Vertices went camera-relative on the way — the fix for streaks and flakes vanishing in far zones, where the raw coordinates get big. - Hovering an item in your bags cost +14 CPU ms for as long as the tooltip sat open. The tooltip's refresh loop is faithful 1.12 behaviour (the bag button re-runs its hover every frame, unthrottled, so a late-arriving item fills in without a re-hover) — but each rebuild dropped the text measuring cache, so every frame re-shaped every line. One line keeps the cache: +14 → +0.1 ms, and the paperdoll, inspect and merchant tooltips stop paying the same tax as spikes. - "Which room is this creature in" walks one anchor per model instead of every lit part — ~13,700 part visits a frame became ~1,700 anchor rows. The frame-time win was honestly flat (the cost had already moved elsewhere); it lands for the structure, and the old sweep's repair duties became event-driven with it. - The perf probe defends its own window now — always-on-top from the first tick, occlusion counted on the measurement line — after two long overnight runs were silently destroyed by the display going to sleep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f32>,
|
||||
light_diffuse: vec4<f32>,
|
||||
light_sun: vec4<f32>,
|
||||
light_spec: vec4<f32>,
|
||||
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
|
||||
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
|
||||
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>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> view: View;
|
||||
@group(1) @binding(0) var effect_texture: texture_2d<f32>;
|
||||
@group(1) @binding(1) var effect_sampler: sampler;
|
||||
@group(1) @binding(2) var<storage, read> 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<uniform> wow_ext_params: vec4<f32>;
|
||||
|
||||
// The rain pass's forced fog colour: 0x80808080 → grey (render-state 0x0d).
|
||||
const RAIN_FOG_GREY: vec3<f32> = vec3<f32>(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<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) color: vec4<f32>, // raw authored gamma RGBA; α is the blend weight
|
||||
};
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// 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<f32>(
|
||||
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<f32>(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<f32> {
|
||||
// `$WOW_PARTICLE_FLAT` (B16 instrument): solid magenta, no inputs — see render.rs.
|
||||
#ifdef WOW_PARTICLE_FLAT
|
||||
return vec4<f32>(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<f32>(0.0); }
|
||||
else if (wow_ext_params.x > 2.5 && wow_ext_params.x < 3.5) { fog_rgb = vec3<f32>(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<f32>(rgb * c.a, 0.0);
|
||||
#else
|
||||
#ifdef BLEND_OPAQUE
|
||||
return vec4<f32>(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<f32>(rgb * c.a, c.a);
|
||||
#else
|
||||
#ifdef BLEND_MOD2X
|
||||
// `(Dst, Src)` reads no alpha (0528's no-alpha law).
|
||||
return vec4<f32>(rgb, 1.0);
|
||||
#else
|
||||
return vec4<f32>(rgb, c.a);
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
@@ -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<f32>,
|
||||
light_diffuse: vec4<f32>,
|
||||
light_sun: vec4<f32>,
|
||||
light_spec: vec4<f32>,
|
||||
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
|
||||
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
|
||||
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>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> 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<uniform> wow_ext_params: vec4<f32>;
|
||||
|
||||
// The rain pass's forced fog colour: 0x80808080 → (0.502, 0.502, 0.502) grey (render-state 0x0d).
|
||||
const RAIN_FOG_GREY: vec3<f32> = vec3<f32>(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<f32>(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<f32>(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<f32>(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<f32>(0.0); }
|
||||
else if (wow_ext_params.x > 2.5 && wow_ext_params.x < 3.5) { fog_rgb = vec3<f32>(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<f32>(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<f32>(rgb * c.a, 1.0);
|
||||
#else
|
||||
let final_color = vec4<f32>(rgb, c.a);
|
||||
#endif
|
||||
out.color = main_pass_post_lighting_processing(pbr_input, final_color);
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
@@ -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<EffectVertex>);
|
||||
|
||||
/// 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<StandardMaterial>,
|
||||
struct ShadowAssets {
|
||||
texture: Handle<Image>,
|
||||
}
|
||||
|
||||
@@ -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<Assets<StandardMaterial>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
) {
|
||||
/// Load the shadow disc once.
|
||||
fn setup_shadow_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
let texture = asset_server.load::<Image>(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<Assets<Mesh>>,
|
||||
material: Option<Res<ShadowMaterial>>,
|
||||
// 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<Time>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
catalog: Option<Res<AnimData>>,
|
||||
rig: Option<Res<CameraControl>>,
|
||||
shadow_material: Option<Res<ShadowMaterial>>,
|
||||
shadow_assets: Option<Res<ShadowAssets>>,
|
||||
images: Res<Assets<Image>>,
|
||||
surfaces: Query<&Collider, With<GroundDecalSurface>>,
|
||||
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
|
||||
@@ -216,15 +203,7 @@ fn update_shadows(
|
||||
// shadow box is untraced — this is the named approximation of decision 0441's P2, carried
|
||||
// until a wow-re shadow-consumer trace pins it.
|
||||
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
|
||||
mut commands: Commands,
|
||||
mut shadows: Query<(
|
||||
Entity,
|
||||
&BlobShadow,
|
||||
&mut ShadowKey,
|
||||
&mut Transform,
|
||||
&mut Visibility,
|
||||
&Mesh3d,
|
||||
)>,
|
||||
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
|
||||
// Once-a-second census at debug level (`RUST_LOG=benilla::blob_shadow=debug`): how many
|
||||
// shadows exist and why the hidden ones hid — the first question of any "no shadow under X"
|
||||
// report, answerable from a log instead of a debugger.
|
||||
@@ -248,11 +227,11 @@ fn update_shadows(
|
||||
*slot = slot.min(alpha);
|
||||
}
|
||||
let surface_count = surfaces.iter().count();
|
||||
for (entity, shadow, mut key, mut transform, mut visibility, mesh) in &mut shadows {
|
||||
for (shadow, mut key, mut verts) in &mut shadows {
|
||||
n_total += 1;
|
||||
let Ok((unit, anims, is_self, mount_child)) = owners.get(shadow.owner) else {
|
||||
// sync_shadows despawns next frame; keep it invisible meanwhile.
|
||||
*visibility = Visibility::Hidden;
|
||||
// sync_shadows despawns next frame; keep it cleared meanwhile.
|
||||
hide(&mut key, &mut verts);
|
||||
n_no_owner += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -269,7 +248,7 @@ fn update_shadows(
|
||||
let stand = catalog.as_deref().map_or(0, |c| anims.resolve(0, &c.0).id);
|
||||
let clip = anims.find(stand);
|
||||
let Some(clip) = clip else {
|
||||
hide(&mut key, &mut visibility);
|
||||
hide(&mut key, &mut verts);
|
||||
n_no_clip += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -285,7 +264,7 @@ fn update_shadows(
|
||||
* s;
|
||||
if bmax.x - bmin.x <= DEGENERATE_EPS || bmax.z - bmin.z <= DEGENERATE_EPS {
|
||||
// The reference's degenerate-box no-op exit (`0x61e9c0`): no shadow.
|
||||
hide(&mut key, &mut visibility);
|
||||
hide(&mut key, &mut verts);
|
||||
n_degen += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -295,7 +274,7 @@ fn update_shadows(
|
||||
alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade);
|
||||
}
|
||||
if alpha <= 0.0 {
|
||||
hide(&mut key, &mut visibility);
|
||||
hide(&mut key, &mut verts);
|
||||
continue;
|
||||
}
|
||||
let next = ShadowKey {
|
||||
@@ -341,10 +320,10 @@ fn update_shadows(
|
||||
max_y: half_v,
|
||||
};
|
||||
let span_v = frame.max_y - frame.min_y;
|
||||
verts.0.clear();
|
||||
let projected = span_v > 0.0
|
||||
&& project_decal(
|
||||
&mut meshes,
|
||||
mesh,
|
||||
&mut verts.0,
|
||||
&surfaces,
|
||||
&frame,
|
||||
|p| {
|
||||
@@ -356,32 +335,18 @@ fn update_shadows(
|
||||
},
|
||||
|x, z| frame.rect_uv(x, z),
|
||||
);
|
||||
if projected {
|
||||
transform.translation = unit.translation;
|
||||
// A hand-set cull volume: the mesh is rewritten in place and Bevy won't recompute an
|
||||
// entity's `Aabb` on asset change — unlike the single ring, dozens of shadows want
|
||||
// real frustum culling, so the box is maintained here.
|
||||
commands.entity(entity).insert(Aabb::from_min_max(
|
||||
Vec3::new(min_x, frame.min_y, min_z),
|
||||
Vec3::new(max_x, frame.max_y, max_z),
|
||||
));
|
||||
}
|
||||
*key = next;
|
||||
key.shown = projected;
|
||||
if projected {
|
||||
n_shown += 1;
|
||||
} else {
|
||||
verts.0.clear();
|
||||
n_no_ground += 1;
|
||||
}
|
||||
*visibility = if projected {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
if census && n_total > 0 {
|
||||
let tex = shadow_material.map_or("no-resource", |m| {
|
||||
if images.contains(&m.texture) {
|
||||
let tex = shadow_assets.map_or("no-resource", |a| {
|
||||
if images.contains(&a.texture) {
|
||||
"loaded"
|
||||
} else {
|
||||
"MISSING"
|
||||
@@ -395,10 +360,43 @@ fn update_shadows(
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the decal and drop the cache key so the next eligible frame rebuilds from scratch.
|
||||
fn hide(key: &mut ShadowKey, visibility: &mut Visibility) {
|
||||
/// Push every shown shadow's cached triangles onto the stream — one Multiply draw per unit at
|
||||
/// the shadow rung, fog off (the reference's shadow pass state).
|
||||
fn push_shadows(
|
||||
assets: Option<Res<ShadowAssets>>,
|
||||
cam: Query<Entity, With<WorldCamera>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
shadows: Query<(Entity, &ShadowKey, &ShadowVerts)>,
|
||||
) {
|
||||
let Some(assets) = assets else { return };
|
||||
let Ok(cam) = cam.single() else { return };
|
||||
for (entity, key, verts) in &shadows {
|
||||
if verts.0.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let start = quads.begin();
|
||||
quads.verts.extend_from_slice(&verts.0);
|
||||
quads.commit_tris(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam,
|
||||
texture: assets.texture.id(),
|
||||
blend: EffectBlend::Multiply,
|
||||
fog: EffectFog::Off,
|
||||
anchor: key.feet,
|
||||
bias: SHADOW_DEPTH_BIAS,
|
||||
raster_bias: SHADOW_DEPTH_BIAS as i32,
|
||||
main_entity: entity,
|
||||
light: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the record and drop the cache key so the next eligible frame rebuilds from scratch.
|
||||
fn hide(key: &mut ShadowKey, verts: &mut ShadowVerts) {
|
||||
key.shown = false;
|
||||
*visibility = Visibility::Hidden;
|
||||
verts.0.clear();
|
||||
}
|
||||
|
||||
/// Did any rebuild input move beyond noise? Position/box at a millimetre, rotation at ~0.05°,
|
||||
|
||||
@@ -218,16 +218,18 @@ impl ExtractResource for QuadProbes {
|
||||
|
||||
/// Project every in-scope emitter's live quads into pixels, once a frame.
|
||||
///
|
||||
/// Deliberately reads the **mesh asset**, not the simulation: these are the vertices the draw will
|
||||
/// consume, so a quad that was mis-built (collapsed, mis-billboarded, left at last frame's anchor)
|
||||
/// is measured as itself rather than as what the sim intended. Runs after `BillboardPlace`, the set
|
||||
/// that rebuilds the meshes, so the buffer read is this frame's.
|
||||
/// Deliberately reads the **shared effect stream** (0732 P1), not the simulation: these are the
|
||||
/// vertices the draw will consume, so a quad that was mis-built (collapsed, mis-billboarded,
|
||||
/// left at last frame's anchor) is measured as itself rather than as what the sim intended. Runs
|
||||
/// after `BillboardPlace`, the set that fills the stream, so the read is this frame's. An
|
||||
/// emitter's CHILD-pool quads ride its draw records too (same `main_entity`), attributed to the
|
||||
/// parent's bone — the old per-mesh read never saw them at all.
|
||||
fn collect_quads(
|
||||
watch: Res<QuadWatch>,
|
||||
mut probes: ResMut<QuadProbes>,
|
||||
cam: Query<(&Camera, &GlobalTransform, &Projection), With<WorldCamera>>,
|
||||
emitters: Query<(&ParticleEmitter, &GlobalTransform, &Mesh3d)>,
|
||||
meshes: Res<Assets<Mesh>>,
|
||||
emitters: Query<(Entity, &ParticleEmitter)>,
|
||||
quads: Res<crate::particles::buffer::EffectQuads>,
|
||||
) {
|
||||
let Some(bones) = watch.0.as_deref() else {
|
||||
return;
|
||||
@@ -242,24 +244,27 @@ fn collect_quads(
|
||||
// The frame's own matrices, the same pair `depthdump` projects with — so a quad's `dquad` here
|
||||
// and its `dquad` there are the same number, and the two logs can be read against each other.
|
||||
let clip_from_world = projection.get_clip_from_view() * cam_tf.to_matrix().inverse();
|
||||
for (emitter, gt, mesh) in &emitters {
|
||||
for (entity, emitter) in &emitters {
|
||||
if !bones.is_empty() && !bones.contains(&emitter.bone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) = meshes
|
||||
.get(&mesh.0)
|
||||
.and_then(|m| m.attribute(Mesh::ATTRIBUTE_POSITION))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Every draw record this emitter committed this frame (its own pool + child pools).
|
||||
let ranges = quads
|
||||
.draws
|
||||
.iter()
|
||||
.filter(|d| d.main_entity == entity)
|
||||
.map(|d| d.range.clone());
|
||||
let pos: Vec<[f32; 3]> = ranges
|
||||
.flat_map(|r| quads.verts[r.start as usize..r.end as usize].iter())
|
||||
.map(|v| v.pos)
|
||||
.collect();
|
||||
for (index, quad) in pos.chunks_exact(4).enumerate() {
|
||||
let mut corners = [Vec2::ZERO; 4];
|
||||
let (mut dmin, mut dmax, mut center) = (f32::MAX, f32::MIN, Vec3::ZERO);
|
||||
let mut behind = false;
|
||||
for (i, v) in quad.iter().enumerate() {
|
||||
// Verts are anchor-relative (the transparent-pass sort key); the entity transform
|
||||
// is the anchor.
|
||||
let world = gt.transform_point(Vec3::from(*v));
|
||||
// Stream verts are world-space (the sort anchor rides the draw record).
|
||||
let world = Vec3::from(*v);
|
||||
center += world / 4.0;
|
||||
let clip = clip_from_world * world.extend(1.0);
|
||||
if clip.w <= 0.0 {
|
||||
|
||||
@@ -128,7 +128,12 @@ impl ExtractResource for PhaseWatch {
|
||||
|
||||
/// One watched batch: entity, WMO batch order (or emitter bone), how it draws, and — particles
|
||||
/// mode only — the mesh + texture asset ids the render half resolves to GPU-side state.
|
||||
type WatchedBatch = (Entity, i32, String, Option<(AssetId<Mesh>, AssetId<Image>)>);
|
||||
type WatchedBatch = (
|
||||
Entity,
|
||||
i32,
|
||||
String,
|
||||
Option<(Option<AssetId<Mesh>>, AssetId<Image>)>,
|
||||
);
|
||||
|
||||
/// Find the object's batch entities once it has streamed in. Re-run until it is found, then hold:
|
||||
/// the set is per-placement and does not change, and re-collecting every frame would make the
|
||||
@@ -202,7 +207,7 @@ fn collect_batches(
|
||||
fn collect_emitters(
|
||||
mut watch: ResMut<PhaseWatch>,
|
||||
time: Res<Time>,
|
||||
emitters: Query<(Entity, &crate::particles::ParticleEmitter, &Mesh3d)>,
|
||||
emitters: Query<(Entity, &crate::particles::ParticleEmitter)>,
|
||||
trails: Query<(Entity, &crate::ribbons::RibbonTrail, &GlobalTransform)>,
|
||||
parts: Query<(Entity, &crate::debug_panel::ModelPart, &GlobalTransform)>,
|
||||
) {
|
||||
@@ -211,14 +216,16 @@ fn collect_emitters(
|
||||
}
|
||||
let mut found: Vec<WatchedBatch> = emitters
|
||||
.iter()
|
||||
.filter(|(_, e, _)| e.live() > 0)
|
||||
.map(|(ent, e, mesh)| {
|
||||
.filter(|(_, e)| e.live() > 0)
|
||||
.map(|(ent, e)| {
|
||||
let def = e.def();
|
||||
(
|
||||
ent,
|
||||
i32::from(def.bone),
|
||||
format!("EMIT {:?}(pool {})", def.blend, e.live()),
|
||||
Some((mesh.0.id(), e.texture().id())),
|
||||
// No mesh asset since the shared effect lane (0732 P1) — the texture is the
|
||||
// GPU-side link left to check; the vertices ride the lane's one buffer.
|
||||
Some((None, e.texture().id())),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -254,8 +261,8 @@ fn collect_emitters(
|
||||
const NEAR: f32 = 6.0;
|
||||
let anchors: Vec<Vec3> = emitters
|
||||
.iter()
|
||||
.filter(|(_, e, _)| e.live() > 0)
|
||||
.map(|(_, e, _)| e.anchor_world())
|
||||
.filter(|(_, e)| e.live() > 0)
|
||||
.map(|(_, e)| e.anchor_world())
|
||||
// A trail's entity translation IS its sort point (the sim writes the live head node
|
||||
// there), so its `GlobalTransform` is the anchor without an accessor of its own.
|
||||
.chain(trails.iter().map(|(_, _, gt)| gt.translation()))
|
||||
@@ -329,9 +336,13 @@ fn report_phases(
|
||||
// and one whose material TEXTURE never became a GpuImage has no material bind group, so
|
||||
// the draw command chain aborts silently every frame.
|
||||
let gpu = assets.map(|(mesh_id, tex_id)| {
|
||||
let mesh = match render_meshes.get(mesh_id) {
|
||||
None => "gpu_mesh MISSING".to_string(),
|
||||
Some(m) => format!("gpu_verts {}", m.vertex_count),
|
||||
let mesh = match mesh_id {
|
||||
// The shared effect lane: no per-emitter mesh asset exists to check.
|
||||
None => "shared-lane".to_string(),
|
||||
Some(id) => match render_meshes.get(id) {
|
||||
None => "gpu_mesh MISSING".to_string(),
|
||||
Some(m) => format!("gpu_verts {}", m.vertex_count),
|
||||
},
|
||||
};
|
||||
let tex = match gpu_images.get(tex_id) {
|
||||
None => "gpu_tex MISSING".to_string(),
|
||||
|
||||
@@ -416,6 +416,8 @@ impl Plugin for LiveFpsPlugin {
|
||||
phase: LiveFpsPhase::Waiting,
|
||||
samples: Vec::new(),
|
||||
cpu_at_start: None,
|
||||
occluded_now: false,
|
||||
occluded_frames: 0,
|
||||
})
|
||||
.init_resource::<ChurnCensus>()
|
||||
.add_systems(Update, drive_live_fps)
|
||||
@@ -433,7 +435,6 @@ impl Plugin for LiveFpsPlugin {
|
||||
),
|
||||
(
|
||||
churn_counter::<crate::sky::SkyMaterial>("sky"),
|
||||
churn_counter::<crate::particles::WowParticleMaterial>("particle"),
|
||||
churn_counter::<crate::sun::CelestialMaterial>("celestial"),
|
||||
churn_counter::<crate::sun::StarMaterial>("star"),
|
||||
churn_counter::<crate::clouds::CloudMaterial>("cloud"),
|
||||
@@ -464,6 +465,15 @@ struct LiveFps {
|
||||
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
|
||||
/// baseline for the window's `cpu_ms`/`cpu_pct`.
|
||||
cpu_at_start: Option<f64>,
|
||||
/// The window's occlusion state, maintained from `WindowOccluded` transitions. macOS
|
||||
/// throttles a FULLY covered window to ~1 fps (any covering window, not just the lock
|
||||
/// screen — the director's correction to 0729/0730's lock-screen reading): a probe launched
|
||||
/// detached spawns unfocused and can land completely behind other windows, and its leg then
|
||||
/// measures the throttle, not the client.
|
||||
occluded_now: bool,
|
||||
/// Sampled frames taken while occluded — `occluded_frames=` on the probe line, so a
|
||||
/// throttled leg names itself instead of being inferred from a ~1 s hitch signature.
|
||||
occluded_frames: usize,
|
||||
}
|
||||
|
||||
/// Asset residency — the leak meter (the #bugs teleport leak: caches hold strong handles, so maps
|
||||
@@ -584,10 +594,28 @@ fn drive_live_fps(
|
||||
mut residency: ResidencyMeter,
|
||||
mut keys: ResMut<ButtonInput<KeyCode>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
|
||||
) {
|
||||
// Drain every frame so the state is current whichever phase we're in — the window can be
|
||||
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
|
||||
for o in occlusions.read() {
|
||||
probe.occluded_now = o.occluded;
|
||||
}
|
||||
match probe.phase {
|
||||
LiveFpsPhase::Done => {}
|
||||
LiveFpsPhase::Waiting => {
|
||||
// A probe window that can't be covered can't be occlusion-throttled: macOS drops
|
||||
// any FULLY covered window to ~1 fps drawables, and a detached probe launch spawns
|
||||
// unfocused, possibly entirely behind other windows. Asserted from the FIRST tick,
|
||||
// not at the uncap: an occluded SETTLE phase streams the world at ~1 fps and
|
||||
// under-warms the scene before sampling even starts. Probe-only — a normal run
|
||||
// never enters this system. (Write-gated: re-marking `Window` every frame would
|
||||
// re-apply its whole state through winit.)
|
||||
if let Ok(mut w) = windows.single_mut() {
|
||||
if w.window_level != bevy::window::WindowLevel::AlwaysOnTop {
|
||||
w.window_level = bevy::window::WindowLevel::AlwaysOnTop;
|
||||
}
|
||||
}
|
||||
if time.elapsed_secs() < probe.at || self_player.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -618,6 +646,10 @@ fn drive_live_fps(
|
||||
// The churn census restarts with the window — warmup noise (streaming, shader
|
||||
// warms) would otherwise read as steady-state ratchets.
|
||||
residency.churn.0.clear();
|
||||
probe.occluded_frames = 0;
|
||||
}
|
||||
if probe.occluded_now {
|
||||
probe.occluded_frames += 1;
|
||||
}
|
||||
let ms = time.delta_secs() * 1000.0;
|
||||
probe.samples.push(ms);
|
||||
@@ -685,7 +717,7 @@ fn drive_live_fps(
|
||||
residency.tint_reg.0.len(),
|
||||
);
|
||||
println!(
|
||||
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{present}{at_pin}",
|
||||
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{present} occluded_frames={}{at_pin}",
|
||||
v.len(),
|
||||
at(0.50),
|
||||
at(0.95),
|
||||
@@ -697,6 +729,7 @@ fn drive_live_fps(
|
||||
entities.iter().len(),
|
||||
px.0,
|
||||
px.1,
|
||||
probe.occluded_frames,
|
||||
);
|
||||
// The window's Modified-event totals per asset type — a type at ~1×/frame here is a
|
||||
// per-frame re-upload ratchet (see [`ChurnCensus`]); absent means quiet.
|
||||
|
||||
+45
-71
@@ -4,44 +4,32 @@
|
||||
//! tiles + WMO faces — **never** doodads/GameObjects) whose BVH overlaps a projection box, clip
|
||||
//! each to the box ([`clip_to_frame`]), and emit them with planar top-down UVs. Because the
|
||||
//! emitted triangles are exact sub-pieces of the drawn surfaces, a decal is pixel-coplanar with
|
||||
//! what's on screen (the caller's `depth_bias` settles the depth test) and drapes down steps and
|
||||
//! ledge faces precisely like the reference (a vertical face gets the smeared texel column of its
|
||||
//! XZ spot: projective texturing, faithfully).
|
||||
//! what's on screen (the draw's rasterizer `depth_bias` settles the depth test) and drapes down
|
||||
//! steps and ledge faces precisely like the reference (a vertical face gets the smeared texel
|
||||
//! column of its XZ spot: projective texturing, faithfully).
|
||||
//!
|
||||
//! Two clients — the same emit loop in the binary: the **selection ring**
|
||||
//! ([`crate::target`]`::ring`, collector flags `0x200122`) and the **unit blob shadow**
|
||||
//! Since 0733 the projector emits **effect-stream triangles** ([`EffectVertex`], world-space,
|
||||
//! fan-unrolled) instead of `Mesh` assets: its clients cache the projected slice and push it
|
||||
//! into the shared lane per frame — zero mesh churn, tint/fade applied at push time.
|
||||
//!
|
||||
//! Three clients — the same emit loop in the binary: the **selection ring**
|
||||
//! ([`crate::target`]`::ring`, collector flags `0x200122`), the **unit blob shadow**
|
||||
//! ([`crate::blob_shadow`], flags `0x2f0122` — the ring's + the liquid receivers, a gap here:
|
||||
//! liquid surfaces aren't in the [`GroundDecalSurface`] set yet).
|
||||
//! liquid surfaces aren't in the [`GroundDecalSurface`] set yet), and the ground-fx spell
|
||||
//! decals ([`crate::ground_fx`]).
|
||||
|
||||
use avian3d::parry::bounding_volume::{Aabb as ParryAabb, BoundingVolume};
|
||||
use avian3d::prelude::Collider;
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::collision::GroundDecalSurface;
|
||||
|
||||
/// A decal mesh's initial (placeholder) contents: one degenerate triangle, so the vertex-buffer
|
||||
/// layout exists before the first projection. [`project_decal`] rewrites every attribute per
|
||||
/// rebuild.
|
||||
pub(crate) fn seed_mesh() -> Mesh {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, vec![[0.0, 0.0, 0.0]; 3]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 3]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.5, 0.5]; 3]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![[1.0, 1.0, 1.0, 1.0]; 3]);
|
||||
mesh.insert_indices(Indices::U32(vec![0, 1, 2]));
|
||||
mesh
|
||||
}
|
||||
use crate::particles::buffer::EffectVertex;
|
||||
|
||||
/// A decal's projection box: a yaw-rotated horizontal rectangle × a vertical slab, all relative
|
||||
/// to `center` (the owning object's feet — the mesh transform places the emitted positions there).
|
||||
/// The horizontal bounds live in the **rotated frame** (`x' = dx·cos − dz·sin`,
|
||||
/// `z' = dz·cos + dx·sin`); UVs map `[min_x, max_x] × [min_z, max_z]` to `[0,1]²`, so the texture
|
||||
/// square IS this rectangle. An axis-aligned box passes `(sin, cos) = (0, 1)`.
|
||||
/// to `center` (the owning object's feet). The horizontal bounds live in the **rotated frame**
|
||||
/// (`x' = dx·cos − dz·sin`, `z' = dz·cos + dx·sin`); UVs map `[min_x, max_x] × [min_z, max_z]`
|
||||
/// to `[0,1]²`, so the texture square IS this rectangle. An axis-aligned box passes
|
||||
/// `(sin, cos) = (0, 1)`.
|
||||
pub(crate) struct DecalFrame {
|
||||
pub center: Vec3,
|
||||
pub sin: f32,
|
||||
@@ -102,17 +90,17 @@ impl DecalFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild `mesh` as a projected surface decal: gather + clip the [`GroundDecalSurface`]
|
||||
/// triangles to `frame`'s box and emit them with top-down UVs (positions **relative to
|
||||
/// `frame.center`** — the caller's transform places them). `alpha` computes each vertex's colour
|
||||
/// alpha from its in-frame position `(x', y_rel, z')` (vertical fades, edge ramps); `uv` maps
|
||||
/// in-frame `(x', z')` to the emitted texture coordinate ([`DecalFrame::rect_uv`] for the plain
|
||||
/// texture-square decals; the ground-fx lane bilerps its quad's authored corner UVs). Returns
|
||||
/// `false` when nothing was gathered (no receiving surface in the box) — the caller hides the
|
||||
/// decal, the reference's own no-ground gate (`0x6d74b5`: the whole draw is skipped).
|
||||
/// Project a surface decal into `out` as **world-space, fan-unrolled effect triangles**: the
|
||||
/// [`GroundDecalSurface`] triangles are gathered, clipped to `frame`'s box, and emitted with
|
||||
/// top-down UVs. `alpha` computes each vertex's colour alpha from its in-frame position
|
||||
/// `(x', y_rel, z')` (vertical fades, edge ramps); the colour is white × that alpha — the
|
||||
/// pushing client multiplies its tint/fade in. `uv` maps in-frame `(x', z')` to the emitted
|
||||
/// texture coordinate ([`DecalFrame::rect_uv`] for the plain texture-square decals; the
|
||||
/// ground-fx lane bilerps its quad's authored corner UVs). Returns `false` when nothing was
|
||||
/// gathered (no receiving surface in the box) — the caller hides the decal, the reference's
|
||||
/// own no-ground gate (`0x6d74b5`: the whole draw is skipped).
|
||||
pub(crate) fn project_decal(
|
||||
meshes: &mut Assets<Mesh>,
|
||||
mesh: &Mesh3d,
|
||||
out: &mut Vec<EffectVertex>,
|
||||
surfaces: &Query<&Collider, With<GroundDecalSurface>>,
|
||||
frame: &DecalFrame,
|
||||
alpha: impl Fn(Vec3) -> f32,
|
||||
@@ -122,13 +110,10 @@ pub(crate) fn project_decal(
|
||||
if frame.max_x - frame.min_x <= 0.0 || frame.max_z - frame.min_z <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let mut positions: Vec<[f32; 3]> = Vec::new();
|
||||
let mut uvs: Vec<[f32; 2]> = Vec::new();
|
||||
let mut colors: Vec<[f32; 4]> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let start = out.len();
|
||||
for collider in surfaces {
|
||||
// The marked colliders are static trimeshes with world-space vertices (identity pose), so
|
||||
// their local AABB/triangles are world AABB/triangles.
|
||||
// The marked colliders are static trimeshes with world-space vertices (identity pose),
|
||||
// so their local AABB/triangles are world AABB/triangles.
|
||||
let Some(trimesh) = collider.shape().as_trimesh() else {
|
||||
continue;
|
||||
};
|
||||
@@ -141,36 +126,25 @@ pub(crate) fn project_decal(
|
||||
if poly.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let base = positions.len() as u32;
|
||||
for p in &poly {
|
||||
let d = *p - frame.center;
|
||||
positions.push([d.x, d.y, d.z]);
|
||||
let (u, v) = frame.in_frame(*p);
|
||||
uvs.push(uv(u, v));
|
||||
let a = alpha(Vec3::new(u, d.y, v));
|
||||
colors.push([1.0, 1.0, 1.0, a]);
|
||||
}
|
||||
// Fan-triangulate the clipped convex polygon.
|
||||
for k in 1..poly.len() as u32 - 1 {
|
||||
indices.extend([base, base + k, base + k + 1]);
|
||||
let vert = |p: Vec3| {
|
||||
let (u, v) = frame.in_frame(p);
|
||||
let a = alpha(Vec3::new(u, p.y - frame.center.y, v));
|
||||
EffectVertex {
|
||||
pos: p.to_array(),
|
||||
uv: uv(u, v),
|
||||
color: [1.0, 1.0, 1.0, a],
|
||||
}
|
||||
};
|
||||
// Fan-triangulate the clipped convex polygon, unrolled (the stream's tri-list
|
||||
// topology: identity indices, no shared vertices).
|
||||
for k in 1..poly.len() - 1 {
|
||||
out.push(vert(poly[0]));
|
||||
out.push(vert(poly[k]));
|
||||
out.push(vert(poly[k + 1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if positions.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(mesh) = meshes.get_mut(mesh.id()) else {
|
||||
return false;
|
||||
};
|
||||
mesh.insert_attribute(
|
||||
Mesh::ATTRIBUTE_NORMAL,
|
||||
vec![[0.0, 1.0, 0.0]; positions.len()],
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
|
||||
mesh.insert_indices(Indices::U32(indices));
|
||||
true
|
||||
out.len() > start
|
||||
}
|
||||
|
||||
/// Sutherland–Hodgman clip of a triangle against the frame's projection box: the yaw-rotated
|
||||
|
||||
@@ -415,11 +415,13 @@ impl Plugin for EntitiesPlugin {
|
||||
.after(WorldStage::Net),
|
||||
)
|
||||
// The ground-fx decal placement (`crate::ground_fx`): rides the BillboardPlace set —
|
||||
// post-propagation (the effect rig's joints carry THIS frame's pose), pre-visibility —
|
||||
// the same slot the following cards place in, with the same direct-global write.
|
||||
// post-propagation (the effect rig's joints carry THIS frame's pose) — after the
|
||||
// stream clear, since it pushes its cached projection into this frame's stream.
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
crate::ground_fx::update_ground_fx_decals.in_set(crate::billboard::BillboardPlace),
|
||||
crate::ground_fx::update_ground_fx_decals
|
||||
.in_set(crate::billboard::BillboardPlace)
|
||||
.after(crate::particles::buffer::begin_effect_frame),
|
||||
)
|
||||
// Terrain conform (decisions 0482/0486, the byte law of wow-re `terrain-tilt.md`):
|
||||
// reads each flagged unit's Update-final transform, writes its conform node's
|
||||
|
||||
@@ -28,7 +28,6 @@ use crate::lighting::SharedLightBuffer;
|
||||
use crate::model_fade::{FadeMaterials, PendingAppearFade};
|
||||
use crate::net::{NetEntity, ObjectStore};
|
||||
use crate::particles;
|
||||
use crate::particles::WowParticleMaterial;
|
||||
use crate::player::CameraPivot;
|
||||
use crate::target::SelectionRadius;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
@@ -278,8 +277,7 @@ pub(super) fn attach_entity_visuals(
|
||||
ResMut<Assets<WowModelMaterial>>,
|
||||
ResMut<EntityMaterials>,
|
||||
),
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
meshes: Res<Assets<Mesh>>,
|
||||
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
time: Res<Time>,
|
||||
@@ -842,7 +840,8 @@ pub(super) fn attach_entity_visuals(
|
||||
};
|
||||
child.insert((
|
||||
MeshTag(tag),
|
||||
InteriorLit::new(entity, lit_kind, mat.clone()),
|
||||
InteriorLit::new(lit_kind, mat.clone()),
|
||||
crate::interior::ClassifiedBy(entity),
|
||||
));
|
||||
}
|
||||
// The batch's **animated material alpha** (the verified combine's runtime half,
|
||||
@@ -974,9 +973,7 @@ pub(super) fn attach_entity_visuals(
|
||||
// same rig identity) — the kobold's candle flame follows the head through the crouch
|
||||
// instead of floating at the rest-pose height. GameObjects/boneless models keep
|
||||
// whole-entity follow (no joints; their bones hold rest pose anyway).
|
||||
// (Effects need the shared light buffer for the scene fog — decision 0155. It exists
|
||||
// whenever parts were built, since every model material already binds it.)
|
||||
if let Some(light) = shared_light.as_deref() {
|
||||
{
|
||||
for em in emitters {
|
||||
let owner = skin
|
||||
.as_ref()
|
||||
@@ -984,9 +981,6 @@ pub(super) fn attach_entity_visuals(
|
||||
.map_or((entity, [0.0; 3]), |&j| (j, em.bone_pivot));
|
||||
particles::spawn_emitter(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
light,
|
||||
em,
|
||||
placement,
|
||||
Some(owner),
|
||||
@@ -1013,7 +1007,7 @@ pub(super) fn attach_entity_visuals(
|
||||
});
|
||||
// Ribbon trails (wisp streamers, trailing quest-object crystals) — the same host-bone
|
||||
// ride as the emitters; the trail self-despawns when its owner joint/entity goes.
|
||||
if let Some(light) = shared_light.as_deref() {
|
||||
{
|
||||
for rb in dm.map(|d| d.ribbons.as_slice()).unwrap_or_default() {
|
||||
let (owner, use_pivot) = skin
|
||||
.as_ref()
|
||||
@@ -1024,9 +1018,6 @@ pub(super) fn attach_entity_visuals(
|
||||
// keying, which body models don't author — so the running gait is immaterial.
|
||||
crate::ribbons::spawn_ribbon(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
light,
|
||||
rb,
|
||||
owner,
|
||||
use_pivot,
|
||||
|
||||
@@ -39,7 +39,6 @@ use crate::model_fade::{
|
||||
UnitAppearFade, APPEAR_FADE_SECS,
|
||||
};
|
||||
use crate::net::{NetCommands, NetEntity, ObjectStore};
|
||||
use crate::particles::WowParticleMaterial;
|
||||
|
||||
use super::{Creatures, DisplayModel, ModelHandle};
|
||||
use crate::model_render::m2_url;
|
||||
@@ -748,11 +747,8 @@ pub(super) fn attach_held_items(
|
||||
)>,
|
||||
held: Option<Res<ItemDisplays>>,
|
||||
time: Res<Time>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
|
||||
) {
|
||||
let (Some(held), Some(light)) = (held, shared_light) else {
|
||||
let Some(held) = held else {
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
@@ -895,7 +891,8 @@ pub(super) fn attach_held_items(
|
||||
};
|
||||
child.insert((
|
||||
MeshTag(crate::mesh_tag::alpha_bits(tag_alpha)),
|
||||
InteriorLit::new(entity, lit_kind, part.material.clone()),
|
||||
InteriorLit::new(lit_kind, part.material.clone()),
|
||||
crate::interior::ClassifiedBy(entity),
|
||||
));
|
||||
}
|
||||
// `FadeMaterials` is persistent bookkeeping (self-avatar zoom fade, decision 0032's
|
||||
@@ -965,9 +962,6 @@ pub(super) fn attach_held_items(
|
||||
for em in &dm.emitters {
|
||||
crate::particles::spawn_emitter(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&light,
|
||||
em,
|
||||
spawn_tf,
|
||||
Some((root, [0.0; 3])),
|
||||
@@ -991,17 +985,7 @@ pub(super) fn attach_held_items(
|
||||
// rests in Stand (anim 0): a thrown weapon's trail is keyed dark there, so the flight
|
||||
// ribbon never shows in the hand — it lights only on the InFlight missile.
|
||||
for rb in &dm.ribbons {
|
||||
crate::ribbons::spawn_ribbon(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&light,
|
||||
rb,
|
||||
root,
|
||||
false,
|
||||
unit_scale,
|
||||
Some(0),
|
||||
);
|
||||
crate::ribbons::spawn_ribbon(&mut commands, rb, root, false, unit_scale, Some(0));
|
||||
}
|
||||
debug!(
|
||||
"held attach: unit {entity} display {} → attach {} (bone {bone}, {} parts)",
|
||||
|
||||
@@ -59,7 +59,6 @@ use crate::creature_anim::{
|
||||
MissileSpawn,
|
||||
};
|
||||
use crate::model_render::m2_url;
|
||||
use crate::particles::WowParticleMaterial;
|
||||
|
||||
use super::equipment::ItemDisplays;
|
||||
use super::spell_fx::{attach_effect_visuals, SpellFx};
|
||||
@@ -527,15 +526,12 @@ pub(super) fn attach_missile_models(
|
||||
mut missiles: Query<(Entity, &mut Missile)>,
|
||||
fx: Option<Res<SpellFx>>,
|
||||
time: Res<Time>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
mut wow_materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
|
||||
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
|
||||
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
|
||||
) {
|
||||
let (Some(fx), Some(light)) = (fx, shared_light) else {
|
||||
let Some(fx) = fx else {
|
||||
return;
|
||||
};
|
||||
for (entity, mut missile) in &mut missiles {
|
||||
@@ -566,13 +562,10 @@ pub(super) fn attach_missile_models(
|
||||
time.elapsed_secs(),
|
||||
false, // a missile flies — its flat quads are geometry, never ground decals
|
||||
None, // a missile is a FREE world model — its trail stays world-frozen
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
&mut palettes,
|
||||
&light,
|
||||
Some(INFLIGHT_ANIM),
|
||||
) {
|
||||
continue;
|
||||
|
||||
@@ -53,7 +53,6 @@ use crate::creature_anim::{scan_events, AnimSoundEvent, FxClass, SpellKitFx};
|
||||
use crate::debug_panel::{ModelKind, ModelPart};
|
||||
use crate::model_render::m2_url;
|
||||
use crate::particles;
|
||||
use crate::particles::WowParticleMaterial;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle};
|
||||
@@ -140,30 +139,9 @@ fn fx_part_material(
|
||||
handle
|
||||
}
|
||||
|
||||
/// Resolve a **ground-decal** part's material ([`crate::ground_fx`]): always a per-instance clone
|
||||
/// — it carries the decal depth bias (the projected mesh is coplanar with the drawn ground; the
|
||||
/// ring's `RING_DEPTH_BIAS` rationale) — with the [`fx_part_material`] tint-clone contract folded
|
||||
/// in when the part's M2Color RGB animates.
|
||||
fn ground_part_material(
|
||||
part: &EntityPart,
|
||||
now: f32,
|
||||
wow_materials: &mut Assets<WowModelMaterial>,
|
||||
tint_reg: &mut FxTintAnims,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
let Some(mut mat) = wow_materials.get(part.material.id()).cloned() else {
|
||||
return part.material.clone(); // shared material not built yet — parts were checked ready
|
||||
};
|
||||
mat.base.depth_bias = crate::ground_fx::GROUND_FX_DEPTH_BIAS;
|
||||
if let Some(anim) = &part.rgb_anim {
|
||||
let t0 = anim.sample(0.0);
|
||||
mat.extension.tint = Vec4::new(t0[0], t0[1], t0[2], 1.0);
|
||||
}
|
||||
let handle = wow_materials.add(mat);
|
||||
if let Some(anim) = &part.rgb_anim {
|
||||
tint_reg.0.insert(handle.id(), (anim.clone(), now));
|
||||
}
|
||||
handle
|
||||
}
|
||||
// (The ground-decal material clone died with 0733: a ground-quad part's draw identity —
|
||||
// texture, blend, fog policy, RGB/alpha loops — rides its `GroundFxDecal` record and the
|
||||
// effect stream's per-vertex tint instead of a per-instance `WowModelMaterial`.)
|
||||
|
||||
/// Drive the `fxview` capture fixture (see [`crate::capture::FxViewRequest`]) — the effect-
|
||||
/// viewer instrument: once the capture driver arms it (scene settled), spawn a root at the
|
||||
@@ -179,13 +157,10 @@ pub(crate) fn drive_fx_view(
|
||||
fx: Option<ResMut<SpellFx>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
time: Res<Time>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut tint_reg: ResMut<FxTintAnims>,
|
||||
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
light: Option<Res<crate::lighting::SharedLightBuffer>>,
|
||||
spatial: avian3d::prelude::SpatialQuery,
|
||||
mut transforms: Query<&mut Transform>,
|
||||
) {
|
||||
@@ -200,7 +175,6 @@ pub(crate) fn drive_fx_view(
|
||||
commands.init_resource::<SpellFx>();
|
||||
return;
|
||||
};
|
||||
let Some(light) = light else { return };
|
||||
let root = *state.root.get_or_insert_with(|| {
|
||||
let mut pos = benilla_assets::coords::wow_to_bevy(crate::capture::FXVIEW_POS);
|
||||
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
|
||||
@@ -313,13 +287,10 @@ pub(crate) fn drive_fx_view(
|
||||
time.elapsed_secs(),
|
||||
true, // the fixture plants at a world point — ground quads decal onto the terrain
|
||||
Some(root), // the fixture previews kit effects, which are attached models
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
&mut palettes,
|
||||
&light,
|
||||
None, // the fixture previews a kit effect at its default (first) clip
|
||||
) {
|
||||
state.attached_at = Some(time.elapsed_secs());
|
||||
@@ -351,27 +322,24 @@ pub(super) fn attach_effect_visuals(
|
||||
now: f32,
|
||||
ground_anchor: bool,
|
||||
attach: Option<Entity>,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
particle_materials: &mut Assets<WowParticleMaterial>,
|
||||
wow_materials: &mut Assets<WowModelMaterial>,
|
||||
tint_reg: &mut FxTintAnims,
|
||||
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
|
||||
palettes: &mut crate::rig_palette::RigPalettes,
|
||||
light: &crate::lighting::SharedLightBuffer,
|
||||
preferred_anim: Option<u16>,
|
||||
) -> bool {
|
||||
let Some(parts) = dm.parts.as_ref() else {
|
||||
return false; // still loading — attach on a later pass
|
||||
};
|
||||
let is_ground_decal = |part: &EntityPart| ground_anchor && part.ground_quad.is_some();
|
||||
// Per-part materials for THIS instance (a tint clone where the RGB animates; a biased decal
|
||||
// clone for ground-quad parts), resolved before the child-spawn closure so the material
|
||||
// assets aren't borrowed inside it.
|
||||
// Per-part materials for THIS instance (a tint clone where the RGB animates), resolved
|
||||
// before the child-spawn closure so the material assets aren't borrowed inside it.
|
||||
// Ground-decal parts keep the shared handle unused — their identity rides the decal record.
|
||||
let part_materials: Vec<Handle<WowModelMaterial>> = parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if is_ground_decal(p) {
|
||||
ground_part_material(p, now, wow_materials, tint_reg)
|
||||
p.material.clone()
|
||||
} else {
|
||||
fx_part_material(p, now, wow_materials, tint_reg)
|
||||
}
|
||||
@@ -480,10 +448,11 @@ pub(super) fn attach_effect_visuals(
|
||||
}
|
||||
// Ground-plane quad parts of a ground-anchored instance → projected surface decals
|
||||
// ([`crate::ground_fx`]): each rides its own joint (posed through the bone's inverse
|
||||
// bindpose, exactly the skinned-vertex path) and carries the part's material animation
|
||||
// exactly like a mesh child would.
|
||||
// bindpose, exactly the skinned-vertex path). The part's draw identity — texture, blend,
|
||||
// the shared material's baked `0x70baf0` fog policy, its RGB loop — rides the decal record
|
||||
// (0733); the alpha loop stays a `MatAnim` rider whose `current` the push samples.
|
||||
let binds = dm.inverse_bindposes.as_ref().and_then(|h| ibps.get(h));
|
||||
for (part, material) in parts.iter().zip(&part_materials) {
|
||||
for part in parts.iter() {
|
||||
let Some(quad) = part.ground_quad.filter(|_| is_ground_decal(part)) else {
|
||||
continue;
|
||||
};
|
||||
@@ -497,25 +466,31 @@ pub(super) fn attach_effect_visuals(
|
||||
),
|
||||
None => (root, Mat4::IDENTITY), // boneless model: the quad rides the instance root
|
||||
};
|
||||
let (texture, fog) = match wow_materials.get(part.material.id()) {
|
||||
Some(mat) => (
|
||||
mat.base.base_color_texture.clone(),
|
||||
crate::particles::buffer::EffectFog::from_model_policy(
|
||||
(mat.extension.clutter_fade.z as u32 >> 4) & 7,
|
||||
),
|
||||
),
|
||||
None => (None, crate::particles::buffer::EffectFog::Scene),
|
||||
};
|
||||
let Some(texture) = texture else {
|
||||
continue; // texture-less ground quad: nothing the stream could draw
|
||||
};
|
||||
let decal = crate::ground_fx::spawn_ground_fx_decal(
|
||||
commands,
|
||||
meshes,
|
||||
material.clone(),
|
||||
texture,
|
||||
crate::particles::buffer::EffectBlend::from_model(part.blend),
|
||||
fog,
|
||||
part.rgb_anim.as_ref().map(|a| (a.clone(), now)),
|
||||
&quad,
|
||||
joint,
|
||||
ibp,
|
||||
);
|
||||
let mut spawned = commands.entity(decal);
|
||||
spawned.insert(ModelPart {
|
||||
kind: ModelKind::Creature,
|
||||
blend: part.blend,
|
||||
});
|
||||
if let Some(anim) = &part.alpha_anim {
|
||||
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq);
|
||||
spawned.insert((
|
||||
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(mat_anim.current)),
|
||||
mat_anim,
|
||||
));
|
||||
commands.entity(decal).insert(mat_anim);
|
||||
}
|
||||
}
|
||||
for em in &dm.emitters {
|
||||
@@ -524,9 +499,6 @@ pub(super) fn attach_effect_visuals(
|
||||
.map_or((root, [0.0; 3]), |&j| (j, em.bone_pivot));
|
||||
particles::spawn_emitter(
|
||||
commands,
|
||||
meshes,
|
||||
particle_materials,
|
||||
light,
|
||||
em,
|
||||
Transform::IDENTITY,
|
||||
Some(owner),
|
||||
@@ -551,9 +523,6 @@ pub(super) fn attach_effect_visuals(
|
||||
.map_or((root, false), |&j| (j, true));
|
||||
crate::ribbons::spawn_ribbon(
|
||||
commands,
|
||||
meshes,
|
||||
particle_materials,
|
||||
light,
|
||||
rb,
|
||||
owner,
|
||||
use_pivot,
|
||||
@@ -752,15 +721,12 @@ pub(super) fn attach_spell_fx(
|
||||
mut units: Query<(Entity, &mut FxAttached, Option<&BoneAttach>)>,
|
||||
fx: Option<Res<SpellFx>>,
|
||||
time: Res<Time>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut tint_reg: ResMut<FxTintAnims>,
|
||||
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
|
||||
) {
|
||||
let (Some(fx), Some(light)) = (fx, shared_light) else {
|
||||
let Some(fx) = fx else {
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
@@ -826,13 +792,10 @@ pub(super) fn attach_spell_fx(
|
||||
now,
|
||||
ground_anchor,
|
||||
Some(root), // a kit instance on a unit is an attached model
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
&mut palettes,
|
||||
&light,
|
||||
None, // an attach-point kit effect runs its default (first) clip
|
||||
);
|
||||
inst.root = Some(root);
|
||||
|
||||
@@ -57,7 +57,7 @@ use bevy::prelude::*;
|
||||
use crate::lighting::{PropProbeSlot, PropProbes, SharedLightBuffer};
|
||||
use crate::model_render::{m2_url, ShadeSel};
|
||||
use crate::net::NetEntity;
|
||||
use crate::particles::{self, WowParticleMaterial};
|
||||
use crate::particles;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
use crate::terrain_stream::{
|
||||
build_collider_task, fold_interior_probe, m2_fade, placement_collider_data, point_light,
|
||||
@@ -189,8 +189,6 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
mut entity_mats: ResMut<EntityMaterials>,
|
||||
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
|
||||
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
mut probes: ResMut<PropProbes>,
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
time: Res<Time>,
|
||||
@@ -312,9 +310,6 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
.map_or((root, [0.0; 3]), |&j| (j, em.bone_pivot));
|
||||
if let Some(e) = particles::spawn_emitter(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&shared_light,
|
||||
em,
|
||||
placement,
|
||||
Some(owner),
|
||||
@@ -340,9 +335,6 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
.map_or((root, false), |&j| (j, true));
|
||||
if crate::ribbons::spawn_ribbon(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&shared_light,
|
||||
rb,
|
||||
owner,
|
||||
use_pivot,
|
||||
|
||||
+136
-78
@@ -13,33 +13,39 @@
|
||||
//! of a base-anchored instance instead of a mesh child. Each frame ([`update_ground_fx_decals`],
|
||||
//! in [`crate::billboard::BillboardPlace`] — post-propagation, like the cards), the quad's four
|
||||
//! authored corners are posed through its live joint × the bone's inverse bindpose (exactly the
|
||||
//! skinned-vertex path, so the authored slide/spin/scale animation is preserved), a projection
|
||||
//! frame is fitted to the posed rectangle, and the ground triangles inside it are emitted with
|
||||
//! the quad's own UVs bilerped across the frame — the crescent drapes the terrain it crosses.
|
||||
//! The part's material rides along (additive blend, animated tint clone, `MatAnim` alpha loops),
|
||||
//! plus the decal depth bias that settles coplanarity with the drawn ground. A decal despawns
|
||||
//! when its joint does (the effect instance's reap/self-termination), the billboard cards' orphan
|
||||
//! rule; it hides when no receiving surface is in the box (mid-air), the ring's no-ground gate.
|
||||
//! skinned-vertex path, so the authored slide/spin/scale animation is preserved); when the posed
|
||||
//! corners moved (0733 §5 — the ShadowKey treatment; a static pose costs a compare), a
|
||||
//! projection frame is fitted to the posed rectangle and the ground triangles inside it are
|
||||
//! re-emitted with the quad's own UVs bilerped across the frame — the crescent drapes the
|
||||
//! terrain it crosses. The cached triangles are pushed onto the shared effect stream every
|
||||
//! frame with the part's authored identity riding the draw record: its blend
|
||||
//! ([`crate::particles::buffer::EffectBlend::from_model`]), its `0x70baf0` fog policy, its
|
||||
//! M2Color RGB loop and `MatAnim` alpha loop sampled into the vertex tint at push time (the old
|
||||
//! path's per-instance material clones and their per-frame mutations are gone). A decal
|
||||
//! despawns when its joint does (the effect instance's reap/self-termination), the billboard
|
||||
//! cards' orphan rule; it pushes nothing when no receiving surface is in the box (mid-air), the
|
||||
//! ring's no-ground gate.
|
||||
|
||||
use avian3d::prelude::Collider;
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use benilla_formats::GroundQuad;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::math::Affine3A;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::collision::GroundDecalSurface;
|
||||
use crate::decal::{project_decal, seed_mesh, DecalFrame};
|
||||
use crate::terrain::WowModelMaterial;
|
||||
use crate::decal::{project_decal, DecalFrame};
|
||||
use crate::particles::buffer::{EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
|
||||
use crate::player::WorldCamera;
|
||||
|
||||
/// Rasterizer depth bias on ground-fx decal materials — the same constant, for the same reason,
|
||||
/// as the ring's (`RING_DEPTH_BIAS`, see the rationale there): the projected vertices are
|
||||
/// geometrically coplanar with the drawn ground, and this pushes only their *depth* far above the
|
||||
/// f32 noise floor. Ties against the ring/shadow don't matter: all three write no depth, so the
|
||||
/// bias only ever competes with the opaque ground.
|
||||
/// The decal ladder rung — sort bias + rasterizer depth-bias constant, the same constant, for
|
||||
/// the same reason, as the ring's (`RING_DEPTH_BIAS`, see the rationale there): the projected
|
||||
/// vertices are geometrically coplanar with the drawn ground, and this pushes only their
|
||||
/// *depth* far above the f32 noise floor. Ties against the ring/shadow don't matter: all three
|
||||
/// write no depth, so the bias only ever competes with the opaque ground.
|
||||
pub(crate) const GROUND_FX_DEPTH_BIAS: f32 = 8192.0;
|
||||
|
||||
/// One ground-quad decal: the live effect-rig joint it rides, and the authored quad it projects.
|
||||
/// One ground-quad decal: the live effect-rig joint it rides, the authored quad it projects,
|
||||
/// the part's draw identity, and the cached projection.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct GroundFxDecal {
|
||||
/// The joint entity whose pose animates the quad (the effect model's own rig; the instance
|
||||
@@ -53,16 +59,37 @@ pub(crate) struct GroundFxDecal {
|
||||
corners: [Vec3; 4],
|
||||
/// The authored UV at each corner, parallel to `corners`.
|
||||
uvs: [[f32; 2]; 4],
|
||||
/// The part's texture (the shared material's, resolved at spawn).
|
||||
texture: Handle<Image>,
|
||||
/// The part's authored blend, mapped onto the lane (`model_render.rs`'s law).
|
||||
blend: EffectBlend,
|
||||
/// The part's `0x70baf0` fog policy (from the shared material's baked marker bits).
|
||||
fog: EffectFog,
|
||||
/// The part's M2Color RGB loop on this instance's clock (loop, attach-time origin) —
|
||||
/// sampled into the vertex tint at push time (was: a per-instance material clone mutated
|
||||
/// per frame through `FxTintAnims`).
|
||||
rgb_anim: Option<(std::sync::Arc<benilla_formats::RgbAnim>, f32)>,
|
||||
/// The cached projection (world-space effect triangles, white × the vertical-fade alpha).
|
||||
cache: Vec<EffectVertex>,
|
||||
/// The posed corners the cache was projected from (NaN-seeded: the first pass always
|
||||
/// projects) + the receiving-surface count that re-arms a static pose when a tile streams.
|
||||
cached_corners: [Vec3; 4],
|
||||
cached_surfaces: usize,
|
||||
/// The cached frame's center — the draw's sort anchor.
|
||||
center: Vec3,
|
||||
}
|
||||
|
||||
/// Spawn one ground-quad decal entity (a world-root entity — placement is absolute, written by
|
||||
/// [`update_ground_fx_decals`]). `material` is the part's resolved per-instance material (the
|
||||
/// caller bakes in [`GROUND_FX_DEPTH_BIAS`] and any animated-tint clone); the caller also inserts
|
||||
/// the part's `ModelPart`/`MatAnim` riders. Spawns `Hidden` — the first placement pass shows it.
|
||||
/// Spawn one ground-quad decal entity (a world-root record — no render components; the
|
||||
/// projection rides the effect stream). The caller resolves the part's texture/blend/fog/tint
|
||||
/// identity from its shared material; the caller also inserts the part's `MatAnim` rider (its
|
||||
/// `current` is the push-time alpha).
|
||||
#[allow(clippy::too_many_arguments)] // the part's full draw identity, one call site
|
||||
pub(crate) fn spawn_ground_fx_decal(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
material: Handle<WowModelMaterial>,
|
||||
texture: Handle<Image>,
|
||||
blend: EffectBlend,
|
||||
fog: EffectFog,
|
||||
rgb_anim: Option<(std::sync::Arc<benilla_formats::RgbAnim>, f32)>,
|
||||
quad: &GroundQuad,
|
||||
joint: Entity,
|
||||
ibp: Mat4,
|
||||
@@ -82,21 +109,20 @@ pub(crate) fn spawn_ground_fx_decal(
|
||||
uvs.swap(1, 3);
|
||||
}
|
||||
commands
|
||||
.spawn((
|
||||
GroundFxDecal {
|
||||
joint,
|
||||
ibp,
|
||||
corners,
|
||||
uvs,
|
||||
},
|
||||
Mesh3d(meshes.add(seed_mesh())),
|
||||
MeshMaterial3d(material),
|
||||
Transform::default(),
|
||||
Visibility::Hidden,
|
||||
// The mesh is rewritten in place every frame and Bevy won't recompute the Aabb on asset
|
||||
// change (the ring's rule); live decals are bounded by live casts, so skip culling.
|
||||
NoFrustumCulling,
|
||||
))
|
||||
.spawn(GroundFxDecal {
|
||||
joint,
|
||||
ibp,
|
||||
corners,
|
||||
uvs,
|
||||
texture,
|
||||
blend,
|
||||
fog,
|
||||
rgb_anim,
|
||||
cache: Vec::new(),
|
||||
cached_corners: [Vec3::splat(f32::NAN); 4],
|
||||
cached_surfaces: 0,
|
||||
center: Vec3::ZERO,
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
@@ -137,62 +163,94 @@ fn bilerp_uv(uvs: &[[f32; 2]; 4], s: f32, t: f32) -> [f32; 2] {
|
||||
lerp2(lerp2(uvs[0], uvs[1], s), lerp2(uvs[2], uvs[3], s), t)
|
||||
}
|
||||
|
||||
/// Per-frame placement (in [`crate::billboard::BillboardPlace`] — post-propagation, so the joint
|
||||
/// pose is THIS frame's): pose each decal's corners through its joint, fit the frame, re-project
|
||||
/// the mesh, and write the absolute world transform directly (the cards' direct-global rule).
|
||||
/// Orphaned decals (joint despawned with its effect instance) despawn; a frame with no receiving
|
||||
/// ground hides (the ring's no-ground gate).
|
||||
/// Per-frame placement + push (in [`crate::billboard::BillboardPlace`] — post-propagation, so
|
||||
/// the joint pose is THIS frame's; after `begin_effect_frame` — the push lands in this frame's
|
||||
/// stream): pose each decal's corners through its joint; re-project only when they (or the
|
||||
/// receiving surfaces) moved; push the cached triangles tinted with this frame's RGB/alpha loop
|
||||
/// samples. Orphaned decals (joint despawned with its effect instance) despawn; a frame with no
|
||||
/// receiving ground pushes nothing (the ring's no-ground gate).
|
||||
pub(crate) fn update_ground_fx_decals(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
time: Res<Time>,
|
||||
cam: Query<Entity, With<WorldCamera>>,
|
||||
surfaces: Query<&Collider, With<GroundDecalSurface>>,
|
||||
joints: Query<&GlobalTransform, Without<GroundFxDecal>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
mut decals: Query<(
|
||||
Entity,
|
||||
&GroundFxDecal,
|
||||
&Mesh3d,
|
||||
&mut Transform,
|
||||
&mut GlobalTransform,
|
||||
&mut Visibility,
|
||||
&mut GroundFxDecal,
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
)>,
|
||||
) {
|
||||
for (entity, decal, mesh, mut tf, mut global, mut vis) in &mut decals {
|
||||
let Ok(cam) = cam.single() else { return };
|
||||
let now = time.elapsed_secs();
|
||||
let mut surface_count = usize::MAX;
|
||||
for (entity, mut decal, mat_anim) in &mut decals {
|
||||
let Ok(joint) = joints.get(decal.joint) else {
|
||||
commands.entity(entity).despawn();
|
||||
continue;
|
||||
};
|
||||
if surface_count == usize::MAX {
|
||||
surface_count = surfaces.iter().count();
|
||||
}
|
||||
let pose = joint.affine() * Affine3A::from_mat4(decal.ibp);
|
||||
let corners = decal.corners.map(|c| pose.transform_point3(c));
|
||||
let projected = fit_frame(&corners).is_some_and(|frame| {
|
||||
let vert = frame.max_y;
|
||||
let ok = project_decal(
|
||||
&mut meshes,
|
||||
mesh,
|
||||
&surfaces,
|
||||
&frame,
|
||||
// The ring's vertical trapezoid: full within half the slab, fading to 0 at its
|
||||
// edge — a wall/ledge smear dims with height instead of ending in a hard clip.
|
||||
|p| ((vert - p.y.abs()) / (0.75 * vert)).clamp(0.0, 1.0),
|
||||
|x, z| {
|
||||
let s = (x - frame.min_x) / (frame.max_x - frame.min_x);
|
||||
let t = (z - frame.min_z) / (frame.max_z - frame.min_z);
|
||||
bilerp_uv(&decal.uvs, s, t)
|
||||
},
|
||||
);
|
||||
if ok {
|
||||
tf.translation = frame.center;
|
||||
tf.rotation = Quat::IDENTITY;
|
||||
tf.scale = Vec3::ONE;
|
||||
// Propagation already ran this frame — the direct global write is what renders.
|
||||
*global = GlobalTransform::from(*tf);
|
||||
// The rebuild gate (0733 §5): the posed corners capture the whole pose effect, so a
|
||||
// static aura under a static rig costs this compare. (NaN-seeded corners make the
|
||||
// first pass always project.)
|
||||
if corners != decal.cached_corners || surface_count != decal.cached_surfaces {
|
||||
let decal = &mut *decal;
|
||||
decal.cached_corners = corners;
|
||||
decal.cached_surfaces = surface_count;
|
||||
decal.cache.clear();
|
||||
if let Some(frame) = fit_frame(&corners) {
|
||||
let vert = frame.max_y;
|
||||
decal.center = frame.center;
|
||||
project_decal(
|
||||
&mut decal.cache,
|
||||
&surfaces,
|
||||
&frame,
|
||||
// The ring's vertical trapezoid: full within half the slab, fading to 0 at
|
||||
// its edge — a wall/ledge smear dims with height instead of a hard clip.
|
||||
|p| ((vert - p.y.abs()) / (0.75 * vert)).clamp(0.0, 1.0),
|
||||
|x, z| {
|
||||
let s = (x - frame.min_x) / (frame.max_x - frame.min_x);
|
||||
let t = (z - frame.min_z) / (frame.max_z - frame.min_z);
|
||||
bilerp_uv(&decal.uvs, s, t)
|
||||
},
|
||||
);
|
||||
}
|
||||
ok
|
||||
});
|
||||
*vis = if projected {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
if decal.cache.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// This frame's tint: the part's M2Color RGB loop (instance clock) × the MatAnim alpha
|
||||
// loop — exactly what the material clone + MeshTag carried on the old path.
|
||||
let tint = decal
|
||||
.rgb_anim
|
||||
.as_ref()
|
||||
.map_or([1.0, 1.0, 1.0], |(anim, origin)| anim.sample(now - origin));
|
||||
let alpha = mat_anim.map_or(1.0, |m| m.current);
|
||||
let start = quads.begin();
|
||||
quads.verts.extend(decal.cache.iter().map(|v| EffectVertex {
|
||||
pos: v.pos,
|
||||
uv: v.uv,
|
||||
color: [tint[0], tint[1], tint[2], v.color[3] * alpha],
|
||||
}));
|
||||
quads.commit_tris(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam,
|
||||
texture: decal.texture.id(),
|
||||
blend: decal.blend,
|
||||
fog: decal.fog,
|
||||
anchor: decal.center,
|
||||
bias: GROUND_FX_DEPTH_BIAS,
|
||||
raster_bias: GROUND_FX_DEPTH_BIAS as i32,
|
||||
main_entity: entity,
|
||||
light: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+412
-190
@@ -30,10 +30,13 @@
|
||||
//! `unit-light-combine-storm.md`; a hand-anchored shield split from its body, director-caught,
|
||||
//! 2026-07-13).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use benilla_assets::{cap96, floor168, AdtTile, WmoModel};
|
||||
use bevy::asset::AssetId;
|
||||
use bevy::ecs::lifecycle::HookContext;
|
||||
use bevy::ecs::relationship::RelationshipTarget as _;
|
||||
use bevy::ecs::world::DeferredWorld;
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -99,7 +102,7 @@ pub(crate) enum InteriorKind {
|
||||
}
|
||||
|
||||
/// The law a part currently renders under (`None` until first classified).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum AppliedLaw {
|
||||
Exterior,
|
||||
/// Indoors on the plain matte (a footprint ray that missed or hit a MOPY&1 face, or a
|
||||
@@ -117,33 +120,61 @@ enum AppliedLaw {
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(crate) struct BodyBakeCenter(pub(crate) Vec3);
|
||||
|
||||
/// The part → anchor edge of the classifier's registry (0734): every [`InteriorLit`] part names
|
||||
/// its NET ENTITY root here — body parts and held/equipped items alike (module docs — the
|
||||
/// reference has one light node per unit and items alias it). Bevy's relationship hooks maintain
|
||||
/// the anchor-side [`LitParts`] list through spawn, gear-swap despawn, and teardown, so a law
|
||||
/// change can write exactly its own parts and a settled anchor touches none.
|
||||
#[derive(Component)]
|
||||
#[relationship(relationship_target = LitParts)]
|
||||
pub(crate) struct ClassifiedBy(pub(crate) Entity);
|
||||
|
||||
/// The anchor-side part list [`ClassifiedBy`] maintains — the classifier's write fan-out. Never
|
||||
/// mutated by hand; bevy removes it when the last part leaves.
|
||||
#[derive(Component)]
|
||||
#[relationship_target(relationship = ClassifiedBy)]
|
||||
pub(crate) struct LitParts(Vec<Entity>);
|
||||
|
||||
/// The anchor's classification record (0734) — the law its parts render under, plus the
|
||||
/// movement/residency gate that used to live per part. Inserted by the classifier on the first
|
||||
/// resolve; a settled anchor is one distance compare per frame, whatever its part count.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct InteriorAnchor {
|
||||
law: AppliedLaw,
|
||||
/// Anchor position at the last down-ray + the residency generation then — the re-test gate.
|
||||
last_pos: Vec3,
|
||||
generation: u32,
|
||||
/// Whether the law was resolved from a bake-capable part's kind — a bake-capable part
|
||||
/// joining a matte-resolved anchor must force a re-resolve (the reauthor drain checks this),
|
||||
/// or it would ride the matte fallback until the anchor next moves.
|
||||
kind_bake: bool,
|
||||
}
|
||||
|
||||
/// Parts whose material/tag need re-authoring from their anchor's current law — the classifier's
|
||||
/// convergence queue (0734), replacing the per-part sweep's repair duty. Fed by the
|
||||
/// [`InteriorLit`] `on_add` hook (a fresh part joining a settled anchor), the fade-latch observer
|
||||
/// ([`enqueue_on_fade_latch`] — a part re-entering the write query after a fade owned its
|
||||
/// channel), and the self-avatar zoom feather's release edge. Drained every classifier run;
|
||||
/// entries whose part is still excluded (or gone) are dropped — the next edge re-enqueues.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct InteriorReauthor(pub(crate) Vec<Entity>);
|
||||
|
||||
/// The interior/exterior material variants for one entity submesh part, so [`classify_entity_interior`]
|
||||
/// can swap by the model's current location without rebuilding. Attached only to M2 entity parts (WMO
|
||||
/// group geometry carries per-submesh interior in its own material + baked MOCV).
|
||||
/// group geometry carries per-submesh interior in its own material + baked MOCV); its anchor edge
|
||||
/// is the sibling [`ClassifiedBy`]. The `on_add` hook enqueues the part for authoring, so a
|
||||
/// gear-swap part joining an already-settled anchor still gets the standing law.
|
||||
#[derive(Component)]
|
||||
#[component(on_add = enqueue_new_part)]
|
||||
pub(crate) struct InteriorLit {
|
||||
/// The model's classification anchor — the entity whose `GlobalTransform` the down-ray probes:
|
||||
/// the NET ENTITY root, for body parts and held/equipped items alike (module docs — the
|
||||
/// reference has one light node per unit and items alias it). Every part sharing an anchor
|
||||
/// shares its verdict: a body, hands, and blade must never split across light laws.
|
||||
anchor: Entity,
|
||||
/// The model's indoor law ([`InteriorKind`]) — uniform across an anchor's parts.
|
||||
kind: InteriorKind,
|
||||
/// The exterior/day-night material (the global-SH lane): since 0354 the Matte law rides it
|
||||
/// too — day/night is the intensity byte at the 1.0 point, not a separate material.
|
||||
exterior: Handle<WowModelMaterial>,
|
||||
/// Last applied law — used to write the material + tag ONLY on change, so a standing prop/NPC
|
||||
/// doesn't re-trigger extraction every frame. `None` until the first classification.
|
||||
/// Last applied law — the part's last-written record (the anchor's [`InteriorAnchor`] is the
|
||||
/// authority): the write gate, and [`Self::is_bake`]'s source. `None` until first written.
|
||||
applied: Option<AppliedLaw>,
|
||||
/// A transient author (the self-avatar zoom feather) overwrote the material/tag: re-author
|
||||
/// them on the next run WITHOUT treating the law as new ([`Self::invalidate`] — clearing
|
||||
/// `applied` here replayed the bake lane's ENTRY effects, reseeding the ambient ramp from the
|
||||
/// scene light: the director's "still flashes for a second on the zoom out").
|
||||
reauthor: bool,
|
||||
/// Anchor position at the last test + the residency generation then — the movement/registry gate
|
||||
/// that keeps a static entity from re-running the down-ray every frame.
|
||||
last_pos: Vec3,
|
||||
generation: u32,
|
||||
}
|
||||
|
||||
impl InteriorLit {
|
||||
@@ -155,40 +186,49 @@ impl InteriorLit {
|
||||
matches!(self.applied, Some(AppliedLaw::Bake(_)))
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
anchor: Entity,
|
||||
kind: InteriorKind,
|
||||
exterior: Handle<WowModelMaterial>,
|
||||
) -> Self {
|
||||
pub(crate) fn new(kind: InteriorKind, exterior: Handle<WowModelMaterial>) -> Self {
|
||||
Self {
|
||||
anchor,
|
||||
kind,
|
||||
exterior,
|
||||
applied: None,
|
||||
reauthor: false,
|
||||
last_pos: Vec3::ZERO,
|
||||
generation: u32::MAX, // != any real generation ⇒ classify on the first frame
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the material/tag channel back to the classifier: a transient author (the self-avatar
|
||||
/// zoom fade) wrote over them, so the settled/changed gates would otherwise never restore the
|
||||
/// steady state. Forces a re-author (material + tag) on the classifier's next run while
|
||||
/// KEEPING the applied law — resetting it made the classifier replay the bake lane's entry
|
||||
/// effects (a fresh ambient-ramp seed from the scene light + a new owned slot), which read as
|
||||
/// a second-long light flash at the end of every zoom-out (director-caught, 2026-07-13).
|
||||
pub(crate) fn invalidate(&mut self) {
|
||||
self.reauthor = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the residency registry + the per-frame entity classifier (the streamer fills the registry).
|
||||
/// `on_add` hook: a freshly spawned part asks for its anchor's standing law (drained by
|
||||
/// [`classify_entity_interior`] — a first-resolving anchor covers its parts anyway, but a part
|
||||
/// joining a SETTLED anchor gets no law-change write without this).
|
||||
fn enqueue_new_part(mut world: DeferredWorld, ctx: HookContext) {
|
||||
if let Some(mut queue) = world.get_resource_mut::<InteriorReauthor>() {
|
||||
queue.0.push(ctx.entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade latch: the appear-fade owned this part's material/tag while it lived (the classifier's
|
||||
/// write query excludes fading parts entirely), so the moment `RenderFade` leaves, the part
|
||||
/// re-authors from its anchor's law — the event-driven replacement for the old settled-path
|
||||
/// stale-slot repair (the "unit stays black indoors" bug: a slot freed while its part sat
|
||||
/// excluded). Removal on despawn also lands here; the drain drops dead entries.
|
||||
fn enqueue_on_fade_latch(
|
||||
fade_end: On<Remove, RenderFade>,
|
||||
parts: Query<(), With<InteriorLit>>,
|
||||
mut queue: ResMut<InteriorReauthor>,
|
||||
) {
|
||||
if parts.contains(fade_end.entity) {
|
||||
queue.0.push(fade_end.entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the residency registry, the reauthor queue + its fade-latch observer, and the
|
||||
/// per-frame entity classifier (the streamer fills the registry).
|
||||
pub(crate) struct InteriorPlugin;
|
||||
|
||||
impl Plugin for InteriorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<WmoResidency>()
|
||||
.add_systems(Update, classify_entity_interior);
|
||||
.init_resource::<InteriorReauthor>()
|
||||
.add_systems(Update, classify_entity_interior)
|
||||
.add_observer(enqueue_on_fade_latch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +252,11 @@ pub(crate) struct BakeState {
|
||||
/// reference's per-tick env update, decision 0354), or the day/night state = the same exterior
|
||||
/// material at the intensity-1.0 byte point. One law for every entity M2, unit and GameObject
|
||||
/// alike (module docs). The verdict is the client's faces-only down-ray at the model's anchor —
|
||||
/// one ray per UNIT per re-test (memoised across its parts), re-run only when the anchor moves or
|
||||
/// a building streams in/out, so static entities cost a compare per frame and nothing else.
|
||||
/// one ray per UNIT per re-test, re-run only when the anchor moves or a building streams in/out.
|
||||
///
|
||||
/// The walk is over ANCHORS, not parts (0734): a settled anchor is one distance compare, whatever
|
||||
/// its part count, and parts are written only when their anchor's law changes (or through the
|
||||
/// [`InteriorReauthor`] drain — a fresh part, a fade latch, the zoom feather's release).
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(crate) fn classify_entity_interior(
|
||||
mut commands: Commands,
|
||||
@@ -224,15 +267,22 @@ pub(crate) fn classify_entity_interior(
|
||||
adt_tiles: Res<Assets<AdtTile>>,
|
||||
lighting: Res<crate::lighting::WowLighting>,
|
||||
mut probes: ResMut<PropProbes>,
|
||||
anchors: Query<&GlobalTransform>,
|
||||
mut anchors: Query<(
|
||||
Entity,
|
||||
&GlobalTransform,
|
||||
&LitParts,
|
||||
Option<&mut InteriorAnchor>,
|
||||
)>,
|
||||
mut nodes: Query<&mut crate::entity_shade::GroundShade>,
|
||||
bake_states: Query<&BakeState>,
|
||||
seats: Query<&PropProbeSlot>,
|
||||
mut queue: ResMut<InteriorReauthor>,
|
||||
part_anchors: Query<&ClassifiedBy>,
|
||||
// Skip an entity whose appear-fade is **pending or live**: it must stay invisible (on its blend twin
|
||||
// at α≈0) until armed, and while ramping `apply_render_fade` owns its material + `MeshTag` (the fade
|
||||
// alpha). We reclaim the channel (steady material) only once both are gone — i.e. the fade has
|
||||
// latched. Without this the classifier would fight the fade for the tag (and force the pending
|
||||
// entity opaque).
|
||||
// latched (the latch observer re-enqueues). Without this the classifier would fight the fade for
|
||||
// the tag (and force the pending entity opaque).
|
||||
mut parts: Query<
|
||||
(
|
||||
&mut InteriorLit,
|
||||
@@ -242,95 +292,99 @@ pub(crate) fn classify_entity_interior(
|
||||
(Without<RenderFade>, Without<PendingAppearFade>),
|
||||
>,
|
||||
) {
|
||||
// One down-ray (and at most one probe fold) per unit per run: parts share their anchor's
|
||||
// resolved law, so an 8-part body re-testing after a step costs one ray, not eight.
|
||||
let mut verdicts: HashMap<Entity, AppliedLaw> = HashMap::new();
|
||||
let _t0 = std::time::Instant::now();
|
||||
let (mut n_parts, mut n_resolved) = (0usize, 0usize);
|
||||
let (mut n_anchors, mut n_resolved, mut n_written) = (0usize, 0usize, 0usize);
|
||||
let mut resolve_us = 0.0f32;
|
||||
for (mut lit, mut material, mut tag) in &mut parts {
|
||||
n_parts += 1;
|
||||
let Ok(anchor_t) = anchors.get(lit.anchor) else {
|
||||
continue; // anchor despawning this frame — the parts go with it
|
||||
};
|
||||
for (anchor, anchor_t, lit_parts, mut state) in &mut anchors {
|
||||
n_anchors += 1;
|
||||
let pos = anchor_t.translation();
|
||||
// Skip the down-ray entirely for a settled entity (no movement, no building streamed) —
|
||||
// this is what keeps a town full of standing NPCs/props from re-raying every frame. A
|
||||
let had_state = state.is_some();
|
||||
// Skip the down-ray entirely for a settled anchor (no movement, no building streamed) —
|
||||
// this is what keeps a town full of standing NPCs/props at one compare per frame. A
|
||||
// Bake-law anchor whose node ramps still chase keeps refolding (from the cached ray
|
||||
// products — no new ray), so a unit that stops just inside a warm zone finishes its
|
||||
// transition instead of freezing mid-ramp.
|
||||
let moved = lit.applied.is_none()
|
||||
|| lit.generation != residency.generation
|
||||
|| pos.distance_squared(lit.last_pos) >= RESAMPLE_DIST_SQ;
|
||||
// The anchor's seated [`PropProbeSlot`] is the single source of truth for the Bake slot —
|
||||
// a part's cached `applied` is only its last-written record. Parts join and leave an
|
||||
// anchor independently (gear/attachment swaps spawn fresh parts, fades exclude parts from
|
||||
// this query for whole transitions), so a part-held slot CAN go stale; trusting the
|
||||
// first-iterated part's copy once freed the live slot out from under a standing body's
|
||||
// other parts, whose tags then pointed at a zeroed (black) row until the law next changed
|
||||
// — the "unit goes black indoors until a charge crosses the doorway" bug.
|
||||
let seated = seats.get(lit.anchor).ok().map(|s| s.0);
|
||||
if !moved {
|
||||
let ramping = lit.is_bake() && nodes.get(lit.anchor).is_ok_and(|n| !n.ramps_settled());
|
||||
// A part whose recorded slot disagrees with the anchor's seated slot is stale (it was
|
||||
// excluded — fading — across a law transition): repair it even though it didn't move.
|
||||
let stale_slot = matches!(lit.applied, Some(AppliedLaw::Bake(s)) if seated != Some(s));
|
||||
if !ramping && !lit.reauthor && !stale_slot {
|
||||
if let Some(state) = state.as_deref_mut() {
|
||||
let settled = state.generation == residency.generation
|
||||
&& pos.distance_squared(state.last_pos) < RESAMPLE_DIST_SQ;
|
||||
if settled {
|
||||
if let AppliedLaw::Bake(slot) = state.law {
|
||||
if let (Ok(node), Ok(bake)) = (nodes.get(anchor), bake_states.get(anchor)) {
|
||||
if !node.ramps_settled() {
|
||||
let coeffs = fold_interior_probe(
|
||||
node.ambient.to_array(),
|
||||
(bake.word * node.intensity()).to_array(),
|
||||
bake.ref_point,
|
||||
&bake.lobes,
|
||||
);
|
||||
probes.update_owned(slot, coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let law = *verdicts.entry(lit.anchor).or_insert_with(|| {
|
||||
n_resolved += 1;
|
||||
let _r = std::time::Instant::now();
|
||||
let out = resolve_anchor_law(
|
||||
&mut commands,
|
||||
&mut probes,
|
||||
&wmos,
|
||||
&instances,
|
||||
&streamer,
|
||||
&adt_tiles,
|
||||
&lighting,
|
||||
&mut nodes,
|
||||
&bake_states,
|
||||
lit.anchor,
|
||||
anchor_t,
|
||||
&lit.kind,
|
||||
lit.applied,
|
||||
seated,
|
||||
moved,
|
||||
);
|
||||
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
|
||||
out
|
||||
});
|
||||
if moved {
|
||||
lit.last_pos = pos;
|
||||
lit.generation = residency.generation;
|
||||
}
|
||||
// Write the material/tag only when the law actually changed — or when a transient author
|
||||
// handed the channel back ([`InteriorLit::invalidate`]) — so re-testing a moving NPC
|
||||
// mid-room doesn't churn the render extraction.
|
||||
if lit.applied == Some(law) && !lit.reauthor {
|
||||
// Re-resolving: the kind comes from the anchor's first classifiable part. Every part
|
||||
// excluded (a whole-model appear-fade, pending or live) ⇒ no resolve at all — the fade
|
||||
// owns the channel, and the latch observer brings the parts back through the queue.
|
||||
let Some(kind) = lit_parts
|
||||
.iter()
|
||||
.find_map(|part| parts.get(part).ok().map(|(lit, _, _)| lit.kind.clone()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let seated = seats.get(anchor).ok().map(|s| s.0);
|
||||
n_resolved += 1;
|
||||
let _r = std::time::Instant::now();
|
||||
let law = resolve_anchor_law(
|
||||
&mut commands,
|
||||
&mut probes,
|
||||
&wmos,
|
||||
&instances,
|
||||
&streamer,
|
||||
&adt_tiles,
|
||||
&lighting,
|
||||
&mut nodes,
|
||||
anchor,
|
||||
anchor_t,
|
||||
&kind,
|
||||
seated,
|
||||
);
|
||||
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
|
||||
let kind_bake = matches!(kind, InteriorKind::Bake { .. });
|
||||
let changed = match state.as_deref_mut() {
|
||||
Some(state) => {
|
||||
let changed = state.law != law;
|
||||
state.law = law;
|
||||
state.last_pos = pos;
|
||||
state.generation = residency.generation;
|
||||
state.kind_bake = kind_bake;
|
||||
changed
|
||||
}
|
||||
None => {
|
||||
// `try_insert`: the anchor may carry a same-frame despawn already queued.
|
||||
commands.entity(anchor).try_insert(InteriorAnchor {
|
||||
law,
|
||||
last_pos: pos,
|
||||
generation: residency.generation,
|
||||
kind_bake,
|
||||
});
|
||||
true
|
||||
}
|
||||
};
|
||||
// Write the parts only when the law actually changed, so re-testing a moving NPC mid-room
|
||||
// doesn't churn the render extraction.
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
lit.reauthor = false;
|
||||
material.0 = match law {
|
||||
// The exterior AND day/night states share the exterior material — the difference is
|
||||
// the node's intensity target (the tag byte `entity_shade` ramps; 0354).
|
||||
AppliedLaw::Exterior | AppliedLaw::Matte => lit.exterior.clone(),
|
||||
AppliedLaw::Bake(_) => match &lit.kind {
|
||||
InteriorKind::Bake { material, .. } => material.clone(),
|
||||
InteriorKind::Matte => lit.exterior.clone(), // unreachable by construction
|
||||
},
|
||||
};
|
||||
// `WOW_INTERIOR_LOG=1`: print interior classifications — the live-probe instrument for
|
||||
// "did this entity actually classify indoors, and under which law?". Scoped to interior
|
||||
// verdicts (plus interior→exterior flips) so the world's exterior masses stay silent.
|
||||
if (law != AppliedLaw::Exterior || lit.applied.is_some())
|
||||
if (law != AppliedLaw::Exterior || had_state)
|
||||
&& std::env::var_os("WOW_INTERIOR_LOG").is_some()
|
||||
{
|
||||
eprintln!(
|
||||
"[interior] anchor {:?} at ({:.1}, {:.1}, {:.1}) -> {}",
|
||||
lit.anchor,
|
||||
"[interior] anchor {anchor:?} at ({:.1}, {:.1}, {:.1}) -> {}",
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
@@ -341,51 +395,117 @@ pub(crate) fn classify_entity_interior(
|
||||
}
|
||||
);
|
||||
}
|
||||
// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field plus an
|
||||
// opaque alpha field (a fade feather composes through `with_alpha` without clobbering the
|
||||
// slot); the other laws reset to the opaque exterior payload (shade byte 0 —
|
||||
// `entity_shade` runs after this classifier and re-asserts the ramped intensity byte
|
||||
// the same frame; it skips only Bake parts). BOTH indoor laws carry the INTERIOR_FOG_BIT
|
||||
// (Bake bakes it in): the reference fogs a unit by the unit's OWN interior
|
||||
// classification, so an indoor day/night character keeps the room's fog — never the
|
||||
// storm's near veil — while the exterior law returns it to the scene fog (wow-re
|
||||
// `m2-unit-interior-fog.md`; the director's corridor-vs-porch walk-out). Every arm
|
||||
// carries the part's rig field through (decision 0720): a skinned part keeps its palette
|
||||
// across the indoor/outdoor transition.
|
||||
tag.0 = match law {
|
||||
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
|
||||
AppliedLaw::Matte => {
|
||||
crate::mesh_tag::INTERIOR_FOG_BIT | crate::mesh_tag::with_exterior_reset(tag.0)
|
||||
for part in lit_parts.iter() {
|
||||
if let Ok((mut lit, mut material, mut tag)) = parts.get_mut(part) {
|
||||
n_written += usize::from(write_part_law(
|
||||
law,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
false,
|
||||
));
|
||||
}
|
||||
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
|
||||
}
|
||||
}
|
||||
// Drain the convergence queue: each entry re-authors from its anchor's standing law. Forced
|
||||
// through the part's change gate — the enqueuing edges (fade latch, zoom release) mean a
|
||||
// transient author overwrote the material/tag while `applied` stayed current.
|
||||
for part in std::mem::take(&mut queue.0) {
|
||||
let Ok(edge) = part_anchors.get(part) else {
|
||||
continue; // despawned since enqueue
|
||||
};
|
||||
lit.applied = Some(law);
|
||||
let Ok((_, _, _, mut state)) = anchors.get_mut(edge.0) else {
|
||||
continue;
|
||||
};
|
||||
let Some(state) = state.as_deref_mut() else {
|
||||
continue; // law not resolved yet — the anchor's first resolve writes every part
|
||||
};
|
||||
let Ok((mut lit, mut material, mut tag)) = parts.get_mut(part) else {
|
||||
continue; // still fade-excluded — the latch re-enqueues
|
||||
};
|
||||
// The mixed-kind hole: a bake-capable part joining an anchor whose law was resolved from
|
||||
// a matte-kind part must force a re-resolve (drop the record; next frame re-rays) — the
|
||||
// standing law can't say Bake. Written with the standing law this frame regardless, so
|
||||
// the part isn't naked for the gap.
|
||||
if matches!(lit.kind, InteriorKind::Bake { .. }) && !state.kind_bake {
|
||||
commands.entity(edge.0).try_remove::<InteriorAnchor>();
|
||||
}
|
||||
n_written += usize::from(write_part_law(
|
||||
state.law,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
true,
|
||||
));
|
||||
}
|
||||
// `WOW_INTERIOR_COST=1`: this lane's per-frame cost, in the terms that diagnose it — how many
|
||||
// PARTS the walk touches vs how many ANCHORS actually re-resolve, and what the resolves cost.
|
||||
// The split is the whole diagnosis: on 2026-07-27 the lane read 11.3 ms/frame at the LBRS pin
|
||||
// and the walk was 0.1 ms of it — 35 moving units paying ~300 us each in `resolve_anchor_law`,
|
||||
// which is what sent the hunt into the WMO column rays (decision 0711) rather than into this
|
||||
// loop. Cheap enough to leave in: two counters and one `Instant` per frame.
|
||||
// anchors the walk visits vs how many actually re-resolve vs how many PARTS got written, and
|
||||
// what the resolves cost. The split is the whole diagnosis (it sent the 2026-07-27 hunt into
|
||||
// the WMO column rays, decision 0711, and priced 0732's slice C — the 13.7k-part walk this
|
||||
// anchor walk replaced). Cheap enough to leave in: three counters and one `Instant` per frame.
|
||||
static COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *COST.get_or_init(|| std::env::var_os("WOW_INTERIOR_COST").is_some()) {
|
||||
eprintln!(
|
||||
"[interior-cost] parts={n_parts} anchors_resolved={n_resolved} resolve_ms={:.2} total_ms={:.2}",
|
||||
"[interior-cost] anchors={n_anchors} resolved={n_resolved} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}",
|
||||
resolve_us / 1000.0,
|
||||
_t0.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one anchor's indoor law this run: the down-ray verdict (or, `!moved`, a ramp-only
|
||||
/// refold from the cached ray products), the node's target/seed updates, and for the Bake law the
|
||||
/// footprint fold into the anchor's OWNED probe slot. `seated` — the anchor's live
|
||||
/// [`PropProbeSlot`] — is the ONLY authority on that slot: Bake stays on it, entry/exit is judged
|
||||
/// by it, and a part-cached `Bake(slot)` is never believed (a fresh part's `previous = None` once
|
||||
/// re-allocated here and freed the seated slot under the anchor's other parts — the
|
||||
/// stuck-black-unit bug; the caller's stale-slot repair is the other half). The slot component
|
||||
/// lives on the ANCHOR — its on-remove hook frees the slot on despawn; law transitions
|
||||
/// remove/insert it here.
|
||||
/// Write one part's material + tag for `law` — the single place a part's channel is authored.
|
||||
/// Change-gated on the part's last-written record unless `force` (a transient author — fade,
|
||||
/// zoom feather — overwrote the channel while `applied` stayed current). Returns whether it wrote.
|
||||
///
|
||||
/// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field plus an
|
||||
/// opaque alpha field (a fade feather composes through `with_alpha` without clobbering the
|
||||
/// slot); the other laws reset to the opaque exterior payload (shade byte 0 — `entity_shade`
|
||||
/// runs after the classifier and re-asserts the ramped intensity byte the same frame; it skips
|
||||
/// only Bake parts). BOTH indoor laws carry the INTERIOR_FOG_BIT (Bake bakes it in): the
|
||||
/// reference fogs a unit by the unit's OWN interior classification, so an indoor day/night
|
||||
/// character keeps the room's fog — never the storm's near veil — while the exterior law returns
|
||||
/// it to the scene fog (wow-re `m2-unit-interior-fog.md`; the director's corridor-vs-porch
|
||||
/// walk-out). Every arm carries the part's rig field through (decision 0720): a skinned part
|
||||
/// keeps its palette across the indoor/outdoor transition.
|
||||
fn write_part_law(
|
||||
law: AppliedLaw,
|
||||
lit: &mut InteriorLit,
|
||||
material: &mut MeshMaterial3d<WowModelMaterial>,
|
||||
tag: &mut MeshTag,
|
||||
force: bool,
|
||||
) -> bool {
|
||||
if lit.applied == Some(law) && !force {
|
||||
return false;
|
||||
}
|
||||
material.0 = match law {
|
||||
// The exterior AND day/night states share the exterior material — the difference is
|
||||
// the node's intensity target (the tag byte `entity_shade` ramps; 0354).
|
||||
AppliedLaw::Exterior | AppliedLaw::Matte => lit.exterior.clone(),
|
||||
AppliedLaw::Bake(_) => match &lit.kind {
|
||||
InteriorKind::Bake { material, .. } => material.clone(),
|
||||
InteriorKind::Matte => lit.exterior.clone(), // a matte part under a bake-law anchor
|
||||
},
|
||||
};
|
||||
tag.0 = match law {
|
||||
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
|
||||
AppliedLaw::Matte => {
|
||||
crate::mesh_tag::INTERIOR_FOG_BIT | crate::mesh_tag::with_exterior_reset(tag.0)
|
||||
}
|
||||
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
|
||||
};
|
||||
lit.applied = Some(law);
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve one anchor's indoor law: the down-ray verdict, the node's target/seed updates, and for
|
||||
/// the Bake law the footprint fold into the anchor's OWNED probe slot. (The settled ramp-only
|
||||
/// refold from the cached ray products lives in the caller's walk — this always rays.) `seated` —
|
||||
/// the anchor's live [`PropProbeSlot`] — is the ONLY authority on that slot: Bake stays on it,
|
||||
/// entry/exit is judged by it, and a part-cached `Bake(slot)` is never believed (a fresh part
|
||||
/// once re-allocated here and freed the seated slot under the anchor's other parts — the
|
||||
/// stuck-black-unit bug; the fade-latch reauthor is the other half). The slot component lives on
|
||||
/// the ANCHOR — its on-remove hook frees the slot on despawn; law transitions remove/insert it
|
||||
/// here.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_anchor_law(
|
||||
commands: &mut Commands,
|
||||
@@ -396,37 +516,12 @@ fn resolve_anchor_law(
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
lighting: &crate::lighting::WowLighting,
|
||||
nodes: &mut Query<&mut crate::entity_shade::GroundShade>,
|
||||
bake_states: &Query<&BakeState>,
|
||||
anchor: Entity,
|
||||
anchor_t: &GlobalTransform,
|
||||
kind: &InteriorKind,
|
||||
previous: Option<AppliedLaw>,
|
||||
seated: Option<u16>,
|
||||
moved: bool,
|
||||
) -> AppliedLaw {
|
||||
let pos = anchor_t.translation();
|
||||
// The settled path (`!moved`): a seated anchor stays on its owned slot — refolded from the
|
||||
// cached ray products when the node's chases still move, returned as-is otherwise. A part
|
||||
// remembering Bake while the anchor holds NO slot is stale (its slot was freed while the part
|
||||
// sat outside the query) — fall through and re-resolve with a fresh ray.
|
||||
if !moved {
|
||||
if let Some(slot) = seated {
|
||||
if let (Ok(node), Ok(bake)) = (nodes.get(anchor), bake_states.get(anchor)) {
|
||||
let coeffs = fold_interior_probe(
|
||||
node.ambient.to_array(),
|
||||
(bake.word * node.intensity()).to_array(),
|
||||
bake.ref_point,
|
||||
&bake.lobes,
|
||||
);
|
||||
probes.update_owned(slot, coeffs);
|
||||
}
|
||||
return AppliedLaw::Bake(slot);
|
||||
}
|
||||
match previous {
|
||||
Some(AppliedLaw::Bake(_)) | None => {}
|
||||
Some(settled) => return settled,
|
||||
}
|
||||
}
|
||||
let verdict = indoor_verdict_at(wmos, instances.iter(), streamer, adt_tiles, pos);
|
||||
// Publish the outdoor GROUND kind to the node before the law resolves: standing on an
|
||||
// outdoor-class WMO surface (street/deck/porch) forces the lit 2.5 target — the WMO-linked
|
||||
@@ -565,17 +660,7 @@ fn seat_probe_slot(commands: &mut Commands, anchor: Entity, new: u16) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The stuck-black repair: a part whose cached law names a slot the anchor no longer owns
|
||||
/// (freed while the part sat outside the classifier's query — a fade/attach window) converges
|
||||
/// to the anchor's SEATED slot even while standing perfectly still. Pre-fix, the resolver
|
||||
/// trusted the stale part's slot: `update_owned` on the freed slot silently no-opped, the
|
||||
/// applied==law gate skipped every rewrite, and the unit rendered the freed slot's zeroed rows
|
||||
/// — a black silhouette that survived any in-room movement until the law itself changed
|
||||
/// (director-caught: charge across the doorway un-blacked it).
|
||||
#[test]
|
||||
fn a_stale_part_converges_to_the_anchors_seated_slot() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
fn classifier_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<WmoResidency>();
|
||||
world.init_resource::<Assets<WmoModel>>();
|
||||
@@ -583,6 +668,24 @@ mod tests {
|
||||
world.init_resource::<crate::lighting::WowLighting>();
|
||||
world.init_resource::<PropProbes>();
|
||||
world.init_resource::<TerrainStreamer>();
|
||||
world.init_resource::<InteriorReauthor>();
|
||||
world
|
||||
}
|
||||
|
||||
/// The stuck-black repair, on 0734's queue: a part whose last-written record names a slot the
|
||||
/// anchor no longer owns (it sat outside the classifier's write query — a fade window — across
|
||||
/// a slot change) converges to the anchor's standing law even while everything stands
|
||||
/// perfectly still, because re-entering the world enqueues it (here via the `on_add` hook; at
|
||||
/// runtime the fade-latch observer is the same edge). Pre-0734's ancestor bug: the resolver
|
||||
/// trusted the stale part's slot, `update_owned` on the freed slot silently no-opped, and the
|
||||
/// unit rendered the freed slot's zeroed rows — a black silhouette that survived any in-room
|
||||
/// movement until the law itself changed (director-caught: charge across the doorway
|
||||
/// un-blacked it).
|
||||
#[test]
|
||||
fn a_stale_part_converges_to_the_anchors_standing_law() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = classifier_world();
|
||||
let coeffs = [Vec4::ZERO; 7];
|
||||
|
||||
// The anchor's live owned slot — and a defunct one its part still remembers.
|
||||
@@ -598,10 +701,18 @@ mod tests {
|
||||
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
let anchor = world
|
||||
.spawn((GlobalTransform::default(), PropProbeSlot(live)))
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
PropProbeSlot(live),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Bake(live),
|
||||
last_pos: Vec3::ZERO, // matches the transform: the settled gate sees NO movement
|
||||
generation,
|
||||
kind_bake: true,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let mut lit = InteriorLit::new(
|
||||
anchor,
|
||||
InteriorKind::Bake {
|
||||
material: Handle::default(),
|
||||
center: Vec3::ZERO,
|
||||
@@ -609,11 +720,10 @@ mod tests {
|
||||
Handle::default(),
|
||||
);
|
||||
lit.applied = Some(AppliedLaw::Bake(stale));
|
||||
lit.last_pos = Vec3::ZERO; // matches the anchor: the settled gate sees NO movement
|
||||
lit.generation = generation;
|
||||
let part = world
|
||||
.spawn((
|
||||
lit,
|
||||
ClassifiedBy(anchor),
|
||||
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
|
||||
MeshTag(crate::mesh_tag::probe_bits(stale)),
|
||||
))
|
||||
@@ -624,7 +734,7 @@ mod tests {
|
||||
let lit = world.get::<InteriorLit>(part).unwrap();
|
||||
assert!(
|
||||
matches!(lit.applied, Some(AppliedLaw::Bake(s)) if s == live),
|
||||
"the part's law re-anchors on the seated slot"
|
||||
"the part's law re-anchors on the anchor's standing slot"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<MeshTag>(part).unwrap().0,
|
||||
@@ -638,6 +748,118 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A part spawned onto a SETTLED anchor (the gear-swap-indoors case) takes the standing law
|
||||
/// through the `on_add` hook + drain — no law change, no anchor movement, and still the fresh
|
||||
/// part's material/tag land on the anchor's law the very next classifier run.
|
||||
#[test]
|
||||
fn a_fresh_part_on_a_settled_anchor_takes_the_standing_law() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = classifier_world();
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
let anchor = world
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Matte,
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: false,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let part = world
|
||||
.spawn((
|
||||
InteriorLit::new(InteriorKind::Matte, Handle::default()),
|
||||
ClassifiedBy(anchor),
|
||||
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
|
||||
MeshTag(0),
|
||||
))
|
||||
.id();
|
||||
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
|
||||
let lit = world.get::<InteriorLit>(part).unwrap();
|
||||
assert_eq!(lit.applied, Some(AppliedLaw::Matte));
|
||||
assert_ne!(
|
||||
world.get::<MeshTag>(part).unwrap().0 & crate::mesh_tag::INTERIOR_FOG_BIT,
|
||||
0,
|
||||
"the day/night law carries the room's fog bit"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mixed-kind hole (0734 §3): a bake-capable part joining an anchor whose law was
|
||||
/// resolved from a matte-kind part drops the anchor's record — the next run re-rays with the
|
||||
/// bake kind in reach instead of riding the matte fallback until the anchor happens to move.
|
||||
#[test]
|
||||
fn a_bake_part_joining_a_matte_resolved_anchor_forces_a_re_resolve() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = classifier_world();
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
let anchor = world
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Exterior,
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: false,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
world.spawn((
|
||||
InteriorLit::new(
|
||||
InteriorKind::Bake {
|
||||
material: Handle::default(),
|
||||
center: Vec3::ZERO,
|
||||
},
|
||||
Handle::default(),
|
||||
),
|
||||
ClassifiedBy(anchor),
|
||||
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
|
||||
MeshTag(0),
|
||||
));
|
||||
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
|
||||
assert!(
|
||||
world.get::<InteriorAnchor>(anchor).is_none(),
|
||||
"the matte-resolved record is dropped so the next run re-rays"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fade-latch edge: removing a part's `RenderFade` re-enqueues it for authoring — the
|
||||
/// event that closes every fade-exclusion window (0734 §3; the old settled-path sweep is
|
||||
/// gone, so this observer IS the convergence path).
|
||||
#[test]
|
||||
fn a_fade_latch_enqueues_the_part_for_reauthoring() {
|
||||
let mut world = classifier_world();
|
||||
world.add_observer(enqueue_on_fade_latch);
|
||||
let part = world
|
||||
.spawn(InteriorLit::new(InteriorKind::Matte, Handle::default()))
|
||||
.id();
|
||||
world.resource_mut::<InteriorReauthor>().0.clear(); // drop the on_add entry
|
||||
world
|
||||
.entity_mut(part)
|
||||
.insert(crate::model_fade::RenderFade {
|
||||
started: 0.0,
|
||||
duration: 1.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
cutout: Handle::default(),
|
||||
blend: Handle::default(),
|
||||
});
|
||||
world
|
||||
.entity_mut(part)
|
||||
.remove::<crate::model_fade::RenderFade>();
|
||||
assert_eq!(
|
||||
world.resource::<InteriorReauthor>().0,
|
||||
vec![part],
|
||||
"the latch is the re-entry edge"
|
||||
);
|
||||
}
|
||||
|
||||
/// The teardown race, both arms: a slot seated on a live anchor swaps normally; one seated
|
||||
/// on an anchor whose despawn applied first neither panics (the old crash) nor leaks the
|
||||
/// freshly allocated slot (the pool never resets — an orphan would be held forever).
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::view::ViewDistance;
|
||||
/// The shared light, std430-packed as contiguous `vec4<f32>` rows. All-`vec4` so std430 == std140
|
||||
/// (each row 16-aligned, no stride surprises). The row order is the canonical layout every shader
|
||||
/// bound at `storage(90)` mirrors as a prefix — the WGSL structs in `wow_model.wgsl`/`terrain.wgsl`/
|
||||
/// `wow_particle.wgsl`. (`liquid.wgsl`/`wdl.wgsl` reuse the field NAMES but bind their own
|
||||
/// `wow_effect.wgsl`. (`liquid.wgsl`/`wdl.wgsl` reuse the field NAMES but bind their own
|
||||
/// per-material uniforms fed by `apply_wow_lighting` — editing this layout does NOT reach them.)
|
||||
/// 0 light_ambient (w=Mod2x 1.0) · 1 light_diffuse (w=clamp on) · 2 light_sun (w=dir/SH enable) ·
|
||||
/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end w=farclip) ·
|
||||
|
||||
+54
-460
@@ -3,40 +3,39 @@
|
||||
//! The 1.12 client simulates particles on the CPU and draws them as camera-facing, additive quads; we
|
||||
//! do the same (the GPU never touched the legacy particle path, and a CPU sim is the only way to match
|
||||
//! the integrator exactly — see decision 0014). Each [`ParticleEmitter`] owns a small pool of live
|
||||
//! particles and **one dynamically-rebuilt mesh**: every frame we age + integrate the pool, then
|
||||
//! expand each live particle into a billboard facing the camera, writing positions/uvs/colours into the
|
||||
//! mesh. Every cloud is **anchored to its MODEL's live position** (the reference rebuilds the
|
||||
//! particles: every frame we age + integrate the pool, then expand each live particle into a
|
||||
//! camera-facing quad written into the **shared effect-quad stream** ([`buffer::EffectQuads`],
|
||||
//! decision 0732 slice P1 — one CPU vertex vec + one GPU upload for the whole family, drawn by
|
||||
//! the dedicated lane in [`render`]; per-emitter `Mesh`/material assets are gone, and with them
|
||||
//! the per-frame allocator churn they priced). Every cloud is **anchored to its MODEL's live
|
||||
//! position** (the reference rebuilds the
|
||||
//! draw matrix with `translate(−emitterPos)` each frame — a moving model carries its flame, no
|
||||
//! world-frozen trails) while the emitter's BONE composes only each particle's birth: bone motion
|
||||
//! is baked per particle (an emitter orbiting on an animated bone — the food sparkle's global-
|
||||
//! sequence spin — births a moving ring of straight risers, never a swirling cloud; the client's
|
||||
//! only live-follow plumbing is file flag `0x4000`, unauthored by our content). File flag `0x10`
|
||||
//! additionally folds the live bone ROTATION at render instead of baking it at birth —
|
||||
//! byte-verified, wow-re `part-simspace-fields.md` + its `1f40db0b` corrections. The material is [`WowParticleMaterial`] — an unlit `StandardMaterial`
|
||||
//! shell (its **own** pipeline, so it adds zero buffers to the model bind group — the Metal cap
|
||||
//! that sank the dynamic-light attempt) whose fragment does the vanilla gamma-space combine
|
||||
//! (`shaders/wow_particle.wgsl`, decision 0152).
|
||||
//! byte-verified, wow-re `part-simspace-fields.md` + its `1f40db0b` corrections. The lane's
|
||||
//! fragment does the vanilla gamma-space combine (`shaders/wow_effect.wgsl`, decisions
|
||||
//! 0152/0161). The whole effect family — particles, ribbons, the decal family, water foam,
|
||||
//! precipitation — draws through the lane; `WowParticleMaterial` retired with slice P2 (0733).
|
||||
//!
|
||||
//! Over-life colour (incl. the alpha that is the additive weight), size and texture-cell are sampled
|
||||
//! from the parsed [`benilla_formats::OverLife`] ramp by the particle's normalized age.
|
||||
|
||||
use benilla_assets::ModelEmitter;
|
||||
use benilla_formats::{ParticleBlend, ParticleEmitterDef};
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::ExtendedMaterial;
|
||||
use benilla_formats::ParticleEmitterDef;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
|
||||
pub mod buffer;
|
||||
mod depthdump;
|
||||
mod material;
|
||||
mod emit;
|
||||
mod model;
|
||||
mod quads;
|
||||
pub mod render;
|
||||
mod sim;
|
||||
|
||||
pub use material::{WowParticleExt, WowParticleMaterial};
|
||||
use emit::{emit_local, next_u32, rand01, rand_s11};
|
||||
use sim::simulate_particles;
|
||||
|
||||
/// Hard cap on a single emitter's live particle count — a backstop against a pathological model. Real
|
||||
@@ -103,8 +102,9 @@ impl ParticleEmitter {
|
||||
}
|
||||
|
||||
/// A spawned particle emitter: its parsed def + the placement that maps its local space to the world,
|
||||
/// the live pool, the fractional emission accumulator, a per-emitter RNG, and the handle of the mesh we
|
||||
/// rewrite each frame. Despawns with its placement (the entity joins the placement's entity list).
|
||||
/// the live pool, the fractional emission accumulator, and a per-emitter RNG; its quads go into
|
||||
/// the shared effect stream ([`buffer::EffectQuads`]) each frame. Despawns with its placement
|
||||
/// (the entity joins the placement's entity list).
|
||||
#[derive(Component)]
|
||||
pub struct ParticleEmitter {
|
||||
def: ParticleEmitterDef,
|
||||
@@ -186,11 +186,14 @@ pub struct ParticleEmitter {
|
||||
/// (see [`owner_last_bias`]). Children carry the parent's, not their recursion model's: a
|
||||
/// child draws at the parent's anchor, so it is the parent's owner it has to clear.
|
||||
owner_reach: f32,
|
||||
mesh: Handle<Mesh>,
|
||||
/// The particle texture — also held here (not just on the material) so the sim can withhold drawing
|
||||
/// until it's resident: a still-loading or failed-to-decode texture would otherwise flash the engine
|
||||
/// fallback (white/magenta) through the additive blend. Particles still simulate meanwhile.
|
||||
/// The particle texture — the sim withholds pushing quads until it's resident: a
|
||||
/// still-loading or failed-to-decode texture would otherwise flash the engine fallback
|
||||
/// (white/magenta) through the additive blend. Particles still simulate meanwhile.
|
||||
texture: Handle<Image>,
|
||||
/// The draw-set gate's memory (was the emitter entity's `Visibility` flip): true while the
|
||||
/// owner is out of the frame's draw set — the edge on which model-instance entities are
|
||||
/// hidden. Quads need no flag: a gated pool simply pushes nothing into the shared stream.
|
||||
gated: bool,
|
||||
/// The pending recursion model (wow-re `part-child-recursion.md`): once the asset resolves,
|
||||
/// [`wire_child_emitters`] turns its own emitters (cap 4, the reference's `0x7b5dfe`) into
|
||||
/// [`Self::children`] and clears this.
|
||||
@@ -208,8 +211,9 @@ pub struct ParticleEmitter {
|
||||
}
|
||||
|
||||
/// One wired CHILD emitter (see [`ParticleEmitter::children`]): the recursion model's own
|
||||
/// emitter def + texture, a private pool, and the mesh entity it draws through. Simulated
|
||||
/// entirely inside the parent's sim block (no ECS ordering with the parent's fresh pool).
|
||||
/// emitter def + texture and a private pool. Simulated entirely inside the parent's sim block
|
||||
/// (no ECS ordering with the parent's fresh pool); its quads go into the shared stream as one
|
||||
/// draw of its own, at the parent's anchor and rung.
|
||||
struct ChildEmitter {
|
||||
def: ParticleEmitterDef,
|
||||
texture: Handle<Image>,
|
||||
@@ -217,20 +221,17 @@ struct ChildEmitter {
|
||||
accumulator: f32,
|
||||
gate_prev: bool,
|
||||
rng: u32,
|
||||
mesh: Handle<Mesh>,
|
||||
/// The child's draw entity (a world-root mesh like the parent's own); despawned with the
|
||||
/// parent.
|
||||
entity: Entity,
|
||||
}
|
||||
|
||||
/// Marker on a child emitter's draw entity — keeps the sim's transform queries provably
|
||||
/// disjoint (the owner-read query excludes these; the child-draw write query requires it).
|
||||
/// Marker on a MODEL-particle instance entity ([`model`]) — keeps the sim's transform queries
|
||||
/// provably disjoint (the owner-read query excludes these; the instance write query requires
|
||||
/// it).
|
||||
#[derive(Component)]
|
||||
pub struct ChildDraw;
|
||||
|
||||
#[cfg(test)]
|
||||
impl ChildEmitter {
|
||||
/// A bare child for unit tests — no draw entity/mesh wiring.
|
||||
/// A bare child for unit tests.
|
||||
fn bare(def: ParticleEmitterDef) -> Self {
|
||||
Self {
|
||||
def,
|
||||
@@ -239,161 +240,10 @@ impl ChildEmitter {
|
||||
accumulator: 0.0,
|
||||
gate_prev: false,
|
||||
rng: 7,
|
||||
mesh: Handle::default(),
|
||||
entity: Entity::PLACEHOLDER,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// xorshift32 — a dependency-free PRNG for particle jitter (visual only; determinism not required).
|
||||
fn next_u32(state: &mut u32) -> u32 {
|
||||
let mut x = *state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
*state = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// A uniform random `f32` in `[0, 1)`.
|
||||
fn rand01(state: &mut u32) -> f32 {
|
||||
(next_u32(state) >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
|
||||
/// A symmetric random `f32` in `(−1, 1)` — the reference's `S11` draw (its RNG builds ±spans with
|
||||
/// a ×2.0 constant; every emission distribution below is plain uniform, wow-re
|
||||
/// `part-shape-kernels.md` §4 — we mirror the distributions, not the bit stream).
|
||||
fn rand_s11(state: &mut u32) -> f32 {
|
||||
rand01(state) * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// The **emission shape kernel** (wow-re `part-shape-kernels.md`, byte-verified off the three
|
||||
/// vtable type-spawn generators): one birth's position + unit velocity direction, in the emitter's
|
||||
/// local WoW frame (Z up, origin at the emitter record's `position`). The caller applies the speed
|
||||
/// roll and the space-mode transform.
|
||||
///
|
||||
/// - **Plane** (`0x7b8890`): position uniform in the ±½·area rectangle; direction a cone around +Z
|
||||
/// with **symmetric** angles θ = S11·verticalRange, φ = S11·horizontalRange (the reference draws
|
||||
/// ±range, not [0, range] — a one-sided cone tilted every flame the same way).
|
||||
/// - **Sphere** (`0x7b8d70`): radius uniform in [areaLength, areaWidth] (= min/max radius for this
|
||||
/// shape), position on the shell at latitude S11·verticalRange / longitude S11·horizontalRange;
|
||||
/// direction radial outward (flag `0x4000` ⇒ straight +Z instead).
|
||||
/// - **Spline** (`0x7b9500`): born ON the authored arc-length-parameterized Bézier chain at a
|
||||
/// uniform arc fraction in `[tMin, tMax]`; velocity = +Z spun about the local tangent by
|
||||
/// ψ = S11·spin (zero velocity when no spin; radial-from-pivot when zSource authored), plus
|
||||
/// an optional along-velocity scatter jitter — see the arm's comment.
|
||||
/// - Any shape with `zSource ≠ 0`: direction is **radial from the pivot** `(0, 0, zSource)` toward
|
||||
/// the birth point (fountains that arc outward from a point below the basin).
|
||||
fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3) {
|
||||
let origin = Vec3::from(def.position);
|
||||
// The emitter-frame R(+Z, 90°) applied at every branch's return — see the law note at the
|
||||
// tail. Per the wow-re bytes the prepend is per-EMITTER and subclass-independent ("gated
|
||||
// only by has-emitters"), so the spline kernel takes it exactly like sphere/plane (the
|
||||
// 0563 landing missed this branch — caught by the compensation audit).
|
||||
let rot90 = |v: Vec3| Vec3::new(-v.y, v.x, v.z);
|
||||
// SPLINE (`0x7b9500`, wow-re `part-shape-kernels.md` §3 — VERIFIED, incl. the scatter
|
||||
// bytes): born ON the authored Bézier chain at arc fraction `t ∈ [tMin, tMax]` (the
|
||||
// repurposed area fields). Velocity: radial from the zSource pivot when authored; else +Z
|
||||
// rotated about the local spline tangent by ψ = S11·spin (the reference extracts the
|
||||
// transposed row — a −ψ rotation — indistinguishable under the symmetric draw), with an
|
||||
// optional `U01·scatter` position jitter along the velocity; else ZERO (the particle sits
|
||||
// on the curve and only gravity/drag move it). A degenerate record (no parsed chain)
|
||||
// falls through to the plane kernel, as before.
|
||||
if let (benilla_formats::ParticleShape::Spline, Some(spline)) = (def.shape, &def.spline) {
|
||||
let (t0, t1) = (
|
||||
def.area_length.clamp(0.0, 1.0),
|
||||
def.area_width.clamp(0.0, 1.0),
|
||||
);
|
||||
let t = t0 + rand01(rng) * (t1 - t0);
|
||||
let mut pos = origin + Vec3::from(spline.eval(t));
|
||||
let dir = if def.z_source != 0.0 {
|
||||
(pos - origin - Vec3::new(0.0, 0.0, def.z_source)).normalize_or(Vec3::Z)
|
||||
} else if def.vertical_range != 0.0 {
|
||||
let tangent = Vec3::from(spline.tangent(t)).normalize_or(Vec3::Z);
|
||||
let (s, c) = (rand_s11(rng) * def.vertical_range).sin_cos();
|
||||
let dir = Vec3::Z * c + tangent.cross(Vec3::Z) * s + tangent * (tangent.z * (1.0 - c));
|
||||
if def.horizontal_range != 0.0 {
|
||||
pos += rand01(rng) * def.horizontal_range * dir;
|
||||
}
|
||||
dir
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
return (origin + rot90(pos - origin), rot90(dir));
|
||||
}
|
||||
// Birth offset in the emitter frame (before the record-position translation). A sphere
|
||||
// draws ONE lat/lon unit vector serving both the shell point and (below) the radial
|
||||
// velocity — the reference reuses the exact sincos pair (`0x7b8fba`), which is what keeps a
|
||||
// ZERO-radius sphere (the fireball impact's plume burst: min = max = 0) spraying uniformly
|
||||
// instead of collapsing every direction to the degenerate normalize fallback.
|
||||
let (local, shell) = if def.shape == benilla_formats::ParticleShape::Sphere {
|
||||
let r = def.area_length + rand01(rng) * (def.area_width - def.area_length).max(0.0);
|
||||
let lat = rand_s11(rng) * def.vertical_range;
|
||||
let lon = rand_s11(rng) * def.horizontal_range;
|
||||
let (slat, clat) = lat.sin_cos();
|
||||
let (slon, clon) = lon.sin_cos();
|
||||
let shell = Vec3::new(clat * clon, clat * slon, slat); // unit by construction
|
||||
(r * shell, Some(shell))
|
||||
} else {
|
||||
(
|
||||
Vec3::new(
|
||||
rand_s11(rng) * 0.5 * def.area_width,
|
||||
rand_s11(rng) * 0.5 * def.area_length,
|
||||
0.0,
|
||||
),
|
||||
None,
|
||||
)
|
||||
};
|
||||
let dir = if def.z_source != 0.0 {
|
||||
// Radial from the (0, 0, zSource) pivot — degenerate at the pivot itself falls back to +Z.
|
||||
(local - Vec3::new(0.0, 0.0, def.z_source)).normalize_or(Vec3::Z)
|
||||
} else if let Some(shell) = shell {
|
||||
if def.sphere_up() {
|
||||
Vec3::Z
|
||||
} else {
|
||||
shell // radial outward — the same unit draw as the birth point
|
||||
}
|
||||
} else {
|
||||
let theta = rand_s11(rng) * def.vertical_range;
|
||||
let phi = rand_s11(rng) * def.horizontal_range;
|
||||
let (st, ct) = theta.sin_cos();
|
||||
let (sp, cp) = phi.sin_cos();
|
||||
Vec3::new(st * cp, st * sp, ct)
|
||||
};
|
||||
// The emitter-frame R(+Z, 90°) — wow-re `part-modelspace-animbone.md`, §5 byte-verified
|
||||
// (`0x719114–0x719142`: axis literal (0,0,1), angle π·0.5, Rodrigues + mat4_mul into the
|
||||
// per-frame emitter matrix rt+0x1fc): the reference prepends a fixed +90°-about-local-+Z
|
||||
// to EVERY M2 particle emitter's bone matrix, applied to the kernel-relative vectors only
|
||||
// (the record-position translation stays outside it). It is what turns the sphere kernel's
|
||||
// local-XZ ring into the wheel PERPENDICULAR to a rotor bone's +X spin — the InstancePortal
|
||||
// vortex swirling in place instead of tumbling edge-on — and what maps a billboard-bone
|
||||
// starburst's spray plane onto the screen plane (the impact flashes). Applied at emission,
|
||||
// so every consumer (anchored bake, model-space storage, child emitters) inherits it.
|
||||
(origin + rot90(local), rot90(dir))
|
||||
}
|
||||
|
||||
/// Empty a particle mesh's attributes/indices (draws nothing). Used for a fresh emitter, and to stop
|
||||
/// drawing one whose texture isn't resident.
|
||||
fn clear_particle_mesh(mesh: &mut Mesh) {
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, Vec::<[f32; 3]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, Vec::<[f32; 4]>::new());
|
||||
mesh.insert_indices(Indices::U32(Vec::new()));
|
||||
}
|
||||
|
||||
/// An empty dynamic mesh (rewritten every frame by [`simulate_particles`]). `RenderAssetUsages::default`
|
||||
/// keeps it in the main world so we can mutate it; positions are world-space (entity transform is
|
||||
/// identity).
|
||||
fn blank_particle_mesh() -> Mesh {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
clear_particle_mesh(&mut mesh);
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Spawn an emitter entity for one [`ModelEmitter`] at `placement`. `None` if the emitter has no
|
||||
/// resolved texture (nothing to draw). `owner` ties the emitter to a live entity's frame — it follows
|
||||
/// that entity's transform each frame and despawns with it: `(entity, [0; 3])` for a streamed
|
||||
@@ -548,12 +398,9 @@ impl EmitterFade {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // the spawn's full wiring: assets + owner/attach frames
|
||||
#[allow(clippy::too_many_arguments)] // the spawn's full wiring: the owner/attach/anchor frames
|
||||
pub fn spawn_emitter(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<WowParticleMaterial>,
|
||||
light: &SharedLightBuffer,
|
||||
emitter: &ModelEmitter,
|
||||
placement: Transform,
|
||||
owner: Option<(Entity, [f32; 3])>,
|
||||
@@ -599,8 +446,6 @@ pub fn spawn_emitter(
|
||||
// The owner's reach is model-local; the rung is a view-space distance, so it takes the
|
||||
// placement scale with it (a scaled-up creature's batches spread proportionally).
|
||||
let owner_reach = emitter.owner_reach * placement.scale.max_element();
|
||||
let material = particle_material(&def, &texture, owner_reach, materials, light);
|
||||
let mesh = meshes.add(blank_particle_mesh());
|
||||
// Seed the RNG from the placement position so two campfires don't flicker in lockstep.
|
||||
let t = placement.translation;
|
||||
let rng = (t.x.to_bits() ^ t.y.to_bits().rotate_left(11) ^ t.z.to_bits().rotate_left(22))
|
||||
@@ -609,12 +454,9 @@ pub fn spawn_emitter(
|
||||
Some(
|
||||
commands
|
||||
.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(material),
|
||||
// The sim writes the anchor here each frame; verts are anchor-relative (the
|
||||
// transparent-pass sort key — see simulate_particles).
|
||||
// The sim writes the anchor here each frame — the census/phase instruments'
|
||||
// read point (the draw's own sort key rides the draw record instead).
|
||||
Transform::IDENTITY,
|
||||
NoFrustumCulling, // the mesh AABB is a moving cloud; skip culling
|
||||
ParticleEmitter {
|
||||
def,
|
||||
placement,
|
||||
@@ -635,8 +477,8 @@ pub fn spawn_emitter(
|
||||
seq,
|
||||
rng,
|
||||
owner_reach,
|
||||
mesh,
|
||||
texture,
|
||||
gated: false,
|
||||
recursion: emitter.recursion.clone(),
|
||||
children: Vec::new(),
|
||||
geometry: emitter.geometry.clone(),
|
||||
@@ -647,52 +489,6 @@ pub fn spawn_emitter(
|
||||
)
|
||||
}
|
||||
|
||||
/// The shared particle material recipe (the parent's and every child's): unlit, never
|
||||
/// backface-culled, blend from the def, and the per-blend fog COLOUR policy (wow-re
|
||||
/// `part-additive-combine.md` §4, byte-verified at `0x70baf0`'s own dispatch — the same
|
||||
/// blend-identity table M2 batches take): an Add emitter fogs toward BLACK (fades under a veil
|
||||
/// instead of gaining grey — the storm-veil level-up fix); Alpha/Opaque emitters fog toward the
|
||||
/// ordinary scene colour; and an emitter with **file flag 0x8 ("unfogged", §4.1) draws with fog
|
||||
/// disabled outright** — the policy is forced to 0 before the colour table is even consulted.
|
||||
/// See [`WowParticleExt::params`].
|
||||
fn particle_material(
|
||||
def: &ParticleEmitterDef,
|
||||
texture: &Handle<Image>,
|
||||
owner_reach: f32,
|
||||
materials: &mut Assets<WowParticleMaterial>,
|
||||
light: &SharedLightBuffer,
|
||||
) -> Handle<WowParticleMaterial> {
|
||||
let alpha_mode = match def.blend {
|
||||
ParticleBlend::Add => AlphaMode::Add,
|
||||
ParticleBlend::Alpha => AlphaMode::Blend,
|
||||
ParticleBlend::Opaque => AlphaMode::Opaque,
|
||||
};
|
||||
let fog_policy = if def.flags & 0x8 != 0 {
|
||||
0.0
|
||||
} else if matches!(def.blend, ParticleBlend::Add) {
|
||||
2.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
materials.add(ExtendedMaterial {
|
||||
base: StandardMaterial {
|
||||
base_color_texture: Some(texture.clone()),
|
||||
unlit: true,
|
||||
alpha_mode,
|
||||
cull_mode: None, // billboards: never backface-cull
|
||||
// The reference's "a model's particles draw after that model's batches" — see
|
||||
// [`owner_last_bias`].
|
||||
depth_bias: owner_last_bias(owner_reach),
|
||||
..default()
|
||||
},
|
||||
extension: WowParticleExt {
|
||||
light_buf: light.0.clone(),
|
||||
params: Vec4::new(fog_policy, 0.0, 0.0, 0.0),
|
||||
mod2x: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The **owner-last** draw-order rung: how far to bias one of a model's EFFECTS past that model's
|
||||
/// own transparent batches (`Transparent3d` sort key = view-space z **+ depth_bias**, ascending, so
|
||||
/// a positive bias draws LATER — the sign law lives in [`crate::sky_order`]).
|
||||
@@ -731,16 +527,12 @@ pub(crate) fn owner_last_bias(reach: f32) -> f32 {
|
||||
|
||||
/// Wire pending CHILD emitters (wow-re `part-child-recursion.md`, VERIFIED): once a parent's
|
||||
/// recursion model resolves, its own particle emitters — **capped at 4** (`0x7b5dfe`) — become
|
||||
/// the parent's children, each with its private pool and draw entity. The reference wires at
|
||||
/// the child model's async-load completion (`0x7b5dd0`); this system is that completion hook.
|
||||
/// A child never self-emits ambiently — its only particle source is the per-parent-particle
|
||||
/// drive in the sim.
|
||||
/// the parent's children, each with its private pool (drawn from the shared stream at the
|
||||
/// parent's anchor and rung). The reference wires at the child model's async-load completion
|
||||
/// (`0x7b5dd0`); this system is that completion hook. A child never self-emits ambiently — its
|
||||
/// only particle source is the per-parent-particle drive in the sim.
|
||||
pub(crate) fn wire_child_emitters(
|
||||
mut commands: Commands,
|
||||
models: Res<Assets<benilla_assets::M2Model>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
light: Res<SharedLightBuffer>,
|
||||
mut emitters: Query<&mut ParticleEmitter>,
|
||||
) {
|
||||
for mut emitter in &mut emitters {
|
||||
@@ -748,7 +540,6 @@ pub(crate) fn wire_child_emitters(
|
||||
continue;
|
||||
};
|
||||
let mut rng_seed = emitter.rng.rotate_left(7) | 1;
|
||||
let parent_reach = emitter.owner_reach;
|
||||
let children: Vec<ChildEmitter> = model
|
||||
.emitters
|
||||
.iter()
|
||||
@@ -758,19 +549,6 @@ pub(crate) fn wire_child_emitters(
|
||||
if em.def.lifespan <= 0.0 || em.def.timing.peak_rate() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The PARENT's rung, not the recursion model's (see `ParticleEmitter::owner_reach`).
|
||||
let material =
|
||||
particle_material(&em.def, &texture, parent_reach, &mut materials, &light);
|
||||
let mesh = meshes.add(blank_particle_mesh());
|
||||
let entity = commands
|
||||
.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(material),
|
||||
Transform::IDENTITY,
|
||||
NoFrustumCulling,
|
||||
ChildDraw,
|
||||
))
|
||||
.id();
|
||||
Some(ChildEmitter {
|
||||
def: em.def.clone(),
|
||||
texture,
|
||||
@@ -781,8 +559,6 @@ pub(crate) fn wire_child_emitters(
|
||||
rng_seed = rng_seed.wrapping_mul(0x9E37_79B9) | 1;
|
||||
rng_seed
|
||||
},
|
||||
mesh,
|
||||
entity,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -856,17 +632,27 @@ pub struct ParticlePlugin;
|
||||
|
||||
impl Plugin for ParticlePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(MaterialPlugin::<WowParticleMaterial>::default())
|
||||
// The whole effect family draws through the dedicated lane
|
||||
// ([`render::EffectLanePlugin`]); `WowParticleMaterial` and its MaterialPlugin retired
|
||||
// with slice P2 (0733 §1) when precipitation and water foam moved onto the stream.
|
||||
app.add_plugins(render::EffectLanePlugin)
|
||||
.init_resource::<ParticleTuning>()
|
||||
.init_resource::<buffer::EffectQuads>()
|
||||
// PostUpdate, after the billboard joint palette: an emitter riding a billboarded
|
||||
// bone must sample the REPLACED frame, same frame — an Update-time read gets the
|
||||
// un-billboarded pose back (avian's fixed-loop sync re-propagates from locals), and
|
||||
// the Demon Skin flames followed the character instead of the camera. This also
|
||||
// retires the one-frame lag emitters on ANY animated joint carried while reading
|
||||
// last frame's globals from Update.
|
||||
//
|
||||
// `begin_effect_frame` clears the shared stream before BOTH writers (the ribbon sim
|
||||
// is the other), so the writer order between them stays free.
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
(
|
||||
buffer::begin_effect_frame
|
||||
.before(simulate_particles)
|
||||
.before(crate::ribbons::simulate_ribbons),
|
||||
wire_child_emitters,
|
||||
simulate_particles,
|
||||
model::update_model_particles,
|
||||
@@ -882,11 +668,11 @@ impl Plugin for ParticlePlugin {
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use benilla_formats::{CellRamp, OverLife, ParticleShape};
|
||||
use benilla_formats::ParticleShape;
|
||||
|
||||
/// The minimal def, exposed for sibling-module tests (the sim's child-drive test).
|
||||
pub(crate) fn plain_def() -> ParticleEmitterDef {
|
||||
def(ParticleShape::Plane)
|
||||
super::emit::tests::def(ParticleShape::Plane)
|
||||
}
|
||||
|
||||
/// Camera at the origin looking down −Z (Bevy's convention), and an owner sphere `depth` yd
|
||||
@@ -992,56 +778,6 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal def for kernel tests — only the fields [`emit_local`] reads matter.
|
||||
fn def(shape: ParticleShape) -> ParticleEmitterDef {
|
||||
ParticleEmitterDef {
|
||||
flags: 0,
|
||||
position: [1.0, 2.0, 3.0],
|
||||
bone: 0,
|
||||
shape,
|
||||
blend: benilla_formats::ParticleBlend::Add,
|
||||
texture: None,
|
||||
tile_rows: 1,
|
||||
tile_cols: 1,
|
||||
head_tail: 0,
|
||||
emission_speed: 1.0,
|
||||
speed_variation: 0.0,
|
||||
vertical_range: 0.5,
|
||||
horizontal_range: std::f32::consts::PI,
|
||||
gravity: 0.0,
|
||||
lifespan: 1.0,
|
||||
timing: benilla_formats::EmitTiming::constant(10.0),
|
||||
area_length: 2.0,
|
||||
area_width: 4.0,
|
||||
z_source: 0.0,
|
||||
drag: 0.0,
|
||||
tail_time: 0.0,
|
||||
spline: None,
|
||||
geometry_model: None,
|
||||
recursion_model: None,
|
||||
angular_velocity_min: [0.0; 3],
|
||||
angular_velocity_max: [0.0; 3],
|
||||
inherit_scale: 0.0,
|
||||
follow_speed1: 0.0,
|
||||
follow_scale1: 0.0,
|
||||
follow_speed2: 0.0,
|
||||
follow_scale2: 0.0,
|
||||
twinkle_speed: 0.0,
|
||||
twinkle_percent: 1.0,
|
||||
twinkle_min: 0.0,
|
||||
twinkle_max: 0.0,
|
||||
spin: 0.0,
|
||||
over_life: OverLife {
|
||||
mid: 0.5,
|
||||
color: [[1.0; 4]; 3],
|
||||
scale: [1.0; 3],
|
||||
head_cells: [CellRamp::new(0, 0); 2],
|
||||
tail_cells: [CellRamp::new(0, 0); 2],
|
||||
repeat: [1.0; 2],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The BURST emission model (file flag 0x8000 — Feint/Eviscerate impact's plume+crescents):
|
||||
/// one `ftol(rate·scale)` puff on the rising edge of `(enabled && rate > 0)`, latched while
|
||||
/// the gate holds, re-armed when it falls. (The rate/enabled *sampling* laws — step vs lerp
|
||||
@@ -1093,7 +829,7 @@ pub(crate) mod tests {
|
||||
/// slope and intercept).
|
||||
#[test]
|
||||
fn follow_line_matches_the_authored_two_point_response() {
|
||||
let mut d = def(ParticleShape::Plane);
|
||||
let mut d = super::emit::tests::def(ParticleShape::Plane);
|
||||
d.follow_speed1 = 1.0;
|
||||
d.follow_scale1 = 0.2;
|
||||
d.follow_speed2 = 3.0;
|
||||
@@ -1107,146 +843,4 @@ pub(crate) mod tests {
|
||||
"equal speeds → the reference zeroes the response"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plane births stay in the ±½·area rectangle around the record position, and the cone is
|
||||
/// SYMMETRIC (θ = S11·range — wow-re `part-shape-kernels.md`'s correction of our old
|
||||
/// [0, range) draw): with a wide sample, x-velocities must land on both signs. The
|
||||
/// rectangle rides the R(+Z,90°) emitter frame (`emit_local`'s tail): the kernel's
|
||||
/// width-along-x/length-along-y square lands width-along-y/length-along-x.
|
||||
#[test]
|
||||
fn plane_kernel_rect_bounds_and_symmetric_cone() {
|
||||
let d = def(ParticleShape::Plane);
|
||||
let mut rng = 12345u32;
|
||||
let (mut neg, mut pos) = (false, false);
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
assert!(
|
||||
(p.x - 1.0).abs() <= 1.0 + 1e-4,
|
||||
"x within ±½·length of position (post-R)"
|
||||
);
|
||||
assert!(
|
||||
(p.y - 2.0).abs() <= 2.0 + 1e-4,
|
||||
"y within ±½·width of position (post-R)"
|
||||
);
|
||||
assert_eq!(p.z, 3.0);
|
||||
assert!(dir.z > 0.0, "cone around +Z stays upward for range < π/2");
|
||||
neg |= dir.x < -1e-3;
|
||||
pos |= dir.x > 1e-3;
|
||||
}
|
||||
assert!(neg && pos, "symmetric cone covers both x signs");
|
||||
}
|
||||
|
||||
/// Sphere births sit on a shell with radius in [areaLength, areaWidth] (min/max radius for
|
||||
/// this shape) and fly radially outward through the shell point.
|
||||
#[test]
|
||||
fn sphere_kernel_radius_bounds_and_radial_velocity() {
|
||||
let d = def(ParticleShape::Sphere);
|
||||
let mut rng = 99u32;
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
let rel = p - Vec3::new(1.0, 2.0, 3.0);
|
||||
let r = rel.length();
|
||||
assert!(
|
||||
(2.0 - 1e-3..=4.0 + 1e-3).contains(&r),
|
||||
"shell radius {r} within [min, max]"
|
||||
);
|
||||
assert!(
|
||||
rel.normalize().dot(dir) > 0.999,
|
||||
"velocity radial through the shell point"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A ZERO-radius sphere (min = max = 0 — the fireball impact's plume burst) still sprays its
|
||||
/// velocities across the authored lat/lon spread: the direction is the shell unit vector
|
||||
/// itself (`0x7b8fba` reuses the sincos pair), never a normalize of the (zero) birth offset.
|
||||
#[test]
|
||||
fn zero_radius_sphere_still_disperses() {
|
||||
let mut d = def(ParticleShape::Sphere);
|
||||
d.area_length = 0.0;
|
||||
d.area_width = 0.0;
|
||||
d.vertical_range = std::f32::consts::PI; // the MoltenBlast plume: full ±π latitude fan
|
||||
d.horizontal_range = 0.0;
|
||||
let mut rng = 4242u32;
|
||||
let (mut up, mut down, mut fwd, mut back) = (false, false, false, false);
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
assert_eq!(p, Vec3::new(1.0, 2.0, 3.0), "births at the centre");
|
||||
assert!((dir.length() - 1.0).abs() < 1e-4, "unit direction");
|
||||
assert_eq!(
|
||||
dir.x, 0.0,
|
||||
"zero longitude range pins the fan to the YZ plane (the R(+Z,90°) emitter frame)"
|
||||
);
|
||||
up |= dir.z > 0.5;
|
||||
down |= dir.z < -0.5;
|
||||
fwd |= dir.y > 0.5;
|
||||
back |= dir.y < -0.5;
|
||||
}
|
||||
assert!(
|
||||
up && down && fwd && back,
|
||||
"the fan covers the full vertical ring, not a single degenerate ray"
|
||||
);
|
||||
}
|
||||
|
||||
/// The SPLINE kernel (`0x7b9500`): births sit ON the authored chain (origin-composed) at
|
||||
/// arc fractions inside [tMin, tMax]; zero spin ⇒ ZERO velocity; a spin range fans +Z
|
||||
/// about the local tangent (here +X ⇒ the fan stays in the YZ plane), and scatter jitters
|
||||
/// the position along the velocity by at most its own length.
|
||||
#[test]
|
||||
fn spline_kernel_births_on_the_chain() {
|
||||
let x = |v: f32| [v, 0.0, 0.0];
|
||||
let mut d = def(ParticleShape::Spline);
|
||||
d.spline = benilla_formats::SplineData::new(vec![
|
||||
x(0.0),
|
||||
x(1.0),
|
||||
x(2.0),
|
||||
x(3.0), // one straight segment along +X
|
||||
]);
|
||||
d.area_length = 0.25; // tMin
|
||||
d.area_width = 0.75; // tMax
|
||||
d.vertical_range = 0.0;
|
||||
d.z_source = 0.0;
|
||||
let mut rng = 31u32;
|
||||
// The R(+Z,90°) emitter frame (`emit_local`'s tail) turns the authored +X chain to +Y.
|
||||
for _ in 0..64 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
let local = p - Vec3::new(1.0, 2.0, 3.0); // minus the record position
|
||||
assert!(
|
||||
(0.75 - 1e-3..=2.25 + 1e-3).contains(&local.y),
|
||||
"on the chain inside [tMin, tMax] (post-R along +Y): {}",
|
||||
local.y
|
||||
);
|
||||
assert_eq!((local.x, local.z), (0.0, 0.0));
|
||||
assert_eq!(dir, Vec3::ZERO, "no spin, no zSource: the flame stands");
|
||||
}
|
||||
// A spin range fans +Z about the +X tangent — the kernel's YZ fan lands in XZ post-R.
|
||||
d.vertical_range = 1.0;
|
||||
d.horizontal_range = 0.5; // scatter
|
||||
let (mut low, mut high) = (false, false);
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
assert!(dir.y.abs() < 1e-4, "fan in the XZ plane (post-R)");
|
||||
assert!((dir.length() - 1.0).abs() < 1e-4);
|
||||
assert!(dir.z > 0.0, "±1 rad about +Z stays upward");
|
||||
low |= dir.x > 0.5;
|
||||
high |= dir.x < -0.5;
|
||||
// The scatter jitter is along dir, bounded by its own length.
|
||||
let local = p - Vec3::new(1.0, 2.0, 3.0);
|
||||
assert!(local.x.hypot(local.z) <= 0.5 + 1e-3, "jitter ≤ scatter");
|
||||
}
|
||||
assert!(low && high, "the fan covers both spin signs");
|
||||
}
|
||||
|
||||
/// zSource ≠ 0 redirects any shape's velocity radially away from the (0, 0, zSource) pivot.
|
||||
#[test]
|
||||
fn z_source_pivots_the_velocity() {
|
||||
let mut d = def(ParticleShape::Plane);
|
||||
d.z_source = -1.0; // pivot below the emitter → births fly up-and-outward
|
||||
let mut rng = 7u32;
|
||||
for _ in 0..64 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
let rel = (p - Vec3::new(1.0, 2.0, 3.0)) - Vec3::new(0.0, 0.0, -1.0);
|
||||
assert!(rel.normalize().dot(dir) > 0.999, "radial from the pivot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! The **shared effect stream** — the one CPU vertex stream every dynamic-effect family writes
|
||||
//! into each frame (particles, ribbons, and — since 0733 — the decal family, water foam, and
|
||||
//! precipitation), and the draw records that slice it (decisions 0732 P1/P2, 0733).
|
||||
//!
|
||||
//! Before this, every effect owned a `Mesh` asset rewritten per frame: ~145 mesh `Modified`
|
||||
//! events/frame at the LBRS pin, each a full free+realloc in Bevy's mesh allocator (no partial
|
||||
//! update exists — `allocator.rs:650`), and together they held the `AssetChanged<Mesh3d>`
|
||||
//! short-circuit open so every material type's specialization probe ran over its whole
|
||||
//! population every frame. One shared buffer + directly-constructed `Transparent3d` items
|
||||
//! (the bevy_ui_render shape) converts the whole family from population-priced to
|
||||
//! change-priced: one `write_buffer` per frame, zero mesh assets, zero material assets.
|
||||
//!
|
||||
//! The write protocol: a system calls [`EffectQuads::begin`], pushes **world-space** vertices —
|
||||
//! whole quads (4 corners in perimeter order, closed by the `[0,1,2, 0,2,3]` pattern) or a
|
||||
//! triangle list — then commits one draw for the range ([`EffectQuads::commit_quads`] /
|
||||
//! [`EffectQuads::commit_tris`]). The render half rebases every draw's vertices against its
|
||||
//! target view's camera position before upload (0733 §2 — absolute coordinates through the view
|
||||
//! transform shear thin geometry apart far from the origin; the precip module learned this
|
||||
//! empirically at ~9000 yd), builds the frame's index stream in sorted-item order, and merges
|
||||
//! sort-adjacent draws that share (pipeline, texture, light, fog) into single draw calls
|
||||
//! (0732 P2). The sort key is [`EffectDraw::anchor`] view-z + [`EffectDraw::bias`] — the ladder
|
||||
//! rungs (owner-last 0719/0721, the decal biases, foam's water tie-break) moved from material
|
||||
//! `depth_bias` into the item key (`sky_order`'s sign law); the rasterizer half of the old
|
||||
//! material `depth_bias` lives on as [`EffectDraw::raster_bias`] (the coplanar decals need it).
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use benilla_formats::{ModelBlend, ParticleBlend};
|
||||
use bevy::asset::AssetId;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::Buffer;
|
||||
|
||||
/// One vertex of the shared lane. **World-space** position in the stream; the render-world
|
||||
/// prepare pass rebases it camera-relative before upload (0733 §2), so instruments reading the
|
||||
/// stream (depth probe, depth dump) always see world coordinates.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct EffectVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
/// RAW authored gamma-space RGBA (the GAMMA LANE invariant, 0161) — alpha is the blend
|
||||
/// weight, never encoded.
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
|
||||
/// The lane's blend variants (0733 §4) — a superset of the file-format enums it serves:
|
||||
/// [`ParticleBlend`]'s three, plus the multiplicative pair the decal family and rain need.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum EffectBlend {
|
||||
/// `(SRC_ALPHA, ONE)` via premultiplied-alpha + the shader's gamma `rgb·a` fold (0160/0161).
|
||||
Add,
|
||||
/// Standard alpha blending.
|
||||
Alpha,
|
||||
/// No blend, depth-write ON — drawn in the transparent bracket at the owner rung (0719).
|
||||
Opaque,
|
||||
/// `dst · lerp(1, src, α)` — bevy's `AlphaMode::Multiply` state (`(Dst, 1−srcα)` + shader
|
||||
/// premultiply, bevy_pbr mesh.rs:2486): the blob shadow's `GL_DST_COLOR/GL_ZERO`-with-fade,
|
||||
/// and `ModelBlend::Mod` (0528) at α = 1.
|
||||
Multiply,
|
||||
/// `2·src·dst` — `(Dst, Src)`, 0528's factors; rain's verified state (rf-weather-render).
|
||||
Mod2x,
|
||||
}
|
||||
|
||||
impl From<ParticleBlend> for EffectBlend {
|
||||
fn from(blend: ParticleBlend) -> Self {
|
||||
match blend {
|
||||
ParticleBlend::Add => EffectBlend::Add,
|
||||
ParticleBlend::Alpha => EffectBlend::Alpha,
|
||||
ParticleBlend::Opaque => EffectBlend::Opaque,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBlend {
|
||||
/// The ground-fx mapping from a part's authored [`ModelBlend`] — `model_render.rs`'s law
|
||||
/// with the lane's two named approximations: `AlphaTest` folds to `Alpha` (no Mask variant
|
||||
/// here; the groundscan census says flat `Spells\` quads are blend batches), and the part
|
||||
/// renders unlit (spell fx are; a lit ground quad would differ — none observed).
|
||||
pub fn from_model(blend: ModelBlend) -> Self {
|
||||
match blend {
|
||||
ModelBlend::Opaque => EffectBlend::Opaque,
|
||||
ModelBlend::AlphaTest | ModelBlend::Blend => EffectBlend::Alpha,
|
||||
ModelBlend::Mod => EffectBlend::Multiply,
|
||||
ModelBlend::Mod2x => EffectBlend::Mod2x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a draw's vertex range is indexed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum EffectTopology {
|
||||
/// Whole quads: 4 perimeter-order corners each, indexed `[b, b+1, b+2, b, b+2, b+3]`.
|
||||
Quads,
|
||||
/// A plain triangle list: identity indices (the decal projector's fans, rain's streaks).
|
||||
Tris,
|
||||
}
|
||||
|
||||
/// The fog COLOUR policy for one draw — `params.x`/`params.y` of the effect shader; each
|
||||
/// variant is one canonical row of the render-world params uniform.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum EffectFog {
|
||||
/// File flag 0x8 ("unfogged") — fog disabled outright (params.x = 0). Also the decal
|
||||
/// family's and foam's verified state.
|
||||
Off,
|
||||
/// The ordinary day-night scene fog (params.x = 1) — Alpha/Opaque blends.
|
||||
Scene,
|
||||
/// Fog toward BLACK (params.x = 2) — an Add-blend emitter fades under a veil instead of
|
||||
/// gaining grey (the same per-blend fog table M2 batches take, `0x70baf0`).
|
||||
Black,
|
||||
/// Fog toward WHITE (params.x = 3) — the `0x70baf0` table's Mod policy (a multiplier fades
|
||||
/// to the identity, not to the scene colour). Ground-fx decals with `ModelBlend::Mod`.
|
||||
White,
|
||||
/// Fog toward GREY-0.5 (params.x = 4) — the table's Mod2x policy (grey is 2·src·dst's
|
||||
/// neutral). Ground-fx decals with `ModelBlend::Mod2x`.
|
||||
Grey,
|
||||
/// Rain's FORCED grey fog (scene fog off; params.y = 1 with zw = 70..75) — under Mod2x the
|
||||
/// grey-0.5 fog colour is neutral, so this IS the streak/patter distance fade
|
||||
/// (rf-weather-render Q3; the row values live with their law in `weather::precip`).
|
||||
Rain,
|
||||
}
|
||||
|
||||
impl EffectFog {
|
||||
/// The policy for one particle/ribbon def — the exact table `particle_material` applied
|
||||
/// (file flag 0x8 wins, then Add ⇒ black, else scene).
|
||||
pub fn for_blend(flags: u32, blend: ParticleBlend) -> Self {
|
||||
if flags & 0x8 != 0 {
|
||||
EffectFog::Off
|
||||
} else if matches!(blend, ParticleBlend::Add) {
|
||||
EffectFog::Black
|
||||
} else {
|
||||
EffectFog::Scene
|
||||
}
|
||||
}
|
||||
|
||||
/// The `0x70baf0` fog policy baked into a model material's `clutter_fade.z` bits 4..7
|
||||
/// (`wow_model.wgsl:761` decodes the same field): 0 scene, 1 black, 2 white, 3 grey,
|
||||
/// 4 unfogged — the ground-fx decal reads its part's authored policy through this.
|
||||
pub fn from_model_policy(policy: u32) -> Self {
|
||||
match policy {
|
||||
1 => EffectFog::Black,
|
||||
2 => EffectFog::White,
|
||||
3 => EffectFog::Grey,
|
||||
4 => EffectFog::Off,
|
||||
_ => EffectFog::Scene,
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot index into the render-world params uniform (one canonical `vec4` per policy).
|
||||
pub fn slot(self) -> u32 {
|
||||
match self {
|
||||
EffectFog::Off => 0,
|
||||
EffectFog::Scene => 1,
|
||||
EffectFog::Black => 2,
|
||||
EffectFog::White => 3,
|
||||
EffectFog::Grey => 4,
|
||||
EffectFog::Rain => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-emitter light-buffer override: this emitter's fragment reads THIS `WowLight` blob
|
||||
/// instead of the world's shared one. The glue-scene booths are the one author (decision 0539
|
||||
/// §5 — their braziers are fogged by the SCENE's own light buffer, the ModelFFX fog that
|
||||
/// covers the whole backdrop model); the call site inserts it on the spawned emitter entity.
|
||||
#[derive(Component, Clone)]
|
||||
pub struct EffectLightOverride(pub Buffer);
|
||||
|
||||
/// One draw of the shared lane: a contiguous vertex range, its texture/blend/fog identity, and
|
||||
/// the sort point the render-world queue keys it by.
|
||||
pub struct EffectDraw {
|
||||
/// The MAIN-world camera entity whose view this draw belongs to (the world camera, or a
|
||||
/// booth camera for a booth-layered emitter — the sim already resolves this per emitter).
|
||||
/// The render-world phase lookup keys on the retained view's main entity, so no
|
||||
/// `RenderLayers` plumbing is needed render-side.
|
||||
pub cam: Entity,
|
||||
pub texture: AssetId<Image>,
|
||||
pub blend: EffectBlend,
|
||||
pub topology: EffectTopology,
|
||||
pub fog: EffectFog,
|
||||
/// The cloud's sort point — the emitter anchor / ribbon head node / decal center, exactly
|
||||
/// the sort point the material path used.
|
||||
pub anchor: Vec3,
|
||||
/// The ladder rung added to the view-space sort distance — owner-last (0719/0721) for
|
||||
/// emitters, the decal constants (ring/ground-fx 8192, shadow 4096), foam's +1 water
|
||||
/// tie-break; `sky_order`'s sign law (positive draws later).
|
||||
pub bias: f32,
|
||||
/// The rasterizer `DepthBiasState` constant for this draw's pipeline (0733 §4): the
|
||||
/// coplanar decals keep the depth-offset half their materials carried (projected verts are
|
||||
/// exact sub-pieces of drawn ground — clip-interpolated vertices land within ULPs of it);
|
||||
/// everything free-floating passes 0.
|
||||
pub raster_bias: i32,
|
||||
/// Vertex range in [`EffectQuads::verts`] (a multiple of 4 for quads, 3 for tris).
|
||||
pub range: Range<u32>,
|
||||
/// The producing entity — the phase probe's identity for this item (`item.entity.1`, so a
|
||||
/// phase line still names the pool that produced it).
|
||||
pub main_entity: Entity,
|
||||
/// [`EffectLightOverride`]'s buffer, when the producer carries one (`None` = the world's
|
||||
/// shared light buffer).
|
||||
pub light: Option<Buffer>,
|
||||
}
|
||||
|
||||
/// The frame's shared stream. Cleared at the top of `PostUpdate`'s effect set
|
||||
/// ([`begin_effect_frame`]), filled by the family systems, copied to the render world in
|
||||
/// `ExtractSchedule`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EffectQuads {
|
||||
pub verts: Vec<EffectVertex>,
|
||||
pub draws: Vec<EffectDraw>,
|
||||
}
|
||||
|
||||
/// Everything about one draw except its vertex range — the argument bundle `commit_quads` /
|
||||
/// `commit_tris` close a range with.
|
||||
pub struct EffectDrawSpec {
|
||||
pub cam: Entity,
|
||||
pub texture: AssetId<Image>,
|
||||
pub blend: EffectBlend,
|
||||
pub fog: EffectFog,
|
||||
pub anchor: Vec3,
|
||||
pub bias: f32,
|
||||
pub raster_bias: i32,
|
||||
pub main_entity: Entity,
|
||||
pub light: Option<Buffer>,
|
||||
}
|
||||
|
||||
impl EffectQuads {
|
||||
/// Open a draw: remember where its vertices start.
|
||||
pub fn begin(&self) -> u32 {
|
||||
self.verts.len() as u32
|
||||
}
|
||||
|
||||
/// Close a quad draw over everything pushed since `begin`. A range that gained no vertices
|
||||
/// commits nothing — the idle steady state costs zero here (the a5521180 law, structural).
|
||||
pub fn commit_quads(&mut self, start: u32, spec: EffectDrawSpec) {
|
||||
debug_assert_eq!((self.verts.len() as u32 - start) % 4, 0, "whole quads only");
|
||||
self.commit(start, EffectTopology::Quads, spec);
|
||||
}
|
||||
|
||||
/// Close a triangle-list draw over everything pushed since `begin`.
|
||||
pub fn commit_tris(&mut self, start: u32, spec: EffectDrawSpec) {
|
||||
debug_assert_eq!(
|
||||
(self.verts.len() as u32 - start) % 3,
|
||||
0,
|
||||
"whole triangles only"
|
||||
);
|
||||
self.commit(start, EffectTopology::Tris, spec);
|
||||
}
|
||||
|
||||
fn commit(&mut self, start: u32, topology: EffectTopology, spec: EffectDrawSpec) {
|
||||
let end = self.verts.len() as u32;
|
||||
if end > start {
|
||||
self.draws.push(EffectDraw {
|
||||
cam: spec.cam,
|
||||
texture: spec.texture,
|
||||
blend: spec.blend,
|
||||
topology,
|
||||
fog: spec.fog,
|
||||
anchor: spec.anchor,
|
||||
bias: spec.bias,
|
||||
raster_bias: spec.raster_bias,
|
||||
range: start..end,
|
||||
main_entity: spec.main_entity,
|
||||
light: spec.light,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the stream for a new frame — scheduled before every family's writer, so the writer
|
||||
/// order between them stays free.
|
||||
pub fn begin_effect_frame(mut quads: ResMut<EffectQuads>) {
|
||||
quads.verts.clear();
|
||||
quads.draws.clear();
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
use bevy::camera::Projection;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::buffer::EffectVertex;
|
||||
use super::quads::{draw_gated, particle_center, particle_half, CamBasis, DrawFrame};
|
||||
use super::Particle;
|
||||
use benilla_formats::ParticleEmitterDef;
|
||||
@@ -82,52 +83,46 @@ pub(super) fn dump_emitter(
|
||||
cam_tf: &GlobalTransform,
|
||||
camera: &Camera,
|
||||
projection: &Projection,
|
||||
// The sim's own draw gate: a non-resident texture means `expand_quads` was SKIPPED and none
|
||||
// The sim's own draw gate: a non-resident texture means the expansion was SKIPPED and none
|
||||
// of these quads rasterized — numbers that look drawable but weren't. Stated on the line so
|
||||
// the reader can't pair a withheld pool with a framebuffer.
|
||||
texture_resident: bool,
|
||||
// The emitter entity's own visibility, and the mesh asset as the LAST expansion left it (the
|
||||
// dump runs just before this frame's expansion) — what the draw actually has in hand.
|
||||
vis: Visibility,
|
||||
mesh_positions: Option<&Mesh>,
|
||||
// The quads THIS frame just wrote into the shared stream (world-space vertices — the exact
|
||||
// data the draw consumes; the dump now runs right after expansion). Empty = the pool is
|
||||
// live but nothing was pushed (non-resident texture / geometry emitter).
|
||||
written: &[EffectVertex],
|
||||
) {
|
||||
let Some(vp) = camera.physical_viewport_size() else {
|
||||
return;
|
||||
};
|
||||
// The mesh's OWN first quad, exactly as last expansion wrote it (anchor-relative) — read back
|
||||
// from the asset rather than recomputed, so vertex-data corruption is visible as itself.
|
||||
// An EMPTY buffer is a real state, not an impossible one: an emitter's pool goes live one
|
||||
// frame before that pool is first expanded into the mesh, so a live-world dump catches
|
||||
// `pos == []` and indexing `pos[0]` panics the sim (it did, mid-session, on the director's
|
||||
// client). Report the empty mesh as itself — it is exactly the "drawable numbers, nothing in
|
||||
// the vertex buffer" state this dump exists to make visible.
|
||||
if let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) =
|
||||
mesh_positions.and_then(|m| m.attribute(Mesh::ATTRIBUTE_POSITION))
|
||||
{
|
||||
if pos.is_empty() {
|
||||
info!("PARTICLE_DEPTHDUMP f={fidx} mesh EMPTY (0 verts — pool live, not yet expanded)");
|
||||
} else {
|
||||
// Per-quad diagonal |v2−v0| over the WHOLE buffer — the young (big) quads live at the
|
||||
// tail, and only reading quad 0 (the oldest, smallest) once mis-called this mesh "sane".
|
||||
let diag = |q: &[[f32; 3]]| (Vec3::from(q[2]) - Vec3::from(q[0])).length();
|
||||
let quads: Vec<f32> = pos.chunks_exact(4).map(diag).collect();
|
||||
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
|
||||
for &d in &quads {
|
||||
dmin = dmin.min(d);
|
||||
dmax = dmax.max(d);
|
||||
}
|
||||
info!(
|
||||
"PARTICLE_DEPTHDUMP f={fidx} mesh quads={} diag_min={dmin:.4} diag_max={dmax:.4} \
|
||||
first=({:.4},{:.4},{:.4}) last=({:.4},{:.4},{:.4})",
|
||||
quads.len(),
|
||||
pos[0][0],
|
||||
pos[0][1],
|
||||
pos[0][2],
|
||||
pos[pos.len() - 1][0],
|
||||
pos[pos.len() - 1][1],
|
||||
pos[pos.len() - 1][2],
|
||||
);
|
||||
// The stream's OWN quads, exactly as expansion wrote them — read back from the vertex data
|
||||
// rather than recomputed, so vertex-data corruption is visible as itself. An EMPTY slice is
|
||||
// a real state (the "drawable numbers, nothing in the vertex buffer" state this dump exists
|
||||
// to make visible).
|
||||
if written.is_empty() {
|
||||
info!("PARTICLE_DEPTHDUMP f={fidx} stream EMPTY (0 verts — pool live, nothing pushed)");
|
||||
} else {
|
||||
// Per-quad diagonal |v2−v0| over the WHOLE range — the young (big) quads live at the
|
||||
// tail, and only reading quad 0 (the oldest, smallest) once mis-called a mesh "sane".
|
||||
let diag = |q: &[EffectVertex]| (Vec3::from(q[2].pos) - Vec3::from(q[0].pos)).length();
|
||||
let quads: Vec<f32> = written.chunks_exact(4).map(diag).collect();
|
||||
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
|
||||
for &d in &quads {
|
||||
dmin = dmin.min(d);
|
||||
dmax = dmax.max(d);
|
||||
}
|
||||
let (first, last) = (written[0].pos, written[written.len() - 1].pos);
|
||||
info!(
|
||||
"PARTICLE_DEPTHDUMP f={fidx} stream quads={} diag_min={dmin:.4} diag_max={dmax:.4} \
|
||||
first=({:.4},{:.4},{:.4}) last=({:.4},{:.4},{:.4})",
|
||||
quads.len(),
|
||||
first[0],
|
||||
first[1],
|
||||
first[2],
|
||||
last[0],
|
||||
last[1],
|
||||
last[2],
|
||||
);
|
||||
}
|
||||
let view_from_world = cam_tf.to_matrix().inverse();
|
||||
let clip_from_view = projection.get_clip_from_view();
|
||||
@@ -146,7 +141,7 @@ pub(super) fn dump_emitter(
|
||||
};
|
||||
info!(
|
||||
"PARTICLE_DEPTHDUMP f={fidx} emitter bone={} pos=({:.3},{:.3},{:.3}) blend={:?} pool={} \
|
||||
tex_resident={texture_resident} mesh_verts={} vis={vis:?} \
|
||||
tex_resident={texture_resident} stream_verts={} \
|
||||
birth=({:.4},{:.4},{:.4}) joint=({:.4},{:.4},{:.4})",
|
||||
def.bone,
|
||||
def.position[0],
|
||||
@@ -154,7 +149,7 @@ pub(super) fn dump_emitter(
|
||||
def.position[2],
|
||||
def.blend,
|
||||
particles.len(),
|
||||
mesh_positions.map_or(0, Mesh::count_vertices),
|
||||
written.len(),
|
||||
birth_world.x,
|
||||
birth_world.y,
|
||||
birth_world.z,
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! The **emission shape kernel** and its RNG — where one birth's position + velocity direction
|
||||
//! come from, byte-verified against the reference's three vtable type-spawn generators (wow-re
|
||||
//! `part-shape-kernels.md`). Split from the emitter module face (`particles.rs`) purely along
|
||||
//! this concern; the laws and cites live on [`emit_local`] itself.
|
||||
|
||||
use benilla_formats::ParticleEmitterDef;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// xorshift32 — a dependency-free PRNG for particle jitter (visual only; determinism not required).
|
||||
pub(super) fn next_u32(state: &mut u32) -> u32 {
|
||||
let mut x = *state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
*state = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// A uniform random `f32` in `[0, 1)`.
|
||||
pub(super) fn rand01(state: &mut u32) -> f32 {
|
||||
(next_u32(state) >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
|
||||
/// A symmetric random `f32` in `(−1, 1)` — the reference's `S11` draw (its RNG builds ±spans with
|
||||
/// a ×2.0 constant; every emission distribution below is plain uniform, wow-re
|
||||
/// `part-shape-kernels.md` §4 — we mirror the distributions, not the bit stream).
|
||||
pub(super) fn rand_s11(state: &mut u32) -> f32 {
|
||||
rand01(state) * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// The **emission shape kernel** (wow-re `part-shape-kernels.md`, byte-verified off the three
|
||||
/// vtable type-spawn generators): one birth's position + unit velocity direction, in the emitter's
|
||||
/// local WoW frame (Z up, origin at the emitter record's `position`). The caller applies the speed
|
||||
/// roll and the space-mode transform.
|
||||
///
|
||||
/// - **Plane** (`0x7b8890`): position uniform in the ±½·area rectangle; direction a cone around +Z
|
||||
/// with **symmetric** angles θ = S11·verticalRange, φ = S11·horizontalRange (the reference draws
|
||||
/// ±range, not [0, range] — a one-sided cone tilted every flame the same way).
|
||||
/// - **Sphere** (`0x7b8d70`): radius uniform in [areaLength, areaWidth] (= min/max radius for this
|
||||
/// shape), position on the shell at latitude S11·verticalRange / longitude S11·horizontalRange;
|
||||
/// direction radial outward (flag `0x4000` ⇒ straight +Z instead).
|
||||
/// - **Spline** (`0x7b9500`): born ON the authored arc-length-parameterized Bézier chain at a
|
||||
/// uniform arc fraction in `[tMin, tMax]`; velocity = +Z spun about the local tangent by
|
||||
/// ψ = S11·spin (zero velocity when no spin; radial-from-pivot when zSource authored), plus
|
||||
/// an optional along-velocity scatter jitter — see the arm's comment.
|
||||
/// - Any shape with `zSource ≠ 0`: direction is **radial from the pivot** `(0, 0, zSource)` toward
|
||||
/// the birth point (fountains that arc outward from a point below the basin).
|
||||
pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3) {
|
||||
let origin = Vec3::from(def.position);
|
||||
// The emitter-frame R(+Z, 90°) applied at every branch's return — see the law note at the
|
||||
// tail. Per the wow-re bytes the prepend is per-EMITTER and subclass-independent ("gated
|
||||
// only by has-emitters"), so the spline kernel takes it exactly like sphere/plane (the
|
||||
// 0563 landing missed this branch — caught by the compensation audit).
|
||||
let rot90 = |v: Vec3| Vec3::new(-v.y, v.x, v.z);
|
||||
// SPLINE (`0x7b9500`, wow-re `part-shape-kernels.md` §3 — VERIFIED, incl. the scatter
|
||||
// bytes): born ON the authored Bézier chain at arc fraction `t ∈ [tMin, tMax]` (the
|
||||
// repurposed area fields). Velocity: radial from the zSource pivot when authored; else +Z
|
||||
// rotated about the local spline tangent by ψ = S11·spin (the reference extracts the
|
||||
// transposed row — a −ψ rotation — indistinguishable under the symmetric draw), with an
|
||||
// optional `U01·scatter` position jitter along the velocity; else ZERO (the particle sits
|
||||
// on the curve and only gravity/drag move it). A degenerate record (no parsed chain)
|
||||
// falls through to the plane kernel, as before.
|
||||
if let (benilla_formats::ParticleShape::Spline, Some(spline)) = (def.shape, &def.spline) {
|
||||
let (t0, t1) = (
|
||||
def.area_length.clamp(0.0, 1.0),
|
||||
def.area_width.clamp(0.0, 1.0),
|
||||
);
|
||||
let t = t0 + rand01(rng) * (t1 - t0);
|
||||
let mut pos = origin + Vec3::from(spline.eval(t));
|
||||
let dir = if def.z_source != 0.0 {
|
||||
(pos - origin - Vec3::new(0.0, 0.0, def.z_source)).normalize_or(Vec3::Z)
|
||||
} else if def.vertical_range != 0.0 {
|
||||
let tangent = Vec3::from(spline.tangent(t)).normalize_or(Vec3::Z);
|
||||
let (s, c) = (rand_s11(rng) * def.vertical_range).sin_cos();
|
||||
let dir = Vec3::Z * c + tangent.cross(Vec3::Z) * s + tangent * (tangent.z * (1.0 - c));
|
||||
if def.horizontal_range != 0.0 {
|
||||
pos += rand01(rng) * def.horizontal_range * dir;
|
||||
}
|
||||
dir
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
return (origin + rot90(pos - origin), rot90(dir));
|
||||
}
|
||||
// Birth offset in the emitter frame (before the record-position translation). A sphere
|
||||
// draws ONE lat/lon unit vector serving both the shell point and (below) the radial
|
||||
// velocity — the reference reuses the exact sincos pair (`0x7b8fba`), which is what keeps a
|
||||
// ZERO-radius sphere (the fireball impact's plume burst: min = max = 0) spraying uniformly
|
||||
// instead of collapsing every direction to the degenerate normalize fallback.
|
||||
let (local, shell) = if def.shape == benilla_formats::ParticleShape::Sphere {
|
||||
let r = def.area_length + rand01(rng) * (def.area_width - def.area_length).max(0.0);
|
||||
let lat = rand_s11(rng) * def.vertical_range;
|
||||
let lon = rand_s11(rng) * def.horizontal_range;
|
||||
let (slat, clat) = lat.sin_cos();
|
||||
let (slon, clon) = lon.sin_cos();
|
||||
let shell = Vec3::new(clat * clon, clat * slon, slat); // unit by construction
|
||||
(r * shell, Some(shell))
|
||||
} else {
|
||||
(
|
||||
Vec3::new(
|
||||
rand_s11(rng) * 0.5 * def.area_width,
|
||||
rand_s11(rng) * 0.5 * def.area_length,
|
||||
0.0,
|
||||
),
|
||||
None,
|
||||
)
|
||||
};
|
||||
let dir = if def.z_source != 0.0 {
|
||||
// Radial from the (0, 0, zSource) pivot — degenerate at the pivot itself falls back to +Z.
|
||||
(local - Vec3::new(0.0, 0.0, def.z_source)).normalize_or(Vec3::Z)
|
||||
} else if let Some(shell) = shell {
|
||||
if def.sphere_up() {
|
||||
Vec3::Z
|
||||
} else {
|
||||
shell // radial outward — the same unit draw as the birth point
|
||||
}
|
||||
} else {
|
||||
let theta = rand_s11(rng) * def.vertical_range;
|
||||
let phi = rand_s11(rng) * def.horizontal_range;
|
||||
let (st, ct) = theta.sin_cos();
|
||||
let (sp, cp) = phi.sin_cos();
|
||||
Vec3::new(st * cp, st * sp, ct)
|
||||
};
|
||||
// The emitter-frame R(+Z, 90°) — wow-re `part-modelspace-animbone.md`, §5 byte-verified
|
||||
// (`0x719114–0x719142`: axis literal (0,0,1), angle π·0.5, Rodrigues + mat4_mul into the
|
||||
// per-frame emitter matrix rt+0x1fc): the reference prepends a fixed +90°-about-local-+Z
|
||||
// to EVERY M2 particle emitter's bone matrix, applied to the kernel-relative vectors only
|
||||
// (the record-position translation stays outside it). It is what turns the sphere kernel's
|
||||
// local-XZ ring into the wheel PERPENDICULAR to a rotor bone's +X spin — the InstancePortal
|
||||
// vortex swirling in place instead of tumbling edge-on — and what maps a billboard-bone
|
||||
// starburst's spray plane onto the screen plane (the impact flashes). Applied at emission,
|
||||
// so every consumer (anchored bake, model-space storage, child emitters) inherits it.
|
||||
(origin + rot90(local), rot90(dir))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use benilla_formats::{CellRamp, OverLife, ParticleShape};
|
||||
|
||||
/// A minimal def for kernel tests — only the fields [`emit_local`] reads matter.
|
||||
pub(crate) fn def(shape: ParticleShape) -> ParticleEmitterDef {
|
||||
ParticleEmitterDef {
|
||||
flags: 0,
|
||||
position: [1.0, 2.0, 3.0],
|
||||
bone: 0,
|
||||
shape,
|
||||
blend: benilla_formats::ParticleBlend::Add,
|
||||
texture: None,
|
||||
tile_rows: 1,
|
||||
tile_cols: 1,
|
||||
head_tail: 0,
|
||||
emission_speed: 1.0,
|
||||
speed_variation: 0.0,
|
||||
vertical_range: 0.5,
|
||||
horizontal_range: std::f32::consts::PI,
|
||||
gravity: 0.0,
|
||||
lifespan: 1.0,
|
||||
timing: benilla_formats::EmitTiming::constant(10.0),
|
||||
area_length: 2.0,
|
||||
area_width: 4.0,
|
||||
z_source: 0.0,
|
||||
drag: 0.0,
|
||||
tail_time: 0.0,
|
||||
spline: None,
|
||||
geometry_model: None,
|
||||
recursion_model: None,
|
||||
angular_velocity_min: [0.0; 3],
|
||||
angular_velocity_max: [0.0; 3],
|
||||
inherit_scale: 0.0,
|
||||
follow_speed1: 0.0,
|
||||
follow_scale1: 0.0,
|
||||
follow_speed2: 0.0,
|
||||
follow_scale2: 0.0,
|
||||
twinkle_speed: 0.0,
|
||||
twinkle_percent: 1.0,
|
||||
twinkle_min: 0.0,
|
||||
twinkle_max: 0.0,
|
||||
spin: 0.0,
|
||||
over_life: OverLife {
|
||||
mid: 0.5,
|
||||
color: [[1.0; 4]; 3],
|
||||
scale: [1.0; 3],
|
||||
head_cells: [CellRamp::new(0, 0); 2],
|
||||
tail_cells: [CellRamp::new(0, 0); 2],
|
||||
repeat: [1.0; 2],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Plane births stay in the ±½·area rectangle around the record position, and the cone is
|
||||
/// SYMMETRIC (θ = S11·range — wow-re `part-shape-kernels.md`'s correction of our old
|
||||
/// [0, range) draw): with a wide sample, x-velocities must land on both signs. The
|
||||
/// rectangle rides the R(+Z,90°) emitter frame (`emit_local`'s tail): the kernel's
|
||||
/// width-along-x/length-along-y square lands width-along-y/length-along-x.
|
||||
#[test]
|
||||
fn plane_kernel_rect_bounds_and_symmetric_cone() {
|
||||
let d = def(ParticleShape::Plane);
|
||||
let mut rng = 12345u32;
|
||||
let (mut neg, mut pos) = (false, false);
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
assert!(
|
||||
(p.x - 1.0).abs() <= 1.0 + 1e-4,
|
||||
"x within ±½·length of position (post-R)"
|
||||
);
|
||||
assert!(
|
||||
(p.y - 2.0).abs() <= 2.0 + 1e-4,
|
||||
"y within ±½·width of position (post-R)"
|
||||
);
|
||||
assert_eq!(p.z, 3.0);
|
||||
assert!(dir.z > 0.0, "cone around +Z stays upward for range < π/2");
|
||||
neg |= dir.x < -1e-3;
|
||||
pos |= dir.x > 1e-3;
|
||||
}
|
||||
assert!(neg && pos, "symmetric cone covers both x signs");
|
||||
}
|
||||
|
||||
/// Sphere births sit on a shell with radius in [areaLength, areaWidth] (min/max radius for
|
||||
/// this shape) and fly radially outward through the shell point.
|
||||
#[test]
|
||||
fn sphere_kernel_radius_bounds_and_radial_velocity() {
|
||||
let d = def(ParticleShape::Sphere);
|
||||
let mut rng = 99u32;
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
let rel = p - Vec3::new(1.0, 2.0, 3.0);
|
||||
let r = rel.length();
|
||||
assert!(
|
||||
(2.0 - 1e-3..=4.0 + 1e-3).contains(&r),
|
||||
"shell radius {r} within [min, max]"
|
||||
);
|
||||
assert!(
|
||||
rel.normalize().dot(dir) > 0.999,
|
||||
"velocity radial through the shell point"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A ZERO-radius sphere (min = max = 0 — the fireball impact's plume burst) still sprays its
|
||||
/// velocities across the authored lat/lon spread: the direction is the shell unit vector
|
||||
/// itself (`0x7b8fba` reuses the sincos pair), never a normalize of the (zero) birth offset.
|
||||
#[test]
|
||||
fn zero_radius_sphere_still_disperses() {
|
||||
let mut d = def(ParticleShape::Sphere);
|
||||
d.area_length = 0.0;
|
||||
d.area_width = 0.0;
|
||||
d.vertical_range = std::f32::consts::PI; // the MoltenBlast plume: full ±π latitude fan
|
||||
d.horizontal_range = 0.0;
|
||||
let mut rng = 4242u32;
|
||||
let (mut up, mut down, mut fwd, mut back) = (false, false, false, false);
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
assert_eq!(p, Vec3::new(1.0, 2.0, 3.0), "births at the centre");
|
||||
assert!((dir.length() - 1.0).abs() < 1e-4, "unit direction");
|
||||
assert_eq!(
|
||||
dir.x, 0.0,
|
||||
"zero longitude range pins the fan to the YZ plane (the R(+Z,90°) emitter frame)"
|
||||
);
|
||||
up |= dir.z > 0.5;
|
||||
down |= dir.z < -0.5;
|
||||
fwd |= dir.y > 0.5;
|
||||
back |= dir.y < -0.5;
|
||||
}
|
||||
assert!(
|
||||
up && down && fwd && back,
|
||||
"the fan covers the full vertical ring, not a single degenerate ray"
|
||||
);
|
||||
}
|
||||
|
||||
/// The SPLINE kernel (`0x7b9500`): births sit ON the authored chain (origin-composed) at
|
||||
/// arc fractions inside [tMin, tMax]; zero spin ⇒ ZERO velocity; a spin range fans +Z
|
||||
/// about the local tangent (here +X ⇒ the fan stays in the YZ plane), and scatter jitters
|
||||
/// the position along the velocity by at most its own length.
|
||||
#[test]
|
||||
fn spline_kernel_births_on_the_chain() {
|
||||
let x = |v: f32| [v, 0.0, 0.0];
|
||||
let mut d = def(ParticleShape::Spline);
|
||||
d.spline = benilla_formats::SplineData::new(vec![
|
||||
x(0.0),
|
||||
x(1.0),
|
||||
x(2.0),
|
||||
x(3.0), // one straight segment along +X
|
||||
]);
|
||||
d.area_length = 0.25; // tMin
|
||||
d.area_width = 0.75; // tMax
|
||||
d.vertical_range = 0.0;
|
||||
d.z_source = 0.0;
|
||||
let mut rng = 31u32;
|
||||
// The R(+Z,90°) emitter frame (`emit_local`'s tail) turns the authored +X chain to +Y.
|
||||
for _ in 0..64 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
let local = p - Vec3::new(1.0, 2.0, 3.0); // minus the record position
|
||||
assert!(
|
||||
(0.75 - 1e-3..=2.25 + 1e-3).contains(&local.y),
|
||||
"on the chain inside [tMin, tMax] (post-R along +Y): {}",
|
||||
local.y
|
||||
);
|
||||
assert_eq!((local.x, local.z), (0.0, 0.0));
|
||||
assert_eq!(dir, Vec3::ZERO, "no spin, no zSource: the flame stands");
|
||||
}
|
||||
// A spin range fans +Z about the +X tangent — the kernel's YZ fan lands in XZ post-R.
|
||||
d.vertical_range = 1.0;
|
||||
d.horizontal_range = 0.5; // scatter
|
||||
let (mut low, mut high) = (false, false);
|
||||
for _ in 0..256 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
assert!(dir.y.abs() < 1e-4, "fan in the XZ plane (post-R)");
|
||||
assert!((dir.length() - 1.0).abs() < 1e-4);
|
||||
assert!(dir.z > 0.0, "±1 rad about +Z stays upward");
|
||||
low |= dir.x > 0.5;
|
||||
high |= dir.x < -0.5;
|
||||
// The scatter jitter is along dir, bounded by its own length.
|
||||
let local = p - Vec3::new(1.0, 2.0, 3.0);
|
||||
assert!(local.x.hypot(local.z) <= 0.5 + 1e-3, "jitter ≤ scatter");
|
||||
}
|
||||
assert!(low && high, "the fan covers both spin signs");
|
||||
}
|
||||
|
||||
/// zSource ≠ 0 redirects any shape's velocity radially away from the (0, 0, zSource) pivot.
|
||||
#[test]
|
||||
fn z_source_pivots_the_velocity() {
|
||||
let mut d = def(ParticleShape::Plane);
|
||||
d.z_source = -1.0; // pivot below the emitter → births fly up-and-outward
|
||||
let mut rng = 7u32;
|
||||
for _ in 0..64 {
|
||||
let (p, dir) = emit_local(&d, &mut rng);
|
||||
let rel = (p - Vec3::new(1.0, 2.0, 3.0)) - Vec3::new(0.0, 0.0, -1.0);
|
||||
assert!(rel.normalize().dot(dir) > 0.999, "radial from the pivot");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
use bevy::pbr::{ExtendedMaterial, MaterialExtension};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{AsBindGroup, Buffer};
|
||||
use bevy::shader::ShaderRef;
|
||||
|
||||
/// The particle/ribbon material: the `StandardMaterial` shell supplies the texture binding,
|
||||
/// alpha mode and cull state; the extension swaps in the fragment that does the vanilla
|
||||
/// gamma-space combine — `tex (Rgba8Unorm, authored bytes) × raw authored vertex colour`, emitted
|
||||
/// raw (`shaders/wow_particle.wgsl`; the buffer holds gamma bytes end-to-end and the frame's one
|
||||
/// decode is the FFXGlow combine — GAMMA LANE, 0161). StandardMaterial alone
|
||||
/// can't express it: its unlit path assumes a linearised texture, which left the texture term
|
||||
/// gamma-brightened on screen (the bonfire's dark authored smoke reading pale and thick).
|
||||
pub type WowParticleMaterial = ExtendedMaterial<StandardMaterial, WowParticleExt>;
|
||||
|
||||
/// The extension: the gamma-combine fragment shader + the shared global light/fog buffer (the
|
||||
/// reference fogs particle quads with the SAME scene day-night fog as world geometry — wow-re
|
||||
/// `part-scene-multipliers.md`, decision 0155; missing fog was the "night smoke glows" bug).
|
||||
#[derive(Asset, AsBindGroup, Clone, TypePath)]
|
||||
#[bind_group_data(WowParticleKey)]
|
||||
pub struct WowParticleExt {
|
||||
/// `lighting::global_light`'s storage buffer — the same binding every world material carries.
|
||||
#[storage(90, read_only, buffer, visibility(fragment))]
|
||||
pub light_buf: Buffer,
|
||||
/// Per-material params: `x` = the fog COLOUR policy, shifted +1 off [`benilla_formats::FogPolicy`]'s
|
||||
/// own discriminants so `0.0` keeps its established meaning (fog off — the water foam, whose
|
||||
/// reference render state disables fog, `0x68fcc1`): `1.0` = scene (particles/ribbons — the
|
||||
/// ordinary day-night fog), `2.0` = black (an `Add`-blend emitter fades under a veil instead of
|
||||
/// gaining grey — the SAME per-blend fog table M2 batches take, `0x70baf0`, wow-re
|
||||
/// rf-weather-emission-timeline ROUND 4, refuting models.md:782's "no per-blend fog"; `3.0`/`4.0`
|
||||
/// = white/grey, unused here — no M2 particle blend distinguishes Mod/Mod2x today). `y` =
|
||||
/// forced-fog mode (rain, render-state ids 0x0a/0x0b/0x0d: fog toward grey-0.5 over the fixed
|
||||
/// `z..w` window regardless of scene fog — the streak's distance fade under Mod2x; wow-re
|
||||
/// `rf-weather-render.md`). `zw` = forced fog start/end (`y > 0.5`).
|
||||
#[uniform(91)]
|
||||
pub params: Vec4,
|
||||
/// Rain's **Mod2x** blend (`glBlendFunc(GL_DST_COLOR, GL_SRC_COLOR)` = `2·src·dst`, EGxBlend
|
||||
/// mode 5 — the byte-verified rain streak/patter state, wow-re `rf-weather-render.md`).
|
||||
/// Swaps the pipeline blend state in [`MaterialExtension::specialize`]; the base
|
||||
/// `StandardMaterial` keeps `AlphaMode::Blend` so the draw stays in the transparent pass with
|
||||
/// depth-write off (the reference's 0x12=0) — only the blend equation differs.
|
||||
pub mod2x: bool,
|
||||
}
|
||||
|
||||
/// Pipeline key for [`WowParticleExt`] — materials differing here specialize their own pipeline.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct WowParticleKey {
|
||||
pub mod2x: bool,
|
||||
}
|
||||
|
||||
impl From<&WowParticleExt> for WowParticleKey {
|
||||
fn from(ext: &WowParticleExt) -> Self {
|
||||
Self { mod2x: ext.mod2x }
|
||||
}
|
||||
}
|
||||
|
||||
impl MaterialExtension for WowParticleExt {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
"shaders/wow_particle.wgsl".into()
|
||||
}
|
||||
|
||||
fn specialize(
|
||||
_pipeline: &bevy::pbr::MaterialExtensionPipeline,
|
||||
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
|
||||
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
|
||||
key: bevy::pbr::MaterialExtensionKey<Self>,
|
||||
) -> std::result::Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
|
||||
// `$WOW_PARTICLE_NODEPTH=1` — the occlusion A/B. Force the depth COMPARE to `Always` on
|
||||
// every particle quad, so "this effect doesn't draw" splits cleanly into *nothing is
|
||||
// emitted* (the census's job — `WOW_PARTICLE_CENSUS` counts live particles) and *it is
|
||||
// emitted, rasterized, and the depth buffer eats it*. Those two look identical on screen
|
||||
// and want completely different fixes.
|
||||
//
|
||||
// Its most useful reading on B16 was a NEGATIVE one: the voidwalker's eye glow stays gone
|
||||
// from above under this switch, and `WOW_DEPTH_QUADS` then measured 30–48% of each quad
|
||||
// surviving the compare at every elevation. Depth was never what killed it — the owner's
|
||||
// own blend batches were drawing over it (`particles::owner_last_bias`, decision 0719).
|
||||
// An earlier note here read the switch the other way round; that reading is retracted.
|
||||
if std::env::var_os("WOW_PARTICLE_NODEPTH").is_some() {
|
||||
if let Some(ds) = descriptor.depth_stencil.as_mut() {
|
||||
ds.depth_compare = bevy::render::render_resource::CompareFunction::Always;
|
||||
}
|
||||
}
|
||||
// `$WOW_PARTICLE_FLAT=1` — the fragment-input A/B (B16): the shader emits solid magenta,
|
||||
// ignoring texture, vertex colour and fog. Splits "the quad rasterizes but its inputs
|
||||
// multiply to zero" from "the fragments never execute".
|
||||
if std::env::var_os("WOW_PARTICLE_FLAT").is_some() {
|
||||
if let Some(fragment) = descriptor.fragment.as_mut() {
|
||||
fragment.shader_defs.push("WOW_PARTICLE_FLAT".into());
|
||||
}
|
||||
}
|
||||
if key.bind_group_data.mod2x {
|
||||
use bevy::render::render_resource::{
|
||||
BlendComponent, BlendFactor, BlendOperation, BlendState,
|
||||
};
|
||||
if let Some(fragment) = descriptor.fragment.as_mut() {
|
||||
for target in fragment.targets.iter_mut().flatten() {
|
||||
// Mod2x: src·dst + dst·src = 2·src·dst. Alpha keeps the destination.
|
||||
target.blend = Some(BlendState {
|
||||
color: BlendComponent {
|
||||
src_factor: BlendFactor::Dst,
|
||||
dst_factor: BlendFactor::Src,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
alpha: BlendComponent {
|
||||
src_factor: BlendFactor::Zero,
|
||||
dst_factor: BlendFactor::One,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use benilla_formats::ParticleEmitterDef;
|
||||
use bevy::mesh::Indices;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::buffer::EffectVertex;
|
||||
use super::{rand01, Particle};
|
||||
|
||||
/// The 128-entry twinkle noise table — the reference seeds `DAT_00cf58f0` with uniform-random f32
|
||||
@@ -50,11 +50,12 @@ pub(super) fn spin_angle(spin: f32, age: f32, phase: u32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera basis one frame of expansion billboards against.
|
||||
/// The camera basis one frame of expansion billboards against. (No facing normal: the effect
|
||||
/// shader is unlit and never backface-culled, and the lane's vertex stream carries none — the
|
||||
/// old per-vertex `cam.face_normal` existed only to satisfy the mesh pipeline's layout.)
|
||||
pub(super) struct CamBasis {
|
||||
pub right: Vec3,
|
||||
pub up: Vec3,
|
||||
pub face_normal: Vec3,
|
||||
}
|
||||
|
||||
/// The cloud's draw frame: where its vertices are relative to ([`anchor`]) and how stored
|
||||
@@ -98,24 +99,19 @@ pub(super) fn particle_half(def: &ParticleEmitterDef, placement: &Transform, p:
|
||||
def.over_life.sample(u_age).size * def.twinkle(noise) * scale
|
||||
}
|
||||
|
||||
/// Expand one pool into its billboard mesh (rewritten in place). Vertices are ANCHOR-relative —
|
||||
/// the caller carries the anchor on the mesh entity's transform (the transparent-pass sort key).
|
||||
/// Expand one pool into the shared effect-quad stream (whole quads, corner order closed by the
|
||||
/// lane's static `[0,1,2, 0,2,3]` index pattern). Vertices are **world-space** — the draw
|
||||
/// record carries the sort anchor now (see [`super::buffer`]).
|
||||
pub(super) fn expand_quads(
|
||||
def: &ParticleEmitterDef,
|
||||
particles: &[Particle],
|
||||
frame: &DrawFrame,
|
||||
placement: &Transform,
|
||||
cam: &CamBasis,
|
||||
mesh: &mut Mesh,
|
||||
out: &mut Vec<EffectVertex>,
|
||||
) {
|
||||
let n = particles.len();
|
||||
let (anchored, anchor) = (frame.anchored, frame.anchor);
|
||||
let anchored = frame.anchored;
|
||||
let (cam_right, cam_up) = (cam.right, cam.up);
|
||||
let mut positions = Vec::with_capacity(n * 4);
|
||||
let mut normals = Vec::with_capacity(n * 4);
|
||||
let mut uvs = Vec::with_capacity(n * 4);
|
||||
let mut colors = Vec::with_capacity(n * 4);
|
||||
let mut indices = Vec::with_capacity(n * 6);
|
||||
// Size scales with the instance transform only when the emitter flags it (0x200) — an
|
||||
// instance-scaled prop otherwise scales its particle *positions* only (wow-re B2).
|
||||
let scale = if def.scale_size_by_instance() {
|
||||
@@ -170,8 +166,8 @@ pub(super) fn expand_quads(
|
||||
let u_age = (p.age / def.lifespan).clamp(0.0, 1.0);
|
||||
let ol = def.over_life.sample(u_age);
|
||||
let (rgba, size) = (ol.color, ol.size);
|
||||
// RAW authored RGB — the gamma-space decode happens ONCE, in the material's fragment
|
||||
// (`wow_particle.wgsl`, decision 0152), where it also covers the texture term that a
|
||||
// RAW authored RGB — the gamma-space decode happens ONCE, in the lane's fragment
|
||||
// (`wow_effect.wgsl`, decision 0152), where it also covers the texture term that a
|
||||
// CPU-side vertex linearisation (0150) could not reach. Alpha is a blend weight — raw
|
||||
// everywhere. The remaining bonfire-CORE brightness gap vs the reference is the
|
||||
// additive-composite-space question (0148 open item: the reference sums gamma bytes in
|
||||
@@ -189,21 +185,18 @@ pub(super) fn expand_quads(
|
||||
// alike burn steady; the old base+rand reading collapsed the kobold candle to zero),
|
||||
// × the instance scale iff flagged.
|
||||
let half = size * def.twinkle(noise) * scale;
|
||||
// Within one cloud, quad order stays pool order — the reference's global per-particle
|
||||
// painter sort is a named deliberate simplification. Between clouds, the draw record's
|
||||
// anchor gives each its real sorted depth (the old anchor-relative vertex encoding's
|
||||
// job — two overlapping smoke plumes must never sort-tie and swap per frame).
|
||||
let mut push_quad = |corners: [Vec3; 4], quv: [[f32; 2]; 4]| {
|
||||
let b = positions.len() as u32;
|
||||
for (c, t) in corners.iter().zip(quv) {
|
||||
// Vertices are ANCHOR-relative (the entity transform carries the anchor):
|
||||
// Bevy's transparent pass sorts meshes by their entity position, so every
|
||||
// cloud needs a real depth — all-world-baked-at-origin made two overlapping
|
||||
// smoke plumes sort-tie and swap draw order per frame (the distant-bonfire
|
||||
// flashing). Within one cloud, quad order stays pool order — the reference's
|
||||
// global per-particle painter sort is a named deliberate simplification.
|
||||
positions.push((*c - anchor).to_array());
|
||||
normals.push(cam.face_normal.to_array());
|
||||
colors.push(rgba);
|
||||
uvs.push(t);
|
||||
out.push(EffectVertex {
|
||||
pos: c.to_array(),
|
||||
uv: t,
|
||||
color: rgba,
|
||||
});
|
||||
}
|
||||
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
|
||||
};
|
||||
// HEAD quad (particleType 0/2), drawn first (`0x7b2bc9`): a camera-facing billboard,
|
||||
// or — XY-quad emitters — the flat plane basis computed above.
|
||||
@@ -288,12 +281,6 @@ pub(super) fn expand_quads(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_indices(Indices::U32(indices));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
//! The **effect lane** — the render-world half of the shared effect stream (decisions 0732
|
||||
//! P1/P2, 0733): one vertex buffer + one per-frame index stream for every dynamic-effect
|
||||
//! family, drawn as directly-constructed [`Transparent3d`] items.
|
||||
//!
|
||||
//! The shape is bevy_ui_render's (`draw_indexed(range, 0, 0..1)` over a shared `RawBufferVec`,
|
||||
//! and its sorted-items batching walk), moved to the 3-D transparent phase:
|
||||
//!
|
||||
//! - **extract** copies the frame's CPU stream;
|
||||
//! - **queue** adds one item per draw record with **sort distance = view-space z of the draw
|
||||
//! anchor + the ladder rung** — exactly the metric the material path produced
|
||||
//! (`rangefinder.distance(center) + depth_bias`, bevy_pbr `material.rs:1307`), so ordering
|
||||
//! against M2 blend batches, model-particle instances and the sky ladder is unchanged by
|
||||
//! construction;
|
||||
//! - **prepare** (after `PhaseSort`) rebases each draw's vertices against its target view's
|
||||
//! camera position (0733 §2 — the `ViewUniform.world_position` source value, so the shader's
|
||||
//! reconstruction is exact), uploads them, then walks the sorted items building the frame's
|
||||
//! index stream in draw order and **merging sort-adjacent items that share (pipeline,
|
||||
//! texture, light, fog)** into one draw call. The merge rides bevy's own sorted-phase
|
||||
//! contract: the phase renderer advances by `batch_range.len()` (`render_phase/mod.rs:1487`),
|
||||
//! so a batch-opening item whose range spans its followers absorbs them — bevy_ui_render's
|
||||
//! exact mechanism. Bevy's mesh batcher leaves items whose main entity owns no registered
|
||||
//! mesh untouched (0732 audit III), so nothing else rewrites the ranges.
|
||||
//!
|
||||
//! Pipeline variants (0733 §4): Add = premultiplied-alpha + the shader's gamma `rgb·a` fold,
|
||||
//! Alpha = standard, Opaque = no blend with depth-write ON (in the transparent bracket at the
|
||||
//! owner rung — the 0719 reference law), Multiply = bevy's `AlphaMode::Multiply` state (the
|
||||
//! blob shadow's modulate; `ModelBlend::Mod` at α=1), Mod2x = 0528's `(Dst, Src)`. The decal
|
||||
//! family's rasterizer depth bias rides [`EffectPipelineKey::raster_bias`] — the half of the
|
||||
//! old material `depth_bias` that settles the coplanar depth tie against the drawn ground.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use bevy::asset::{AssetEvent, AssetId};
|
||||
use bevy::core_pipeline::core_3d::{Transparent3d, CORE_3D_DEPTH_FORMAT};
|
||||
use bevy::ecs::system::lifetimeless::{Read, SRes};
|
||||
use bevy::ecs::system::SystemParamItem;
|
||||
use bevy::image::BevyDefault as _;
|
||||
use bevy::mesh::VertexBufferLayout;
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_asset::RenderAssets;
|
||||
use bevy::render::render_phase::{
|
||||
AddRenderCommand, DrawFunctions, PhaseItem, PhaseItemExtraIndex, RenderCommand,
|
||||
RenderCommandResult, SetItemPipeline, TrackedRenderPass, ViewSortedRenderPhases,
|
||||
};
|
||||
use bevy::render::render_resource::binding_types::{
|
||||
sampler, storage_buffer_read_only_sized, texture_2d, uniform_buffer,
|
||||
};
|
||||
use bevy::render::render_resource::{
|
||||
BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, BlendComponent,
|
||||
BlendFactor, BlendOperation, BlendState, Buffer, BufferId, BufferUsages, ColorTargetState,
|
||||
ColorWrites, CompareFunction, DepthBiasState, DepthStencilState, DynamicUniformBuffer,
|
||||
FragmentState, IndexFormat, MultisampleState, PipelineCache, PrimitiveState, RawBufferVec,
|
||||
RenderPipelineDescriptor, SamplerBindingType, ShaderStages, SpecializedRenderPipeline,
|
||||
SpecializedRenderPipelines, StencilState, TextureFormat, TextureSampleType, VertexFormat,
|
||||
VertexState, VertexStepMode,
|
||||
};
|
||||
use bevy::render::renderer::{RenderDevice, RenderQueue};
|
||||
use bevy::render::sync_world::MainEntity;
|
||||
use bevy::render::texture::GpuImage;
|
||||
use bevy::render::view::{
|
||||
ExtractedView, Msaa, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms,
|
||||
};
|
||||
use bevy::render::{Extract, ExtractSchedule, Render, RenderApp, RenderStartup, RenderSystems};
|
||||
use bevy::shader::Shader;
|
||||
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
|
||||
use super::buffer::{EffectBlend, EffectFog, EffectQuads, EffectTopology, EffectVertex};
|
||||
|
||||
/// One extracted draw: the render-world copy of a [`super::buffer::EffectDraw`].
|
||||
struct ExtractedDraw {
|
||||
cam: Entity,
|
||||
main_entity: Entity,
|
||||
texture: AssetId<Image>,
|
||||
blend: EffectBlend,
|
||||
topology: EffectTopology,
|
||||
fog: EffectFog,
|
||||
anchor: Vec3,
|
||||
bias: f32,
|
||||
raster_bias: i32,
|
||||
/// Vertex range in the shared stream.
|
||||
range: Range<u32>,
|
||||
/// A booth's scene-light override (0539 §5); `None` = the world's shared light buffer.
|
||||
light: Option<Buffer>,
|
||||
}
|
||||
|
||||
/// One GPU draw call after the merge walk: a contiguous index range plus the bind-group
|
||||
/// identity every item folded into it shares.
|
||||
struct MergedDraw {
|
||||
index_range: Range<u32>,
|
||||
texture: AssetId<Image>,
|
||||
light: Option<Buffer>,
|
||||
fog: EffectFog,
|
||||
}
|
||||
|
||||
/// The lane's per-frame GPU state: the shared vertex stream (rebased camera-relative in
|
||||
/// prepare), the frame's index stream (built in sorted-item order by the merge walk), the
|
||||
/// draw and merged-draw records, and the canonical fog-params uniform (one `vec4` per
|
||||
/// [`EffectFog`] policy, written once).
|
||||
#[derive(Resource)]
|
||||
pub struct EffectMeta {
|
||||
vertices: RawBufferVec<EffectVertex>,
|
||||
indices: RawBufferVec<u32>,
|
||||
draws: Vec<ExtractedDraw>,
|
||||
merged: Vec<MergedDraw>,
|
||||
view_bind_group: Option<BindGroup>,
|
||||
params: DynamicUniformBuffer<Vec4>,
|
||||
params_offsets: Option<[u32; 6]>,
|
||||
}
|
||||
|
||||
impl Default for EffectMeta {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vertices: RawBufferVec::new(BufferUsages::VERTEX),
|
||||
indices: RawBufferVec::new(BufferUsages::INDEX),
|
||||
draws: Vec::new(),
|
||||
merged: Vec::new(),
|
||||
view_bind_group: None,
|
||||
params: DynamicUniformBuffer::default(),
|
||||
params_offsets: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-(texture, light-buffer) bind groups (texture + sampler + light blob + params uniform),
|
||||
/// cached across frames — invalidated by that image's asset events, mirroring bevy_ui_render's
|
||||
/// cache. The light key is `None` for the world's shared buffer (startup-created, never
|
||||
/// re-created, so it cannot stale a cached group) or a booth scene blob's id (0539 §5).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EffectBindGroups {
|
||||
images: HashMap<(AssetId<Image>, Option<BufferId>), BindGroup>,
|
||||
}
|
||||
|
||||
/// The lane's pipeline: layouts + shader, specialized per (blend, raster bias, msaa, hdr).
|
||||
#[derive(Resource)]
|
||||
pub struct EffectPipeline {
|
||||
view_layout: BindGroupLayoutDescriptor,
|
||||
image_layout: BindGroupLayoutDescriptor,
|
||||
shader: Handle<Shader>,
|
||||
}
|
||||
|
||||
pub fn init_effect_pipeline(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
let view_layout = BindGroupLayoutDescriptor::new(
|
||||
"effect_view_layout",
|
||||
&BindGroupLayoutEntries::single(
|
||||
ShaderStages::VERTEX_FRAGMENT,
|
||||
uniform_buffer::<ViewUniform>(true),
|
||||
),
|
||||
);
|
||||
let image_layout = BindGroupLayoutDescriptor::new(
|
||||
"effect_image_layout",
|
||||
&BindGroupLayoutEntries::sequential(
|
||||
ShaderStages::FRAGMENT,
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
// The shared global light/fog blob (`lighting::global_light`) — sized at bind
|
||||
// time; the WGSL struct pins the layout.
|
||||
storage_buffer_read_only_sized(false, None),
|
||||
// The per-draw fog-params `vec4`, dynamic-offset into the canonical rows.
|
||||
uniform_buffer::<Vec4>(true),
|
||||
),
|
||||
),
|
||||
);
|
||||
commands.insert_resource(EffectPipeline {
|
||||
view_layout,
|
||||
image_layout,
|
||||
shader: asset_server.load("shaders/wow_effect.wgsl"),
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct EffectPipelineKey {
|
||||
samples: u32,
|
||||
hdr: bool,
|
||||
/// The draw's blend — the whole state-variant axis (0733 §4).
|
||||
blend: EffectBlend,
|
||||
/// The rasterizer depth-bias constant (the decal family's coplanarity settle; 0 for
|
||||
/// free-floating geometry). Three values exist ({0, 4096, 8192}), so the key space stays
|
||||
/// small.
|
||||
raster_bias: i32,
|
||||
}
|
||||
|
||||
impl SpecializedRenderPipeline for EffectPipeline {
|
||||
type Key = EffectPipelineKey;
|
||||
|
||||
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
|
||||
let vertex_layout = VertexBufferLayout::from_vertex_formats(
|
||||
VertexStepMode::Vertex,
|
||||
vec![
|
||||
// position (camera-relative — prepare rebased it; 0733 §2)
|
||||
VertexFormat::Float32x3,
|
||||
// uv
|
||||
VertexFormat::Float32x2,
|
||||
// color (raw authored gamma RGBA)
|
||||
VertexFormat::Float32x4,
|
||||
],
|
||||
);
|
||||
// The blend states each family's material carried (0733 §4): Add/Alpha/Opaque are the
|
||||
// P1 trio; Multiply is bevy's own `AlphaMode::Multiply` state (mesh.rs:2486 — the blob
|
||||
// shadow's `dst·lerp(1, src, α)` with the shader-side premultiply); Mod2x is 0528's
|
||||
// `(Dst, Src)` = `2·src·dst` (rain's verified state, ARMORREFLECT's law).
|
||||
let (blend, depth_write, blend_def) = match key.blend {
|
||||
EffectBlend::Add => (
|
||||
Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
false,
|
||||
"BLEND_ADD",
|
||||
),
|
||||
EffectBlend::Alpha => (Some(BlendState::ALPHA_BLENDING), false, "BLEND_ALPHA"),
|
||||
EffectBlend::Opaque => (None, true, "BLEND_OPAQUE"),
|
||||
EffectBlend::Multiply => (
|
||||
Some(BlendState {
|
||||
color: BlendComponent {
|
||||
src_factor: BlendFactor::Dst,
|
||||
dst_factor: BlendFactor::OneMinusSrcAlpha,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
alpha: BlendComponent::OVER,
|
||||
}),
|
||||
false,
|
||||
"BLEND_MULTIPLY",
|
||||
),
|
||||
EffectBlend::Mod2x => (
|
||||
Some(BlendState {
|
||||
color: BlendComponent {
|
||||
src_factor: BlendFactor::Dst,
|
||||
dst_factor: BlendFactor::Src,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
alpha: BlendComponent {
|
||||
src_factor: BlendFactor::Zero,
|
||||
dst_factor: BlendFactor::One,
|
||||
operation: BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
"BLEND_MOD2X",
|
||||
),
|
||||
};
|
||||
let mut shader_defs = vec![blend_def.into()];
|
||||
// `$WOW_PARTICLE_FLAT` — the fragment-input A/B (B16): solid magenta, no inputs.
|
||||
if std::env::var_os("WOW_PARTICLE_FLAT").is_some() {
|
||||
shader_defs.push("WOW_PARTICLE_FLAT".into());
|
||||
}
|
||||
// `$WOW_PARTICLE_NODEPTH` — the occlusion A/B (B16): force the depth COMPARE to
|
||||
// `Always`, splitting "nothing is emitted" from "emitted and the depth buffer eats it".
|
||||
let depth_compare = if std::env::var_os("WOW_PARTICLE_NODEPTH").is_some() {
|
||||
CompareFunction::Always
|
||||
} else {
|
||||
CompareFunction::GreaterEqual
|
||||
};
|
||||
RenderPipelineDescriptor {
|
||||
label: Some("wow_effect_pipeline".into()),
|
||||
layout: vec![self.view_layout.clone(), self.image_layout.clone()],
|
||||
vertex: VertexState {
|
||||
shader: self.shader.clone(),
|
||||
shader_defs: shader_defs.clone(),
|
||||
buffers: vec![vertex_layout],
|
||||
..default()
|
||||
},
|
||||
fragment: Some(FragmentState {
|
||||
shader: self.shader.clone(),
|
||||
shader_defs,
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format: if key.hdr {
|
||||
ViewTarget::TEXTURE_FORMAT_HDR
|
||||
} else {
|
||||
TextureFormat::bevy_default()
|
||||
},
|
||||
blend,
|
||||
write_mask: ColorWrites::ALL,
|
||||
})],
|
||||
..default()
|
||||
}),
|
||||
primitive: PrimitiveState {
|
||||
cull_mode: None, // billboards, trails, decals: never backface-cull
|
||||
..default()
|
||||
},
|
||||
depth_stencil: Some(DepthStencilState {
|
||||
format: CORE_3D_DEPTH_FORMAT,
|
||||
depth_write_enabled: depth_write,
|
||||
depth_compare,
|
||||
stencil: StencilState::default(),
|
||||
bias: DepthBiasState {
|
||||
constant: key.raster_bias,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: MultisampleState {
|
||||
count: key.samples,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the main world's frame stream into the lane, and drop bind-group cache entries for
|
||||
/// images that changed (the UI cache-invalidation shape).
|
||||
fn extract_effects(
|
||||
mut meta: ResMut<EffectMeta>,
|
||||
mut bind_groups: ResMut<EffectBindGroups>,
|
||||
quads: Extract<Res<EffectQuads>>,
|
||||
mut image_events: Extract<MessageReader<AssetEvent<Image>>>,
|
||||
) {
|
||||
for event in image_events.read() {
|
||||
match event {
|
||||
AssetEvent::Added { id }
|
||||
| AssetEvent::Modified { id }
|
||||
| AssetEvent::Removed { id }
|
||||
| AssetEvent::Unused { id } => {
|
||||
bind_groups.images.retain(|(image, _), _| image != id);
|
||||
}
|
||||
AssetEvent::LoadedWithDependencies { .. } => {}
|
||||
}
|
||||
}
|
||||
meta.vertices.clear();
|
||||
meta.vertices.extend(quads.verts.iter().copied());
|
||||
meta.draws.clear();
|
||||
meta.draws.extend(quads.draws.iter().map(|d| ExtractedDraw {
|
||||
cam: d.cam,
|
||||
main_entity: d.main_entity,
|
||||
texture: d.texture,
|
||||
blend: d.blend,
|
||||
topology: d.topology,
|
||||
fog: d.fog,
|
||||
anchor: d.anchor,
|
||||
bias: d.bias,
|
||||
raster_bias: d.raster_bias,
|
||||
range: d.range.clone(),
|
||||
light: d.light.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add one [`Transparent3d`] item per draw record to the matching camera's phase.
|
||||
fn queue_effects(
|
||||
effect_pipeline: Res<EffectPipeline>,
|
||||
mut pipelines: ResMut<SpecializedRenderPipelines<EffectPipeline>>,
|
||||
pipeline_cache: Res<PipelineCache>,
|
||||
draw_functions: Res<DrawFunctions<Transparent3d>>,
|
||||
meta: Res<EffectMeta>,
|
||||
mut phases: ResMut<ViewSortedRenderPhases<Transparent3d>>,
|
||||
views: Query<(&ExtractedView, &Msaa)>,
|
||||
) {
|
||||
if meta.draws.is_empty() {
|
||||
return;
|
||||
}
|
||||
let draw_function = draw_functions.read().id::<DrawEffects>();
|
||||
for (view, msaa) in &views {
|
||||
// A draw targets ONE main-world camera (the world camera or a booth's — resolved by
|
||||
// the sim); the phase map is keyed by the retained view, whose main entity IS that
|
||||
// camera. Views without a transparent phase (shadow/prepass) fall out here.
|
||||
let Some(phase) = phases.get_mut(&view.retained_view_entity) else {
|
||||
continue;
|
||||
};
|
||||
let rangefinder = view.rangefinder3d();
|
||||
for (i, draw) in meta.draws.iter().enumerate() {
|
||||
if view.retained_view_entity.main_entity != MainEntity::from(draw.cam) {
|
||||
continue;
|
||||
}
|
||||
let pipeline = pipelines.specialize(
|
||||
&pipeline_cache,
|
||||
&effect_pipeline,
|
||||
EffectPipelineKey {
|
||||
samples: msaa.samples(),
|
||||
hdr: view.hdr,
|
||||
blend: draw.blend,
|
||||
raster_bias: draw.raster_bias,
|
||||
},
|
||||
);
|
||||
phase.add(Transparent3d {
|
||||
// The material path's exact metric: view-space z of the sort point + the
|
||||
// ladder rung (see the module doc).
|
||||
distance: rangefinder.distance(&draw.anchor) + draw.bias,
|
||||
pipeline,
|
||||
entity: (Entity::PLACEHOLDER, MainEntity::from(draw.main_entity)),
|
||||
draw_function,
|
||||
// The draw-record index rides here until the prepare walk rewrites it to a
|
||||
// merged-draw index (whose length spans the items it absorbed — the sorted
|
||||
// phase renderer's own batching contract).
|
||||
batch_range: (i as u32)..(i as u32 + 1),
|
||||
extra_index: PhaseItemExtraIndex::None,
|
||||
indexed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The run identity the merge walk groups by: everything two adjacent items must share to be
|
||||
/// one GPU draw — pipeline (blend/bias/msaa/hdr), bind group (texture + light), params row.
|
||||
type RunKey = (
|
||||
bevy::render::render_resource::CachedRenderPipelineId,
|
||||
AssetId<Image>,
|
||||
Option<BufferId>,
|
||||
u32,
|
||||
);
|
||||
|
||||
/// After `PhaseSort`: rebase each draw's vertices against its target view's camera position
|
||||
/// (0733 §2), upload them, build the frame's index stream in sorted-item order while merging
|
||||
/// sort-adjacent compatible items into single draws, and write the canonical fog-params rows
|
||||
/// once.
|
||||
#[allow(clippy::too_many_arguments)] // one render system's full input set
|
||||
fn prepare_effects(
|
||||
device: Res<RenderDevice>,
|
||||
queue: Res<RenderQueue>,
|
||||
draw_functions: Res<DrawFunctions<Transparent3d>>,
|
||||
mut meta: ResMut<EffectMeta>,
|
||||
mut phases: ResMut<ViewSortedRenderPhases<Transparent3d>>,
|
||||
views: Query<&ExtractedView>,
|
||||
) {
|
||||
let meta = &mut *meta;
|
||||
// The rebase (0733 §2): subtract the draw's target camera position — the exact value the
|
||||
// view's `ViewUniform.world_position` is built from (`view/mod.rs:985`), so the shader's
|
||||
// `view_from_world`-rotation reconstruction is bitwise-consistent. The stream stays
|
||||
// world-space main-world-side (instruments read it); only the upload copy moves.
|
||||
// (A handful of extracted views; prepass/shadow views of the same camera collapse to the
|
||||
// same translation.)
|
||||
let mut cams: HashMap<MainEntity, Vec3> = HashMap::default();
|
||||
for view in &views {
|
||||
cams.insert(
|
||||
view.retained_view_entity.main_entity,
|
||||
view.world_from_view.translation(),
|
||||
);
|
||||
}
|
||||
for draw in &meta.draws {
|
||||
let Some(cam) = cams.get(&MainEntity::from(draw.cam)) else {
|
||||
continue;
|
||||
};
|
||||
let offset = cam.to_array();
|
||||
for v in &mut meta.vertices.values_mut()[draw.range.start as usize..draw.range.end as usize]
|
||||
{
|
||||
v.pos[0] -= offset[0];
|
||||
v.pos[1] -= offset[1];
|
||||
v.pos[2] -= offset[2];
|
||||
}
|
||||
}
|
||||
meta.vertices.write_buffer(&device, &queue);
|
||||
|
||||
// The merge walk (0733 §3, the bevy_ui_render shape): one pass over each LIVE view's sorted
|
||||
// items — the same views queue targeted, never the raw phase map (a map entry bevy hasn't
|
||||
// swept yet would carry a dead frame's indices). Every effect item's indices are appended
|
||||
// (quad pattern or identity tri-list); adjacent items sharing a [`RunKey`] fold into the
|
||||
// run-opening item, whose `batch_range` is rewritten to
|
||||
// `merged_index .. merged_index + run_len` — the phase renderer draws the opener once and
|
||||
// advances past the absorbed items (`render_phase/mod.rs:1487`).
|
||||
let effect_fn = draw_functions.read().id::<DrawEffects>();
|
||||
meta.indices.clear();
|
||||
meta.merged.clear();
|
||||
let mut walked: Vec<bevy::render::view::RetainedViewEntity> = Vec::new();
|
||||
for view in &views {
|
||||
// Prepass/shadow views share a camera; only the first walk of a phase counts.
|
||||
if walked.contains(&view.retained_view_entity) {
|
||||
continue;
|
||||
}
|
||||
walked.push(view.retained_view_entity);
|
||||
let Some(phase) = phases.get_mut(&view.retained_view_entity) else {
|
||||
continue;
|
||||
};
|
||||
let mut open: Option<(usize, u32, RunKey)> = None;
|
||||
let close = |items: &mut Vec<Transparent3d>,
|
||||
open: &mut Option<(usize, u32, RunKey)>,
|
||||
merged_len: usize| {
|
||||
if let Some((item_idx, run_len, _)) = open.take() {
|
||||
let m = merged_len as u32 - 1;
|
||||
items[item_idx].batch_range = m..m + run_len;
|
||||
}
|
||||
};
|
||||
for i in 0..phase.items.len() {
|
||||
let item = &phase.items[i];
|
||||
if item.draw_function != effect_fn {
|
||||
close(&mut phase.items, &mut open, meta.merged.len());
|
||||
continue;
|
||||
}
|
||||
let (pipeline, draw_idx) = (item.pipeline, item.batch_range.start as usize);
|
||||
// Defense in depth: `batch_range` is only a draw index if nothing else rewrote it.
|
||||
// Every draw's probe identity is mesh-less, which keeps bevy's sorted-phase batcher
|
||||
// off our items (it claims any item whose MAIN entity has a registered mesh and
|
||||
// rewrites the range to an instance index — the Goldshire-teleport crash, where
|
||||
// foam draws carried their water chunk). If a future violation sneaks in, degrade
|
||||
// to one skipped item — never a render-thread panic.
|
||||
let Some(draw) = meta.draws.get(draw_idx) else {
|
||||
close(&mut phase.items, &mut open, meta.merged.len());
|
||||
phase.items[i].batch_range = 0..0;
|
||||
continue;
|
||||
};
|
||||
let index_start = meta.indices.len() as u32;
|
||||
match draw.topology {
|
||||
EffectTopology::Quads => {
|
||||
let mut b = draw.range.start;
|
||||
while b < draw.range.end {
|
||||
for k in [b, b + 1, b + 2, b, b + 2, b + 3] {
|
||||
meta.indices.push(k);
|
||||
}
|
||||
b += 4;
|
||||
}
|
||||
}
|
||||
EffectTopology::Tris => {
|
||||
for k in draw.range.clone() {
|
||||
meta.indices.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
let index_end = meta.indices.len() as u32;
|
||||
let key: RunKey = (
|
||||
pipeline,
|
||||
draw.texture,
|
||||
draw.light.as_ref().map(|b| b.id()),
|
||||
draw.fog.slot(),
|
||||
);
|
||||
match &mut open {
|
||||
Some((_, run_len, open_key)) if *open_key == key => {
|
||||
meta.merged
|
||||
.last_mut()
|
||||
.expect("open run has a merged record")
|
||||
.index_range
|
||||
.end = index_end;
|
||||
*run_len += 1;
|
||||
}
|
||||
_ => {
|
||||
close(&mut phase.items, &mut open, meta.merged.len());
|
||||
meta.merged.push(MergedDraw {
|
||||
index_range: index_start..index_end,
|
||||
texture: draw.texture,
|
||||
light: draw.light.clone(),
|
||||
fog: draw.fog,
|
||||
});
|
||||
open = Some((i, 1, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut phase.items, &mut open, meta.merged.len());
|
||||
}
|
||||
if !meta.indices.is_empty() {
|
||||
meta.indices.write_buffer(&device, &queue);
|
||||
}
|
||||
|
||||
if meta.params_offsets.is_none() {
|
||||
// Slot order = `EffectFog::slot`: off, scene, black, white, grey, rain-forced —
|
||||
// `params.x` carries the shader's fog COLOUR policy (the `0x70baf0` table), `params.y`
|
||||
// the forced-fog enable with `zw` its start/end (rain's verified 70..75 window — the
|
||||
// constants live with their law in `weather::precip`).
|
||||
let offsets = [
|
||||
meta.params.push(&Vec4::new(0.0, 0.0, 0.0, 0.0)),
|
||||
meta.params.push(&Vec4::new(1.0, 0.0, 0.0, 0.0)),
|
||||
meta.params.push(&Vec4::new(2.0, 0.0, 0.0, 0.0)),
|
||||
meta.params.push(&Vec4::new(3.0, 0.0, 0.0, 0.0)),
|
||||
meta.params.push(&Vec4::new(4.0, 0.0, 0.0, 0.0)),
|
||||
meta.params.push(&Vec4::new(
|
||||
0.0,
|
||||
1.0,
|
||||
crate::weather::RAIN_FOG_START,
|
||||
crate::weather::RAIN_FOG_END,
|
||||
)),
|
||||
];
|
||||
meta.params.write_buffer(&device, &queue);
|
||||
meta.params_offsets = Some(offsets);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the view bind group and any missing per-texture groups for this frame's draws.
|
||||
#[allow(clippy::too_many_arguments)] // one render system's full input set
|
||||
fn prepare_effect_bind_groups(
|
||||
device: Res<RenderDevice>,
|
||||
pipeline_cache: Res<PipelineCache>,
|
||||
pipeline: Res<EffectPipeline>,
|
||||
view_uniforms: Res<ViewUniforms>,
|
||||
gpu_images: Res<RenderAssets<GpuImage>>,
|
||||
light: Option<Res<SharedLightBuffer>>,
|
||||
mut meta: ResMut<EffectMeta>,
|
||||
mut bind_groups: ResMut<EffectBindGroups>,
|
||||
) {
|
||||
let Some(view_binding) = view_uniforms.uniforms.binding() else {
|
||||
return;
|
||||
};
|
||||
meta.view_bind_group = Some(device.create_bind_group(
|
||||
"effect_view_bind_group",
|
||||
&pipeline_cache.get_bind_group_layout(&pipeline.view_layout),
|
||||
&BindGroupEntries::single(view_binding),
|
||||
));
|
||||
let Some(light) = light else { return };
|
||||
let Some(params_binding) = meta.params.binding() else {
|
||||
return;
|
||||
};
|
||||
for draw in &meta.draws {
|
||||
let key = (draw.texture, draw.light.as_ref().map(|b| b.id()));
|
||||
if bind_groups.images.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
// Not yet prepared GPU-side: the draw is skipped this frame (the same "withhold until
|
||||
// resident" the main-world gate applies one asset-layer earlier).
|
||||
let Some(image) = gpu_images.get(draw.texture) else {
|
||||
continue;
|
||||
};
|
||||
let light_buf = draw.light.as_ref().unwrap_or(&light.0);
|
||||
bind_groups.images.insert(
|
||||
key,
|
||||
device.create_bind_group(
|
||||
"effect_image_bind_group",
|
||||
&pipeline_cache.get_bind_group_layout(&pipeline.image_layout),
|
||||
&BindGroupEntries::sequential((
|
||||
&image.texture_view,
|
||||
&image.sampler,
|
||||
light_buf.as_entire_binding(),
|
||||
params_binding.clone(),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub type DrawEffects = (SetItemPipeline, SetEffectViewBindGroup<0>, DrawEffectBatch);
|
||||
|
||||
pub struct SetEffectViewBindGroup<const I: usize>;
|
||||
impl<P: PhaseItem, const I: usize> RenderCommand<P> for SetEffectViewBindGroup<I> {
|
||||
type Param = SRes<EffectMeta>;
|
||||
type ViewQuery = Read<ViewUniformOffset>;
|
||||
type ItemQuery = ();
|
||||
|
||||
fn render<'w>(
|
||||
_item: &P,
|
||||
view_uniform: &'w ViewUniformOffset,
|
||||
_entity: Option<()>,
|
||||
meta: SystemParamItem<'w, '_, Self::Param>,
|
||||
pass: &mut TrackedRenderPass<'w>,
|
||||
) -> RenderCommandResult {
|
||||
let Some(view_bind_group) = meta.into_inner().view_bind_group.as_ref() else {
|
||||
return RenderCommandResult::Failure("effect view bind group not available");
|
||||
};
|
||||
pass.set_bind_group(I, view_bind_group, &[view_uniform.offset]);
|
||||
RenderCommandResult::Success
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DrawEffectBatch;
|
||||
impl<P: PhaseItem> RenderCommand<P> for DrawEffectBatch {
|
||||
type Param = (SRes<EffectMeta>, SRes<EffectBindGroups>);
|
||||
type ViewQuery = ();
|
||||
type ItemQuery = ();
|
||||
|
||||
#[inline]
|
||||
fn render<'w>(
|
||||
item: &P,
|
||||
_view: (),
|
||||
_entity: Option<()>,
|
||||
(meta, bind_groups): SystemParamItem<'w, '_, Self::Param>,
|
||||
pass: &mut TrackedRenderPass<'w>,
|
||||
) -> RenderCommandResult {
|
||||
let meta = meta.into_inner();
|
||||
// `batch_range.start` is the merged-draw index (the prepare walk's rewrite); its length
|
||||
// is the item span the phase renderer advances by — not read here.
|
||||
let Some(draw) = meta.merged.get(item.batch_range().start as usize) else {
|
||||
return RenderCommandResult::Skip;
|
||||
};
|
||||
// GPU image not prepared yet: withheld, exactly like the main-world residency gate.
|
||||
let key = (draw.texture, draw.light.as_ref().map(|b| b.id()));
|
||||
let Some(image_bind_group) = bind_groups.into_inner().images.get(&key) else {
|
||||
return RenderCommandResult::Skip;
|
||||
};
|
||||
let (Some(vertices), Some(indices), Some(offsets)) = (
|
||||
meta.vertices.buffer(),
|
||||
meta.indices.buffer(),
|
||||
meta.params_offsets,
|
||||
) else {
|
||||
return RenderCommandResult::Failure("effect lane buffers not available");
|
||||
};
|
||||
pass.set_bind_group(1, image_bind_group, &[offsets[draw.fog.slot() as usize]]);
|
||||
pass.set_vertex_buffer(0, vertices.slice(..));
|
||||
pass.set_index_buffer(indices.slice(..), IndexFormat::Uint32);
|
||||
pass.draw_indexed(draw.index_range.clone(), 0, 0..1);
|
||||
RenderCommandResult::Success
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the render-world half. The main-world [`EffectQuads`] resource and the family
|
||||
/// writers are their plugins' ([`super::ParticlePlugin`], [`crate::ribbons::RibbonPlugin`],
|
||||
/// the decal/foam/precip modules).
|
||||
pub struct EffectLanePlugin;
|
||||
|
||||
impl Plugin for EffectLanePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
|
||||
return;
|
||||
};
|
||||
render_app
|
||||
.init_resource::<EffectMeta>()
|
||||
.init_resource::<EffectBindGroups>()
|
||||
.init_resource::<SpecializedRenderPipelines<EffectPipeline>>()
|
||||
.add_render_command::<Transparent3d, DrawEffects>()
|
||||
.add_systems(RenderStartup, init_effect_pipeline)
|
||||
.add_systems(ExtractSchedule, extract_effects)
|
||||
.add_systems(
|
||||
Render,
|
||||
(
|
||||
queue_effects.in_set(RenderSystems::Queue),
|
||||
// After `PhaseSort` (the set ordering) — the merge walk needs the final
|
||||
// item order.
|
||||
prepare_effects.in_set(RenderSystems::PrepareResources),
|
||||
prepare_effect_bind_groups.in_set(RenderSystems::PrepareBindGroups),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::player::WorldCamera;
|
||||
|
||||
use super::buffer::{EffectDrawSpec, EffectFog, EffectLightOverride, EffectQuads};
|
||||
use super::quads::{expand_quads, CamBasis, DrawFrame};
|
||||
use super::{
|
||||
accumulate_emission, clear_particle_mesh, emit_local, next_u32, rand01, rand_s11, ChildDraw,
|
||||
ChildEmitter, EmitterFade, Particle, ParticleEmitter, ParticleTuning, MAX_PARTICLES,
|
||||
accumulate_emission, emit_local, next_u32, rand01, rand_s11, ChildDraw, ChildEmitter,
|
||||
EmitterFade, Particle, ParticleEmitter, ParticleTuning, MAX_PARTICLES,
|
||||
};
|
||||
|
||||
/// The emitter-constant inputs of one integrator step ([`integrate_particle`]).
|
||||
@@ -161,7 +162,8 @@ fn drive_child(
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame: emit, integrate, and rebuild each emitter's billboard mesh.
|
||||
/// Per-frame: emit, integrate, and expand each emitter's pool into the shared effect-quad
|
||||
/// stream ([`super::buffer::EffectQuads`]).
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
|
||||
pub(super) fn simulate_particles(
|
||||
time: Res<Time>,
|
||||
@@ -169,12 +171,12 @@ pub(super) fn simulate_particles(
|
||||
// The far-clip wall — the emitter draw-set gate's depth bound (see the gate below).
|
||||
view: Res<crate::view::ViewDistance>,
|
||||
mut commands: Commands,
|
||||
cam: Query<(&GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
|
||||
cam: Query<(Entity, &GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
|
||||
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
|
||||
// both `&mut GlobalTransform` queries below.
|
||||
transforms: Query<&GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
|
||||
// CHILD draw entities (world-root meshes like the parent's own): anchored + published
|
||||
// exactly like the parent entity each frame.
|
||||
// MODEL-particle instance entities ([`super::model`]): the draw-set gate hides them with
|
||||
// their frozen emitter.
|
||||
mut child_draws: Query<
|
||||
(&mut Transform, &mut GlobalTransform, &mut Visibility),
|
||||
(
|
||||
@@ -184,7 +186,7 @@ pub(super) fn simulate_particles(
|
||||
),
|
||||
>,
|
||||
images: Res<Assets<Image>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
// The ground-snap probe (file 0x2000 births): terrain + WMO/doodad geometry, the walking
|
||||
// collision audience.
|
||||
spatial: SpatialQuery,
|
||||
@@ -197,8 +199,8 @@ pub(super) fn simulate_particles(
|
||||
&mut Transform,
|
||||
&mut GlobalTransform,
|
||||
Option<&EmitterFade>,
|
||||
&mut Visibility,
|
||||
Option<&RenderLayers>,
|
||||
Option<&EffectLightOverride>,
|
||||
),
|
||||
Without<WorldCamera>,
|
||||
>,
|
||||
@@ -207,7 +209,7 @@ pub(super) fn simulate_particles(
|
||||
// rule. `Without<ParticleEmitter>`/`Without<ChildDraw>` keep the read provably disjoint from
|
||||
// the `&mut GlobalTransform` writes above.
|
||||
booth_cams: Query<
|
||||
(&GlobalTransform, &RenderLayers),
|
||||
(Entity, &GlobalTransform, &RenderLayers),
|
||||
(
|
||||
With<Camera3d>,
|
||||
Without<WorldCamera>,
|
||||
@@ -222,7 +224,7 @@ pub(super) fn simulate_particles(
|
||||
// `$WOW_PARTICLE_DEPTHDUMP`'s frame counter (see [`super::depthdump`]); inert without the env.
|
||||
mut dump_count: Local<u32>,
|
||||
) {
|
||||
let Ok((cam_tf, frustum, camera, projection)) = cam.single() else {
|
||||
let Ok((world_cam, cam_tf, frustum, camera, projection)) = cam.single() else {
|
||||
return;
|
||||
};
|
||||
// Clamp dt so a load hitch doesn't fling every particle out of existence in one step.
|
||||
@@ -236,43 +238,30 @@ pub(super) fn simulate_particles(
|
||||
let (_, cam_rot, _) = cam_tf.to_scale_rotation_translation();
|
||||
let cam_right = cam_rot * Vec3::X;
|
||||
let cam_up = cam_rot * Vec3::Y;
|
||||
// Toward the camera: a camera-facing billboard's normal is the camera's BACKWARD axis, and
|
||||
// `right × up` = X × Y = +Z local, which is backward in Bevy (a camera looks down −Z). The
|
||||
// reverse product — what this used to be, under a comment claiming "toward the camera" —
|
||||
// points down the view direction instead. Inert either way today (`wow_particle.wgsl` reads no
|
||||
// normal, and the material is unlit + never backface-culled), so this buys honesty in the
|
||||
// vertex data rather than a pixel; a lit particle lane would have inherited the sign error.
|
||||
let face_normal = cam_right.cross(cam_up).normalize_or_zero();
|
||||
// The far-clip wall's axis — the SAME forward `debug_panel::visibility` measures the owner
|
||||
// doodad's own cull along, so an emitter and its owner cross the wall together.
|
||||
let cam_fwd = Vec3::from(cam_tf.forward());
|
||||
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
|
||||
let dump_frame = super::depthdump::frame(time.elapsed_secs(), &mut dump_count);
|
||||
|
||||
for (entity, mut emitter, mut entity_tf, mut entity_global, fade, mut vis, layers) in
|
||||
for (entity, mut emitter, mut entity_tf, mut entity_global, fade, layers, light_override) in
|
||||
&mut emitters
|
||||
{
|
||||
// The camera this emitter's quads face + its emission-LOD distance origin: a booth-layered
|
||||
// emitter uses its booth's camera (decision 0539 §5 — the glue scenes' braziers, which the
|
||||
// WORLD camera would billboard sideways and LOD from a nonsense distance); everything else
|
||||
// the world camera.
|
||||
// The camera this emitter's quads face + its emission-LOD distance origin — and, in the
|
||||
// shared lane, the view its draw record targets: a booth-layered emitter uses its
|
||||
// booth's camera (decision 0539 §5 — the glue scenes' braziers, which the WORLD camera
|
||||
// would billboard sideways and LOD from a nonsense distance); everything else the world
|
||||
// camera.
|
||||
let booth = layers
|
||||
.filter(|l| !l.intersects(&RenderLayers::default()))
|
||||
.and_then(|l| booth_cams.iter().find(|(_, cl)| cl.intersects(l)));
|
||||
.and_then(|l| booth_cams.iter().find(|(_, _, cl)| cl.intersects(l)));
|
||||
let is_booth = booth.is_some();
|
||||
let (e_cam_pos, e_right, e_up, e_normal) = match booth {
|
||||
Some((tf, _)) => {
|
||||
let (draw_cam, e_cam_pos, e_right, e_up) = match booth {
|
||||
Some((cam_entity, tf, _)) => {
|
||||
let (_, rot, _) = tf.to_scale_rotation_translation();
|
||||
let right = rot * Vec3::X;
|
||||
let up = rot * Vec3::Y;
|
||||
(
|
||||
tf.translation(),
|
||||
right,
|
||||
up,
|
||||
right.cross(up).normalize_or_zero(), // toward the booth camera — see above
|
||||
)
|
||||
(cam_entity, tf.translation(), rot * Vec3::X, rot * Vec3::Y)
|
||||
}
|
||||
None => (cam_pos, cam_right, cam_up, face_normal),
|
||||
None => (world_cam, cam_pos, cam_right, cam_up),
|
||||
};
|
||||
// Draw-set gate (byte-verified, decision 0171): the reference ticks an emitter only when
|
||||
// its owner doodad is in the frame's scene worklist — admission = the frustum sphere test
|
||||
@@ -309,15 +298,11 @@ pub(super) fn simulate_particles(
|
||||
),
|
||||
);
|
||||
if !in_set {
|
||||
if *vis != Visibility::Hidden {
|
||||
*vis = Visibility::Hidden;
|
||||
// Child + model-instance draw entities mirror the gate (frozen with the
|
||||
// parent).
|
||||
for c in &emitter.children {
|
||||
if let Ok((_, _, mut cv)) = child_draws.get_mut(c.entity) {
|
||||
*cv = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
// Frozen: the pool writes no quads this frame (the shared stream is cleared
|
||||
// per frame, so "not pushed" IS "not drawn"); model-instance entities mirror
|
||||
// the gate on its edge, as the old entity-visibility flip did.
|
||||
if !emitter.gated {
|
||||
emitter.gated = true;
|
||||
for slot in &emitter.model_instances {
|
||||
for (e, _) in &slot.meshes {
|
||||
if let Ok((_, _, mut cv)) = child_draws.get_mut(*e) {
|
||||
@@ -328,9 +313,7 @@ pub(super) fn simulate_particles(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *vis != Visibility::Inherited {
|
||||
*vis = Visibility::Inherited;
|
||||
}
|
||||
emitter.gated = false;
|
||||
} else if !is_booth && !emitter.draining {
|
||||
// ENTITY-owned emitters (creatures, GameObjects, spell kits, WMO-prop deck lanterns)
|
||||
// carry no `EmitterFade`, and the premise that excused them — "the population is
|
||||
@@ -361,13 +344,8 @@ pub(super) fn simulate_particles(
|
||||
};
|
||||
if let Some(at) = subject {
|
||||
if !crate::view::within_farclip(view.farclip, cam_pos, cam_fwd, at, 0.0) {
|
||||
if *vis != Visibility::Hidden {
|
||||
*vis = Visibility::Hidden;
|
||||
for c in &emitter.children {
|
||||
if let Ok((_, _, mut cv)) = child_draws.get_mut(c.entity) {
|
||||
*cv = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
if !emitter.gated {
|
||||
emitter.gated = true;
|
||||
for slot in &emitter.model_instances {
|
||||
for (e, _) in &slot.meshes {
|
||||
if let Ok((_, _, mut cv)) = child_draws.get_mut(*e) {
|
||||
@@ -378,9 +356,7 @@ pub(super) fn simulate_particles(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *vis != Visibility::Inherited {
|
||||
*vis = Visibility::Inherited;
|
||||
}
|
||||
emitter.gated = false;
|
||||
}
|
||||
}
|
||||
let ParticleEmitter {
|
||||
@@ -402,13 +378,13 @@ pub(super) fn simulate_particles(
|
||||
host,
|
||||
seq,
|
||||
rng,
|
||||
owner_reach: _, // a draw-order rung, baked into the material at spawn
|
||||
mesh,
|
||||
owner_reach,
|
||||
texture,
|
||||
recursion: _,
|
||||
children,
|
||||
geometry: _,
|
||||
model_instances,
|
||||
gated: _,
|
||||
} = &mut *emitter;
|
||||
*age += dt;
|
||||
// Anchored mode (see [`Particle`]): positions are emitter-relative, so tracking a moving
|
||||
@@ -431,9 +407,6 @@ pub(super) fn simulate_particles(
|
||||
}
|
||||
}
|
||||
if *draining && particles.is_empty() && children.iter().all(|c| c.particles.is_empty()) {
|
||||
for c in children.iter() {
|
||||
commands.entity(c.entity).despawn();
|
||||
}
|
||||
for slot in model_instances.iter() {
|
||||
for (e, _) in &slot.meshes {
|
||||
commands.entity(*e).despawn();
|
||||
@@ -734,23 +707,23 @@ pub(super) fn simulate_particles(
|
||||
.retain_mut(|p| integrate_particle(p, &c_env));
|
||||
}
|
||||
|
||||
// 3. Expand each pool into its billboard mesh ([`expand_quads`]) — per pool, only once
|
||||
// its texture is resident. While one streams (or failed to decode), that mesh draws
|
||||
// nothing rather than flash the engine fallback through the additive blend.
|
||||
// The cloud's anchor: the emitter's live position. The entity transform carries it (and
|
||||
// the mesh verts are relative to it) so the transparent pass sorts this cloud at a real
|
||||
// depth — see the note at the vertex push in `expand_quads`.
|
||||
// 3. Expand each pool into the shared effect-quad stream — per pool, only once its
|
||||
// texture is resident. While one streams (or failed to decode), the pool pushes
|
||||
// nothing rather than flash the engine fallback through the additive blend. An idle
|
||||
// pool pushes nothing and commits nothing — the old "don't rewrite an already-empty
|
||||
// mesh" guard is now the structure itself.
|
||||
// The cloud's anchor: the emitter's live position — the draw record's SORT point (the
|
||||
// transparent-phase depth this cloud takes; see `super::buffer`). Still published to
|
||||
// the entity transform: the census probe and phase instruments read the anchor there.
|
||||
let anchor = if anchored {
|
||||
*anchor_pos
|
||||
} else {
|
||||
placement.translation
|
||||
};
|
||||
entity_tf.translation = anchor;
|
||||
// Propagation already ran this frame (we're post-palette in PostUpdate) — publish the
|
||||
// anchor to the rendered GlobalTransform directly, or the cloud draws at LAST frame's
|
||||
// anchor: one frame × 20 yd/s detached the Shadow Bolt's puff cloud visibly behind the
|
||||
// skull (the same exactness rule as `face_billboards`; emitter entities live at the
|
||||
// world root, so the direct write is exact).
|
||||
// Post-propagation frame (we're post-palette in PostUpdate) — publish directly so any
|
||||
// same-frame reader sees THIS frame's anchor (the `face_billboards` exactness rule;
|
||||
// emitter entities live at the world root, so the direct write is exact).
|
||||
*entity_global = GlobalTransform::from(*entity_tf);
|
||||
let frame = DrawFrame {
|
||||
anchored,
|
||||
@@ -760,9 +733,20 @@ pub(super) fn simulate_particles(
|
||||
let cam = CamBasis {
|
||||
right: e_right,
|
||||
up: e_up,
|
||||
face_normal: e_normal,
|
||||
};
|
||||
// `$WOW_PARTICLE_DEPTHDUMP` (B16): the depth numbers this pool brings to the compare.
|
||||
// A GEOMETRY (model-particle) emitter never draws quads — the reference's render
|
||||
// dispatch skips the whole quad path when the model mode is on; its instances
|
||||
// render via [`super::model::update_model_particles`].
|
||||
let want_quads = def.geometry_model.is_none() && images.contains(&*texture);
|
||||
// Every effect of one model takes the SAME rung (0719/0721) — computed here per frame,
|
||||
// where it used to be baked into the material's `depth_bias` at spawn.
|
||||
let bias = super::owner_last_bias(*owner_reach);
|
||||
let start = quads.begin();
|
||||
if want_quads && !particles.is_empty() {
|
||||
expand_quads(def, particles, &frame, placement, &cam, &mut quads.verts);
|
||||
}
|
||||
// `$WOW_PARTICLE_DEPTHDUMP` (B16): the depth numbers this pool brings to the compare —
|
||||
// now over the quads THIS frame just wrote (the exact vertices the draw will consume).
|
||||
// Booth emitters are skipped — their pixels belong to a booth camera's target, not the
|
||||
// world depth buffer `WOW_DEPTH` reads.
|
||||
if let Some(fidx) = dump_frame {
|
||||
@@ -783,36 +767,54 @@ pub(super) fn simulate_particles(
|
||||
camera,
|
||||
projection,
|
||||
images.contains(&*texture),
|
||||
*vis,
|
||||
meshes.get(&*mesh),
|
||||
&quads.verts[start as usize..],
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(m) = meshes.get_mut(&*mesh) {
|
||||
// A GEOMETRY (model-particle) emitter never draws quads — the reference's render
|
||||
// dispatch skips the whole quad path when the model mode is on; its instances
|
||||
// render via [`super::model::update_model_particles`].
|
||||
if def.geometry_model.is_none() && images.contains(&*texture) {
|
||||
expand_quads(def, particles, &frame, placement, &cam, m);
|
||||
} else {
|
||||
clear_particle_mesh(m);
|
||||
}
|
||||
}
|
||||
quads.commit_quads(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam: draw_cam,
|
||||
texture: texture.id(),
|
||||
blend: def.blend.into(),
|
||||
fog: EffectFog::for_blend(def.flags, def.blend),
|
||||
anchor,
|
||||
bias,
|
||||
raster_bias: 0,
|
||||
main_entity: entity,
|
||||
light: light_override.map(|l| l.0.clone()),
|
||||
},
|
||||
);
|
||||
// CHILD pools: their own texture/blend/fog identity, the PARENT's anchor and rung
|
||||
// (a child draws at the parent's anchor, and it is the parent's owner it must clear —
|
||||
// `ParticleEmitter::owner_reach`'s doc).
|
||||
for child in children.iter() {
|
||||
if let Ok((mut ct, mut cg, mut cv)) = child_draws.get_mut(child.entity) {
|
||||
ct.translation = anchor;
|
||||
*cg = GlobalTransform::from(*ct);
|
||||
if *cv != Visibility::Inherited {
|
||||
*cv = Visibility::Inherited;
|
||||
}
|
||||
}
|
||||
if let Some(m) = meshes.get_mut(&child.mesh) {
|
||||
if images.contains(&child.texture) {
|
||||
expand_quads(&child.def, &child.particles, &frame, placement, &cam, m);
|
||||
} else {
|
||||
clear_particle_mesh(m);
|
||||
}
|
||||
if child.particles.is_empty() || !images.contains(&child.texture) {
|
||||
continue;
|
||||
}
|
||||
let cstart = quads.begin();
|
||||
expand_quads(
|
||||
&child.def,
|
||||
&child.particles,
|
||||
&frame,
|
||||
placement,
|
||||
&cam,
|
||||
&mut quads.verts,
|
||||
);
|
||||
quads.commit_quads(
|
||||
cstart,
|
||||
EffectDrawSpec {
|
||||
cam: draw_cam,
|
||||
texture: child.texture.id(),
|
||||
blend: child.def.blend.into(),
|
||||
fog: EffectFog::for_blend(child.def.flags, child.def.blend),
|
||||
anchor,
|
||||
bias,
|
||||
raster_bias: 0,
|
||||
main_entity: entity,
|
||||
light: light_override.map(|l| l.0.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ pub(crate) fn apply_self_model_fade(
|
||||
&mut MeshTag,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut Visibility,
|
||||
Option<&mut crate::interior::InteriorLit>,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
),
|
||||
(
|
||||
Without<RenderFade>,
|
||||
@@ -522,6 +522,7 @@ pub(crate) fn apply_self_model_fade(
|
||||
&mut MeshTag,
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
)>,
|
||||
mut reauthor: ResMut<crate::interior::InteriorReauthor>,
|
||||
mut was_fading: Local<bool>,
|
||||
) {
|
||||
let fading = rig.self_fade_alpha < 1.0;
|
||||
@@ -541,6 +542,7 @@ pub(crate) fn apply_self_model_fade(
|
||||
rig.self_fade_alpha,
|
||||
&children_of,
|
||||
&mut parts,
|
||||
&mut reauthor,
|
||||
&mut walked,
|
||||
);
|
||||
let alpha = rig.self_fade_alpha.clamp(0.0, 1.0);
|
||||
@@ -580,7 +582,7 @@ fn apply_self_fade_to_descendants(
|
||||
&mut MeshTag,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut Visibility,
|
||||
Option<&mut crate::interior::InteriorLit>,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
),
|
||||
(
|
||||
Without<RenderFade>,
|
||||
@@ -588,21 +590,22 @@ fn apply_self_fade_to_descendants(
|
||||
Without<crate::billboard::BillboardCard>,
|
||||
),
|
||||
>,
|
||||
reauthor: &mut crate::interior::InteriorReauthor,
|
||||
walked: &mut EntityHashSet,
|
||||
) {
|
||||
walked.insert(entity);
|
||||
if let Ok((fm, mut tag, mut mat, mut vis, lit)) = parts.get_mut(entity) {
|
||||
if alpha >= 1.0 {
|
||||
// The release edge (runs once, on the frame the fade ends — decision 0213): un-hide,
|
||||
// and hand the material/tag channel back. A part the classifier lights gets
|
||||
// invalidated so it re-authors the correct interior/exterior state next frame; a part
|
||||
// without a classifier (some attach models) gets the direct restore — the same
|
||||
// completion write `apply_render_fade` uses.
|
||||
// and hand the material/tag channel back. A part the classifier lights is enqueued so
|
||||
// it re-authors the correct interior/exterior state next classifier run (0734's
|
||||
// convergence queue); a part without a classifier (some attach models) gets the
|
||||
// direct restore — the same completion write `apply_render_fade` uses.
|
||||
if *vis != Visibility::Inherited {
|
||||
*vis = Visibility::Inherited;
|
||||
}
|
||||
if let Some(mut lit) = lit {
|
||||
lit.invalidate();
|
||||
if lit.is_some() {
|
||||
reauthor.0.push(entity);
|
||||
} else {
|
||||
let bits = crate::mesh_tag::with_alpha(tag.0, 1.0);
|
||||
if tag.0 != bits {
|
||||
@@ -643,7 +646,7 @@ fn apply_self_fade_to_descendants(
|
||||
}
|
||||
if let Ok(children) = children_of.get(entity) {
|
||||
for &child in children {
|
||||
apply_self_fade_to_descendants(child, alpha, children_of, parts, walked);
|
||||
apply_self_fade_to_descendants(child, alpha, children_of, parts, reauthor, walked);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -739,6 +742,7 @@ mod tests {
|
||||
};
|
||||
let mut app = App::new();
|
||||
app.init_resource::<CameraControl>();
|
||||
app.init_resource::<crate::interior::InteriorReauthor>();
|
||||
app.add_systems(Update, apply_self_model_fade);
|
||||
|
||||
// The avatar: root -> joint (the eye-glow bone). Its card follows the joint.
|
||||
|
||||
@@ -428,13 +428,11 @@ pub(super) fn sync_glue_scene(
|
||||
// The scene's particle-emitter spawn wiring (decision 0539 §5) + the skin-palette table and
|
||||
// its mirror registry (decision 0720) — one tuple param (the 16-SystemParam ceiling).
|
||||
particle_assets: (
|
||||
ResMut<Assets<Mesh>>,
|
||||
ResMut<Assets<crate::particles::WowParticleMaterial>>,
|
||||
ResMut<crate::rig_palette::RigPalettes>,
|
||||
ResMut<crate::rig_palette::RigPaletteMirrors>,
|
||||
),
|
||||
) {
|
||||
let (mut meshes, mut particle_materials, mut palettes, mut mirrors) = particle_assets;
|
||||
let (mut palettes, mut mirrors) = particle_assets;
|
||||
let Some(booth) = booths.0.get(GLUE_SLOT) else {
|
||||
return;
|
||||
};
|
||||
@@ -611,10 +609,10 @@ pub(super) fn sync_glue_scene(
|
||||
// The scene's authored particle emitters (decision 0539 §5) — the braziers/embers every
|
||||
// UI_* scene carries (MainMenu 28, Orc 11, NightElf 12…), dead until now. The world spawn
|
||||
// path, on the booth's layer, lit/fogged by the SCENE's own light buffer (the ModelFFX fog
|
||||
// covers the whole model, emitters included); owner = the emitter bone's joint, so fire
|
||||
// flicker rides the animated bones; anchored at the static scene root. No `EmitterFade`:
|
||||
// a glue scene always ticks. Parented under the scene root so teardown cascades.
|
||||
let scene_light_ref = crate::lighting::SharedLightBuffer(light.clone());
|
||||
// covers the whole model, emitters included — carried by `EffectLightOverride` into the
|
||||
// shared lane's per-draw bind group); owner = the emitter bone's joint, so fire flicker
|
||||
// rides the animated bones; anchored at the static scene root. No `EmitterFade`: a glue
|
||||
// scene always ticks. Parented under the scene root so teardown cascades.
|
||||
let mut emitters = 0usize;
|
||||
for em in &model.emitters {
|
||||
let owner = joints
|
||||
@@ -622,9 +620,6 @@ pub(super) fn sync_glue_scene(
|
||||
.map_or((scene.root, [0.0; 3]), |&j| (j, em.bone_pivot));
|
||||
if let Some(e) = crate::particles::spawn_emitter(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&scene_light_ref,
|
||||
em,
|
||||
Transform::IDENTITY,
|
||||
Some(owner),
|
||||
@@ -633,9 +628,11 @@ pub(super) fn sync_glue_scene(
|
||||
// A glue scene loops its one authored clip forever — the doodad law.
|
||||
crate::particles::EmitClock::Pinned,
|
||||
) {
|
||||
commands
|
||||
.entity(e)
|
||||
.insert((booth.layer.clone(), ChildOf(scene.root)));
|
||||
commands.entity(e).insert((
|
||||
booth.layer.clone(),
|
||||
ChildOf(scene.root),
|
||||
crate::particles::buffer::EffectLightOverride(light.clone()),
|
||||
));
|
||||
emitters += 1;
|
||||
}
|
||||
}
|
||||
|
||||
+83
-116
@@ -9,23 +9,23 @@
|
||||
//! the sim below transcribes it with the same simplifications as `particles` (distributions and
|
||||
//! frames mirrored, not the reference's exact float slots).
|
||||
//!
|
||||
//! Like particles, each trail owns one dynamically-rebuilt mesh in world space and an unlit
|
||||
//! material on its own pipeline. A trail rides its **owner** entity (a skinned model's host-bone
|
||||
//! joint, an item root, a missile); when the owner goes it drains — committed edges finish
|
||||
//! fading, then the trail despawns itself (the reference's enable-gate law).
|
||||
//! Like particles, each trail writes its strip into the **shared effect-quad stream**
|
||||
//! ([`crate::particles::buffer::EffectQuads`], decision 0732 slice P1) — per segment, one quad
|
||||
//! duplicating the shared edge vertices (identical triangles to the old strip mesh; a few dozen
|
||||
//! extra vertices per trail buys the whole family one vertex layout and one index pattern). A
|
||||
//! trail rides its **owner** entity (a skinned model's host-bone joint, an item root, a
|
||||
//! missile); when the owner goes it drains — committed edges finish fading, then the trail
|
||||
//! despawns itself (the reference's enable-gate law).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use benilla_assets::ModelRibbon;
|
||||
use benilla_formats::ParticleBlend;
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::ExtendedMaterial;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::particles::{WowParticleExt, WowParticleMaterial};
|
||||
use crate::particles::buffer::{EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
|
||||
use crate::player::WorldCamera;
|
||||
|
||||
/// Hard cap on stored edges — a backstop against a pathological rate·lifetime (the reference's
|
||||
/// ring capacity is `ceil(rate·lifetime)+2`; shipped trails sit far below this).
|
||||
@@ -66,8 +66,12 @@ pub struct RibbonTrail {
|
||||
/// against (an effect model's ribbons spawn at its clip start, so age == clip time — the
|
||||
/// particle emitters' law; a persistent trail's constant tracks are age-invariant).
|
||||
age: f32,
|
||||
mesh: Handle<Mesh>,
|
||||
texture: Handle<Image>,
|
||||
/// The owner-last draw-order rung ([`crate::particles::owner_last_bias`] over the owner's
|
||||
/// world reach, computed at spawn) — a trail is one of its model's emitters and takes the
|
||||
/// SAME rung as the quad clouds beside it (0721). Was the material's `depth_bias`; now the
|
||||
/// draw record's sort-key add.
|
||||
bias: f32,
|
||||
}
|
||||
|
||||
impl RibbonTrail {
|
||||
@@ -83,15 +87,6 @@ impl RibbonTrail {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty a trail mesh (draws nothing).
|
||||
fn clear_ribbon_mesh(mesh: &mut Mesh) {
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, Vec::<[f32; 3]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, Vec::<[f32; 4]>::new());
|
||||
mesh.insert_indices(Indices::U32(Vec::new()));
|
||||
}
|
||||
|
||||
/// Spawn a ribbon-trail entity for one [`ModelRibbon`], riding `owner` (a host-bone joint for a
|
||||
/// skinned model — pass the joint and the def's baked pivot does the rebase — or the model/item
|
||||
/// root). `current_anim` is the `AnimationData.dbc` id the owner's model is running (a static held
|
||||
@@ -103,9 +98,6 @@ fn clear_ribbon_mesh(mesh: &mut Mesh) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_ribbon(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<WowParticleMaterial>,
|
||||
light: &crate::lighting::SharedLightBuffer,
|
||||
ribbon: &ModelRibbon,
|
||||
owner: Entity,
|
||||
use_pivot: bool,
|
||||
@@ -151,56 +143,12 @@ pub fn spawn_ribbon(
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let alpha_mode = match def.blend {
|
||||
ParticleBlend::Add => AlphaMode::Add,
|
||||
ParticleBlend::Alpha => AlphaMode::Blend,
|
||||
ParticleBlend::Opaque => AlphaMode::Opaque,
|
||||
};
|
||||
// The shared effect material (gamma combine + output decode — see `WowParticleMaterial`).
|
||||
let material = materials.add(ExtendedMaterial {
|
||||
base: StandardMaterial {
|
||||
base_color_texture: Some(texture.clone()),
|
||||
unlit: true,
|
||||
alpha_mode,
|
||||
cull_mode: None, // a trail is visible from both sides
|
||||
double_sided: true,
|
||||
// The reference's "a model's emitters draw after that model's batches" — the same
|
||||
// rung the quad clouds take, from the same authored reach, because a trail is one of
|
||||
// the model's emitters (`crate::particles::owner_last_bias`).
|
||||
depth_bias: crate::particles::owner_last_bias(ribbon.owner_reach * owner_scale),
|
||||
..default()
|
||||
},
|
||||
extension: WowParticleExt {
|
||||
light_buf: light.0.clone(),
|
||||
// params.x = the per-blend fog-colour policy (the M2 batch state setter's table,
|
||||
// `0x70baf0` / wow-re ROUND 4 — ribbons ride the same trio): additive trails fog
|
||||
// toward BLACK, fading under the storm veil instead of adding grey; alpha/opaque
|
||||
// trails fog toward the scene colour.
|
||||
params: Vec4::new(
|
||||
match def.blend {
|
||||
ParticleBlend::Add => 2.0,
|
||||
_ => 1.0,
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
),
|
||||
mod2x: false,
|
||||
},
|
||||
});
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
clear_ribbon_mesh(&mut mesh);
|
||||
let mesh = meshes.add(mesh);
|
||||
Some(
|
||||
commands
|
||||
.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(material),
|
||||
// The sim writes the trail's sort anchor (the live head node) here each frame
|
||||
// — the phase probe's read point.
|
||||
Transform::IDENTITY,
|
||||
NoFrustumCulling, // the trail AABB is a moving streak; skip culling
|
||||
RibbonTrail {
|
||||
local_offset: wow_to_bevy(local),
|
||||
def,
|
||||
@@ -208,8 +156,11 @@ pub fn spawn_ribbon(
|
||||
edges: VecDeque::new(),
|
||||
accumulator: 0.0,
|
||||
age: 0.0,
|
||||
mesh,
|
||||
texture,
|
||||
// The reference's "a model's emitters draw after that model's batches" —
|
||||
// the same rung the quad clouds take, from the same authored reach,
|
||||
// because a trail is one of the model's emitters.
|
||||
bias: crate::particles::owner_last_bias(ribbon.owner_reach * owner_scale),
|
||||
},
|
||||
))
|
||||
.id(),
|
||||
@@ -217,15 +168,18 @@ pub fn spawn_ribbon(
|
||||
}
|
||||
|
||||
/// Per-frame: place the node from the owner's live transform, commit/expire edges, sag by
|
||||
/// gravity, and rebuild the strip mesh.
|
||||
fn simulate_ribbons(
|
||||
/// gravity, and write the strip into the shared effect-quad stream.
|
||||
pub(crate) fn simulate_ribbons(
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
// Owner reads (joints/roots — never trail entities): disjoint from the trail query's
|
||||
// `&mut GlobalTransform` below.
|
||||
transforms: Query<&GlobalTransform, Without<RibbonTrail>>,
|
||||
images: Res<Assets<Image>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
// Trails belong to the world lane (no booth ribbons; a booth-parked owner's strip is eaten
|
||||
// by the shader's farclip wall, exactly as on the material path).
|
||||
world_cam: Query<Entity, With<WorldCamera>>,
|
||||
mut trails: Query<(
|
||||
Entity,
|
||||
&mut RibbonTrail,
|
||||
@@ -233,6 +187,9 @@ fn simulate_ribbons(
|
||||
&mut GlobalTransform,
|
||||
)>,
|
||||
) {
|
||||
let Ok(cam) = world_cam.single() else {
|
||||
return;
|
||||
};
|
||||
let dt = time.delta_secs().min(0.1);
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, mut trail, mut entity_tf, mut entity_global) in &mut trails {
|
||||
@@ -243,8 +200,8 @@ fn simulate_ribbons(
|
||||
edges,
|
||||
accumulator,
|
||||
age,
|
||||
mesh,
|
||||
texture,
|
||||
bias,
|
||||
} = &mut *trail;
|
||||
// The keyed look tracks sample on the trail's clip clock (see [`RibbonTrail::age`]):
|
||||
// heights at edge-commit time (each edge keeps the width it was born with — the
|
||||
@@ -305,21 +262,16 @@ fn simulate_ribbons(
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the strip: live head first (while the owner lives), then committed edges
|
||||
// newest→oldest. u slides with age across the tex-slot cell (the texture's transparent
|
||||
// tail is the fade); v spans the cell band. Colour = the authored tint × the shared
|
||||
// intensity knob on the additive weight.
|
||||
// Write the strip into the shared stream: live head first (while the owner lives), then
|
||||
// committed edges newest→oldest. u slides with age across the tex-slot cell (the
|
||||
// texture's transparent tail is the fade); v spans the cell band. An idle trail — no
|
||||
// strip yet, or a non-resident texture — pushes nothing and commits nothing: the old
|
||||
// "don't rewrite an already-empty mesh" guard is now the structure itself.
|
||||
if !images.contains(&*texture) {
|
||||
if let Some(m) = meshes.get_mut(&*mesh) {
|
||||
clear_ribbon_mesh(m);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let n = edges.len() + usize::from(head.is_some());
|
||||
if n < 2 {
|
||||
if let Some(m) = meshes.get_mut(&*mesh) {
|
||||
clear_ribbon_mesh(m);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (rows, cols) = (def.tile_rows.max(1), def.tile_cols.max(1));
|
||||
@@ -332,56 +284,71 @@ fn simulate_ribbons(
|
||||
f32::from(cell / cols) / f32::from(rows),
|
||||
f32::from(cell / cols + 1) / f32::from(rows),
|
||||
);
|
||||
// RAW authored RGB — the gamma decode happens once in `wow_particle.wgsl` (decision 0152),
|
||||
// RAW authored RGB — the gamma decode happens once in the effect shader (decision 0152),
|
||||
// covering the texture term too. Alpha is a blend weight, raw.
|
||||
let rgb = def.color.sample_ms(ms);
|
||||
let rgba = [rgb[0], rgb[1], rgb[2], def.alpha.sample_ms(ms).max(0.0)];
|
||||
let mut positions = Vec::with_capacity(n * 2);
|
||||
let mut uvs = Vec::with_capacity(n * 2);
|
||||
// Verts are ANCHOR-relative and the entity transform carries the anchor: the transparent
|
||||
// pass sorts meshes by entity position, so the trail needs a real depth (the same
|
||||
// sort-tie flashing fix as the particle clouds). Draining trails anchor on their newest
|
||||
// surviving edge.
|
||||
// The trail's SORT anchor — the live head node (the point the material path's entity
|
||||
// translation used to carry; same sort-tie flashing fix as the particle clouds).
|
||||
// Draining trails anchor on their newest surviving edge.
|
||||
let anchor = head.map(|(node, _)| node).unwrap_or_else(|| {
|
||||
let e = edges.back().expect("n >= 2 ⇒ edges exist while draining");
|
||||
(e.top + e.bottom) * 0.5
|
||||
});
|
||||
entity_tf.translation = anchor;
|
||||
// Post-propagation frame: publish directly, or the strip renders at last frame's anchor
|
||||
// — the trail head visibly detached from a 20 yd/s missile (see the particle sim's
|
||||
// matching note; trail entities live at the world root, the direct write is exact).
|
||||
// Post-propagation frame: publish directly (see the particle sim's matching note; trail
|
||||
// entities live at the world root, the direct write is exact).
|
||||
*entity_global = GlobalTransform::from(*entity_tf);
|
||||
let mut push_pair = |t: Vec3, b: Vec3, age01: f32| {
|
||||
positions.push((t - anchor).to_array());
|
||||
positions.push((b - anchor).to_array());
|
||||
let u = u0 + (u1 - u0) * age01;
|
||||
uvs.push([u, v0]);
|
||||
uvs.push([u, v1]);
|
||||
};
|
||||
// The edge sequence, head first then newest→oldest — each consecutive pair becomes one
|
||||
// quad whose corner order reproduces the old strip's exact triangles: strip triangles
|
||||
// (t₀,b₀,t₁),(b₀,b₁,t₁) = quad [b₀,b₁,t₁,t₀] under the lane's [0,1,2, 0,2,3] pattern.
|
||||
let mut pairs: Vec<(Vec3, Vec3, f32)> = Vec::with_capacity(n);
|
||||
if let Some((node, axis)) = head {
|
||||
push_pair(node + axis * h_above, node - axis * h_below, 0.0);
|
||||
pairs.push((node + axis * h_above, node - axis * h_below, 0.0));
|
||||
}
|
||||
for e in edges.iter().rev() {
|
||||
push_pair(
|
||||
pairs.push((
|
||||
e.top,
|
||||
e.bottom,
|
||||
((now - e.born) / def.edge_lifetime).clamp(0.0, 1.0),
|
||||
);
|
||||
));
|
||||
}
|
||||
let normals = vec![[0.0, 1.0, 0.0]; positions.len()];
|
||||
let colors = vec![rgba; positions.len()];
|
||||
let mut indices = Vec::with_capacity((n - 1) * 6);
|
||||
for k in 0..(n - 1) as u32 {
|
||||
let b = k * 2;
|
||||
indices.extend_from_slice(&[b, b + 1, b + 2, b + 1, b + 3, b + 2]);
|
||||
}
|
||||
if let Some(m) = meshes.get_mut(&*mesh) {
|
||||
m.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
m.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
m.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
||||
m.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
|
||||
m.insert_indices(Indices::U32(indices));
|
||||
let start = quads.begin();
|
||||
for w in pairs.windows(2) {
|
||||
let ((t0, b0, a0), (t1, b1, a1)) = (w[0], w[1]);
|
||||
let (ua0, ua1) = (u0 + (u1 - u0) * a0, u0 + (u1 - u0) * a1);
|
||||
for (pos, uv) in [
|
||||
(b0, [ua0, v1]),
|
||||
(b1, [ua1, v1]),
|
||||
(t1, [ua1, v0]),
|
||||
(t0, [ua0, v0]),
|
||||
] {
|
||||
quads.verts.push(EffectVertex {
|
||||
pos: pos.to_array(),
|
||||
uv,
|
||||
color: rgba,
|
||||
});
|
||||
}
|
||||
}
|
||||
quads.commit_quads(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam,
|
||||
texture: texture.id(),
|
||||
blend: def.blend.into(),
|
||||
// params.x = the per-blend fog-colour policy (the M2 batch state setter's
|
||||
// table, `0x70baf0` / wow-re ROUND 4 — ribbons ride the same trio): additive
|
||||
// trails fog toward BLACK, fading under the storm veil instead of adding grey;
|
||||
// alpha/opaque trails fog toward the scene colour. (No ribbon authors the
|
||||
// particle "unfogged" file flag — pass 0.)
|
||||
fog: EffectFog::for_blend(0, def.blend),
|
||||
anchor,
|
||||
bias: *bias,
|
||||
raster_bias: 0,
|
||||
main_entity: entity,
|
||||
light: None, // trails never carry a light override (world lane only)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,13 @@ impl Plugin for TargetPlugin {
|
||||
)
|
||||
// The model brighten writes MeshTag bit 31 — PostUpdate, after every Update payload
|
||||
// writer (fade/interior/…), so their whole-tag overwrites can't strand the bit.
|
||||
.add_systems(PostUpdate, highlight::apply_highlight);
|
||||
.add_systems(PostUpdate, highlight::apply_highlight)
|
||||
// The ring's stream push: after the frame's stream clear (the projection cache was
|
||||
// rebuilt by `update_ring` in Update; this is a tinted copy).
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
ring::push_ring.after(crate::particles::buffer::begin_effect_frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
|
||||
+127
-161
@@ -41,22 +41,46 @@
|
||||
use avian3d::prelude::Collider;
|
||||
use benilla_formats::{load_faction_catalog, reputation_rank, FactionCatalog, Reaction};
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::assets::{LockRecover, WorldAssets};
|
||||
use crate::collision::GroundDecalSurface;
|
||||
use crate::decal::{project_decal, DecalFrame};
|
||||
use crate::net::{NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::particles::buffer::{EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
|
||||
|
||||
use super::click::clear;
|
||||
use super::{CombatFlash, Selection, SelectionRadius};
|
||||
use crate::creature_anim::Engaged;
|
||||
use crate::player::WorldCamera;
|
||||
|
||||
/// The single, persistent ground-ring entity (spawned once, hidden until a target is set).
|
||||
#[derive(Component)]
|
||||
pub(super) struct SelectionRing;
|
||||
/// The single ring record (a resource since 0733 — the projection rides the effect stream; no
|
||||
/// entity, no mesh, no materials). `update_ring` rebuilds `verts` when the [`RingKey`] moves and
|
||||
/// re-resolves `color` every frame; `push_ring` copies the slice onto the stream tinted.
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct RingState {
|
||||
verts: Vec<EffectVertex>,
|
||||
key: RingKey,
|
||||
/// This frame's resolved tint (the selector's dword — or the combat flash's wave sample).
|
||||
color: Color,
|
||||
shown: bool,
|
||||
}
|
||||
|
||||
/// The projection's rebuild inputs — a still target under a still camera costs a compare
|
||||
/// (0733 §5, the ShadowKey treatment; the old path re-projected every shown frame).
|
||||
#[derive(Default, PartialEq, Clone, Copy)]
|
||||
struct RingKey {
|
||||
feet: Vec3,
|
||||
radius: f32,
|
||||
fade_angle: f32,
|
||||
surfaces: usize,
|
||||
}
|
||||
|
||||
/// The ring texture (the render-side residency gate withholds the draw until it loads).
|
||||
#[derive(Resource)]
|
||||
pub(super) struct RingAssets {
|
||||
texture: Handle<Image>,
|
||||
}
|
||||
|
||||
/// The reference's ground selection-circle texture (`Textures\UnitSelectTexture.blp`, wow-re
|
||||
/// selection-circle RE) — a white ring, sampled top-down, tinted + additively blended below.
|
||||
@@ -110,28 +134,9 @@ impl Factions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ring's prebuilt reaction materials (same texture/blend, different tint). [`update_ring`]
|
||||
/// swaps the ring's material handle between them as the resolved reaction changes — no per-frame
|
||||
/// material mutation, with one deliberate exception: the `flash` material's tint is rewritten each
|
||||
/// shown frame with the wave sample (the reference recolours its global every frame too).
|
||||
#[derive(Resource)]
|
||||
pub(super) struct RingMaterials {
|
||||
hostile: Handle<StandardMaterial>,
|
||||
unfriendly: Handle<StandardMaterial>,
|
||||
neutral: Handle<StandardMaterial>,
|
||||
friendly: Handle<StandardMaterial>,
|
||||
player: Handle<StandardMaterial>,
|
||||
dead: Handle<StandardMaterial>,
|
||||
/// The party legs of the player path's `¬X∧¬Y` split (pale blue / pale green).
|
||||
party: Handle<StandardMaterial>,
|
||||
party_pvp: Handle<StandardMaterial>,
|
||||
/// The combat-flash pulse — tint mutated per frame to [`CombatFlash::color`].
|
||||
flash: Handle<StandardMaterial>,
|
||||
}
|
||||
|
||||
/// The colour `GetSelectionCircleColor` resolves — the pure classification half of
|
||||
/// [`RingMaterials::pick`], split out so the branch logic (players vs NPCs, the `¬X∧¬Y` party
|
||||
/// split) is unit-testable without material handles.
|
||||
/// The colour `GetSelectionCircleColor` resolves — the pure classification half of the
|
||||
/// selector, split out so the branch logic (players vs NPCs, the `¬X∧¬Y` party split) is
|
||||
/// unit-testable.
|
||||
///
|
||||
/// **This is the single source for BOTH surfaces the selector feeds** — the ground ring here and
|
||||
/// the overhead name (`nameplates.rs` fetches the same `vtable+0x2c`, decision 0156). It was a
|
||||
@@ -219,75 +224,14 @@ pub(crate) fn ring_variant(
|
||||
}
|
||||
}
|
||||
|
||||
impl RingMaterials {
|
||||
/// The material for a resolved [`RingVariant`] (the handle-mapping half of the selector).
|
||||
fn pick(
|
||||
&self,
|
||||
rank: u8,
|
||||
is_player: bool,
|
||||
is_dead: bool,
|
||||
pvp: bool,
|
||||
in_party: bool,
|
||||
) -> &Handle<StandardMaterial> {
|
||||
match ring_variant(rank, is_player, is_dead, pvp, in_party) {
|
||||
RingVariant::Hostile => &self.hostile,
|
||||
RingVariant::Unfriendly => &self.unfriendly,
|
||||
RingVariant::Neutral => &self.neutral,
|
||||
RingVariant::Friendly => &self.friendly,
|
||||
RingVariant::Player => &self.player,
|
||||
RingVariant::Dead => &self.dead,
|
||||
RingVariant::Party => &self.party,
|
||||
RingVariant::PartyPvp => &self.party_pvp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the one ring entity (hidden) and build its shared mesh + the reaction materials once.
|
||||
pub(super) fn setup_ring(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
) {
|
||||
let mesh = meshes.add(crate::decal::seed_mesh());
|
||||
/// Load the ring texture and seed the ring record. (The old path built 9 tinted
|
||||
/// `StandardMaterial` clones and a mesh asset here; the tint is per-vertex colour at push time
|
||||
/// now — the reference writes the selector's dword as every decal vertex's diffuse, which is
|
||||
/// exactly what the stream does.)
|
||||
pub(super) fn setup_ring(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
let texture = asset_server.load::<Image>(RING_TEXTURE);
|
||||
let mut tinted = |base_color| {
|
||||
materials.add(StandardMaterial {
|
||||
base_color,
|
||||
base_color_texture: Some(texture.clone()),
|
||||
// Unlit + additive (the reference's `GL_SRC_ALPHA/GL_ONE`): the ring glows on the ground
|
||||
// regardless of time-of-day and never darkens it.
|
||||
unlit: true,
|
||||
alpha_mode: AlphaMode::Add,
|
||||
cull_mode: None,
|
||||
// Wins the depth test over the terrain it lies exactly on (see [`RING_DEPTH_BIAS`]).
|
||||
depth_bias: RING_DEPTH_BIAS,
|
||||
..default()
|
||||
})
|
||||
};
|
||||
let ring_mats = RingMaterials {
|
||||
hostile: tinted(RING_HOSTILE),
|
||||
unfriendly: tinted(RING_UNFRIENDLY),
|
||||
neutral: tinted(RING_NEUTRAL),
|
||||
friendly: tinted(RING_FRIENDLY),
|
||||
player: tinted(RING_PLAYER),
|
||||
dead: tinted(RING_DEAD),
|
||||
party: tinted(RING_PARTY),
|
||||
party_pvp: tinted(RING_PARTY_PVP),
|
||||
// Seeded red (the wave's G=0 endpoint); update_ring rewrites the tint every flash frame.
|
||||
flash: tinted(RING_HOSTILE),
|
||||
};
|
||||
commands.spawn((
|
||||
SelectionRing,
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(ring_mats.neutral.clone()),
|
||||
Transform::default(),
|
||||
Visibility::Hidden,
|
||||
// The projector mutates the mesh each frame and Bevy doesn't recompute an entity's `Aabb`
|
||||
// on asset change — skip culling for this one tiny always-near-the-target mesh.
|
||||
NoFrustumCulling,
|
||||
));
|
||||
commands.insert_resource(ring_mats);
|
||||
commands.insert_resource(RingAssets { texture });
|
||||
commands.init_resource::<RingState>();
|
||||
}
|
||||
|
||||
/// Startup (after the MPQ chain opens): load FactionTemplate.dbc for the reaction colour. On failure
|
||||
@@ -319,9 +263,7 @@ pub(super) fn update_ring(
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
flash: Res<CombatFlash>,
|
||||
ring_mats: Res<RingMaterials>,
|
||||
// The mesh + material stores, tupled into one param (Bevy caps function systems at 16).
|
||||
mut assets: (ResMut<Assets<Mesh>>, ResMut<Assets<StandardMaterial>>),
|
||||
mut state: ResMut<RingState>,
|
||||
camera: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
surfaces: Query<&Collider, With<GroundDecalSurface>>,
|
||||
// The last camera-relative fade angle, kept across frames so a degenerate (straight-down) camera
|
||||
@@ -335,26 +277,23 @@ pub(super) fn update_ring(
|
||||
// second kill (the bug this replaces).
|
||||
mut last_vitals: Local<Option<(u64, bool)>>,
|
||||
net_commands: Res<NetCommands>,
|
||||
// `Without<SelectionRing>` makes this disjoint from the ring's own `&mut Transform` below (no access
|
||||
// conflict); net entities are roots, so their `Transform` is already world-space + current this frame
|
||||
// (net motion ran in `WorldStage::Net`), avoiding the 1-frame lag a `GlobalTransform` read would add.
|
||||
// Tupled into one param (the 16-param ceiling): `.0` the target's own components; `.1` the
|
||||
// mounted footprint source — while a mount model is attached, the ring reads the MOUNT's
|
||||
// Stand-box footprint at the mount's rendered scale (VERIFIED wow-re `mount-composition.md`:
|
||||
// the `+0xcf0` ring cache recomputes from the mount model's Stand box, `0x60ce70` tail →
|
||||
// `0x60aee0`; the scale law is B3 — `SCALE_X × CreatureDisplayInfo.creatureModelScale`, and
|
||||
// the child's `NetEntity.scale` carries exactly the CDI column).
|
||||
// Net entities are roots, so their `Transform` is already world-space + current this frame
|
||||
// (net motion ran in `WorldStage::Net`), avoiding the 1-frame lag a `GlobalTransform` read
|
||||
// would add. Tupled into one param (the 16-param ceiling): `.0` the target's own components;
|
||||
// `.1` the mounted footprint source — while a mount model is attached, the ring reads the
|
||||
// MOUNT's Stand-box footprint at the mount's rendered scale (VERIFIED wow-re
|
||||
// `mount-composition.md`: the `+0xcf0` ring cache recomputes from the mount model's Stand
|
||||
// box, `0x60ce70` tail → `0x60aee0`; the scale law is B3 —
|
||||
// `SCALE_X × CreatureDisplayInfo.creatureModelScale`, and the child's `NetEntity.scale`
|
||||
// carries exactly the CDI column).
|
||||
targets: (
|
||||
Query<
|
||||
(
|
||||
&Transform,
|
||||
Option<&SelectionRadius>,
|
||||
Option<&ObjectStore>,
|
||||
Option<&NetEntity>,
|
||||
Option<&crate::entities::mount::MountChild>,
|
||||
),
|
||||
Without<SelectionRing>,
|
||||
>,
|
||||
Query<(
|
||||
&Transform,
|
||||
Option<&SelectionRadius>,
|
||||
Option<&ObjectStore>,
|
||||
Option<&NetEntity>,
|
||||
Option<&crate::entities::mount::MountChild>,
|
||||
)>,
|
||||
Query<(&NetEntity, Option<&SelectionRadius>), With<crate::entities::mount::MountBody>>,
|
||||
// The party roster — the selector's 4-slot guid table (the party ring colours).
|
||||
Res<crate::ui_party::GroupState>,
|
||||
@@ -363,19 +302,8 @@ pub(super) fn update_ring(
|
||||
// Are *we* mid auto-attack (server-echoed [`Engaged`])? Both clear paths below end the swing
|
||||
// then — the reference's death/teardown edges stop the attack along with the selection.
|
||||
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
|
||||
mut ring: Query<
|
||||
(
|
||||
&mut Transform,
|
||||
&mut Visibility,
|
||||
&mut MeshMaterial3d<StandardMaterial>,
|
||||
&Mesh3d,
|
||||
),
|
||||
With<SelectionRing>,
|
||||
>,
|
||||
) {
|
||||
let Ok((mut ring_tf, mut vis, mut ring_mat, ring_mesh)) = ring.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let state = &mut *state;
|
||||
let hide = match selection.target {
|
||||
None => {
|
||||
// No target: drop the per-target trackers, so re-selecting the *same* guid later is a
|
||||
@@ -398,19 +326,24 @@ pub(super) fn update_ring(
|
||||
};
|
||||
let local = local.max(0.05);
|
||||
let radius = local * (unit.scale.x * mount_scale).max(0.01);
|
||||
// The mesh is built in feet-relative world axes (yards); the transform only places it.
|
||||
ring_tf.translation = unit.translation;
|
||||
ring_tf.scale = Vec3::ONE;
|
||||
ring_tf.rotation = Quat::IDENTITY;
|
||||
*fade_angle = ring_fade_angle(&camera).unwrap_or(*fade_angle);
|
||||
let projected = project_ring(
|
||||
&mut assets.0,
|
||||
ring_mesh,
|
||||
&surfaces,
|
||||
unit.translation,
|
||||
// The rebuild gate (0733 §5): a still target under a still camera keeps the
|
||||
// cached projection — the old path re-projected every shown frame.
|
||||
let key = RingKey {
|
||||
feet: unit.translation,
|
||||
radius,
|
||||
*fade_angle,
|
||||
);
|
||||
fade_angle: *fade_angle,
|
||||
surfaces: surfaces.iter().count(),
|
||||
};
|
||||
let projected = if state.shown && key == state.key {
|
||||
!state.verts.is_empty()
|
||||
} else {
|
||||
state.verts.clear();
|
||||
state.key = key;
|
||||
project_ring(&mut state.verts, &surfaces, unit.translation, radius, {
|
||||
*fade_angle
|
||||
})
|
||||
};
|
||||
let rank = ring_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
@@ -444,7 +377,8 @@ pub(super) fn update_ring(
|
||||
if died {
|
||||
clear(&mut selection, &net_commands, !engaged.is_empty());
|
||||
*last_vitals = None;
|
||||
*vis = Visibility::Hidden;
|
||||
state.shown = false;
|
||||
state.verts.clear();
|
||||
return;
|
||||
}
|
||||
// One line per target change — the whole colour decision, so a wrong ring colour in
|
||||
@@ -463,20 +397,15 @@ pub(super) fn update_ring(
|
||||
if is_dead { " [dead→gray]" } else { "" },
|
||||
);
|
||||
}
|
||||
// The selector's first-priority branch (`0x605960`, byte order): the combat flash
|
||||
// outranks player/dead/reaction. Its material tint is rewritten with this frame's
|
||||
// wave sample — the module's one per-frame material mutation.
|
||||
let want = if flash.unit == Some(target) {
|
||||
if let Some(mat) = assets.1.get_mut(&ring_mats.flash) {
|
||||
mat.base_color = flash.color;
|
||||
}
|
||||
&ring_mats.flash
|
||||
// The selector's first-priority branch (`0x605960`, byte order): the combat
|
||||
// flash outranks player/dead/reaction. The tint is a per-vertex colour at push
|
||||
// time now, so the flash's per-frame wave sample costs a resource write — the
|
||||
// old path's one material mutation is gone.
|
||||
state.color = if flash.unit == Some(target) {
|
||||
flash.color
|
||||
} else {
|
||||
ring_mats.pick(rank, is_player, is_dead, pvp, in_party)
|
||||
ring_variant(rank, is_player, is_dead, pvp, in_party).color()
|
||||
};
|
||||
if ring_mat.0 != *want {
|
||||
ring_mat.0 = want.clone();
|
||||
}
|
||||
// No receiving surface in the box (mid-air, unstreamed tile) → hide, the reference's
|
||||
// own no-ground gate (`0x6d74b5`: the whole draw is skipped).
|
||||
!projected
|
||||
@@ -494,11 +423,10 @@ pub(super) fn update_ring(
|
||||
}
|
||||
},
|
||||
};
|
||||
*vis = if hide {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Visible
|
||||
};
|
||||
state.shown = !hide;
|
||||
if hide {
|
||||
state.verts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The ring fade's camera-relative angle θ: the texture's **faded side always points away from the
|
||||
@@ -535,8 +463,7 @@ fn ring_fade_angle(camera: &Query<&GlobalTransform, With<WorldCamera>>) -> Optio
|
||||
/// the gather O(log n + k). Returns `false` when nothing was gathered (no ground in the box) —
|
||||
/// the caller hides the ring, the reference's no-ground gate.
|
||||
fn project_ring(
|
||||
meshes: &mut Assets<Mesh>,
|
||||
ring_mesh: &Mesh3d,
|
||||
out: &mut Vec<EffectVertex>,
|
||||
surfaces: &Query<&Collider, With<GroundDecalSurface>>,
|
||||
feet: Vec3,
|
||||
radius: f32,
|
||||
@@ -556,8 +483,7 @@ fn project_ring(
|
||||
max_y: vert,
|
||||
};
|
||||
project_decal(
|
||||
meshes,
|
||||
ring_mesh,
|
||||
out,
|
||||
surfaces,
|
||||
&frame,
|
||||
|p| ((vert - p.y.abs()) / (1.5 * radius)).clamp(0.0, 1.0),
|
||||
@@ -565,6 +491,46 @@ fn project_ring(
|
||||
)
|
||||
}
|
||||
|
||||
/// Push the shown ring's cached projection onto the stream, tinted with this frame's resolved
|
||||
/// colour — one Add draw at the ring rung (the top decal, over the blob shadows), fog off.
|
||||
pub(super) fn push_ring(
|
||||
assets: Option<Res<RingAssets>>,
|
||||
state: Option<Res<RingState>>,
|
||||
cam: Query<Entity, With<WorldCamera>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
) {
|
||||
let (Some(assets), Some(state)) = (assets, state) else {
|
||||
return;
|
||||
};
|
||||
let Ok(cam) = cam.single() else { return };
|
||||
if !state.shown || state.verts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tint = state.color.to_linear();
|
||||
let start = quads.begin();
|
||||
quads.verts.extend(state.verts.iter().map(|v| EffectVertex {
|
||||
pos: v.pos,
|
||||
uv: v.uv,
|
||||
// The selector's dword as every vertex's diffuse — the reference's own wiring
|
||||
// (`0x605960` → vertex colour, alpha = the vertical fade the projector baked).
|
||||
color: [tint.red, tint.green, tint.blue, v.color[3]],
|
||||
}));
|
||||
quads.commit_tris(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam,
|
||||
texture: assets.texture.id(),
|
||||
blend: EffectBlend::Add,
|
||||
fog: EffectFog::Off,
|
||||
anchor: state.key.feet,
|
||||
bias: RING_DEPTH_BIAS,
|
||||
raster_bias: RING_DEPTH_BIAS as i32,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The target's reaction toward our player — the direction the reference colours by (its nameplate/ring
|
||||
/// resolver `0x7cbaa0` calls `unit->UnitReaction(activePlayer)`, byte-verified; the reverse would read
|
||||
/// every attackable-but-passive yellow beast as hostile red, since a *player* template is enemy-masked
|
||||
|
||||
@@ -8,8 +8,7 @@ use benilla_assets::{ModelEmitter, ModelLight};
|
||||
use benilla_formats::WmoLight;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
use crate::particles::{self, WowParticleMaterial};
|
||||
use crate::particles;
|
||||
|
||||
/// Bevy `PointLight` intensity (lumens) for an M2 light of unit `diffuse_intensity`. Bevy stores the GPU
|
||||
/// colour as `linear_color × intensity/(4π)` (bevy_pbr light.rs), so `4π` makes the shader read exactly
|
||||
@@ -115,9 +114,6 @@ pub(super) fn spawn_wmo_lights_for(
|
||||
#[allow(clippy::too_many_arguments)] // a spawn plumbing fn: every arg is a distinct resource
|
||||
pub(super) fn spawn_emitters_for(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
particle_materials: &mut Assets<WowParticleMaterial>,
|
||||
light: &SharedLightBuffer,
|
||||
emitters: &[ModelEmitter],
|
||||
transform: Transform,
|
||||
joints: Option<&[Entity]>,
|
||||
@@ -137,9 +133,6 @@ pub(super) fn spawn_emitters_for(
|
||||
.map(|&j| (j, em.bone_pivot));
|
||||
if let Some(e) = particles::spawn_emitter(
|
||||
commands,
|
||||
meshes,
|
||||
particle_materials,
|
||||
light,
|
||||
em,
|
||||
transform,
|
||||
owner,
|
||||
@@ -161,12 +154,8 @@ pub(super) fn spawn_emitters_for(
|
||||
/// [`spawn_emitters_for`]); a static placement's trail rides the first spawned submesh entity (its
|
||||
/// transform IS the placement). Trails self-despawn when their owner goes, so they don't join
|
||||
/// `out` — the placement's despawn cascades within a frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_ribbons_for(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
particle_materials: &mut Assets<WowParticleMaterial>,
|
||||
light: &SharedLightBuffer,
|
||||
ribbons: &[benilla_assets::ModelRibbon],
|
||||
transform: Transform,
|
||||
joints: Option<&[Entity]>,
|
||||
@@ -185,9 +174,6 @@ pub(super) fn spawn_ribbons_for(
|
||||
// is immaterial here.
|
||||
crate::ribbons::spawn_ribbon(
|
||||
commands,
|
||||
meshes,
|
||||
particle_materials,
|
||||
light,
|
||||
rb,
|
||||
owner,
|
||||
use_pivot,
|
||||
|
||||
@@ -25,7 +25,6 @@ use crate::lighting::SharedLightBuffer;
|
||||
use crate::lighting::{PropProbeSlot, PropProbes};
|
||||
use crate::liquid::{spawn_wmo_liquids, LiquidAssets};
|
||||
use crate::model_render::{m2_url, ShadeSel};
|
||||
use crate::particles::WowParticleMaterial;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
|
||||
|
||||
@@ -51,7 +50,6 @@ pub(super) fn spawn_loaded_placements(
|
||||
// The shared per-kind animated liquid materials — WMO groups spawn their embedded water/lava/slime
|
||||
// on these, same as MCLQ terrain water. Absent when the client has no data.
|
||||
liquid_assets: Option<Res<LiquidAssets>>,
|
||||
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
// Read-only: the loaded-tile map + decoded tiles, for the global MCSH ground-shade lookup at spawn.
|
||||
// `stream_terrain` (chained before this) owns them mutably; here we only read.
|
||||
streamer: Res<TerrainStreamer>,
|
||||
@@ -148,9 +146,6 @@ pub(super) fn spawn_loaded_placements(
|
||||
}
|
||||
spawn_emitters_for(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&shared_light,
|
||||
&m.emitters,
|
||||
p.transform,
|
||||
joints.as_deref(),
|
||||
@@ -159,9 +154,6 @@ pub(super) fn spawn_loaded_placements(
|
||||
);
|
||||
spawn_ribbons_for(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&shared_light,
|
||||
&m.ribbons,
|
||||
p.transform,
|
||||
joints.as_deref(),
|
||||
@@ -464,9 +456,6 @@ pub(super) fn spawn_loaded_placements(
|
||||
}
|
||||
spawn_emitters_for(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&shared_light,
|
||||
&m.emitters,
|
||||
d.transform,
|
||||
joints.as_deref(),
|
||||
@@ -475,9 +464,6 @@ pub(super) fn spawn_loaded_placements(
|
||||
);
|
||||
spawn_ribbons_for(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut particle_materials,
|
||||
&shared_light,
|
||||
&m.ribbons,
|
||||
d.transform,
|
||||
joints.as_deref(),
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Default for ViewDistance {
|
||||
/// **The one spelling of "is it nearer than `farclip`", shared by every CPU-side consumer.** The test is
|
||||
/// planar depth along the camera-forward axis (`(center − eye)·fwd`) of the sphere's NEAREST point, which
|
||||
/// is deliberately the *same coordinate* the per-pixel wall uses in the shaders (`terrain.wgsl` /
|
||||
/// `wow_model.wgsl` / `wow_particle.wgsl` all discard on eye-Z past `fog_params.w`). Agreeing on the
|
||||
/// `wow_model.wgsl` / `wow_effect.wgsl` all discard on eye-Z past `fog_params.w`). Agreeing on the
|
||||
/// coordinate is what makes an object straddling the boundary **dissolve** through it instead of popping
|
||||
/// when its origin crosses.
|
||||
///
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
//! the 0264 INTERIM constants are resolved, decision 0265).
|
||||
//!
|
||||
//! The formulas + lifecycle math live in [`params`]; this half is the ECS: the emitter over the
|
||||
//! avatar + streamed units, the record pool, and the per-(chunk, category) additive meshes on the
|
||||
//! shared effect gamma lane (fog OFF — the reference's verified foam render state).
|
||||
//! avatar + streamed units, the record pool, and one additive effect-stream draw per
|
||||
//! (chunk, category) with live records (fog OFF — the reference's verified foam render state).
|
||||
|
||||
mod params;
|
||||
mod view;
|
||||
@@ -46,8 +46,6 @@ pub(crate) use view::{WaterFxView, WfxMode};
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::ecs::entity::EntityHashMap;
|
||||
use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::ExtendedMaterial;
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
@@ -58,11 +56,12 @@ use benilla_protocol::EntityKind;
|
||||
use crate::assets::{AssetSet, WorldAssets};
|
||||
use crate::creature_anim::move_flags;
|
||||
use crate::entities::CollisionHeight;
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
use crate::liquid::{FoamPatch, WaterChunkInfo};
|
||||
use crate::net::{NetEntity, SelfPlayer};
|
||||
use crate::particles::{WowParticleExt, WowParticleMaterial};
|
||||
use crate::player::Player;
|
||||
use crate::particles::buffer::{
|
||||
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex,
|
||||
};
|
||||
use crate::player::{Player, WorldCamera};
|
||||
use crate::schedule::WorldStage;
|
||||
|
||||
use params::{foam_params, foam_uv, rand01, record_alpha, record_size, WadeState};
|
||||
@@ -188,32 +187,26 @@ fn alloc_slot(cursor: &mut usize, base: usize, len: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// The two shared foam materials (ring/wake stencils on the effect gamma lane, fog off).
|
||||
/// The two foam stencils (ring/wake), decoded raw at startup. The draws they feed are
|
||||
/// effect-stream records: additive, fog OFF (VERIFIED `0x68fcc1`), sort bias +1.0 over the
|
||||
/// hosting water chunk (the coplanar tie — the reference draws foam in the water group right
|
||||
/// after the liquid surface).
|
||||
#[derive(Resource)]
|
||||
struct FoamMaterials {
|
||||
ring: Handle<WowParticleMaterial>,
|
||||
wake: Handle<WowParticleMaterial>,
|
||||
struct FoamAssets {
|
||||
ring: Handle<Image>,
|
||||
wake: Handle<Image>,
|
||||
}
|
||||
|
||||
/// One pooled mesh entity per (liquid chunk, is-ring) with live records.
|
||||
#[derive(Resource, Default)]
|
||||
struct FoamMeshes(HashMap<(Entity, bool), Entity>);
|
||||
/// The foam draw's sort-bias over its water (the old material's `depth_bias: 1.0`).
|
||||
const FOAM_BIAS: f32 = 1.0;
|
||||
|
||||
/// Marks a foam mesh entity.
|
||||
#[derive(Component)]
|
||||
struct FoamMesh;
|
||||
|
||||
/// Load the stencils raw (CLAMP, no mips — the reference's measured sampler state), build the two
|
||||
/// materials on the shared effect lane with fog disabled (VERIFIED render state: FOG off).
|
||||
/// Load the stencils raw (CLAMP, no mips — the reference's measured sampler state).
|
||||
fn setup_water_fx(
|
||||
mut commands: Commands,
|
||||
world_assets: Option<ResMut<WorldAssets>>,
|
||||
light: Res<SharedLightBuffer>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
) {
|
||||
commands.init_resource::<WaterFoam>();
|
||||
commands.init_resource::<FoamMeshes>();
|
||||
let Some(mut world_assets) = world_assets else {
|
||||
return;
|
||||
};
|
||||
@@ -223,30 +216,7 @@ fn setup_water_fx(
|
||||
let Some(wake) = foam_image(&mut world_assets, WAKE_TEXTURE, &mut images) else {
|
||||
return;
|
||||
};
|
||||
let mut material = |texture: Handle<Image>| {
|
||||
materials.add(ExtendedMaterial {
|
||||
base: StandardMaterial {
|
||||
base_color_texture: Some(texture),
|
||||
unlit: true,
|
||||
alpha_mode: AlphaMode::Add,
|
||||
cull_mode: None,
|
||||
double_sided: true,
|
||||
// Wins the coplanar transparent-sort tie against its own water chunk (the
|
||||
// reference draws foam in the water group right after the liquid surface).
|
||||
depth_bias: 1.0,
|
||||
..default()
|
||||
},
|
||||
extension: WowParticleExt {
|
||||
light_buf: light.0.clone(),
|
||||
// x = fog enable: the reference's foam render sets FOG off (VERIFIED `0x68fcc1`).
|
||||
params: Vec4::ZERO,
|
||||
mod2x: false,
|
||||
},
|
||||
})
|
||||
};
|
||||
let wake = material(wake);
|
||||
let ring = material(ring);
|
||||
commands.insert_resource(FoamMaterials { ring, wake });
|
||||
commands.insert_resource(FoamAssets { ring, wake });
|
||||
}
|
||||
|
||||
/// Decode a foam stencil, keeping its authored RGBA verbatim (raw gamma bytes, the house
|
||||
@@ -411,7 +381,7 @@ fn drive_unit(
|
||||
/// selection bits) and for every streamed unit (the velocity/yaw proxy), emitting pool records.
|
||||
fn emit_water_foam(
|
||||
time: Res<Time>,
|
||||
materials: Option<Res<FoamMaterials>>,
|
||||
materials: Option<Res<FoamAssets>>,
|
||||
mut foam: ResMut<WaterFoam>,
|
||||
player: Res<Player>,
|
||||
self_store: Query<&NetEntity, With<SelfPlayer>>,
|
||||
@@ -522,22 +492,21 @@ fn emit_water_foam(
|
||||
foam.units.retain(|_, uf| uf.active);
|
||||
}
|
||||
|
||||
/// Per frame: age the pool, drop dead records, and rebuild one mesh per (chunk, category) with
|
||||
/// live records — positions static, UV = the texgen at `size(t)`, colour = white × the alpha ramp.
|
||||
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
fn render_water_foam(
|
||||
/// Per frame (PostUpdate, after the stream clear): age the pool, drop dead records, and push
|
||||
/// one effect-stream draw per (chunk, category) with live records — positions static, UV = the
|
||||
/// texgen at `size(t)`, colour = white × the alpha ramp (the stencil's near-black RGB carries
|
||||
/// the intensity through the texture product). Sorted at the group's vert centroid + the water
|
||||
/// tie-break bias; a chunk that streamed out lets its records age out silently.
|
||||
fn push_water_foam(
|
||||
time: Res<Time>,
|
||||
materials: Option<Res<FoamMaterials>>,
|
||||
assets: Option<Res<FoamAssets>>,
|
||||
cam: Query<Entity, With<WorldCamera>>,
|
||||
mut foam: ResMut<WaterFoam>,
|
||||
mut foam_meshes: ResMut<FoamMeshes>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut commands: Commands,
|
||||
mesh_entities: Query<&Mesh3d, With<FoamMesh>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
chunks_alive: Query<(), With<WaterChunkInfo>>,
|
||||
) {
|
||||
let Some(materials) = materials else {
|
||||
return;
|
||||
};
|
||||
let Some(assets) = assets else { return };
|
||||
let Ok(cam) = cam.single() else { return };
|
||||
let now = time.elapsed_secs();
|
||||
|
||||
for slot in &mut foam.pool {
|
||||
@@ -546,93 +515,60 @@ fn render_water_foam(
|
||||
}
|
||||
}
|
||||
|
||||
let mut groups: HashMap<(Entity, bool), MeshBuilder> = HashMap::default();
|
||||
for rec in foam.pool.iter().flatten() {
|
||||
// Group record indices per (chunk, category) — each group is one contiguous draw.
|
||||
let mut groups: HashMap<(Entity, bool), Vec<usize>> = HashMap::default();
|
||||
for (i, rec) in foam.pool.iter().enumerate() {
|
||||
let Some(rec) = rec else { continue };
|
||||
if chunks_alive.get(rec.chunk).is_err() {
|
||||
continue; // chunk streamed out; the record ages out silently
|
||||
}
|
||||
let b = groups.entry((rec.chunk, rec.ring)).or_default();
|
||||
let size = record_size(rec.size0, rec.growth, rec.born, now);
|
||||
let alpha = record_alpha(rec.peak, rec.lifetime, rec.born, now);
|
||||
for v in &rec.verts {
|
||||
let wow = bevy_to_wow(*v);
|
||||
let uv = foam_uv(rec.center, rec.heading, size, [wow[0], wow[1]]);
|
||||
b.push(*v, uv, alpha);
|
||||
}
|
||||
groups.entry((rec.chunk, rec.ring)).or_default().push(i);
|
||||
}
|
||||
|
||||
// Clear meshes whose group emptied; drop mesh entities whose chunk despawned.
|
||||
foam_meshes.0.retain(|key, entity| {
|
||||
if groups.contains_key(key) {
|
||||
return true;
|
||||
}
|
||||
if chunks_alive.get(key.0).is_ok() {
|
||||
if let Ok(Mesh3d(h)) = mesh_entities.get(*entity) {
|
||||
if let Some(m) = meshes.get_mut(h) {
|
||||
MeshBuilder::default().write(m);
|
||||
}
|
||||
for ((_chunk, ring), records) in groups {
|
||||
let start = quads.begin();
|
||||
let mut centroid = Vec3::ZERO;
|
||||
let mut n = 0u32;
|
||||
for i in records {
|
||||
let rec = foam.pool[i].as_ref().expect("grouped above");
|
||||
let size = record_size(rec.size0, rec.growth, rec.born, now);
|
||||
let alpha = record_alpha(rec.peak, rec.lifetime, rec.born, now);
|
||||
for v in &rec.verts {
|
||||
let wow = bevy_to_wow(*v);
|
||||
quads.verts.push(EffectVertex {
|
||||
pos: v.to_array(),
|
||||
uv: foam_uv(rec.center, rec.heading, size, [wow[0], wow[1]]),
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
});
|
||||
centroid += *v;
|
||||
n += 1;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
commands.entity(*entity).despawn();
|
||||
false
|
||||
}
|
||||
});
|
||||
for (key, builder) in groups {
|
||||
if let Some(&entity) = foam_meshes.0.get(&key) {
|
||||
if let Ok(Mesh3d(h)) = mesh_entities.get(entity) {
|
||||
if let Some(m) = meshes.get_mut(h) {
|
||||
builder.write(m);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let material = if key.1 {
|
||||
materials.ring.clone()
|
||||
} else {
|
||||
materials.wake.clone()
|
||||
};
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
builder.write(&mut mesh);
|
||||
let entity = commands
|
||||
.spawn((
|
||||
FoamMesh,
|
||||
Mesh3d(meshes.add(mesh)),
|
||||
MeshMaterial3d(material),
|
||||
Transform::IDENTITY,
|
||||
))
|
||||
.id();
|
||||
foam_meshes.0.insert(key, entity);
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tiny growable triangle-list accumulator: white verts with per-vertex alpha, up normal, UV.
|
||||
/// Foam triangles are unshared (identity indices), like the reference's per-record fans.
|
||||
#[derive(Default)]
|
||||
struct MeshBuilder {
|
||||
positions: Vec<[f32; 3]>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
colors: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl MeshBuilder {
|
||||
fn push(&mut self, pos: Vec3, uv: [f32; 2], alpha: f32) {
|
||||
self.positions.push(pos.to_array());
|
||||
self.uvs.push(uv);
|
||||
self.colors.push([1.0, 1.0, 1.0, alpha]);
|
||||
}
|
||||
|
||||
fn write(self, mesh: &mut Mesh) {
|
||||
let n = self.positions.len();
|
||||
let indices = (0..n as u32).collect::<Vec<_>>();
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, self.positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, self.uvs);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, self.colors);
|
||||
mesh.insert_indices(Indices::U32(indices));
|
||||
quads.commit_tris(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam,
|
||||
texture: if ring {
|
||||
assets.ring.id()
|
||||
} else {
|
||||
assets.wake.id()
|
||||
},
|
||||
blend: EffectBlend::Add,
|
||||
// The reference's foam render sets FOG off (VERIFIED `0x68fcc1`).
|
||||
fog: EffectFog::Off,
|
||||
anchor: centroid / n as f32,
|
||||
bias: FOAM_BIAS,
|
||||
raster_bias: 0,
|
||||
// NEVER the chunk entity: a draw's probe identity must not own a registered
|
||||
// mesh, or bevy's sorted-phase batcher claims the item and rewrites its
|
||||
// `batch_range` (gpu_preprocessing.rs keys purely on `item.main_entity()`) —
|
||||
// the Goldshire-teleport crash. The chunk still keys the record grouping above.
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,15 +580,12 @@ impl Plugin for WaterFxPlugin {
|
||||
app.add_systems(Startup, setup_water_fx.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
view::waterfx_spawn,
|
||||
view::waterfx_drive,
|
||||
emit_water_foam,
|
||||
render_water_foam,
|
||||
)
|
||||
(view::waterfx_spawn, view::waterfx_drive, emit_water_foam)
|
||||
.chain()
|
||||
.in_set(WorldStage::Present),
|
||||
);
|
||||
)
|
||||
// The stream push: PostUpdate after the frame's clear (emission ran in Update).
|
||||
.add_systems(PostUpdate, push_water_foam.after(begin_effect_frame));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ use crate::net::WeatherMessage;
|
||||
|
||||
mod precip;
|
||||
|
||||
/// Rain's forced-fog window — the effect lane's `EffectFog::Rain` params row reads the law
|
||||
/// from its owner (0733 §4).
|
||||
pub(crate) use precip::{RAIN_FOG_END, RAIN_FOG_START};
|
||||
|
||||
/// Wire weather types (`SMSG_WEATHER` / vmangos `WeatherType`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum WeatherKind {
|
||||
|
||||
@@ -18,13 +18,12 @@
|
||||
//! `bcc = min(1, density·4)`, which `lighting::update_time_lighting` already applies.)
|
||||
|
||||
use avian3d::prelude::{SpatialQuery, SpatialQueryFilter};
|
||||
use bevy::mesh::Indices;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::weather::{WeatherKind, WeatherState};
|
||||
|
||||
use super::pool::{HeightCache, CELL};
|
||||
use super::{pad_mesh, rand01, wow_azimuth_to_bevy, WeatherWind, WriteGate};
|
||||
use super::{rand01, wow_azimuth_to_bevy, WeatherWind};
|
||||
|
||||
/// Mist spawn-rate gain `Q` — per leg (fixed-func `0x80ff9c` = 18, shader `0x80ffa0` = 38;
|
||||
/// see [`super::SHADER_LEG`]). The rate is `2·max(density − 0.5, 0)·K·Q` nodes/s
|
||||
@@ -115,30 +114,13 @@ struct MistNode {
|
||||
|
||||
/// The mist companion pool — every precip type carries one (the reference builds it inside
|
||||
/// each effect ctor); it spawns only above the density 0.5 knee.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Mist {
|
||||
nodes: Vec<MistNode>,
|
||||
budget: f32,
|
||||
pub(super) mesh: Handle<Mesh>,
|
||||
/// Mesh-write liveness edge — see [`WriteGate`]: an idle mist field leaves its GPU buffer
|
||||
/// untouched instead of re-uploading it every frame.
|
||||
pub(super) written: WriteGate,
|
||||
}
|
||||
|
||||
impl Mist {
|
||||
pub(super) fn new(mesh: Handle<Mesh>) -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
budget: 0.0,
|
||||
mesh,
|
||||
written: WriteGate::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any puff is alive (the mesh-write gate's liveness input).
|
||||
pub(super) fn is_live(&self) -> bool {
|
||||
!self.nodes.is_empty()
|
||||
}
|
||||
|
||||
/// The stop-flag retire (`0x67b234`, round 7): a wire TYPE change also retires the
|
||||
/// SCHEDULED-but-unborn nodes — the spawn budget zeroes with the cut. Live nodes finish
|
||||
/// their (possibly truncated) lives normally.
|
||||
@@ -280,58 +262,42 @@ pub(super) fn run_mist(
|
||||
/// evaluated **per corner** — `linearstep(6, 18, corner→cam) · trapezoid(age)` at full range
|
||||
/// (the reference computes the distance ramp per billboard corner: `mistr_corner_dist` →
|
||||
/// `mistr_color_repack`; no peak cap) — so a quad hanging beside the camera still shows its
|
||||
/// far corners. Alpha-blended, fog off (the tint already IS the fog colour).
|
||||
pub(super) fn build_mist_mesh(
|
||||
mesh: Option<&mut Mesh>,
|
||||
/// far corners. Alpha-blended, fog off (the tint already IS the fog colour). Pushed onto the
|
||||
/// shared effect stream (0733), perimeter corner order for the quad-index pattern.
|
||||
pub(super) fn push_mist(
|
||||
out: &mut Vec<crate::particles::buffer::EffectVertex>,
|
||||
mist: &Mist,
|
||||
cam_right: Vec3,
|
||||
cam_up: Vec3,
|
||||
cam_pos: Vec3,
|
||||
anchor: Vec3,
|
||||
fog_color: [f32; 3],
|
||||
) {
|
||||
let Some(mesh) = mesh else { return };
|
||||
let n = mist.nodes.len().min(MIST_CAP);
|
||||
let mut pos = Vec::with_capacity(n * 4);
|
||||
let mut uv = Vec::with_capacity(n * 4);
|
||||
let mut col: Vec<[f32; 4]> = Vec::with_capacity(n * 4);
|
||||
let mut idx = Vec::with_capacity(n * 6);
|
||||
let r = cam_right * MIST_HALF;
|
||||
let u = cam_up * MIST_HALF;
|
||||
let cam_rel = cam_pos - anchor;
|
||||
for node in mist.nodes.iter().take(MIST_CAP) {
|
||||
// trapezoid = clamp(age/0.4) · clamp((life−age)/0.4) — 0.4 s ramps, plateau 1.0. The
|
||||
// spawn lookahead already truncated `life` at the first slope jump, so a wall-bound
|
||||
// puff rides this SAME ramp to zero before the wall — no separate hide.
|
||||
let trapezoid = (node.age / MIST_FADE_S).clamp(0.0, 1.0)
|
||||
* ((node.life - node.age) / MIST_FADE_S).clamp(0.0, 1.0);
|
||||
let c = node.pos - anchor;
|
||||
let corners = [c - r - u, c + r - u, c - r + u, c + r + u];
|
||||
let base = pos.len() as u32;
|
||||
for corner in corners {
|
||||
let dist_a = ((corner.distance(cam_rel) - MIST_ALPHA_NEAR)
|
||||
let c = node.pos;
|
||||
// Perimeter order (bl, br, tr, tl).
|
||||
for (corner, uv) in [
|
||||
(c - r - u, [0.0, 1.0]),
|
||||
(c + r - u, [1.0, 1.0]),
|
||||
(c + r + u, [1.0, 0.0]),
|
||||
(c - r + u, [0.0, 0.0]),
|
||||
] {
|
||||
let dist_a = ((corner.distance(cam_pos) - MIST_ALPHA_NEAR)
|
||||
/ (MIST_ALPHA_FAR - MIST_ALPHA_NEAR))
|
||||
.clamp(0.0, 1.0);
|
||||
col.push([fog_color[0], fog_color[1], fog_color[2], dist_a * trapezoid]);
|
||||
pos.push(corner.to_array());
|
||||
out.push(crate::particles::buffer::EffectVertex {
|
||||
pos: corner.to_array(),
|
||||
uv,
|
||||
color: [fog_color[0], fog_color[1], fog_color[2], dist_a * trapezoid],
|
||||
});
|
||||
}
|
||||
uv.extend([[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]]);
|
||||
idx.extend([base, base + 1, base + 2, base + 1, base + 3, base + 2]);
|
||||
}
|
||||
pad_mesh(
|
||||
&mut pos,
|
||||
&mut uv,
|
||||
&mut col,
|
||||
&mut idx,
|
||||
MIST_CAP * 4,
|
||||
MIST_CAP * 6,
|
||||
);
|
||||
let normals = vec![[0.0, 1.0, 0.0]; pos.len()];
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, pos);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uv);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, col);
|
||||
mesh.insert_indices(Indices::U32(idx));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -46,22 +46,20 @@
|
||||
//! clear-down. A wire TYPE change instead cuts: emission stops at once and the unreplayed
|
||||
//! pipeline is discarded (`pool::Pool::cut`). See `pool` and [`SHADER_LEG`].
|
||||
//!
|
||||
//! Meshes are rebuilt per frame from the live pools (the `particles.rs` pattern) on
|
||||
//! [`WowParticleMaterial`]; rain's Mod2x blend + forced fog ride the material's `mod2x` key and
|
||||
//! forced-fog params.
|
||||
//! Geometry is pushed per frame from the live pools onto the shared effect stream (0733):
|
||||
//! rain's Mod2x blend + forced fog are the lane's `EffectBlend::Mod2x` + `EffectFog::Rain`
|
||||
//! variants; snow and mist ride the Alpha/fog-off rows. Idle pools push nothing — the
|
||||
//! structural replacement for the old fixed-capacity meshes' write gate (the 0353 fps hunt).
|
||||
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::ExtendedMaterial;
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::Buffer;
|
||||
|
||||
use avian3d::prelude::{SpatialQuery, SpatialQueryFilter};
|
||||
|
||||
use crate::collision::player_query_filter;
|
||||
use crate::lighting::{SharedLightBuffer, WowLighting};
|
||||
use crate::particles::{WowParticleExt, WowParticleMaterial};
|
||||
use crate::lighting::WowLighting;
|
||||
use crate::particles::buffer::{
|
||||
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads,
|
||||
};
|
||||
use crate::player::WorldCamera;
|
||||
use crate::wmo_portal::{CameraInteriorClaim, WmoPvsSet};
|
||||
|
||||
@@ -71,9 +69,9 @@ mod mist;
|
||||
mod pool;
|
||||
mod render;
|
||||
mod wind;
|
||||
use mist::{build_mist_mesh, run_mist, Mist, MIST_CAP};
|
||||
use mist::{push_mist, run_mist, Mist};
|
||||
use pool::{run_kind, HeightCache, Pool};
|
||||
use render::{build_flake_mesh, build_patter_mesh, build_streak_mesh};
|
||||
use render::{push_flakes, push_patters, push_streaks};
|
||||
pub(crate) use wind::{wow_azimuth_to_bevy, WeatherWind};
|
||||
|
||||
// ===== The reference's constants (byte-cited; render laws from wow-re rf-weather-render.md,
|
||||
@@ -133,9 +131,10 @@ const RAIN_SPREAD_BIAS: f32 = f32::from_bits(0x3d56_7750); // 0x80ffc0 ≈ 0.052
|
||||
const STREAK_HALF_W: f32 = 0.05;
|
||||
const STREAK_TAIL: f32 = 2.0;
|
||||
/// Rain's forced fog window (render-state 0x0a/0x0b, CPU leg): start 70, end 75 — under Mod2x
|
||||
/// the grey-0.5 fog colour is NEUTRAL, so this IS the streak distance fade.
|
||||
const RAIN_FOG_START: f32 = 70.0;
|
||||
const RAIN_FOG_END: f32 = 75.0;
|
||||
/// the grey-0.5 fog colour is NEUTRAL, so this IS the streak distance fade. `pub(crate)`: the
|
||||
/// effect lane's canonical `EffectFog::Rain` params row is written from these (0733 §4).
|
||||
pub(crate) const RAIN_FOG_START: f32 = 70.0;
|
||||
pub(crate) const RAIN_FOG_END: f32 = 75.0;
|
||||
/// Patter triangle half-edges: `view_right/12` × `view_up/6` (`0x80e004`/`0x803568`, CONFIRMED).
|
||||
const PATTER_RIGHT: f32 = 1.0 / 12.0;
|
||||
const PATTER_UP: f32 = 1.0 / 6.0;
|
||||
@@ -196,24 +195,11 @@ pub(super) struct Precip {
|
||||
pub(super) struct WeatherIndoors(bool);
|
||||
|
||||
/// Resolve [`WeatherIndoors`] from the camera's PVS-flood interior claim
|
||||
/// ([`CameraInteriorClaim`], written by the portal pass this frame) and flip the layer
|
||||
/// entities' visibility: hidden only when the claimed room reaches no exterior.
|
||||
fn gate_weather_indoors(
|
||||
claim: Res<CameraInteriorClaim>,
|
||||
mut indoors: ResMut<WeatherIndoors>,
|
||||
mut layers: Query<&mut Visibility, With<PrecipLayer>>,
|
||||
) {
|
||||
let now_hidden = claim.0.is_some_and(|c| !c.exterior_visible);
|
||||
if now_hidden != indoors.0 {
|
||||
indoors.0 = now_hidden;
|
||||
for mut vis in &mut layers {
|
||||
*vis = if now_hidden {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
}
|
||||
}
|
||||
/// ([`CameraInteriorClaim`], written by the portal pass this frame). The flag gates both the
|
||||
/// sim ([`simulate_precip`] — the freeze) and the stream push ([`push_precip`] — frozen drops
|
||||
/// hang, unrendered, exactly the reference's `[0xca80c4]` draw kill).
|
||||
fn gate_weather_indoors(claim: Res<CameraInteriorClaim>, mut indoors: ResMut<WeatherIndoors>) {
|
||||
indoors.0 = claim.0.is_some_and(|c| !c.exterior_visible);
|
||||
}
|
||||
|
||||
/// xorshift32 → [0,1) — the reference's lagged-table generator is byte-known but its
|
||||
@@ -227,144 +213,31 @@ fn rand01(rng: &mut u32) -> f32 {
|
||||
(x >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
|
||||
/// Marks the layer entities. The sim writes the camera anchor into their `Transform` each
|
||||
/// frame and builds vertices anchor-relative — the `particles.rs` convention: the transparent
|
||||
/// pass sorts (and the GPU transforms) per entity, and a mesh whose entity sits at the world
|
||||
/// origin ~9000 yd from its absolute-coordinate vertices does not survive the view transform
|
||||
/// intact (the streak/flake fragments vanished; anchor-relative verts render).
|
||||
#[derive(Component)]
|
||||
struct PrecipLayer;
|
||||
|
||||
/// Ground-layer capacity — the reference's patter pool is 0x1800 slots (`Packet<Patter,Rain>`),
|
||||
/// same as the drop pool; at grade 1 the 1:1 landing spawns keep ~rate·0.25 s ≈ 3–6k alive.
|
||||
const GROUND_CAP: usize = 0x1800;
|
||||
|
||||
/// Pad the vertex arrays + indices to a **fixed capacity** with degenerate (zero-area, alpha-0)
|
||||
/// geometry, so the mesh's GPU buffers keep one size for life (no per-frame reallocation as the
|
||||
/// pools breathe). The padding indices all reference vertex 0 → zero-area triangles, no
|
||||
/// fragments.
|
||||
fn pad_mesh(
|
||||
pos: &mut Vec<[f32; 3]>,
|
||||
uv: &mut Vec<[f32; 2]>,
|
||||
col: &mut Vec<[f32; 4]>,
|
||||
idx: &mut Vec<u32>,
|
||||
vert_cap: usize,
|
||||
idx_cap: usize,
|
||||
) {
|
||||
pos.resize(vert_cap, [0.0; 3]);
|
||||
uv.resize(vert_cap, [0.0; 2]);
|
||||
col.resize(vert_cap, [0.0; 4]);
|
||||
idx.resize(idx_cap, 0);
|
||||
}
|
||||
|
||||
/// A fresh mesh with the particle vertex layout (`particles.rs` convention), pre-filled to its
|
||||
/// full fixed capacity with degenerate geometry so the buffers hold ONE size for life (no
|
||||
/// per-frame reallocation, and the render side's first extraction is already final-sized).
|
||||
fn blank_mesh_sized(vert_cap: usize, idx_cap: usize) -> Mesh {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
bevy::asset::RenderAssetUsages::default(),
|
||||
);
|
||||
let (mut pos, mut uv, mut col, mut idx) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
pad_mesh(&mut pos, &mut uv, &mut col, &mut idx, vert_cap, idx_cap);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; pos.len()]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, pos);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uv);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, col);
|
||||
mesh.insert_indices(Indices::U32(idx));
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Gates a pool's per-frame mesh write on its **liveness edge**. An idle pool re-writing its
|
||||
/// already all-degenerate fixed-capacity buffer is not free: `Assets::get_mut` alone marks the
|
||||
/// asset Modified, and the renderer re-extracts and re-uploads the whole capacity-sized buffer
|
||||
/// every frame — capacity-priced, not demand-priced (at the shader leg's `POOL` = 0xC000 that
|
||||
/// was ~5 ms/frame on a clear noon — the 0353 fps hunt). Write while live, write ONCE more on
|
||||
/// the live→empty edge (so the last drops actually clear from the GPU), then leave the asset
|
||||
/// untouched. Empty content is all-degenerate and camera-independent, so the skip is exact.
|
||||
#[derive(Default)]
|
||||
pub(super) struct WriteGate {
|
||||
written_live: bool,
|
||||
}
|
||||
|
||||
impl WriteGate {
|
||||
/// `Some(mesh)` when this frame's write must happen (live content, or the clearing write).
|
||||
pub(super) fn write<'a>(
|
||||
&mut self,
|
||||
meshes: &'a mut Assets<Mesh>,
|
||||
mesh: &Handle<Mesh>,
|
||||
live: bool,
|
||||
) -> Option<&'a mut Mesh> {
|
||||
let write = live || self.written_live;
|
||||
self.written_live = live;
|
||||
write.then(|| meshes.get_mut(mesh)).flatten()
|
||||
}
|
||||
}
|
||||
|
||||
/// The three verified weather render states (rf-weather-render Q3).
|
||||
enum WeatherBlend {
|
||||
/// Rain streak/patter: **Mod2x** (`2·src·dst`) + the FORCED fog — grey-0.5 over 70..75 yd,
|
||||
/// regardless of scene fog. Under Mod2x grey-0.5 is neutral, so the fog IS the distance fade.
|
||||
RainMod2x,
|
||||
/// Snow: standard alpha blend, fog OFF.
|
||||
SnowAlpha,
|
||||
/// Mist: standard alpha blend, fog OFF (the puff RGB is the fog colour already).
|
||||
MistAlpha,
|
||||
}
|
||||
|
||||
fn weather_material(
|
||||
materials: &mut Assets<WowParticleMaterial>,
|
||||
texture: Handle<Image>,
|
||||
light_buf: Buffer,
|
||||
blend: WeatherBlend,
|
||||
) -> Handle<WowParticleMaterial> {
|
||||
// AlphaMode::Blend keeps every layer in the transparent pass with depth-write off (the
|
||||
// reference's 0x12 = 0); the Mod2x key swaps the blend equation in specialize().
|
||||
let (params, mod2x) = match blend {
|
||||
WeatherBlend::RainMod2x => (Vec4::new(0.0, 1.0, RAIN_FOG_START, RAIN_FOG_END), true),
|
||||
WeatherBlend::SnowAlpha | WeatherBlend::MistAlpha => (Vec4::ZERO, false),
|
||||
};
|
||||
materials.add(ExtendedMaterial {
|
||||
base: StandardMaterial {
|
||||
base_color_texture: Some(texture),
|
||||
unlit: true,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
cull_mode: None, // the reference's 0x14 = 0, two-sided
|
||||
..default()
|
||||
},
|
||||
extension: WowParticleExt {
|
||||
light_buf,
|
||||
params,
|
||||
mod2x,
|
||||
},
|
||||
})
|
||||
/// The four precip textures (loaded once; the stream's render-side residency gate withholds
|
||||
/// draws until they arrive).
|
||||
#[derive(Resource)]
|
||||
struct PrecipAssets {
|
||||
streak: Handle<Image>,
|
||||
splash: Handle<Image>,
|
||||
flake: Handle<Image>,
|
||||
mist: Handle<Image>,
|
||||
}
|
||||
|
||||
fn setup_precip(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<WowParticleMaterial>>,
|
||||
light: Option<Res<SharedLightBuffer>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
cam: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
existing: Option<Res<Precip>>,
|
||||
) {
|
||||
// Lazy spawn on the first frame with a live camera (the same lifecycle as every other
|
||||
// particle entity — M2 emitters spawn when terrain streams). No asset chain (data-less
|
||||
// run) → no shared light buffer → no weather visuals.
|
||||
// particle entity — M2 emitters spawn when terrain streams).
|
||||
if existing.is_some() || cam.single().is_err() {
|
||||
return;
|
||||
}
|
||||
let Some(light) = light else { return };
|
||||
|
||||
// Constant per-purpose buffer sizes: streaks are 3-vert triangles, flakes 4-vert quads,
|
||||
// ground layers their own caps. Fixed-size buffers skip per-frame reallocation and keep the
|
||||
// render side's first extraction at final size.
|
||||
let rain_drop_mesh = meshes.add(blank_mesh_sized(POOL * 3, POOL * 3));
|
||||
let rain_ground_mesh = meshes.add(blank_mesh_sized(GROUND_CAP * 3, GROUND_CAP * 3));
|
||||
let snow_drop_mesh = meshes.add(blank_mesh_sized(POOL * 4, POOL * 6));
|
||||
let snow_ground_mesh = meshes.add(blank_mesh_sized(GROUND_CAP * 4, GROUND_CAP * 6));
|
||||
let mist_mesh = meshes.add(blank_mesh_sized(MIST_CAP * 4, MIST_CAP * 6));
|
||||
// Streaks keep the authored mips (a solid bar survives minification); the flake + splash
|
||||
// cut-outs load mip-0-only (`BlpVariant::Effect`) — their thin arms collapse to near-zero
|
||||
// alpha under the mip chain and vanish at a few pixels (the reference's point-sprite path
|
||||
@@ -372,64 +245,34 @@ fn setup_precip(
|
||||
let effect = |s: &mut benilla_assets::BlpLoaderSettings| {
|
||||
s.variant = benilla_assets::BlpVariant::Effect;
|
||||
};
|
||||
let streak_tex: Handle<Image> = asset_server.load("mpq://textures/weather/raindrop01.blp");
|
||||
let splash_tex: Handle<Image> =
|
||||
asset_server.load_with_settings("mpq://textures/weather/raindropsplash01.blp", effect);
|
||||
let flake_tex: Handle<Image> =
|
||||
asset_server.load_with_settings("mpq://textures/weather/snowflake01.blp", effect);
|
||||
let mist_tex: Handle<Image> =
|
||||
asset_server.load_with_settings("mpq://textures/weather/snowmist01.blp", effect);
|
||||
|
||||
// The verified render states (rf-weather-render Q3): rain streak + patter draw Mod2x under
|
||||
// the forced grey fog (their textures are authored grey-128-neutral — checked: both
|
||||
// backgrounds mean exactly RGB 128); snow and mist are standard alpha blends with fog off.
|
||||
for (mesh, tex, blend) in [
|
||||
(&rain_drop_mesh, streak_tex, WeatherBlend::RainMod2x),
|
||||
(&rain_ground_mesh, splash_tex, WeatherBlend::RainMod2x),
|
||||
(&snow_drop_mesh, flake_tex.clone(), WeatherBlend::SnowAlpha),
|
||||
(&snow_ground_mesh, flake_tex, WeatherBlend::SnowAlpha),
|
||||
(&mist_mesh, mist_tex, WeatherBlend::MistAlpha),
|
||||
] {
|
||||
commands.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(weather_material(
|
||||
&mut materials,
|
||||
tex,
|
||||
light.0.clone(),
|
||||
blend,
|
||||
)),
|
||||
Transform::IDENTITY,
|
||||
NoFrustumCulling, // a camera-tracking cloud; its AABB is meaningless
|
||||
PrecipLayer,
|
||||
));
|
||||
}
|
||||
|
||||
commands.insert_resource(PrecipAssets {
|
||||
streak: asset_server.load("mpq://textures/weather/raindrop01.blp"),
|
||||
splash: asset_server
|
||||
.load_with_settings("mpq://textures/weather/raindropsplash01.blp", effect),
|
||||
flake: asset_server.load_with_settings("mpq://textures/weather/snowflake01.blp", effect),
|
||||
mist: asset_server.load_with_settings("mpq://textures/weather/snowmist01.blp", effect),
|
||||
});
|
||||
commands.insert_resource(Precip {
|
||||
rain: Pool::new(rain_drop_mesh, rain_ground_mesh),
|
||||
snow: Pool::new(snow_drop_mesh, snow_ground_mesh),
|
||||
mist: Mist::new(mist_mesh),
|
||||
rain: Pool::default(),
|
||||
snow: Pool::default(),
|
||||
mist: Mist::default(),
|
||||
rng: 0x9E37_79B9,
|
||||
});
|
||||
}
|
||||
|
||||
/// `WOW_WEATHER_PROBE=1`: spawn a STATIC one-off grid of flake quads at fixed offsets ahead of
|
||||
/// the spawn point — built once, never touched again. Splits "per-frame rebuild breaks it" from
|
||||
/// "material/texture/distance breaks it" (diagnosis-only; costs nothing when the env is unset).
|
||||
/// Per frame: wind, spawn budgets, integrate, land, and rebuild the four meshes.
|
||||
/// Per frame: wind, spawn budgets, integrate, land. (The geometry push is [`push_precip`]'s,
|
||||
/// in `PostUpdate` after the stream clear.)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn simulate_precip(
|
||||
time: Res<Time>,
|
||||
weather: Res<WeatherState>,
|
||||
lighting: Res<WowLighting>,
|
||||
cam: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
player: Query<&Transform, (With<crate::net::SelfPlayer>, Without<PrecipLayer>)>,
|
||||
player: Query<&Transform, (With<crate::net::SelfPlayer>, Without<WorldCamera>)>,
|
||||
spatial: SpatialQuery,
|
||||
indoors: Res<WeatherIndoors>,
|
||||
mut wind: ResMut<WeatherWind>,
|
||||
mut heights: ResMut<HeightCache>,
|
||||
mut precip: Option<ResMut<Precip>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut layers: Query<&mut Transform, With<PrecipLayer>>,
|
||||
mut last_cut: Local<u32>,
|
||||
) {
|
||||
let Some(precip) = precip.as_deref_mut() else {
|
||||
@@ -536,69 +379,85 @@ fn simulate_precip(
|
||||
cam_pos,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== rebuild the meshes (verts anchor-relative; the entities ride the anchor) =====
|
||||
let Precip {
|
||||
rain, snow, mist, ..
|
||||
} = precip;
|
||||
let anchor = cam_pos;
|
||||
for mut tf in &mut layers {
|
||||
tf.translation = anchor;
|
||||
/// Push the frame's precip geometry onto the shared effect stream (PostUpdate, after the
|
||||
/// stream clear): rain streaks + patters as Mod2x tri-lists under the forced grey fog (their
|
||||
/// textures are authored grey-128-neutral — checked: both backgrounds mean exactly RGB 128);
|
||||
/// snow and mist as alpha-blended quads with fog off (rf-weather-render Q3). All four draws
|
||||
/// anchor at the camera — view-z ≈ 0 sorts them after every world transparent, before the
|
||||
/// biased glare/nameplate rungs, exactly where the old camera-anchored layer entities landed.
|
||||
/// Indoors nothing is pushed — the frozen drops hang, unrendered (`[0xca80c4]`'s draw kill).
|
||||
#[allow(clippy::too_many_arguments)] // one push system's full input set
|
||||
fn push_precip(
|
||||
precip: Option<Res<Precip>>,
|
||||
assets: Option<Res<PrecipAssets>>,
|
||||
indoors: Res<WeatherIndoors>,
|
||||
lighting: Res<WowLighting>,
|
||||
wind: Res<WeatherWind>,
|
||||
cam: Query<(Entity, &GlobalTransform), With<WorldCamera>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
) {
|
||||
let (Some(precip), Some(assets)) = (precip, assets) else {
|
||||
return;
|
||||
};
|
||||
if indoors.0 {
|
||||
return;
|
||||
}
|
||||
let Ok((cam_entity, cam_tf)) = cam.single() else {
|
||||
return;
|
||||
};
|
||||
let cam_pos = cam_tf.translation();
|
||||
let cam_right = cam_tf.right().as_vec3();
|
||||
let cam_up = cam_tf.up().as_vec3();
|
||||
let rain_drops_live = !rain.drops.is_empty();
|
||||
build_streak_mesh(
|
||||
rain.drop_written
|
||||
.write(&mut meshes, &rain.drop_mesh, rain_drops_live),
|
||||
&rain.drops,
|
||||
wind.tilt,
|
||||
cam_pos,
|
||||
anchor,
|
||||
let spec = |texture: &Handle<Image>, blend: EffectBlend, fog: EffectFog| EffectDrawSpec {
|
||||
cam: cam_entity,
|
||||
texture: texture.id(),
|
||||
blend,
|
||||
fog,
|
||||
anchor: cam_pos,
|
||||
bias: 0.0,
|
||||
raster_bias: 0,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
};
|
||||
let start = quads.begin();
|
||||
push_streaks(&mut quads.verts, &precip.rain.drops, wind.tilt, cam_pos);
|
||||
quads.commit_tris(
|
||||
start,
|
||||
spec(&assets.streak, EffectBlend::Mod2x, EffectFog::Rain),
|
||||
);
|
||||
let rain_ground_live = !rain.patters.is_empty();
|
||||
build_patter_mesh(
|
||||
rain.ground_written
|
||||
.write(&mut meshes, &rain.ground_mesh, rain_ground_live),
|
||||
&rain.patters,
|
||||
let start = quads.begin();
|
||||
push_patters(&mut quads.verts, &precip.rain.patters, cam_right, cam_up);
|
||||
quads.commit_tris(
|
||||
start,
|
||||
spec(&assets.splash, EffectBlend::Mod2x, EffectFog::Rain),
|
||||
);
|
||||
let start = quads.begin();
|
||||
push_flakes(
|
||||
&mut quads.verts,
|
||||
&precip.snow.drops,
|
||||
&precip.snow.patters,
|
||||
cam_right,
|
||||
cam_up,
|
||||
anchor,
|
||||
);
|
||||
let snow_drops_live = !snow.drops.is_empty();
|
||||
build_flake_mesh(
|
||||
snow.drop_written
|
||||
.write(&mut meshes, &snow.drop_mesh, snow_drops_live),
|
||||
&snow.drops,
|
||||
&[],
|
||||
cam_right,
|
||||
cam_up,
|
||||
anchor,
|
||||
POOL * 4,
|
||||
POOL * 6,
|
||||
quads.commit_quads(
|
||||
start,
|
||||
spec(&assets.flake, EffectBlend::Alpha, EffectFog::Off),
|
||||
);
|
||||
let snow_ground_live = !snow.patters.is_empty();
|
||||
build_flake_mesh(
|
||||
snow.ground_written
|
||||
.write(&mut meshes, &snow.ground_mesh, snow_ground_live),
|
||||
&[],
|
||||
&snow.patters,
|
||||
cam_right,
|
||||
cam_up,
|
||||
anchor,
|
||||
GROUND_CAP * 4,
|
||||
GROUND_CAP * 6,
|
||||
);
|
||||
let mist_live = mist.is_live();
|
||||
build_mist_mesh(
|
||||
mist.written.write(&mut meshes, &mist.mesh, mist_live),
|
||||
mist,
|
||||
let start = quads.begin();
|
||||
push_mist(
|
||||
&mut quads.verts,
|
||||
&precip.mist,
|
||||
cam_right,
|
||||
cam_up,
|
||||
cam_pos,
|
||||
anchor,
|
||||
lighting.fog_color,
|
||||
);
|
||||
quads.commit_quads(
|
||||
start,
|
||||
spec(&assets.mist, EffectBlend::Alpha, EffectFog::Off),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn register(app: &mut App) {
|
||||
@@ -614,25 +473,7 @@ pub(super) fn register(app: &mut App) {
|
||||
.after(WeatherTick)
|
||||
.after(gate_weather_indoors),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The idle skip is edge-exact: untouched from birth, written while live, written ONCE
|
||||
/// more on the live→empty edge (the clearing write), then untouched again — so a quiet
|
||||
/// scene never re-uploads the fixed-capacity buffers (the 0353 fps hunt).
|
||||
#[test]
|
||||
fn write_gate_touches_on_live_and_the_clearing_edge_only() {
|
||||
let mut meshes = Assets::<Mesh>::default();
|
||||
let handle = meshes.add(blank_mesh_sized(4, 6));
|
||||
let mut gate = WriteGate::default();
|
||||
assert!(gate.write(&mut meshes, &handle, false).is_none());
|
||||
assert!(gate.write(&mut meshes, &handle, true).is_some());
|
||||
assert!(gate.write(&mut meshes, &handle, true).is_some());
|
||||
assert!(gate.write(&mut meshes, &handle, false).is_some());
|
||||
assert!(gate.write(&mut meshes, &handle, false).is_none());
|
||||
}
|
||||
)
|
||||
// The stream push: PostUpdate after the frame's clear (the sim ran in Update).
|
||||
.add_systems(PostUpdate, push_precip.after(begin_effect_frame));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! ground oracle cache, and the per-kind frame (`run_kind`). Split from `precip`'s root;
|
||||
//! byte-cited constants stay in the root's table, ECS wiring stays in the root's systems.
|
||||
|
||||
use bevy::platform::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One falling particle. Positions/velocities are Bevy world space (y up).
|
||||
@@ -57,7 +59,9 @@ pub(super) struct Patter {
|
||||
pub(super) variant: u8,
|
||||
}
|
||||
|
||||
/// The per-kind pool + its two mesh handles (falling layer + ground layer).
|
||||
/// The per-kind pool (falling layer + ground layer) — its geometry rides the shared effect
|
||||
/// stream (0733), so it owns no meshes.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Pool {
|
||||
/// ACTIVE drops — falling, rendered, landing.
|
||||
pub(super) drops: Vec<Drop>,
|
||||
@@ -66,28 +70,9 @@ pub(super) struct Pool {
|
||||
/// SEALED packets — closed cohorts replaying (or waiting for) their baseTime windows.
|
||||
sealed: Vec<Packet>,
|
||||
pub(super) patters: Vec<Patter>,
|
||||
pub(super) drop_mesh: Handle<Mesh>,
|
||||
pub(super) ground_mesh: Handle<Mesh>,
|
||||
/// Mesh-write liveness edges (one per mesh) — see [`WriteGate`]: an idle pool leaves its
|
||||
/// fixed-capacity GPU buffers untouched instead of re-uploading them every frame.
|
||||
pub(super) drop_written: WriteGate,
|
||||
pub(super) ground_written: WriteGate,
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
pub(super) fn new(drop_mesh: Handle<Mesh>, ground_mesh: Handle<Mesh>) -> Self {
|
||||
Self {
|
||||
drops: Vec::new(),
|
||||
open: None,
|
||||
sealed: Vec::new(),
|
||||
patters: Vec::new(),
|
||||
drop_mesh,
|
||||
ground_mesh,
|
||||
drop_written: WriteGate::default(),
|
||||
ground_written: WriteGate::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records still in the pipeline (open + sealed) — the instrument's "pipe" count.
|
||||
pub(super) fn pending_len(&self) -> usize {
|
||||
self.open.as_ref().map_or(0, |p| p.records.len())
|
||||
@@ -398,7 +383,7 @@ mod tests {
|
||||
/// buffer, `0x6752b0` ← `0x675a97` sole caller).
|
||||
#[test]
|
||||
fn packet_seals_before_replaying() {
|
||||
let mut pool = Pool::new(Handle::default(), Handle::default());
|
||||
let mut pool = Pool::default();
|
||||
let close_age = RAIN_P / PACKET_CAP as f32; // 35000/6144 ≈ 5.7 s
|
||||
let (_, at0) = pool.open_for(0.0, RAIN_P, close_age);
|
||||
assert!((at0 - PACKET_CAP as f32 / RAIN_P).abs() < 1e-3);
|
||||
@@ -421,16 +406,18 @@ mod tests {
|
||||
cell: (0, 0),
|
||||
size: 1.0,
|
||||
};
|
||||
let mut pool = Pool::new(Handle::default(), Handle::default());
|
||||
pool.open = Some(Packet {
|
||||
opened: 9.0,
|
||||
visible_at: 12.0,
|
||||
records: vec![Pending {
|
||||
at: 12.0,
|
||||
drop: d(),
|
||||
}],
|
||||
count: 1,
|
||||
});
|
||||
let mut pool = Pool {
|
||||
open: Some(Packet {
|
||||
opened: 9.0,
|
||||
visible_at: 12.0,
|
||||
records: vec![Pending {
|
||||
at: 12.0,
|
||||
drop: d(),
|
||||
}],
|
||||
count: 1,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
pool.sealed.push(Packet {
|
||||
opened: 0.0,
|
||||
visible_at: 1.0, // replaying since t=1
|
||||
@@ -458,7 +445,7 @@ mod tests {
|
||||
/// pre-pipeline 2.5 s.
|
||||
#[test]
|
||||
fn upswing_first_visible_rain_is_gated() {
|
||||
let mut pool = Pool::new(Handle::default(), Handle::default());
|
||||
let mut pool = Pool::default();
|
||||
let close_age = RAIN_P / PACKET_CAP as f32;
|
||||
let dt = 1.0 / 60.0;
|
||||
let mut first_visible = f32::MAX;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! The precip **mesh builders** — streak/patter/flake quads rebuilt each frame from the live
|
||||
//! pools. Split from `precip`'s root; geometry only, no sim state.
|
||||
//! The precip **stream pushers** — streak/patter/flake geometry emitted each frame from the
|
||||
//! live pools straight into the shared effect stream (0733; world-space — the lane rebases
|
||||
//! camera-relative render-side). Split from `precip`'s root; geometry only, no sim state.
|
||||
//! Empty pools push nothing, so an idle sky costs zero here — the structural replacement for
|
||||
//! the old fixed-capacity meshes' `WriteGate` (the 0353 fps hunt).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::particles::buffer::EffectVertex;
|
||||
|
||||
use super::pool::{Drop, Patter};
|
||||
use super::*;
|
||||
|
||||
@@ -10,137 +15,85 @@ use super::*;
|
||||
/// verts `head ∓ 0.05·RIGHT` (RIGHT = normalize(cross(toCam, antiVel)), camera-facing width
|
||||
/// axis), apex `head + M·(2.0·antiVel̂)` with M the wind-tilt applied to the APEX ONLY. UVs
|
||||
/// (0,1)/(1,1)/(0.5,0). No vertex colour/alpha (white; the look is the texture under Mod2x).
|
||||
pub(super) fn build_streak_mesh(
|
||||
mesh: Option<&mut Mesh>,
|
||||
drops: &[Drop],
|
||||
tilt: Quat,
|
||||
cam: Vec3,
|
||||
anchor: Vec3,
|
||||
) {
|
||||
let Some(mesh) = mesh else { return };
|
||||
let n = drops.len().min(POOL);
|
||||
let mut pos = Vec::with_capacity(n * 3);
|
||||
let mut uv = Vec::with_capacity(n * 3);
|
||||
let mut col = Vec::with_capacity(n * 3);
|
||||
let mut idx = Vec::with_capacity(n * 3);
|
||||
pub(super) fn push_streaks(out: &mut Vec<EffectVertex>, drops: &[Drop], tilt: Quat, cam: Vec3) {
|
||||
let white = [1.0, 1.0, 1.0, 1.0];
|
||||
for d in drops.iter().take(POOL) {
|
||||
let anti_vel = -d.vel.normalize_or(Vec3::NEG_Y);
|
||||
let to_cam = (cam - d.pos).normalize_or(Vec3::X);
|
||||
let right = to_cam.cross(anti_vel).normalize_or(Vec3::X) * STREAK_HALF_W;
|
||||
let head = d.pos - anchor;
|
||||
let apex = head + tilt * (anti_vel * STREAK_TAIL);
|
||||
let base = pos.len() as u32;
|
||||
pos.extend([
|
||||
(head - right).to_array(),
|
||||
(head + right).to_array(),
|
||||
apex.to_array(),
|
||||
]);
|
||||
uv.extend([[0.0, 1.0], [1.0, 1.0], [0.5, 0.0]]);
|
||||
col.extend([white; 3]);
|
||||
idx.extend([base, base + 1, base + 2]);
|
||||
let apex = d.pos + tilt * (anti_vel * STREAK_TAIL);
|
||||
for (pos, uv) in [
|
||||
(d.pos - right, [0.0, 1.0]),
|
||||
(d.pos + right, [1.0, 1.0]),
|
||||
(apex, [0.5, 0.0]),
|
||||
] {
|
||||
out.push(EffectVertex {
|
||||
pos: pos.to_array(),
|
||||
uv,
|
||||
color: white,
|
||||
});
|
||||
}
|
||||
}
|
||||
pad_mesh(&mut pos, &mut uv, &mut col, &mut idx, POOL * 3, POOL * 3);
|
||||
let normals = vec![[0.0, 1.0, 0.0]; pos.len()];
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, pos);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uv);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, col);
|
||||
mesh.insert_indices(Indices::U32(idx));
|
||||
}
|
||||
|
||||
/// Ground patters: one camera-facing **triangle** per splash (the byte geometry: corners
|
||||
/// `center − right`, `center + up`, `center + right` with `right = view_right/12`,
|
||||
/// `up = view_up/6`), animated left→right across its atlas row over the 0.25 s life.
|
||||
pub(super) fn build_patter_mesh(
|
||||
mesh: Option<&mut Mesh>,
|
||||
pub(super) fn push_patters(
|
||||
out: &mut Vec<EffectVertex>,
|
||||
patters: &[Patter],
|
||||
cam_right: Vec3,
|
||||
cam_up: Vec3,
|
||||
anchor: Vec3,
|
||||
) {
|
||||
let Some(mesh) = mesh else { return };
|
||||
let right = cam_right * PATTER_RIGHT;
|
||||
let up = cam_up * PATTER_UP;
|
||||
let n = patters.len();
|
||||
let mut pos = Vec::with_capacity(n * 3);
|
||||
let mut uv = Vec::with_capacity(n * 3);
|
||||
let mut col = Vec::with_capacity(n * 3);
|
||||
let mut idx = Vec::with_capacity(n * 3);
|
||||
for p in patters.iter().take(GROUND_CAP) {
|
||||
let t = (p.age / PATTER_LIFE).clamp(0.0, 1.0);
|
||||
let frame = ((t * 4.0) as u32).min(3) as f32;
|
||||
let (u0, v0) = (frame * 0.25, f32::from(p.variant) * 0.25);
|
||||
// No vertex alpha (Mod2x has none): the atlas's 4 growth frames are the animation, and
|
||||
// the texture's grey-128 background is neutral.
|
||||
let alpha = 1.0;
|
||||
let base = pos.len() as u32;
|
||||
let c = p.pos - anchor;
|
||||
pos.extend([
|
||||
(c - right).to_array(),
|
||||
(c + up).to_array(),
|
||||
(c + right).to_array(),
|
||||
]);
|
||||
// The byte texcoord law (`wx_rainrender.rs` step 8): base-left, apex, base-right.
|
||||
uv.extend([
|
||||
[u0, v0 + 0.25],
|
||||
[u0 + 0.125, v0 + 0.043],
|
||||
[u0 + 0.25, v0 + 0.25],
|
||||
]);
|
||||
col.extend([[1.0, 1.0, 1.0, alpha]; 3]);
|
||||
idx.extend([base, base + 1, base + 2]);
|
||||
for (pos, uv) in [
|
||||
(p.pos - right, [u0, v0 + 0.25]),
|
||||
(p.pos + up, [u0 + 0.125, v0 + 0.043]),
|
||||
(p.pos + right, [u0 + 0.25, v0 + 0.25]),
|
||||
] {
|
||||
out.push(EffectVertex {
|
||||
pos: pos.to_array(),
|
||||
uv,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
pad_mesh(
|
||||
&mut pos,
|
||||
&mut uv,
|
||||
&mut col,
|
||||
&mut idx,
|
||||
GROUND_CAP * 3,
|
||||
GROUND_CAP * 3,
|
||||
);
|
||||
let normals = vec![[0.0, 1.0, 0.0]; pos.len()];
|
||||
// (capacity matches creation)
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, pos);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uv);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, col);
|
||||
mesh.insert_indices(Indices::U32(idx));
|
||||
}
|
||||
|
||||
/// Snow flakes: camera-facing quads (the byte pass builds a face-camera basis; half-size 0.05,
|
||||
/// jittered per flake). `drops` = falling flakes; `settled` = landed ones fading out over the
|
||||
/// `+0.25 s` window.
|
||||
#[allow(clippy::too_many_arguments)] // one mesh-build's full input set
|
||||
pub(super) fn build_flake_mesh(
|
||||
mesh: Option<&mut Mesh>,
|
||||
/// Snow flakes: camera-facing quads (the byte pass builds a face-camera basis; half-size 1/12,
|
||||
/// jittered per flake), pushed in the stream's perimeter corner order. `drops` = falling
|
||||
/// flakes; `settled` = landed ones fading out over the `+0.25 s` window.
|
||||
pub(super) fn push_flakes(
|
||||
out: &mut Vec<EffectVertex>,
|
||||
drops: &[Drop],
|
||||
settled: &[Patter],
|
||||
cam_right: Vec3,
|
||||
cam_up: Vec3,
|
||||
anchor: Vec3,
|
||||
vert_cap: usize,
|
||||
idx_cap: usize,
|
||||
) {
|
||||
let Some(mesh) = mesh else { return };
|
||||
let n = drops.len() + settled.len();
|
||||
let mut pos = Vec::with_capacity(n * 4);
|
||||
let mut uv = Vec::with_capacity(n * 4);
|
||||
let mut col = Vec::with_capacity(n * 4);
|
||||
let mut idx = Vec::with_capacity(n * 6);
|
||||
let mut quad = |center: Vec3, half: f32, alpha: f32| {
|
||||
let r = cam_right * half;
|
||||
let u = cam_up * half;
|
||||
let base = pos.len() as u32;
|
||||
let c = center - anchor;
|
||||
pos.extend([
|
||||
(c - r - u).to_array(),
|
||||
(c + r - u).to_array(),
|
||||
(c - r + u).to_array(),
|
||||
(c + r + u).to_array(),
|
||||
]);
|
||||
uv.extend([[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]]);
|
||||
col.extend([[1.0, 1.0, 1.0, alpha]; 4]);
|
||||
idx.extend([base, base + 1, base + 2, base + 1, base + 3, base + 2]);
|
||||
// Perimeter order (bl, br, tr, tl) — the stream's quad-index pattern closes it.
|
||||
for (pos, uv) in [
|
||||
(center - r - u, [0.0, 1.0]),
|
||||
(center + r - u, [1.0, 1.0]),
|
||||
(center + r + u, [1.0, 0.0]),
|
||||
(center - r + u, [0.0, 0.0]),
|
||||
] {
|
||||
out.push(EffectVertex {
|
||||
pos: pos.to_array(),
|
||||
uv,
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
});
|
||||
}
|
||||
};
|
||||
for d in drops.iter().take(POOL) {
|
||||
quad(d.pos, SNOW_HALF * d.size, SNOW_ALPHA);
|
||||
@@ -149,11 +102,4 @@ pub(super) fn build_flake_mesh(
|
||||
let t = (s.age / SNOW_SETTLE_LIFE).clamp(0.0, 1.0);
|
||||
quad(s.pos, SNOW_HALF, SNOW_ALPHA * (1.0 - t));
|
||||
}
|
||||
pad_mesh(&mut pos, &mut uv, &mut col, &mut idx, vert_cap, idx_cap);
|
||||
let normals = vec![[0.0, 1.0, 0.0]; pos.len()];
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, pos);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uv);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, col);
|
||||
mesh.insert_indices(Indices::U32(idx));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user