Files
benilla-octo/crates/benilla-app/src/blob_shadow.rs
T
pkuzicandClaude Opus 5.5 129ea35f5a Merge upstream main (samwhosung/benilla fd386e75) into lighting
71 upstream commits: the repo-wide comment rewrite, the hover highlight as the
scene ambient, M2 Mod/Mod2x batches drawing the bare texel, test and contribution
changes. Conflicts were comment-vs-code in 36 files; where upstream changed only
comments our code was kept, and wow_model.wgsl carries both our lighting lanes
and upstream's new unlit/Mod/Mod2x paths.

Follow-ups folded in:
- our nine Advanced Graphics / water / fog options tests take upstream's
  wow_data_or_skip! guard
- PostUpdate ambiguity ceiling re-measured under upstream's new census: 466,
  with the per-family breakdown in the comment
- LIGHTING.md: new upstream base

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 00:18:30 +01:00

759 lines
39 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 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
//! ([`benilla_world::decal`]), drawn on the shared effect stream (0733).
//!
//! **The mechanism** (`0x6d7920` IS the unit shadow draw):
//! - **Draw path**: a per-frame pass over registered model nodes (`0x683dd0`, list `[0xc7cb10]`)
//! → gate `0x6d78f0` (model streamed, master toggle) → `0x6d7920` → the **same decal chain the
//! selection ring uses** (`0x6d7330 → 0x6d6fa0 → 0x6d7480`), collector flags `0x2f0122` = the
//! ring's `0x200122` **+ the liquid receivers** (a gap here: liquid isn't in the
//! [`GroundDecalSurface`] set yet — the shadow lands on terrain + WMO faces only).
//! - **Frame slot**: PHASE 1, among the opaque drains — `0x6812c5 call 0x683dd0`, fifth of the
//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at
//! `0x48361d`. `0x683dd0` is the **M2 node drain**, and the same loop body ticks each node's
//! object first (`0x48160c call [obj vt+0x38]` → the selection ring) and draws its shadow second
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never the
//! other way round. That second step is **not** a gate: both exits of the tick `0x481540` return
//! 1, so `0x683ea5`'s `je` is dead. What the tick DOES do is write the alpha this draw is about
//! to read, 30 bytes later — which is the whole of §"Appearance" below. So the shadow
//! lands after terrain and WMO and **before** everything else: the footprint decals
//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between*
//! the two M2 transparent passes) and both of those passes. Every transparent in the world
//! paints over it — which is what [`Rung::SHADOW_SORT`](benilla_world::sky_order::Rung) now
//! expresses, and what it did not until B347: at the old positive rung the shadow was the one
//! thing in the scene a river or a portal effect could not attenuate.
//! - **Texture**: `Textures\ShadowBlob.blp` — a 32×32 grayscale radial blob (flat gray-160 core,
//! linear rim to white) under a binary alpha disc. The reference multitextures a procedural 64×8
//! trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`, blend-mode-selected); its combine
//! wiring is open (an apitrace question) — here the ramp is the vertex-alpha vertical fade below.
//! - **Box law** (`0x711a20` + the `0x6d7920` corner build): a sequence CAaBox, clamped INTO ±5
//! per axis (a cap, never a floor), scaled by the world matrix, yaw-rotated with the unit's
//! facing then **axis-aligned-bounded**. Vertical about the model origin: `+1.0·(zExt/2)` up,
//! `−(5/3)·(zExt/2)` down. A degenerate horizontal box is the reference's no-op exit (no
//! shadow). **No** `OBJECT_FIELD_SCALE_X` re-read (the transform scale already carries it), no
//! ring-style `sqrt` compression, no floor. **WHICH sequence — settled at bytes + pixels**
//! (decision 0316): the draw re-reads
//! `playableAnimationLookup[0]` every frame — **slot 0 = Stand for characters, from the file
//! image, so the value never changes** (not the playing sequence: the director's gait-stable
//! observation falsified that first reading, and reference captures confirmed — 1,682 measured
//! 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 the lane's `EffectBlend::Multiply` (`dst × lerp(1, src, α)`).
//! Vertex diffuse is **white** with α = the model's **base** alpha — `CM2Model+0x180`, read
//! through the accessor `0x710ca0` (`fld [ecx+0x180]`) at `0x6d7fd6`, then clamped to `[0,1]`,
//! ×255, `__ftol`, packed `(a<<24)|0x00FFFFFF`. VERIFIED, and it is `+0x180` and not `+0x19c`:
//! over the whole draw every `0x19c` operand is the stack local `[ebp-0x19c]`, and `this` is
//! touched at exactly three `mov ecx,ebx; call` sites (box `0x711a20`, matrix `0x710600`,
//! alpha `0x710ca0`), which makes that list the complete field census.
//!
//! **What rides in, at the bytes** (2026-09-06): the 2 s appear ramp and the despawn
//! ramp (`obj+0xf4`, `0x613b1e`'s `0x7d0`), the self first-person fade, **and the CharProc-14
//! aura transition** — stealth's `0.3`, ghost/invisibility's `0.5`. The aura is not a second
//! channel: `0x60d180` drives the SAME `StartAlphaFade` (`0x614f80`) the appear fade uses, and
//! `0x614a90` writes `model+0x180 = obj+0x100 · obj+0xf4` unconditionally given a model handle.
//! A stealthed unit's blob shadow dims with its body, on the same 1000 ms cubic.
//!
//! **What does not**: the M2 animated colour / texture-weight tracks. The body's per-batch
//! alpha is `+0x19c · M2Color[i].alpha · M2TextureWeight[j].value` (`0x707aea`–`0x707b33`);
//! this draw reads `+0x180` raw, so an asset-authored transparency track fades the body and
//! leaves its shadow at full strength. [`benilla_world::model_fade::UnitRenderAlpha`] is that
//! same product and stops at the same place.
//!
//! 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, and **which** objects
//! register is now settled (2026-09-06):
//! `[node+0x90]` bit `0x400` is `NOT(arg bit1)` (`0x670e94`), and `0x613e10` takes that arg off
//! `OBJECT_FIELD_TYPE` — `0xb` GAMEOBJECT, `2` DYNAMICOBJECT, `0` otherwise. **GameObjects and
//! DynamicObjects never cast a blob shadow; units, players and corpses always do.** Which is
//! the v1 policy this lane already ran (every Player/Unit with a built animated model), now a
//! byte-fact rather than a guess.
//!
//! **One benilla divergence, deliberate.** `0x6d78f0`/`0x6d7920` read no visibility state at
//! all — the body's draw flag `[CM2Model+0x50]` comes from `ShouldRender` at `0x48174e`, which
//! is consumed at `0x48161d`, *after* the tick — so the reference can and does draw a shadow
//! under an undrawn body (a unit whose skin composite is still unbaked, `0x477860`). We gate on
//! `InheritedVisibility` anyway, because benilla's exterior-scene election is not a mechanism
//! the reference has and left Tanaris mobs' shadows on the Caverns of Time floor (decision
//! 1277). Keeping that gate is the 1277 call; it is named here so it reads as a choice.
//!
//! 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 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 benilla_world::lighting::ShadowHandover;
use crate::character_shadow::CharacterShadowReady;
use crate::shadow_core::{ShadowFrame, ShadowSet};
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 (`Textures\ShadowBlob.blp`, created by `0x6d8070`): 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";
/// 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).
const BOX_CLAMP: f32 = 5.0;
/// Degenerate-box epsilon (the reference's `[0x8029d4]` = 2.384e-7): a zero horizontal extent is
/// the no-op exit — no shadow.
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 — 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 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)]
struct ShadowKey {
feet: Vec3,
rotation: Quat,
box_min: Vec3,
box_max: Vec3,
alpha: f32,
surfaces: usize,
shown: bool,
}
/// 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 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 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 still open; 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,
// 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).
units: Query<
(Entity, &NetEntity),
(
With<ModelAnimations>,
Without<crate::entities::mount::MountBody>,
),
>,
shadows: Query<(Entity, &BlobShadow)>,
// The reconciler's inputs move only when a unit's animated model arrives or leaves, or a
// unit becomes or stops being a mount child (the `units` filter's two terms); a frame with
// none of those has the same answer it had last frame, and used to pay a set build plus a
// walk of every unit for it.
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 {
// Owner gone or no longer eligible (model torn down) → the decal goes with it.
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>>,
// The unit's render alpha, asked of the unit — see the alpha block below.
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
owners: Query<
(
&Transform,
&ModelAnimations,
Has<Embodied>,
Option<&crate::entities::mount::MountChild>,
// …and whether the owner is drawn at all. A body the exterior-scene election sent to
// pass 2 is not in the reference's scene, so it casts nothing (decision 1277). The
// election writes the ROOT's `Visibility`; this is that verdict 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>,
>,
// The mounted box source: the composite's one shadow reads the MOUNT's Stand box at the
// mount's rendered scale while a mount model is attached (the mount IS the footprint on the
// ground; the rider's box would undersize it). The mount-vs-body source of the client's own
// shadow box is untraced — this is the named approximation of decision 0441's P2, carried
// until that source is known.
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
// Once-a-second census at debug level (`RUST_LOG=benilla_app::blob_shadow=debug` — the lib
// target is `benilla_app`; a `benilla::` filter silently matches nothing): 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.
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);
// Counted apart from `n_no_owner`: a body the exterior election sent to pass 2 HAS an owner,
// and folding the two would make the census answer "why did it hide" with a lie. This is the
// instrument's whole job (decision 1283).
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 next frame; keep it cleared meanwhile.
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;
}
}
// An owner that is not drawn casts nothing. The director's report from inside Caverns of
// Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris
// mobs overhead, and their shadows carried on being projected onto the cavern floor,
// because this lane keys off the unit's `Transform` and never asked whether the unit was
// in the scene. The census counts it separately, so "why is there a shadow with no
// creature" is answerable from the log rather than from a debugger.
if !drawn.is_none_or(|v| v.get()) {
hide(&mut key, &mut verts);
n_undrawn += 1;
continue;
}
// Mounted: the box and the extra scale column come from the mount child (the
// `mount_anims` doc above); until its model lands, the rider's own box carries the frame.
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 byte+pixel law (0316): the box is playableAnimationLookup[0]'s
// sequence — Stand, permanently (the reference re-reads it per frame from the file image;
// the value can't change). resolve(0) walks the same baked table, so Stand-less models
// land on their substitute exactly like the binary's row-0 fast path.
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;
};
// The box law (see module docs): clamp INTO ±5 pre-scale, scale, yaw-rotate + AA-bound.
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, asked of the unit** — the shadow rides the model's fade slot
// (module docs), so it takes the engine's answer rather than reconstructing one.
//
// It used to gather the live `RenderFade`s off this unit's *part* entities and read "no
// part is fading" as `1.0`. That is true of a settled unit and exactly backwards for the
// state a unit spends its whole arrival in: a streamed unit holds `PendingAppearFade` —
// no `RenderFade` anywhere yet — until the world is actually shown, so its parts are
// deliberately invisible while this walk found nothing to attribute and drew the shadow
// **fully opaque**. A dark oval on the ground under no creature for the length of the
// load (2.9 s on a probe login), snapping to zero the instant the ramp armed, then easing
// back in with the body. `UnitRenderAlpha` asks the root, where `UnitAppearFade` tells
// pending from settled — the distinction a part-side walk cannot make.
//
// The self first-person fade comes with it (the reference rides the same slot), which is
// why there is no `is_self` term here any more.
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;
}
// Horizontal: the model box's 4 rect corners through the unit's rotation with the
// vertical column dead (`rot × (x, 0, z)`, XZ taken — the byte build zeroes the z-terms),
// 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));
}
// Vertical about the model origin: `+1.0·(zExt/2)` up, `−(5/3)·(zExt/2)` down (the byte
// constants `[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 trapezoid ramp over the vertical span (the reference's second texture
// stage, `0x6d81a0`: rise x<2, flat, fall x≥10 over x = 12·u). *Interim
// seat*: that the ramp runs vertically is inferred from the box's asymmetric
// vertical reach; the combine wiring is the flagged apitrace item.
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 {
// The census's self row: where the OWN shadow's projection actually went — the
// "shadow missing under me" report's second question (the first is the gate line
// below). Y-extents vs feet split "landed on the surface I stand on" from "fell
// through to a receiver below" in one read.
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]);
}
// World-XZ extents of what was actually emitted vs the frame rect: if these disagree,
// the projector inflated/displaced the box (the -8844,669 hunt).
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
);
// The -8844,669 hunt's uv probe: a span check alone can't see a DEGENERATE mapping
// (all uv.y at the rim still spans 0..1) — print the actual per-vert uv pairs.
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(" "));
// And the box/rect numbers: measured on the reference client, HumanFemale's
// footprint is 0.77x0.74 yd nearly centred; a bigger or offset rect indicts the box
// math, not the projector.
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 {
// Not just "loaded": the CONTENT. A white-decoded blob multiplies to a no-op — an
// invisible shadow whose every draw-side reading looks healthy (the -8844,669 hunt).
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 -8844,669 hunt: the whole centre ROW, not one texel — the ink radius and
// alpha reach decide 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's cached triangles onto the stream — one Multiply draw per unit at
/// the shadow rung (the pre-water decal band's floor, the module header's frame slot), 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;
}
// Multiply: the modulate decal is its own darkening — the scene light is already in the
// ground it multiplies, which is why the lane's unlit default is right here.
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 and drop the cache key so the next eligible frame rebuilds from scratch.
fn hide(key: &mut ShadowKey, verts: &mut ShadowVerts) {
key.shown = false;
verts.0.clear();
}
/// Did any rebuild input move beyond noise? Position/box at a millimetre, rotation at ~0.05°,
/// alpha at under a 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`, diffed bit-exact in wow-re:
/// `x = 12·u` — rise `x<2 → x/2`, flat `2≤x<10 → 1`, fall `x≥10 → (12−x)/2`, clamped at 0).
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::*;
/// The ramp's byte-verified shape: rise to 1 at x=2 (u=1/6), flat through x=10 (u=5/6), fall
/// to 0 at x=12 (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 box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
#[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 — the cap is pre-scale.
assert_eq!(clamped * 2.0, Vec3::new(-10.0, 0.0, 6.0));
}
}