Files
benilla-octo/crates/benilla-app/src/blob_shadow.rs
T

658 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
}