MONKEY (daylight): terrain as a caster in exterior torch cube maps (torchTerrainShadows, default off)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 02:52:49 +01:00
co-authored by Claude Opus 5.5
parent 6f30b5466b
commit adc9d277e7
7 changed files with 223 additions and 5 deletions
+36 -2
View File
@@ -513,6 +513,7 @@
interiorLight = "Dynamic Interior Lighting",
interiorShadows = "Indoor Torch Shadows",
exteriorShadows = "Outdoor Torch Shadows",
torchTerrainShadows = "Terrain Blocks Torchlight",
torchSoft = "Torch Shadow Softness",
fireLight = "Fire Light Brightness",
fireFlicker = "Fire Flicker",
@@ -578,6 +579,13 @@
"The same for fires out in the open: campfires, braziers and lampposts "
.. "cast real shadows after dark.\n\n"
.. "It does nothing by day and does not require Dynamic Interior Lighting.",
TORCH_TERRAIN_SHADOWS =
"Lets the ground itself take part in outdoor torch shadows: a hill or a "
.. "bank between a fire and the slope behind it blocks the fire's light.
"
.. "Adds the nearby terrain to each outdoor fire's shadow map when it is "
.. "rebuilt; the per-frame cost is small. Needs Outdoor Torch Shadows.",
TORCH_SOFT =
"How soft the edge of a torch shadow is where the object meets the "
.. "ground. Low is hard and crisp; high spreads the edge the way a real "
@@ -637,6 +645,7 @@
interiorLight = "Динамическое освещение помещений",
interiorShadows = "Тени факелов в помещениях",
exteriorShadows = "Тени огней под открытым небом",
torchTerrainShadows = "Рельеф заслоняет огни",
torchSoft = "Мягкость теней от огня",
fireLight = "Яркость огня",
fireFlicker = "Мерцание огня",
@@ -710,6 +719,14 @@
"То же для огней под открытым небом: костры, жаровни и фонари "
.. "отбрасывают настоящие тени после наступления темноты.\n\n"
.. "Днём не действует и не требует динамического освещения помещений.",
TORCH_TERRAIN_SHADOWS =
"Земля сама участвует в тенях от огней снаружи: холм или склон между "
.. "костром и местностью за ним заслоняет свет огня.
"
.. "Добавляет ближний рельеф в карту теней каждого уличного огня при её "
.. "перестройке; затраты на кадр невелики. Требует теней огней под "
.. "открытым небом.",
TORCH_SOFT =
"Насколько мягок край тени от огня там, где предмет касается пола. "
.. "Низкое значение — резкий край, высокое — размытый, как от "
@@ -798,7 +815,9 @@
"RowLightingQuality", "RowWaterQuality", "RowVolumetricFog",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows",
-- MONKEY (daylight: terrain torch casters)
"RowTorchTerrainShadows", "RowTorchSoft",
"RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow",
"RowNightDarkness", "RowInteriorDarkness",
},
@@ -1315,6 +1334,9 @@
local torch = (interior and GetCVar("interiorShadows") ~= "0")
or GetCVar("exteriorShadows") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch)
-- MONKEY (daylight: terrain torch casters): the ground only casts for outdoor fires.
OptionsRow_SetEnabled(getglobal(base .. "RowTorchTerrainShadows"),
GetCVar("exteriorShadows") ~= "0")
return
end
if key == "Controls" then
@@ -2721,11 +2743,23 @@
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<!-- MONKEY (daylight: terrain torch casters) -->
<Frame name="$parentRowTorchTerrainShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "torchTerrainShadows",
BENILLA_ADVGFX.torchTerrainShadows,
"BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowTorchTerrainShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTorchTerrainShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0.5..3, the CVar's own clamp verbatim: it is a
-- SCALE on the PCF tap radius at a contact, so the
+10 -2
View File
@@ -1070,6 +1070,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1",
"benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day",
),
// MONKEY (daylight: terrain torch casters): the ground as a torch caster.
ours(
"torchTerrainShadows",
"0",
"benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's light); needs exteriorShadows",
),
// MONKEY (static torch cache): residency and per-frame work have separate live budgets.
ours(
"interiorShadowCasters",
@@ -2898,7 +2904,7 @@ mod tests {
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
// MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table.
let lighting: [(&str, f32); 33] = [
let lighting: [(&str, f32); 34] = [
("waterQuality", shadows.water_quality as f32),
// MONKEY (volumetric fog): weld registry and renderer defaults.
("volumetricFog", shadows.volumetric_fog as f32),
@@ -2926,6 +2932,8 @@ mod tests {
// MONKEY (torch shadows): the cube-map lane, indoors and out.
("interiorShadows", flag(shadows.interior_shadows)),
("exteriorShadows", flag(shadows.exterior_shadows)),
// MONKEY (daylight: terrain torch casters)
("torchTerrainShadows", flag(shadows.torch_terrain_shadows)),
(
"interiorShadowCasters",
shadows.interior_shadow_casters as f32,
@@ -2957,7 +2965,7 @@ mod tests {
// row the client does not have; the length is the other half — 33 rows, 33 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows");
assert_eq!(welded.len(), 34, "the lighting lane welds 34 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
+46 -1
View File
@@ -645,6 +645,23 @@ struct EntityCasters<'w, 's> {
/// bundle rather than being a 17th system param because `update_torch_shadows` already sits at
/// Bevy's 16-param ceiling — the same reason `distance` is here.
lighting: Res<'w, WowLighting>,
/// MONKEY (daylight: terrain torch casters): the resident ADT tiles, for the ground half of an
/// exterior slot's static caster mesh (`torchTerrainShadows`). `Option` so a scene with no
/// terrain streamer (the glue, a booth) is simply terrain-less.
terrain: Option<Res<'w, benilla_world::terrain_stream::TerrainStreamer>>,
adt_tiles: Option<Res<'w, Assets<benilla_assets::AdtTile>>>,
}
/// MONKEY (daylight: terrain torch casters): does an EXTERIOR slot's static map include the ground?
/// The cvar `torchTerrainShadows`, or `WOW_TORCH_TERRAIN=0|1` (read once) for hermetic captures.
fn terrain_casters_on(video: &VideoConfig) -> bool {
static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
ENV.get_or_init(|| match std::env::var("WOW_TORCH_TERRAIN").ok().as_deref() {
Some("1") => Some(true),
Some("0") => Some(false),
_ => None,
})
.unwrap_or(video.torch_terrain_shadows)
}
/// The INTERIOR lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
@@ -1123,9 +1140,22 @@ fn update_torch_shadows(
// While this pair is unchanged the fixture-local source set cannot have changed either, so the
// slot's walk is skipped outright — which is what turns "a full scene scan every frame,
// forever, in a room where nothing ever moves" into "a scan the frame something does".
// MONKEY (daylight: terrain torch casters): the resident terrain set joins the retained
// scene's stamp, so a tile streaming in re-checks the exterior slots near it; the switch itself
// is folded in so toggling the cvar re-keys every slot.
let terrain_on = terrain_casters_on(&video);
let terrain_gen = match (&ents.terrain, &ents.adt_tiles) {
(Some(t), Some(a)) if terrain_on => {
benilla_world::terrain_stream::terrain_torch_generation(t, a) ^ 0x7e44_a1d0
}
_ => 0,
};
let census = (slot_count > 0)
.then(|| PartCensus {
gx: gx.as_ref().map_or(0, |gx| gx.torch_residency_generation()),
gx: gx
.as_ref()
.map_or(0, |gx| gx.torch_residency_generation())
.wrapping_add(terrain_gen),
parts: static_part_census(&ents),
})
.unwrap_or_default();
@@ -1210,6 +1240,12 @@ fn update_torch_shadows(
entity_key = entity_key.wrapping_add(part_hash.finish());
}
entity_key.hash(&mut hash);
// MONKEY (daylight: terrain torch casters): settled EXTERIOR slots only. An interior
// fixture's room already occludes the ground, and a moving slot regathers every frame.
let with_terrain = terrain_gen != 0 && slot.exterior && !moving;
if with_terrain {
terrain_gen.hash(&mut hash);
}
let key = hash.finish();
let dirty = slot.geometry_key != Some(key);
if !dirty {
@@ -1235,6 +1271,15 @@ fn update_torch_shadows(
});
let (_, part_excluded) =
collect_torch_entities(&ents, true, &gather, &mut positions, &mut indices);
if with_terrain {
if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) {
let tris = benilla_world::terrain_stream::append_terrain_torch_triangles(
t, a, slot.pos, range, &mut positions, &mut indices);
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
info!("torch-terrain: slot {i} +{tris} ground tris within {range:.0} yd");
}
}
}
slot.trace_excluded = (gx_excluded, part_excluded);
// MONKEY (moving fixture): a live slot MUTATES its mesh in place and keeps its asset
// id; only a settled rebuild takes a fresh one. Two reasons. A new `Mesh` asset every
@@ -3051,6 +3051,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS",
"AdvancedGraphicsRowExteriorShadows",
),
// MONKEY (daylight: terrain torch casters)
(
"BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS",
"AdvancedGraphicsRowTorchTerrainShadows",
),
("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"),
("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"),
(
+8
View File
@@ -314,6 +314,11 @@ pub(crate) struct VideoConfig {
/// It shares `interior_shadow_casters`' sixteen resident cube slots, capped at half of them
/// (`torch_shadow::exterior_budget`) so a village square cannot evict an inn's candles.
pub(crate) exterior_shadows: bool,
/// MONKEY (daylight: terrain torch casters): whether the GROUND casts into an exterior torch's
/// cube map (`torchTerrainShadows`, default off; High = on) — a hill or bank between a fire and
/// the slope behind it blocks the fire. Only settled exterior slots gather it
/// (`torch_shadow`); `0` leaves the torch lane exactly as it was.
pub(crate) torch_terrain_shadows: bool,
/// MONKEY (static torch cache): resident fixture budget (1..16, default 12). Static
/// geometry renders only on promotion/residency changes; lowering this fades extra slots out.
pub(crate) interior_shadow_casters: u32,
@@ -439,6 +444,7 @@ impl Default for VideoConfig {
// MONKEY (outdoor torch shadows): on — a night campfire with no shadow is the thing
// this lane exists to fix, and it costs nothing whenever the sun is up.
exterior_shadows: true,
torch_terrain_shadows: false,
// MONKEY (static torch cache): 12 resident maps, four moving-caster overlays.
interior_shadow_casters: 12,
interior_shadow_dynamic: 4,
@@ -600,6 +606,8 @@ pub(crate) fn on_cvar(
// reads `VideoConfig` every frame, so `0` fades the outdoor shadows out (the slots evict
// through the same cross-fade a walked-away fixture does) and `1` fades them back in.
"exteriorshadows" => cfg.exterior_shadows = ev.flag(),
// MONKEY (daylight: terrain torch casters)
"torchterrainshadows" => cfg.torch_terrain_shadows = ev.flag(),
// MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the
// edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is
// already the off switch, and a 0 here would be a second, confusing one.
@@ -34,6 +34,9 @@ mod queries;
mod spawn;
mod weld;
pub(crate) mod window;
// MONKEY (daylight: terrain torch casters): resident MCNK chunks as torch cube-map casters.
mod torch_terrain;
pub use torch_terrain::{append_terrain_torch_triangles, terrain_torch_generation};
use collider::{finish_colliders, impassable_wall_data, terrain_collider_data};
use furnish::furnish_tile_cells;
@@ -0,0 +1,115 @@
//! MONKEY (daylight: terrain torch casters): terrain as a CASTER in the torch cube maps.
//!
//! The torch lane's static caster mesh is gathered from the retained scene (`StaticGx`) and the
//! rigid model parts; the ground was never in it, so a hill or a bank between a campfire and the
//! slope behind it never blocked the fire. This module hands the gather the resident MCNK chunks
//! within a fixture's reach, as world-space triangles (hole-masked indices, draw space — the same
//! vertices `collider::terrain_collider_data` welds, so the depth map and the drawn ground agree).
//!
//! Budget: one chunk is 145 vertices / up to 256 triangles; a 48 yd sphere touches at most ~16
//! chunks, so a slot gathers at most ~2.3k vertices and ~4k triangles once, when its static map
//! rebuilds. Chunks are culled by their own box against the sphere, then per triangle.
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::AdtTile;
use bevy::prelude::*;
use super::TerrainStreamer;
/// The terrain vertex bounds of one chunk in WoW axes. A chunk's positions are absolute world
/// yards, so the box is found without the tile index.
fn chunk_box(chunk: &benilla_formats::ChunkMesh) -> Option<(Vec3, Vec3)> {
let mut it = chunk.positions.iter();
let first = Vec3::from(*it.next()?);
let (mut lo, mut hi) = (first, first);
for p in it {
let p = Vec3::from(*p);
lo = lo.min(p);
hi = hi.max(p);
}
Some((lo, hi))
}
fn box_sphere_d2(lo: Vec3, hi: Vec3, c: Vec3) -> f32 {
(c.clamp(lo, hi) - c).length_squared()
}
/// Append every resident terrain triangle within `reach` of `center` (Bevy world space) to the
/// caster buffers, and return how many triangles were added. A triangle is kept when its own
/// box reaches the sphere — cheaper than an exact test and never drops a real occluder.
pub fn append_terrain_torch_triangles(
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
center: Vec3,
reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> u32 {
// Chunk boxes are in WoW axes, so test the sphere there (the conversion is a rigid swizzle).
let c = Vec3::from(benilla_assets::coords::bevy_to_wow(center));
let r2 = reach * reach;
let mut added = 0u32;
for ts in streamer.tiles.values() {
let Some(adt) = adt_tiles.get(&ts.handle) else {
continue;
};
for chunk in &adt.chunks {
let Some((lo, hi)) = chunk_box(chunk) else {
continue;
};
if box_sphere_d2(lo, hi, c) > r2 {
continue;
}
let base = positions.len() as u32;
let mut used = false;
for tri in chunk.indices.as_chunks::<3>().0 {
let (Some(a), Some(b), Some(d)) = (
chunk.positions.get(tri[0] as usize),
chunk.positions.get(tri[1] as usize),
chunk.positions.get(tri[2] as usize),
) else {
continue;
};
let (a, b, d) = (Vec3::from(*a), Vec3::from(*b), Vec3::from(*d));
if box_sphere_d2(a.min(b).min(d), a.max(b).max(d), c) > r2 {
continue;
}
if !used {
positions.extend(chunk.positions.iter().map(|p| wow_to_bevy(*p).to_array()));
used = true;
}
indices.extend(tri.iter().map(|i| base + i));
added += 1;
}
}
}
added
}
/// A cheap stamp of the resident terrain set: which tiles are loaded (decoded) right now. Folded
/// into the torch lane's census and slot fingerprint so a tile streaming in or out re-gathers the
/// fixtures near it. Order-independent.
pub fn terrain_torch_generation(streamer: &TerrainStreamer, adt_tiles: &Assets<AdtTile>) -> u64 {
use std::hash::{Hash, Hasher};
let mut sum = 0u64;
for (key, ts) in &streamer.tiles {
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
ts.handle.id().hash(&mut h);
adt_tiles.contains(&ts.handle).hash(&mut h);
sum = sum.wrapping_add(h.finish());
}
sum
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn box_sphere_distance_is_zero_inside_and_grows_outside() {
let (lo, hi) = (Vec3::ZERO, Vec3::ONE);
assert_eq!(box_sphere_d2(lo, hi, Vec3::splat(0.5)), 0.0);
assert!((box_sphere_d2(lo, hi, Vec3::new(3.0, 0.5, 0.5)) - 4.0).abs() < 1e-6);
}
}