diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 71e19e82..2598e806 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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") - + + + + OptionsRow_OnLoad(self, "torchTerrainShadows", + BENILLA_ADVGFX.torchTerrainShadows, + "BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS") + + + + + + + -- 0.5..3, the CVar's own clamp verbatim: it is a -- SCALE on the PCF tap radius at a contact, so the diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 31a7eced..2235a622 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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), diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs index 26d8f3d7..6f31663b 100644 --- a/crates/benilla-app/src/torch_shadow.rs +++ b/crates/benilla-app/src/torch_shadow.rs @@ -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>, + adt_tiles: Option>>, +} + +/// 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> = 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 diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 43d3f917..ff8b4d34 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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"), ( diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 07c17824..bcc30b60 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -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. diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index 32822b4a..c6d3e353 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -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; diff --git a/crates/benilla-world/src/terrain_stream/torch_terrain.rs b/crates/benilla-world/src/terrain_stream/torch_terrain.rs new file mode 100644 index 00000000..b58e2aa4 --- /dev/null +++ b/crates/benilla-world/src/terrain_stream/torch_terrain.rs @@ -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, + center: Vec3, + reach: f32, + positions: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) -> 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) -> 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); + } +}