658 lines
29 KiB
Rust
658 lines
29 KiB
Rust
//! The unit blob shadow: the dark oval under every player, NPC and creature, drawn by the
|
||
//! reference's `0x6d7920` and rebuilt here on the shared surface-decal projector.
|
||
//!
|
||
//! - Draw path: the per-frame model-node pass `0x683dd0` gates at `0x6d78f0` and draws through
|
||
//! the selection ring's decal chain (`0x6d7330 → 0x6d6fa0 → 0x6d7480`). Its collector flags
|
||
//! `0x2f0122` add liquid receivers to the ring's; `GroundDecalSurface` has none yet, so the
|
||
//! shadow lands on terrain and WMO faces only.
|
||
//! - Frame slot: phase 1, among the opaque drains (`0x6812c5` in `0x681070`). Per unit the ring
|
||
//! ticks first and the shadow draws second (`0x683ec3`), so the multiply darkens the ring. It
|
||
//! lands after terrain and WMO and before footprints, M2 opaque, water and both M2 transparent
|
||
//! passes, so every transparent paints over it: `Rung::SHADOW_SORT`.
|
||
//! - Texture: `Textures\ShadowBlob.blp`, a 32×32 grayscale radial blob under a binary alpha
|
||
//! disc. The reference adds a 64×8 trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`)
|
||
//! whose combine is untraced; here the ramp is a vertical vertex-alpha fade.
|
||
//! - Box (`0x711a20`, `0x6d7920`): the `playableAnimationLookup[0]` sequence's box (Stand, fixed
|
||
//! per model, not the playing sequence), clamped into ±5 per axis pre-scale (a cap, never a
|
||
//! floor), scaled, yaw-rotated and axis-aligned-bounded. Vertically `+(h/2)` up and
|
||
//! `-(5/3)(h/2)` down about the origin. A zero horizontal box draws nothing.
|
||
//! - Appearance: multiply (`GL_DST_COLOR/GL_ZERO`, `EffectBlend::Multiply`), white vertices with
|
||
//! alpha = the model's base alpha `CM2Model+0x180` (read at `0x6d7fd6`). That carries the
|
||
//! appear and despawn fades, the first-person fade and the stealth or ghost aura fade
|
||
//! (`0x60d180` drives the same `0x614f80`), but not the M2 colour and texture-weight tracks
|
||
//! (`0x707aea`), exactly like `UnitRenderAlpha`. Unlit, no fog, no depth write; the texture is
|
||
//! `Rgba8Unorm`, so the multiply runs on raw bytes as the reference's does.
|
||
//! - Registration: units, players and corpses cast one; game objects and dynamic objects never do
|
||
//! (`0x670e94`, `0x613e10`). The reference's `shadowLOD` toggle is not built: always on.
|
||
//! - Deviation: the shadow is gated on `InheritedVisibility`, though the reference draws one under
|
||
//! an undrawn body (`0x48161d`), because benilla's exterior-scene election hides units the
|
||
//! reference never loads, and their shadows would stay on the floor below.
|
||
//!
|
||
//! Each shadow's triangles are rebuilt only when [`ShadowKey`] moves and copied onto the effect
|
||
//! stream every shown frame.
|
||
|
||
use benilla_assets::ModelAnimations;
|
||
use benilla_protocol::EntityKind;
|
||
use bevy::ecs::entity::EntityHashSet;
|
||
use bevy::prelude::*;
|
||
|
||
use crate::creature_anim::AnimData;
|
||
use crate::net::{Embodied, NetEntity};
|
||
use benilla_world::decal::{DecalFrame, WorldDecal};
|
||
// MONKEY (moon shadows): the oval now yields to a NIGHT cast as well as a day one, so it needs the
|
||
// same two numbers the packer weights that cast with.
|
||
use crate::character_shadow::CharacterShadowReady;
|
||
use crate::shadow_core::{ShadowFrame, ShadowSet};
|
||
use benilla_world::lighting::ShadowHandover;
|
||
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
|
||
use benilla_world::view::WorldCamera;
|
||
use benilla_world::wmo_portal::UnitWmoRoom;
|
||
|
||
/// The reference's shadow disc, created by `0x6d8070`.
|
||
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
|
||
/// Each box corner component is clamped into ±5 yd pre-scale (`0x6992c0`, `0x699250`).
|
||
const BOX_CLAMP: f32 = 5.0;
|
||
/// The reference's degenerate-box epsilon (`[0x8029d4]`).
|
||
const DEGENERATE_EPS: f32 = 2.384e-7;
|
||
/// MONKEY (night blob): the realtime lane's edge band — mirrors `SHADOW_EDGE_BAND` in
|
||
/// `shadow_hook.wgsl`, where the realtime shadow lightens over the last yards of the cascade.
|
||
/// The oval fades back IN across the same band, so the handover is a crossfade at both ends.
|
||
const SHADOW_EDGE_BAND: f32 = 14.0;
|
||
|
||
/// One unit's shadow record, a top-level entity despawned with its owner.
|
||
#[derive(Component)]
|
||
struct BlobShadow {
|
||
owner: Entity,
|
||
}
|
||
|
||
/// Last frame's rebuild inputs. `surfaces` counts the decal receivers, so a tile streaming in
|
||
/// under a standing unit still re-arms the rebuild.
|
||
#[derive(Component, Default)]
|
||
struct ShadowKey {
|
||
feet: Vec3,
|
||
rotation: Quat,
|
||
box_min: Vec3,
|
||
box_max: Vec3,
|
||
alpha: f32,
|
||
surfaces: usize,
|
||
shown: bool,
|
||
}
|
||
|
||
/// The cached world-space triangles; empty when hidden.
|
||
#[derive(Component, Default)]
|
||
struct ShadowVerts(Vec<EffectVertex>);
|
||
|
||
/// The shadow texture, kept so the census can report its load state: a missing texture
|
||
/// silently withholds every draw.
|
||
#[derive(Resource)]
|
||
struct ShadowAssets {
|
||
texture: Handle<Image>,
|
||
}
|
||
|
||
pub(crate) struct BlobShadowPlugin;
|
||
|
||
impl Plugin for BlobShadowPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.add_systems(Startup, setup_shadow_assets)
|
||
.add_systems(
|
||
PostUpdate,
|
||
(sync_shadows, update_shadows)
|
||
.chain()
|
||
// MONKEY (moon shadows): after ALL Update work (resolve + strength bridge)
|
||
// and this frame's rig verdict. Push only after the blob has consumed it.
|
||
.after(ShadowSet::Rig)
|
||
.after(bevy::transform::TransformSystems::Propagate)
|
||
.before(push_shadows),
|
||
)
|
||
// The stream push: after both the frame's stream clear and the blob cache rebuild.
|
||
.add_systems(PostUpdate, push_shadows.after(begin_effect_frame));
|
||
}
|
||
}
|
||
|
||
/// Load the shadow disc once.
|
||
fn setup_shadow_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||
let texture = asset_server.load::<Image>(SHADOW_TEXTURE);
|
||
commands.insert_resource(ShadowAssets { texture });
|
||
}
|
||
|
||
/// Keep one shadow record per player or unit whose animated model has built; despawn the ones
|
||
/// whose owner went. Corpses, which the reference also shadows (module docs), get none here.
|
||
#[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline
|
||
fn sync_shadows(
|
||
mut commands: Commands,
|
||
// A mount child's `Transform` is parent-relative, so it casts none; the rider's shadow reads
|
||
// the mount's box instead.
|
||
units: Query<
|
||
(Entity, &NetEntity),
|
||
(
|
||
With<ModelAnimations>,
|
||
Without<crate::entities::mount::MountBody>,
|
||
),
|
||
>,
|
||
shadows: Query<(Entity, &BlobShadow)>,
|
||
// The answer changes only when a model or a mount arrives or leaves; any other frame is
|
||
// skipped.
|
||
grew: Query<(), Added<ModelAnimations>>,
|
||
mut shrank: RemovedComponents<ModelAnimations>,
|
||
mounted: Query<(), Added<crate::entities::mount::MountBody>>,
|
||
mut unmounted: RemovedComponents<crate::entities::mount::MountBody>,
|
||
) {
|
||
if grew.is_empty()
|
||
&& mounted.is_empty()
|
||
&& shrank.read().next().is_none()
|
||
&& unmounted.read().next().is_none()
|
||
{
|
||
return;
|
||
}
|
||
let mut shadowed = EntityHashSet::default();
|
||
for (entity, shadow) in &shadows {
|
||
if units.get(shadow.owner).is_err() {
|
||
commands.entity(entity).despawn();
|
||
} else {
|
||
shadowed.insert(shadow.owner);
|
||
}
|
||
}
|
||
for (owner, net) in &units {
|
||
if !matches!(net.kind, EntityKind::Player | EntityKind::Unit) || shadowed.contains(&owner) {
|
||
continue;
|
||
}
|
||
commands.spawn((
|
||
BlobShadow { owner },
|
||
ShadowKey::default(),
|
||
ShadowVerts::default(),
|
||
));
|
||
}
|
||
}
|
||
|
||
/// 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::type_complexity)]
|
||
fn update_shadows(
|
||
video: Res<crate::video::VideoConfig>,
|
||
time: Res<Time>,
|
||
catalog: Option<Res<AnimData>>,
|
||
shadow_assets: Option<Res<ShadowAssets>>,
|
||
images: Res<Assets<Image>>,
|
||
decals: WorldDecal,
|
||
// MONKEY (moon shadows): SAME acknowledged weight as the packed receivers, plus actual
|
||
// per-unit caster readiness. The cvar alone cannot promise a silhouette exists.
|
||
handover: Res<ShadowHandover>,
|
||
// Missing core/character plugins are a valid no-silhouette state: keep the oval there too.
|
||
shadow_frame: Option<Res<ShadowFrame>>,
|
||
ready: Option<Res<CharacterShadowReady>>,
|
||
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
|
||
cam_pos: Query<&GlobalTransform, With<WorldCamera>>,
|
||
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
|
||
owners: Query<
|
||
(
|
||
&Transform,
|
||
&ModelAnimations,
|
||
Has<Embodied>,
|
||
Option<&crate::entities::mount::MountChild>,
|
||
// The exterior-scene election's verdict on the root, after propagation.
|
||
Option<&InheritedVisibility>,
|
||
// …and the unit's WMO room. `Some(room)` = indoors — the realtime SUN shadow is gated
|
||
// out of interiors, so an INDOOR unit keeps its oval even when `characterShadows` is on
|
||
// (otherwise it would have no ground shadow at all). Absent / no-room = outdoors.
|
||
Option<&UnitWmoRoom>,
|
||
),
|
||
Without<BlobShadow>,
|
||
>,
|
||
// Mounted, the shadow reads the mount's Stand box at the mount's scale; which box the
|
||
// reference uses for a mounted unit is untraced.
|
||
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
|
||
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
|
||
// A once-a-second census of shadows and why the hidden ones hid, at
|
||
// `RUST_LOG=benilla_app::blob_shadow=debug` (a `benilla::` filter matches nothing).
|
||
mut census_at: Local<f32>,
|
||
) {
|
||
// MONKEY (moon shadows): a time jump or pending aim must restore the oval in the SAME
|
||
// frame that the receiver goes fully lit. Do not independently reconstruct the clock weight.
|
||
let lane_strength = handover.weight.abs();
|
||
// No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward
|
||
// "has a shadow" is the safe direction for a lane whose bug is a missing shadow.
|
||
let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation);
|
||
let now = time.elapsed_secs();
|
||
let census = now >= *census_at;
|
||
if census {
|
||
*census_at = now + 1.0;
|
||
}
|
||
let (mut n_total, mut n_shown, mut n_no_owner, mut n_no_clip, mut n_degen, mut n_no_ground) =
|
||
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
|
||
// Apart from `n_no_owner`: an undrawn body still has an owner.
|
||
let mut n_undrawn = 0u32;
|
||
let surface_count = decals.receiver_count();
|
||
for (shadow, mut key, mut verts) in &mut shadows {
|
||
n_total += 1;
|
||
let Ok((unit, anims, is_self, mount_child, drawn, room)) = owners.get(shadow.owner) else {
|
||
// `sync_shadows` despawns it next frame.
|
||
hide(&mut key, &mut verts);
|
||
n_no_owner += 1;
|
||
continue;
|
||
};
|
||
// `characterShadows`: the realtime cast owns OUTDOOR units, so the oval yields to it there
|
||
// (no double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it at full strength —
|
||
// the realtime sun shadow is gated out of interiors, so without this an indoor unit has no
|
||
// ground shadow at all. Absent room component / no-room claim = outdoors.
|
||
//
|
||
// MONKEY (night blob): "yields to it" is now proportional to how much the realtime lane
|
||
// actually casts for THIS unit — full oval at night and past the cascade edge, none in
|
||
// broad daylight up close, a crossfade between. Below a colour step of oval there is
|
||
// nothing left to draw, so that stays the early-out the hard hide used to be.
|
||
let indoors = room.is_some_and(|r| r.room().is_some());
|
||
let mut lane_w = 1.0;
|
||
if video.character_shadows
|
||
&& !indoors
|
||
&& shadow_frame
|
||
.as_ref()
|
||
.is_some_and(|frame| frame.active && !frame.suspended)
|
||
&& ready
|
||
.as_ref()
|
||
.is_some_and(|ready| ready.0.contains(&shadow.owner))
|
||
{
|
||
let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c));
|
||
lane_w = blob_weight(lane_strength, cam_dist, video.shadow_distance);
|
||
if lane_w <= 1.0 / 255.0 {
|
||
hide(&mut key, &mut verts);
|
||
continue;
|
||
}
|
||
}
|
||
// The visibility deviation (module docs): an undrawn owner casts nothing.
|
||
if !drawn.is_none_or(|v| v.get()) {
|
||
hide(&mut key, &mut verts);
|
||
n_undrawn += 1;
|
||
continue;
|
||
}
|
||
// Until the mount's model lands, the rider's own box stands in.
|
||
let (anims, extra_scale) = match mount_child.and_then(|mc| mount_anims.get(mc.0).ok()) {
|
||
Some((mnet, manims)) => (manims, mnet.scale),
|
||
None => (anims, 1.0),
|
||
};
|
||
// The box is `playableAnimationLookup[0]`'s sequence, Stand, fixed per model; `resolve(0)`
|
||
// walks the same table, so a Stand-less model gets the reference's substitute.
|
||
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 verts);
|
||
n_no_clip += 1;
|
||
continue;
|
||
};
|
||
// Clamp into ±5 pre-scale, then scale.
|
||
let s = (unit.scale.x * extra_scale).max(0.0);
|
||
let bmin = clip
|
||
.bounds_min
|
||
.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP))
|
||
* s;
|
||
let bmax = clip
|
||
.bounds_max
|
||
.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP))
|
||
* 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 verts);
|
||
n_degen += 1;
|
||
continue;
|
||
}
|
||
// The unit's render alpha from the root, which also tells a pending appear fade (zero)
|
||
// from a settled unit, and carries the first-person fade.
|
||
let mut alpha = unit_alpha.get(shadow.owner);
|
||
// MONKEY (night blob): the realtime-lane handover rides the SAME alpha the spawn/despawn
|
||
// fades do — the oval is a Multiply decal, so scaling its alpha scales its darkening, and
|
||
// `ShadowKey::alpha` already re-arms the projection when it moves by a colour step. `1.0`
|
||
// whenever the realtime lane is off, indoors, or contributing nothing, so the shipped
|
||
// default path is untouched.
|
||
alpha *= lane_w;
|
||
if alpha <= 0.0 {
|
||
hide(&mut key, &mut verts);
|
||
continue;
|
||
}
|
||
let next = ShadowKey {
|
||
feet: unit.translation,
|
||
rotation: unit.rotation,
|
||
box_min: bmin,
|
||
box_max: bmax,
|
||
alpha,
|
||
surfaces: surface_count,
|
||
shown: true,
|
||
};
|
||
if key.shown && !key_changed(&key, &next) {
|
||
n_shown += 1;
|
||
continue;
|
||
}
|
||
// The four horizontal corners through the rotation with the vertical term zeroed, as
|
||
// the reference's build does, then axis-aligned-bounded.
|
||
let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
|
||
let (mut min_z, mut max_z) = (f32::MAX, f32::MIN);
|
||
for (x, z) in [
|
||
(bmin.x, bmin.z),
|
||
(bmin.x, bmax.z),
|
||
(bmax.x, bmin.z),
|
||
(bmax.x, bmax.z),
|
||
] {
|
||
let w = unit.rotation * Vec3::new(x, 0.0, z);
|
||
(min_x, max_x) = (min_x.min(w.x), max_x.max(w.x));
|
||
(min_z, max_z) = (min_z.min(w.z), max_z.max(w.z));
|
||
}
|
||
// Vertically `+(h/2)` up, `-(5/3)(h/2)` down (`[0xcea60c]`, `[0xcea610]`).
|
||
let half_v = (bmax.y - bmin.y) * 0.5;
|
||
let frame = DecalFrame {
|
||
center: unit.translation,
|
||
sin: 0.0,
|
||
cos: 1.0,
|
||
min_x,
|
||
max_x,
|
||
min_z,
|
||
max_z,
|
||
min_y: -half_v * (5.0 / 3.0),
|
||
max_y: half_v,
|
||
};
|
||
let span_v = frame.max_y - frame.min_y;
|
||
verts.0.clear();
|
||
let projected = span_v > 0.0
|
||
&& decals.project(
|
||
&mut verts.0,
|
||
&frame,
|
||
|p| {
|
||
// The reference's second-stage ramp (`0x6d81a0`), run vertically: inferred
|
||
// from the asymmetric vertical reach, its combine being untraced.
|
||
alpha * shadow_ramp((p.y - frame.min_y) / span_v)
|
||
},
|
||
|x, z| frame.rect_uv(x, z),
|
||
);
|
||
*key = next;
|
||
key.shown = projected;
|
||
if projected {
|
||
n_shown += 1;
|
||
} else {
|
||
verts.0.clear();
|
||
n_no_ground += 1;
|
||
}
|
||
if census && is_self {
|
||
// Where the own shadow's projection went: vertex y against feet y tells the surface
|
||
// stood on from a receiver below.
|
||
let (mut y_min, mut y_max) = (f32::MAX, f32::MIN);
|
||
let (mut x_min, mut x_max) = (f32::MAX, f32::MIN);
|
||
let (mut z_min, mut z_max) = (f32::MAX, f32::MIN);
|
||
for v in &verts.0 {
|
||
y_min = y_min.min(v.pos[1]);
|
||
y_max = y_max.max(v.pos[1]);
|
||
x_min = x_min.min(v.pos[0]);
|
||
x_max = x_max.max(v.pos[0]);
|
||
z_min = z_min.min(v.pos[2]);
|
||
z_max = z_max.max(v.pos[2]);
|
||
}
|
||
// Emitted XZ extents against the frame rect: a mismatch indicts the projector.
|
||
debug!(
|
||
"self shadow world span: x [{:.3}, {:.3}] ({:.3}), z [{:.3}, {:.3}] ({:.3}); \
|
||
feet ({:.3}, {:.3}), yaw {:.1} deg",
|
||
x_min,
|
||
x_max,
|
||
x_max - x_min,
|
||
z_min,
|
||
z_max,
|
||
z_max - z_min,
|
||
unit.translation.x,
|
||
unit.translation.z,
|
||
unit.rotation
|
||
.to_euler(bevy::math::EulerRot::YXZ)
|
||
.0
|
||
.to_degrees(),
|
||
);
|
||
debug!(
|
||
"self shadow: feet y {:.3}, box y [{:.3}, {:.3}], {} verts, vert y [{:.3}, \
|
||
{:.3}]",
|
||
unit.translation.y,
|
||
unit.translation.y + frame.min_y,
|
||
unit.translation.y + frame.max_y,
|
||
verts.0.len(),
|
||
y_min,
|
||
y_max
|
||
);
|
||
// Per-vertex uvs: a span check cannot see a degenerate mapping.
|
||
let uvs: Vec<String> = verts
|
||
.0
|
||
.iter()
|
||
.map(|v| format!("({:.3},{:.3})", v.uv[0], v.uv[1]))
|
||
.collect();
|
||
debug!("self shadow uvs: {}", uvs.join(" "));
|
||
// On the reference, HumanFemale's footprint is 0.77x0.74 yd, nearly centred.
|
||
debug!(
|
||
"self shadow box: bmin {:?} bmax {:?} rect x [{:.3}, {:.3}] z [{:.3}, {:.3}] \
|
||
(extent {:.3}x{:.3}, centre offset ({:.3}, {:.3}))",
|
||
bmin,
|
||
bmax,
|
||
min_x,
|
||
max_x,
|
||
min_z,
|
||
max_z,
|
||
max_x - min_x,
|
||
max_z - min_z,
|
||
(min_x + max_x) * 0.5,
|
||
(min_z + max_z) * 0.5,
|
||
);
|
||
}
|
||
}
|
||
if census && n_total > 0 {
|
||
// The texture's content, not just its load: a white-decoded blob multiplies to nothing.
|
||
let tex = shadow_assets.map_or("no-resource".into(), |a| {
|
||
images.get(&a.texture).map_or("MISSING".into(), |img| {
|
||
let (w, h) = (img.width(), img.height());
|
||
let center = img
|
||
.data
|
||
.as_ref()
|
||
.and_then(|d| {
|
||
let i = ((h / 2) * w + w / 2) as usize * 4;
|
||
d.get(i..i + 4).map(|p| format!("{p:?}"))
|
||
})
|
||
.unwrap_or_else(|| "no-data".into());
|
||
// The whole centre row: how much of the box the disc visibly fills.
|
||
if let Some(d) = img.data.as_ref() {
|
||
let row: Vec<String> = (0..w as usize)
|
||
.map(|x| {
|
||
let i = ((h / 2) as usize * w as usize + x) * 4;
|
||
d.get(i..i + 4)
|
||
.map(|p| format!("{}/{}", p[0], p[3]))
|
||
.unwrap_or_default()
|
||
})
|
||
.collect();
|
||
debug!("blob row {}: {}", h / 2, row.join(" "));
|
||
}
|
||
format!("loaded {w}x{h} center {center} id {:?}", a.texture.id())
|
||
})
|
||
});
|
||
debug!(
|
||
"blob shadows: {n_total} ({n_shown} shown, {n_no_owner} ownerless, {n_undrawn} \
|
||
undrawn, {n_no_clip} no-clip, {n_degen} degenerate, {n_no_ground} no-ground; \
|
||
{surface_count} surfaces; texture {tex})"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Push every shown shadow 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 draw: benilla_world::particles::buffer::WorldEffectDraw,
|
||
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;
|
||
}
|
||
// Unlit: the scene light is already in the ground it multiplies.
|
||
let mut batch = draw
|
||
.batch(cam, assets.texture.id())
|
||
.multiply()
|
||
.anchored(key.feet)
|
||
.rung(
|
||
benilla_world::sky_order::Rung::SHADOW_SORT,
|
||
benilla_world::sky_order::Rung::DECAL_RASTER,
|
||
)
|
||
.owner(entity);
|
||
batch.vertices(&verts.0);
|
||
batch.tris();
|
||
}
|
||
}
|
||
|
||
/// Clear the record so the next eligible frame rebuilds.
|
||
fn hide(key: &mut ShadowKey, verts: &mut ShadowVerts) {
|
||
key.shown = false;
|
||
verts.0.clear();
|
||
}
|
||
|
||
/// Whether an input moved beyond noise: a millimetre, ~0.05°, under one colour step.
|
||
fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
|
||
const POS_EPS: f32 = 1e-3;
|
||
a.feet.distance_squared(b.feet) > POS_EPS * POS_EPS
|
||
|| a.rotation.angle_between(b.rotation) > 1e-3
|
||
|| (a.box_min - b.box_min).abs().max_element() > POS_EPS
|
||
|| (a.box_max - b.box_max).abs().max_element() > POS_EPS
|
||
|| (a.alpha - b.alpha).abs() > 1.0 / 255.0
|
||
|| a.surfaces != b.surfaces
|
||
}
|
||
|
||
/// MONKEY (night blob): the realtime sun lane's DAY strength — 0 at or below the horizon, 1 by
|
||
/// ~12° of elevation, smoothstepped. A three-line mirror of `sun_shadow_strength` in
|
||
/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the
|
||
/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane`
|
||
/// below — if the curve is ever tuned, tune both or the handover gains a seam.
|
||
#[cfg(test)]
|
||
fn sun_shadow_strength(sun_height: f32) -> f32 {
|
||
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
|
||
t * t * (3.0 - 2.0 * t)
|
||
}
|
||
|
||
/// MONKEY (night blob): how much OVAL a unit still wears while `characterShadows` owns it.
|
||
///
|
||
/// `shadow_hook.wgsl::realtime_shadow` returns `1 − (1 − shadow) · night · (1 − edge_fade)`, so
|
||
/// `night · (1 − edge_fade)` is precisely the fraction of a realtime cast that survives to the
|
||
/// screen. The oval takes the COMPLEMENT of that: full where the realtime lane lands nothing (all
|
||
/// night; past the cascade's last [`SHADOW_EDGE_BAND`] yards), zero where it lands everything
|
||
/// (daylight, close in), a continuous crossfade through dusk and dawn and across the edge band —
|
||
/// so neither shadow pops in or out.
|
||
fn blob_weight(sun_strength: f32, cam_dist: f32, shadow_range: f32) -> f32 {
|
||
let t = ((cam_dist - (shadow_range - SHADOW_EDGE_BAND)) / SHADOW_EDGE_BAND).clamp(0.0, 1.0);
|
||
let edge_fade = t * t * (3.0 - 2.0 * t);
|
||
(1.0 - sun_strength.clamp(0.0, 1.0) * (1.0 - edge_fade)).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`): over `x = 12u`, `x/2` below 2,
|
||
/// 1 through 10, `(12-x)/2` after.
|
||
fn shadow_ramp(u: f32) -> f32 {
|
||
let x = 12.0 * u.clamp(0.0, 1.0);
|
||
if x < 2.0 {
|
||
0.5 * x
|
||
} else if x < 10.0 {
|
||
1.0
|
||
} else {
|
||
(0.5 * (12.0 - x)).max(0.0)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Rise to 1 at u=1/6, flat through u=5/6, fall to 0 at u=1.
|
||
#[test]
|
||
fn ramp_matches_reference_trapezoid() {
|
||
assert_eq!(shadow_ramp(0.0), 0.0);
|
||
assert!((shadow_ramp(1.0 / 12.0) - 0.5).abs() < 1e-6); // x=1 → 0.5
|
||
assert!((shadow_ramp(1.0 / 6.0) - 1.0).abs() < 1e-6); // x=2 → 1.0
|
||
assert_eq!(shadow_ramp(0.5), 1.0);
|
||
assert!((shadow_ramp(5.0 / 6.0) - 1.0).abs() < 1e-6); // x=10 → 1.0
|
||
assert!((shadow_ramp(11.0 / 12.0) - 0.5).abs() < 1e-6); // x=11 → 0.5
|
||
assert_eq!(shadow_ramp(1.0), 0.0);
|
||
// Out-of-range clamps, never negative.
|
||
assert_eq!(shadow_ramp(-1.0), 0.0);
|
||
assert_eq!(shadow_ramp(2.0), 0.0);
|
||
}
|
||
|
||
/// MONKEY (night blob): the oval is the exact complement of what the realtime lane casts —
|
||
/// gone in daylight up close (the old hide), FULL at night (the bug: the sun lane's `night`
|
||
/// scalar is 0 under the horizon, so nothing was casting and nothing was drawing either), and
|
||
/// full again past the cascade's edge band. Monotone in between, so no pop at dusk or dawn.
|
||
#[test]
|
||
fn oval_is_the_complement_of_the_realtime_lane() {
|
||
// Sun down (and the whole night): the realtime lane casts nothing → the full oval.
|
||
assert_eq!(sun_shadow_strength(-0.3), 0.0);
|
||
assert_eq!(blob_weight(sun_shadow_strength(-0.3), 10.0, 70.0), 1.0);
|
||
// High sun, well inside the cascade: the realtime cast owns it → no oval.
|
||
assert_eq!(sun_shadow_strength(0.9), 1.0);
|
||
assert_eq!(blob_weight(sun_shadow_strength(0.9), 10.0, 70.0), 0.0);
|
||
// Same high sun, but past the cascade edge: the realtime cast has faded → the oval is back.
|
||
assert_eq!(blob_weight(sun_shadow_strength(0.9), 80.0, 70.0), 1.0);
|
||
// Dusk sweeps the weight monotonically from 0 up to 1 with no jump.
|
||
let mut prev = 0.0;
|
||
for step in 0..=20 {
|
||
let height = 0.25 - 0.25 * step as f32 / 20.0; // ~14° down to the horizon
|
||
let w = blob_weight(sun_shadow_strength(height), 10.0, 70.0);
|
||
assert!(
|
||
w >= prev - 1e-6,
|
||
"dusk weight went backwards at step {step}"
|
||
);
|
||
assert!(w - prev < 0.35, "dusk weight jumped at step {step}");
|
||
prev = w;
|
||
}
|
||
assert!((prev - 1.0).abs() < 1e-6);
|
||
}
|
||
|
||
/// MONKEY (moon shadows): the oval yields to the NIGHT cast by exactly what that cast lands.
|
||
///
|
||
/// The bug this forbids is the one the feature creates if the blob is left alone: a unit at
|
||
/// midnight now HAS a real silhouette shadow, and an unfaded oval under it is a second,
|
||
/// differently-shaped shadow in the same place. The complement law already handles that — what
|
||
/// has to hold is that the moon's weight enters it the same way the sun's does, and that
|
||
/// `moonShadowStrength 0` puts the full oval back, bit for bit.
|
||
///
|
||
/// The sum is exact rather than approximate because the two weights are mutually exclusive
|
||
/// (`benilla_world::lighting::moon_shadow_weight`, swept over the whole game day by its own
|
||
/// test), so `sun + moon` IS the realtime lane's total strength at every instant.
|
||
#[test]
|
||
fn the_oval_yields_to_a_moon_cast_by_exactly_its_strength() {
|
||
// Midnight, close in: the sun casts nothing, the moon casts at the shipped 0.35.
|
||
let night_sun = sun_shadow_strength(-0.3);
|
||
assert_eq!(night_sun, 0.0);
|
||
let full = blob_weight(night_sun, 10.0, 70.0);
|
||
assert_eq!(
|
||
full, 1.0,
|
||
"with the feature off the whole oval is still there"
|
||
);
|
||
let under_moon = blob_weight(night_sun + 0.35, 10.0, 70.0);
|
||
assert!(
|
||
(under_moon - 0.65).abs() < 1e-6,
|
||
"a 0.35 moon cast leaves 0.65 of the oval, not {under_moon}"
|
||
);
|
||
// …and the two together are the constant the day arm is: whatever the realtime lane lands,
|
||
// the oval lands the rest.
|
||
assert!((under_moon + 0.35 - 1.0).abs() < 1e-6);
|
||
// Past the cascade edge the moon cast resolves nothing, so the full oval returns even
|
||
// though the moon is up — the same edge behaviour the sun arm has.
|
||
assert_eq!(blob_weight(night_sun + 0.35, 80.0, 70.0), 1.0);
|
||
// The moonrise ramp reaches the oval monotonically: no pop as the weight lifts off zero.
|
||
let mut prev = 1.0;
|
||
for step in 0..=20 {
|
||
let w = blob_weight(night_sun + 0.35 * step as f32 / 20.0, 10.0, 70.0);
|
||
assert!(
|
||
w <= prev + 1e-6,
|
||
"the oval brightened as the moon rose, at step {step}"
|
||
);
|
||
prev = w;
|
||
}
|
||
assert!((prev - 0.65).abs() < 1e-6);
|
||
}
|
||
|
||
/// The ±5 clamp is a pre-scale cap, not a floor.
|
||
#[test]
|
||
fn box_clamp_caps_pre_scale() {
|
||
let raw = Vec3::new(-7.0, 0.0, 3.0);
|
||
let clamped = raw.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP));
|
||
assert_eq!(clamped, Vec3::new(-5.0, 0.0, 3.0));
|
||
// A scale-2 unit's clamped box still doubles.
|
||
assert_eq!(clamped * 2.0, Vec3::new(-10.0, 0.0, 6.0));
|
||
}
|
||
}
|