//! The 1.12 world-doodad distance fade (`0x683f80`, run every frame from the world render //! `0x681070`). `d` is the horizontal camera distance to the bounding-sphere centre //! (`rec+0x5c/0x60`) minus the radius (`rec+0x68`), and the radius alone picks the band: a candle //! fades at 40 to 50 yd, a fence (0.75 to 4.97 yd) or haystack at 100 to 125 or 150 to 200, a tree //! never (`cargo run -p benilla-formats --example fade_bucket -- ` measures a model). The //! alpha reaches the fragment's `diffuse.a` (`CM2Model+0x180` → `+0x19c`), where the 224/255 alpha //! test erodes a cutout edge-first; a fading doodad draws blended. Constants: radius cutoffs //! `0x810188`/`0x81018c`/`0x810190`, band ends (start + range) `0x8101a0`/`a4`/`a8`, ranges //! `0x810194`/`98`/`9c`, `1.0` at `0x7ff9d8`. use benilla_assets::materials::WowModelMaterial; use bevy::mesh::MeshTag; use bevy::prelude::*; /// A doodad submesh's distance-fade inputs. `apply_model_visibility` writes the fade into its /// `MeshTag` and draws `blend` while `0 < fade < 1`, `cutout` otherwise. #[derive(Component, Clone)] pub struct DoodadFade { /// The scaled bounding-sphere radius (yd), the reference's `rec+0x68` (`0x6952a0`). pub(crate) radius: f32, /// The model-local bounding-box centre the fade measures to (`0x6952a0`), not the origin. pub(crate) local_center: Vec3, /// The steady material, in the submesh's authored blend mode. pub(crate) cutout: Handle, /// Its `AlphaMode::Blend` twin, drawn only while feathering. pub(crate) blend: Handle, } /// Above this bounding radius (yd) a doodad never distance-fades; only the far clip drops it. pub const NEVER_FADE_RADIUS: f32 = 7.0; /// `(max_radius, band_start, band_range)` in yd; a doodad takes the first row it does not exceed. const BUCKETS: [(f32, f32, f32); 3] = [ (0.5, 40.0, 10.0), (2.5, 100.0, 25.0), (NEVER_FADE_RADIUS, 150.0, 50.0), ]; /// The distance-fade alpha of a doodad of `radius` yd (placement scale applied) whose centre is /// `horiz_dist` yd away in the horizontal plane (the reference ignores height). `0.0` means cull. pub fn doodad_fade_alpha(radius: f32, horiz_dist: f32) -> f32 { if radius > NEVER_FADE_RADIUS { return 1.0; } let d = horiz_dist - radius; let (_, start, range) = BUCKETS .iter() .copied() .find(|(max_r, _, _)| radius <= *max_r) // Unreached: the last row's bound is `NEVER_FADE_RADIUS`. .unwrap_or((NEVER_FADE_RADIUS, 150.0, 50.0)); (1.0 - (d - start) / range).clamp(0.0, 1.0) } /// The `(near, far)` centre distances between which [`doodad_fade_alpha`] feathers, from the same /// `BUCKETS` rows, so the retained static pass that rings its cells on them cannot disagree. pub fn fade_band(radius: f32) -> Option<(f32, f32)> { if radius > NEVER_FADE_RADIUS { return None; } let (_, start, range) = BUCKETS .iter() .copied() .find(|(max_r, _, _)| radius <= *max_r) .unwrap_or((NEVER_FADE_RADIUS, 150.0, 50.0)); Some((start + radius, start + radius + range)) } /// A render-alpha ramp on one part's `MeshTag`, drawn on the blend twin while `α < 1` /// ([`apply_render_fade`]). The appear ramp is `FadeTo` (`0x614f80`), cubic over 2 s from first /// visibility; the teardown is the `SWModelFadeout` pump (`0x672ef0`) on the model an object left /// behind ([`DespawnFade`]). The interior classifier keeps the light law through either. #[derive(Component, Clone)] pub struct RenderFade { /// `Time::elapsed_secs` at arming. pub started: f32, /// Seconds; both reference ramps are 2000 ms (`FadeTo`'s `0x7d0`, the pump's `age > 0x7d0`). pub duration: f32, /// Appear `0 → 1`; teardown `live α → 0`, `from` being the pump's `startAlpha`. pub from: f32, pub to: f32, /// Which of the reference's two ramps this is. pub curve: FadeCurve, } /// The reference's two render-alpha ramps, two functions and not one curve: the CGObject's appear /// ease (`0x614a90`, `t³`) and the fadeout scheduler's teardown (`0x672ef0`, `smoothstep(1 − t)`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum FadeCurve { #[default] Cubic, Smoothstep, } impl RenderFade { /// This fade's alpha at fractional age `t`, by its own curve. pub fn alpha_at(&self, t: f32) -> f32 { match self.curve { FadeCurve::Cubic => fade_alpha(self.from, self.to, t), FadeCurve::Smoothstep => teardown_fade_alpha(self.from, t), } } } /// The appear fade's length, `FadeTo(1.0, 2000 ms)` on the wall clock (`OsGetAsyncTimeMs`). pub const APPEAR_FADE_SECS: f32 = 2.0; impl RenderFade { /// A spawn appear-fade armed at `now`: `α = t³` from 0 → 1 over [`APPEAR_FADE_SECS`]. pub fn appear(now: f32) -> Self { Self { started: now, duration: APPEAR_FADE_SECS, from: 0.0, to: 1.0, curve: FadeCurve::Cubic, } } } /// The appear ramp, `lerp(from, to, clamp(t, 0, 1)³)` at fractional age `t` (`0x614a90`). pub fn fade_alpha(from: f32, to: f32, t: f32) -> f32 { let t = t.clamp(0.0, 1.0); from + (to - from) * t * t * t } /// The teardown ramp, the `SWModelFadeout` pump (`0x672ef0`): `α = (3 − 2t) · t²` with /// `t = clamp((1 − t_frac) · startAlpha, 0, 1)`, `t_frac` being `age_ms · 0.0005` (`[0x80c698]`). /// `start_alpha`, the live alpha at the hand-off (`obj+0xf4`), scales the ramp, not the output. pub fn teardown_fade_alpha(start_alpha: f32, t_frac: f32) -> f32 { let t = ((1.0 - t_frac) * start_alpha).clamp(0.0, 1.0); (3.0 - 2.0 * t) * t * t } /// The scheduler's skip gate (`0x672df0` @ `0x672e21`, against `[0x8029d0]`): a model handed over /// below this alpha is unlinked at once (`0x671ac0`), not faded. pub const TEARDOWN_MIN_ALPHA: f32 = 0.01; /// A model instance's render alpha, the reference's `CM2Model+0x19c` (`argAlpha · +0x180`, where /// `+0x180` is `obj+0x100 · obj+0xf4` for a CGObject and the distance fade for a doodad), for the /// consumers that cannot read a `MeshTag`: particles and ribbons. Missing reads `1.0`. #[derive(Component, Clone, Copy, Debug, PartialEq)] pub struct ModelAlpha(pub f32); /// A fade the game declares on an instance root (an aura's ramp, stealth, a ghost form); the engine /// folds it into the composed alpha and owns the write. Absent reads `1.0`. #[derive(Component, Clone, Copy, Debug)] pub struct ModelFade(pub f32); /// The model this one is attached to, the reference's `[model+0x1cc]` (`0x712f70`, /// `CM2Model::attachChild`). A spawn site names only its immediate parent. #[derive(Component, Clone, Copy, Debug, PartialEq)] pub struct ParentModel(pub Entity); /// The bound on a [`ParentModel`] walk, a cycle backstop: real chains are 1 to 3 links. pub const MAX_MODEL_CHAIN: usize = 8; /// A model instance's composed render alpha, the reference's recursion (`0x714000`, `0x714260`): /// `child+0x19c = parent+0x19c × child+0x180` up the [`ParentModel`] chain, so an effect passes /// its own instance root. A link with no published [`ModelAlpha`] (a booth bake) reads its /// [`ModelFade`]; the published one wins. #[derive(bevy::ecs::system::SystemParam)] pub struct ModelAlphas<'w, 's> { chain: Query< 'w, 's, ( Option<&'static ModelAlpha>, Option<&'static ModelFade>, Option<&'static ParentModel>, ), >, } impl ModelAlphas<'_, '_> { /// `instance`'s composed alpha; a despawned link (its effects freed with it) ends the walk. pub fn get(&self, instance: Entity) -> f32 { let mut alpha = 1.0; let mut at = instance; for _ in 0..MAX_MODEL_CHAIN { let Ok((published, declared, parent)) = self.chain.get(at) else { break; }; // Published wins: it already folds the declared fade in. if let Some(a) = published.map(|a| a.0).or(declared.map(|d| d.0)) { alpha *= a; } match parent { Some(p) => at = p.0, None => break, } } alpha } } /// One streamed model's render alpha: the product of the appear ramp (0 while pending, hiding its /// effects too), the teardown, the self zoom feather and the declared fade, as the reference /// multiplies the transition alpha into `+0x180`; its aura recompute drives the appear fade's own /// `StartAlphaFade` (`0x60d180` → `0x614f80` → `obj+0xf4` → `+0x180` → `+0x19c` → `emitter+0x1a8`). pub fn model_render_alpha( now: f32, appear: Option, despawn_started: Option, self_fade: f32, declared: f32, ) -> f32 { let appear = match appear { None => 1.0, Some(UnitAppearFade::Pending { .. }) => 0.0, Some(UnitAppearFade::Live { started }) => { fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS) } }; // The mesh channel's teardown smoothstep, so effects thin out with their geometry. let despawn = despawn_started.map_or(1.0, |started| { teardown_fade_alpha(1.0, (now - started) / APPEAR_FADE_SECS) }); (appear * despawn * self_fade * declared).clamp(0.0, 1.0) } /// A streamed unit's render alpha computed from its root, for a consumer that runs before /// [`publish_model_alpha`] (in `Update`, or on a unit's first frame). Ask the root: a pending /// unit's parts carry no [`RenderFade`], so a walk over them reads it as opaque. #[derive(bevy::ecs::system::SystemParam)] #[allow(clippy::type_complexity)] // one query, the four facets of a unit's presentation pub struct UnitRenderAlpha<'w, 's> { time: Res<'w, Time>, viewer: Res<'w, crate::view::Viewer>, units: Query< 'w, 's, ( Option<&'static UnitAppearFade>, Option<&'static DespawnFade>, Option<&'static ModelFade>, Has, ), >, } impl UnitRenderAlpha<'_, '_> { /// `unit`'s render alpha, zoom feather included; `1.0` for an entity it cannot read. pub fn get(&self, unit: Entity) -> f32 { let Ok((appear, despawn, declared, is_self)) = self.units.get(unit) else { return 1.0; }; model_render_alpha( self.time.elapsed_secs(), appear.copied(), despawn.and_then(DespawnFade::armed), if is_self { self.viewer.self_fade } else { 1.0 }, declared.map_or(1.0, |d| d.0), ) } } #[cfg(test)] mod unit_render_alpha_tests { use super::*; use bevy::ecs::system::SystemState; fn unit_at(now: f32, build: impl FnOnce(&mut bevy::ecs::world::EntityWorldMut)) -> f32 { let mut world = World::new(); let mut time = Time::<()>::default(); time.advance_by(std::time::Duration::from_secs_f32(now)); world.insert_resource(time); world.insert_resource(crate::view::Viewer::default()); let mut unit = world.spawn_empty(); build(&mut unit); let unit = unit.id(); let mut state = SystemState::::new(&mut world); let alpha = state.get(&world).get(unit); alpha } #[test] fn a_pending_unit_is_zero_and_a_settled_one_is_opaque() { assert_eq!( unit_at(10.0, |u| { u.insert(UnitAppearFade::Pending { since: 9.0 }); }), 0.0, "pending: not shown yet, so nothing that rides this number may show either" ); assert_eq!( unit_at(11.0, |u| { u.insert(UnitAppearFade::Live { started: 10.0 }); }), fade_alpha(0.0, 1.0, 0.5), "live: the same cubic the body's parts run" ); assert_eq!( unit_at(10.0, |_| {}), 1.0, "nothing in flight: opaque, and no arithmetic paid for it" ); } #[test] fn an_unarmed_despawn_stamp_is_not_a_fade() { assert_eq!(DespawnFade::default().armed(), None); assert_eq!(DespawnFade { started: 4.0 }.armed(), Some(4.0)); assert_eq!( unit_at(10.0, |u| { u.insert(DespawnFade::default()); }), 1.0, "marked but not armed: still fully there" ); assert_eq!( unit_at(11.0, |u| { u.insert(DespawnFade { started: 10.0 }); }), teardown_fade_alpha(1.0, 0.5), "armed: the ramp down — the SWModelFadeout smoothstep, not the appear cubic reversed" ); } #[test] fn the_teardown_curve_is_not_the_appear_curve_reversed() { assert_eq!(teardown_fade_alpha(1.0, 0.0), 1.0, "opaque at the hand-off"); assert_eq!(teardown_fade_alpha(1.0, 1.0), 0.0, "gone at the window end"); // Mid-window: 0.5, against the reversed cubic's 1 − 0.5³ = 0.875. assert!((teardown_fade_alpha(1.0, 0.5) - 0.5).abs() < 1e-6); assert!((fade_alpha(1.0, 0.0, 0.5) - 0.875).abs() < 1e-6); let mut prev = f32::INFINITY; for i in 0..=20 { let a = teardown_fade_alpha(1.0, i as f32 / 20.0); assert!(a <= prev + 1e-6, "monotone at t={i}"); prev = a; } assert_eq!( teardown_fade_alpha(1.0, 2.0), 0.0, "clamped past the window" ); assert!( teardown_fade_alpha(0.25, 0.0) < 0.25, "smoothstep(0.25) < 0.25" ); assert_eq!( teardown_fade_alpha(0.0, 0.0), 0.0, "handed nothing, shows nothing" ); } #[test] fn an_unreadable_owner_is_opaque() { let mut world = World::new(); world.insert_resource(Time::<()>::default()); world.insert_resource(crate::view::Viewer::default()); let gone = world.spawn_empty().id(); world.despawn(gone); let mut state = SystemState::::new(&mut world); assert_eq!(state.get(&world).get(gone), 1.0); } } /// Publish [`ModelAlpha`] on every streamed object. `PostUpdate`: after every fade writer and the /// self feather, before the effect sims that read it ([`crate::particles`], [`crate::ribbons`]). /// An object that never faded gets none. #[allow(clippy::type_complexity)] // one query, five optional facets of the same entity pub(crate) fn publish_model_alpha( time: Res