From b2353797d7857fdef53550685116a8ef44901650 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 17:48:06 +0100
Subject: [PATCH] MONKEY: keep the blob shadow for indoor units under Real
Character Shadows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An indoor unit was left with NO ground shadow when characterShadows was on: the
realtime sun shadow is gated out of WMO interiors (correct — no sun reaches a
sealed room), and the oval blob was hidden for every unit. So indoor characters
floated. Now the blob is hidden only for OUTDOOR units (where the realtime cast
owns them); INDOOR units (UnitWmoRoom::room() = Some) keep the soft oval, so they
stay grounded. Folded the old blanket early-return into a per-unit gate in the
projection loop. Confirmed in-game. Default path (both cvars off) unchanged.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-app/src/blob_shadow.rs | 37 +++++++++++++++------------
1 file changed, 21 insertions(+), 16 deletions(-)
diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs
index c511e27f..5acf0792 100644
--- a/crates/benilla-app/src/blob_shadow.rs
+++ b/crates/benilla-app/src/blob_shadow.rs
@@ -70,6 +70,7 @@ use benilla_world::model_fade::{fade_alpha, RenderFade};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
+use benilla_world::wmo_portal::UnitWmoRoom;
/// 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.
@@ -204,6 +205,10 @@ fn update_shadows(
// 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,
>,
@@ -220,21 +225,12 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local,
) {
- // `characterShadows 1`: the realtime shadow-map path owns UNIT shadows — every record hides so a
- // unit never wears the oval underneath its cast silhouette. (This keys on the CHARACTER lane, not
- // the world lane: the oval is a character's shadow, so `worldShadows` alone must leave it be.)
- // The records themselves stay (spawned by `sync_shadows` as usual) and `hide` drops each cache
- // key, so flipping the cvar back rebuilds every projection on the next frame. With the cvar off —
- // the shipped default — this gate is a single false branch and the lane below is the untouched
- // reference path.
- if video.character_shadows {
- for (_, mut key, mut verts) in &mut shadows {
- if key.shown || !verts.0.is_empty() {
- hide(&mut key, &mut verts);
- }
- }
- return;
- }
+ // `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
+ // gate in the loop below hides the oval for outdoor units so they don't wear both, while INDOOR
+ // units keep it (the realtime sun shadow is gated out of interiors, so an indoor unit would
+ // otherwise have no ground shadow at all). (This keys on the CHARACTER lane, not the world lane:
+ // the oval is a character's shadow, so `worldShadows` alone must leave it be.) With the cvar off
+ // — the shipped default — that gate is a single false branch and this is the reference path.
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
@@ -259,12 +255,21 @@ fn update_shadows(
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)) = owners.get(shadow.owner) else {
+ 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 hide the oval there (no
+ // double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it — 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.
+ let indoors = room.is_some_and(|r| r.room().is_some());
+ if video.character_shadows && !indoors {
+ 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,