From b96b9deb59ae52e84b1bfdbab845958a224e0a45 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 16:01:14 +0100
Subject: [PATCH] MONKEY (lampfog): scatter resolved lamps through fog
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-app/src/cvars.rs | 20 +-
crates/benilla-app/src/video.rs | 11 +-
crates/benilla-app/src/volumetric_fog.rs | 388 +++++++++++++++---
.../src/lighting/global_light.rs | 53 +++
crates/benilla-world/src/lighting/mod.rs | 2 +-
5 files changed, 402 insertions(+), 72 deletions(-)
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 03a8bf3b..c6ea36b5 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -921,6 +921,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
),
// MONKEY (volumetric fog): saved live tier; capture override stays session-only.
ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"),
+ // MONKEY (lampfog): opt-in point-light halos; High graphics preset value is 2.
+ ours(
+ "lampFog",
+ "0",
+ "benilla's own: lamps scattering through night fog, 0 Off / 1 Low (16 lamps) / 2 High (32 lamps)",
+ ),
// MONKEY (p0 skyDither): the FFXGlow combine's deband dither (was env WOW_DITHER only).
// Default 0 = the reference look; the Graphics preset's High sets 1.
ours(
@@ -2903,7 +2909,7 @@ mod tests {
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
// ── MONKEY (lighting): the weld for the dynamic light + shadow system's whole row set ──
//
- // **All 32, as one census, with the count asserted.** Every one of them lands on
+ // **All 34, as one census, with the count asserted.** Every one of them lands on
// `VideoConfig` and is read from there per frame (`shadow_core::update_shadows`,
// `dynamic_interior::bridge`, `torch_shadow`), so a registered default that drifts from
// the struct's literal is a setting that reads one way in `config.toml` and renders
@@ -2912,11 +2918,13 @@ mod tests {
// a row, forgot its weld" fail HERE: the length check below is the gate.
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] = [
+ // MONKEY (lampfog): include both atmospheric tiers in this fixed-size default table.
+ 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),
+ // MONKEY (lampfog): opt-in point-light fog.
+ ("lampFog", shadows.lamp_fog as f32),
("lavaLightGain", shadows.lava_light_gain),
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
@@ -2969,10 +2977,10 @@ mod tests {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
- // row the client does not have; the length is the other half — 33 rows, 33 welds.
+ // row the client does not have; the length is the other half — 34 rows, 34 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");
+ // MONKEY (lampfog): both atmospheric tiers join the default-consumer weld.
+ 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/video.rs b/crates/benilla-app/src/video.rs
index 07c17824..b2baecc5 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -222,6 +222,8 @@ pub(crate) struct VideoConfig {
pub(crate) water_quality: u8,
// MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High.
pub(crate) volumetric_fog: u8,
+ // MONKEY (lampfog): point-light fog tier: 0 Off, 1 nearest 16, 2 nearest 32.
+ pub(crate) lamp_fog: u8,
/// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow.
/// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`.
pub(crate) lava_light_gain: f32,
@@ -467,6 +469,8 @@ impl Default for VideoConfig {
water_quality: 1,
// MONKEY (volumetric fog): default to the inexpensive atmosphere.
volumetric_fog: 1,
+ // MONKEY (lampfog): opt-in; zero is exactly the pre-lane render.
+ lamp_fog: 0,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
@@ -527,7 +531,8 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
- // ── MONKEY (lighting): the dynamic light + shadow system's 33 rows ────────────────────
+ // ── MONKEY (lighting): the dynamic light + shadow system's 34 rows ────────────────────
+ // MONKEY (lampfog): lampFog is the 34th live VideoConfig row.
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
@@ -543,10 +548,12 @@ pub(crate) fn on_cvar(
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
- // MONKEY (volumetric fog): the atmospheric tier brings the defaults weld to 33 pairs.
+ // MONKEY (lampfog): the two atmospheric tiers bring the defaults weld to 34 pairs.
"waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8,
// MONKEY (volumetric fog): constrain UI/console writes to supported tiers.
"volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8,
+ // MONKEY (lampfog): 0 Off / 1 nearest 16 / 2 nearest 32.
+ "lampfog" => cfg.lamp_fog = v.clamp(0.0, 2.0) as u8,
"lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0),
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs
index aff59f9c..cbfe9314 100644
--- a/crates/benilla-app/src/volumetric_fog.rs
+++ b/crates/benilla-app/src/volumetric_fog.rs
@@ -8,25 +8,27 @@ use crate::{
world_shadow::WorldLane,
};
use benilla_world::{
- lighting::{WorldTime, WowLighting},
+ lighting::{ResolvedPointLight, ResolvedPointLights, WorldTime, WowLighting},
static_gx::StaticGx,
view::WorldCamera,
weather::WeatherState,
wmo_portal::CameraInteriorClaim,
};
use bevy::{
+ camera::primitives::{Frustum, Sphere as CullSphere},
core_pipeline::{
- core_3d::graph::{Core3d, Node3d},
FullscreenShader,
+ core_3d::graph::{Core3d, Node3d},
},
ecs::query::QueryItem,
light::VolumetricLight,
pbr::{
- GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT,
- MAX_DIRECTIONAL_LIGHTS,
+ GpuLights, LightMeta, MAX_CASCADES_PER_LIGHT, MAX_DIRECTIONAL_LIGHTS,
+ ViewLightsUniformOffset, ViewShadowBindings,
},
prelude::*,
render::{
+ Render, RenderApp, RenderStartup, RenderSystems,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
@@ -35,14 +37,38 @@ use bevy::{
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
- Render, RenderApp, RenderStartup, RenderSystems,
},
shader::ShaderDefVal,
};
pub(crate) struct VolumetricFogPlugin;
#[derive(Resource)]
-struct FogOverride(Option, f32);
+struct FogOverride {
+ fog: Option,
+ lamp: Option,
+ shaft_gain: f32,
+}
+
+const MAX_FOG_LAMPS: usize = 32;
+const LOW_FOG_LAMPS: usize = 16;
+const FOG_LAMP_RADIUS: f32 = 100.0;
+
+#[derive(Clone, Copy)]
+struct FogLamps {
+ positions: [Vec4; MAX_FOG_LAMPS],
+ colors: [Vec4; MAX_FOG_LAMPS],
+ count: usize,
+}
+
+impl Default for FogLamps {
+ fn default() -> Self {
+ Self {
+ positions: [Vec4::ZERO; MAX_FOG_LAMPS],
+ colors: [Vec4::ZERO; MAX_FOG_LAMPS],
+ count: 0,
+ }
+ }
+}
#[derive(Component, Clone, Copy, ExtractComponent, ShaderType)]
struct FogView {
@@ -57,6 +83,11 @@ struct FogView {
mf_fog1: Vec4,
mf_fog2: Vec4,
mf_fog3: Vec4,
+ // MONKEY (lampfog): count + strength, followed by 32 resolved point-table rows. Colour already
+ // includes intensity, flame flicker and (for synthesised fire) fireLightGain.
+ lamp_meta: Vec4,
+ lamp_positions: [Vec4; MAX_FOG_LAMPS],
+ lamp_colors: [Vec4; MAX_FOG_LAMPS],
}
impl Plugin for VolumetricFogPlugin {
@@ -65,6 +96,11 @@ impl Plugin for VolumetricFogPlugin {
.ok()
.and_then(|v| v.parse::().ok())
.filter(|v| *v <= 2);
+ // MONKEY (lampfog): session-only capture override; the persistent setting is `lampFog`.
+ let lamp = std::env::var("WOW_LAMPFOG")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| *v <= 2);
// Dev capture instrument: isolate shafts without changing surface shadows or haze.
let shaft_gain = if cfg!(feature = "dev") && std::env::var_os("WOW_CAPTURE").is_some() {
std::env::var("WOW_VOLFOG_SHAFT_GAIN")
@@ -76,10 +112,14 @@ impl Plugin for VolumetricFogPlugin {
} else {
2.0
};
- app.insert_resource(FogOverride(value, shaft_gain))
- .add_plugins(ExtractComponentPlugin::::default())
- .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes))
- .add_systems(Last, update_fog.after(ShadowSet::Lanes));
+ app.insert_resource(FogOverride {
+ fog: value,
+ lamp,
+ shaft_gain,
+ })
+ .add_plugins(ExtractComponentPlugin::::default())
+ .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes))
+ .add_systems(Last, update_fog.after(ShadowSet::Lanes));
// Keep headless policy tests independent of the renderer.
if !app.is_plugin_added::() {
return;
@@ -117,7 +157,7 @@ fn refresh_streamed_shadows(
mut lane: ResMut,
mut seen: Local