From e254e6dce8a5f26a57aca864c58e1bfa167404d5 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Mon, 7 Sep 2026 20:12:47 +0100
Subject: [PATCH] MONKEY: dynamic WMO interiors + cube-map torch shadows
(surfaces + entities)
Interior rooms light per fragment from their authored fixtures instead of
the MOCV bake (direct + fill + ambient, soft rolloff; live cvars
interiorLight/interiorAmbient/interiorFill/interiorExposure/interiorShadows/
interiorDebug), TRANS/EXT batches blend to the day/night reference at the
portal seam, and fixtures admit camera-independently (90 yd).
Torch shadows are a from-scratch depth-map renderer (Bevy point shadows are
dead under ClusterConfig::None): a shared Depth32Float 512^2 x 24-layer Image
+ depth-only pipeline + ViewNode rendering the static caster and a per-frame
entity caster into 6 cube faces for the nearest <=4 fixtures (reverse-Z,
GreaterEqual compare, 4-tap PCF, far fade). WMO surfaces receive through
static_gx group 3; entities receive on the wow_model path through
WowModelExt bindings 91/92/93 (depth array, comparison sampler, 1616-byte
torch table) with a 0.15 yd normal offset against CPU-vs-GPU skinning
self-shadow.
The entity lane is gated on is_interior OR the new MATTE_INDOOR tag bit
(exterior payload bit 14, written by the classifier's Matte law), so a unit
whose verdict flickers Bake<->Matte at one spot keeps its room lighting.
Co-Authored-By: Claude Fable 5.1
---
crates/benilla-app/src/cvars.rs | 51 +-
crates/benilla-app/src/dynamic_interior.rs | 37 ++
crates/benilla-app/src/entities/wmo_props.rs | 6 +-
crates/benilla-app/src/lib.rs | 5 +
crates/benilla-app/src/pipe_warm/menagerie.rs | 11 +-
crates/benilla-app/src/pipe_warm/mod.rs | 6 +
crates/benilla-app/src/torch_shadow.rs | 309 +++++++---
crates/benilla-app/src/video.rs | 21 +
crates/benilla-assets/src/materials.rs | 37 ++
.../src/shaders/shadow_hook.wgsl | 110 +++-
.../benilla-assets/src/shaders/wow_model.wgsl | 151 +++++
crates/benilla-assets/src/world_assets/mod.rs | 15 +-
crates/benilla-world/src/assets/mod.rs | 22 +-
crates/benilla-world/src/interior.rs | 6 +-
.../src/lighting/global_light.rs | 95 ++-
crates/benilla-world/src/lighting/mod.rs | 3 +-
crates/benilla-world/src/mesh_tag.rs | 37 +-
crates/benilla-world/src/model_render.rs | 10 +-
.../benilla-world/src/model_render/batch.rs | 53 +-
crates/benilla-world/src/particles/model.rs | 8 +
crates/benilla-world/src/shaders.rs | 7 +-
.../benilla-world/src/shaders/static_gx.wgsl | 206 ++++++-
.../src/shaders/torch_depth.wgsl | 16 +
crates/benilla-world/src/static_gx/mod.rs | 9 +
crates/benilla-world/src/static_gx/render.rs | 88 +++
.../src/static_gx/torch_depth.rs | 569 ++++++++++++++++++
.../src/terrain_stream/spawn/assemble.rs | 6 +-
.../src/terrain_stream/spawn/mod.rs | 13 +-
28 files changed, 1774 insertions(+), 133 deletions(-)
create mode 100644 crates/benilla-app/src/dynamic_interior.rs
create mode 100644 crates/benilla-world/src/shaders/torch_depth.wgsl
create mode 100644 crates/benilla-world/src/static_gx/torch_depth.rs
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 076154a8..bf3d08e1 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -600,6 +600,41 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1900: benilla's own realtime-shadow render-distance slider; the reference bakes MCSH and \
has no cascade to size",
),
+ // MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
+ // instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
+ // bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
+ // `/script SetCVar("interiorExposure", 2)` — which is how their defaults were found.
+ ours(
+ "interiorLight",
+ "1",
+ "benilla's own: fixture-lit WMO interiors (0 = the reference's baked interior path)",
+ ),
+ ours(
+ "interiorAmbient",
+ "0.15",
+ "benilla's own: interior base ambient, 0..1",
+ ),
+ ours(
+ "interiorFill",
+ "0.12",
+ "benilla's own: interior per-fixture bounce gain, 0..2",
+ ),
+ ours(
+ "interiorExposure",
+ "2.5",
+ "benilla's own: interior light-budget multiplier before the soft rolloff, 0.25..8",
+ ),
+ ours(
+ "interiorShadows",
+ "1",
+ "benilla's own: interior fixtures cast real shadows (Stage B, the nearest few); needs \
+ interiorLight",
+ ),
+ ours(
+ "interiorDebug",
+ "0",
+ "benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count",
+ ),
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
@@ -1085,6 +1120,14 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
knobs.video.shadow_distance =
v.clamp(*SHADOW_DISTANCE_RANGE.start(), *SHADOW_DISTANCE_RANGE.end());
}
+ // MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
+ // every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
+ "interiorlight" => knobs.video.interior_light = v != 0.0,
+ "interiorambient" => knobs.video.interior_ambient = v.clamp(0.0, 1.0),
+ "interiorfill" => knobs.video.interior_fill = v.clamp(0.0, 2.0),
+ "interiorexposure" => knobs.video.interior_exposure = v.clamp(0.25, 8.0),
+ "interiorshadows" => knobs.video.interior_shadows = v != 0.0,
+ "interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3),
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
// watches the value and pushes it to the window; nothing else reads it.
@@ -1387,7 +1430,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 47] = [
+ let session: [(&str, String); 53] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1436,6 +1479,12 @@ fn sync_cvars(
("worldShadows", flag(video.world_shadows)),
("characterShadows", flag(video.character_shadows)),
("shadowDistance", video.shadow_distance.to_string()),
+ ("interiorLight", flag(video.interior_light)),
+ ("interiorAmbient", video.interior_ambient.to_string()),
+ ("interiorFill", video.interior_fill.to_string()),
+ ("interiorExposure", video.interior_exposure.to_string()),
+ ("interiorShadows", flag(video.interior_shadows)),
+ ("interiorDebug", video.interior_debug.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
new file mode 100644
index 00000000..8d25440e
--- /dev/null
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -0,0 +1,37 @@
+//! MONKEY (dynamic interiors): the app-side half of the fixture-lit WMO interior lane — a
+//! plug-and-play bridge from the `interiorLight` / `interiorAmbient` / `interiorFill` /
+//! `interiorExposure` cvars ([`VideoConfig`]) to benilla-world's [`DynamicInteriors`] resource,
+//! which `global_light` packs for `static_gx.wgsl`'s `interior_room_light` (WMO interior surfaces
+//! AND interior props light from the room's live fixtures instead of the MOCV bake / the baked prop
+//! probe). Independent of the shadow lanes: removing this plugin leaves the resource at its
+//! shipped defaults; `interiorLight 0` restores the faithful baked interior path.
+//!
+//! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without
+//! a rebuild, which is how the defaults were found.
+use benilla_world::lighting::DynamicInteriors;
+use bevy::prelude::*;
+
+use crate::video::VideoConfig;
+
+pub(crate) struct DynamicInteriorPlugin;
+
+impl Plugin for DynamicInteriorPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(Update, bridge);
+ }
+}
+
+/// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks
+/// stay quiet on idle frames.
+fn bridge(video: Res, mut out: ResMut) {
+ let want = DynamicInteriors {
+ enabled: video.interior_light,
+ ambient: video.interior_ambient,
+ fill: video.interior_fill,
+ exposure: video.interior_exposure,
+ debug: video.interior_debug,
+ };
+ if *out != want {
+ *out = want;
+ }
+}
diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs
index ea4e298d..e9e437d5 100644
--- a/crates/benilla-app/src/entities/wmo_props.rs
+++ b/crates/benilla-app/src/entities/wmo_props.rs
@@ -193,10 +193,11 @@ pub(super) fn spawn_wmo_gameobject_props(
time: Res