Files
benilla-octo/crates/benilla/src/blob_shadow.rs
T
SamandClaude Fable 5 56c62c7591 point-and-click casting: Blizzard lands where you aim
- ground-targeted spells work end to end: press the button, get the targeting cursor, click the ground, and the cast ships on the wire the way the real client sends it
- a reticle sized to the spell's real radius rides the cursor, and on the click the storm model, falling shards and area sound all land at the point
- character screens: a newly created character comes back selected on the list, and the authored layout scales to small windows instead of clipping the bottom controls off
- blob shadows ride their receiver's clip transform and the raster bias that kept them faint is measured and fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 19:40:05 +07:00

560 lines
26 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
//! ([`crate::decal`]), drawn on the shared effect stream (0733).
//!
//! **The byte-verified mechanism** (wow-re `unit-blob-shadow.md`, a §5 cross-check; the "cloud
//! shadow" label on `0x6d7920` was corrected — it IS the unit shadow draw):
//! - **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).
//! - **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 an open RE item (apitrace) — 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; wow-re `27406d9b`, Q3-ORACLE): 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 the trace oracle 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 fade alpha (`[model+0x180]` — spawn/despawn
//! fades + the self first-person fade ride into the shadow); the darkness lives in the texture
//! RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
//! `Rgba8Unorm`, so the modulate multiplies raw bytes in the gamma lane — the reference's own
//! arithmetic (0161).
//! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are
//! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays
//! that role here. No dead/mount/kind test exists on the draw path; **which** objects register
//! for shadows is an open RE item (`HANDOFF(-> object-layer)`) — v1 policy: every Player/Unit
//! entity with a built animated model (GameObjects/doodads excluded).
//!
//! One shadow record per unit, its projected triangles rebuilt only when the inputs move
//! ([`ShadowKey`]) and pushed onto the effect stream every shown frame — an idle unit costs a
//! key compare plus one memcpy of its cached slice. (The stream has no per-draw frustum cull;
//! an off-screen shadow's triangles are vertex-clipped GPU-side — dozens of ~50-vert slices,
//! below any ledger line.)
use avian3d::prelude::Collider;
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::ecs::entity::{EntityHashMap, EntityHashSet};
use bevy::prelude::*;
use crate::collision::GroundDecalSurface;
use crate::creature_anim::AnimData;
use crate::decal::{project_decal, DecalFrame};
use crate::model_fade::{fade_alpha, RenderFade};
use crate::net::{NetEntity, SelfPlayer};
use crate::particles::buffer::{
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex,
};
use crate::player::{CameraControl, WorldCamera};
use crate::schedule::WorldStage;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
/// The shadow's sort-ladder rung: half the ring's `RING_DEPTH_BIAS`, so where both decals stack
/// the ring draws later deterministically. Sort only — the rasterizer margin is
/// [`SHADOW_RASTER_BIAS`], sized independently (B131 split one constant into its two roles).
const SHADOW_SORT_BIAS: f32 = 4096.0;
/// The shadow's rasterizer depth-bias constant — the margin funding the `GreaterEqual` tie
/// against the drawn ground (`DECAL_WORLD_CLIP`, 0781). The reference's mechanism is the same
/// class: `shadowBias` cvar 0.1 → a **constant-only** polygon offset (−102.4 LSBs of its 24-bit
/// buffer; no slope term exists in the binary — wow-re unit-blob-shadow RE, corroborated across
/// every shadow draw of four apitraces). The *size* is ours, not the reference's number: our
/// residual is 0781's — the decal's CPU-baked world verts vs the receiver's GPU-transformed
/// verts diverge by ~1–3 ulps of the world coordinate, which is millimetres at city magnitudes
/// and lands on the depth tie where the receiver is *sloped* (horizontal ulps project onto the
/// tilted normal — the confirmed B131 flicker walk was the Stormwind gate ramp). 4096 absorbed
/// under half the 3-ulp worst case at close zoom and lost the tie while moving; 32768 dominates
/// it ≥2× at every zoom ≥1 yd and stays centimetre-order at 30 yd (no visible punch-through).
/// Sizing pinned by `raised_bias_dominates_the_bake_residual` (particles/render.rs).
pub(crate) const SHADOW_RASTER_BIAS: i32 = 32768;
/// 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;
/// 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(
Update,
(sync_shadows, update_shadows)
.chain()
// After net motion + input: the decal follows this frame's unit transforms.
.after(WorldStage::Input),
)
// The stream push: after the frame's stream clear (the caches were rebuilt in
// `Update`, so this is a pure copy).
.add_systems(PostUpdate, push_shadows.after(begin_effect_frame));
}
}
/// 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 the open RE item; this is the v1 set
/// (see module docs).
#[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline
fn sync_shadows(
mut commands: Commands,
// 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)>,
) {
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::too_many_arguments, clippy::type_complexity)]
fn update_shadows(
time: Res<Time>,
catalog: Option<Res<AnimData>>,
rig: Option<Res<CameraControl>>,
shadow_assets: Option<Res<ShadowAssets>>,
images: Res<Assets<Image>>,
surfaces: Query<&Collider, With<GroundDecalSurface>>,
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
// the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero
// in the steady state).
fades: Query<(Entity, &RenderFade)>,
parents: Query<&ChildOf>,
owners: Query<
(
&Transform,
&ModelAnimations,
Has<SelfPlayer>,
Option<&crate::entities::mount::MountChild>,
),
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 a wow-re shadow-consumer trace pins it.
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::blob_shadow=debug`): how many
// shadows exist and why the hidden ones hid — the first question of any "no shadow under X"
// report, answerable from a log instead of a debugger.
mut census_at: Local<f32>,
) {
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);
let mut root_fade: EntityHashMap<f32> = EntityHashMap::default();
for (part, fade) in &fades {
let alpha = fade_alpha(fade.from, fade.to, (now - fade.started) / fade.duration);
let mut root = part;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let slot = root_fade.entry(root).or_insert(1.0);
*slot = slot.min(alpha);
}
let surface_count = surfaces.iter().count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
let Ok((unit, anims, is_self, mount_child)) = owners.get(shadow.owner) else {
// sync_shadows despawns next frame; keep it cleared meanwhile.
hide(&mut key, &mut verts);
n_no_owner += 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, wow-re 27406d9b): 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;
}
let mut alpha = root_fade.get(&shadow.owner).copied().unwrap_or(1.0);
if is_self {
// The self first-person fade rides the same model-fade slot in the reference.
alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade);
}
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
&& project_decal(
&mut verts.0,
&surfaces,
&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: the oracle says 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_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, fog off (the reference's shadow pass state).
fn push_shadows(
assets: Option<Res<ShadowAssets>>,
cam: Query<Entity, With<WorldCamera>>,
mut quads: ResMut<EffectQuads>,
shadows: Query<(Entity, &ShadowKey, &ShadowVerts)>,
) {
let Some(assets) = assets else { return };
let Ok(cam) = cam.single() else { return };
for (entity, key, verts) in &shadows {
if verts.0.is_empty() {
continue;
}
let start = quads.begin();
quads.verts.extend_from_slice(&verts.0);
quads.commit_tris(
start,
EffectDrawSpec {
cam,
texture: assets.texture.id(),
blend: EffectBlend::Multiply,
fog: EffectFog::Off,
anchor: key.feet,
bias: SHADOW_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
main_entity: entity,
light: None,
},
);
}
}
/// Clear the record and drop the cache key so the next eligible frame rebuilds from scratch.
fn hide(key: &mut ShadowKey, verts: &mut ShadowVerts) {
key.shown = false;
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
}
/// 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);
}
/// 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));
}
}