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>, ) { - if override_value.0.unwrap_or(video.volumetric_fog) == 0 || !video.world_shadows { + if override_value.fog.unwrap_or(video.volumetric_fog) == 0 || !video.world_shadows { *seen = None; return; } @@ -135,6 +175,71 @@ fn density(minute: f32, weather: f32, indoors: bool) -> f32 { 0.004 * (1.0 + dawn * 1.5 + weather.clamp(0.0, 1.0) * 2.0) * if indoors { 0.18 } else { 1.0 } } +// MONKEY (lampfog): independent near-field haze floor. Full daylight is exactly zero; weather +// only strengthens an already-night-time halo, and interiors keep a restrained local version. +fn lamp_haze_strength(sun_height: f32, weather: f32, indoors: bool) -> f32 { + let daylight = ((sun_height + 0.02) / 0.15).clamp(0.0, 1.0); + (1.0 - daylight) * (0.8 + 1.2 * weather.clamp(0.0, 1.0)) * if indoors { 0.55 } else { 1.0 } +} + +// MONKEY (lampfog): pick from the exact CPU mirror of the packed point table. A sphere test keeps +// off-screen fixtures whose halo can still cross the view. Both exterior and interior rows are +// eligible: a street camera can see facade/room fixtures, and scene depth—not a camera-lane guess— +// cuts the view segment. This pass is deliberately unshadowed, so the remaining leakage is the +// documented tradeoff until point-light cube shadowing exists. +fn select_fog_lamps( + points: &[ResolvedPointLight], + camera_position: Vec3, + frustum: Option<&Frustum>, + limit: usize, +) -> FogLamps { + let mut candidates: Vec<(f32, ResolvedPointLight)> = points + .iter() + .copied() + .filter_map(|point| { + let point_is_indoors = point.lane > 0.5; + if point.color.max_element() <= 0.0001 { + return None; + } + let distance_squared = point.position.distance_squared(camera_position); + if distance_squared > FOG_LAMP_RADIUS * FOG_LAMP_RADIUS { + return None; + } + let reach = if point_is_indoors { + point.lane + } else { + point.range + } + .clamp(1.0, 48.0); + let visible = frustum.is_none_or(|frustum| { + frustum.intersects_sphere( + &CullSphere { + center: point.position.into(), + radius: reach, + }, + false, + ) + }); + visible.then_some((distance_squared, point)) + }) + .collect(); + candidates.sort_by(|left, right| left.0.total_cmp(&right.0)); + + let mut selected = FogLamps::default(); + for (_, point) in candidates.into_iter().take(limit.min(MAX_FOG_LAMPS)) { + let reach = if point.lane > 0.5 { + point.lane + } else { + point.range + } + .clamp(1.0, 48.0); + selected.positions[selected.count] = point.position.extend(reach); + selected.colors[selected.count] = point.color.extend(0.0); + selected.count += 1; + } + selected +} + fn update_fog( mut commands: Commands, video: Res, @@ -143,23 +248,36 @@ fn update_fog( clock: Res, weather: Res, interior: Res, + // MONKEY (lampfog): the resolved point-table view published by global_light's packer. + point_lights: Res, // MONKEY (fog): the Modern fog rows (Option: absent in unit tests). monkey: Option>, - mut cameras: Query<(Entity, &Camera, &mut Camera3d), With>, + mut cameras: Query< + ( + Entity, + &Camera, + &mut Camera3d, + Option<&GlobalTransform>, + Option<&Frustum>, + ), + With, + >, suns: Query<(Entity, &DirectionalLight), With>, ) { - let tier = override_value.0.unwrap_or(video.volumetric_fog).min(2); - let enabled = tier != 0 && cameras.iter().any(|(_, c, _)| c.is_active); + let tier = override_value.fog.unwrap_or(video.volumetric_fog).min(2); + let lamp_tier = override_value.lamp.unwrap_or(video.lamp_fog).min(2); + let enabled = (tier != 0 || lamp_tier != 0) + && cameras.iter().any(|(_, camera, _, _, _)| camera.is_active); for (entity, sun) in &suns { // Only a tag for selecting this light in our shader. No Bevy VolumetricFog // camera/volume is created, and the rig's lux and surface lighting stay intact. - if enabled && sun.shadows_enabled { + if tier != 0 && enabled && sun.shadows_enabled { commands.entity(entity).insert(VolumetricLight); } else { commands.entity(entity).remove::(); } } - for (entity, camera, mut camera3d) in &mut cameras { + for (entity, camera, mut camera3d, camera_transform, frustum) in &mut cameras { if !enabled || !camera.is_active { commands.entity(entity).remove::(); continue; @@ -167,6 +285,31 @@ fn update_fog( camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits(); let sun = lighting.celestial_dir(); let daylight = ((sun.y + 0.02) / 0.15).clamp(0.0, 1.0); + let weather_amount = weather + .sky_density + .max(weather.effect_density) + .clamp(0.0, 1.0); + // Exact zero by day is both the intended look and the A/B invariant. At night a constant + // floor makes nearby halos survive the zone haze's ten-yard dead zone; rain/fog raises it. + let lamp_strength = if lamp_tier == 0 { + 0.0 + } else { + lamp_haze_strength(sun.y, weather_amount, interior.0.is_some()) + }; + let lamps = if lamp_strength > 0.0 { + select_fog_lamps( + point_lights.as_slice(), + camera_transform.map_or(Vec3::ZERO, GlobalTransform::translation), + frustum, + if lamp_tier == 1 { + LOW_FOG_LAMPS + } else { + MAX_FOG_LAMPS + }, + ) + } else { + FogLamps::default() + }; let fog = Vec3::from_array(lighting.fog_color); // MONKEY (fog): rows 1-3 as packed for the light buffer. let rows = monkey.as_ref().map_or([[0.0; 4]; 16], |m| m.pack(0.0, 0.0)); @@ -174,16 +317,26 @@ fn update_fog( mf_fog1: Vec4::from_array(rows[1]), mf_fog2: Vec4::from_array(rows[2]), mf_fog3: Vec4::from_array(rows[3]), + lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, 0.0), + lamp_positions: lamps.positions, + lamp_colors: lamps.colors, color_density: fog.extend( - density( - clock.minute_f, - weather.sky_density.max(weather.effect_density), - interior.0.is_some(), - ) * if tier == 2 { 1.6 } else { 1.0 }, + density(clock.minute_f, weather_amount, interior.0.is_some()) + * if tier == 0 { + 0.0 + } else if tier == 2 { + 1.6 + } else { + 1.0 + }, ), sun_strength: Vec3::from_array(lighting.diffuse) .lerp(Vec3::ONE, 0.5) - .extend(override_value.1 * daylight), + .extend(if tier == 0 { + 0.0 + } else { + override_value.shaft_gain * daylight + }), direction_steps: sun.extend(if tier == 1 { 24.0 } else { 64.0 }), }); } @@ -398,6 +551,10 @@ struct Fog { color_density: vec4, sun_strength: vec4, direction_steps: vec4, // MONKEY (fog): MonkeyFrame fog1..fog3. mf_fog1: vec4, mf_fog2: vec4, mf_fog3: vec4, + // MONKEY (lampfog): x=count, y=night/weather strength; packed point-table rows follow. + lamp_meta: vec4, + lamp_positions: array, 32>, + lamp_colors: array, 32>, } @group(0) @binding(6) var fog: Fog; @@ -425,6 +582,56 @@ fn visibility(light_index: u32, p: vec3) -> f32 { // Unknown outside caster coverage is not evidence of direct sunlight. return 0.0; } + +// MONKEY (lampfog): a mild Henyey-Greenstein lobe. The inverse-square term is integrated exactly; +// evaluating this slow angular term at the closest admitted point keeps the closed form intact. +fn lamp_phase(cosine: f32) -> f32 { + let anisotropy = 0.2; + let denom = 1.0 + anisotropy * anisotropy - 2.0 * anisotropy * cosine; + return 0.07957747 * (1.0 - anisotropy * anisotropy) / (denom * sqrt(denom)); +} + +// MONKEY (lampfog): analytic line integral of a softened inverse-square source over the visible +// camera-to-depth segment. It is intentionally UNSHADOWED; scene depth cuts the segment, but no +// point-light cube map is sampled. Range is a smooth screen-space gate around the exact sphere +// intersection. The exponential soft cap plus destination headroom keeps halos below 1.0 so a +// later bloom pass cannot turn a stacked row of lamps into white slabs. +fn lamp_halos(ray: vec3, distance: f32) -> vec3 { + var energy = vec3(0.0); + let lamp_count = u32(fog.lamp_meta.x); + for (var lamp_index = 0u; lamp_index < 32u; lamp_index += 1u) { + if (lamp_index >= lamp_count) { break; } + let position_range = fog.lamp_positions[lamp_index]; + let to_lamp = position_range.xyz - view.world_position; + let projection = dot(to_lamp, ray); + let perpendicular = to_lamp - ray * projection; + let perpendicular_sq = dot(perpendicular, perpendicular); + let range_sq = position_range.w * position_range.w; + if (perpendicular_sq >= range_sq) { continue; } + + let half_span = sqrt(max(range_sq - perpendicular_sq, 0.0)); + let segment_start = max(0.0, projection - half_span); + let segment_end = min(distance, projection + half_span); + if (segment_end <= segment_start) { continue; } + + // Integral dt / (core^2 + perpendicular^2 + (t - projection)^2). + let core_radius = 1.25; + let height = sqrt(perpendicular_sq + core_radius * core_radius); + let integral = (atan((segment_end - projection) / height) + - atan((segment_start - projection) / height)) / height; + let closest = clamp(projection, segment_start, segment_end); + let sample_to_lamp = view.world_position + ray * closest - position_range.xyz; + let light_path = sample_to_lamp / max(length(sample_to_lamp), 0.0001); + let phase = lamp_phase(dot(light_path, -ray)); + let edge = 1.0 - smoothstep(position_range.w * 0.72, position_range.w, + sqrt(perpendicular_sq)); + energy += max(fog.lamp_colors[lamp_index].rgb, vec3(0.0)) + * (integral * phase * edge); + } + let capped = min(energy * fog.lamp_meta.y * 0.9, vec3(0.75)); + return vec3(1.0) - exp(-capped); +} + @fragment fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { let xy = vec2(pixel.xy); @@ -434,45 +641,52 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { let q = view.world_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), max(z, 0.000001), 1.0); let delta = q.xyz / q.w - view.world_position; let distance = min(length(delta), 150.0); - if (distance <= 10.0) { return source; } + if (distance <= 10.0 && fog.lamp_meta.x < 0.5) { return source; } let ray = normalize(delta); - let optical_depth = fog.color_density.w * fog_path(distance); - var opacity = 1.0 - exp(-optical_depth); - var haze_rgb = fog.color_density.rgb; - if (fog_hook::fog_is_modern(fog.mf_fog3)) { - // MONKEY (fog): the sky (depth 0, clamped to 150 yd above) already carries its own - // horizon fog, so it takes no distance haze; the haze colour is the shared fog colour. - if (z <= 0.0) { opacity = 0.0; } - haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta), - fog.mf_fog1, fog.mf_fog2, fog.mf_fog3); - } let luma = vec3(0.2126, 0.7152, 0.0722); - var haze = mix(source.rgb, haze_rgb, opacity); - // A darker zone colour may change hue, never the pixel's luminance. Add - // neutral headroom proportionally so saturated channels cannot clip dark. - let missing = max(0.0, dot(source.rgb - haze, luma)); - let headroom = max(vec3(0.0), vec3(1.0) - haze); - haze += headroom * clamp(missing / max(dot(headroom, luma), 0.00001), 0.0, 1.0); - if (fog.sun_strength.w > 0.0) { - let g = 0.65; - let cosine = dot(ray, fog.direction_steps.xyz); - let denom = 1.0 + g*g - 2.0*g*cosine; - let phase = 0.07957747 * (1.0-g*g) / (denom * sqrt(denom)); - let count = u32(fog.direction_steps.w); - let step_size = (distance - 10.0) / f32(count); - var scatter = 0.0; - for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) { - if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; } - for (var step = 0u; step < count; step += 1u) { - // Fixed midpoint samples: no temporal noise in this gamma lane. - let t = 10.0 + (f32(step) + 0.5) * step_size; - let density = fog.color_density.w * smoothstep(10.0, 30.0, t); - scatter += visibility(light_index, view.world_position + ray*t) - * exp(-fog.color_density.w * fog_path(t)) * density * step_size; - } + var haze = source.rgb; + if (distance > 10.0) { + let optical_depth = fog.color_density.w * fog_path(distance); + var opacity = 1.0 - exp(-optical_depth); + var haze_rgb = fog.color_density.rgb; + if (fog_hook::fog_is_modern(fog.mf_fog3)) { + // MONKEY (fog): the sky (depth 0, clamped to 150 yd above) already carries its own + // horizon fog, so it takes no distance haze; the haze colour is the shared fog colour. + if (z <= 0.0) { opacity = 0.0; } + haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta), + fog.mf_fog1, fog.mf_fog2, fog.mf_fog3); } - let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter; - haze += max(vec3(0.0), vec3(1.0) - haze) * (vec3(1.0) - exp(-shafts)); + haze = mix(source.rgb, haze_rgb, opacity); + // A darker zone colour may change hue, never the pixel's luminance. Add + // neutral headroom proportionally so saturated channels cannot clip dark. + let missing = max(0.0, dot(source.rgb - haze, luma)); + let headroom = max(vec3(0.0), vec3(1.0) - haze); + haze += headroom * clamp(missing / max(dot(headroom, luma), 0.00001), 0.0, 1.0); + if (fog.sun_strength.w > 0.0) { + let g = 0.65; + let cosine = dot(ray, fog.direction_steps.xyz); + let denom = 1.0 + g*g - 2.0*g*cosine; + let phase = 0.07957747 * (1.0-g*g) / (denom * sqrt(denom)); + let count = u32(fog.direction_steps.w); + let step_size = (distance - 10.0) / f32(count); + var scatter = 0.0; + for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) { + if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; } + for (var step = 0u; step < count; step += 1u) { + // Fixed midpoint samples: no temporal noise in this gamma lane. + let t = 10.0 + (f32(step) + 0.5) * step_size; + let density = fog.color_density.w * smoothstep(10.0, 30.0, t); + scatter += visibility(light_index, view.world_position + ray*t) + * exp(-fog.color_density.w * fog_path(t)) * density * step_size; + } + } + let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter; + haze += max(vec3(0.0), vec3(1.0) - haze) * (vec3(1.0) - exp(-shafts)); + } + } + if (fog.lamp_meta.x > 0.5) { + let halo = lamp_halos(ray, distance); + haze += max(vec3(0.0), vec3(1.0) - haze) * halo; } return vec4(haze, source.a); } @@ -487,12 +701,16 @@ mod tests { app.init_resource::() .init_resource::() .init_resource::() - .insert_resource(FogOverride(Some(0), 2.0)) + .insert_resource(FogOverride { + fog: Some(0), + lamp: None, + shaft_gain: 2.0, + }) .add_systems(Update, refresh_streamed_shadows); app.world_mut().clear_trackers(); app.world_mut().run_schedule(Update); assert!(!app.world().resource_ref::().is_changed()); - app.world_mut().resource_mut::().0 = Some(1); + app.world_mut().resource_mut::().fog = Some(1); app.world_mut().run_schedule(Update); assert!(app.world().resource_ref::().is_changed()); app.world_mut().clear_trackers(); @@ -509,6 +727,45 @@ mod tests { assert!(density(390.0, 1.0, true) < noon); } #[test] + fn lamp_haze_is_exactly_off_by_day_and_weather_only_strengthens_night() { + assert_eq!(lamp_haze_strength(1.0, 1.0, false), 0.0); + let clear = lamp_haze_strength(-1.0, 0.0, false); + assert!(lamp_haze_strength(-1.0, 1.0, false) > clear * 2.0); + assert!(lamp_haze_strength(-1.0, 0.0, true) < clear); + } + #[test] + fn lamp_picker_keeps_the_nearest_across_both_lanes_and_tier_limit() { + let mut points: Vec<_> = (1..=40) + .rev() + .map(|distance| ResolvedPointLight { + position: Vec3::X * distance as f32, + range: 48.0, + color: Vec3::new(0.8, 0.4, 0.1), + lane: 0.0, + }) + .collect(); + // Interior and exterior table rows compete by distance; black rows never consume a slot. + points.push(ResolvedPointLight { + position: Vec3::X * 0.25, + range: 48.0, + color: Vec3::ONE, + lane: 8.0, + }); + points.push(ResolvedPointLight { + position: Vec3::X * 0.5, + range: 48.0, + color: Vec3::ZERO, + lane: 0.0, + }); + let low = select_fog_lamps(&points, Vec3::ZERO, None, LOW_FOG_LAMPS); + assert_eq!(low.count, LOW_FOG_LAMPS); + assert_eq!(low.positions[0], Vec4::new(0.25, 0.0, 0.0, 8.0)); + assert_eq!(low.positions[1].x, 1.0); + assert_eq!(low.positions[LOW_FOG_LAMPS - 1].x, 15.0); + assert_eq!(low.colors[0].truncate(), Vec3::ONE); + assert_eq!(low.colors[1].truncate(), Vec3::new(0.8, 0.4, 0.1)); + } + #[test] fn live_tiers_shadow_loss_inactive_camera_and_off() { let mut app = App::new(); app.init_resource::() @@ -516,7 +773,12 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .insert_resource(FogOverride(None, 2.0)) + .init_resource::() + .insert_resource(FogOverride { + fog: None, + lamp: None, + shaft_gain: 2.0, + }) .add_systems(Update, update_fog); let camera = app .world_mut() @@ -563,10 +825,10 @@ mod tests { app.update(); assert!(app.world().get::(camera).is_none()); app.world_mut().get_mut::(camera).unwrap().is_active = true; - app.world_mut().resource_mut::().0 = Some(0); + app.world_mut().resource_mut::().fog = Some(0); app.update(); assert!(app.world().get::(camera).is_none()); - app.world_mut().resource_mut::().0 = None; + app.world_mut().resource_mut::().fog = None; app.update(); assert!(app.world().get::(camera).is_some()); } diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 06d5d90b..00607dc3 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -609,6 +609,29 @@ pub struct WorldPointLight { pub range: f32, } +/// MONKEY (lampfog): one live entry exactly as [`build_light_data`] resolved it for the shared +/// point table. Colour is already `colour x intensity`, with the source's live gain and flame +/// flicker folded in; position is Bevy world space and `lane` keeps the table's exterior/interior +/// discriminator (an interior lane's value is also its effective reach). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ResolvedPointLight { + pub position: Vec3, + pub range: f32, + pub color: Vec3, + pub lane: f32, +} + +/// MONKEY (lampfog): read-only CPU view of the shared point table for post-process consumers. +/// The GPU blob stays private and its pinned layout does not grow; readers get only the live rows. +#[derive(Resource, Default)] +pub struct ResolvedPointLights(Vec); + +impl ResolvedPointLights { + pub fn as_slice(&self) -> &[ResolvedPointLight] { + &self.0 + } +} + /// The packed light for this frame, extracted for [`upload_light`]. #[derive(Resource, Clone, Copy, ExtractResource)] struct WowLightData(LightStd430); @@ -631,6 +654,8 @@ pub struct SharedLightBuffer(pub Buffer); /// Registers the light pack, the probe publish, their extracts and the render-world uploads. pub(super) fn register(app: &mut App) { app.init_resource::() + // MONKEY (lampfog): CPU mirror of only the live point rows; not extracted or GPU-bound. + .init_resource::() // MONKEY (room gate): the per-fixture room claims, packed beside the point table. .init_resource::() .init_resource::() @@ -1301,6 +1326,8 @@ fn build_light_data( // The per-frame portal PVS, for the room term below ([`LightRooms`]). portals: Query<&crate::wmo_portal::WmoPortalInstance>, mut data: ResMut, + // MONKEY (lampfog): publish the already-resolved live rows without exposing the GPU blob. + mut resolved_points: ResMut, // MONKEY (room gate): packed in the same walk as the point table, so the two can never // disagree about which entry is which. mut claims: ResMut, @@ -1584,6 +1611,20 @@ fn build_light_data( fresh.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], *lane]; claim.write(&mut claims.0[i * ROOM_CLAIM_STRIDE..][..ROOM_CLAIM_STRIDE]); } + // MONKEY (lampfog): same order and values as the rows above. This is intentionally updated + // after the 255-entry truncate, so a CPU post effect can never see a light the shaders cannot. + let next_points: Vec<_> = pts + .iter() + .map(|(_, p, range, rgb, _, lane, _, _)| ResolvedPointLight { + position: *p, + range: *range, + color: Vec3::from_array(*rgb), + lane: *lane, + }) + .collect(); + if resolved_points.0 != next_points { + resolved_points.0 = next_points; + } // Entries past the count are stale in the point table by design (the count row guards every // reader) — but the claim table is read at the SAME index, so a stale head there would gate a // live light with a dead building's identity. Clear the tail instead of trusting the count. @@ -2050,6 +2091,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() // MONKEY (room gate): the packer writes the claim table in the same walk. .init_resource::() .init_resource::