MONKEY (lampfog): scatter resolved lamps through fog

Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
pkuzic
2026-09-25 16:01:14 +01:00
co-authored by GPT-5.6 Sol (Codex)
parent 06d2fc4669
commit b96b9deb59
5 changed files with 402 additions and 72 deletions
+14 -6
View File
@@ -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),
+9 -2
View File
@@ -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(),
+325 -63
View File
@@ -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<u8>, f32);
struct FogOverride {
fog: Option<u8>,
lamp: Option<u8>,
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::<u8>().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::<u8>().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::<FogView>::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::<FogView>::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::<AssetPlugin>() {
return;
@@ -117,7 +157,7 @@ fn refresh_streamed_shadows(
mut lane: ResMut<WorldLane>,
mut seen: Local<Option<u64>>,
) {
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<VideoConfig>,
@@ -143,23 +248,36 @@ fn update_fog(
clock: Res<WorldTime>,
weather: Res<WeatherState>,
interior: Res<CameraInteriorClaim>,
// MONKEY (lampfog): the resolved point-table view published by global_light's packer.
point_lights: Res<ResolvedPointLights>,
// MONKEY (fog): the Modern fog rows (Option: absent in unit tests).
monkey: Option<Res<benilla_world::lighting::MonkeyFrame>>,
mut cameras: Query<(Entity, &Camera, &mut Camera3d), With<WorldCamera>>,
mut cameras: Query<
(
Entity,
&Camera,
&mut Camera3d,
Option<&GlobalTransform>,
Option<&Frustum>,
),
With<WorldCamera>,
>,
suns: Query<(Entity, &DirectionalLight), With<ShadowSun>>,
) {
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::<VolumetricLight>();
}
}
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::<FogView>();
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<f32>, sun_strength: vec4<f32>, direction_steps: vec4<f32>,
// MONKEY (fog): MonkeyFrame fog1..fog3.
mf_fog1: vec4<f32>, mf_fog2: vec4<f32>, mf_fog3: vec4<f32>,
// MONKEY (lampfog): x=count, y=night/weather strength; packed point-table rows follow.
lamp_meta: vec4<f32>,
lamp_positions: array<vec4<f32>, 32>,
lamp_colors: array<vec4<f32>, 32>,
}
@group(0) @binding(6) var<uniform> fog: Fog;
@@ -425,6 +582,56 @@ fn visibility(light_index: u32, p: vec3<f32>) -> 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<f32>, distance: f32) -> vec3<f32> {
var energy = vec3<f32>(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<f32>(0.0))
* (integral * phase * edge);
}
let capped = min(energy * fog.lamp_meta.y * 0.9, vec3<f32>(0.75));
return vec3<f32>(1.0) - exp(-capped);
}
@fragment
fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
let xy = vec2<i32>(pixel.xy);
@@ -434,45 +641,52 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
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::<VideoConfig>()
.init_resource::<StaticGx>()
.init_resource::<WorldLane>()
.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::<WorldLane>().is_changed());
app.world_mut().resource_mut::<FogOverride>().0 = Some(1);
app.world_mut().resource_mut::<FogOverride>().fog = Some(1);
app.world_mut().run_schedule(Update);
assert!(app.world().resource_ref::<WorldLane>().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::<VideoConfig>()
@@ -516,7 +773,12 @@ mod tests {
.init_resource::<WorldTime>()
.init_resource::<WeatherState>()
.init_resource::<CameraInteriorClaim>()
.insert_resource(FogOverride(None, 2.0))
.init_resource::<ResolvedPointLights>()
.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::<FogView>(camera).is_none());
app.world_mut().get_mut::<Camera>(camera).unwrap().is_active = true;
app.world_mut().resource_mut::<FogOverride>().0 = Some(0);
app.world_mut().resource_mut::<FogOverride>().fog = Some(0);
app.update();
assert!(app.world().get::<FogView>(camera).is_none());
app.world_mut().resource_mut::<FogOverride>().0 = None;
app.world_mut().resource_mut::<FogOverride>().fog = None;
app.update();
assert!(app.world().get::<FogView>(camera).is_some());
}
@@ -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<ResolvedPointLight>);
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::<WowLightData>()
// MONKEY (lampfog): CPU mirror of only the live point rows; not extracted or GPU-bound.
.init_resource::<ResolvedPointLights>()
// MONKEY (room gate): the per-fixture room claims, packed beside the point table.
.init_resource::<RoomClaimTable>()
.init_resource::<WorldShadowActive>()
@@ -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<WowLightData>,
// MONKEY (lampfog): publish the already-resolved live rows without exposing the GPU blob.
mut resolved_points: ResMut<ResolvedPointLights>,
// 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<RoomClaimTable>,
@@ -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::<crate::dev_state::DebugState>()
.init_resource::<crate::view::ViewDistance>()
.init_resource::<WowLightData>()
.init_resource::<ResolvedPointLights>()
// MONKEY (room gate): the packer writes the claim table in the same walk.
.init_resource::<RoomClaimTable>()
.init_resource::<Time>()
@@ -2280,6 +2322,12 @@ mod tests {
(synthetic[0] - 1.0).abs() < 1e-4,
"the synthesised light takes the gain: {synthetic:?}"
);
// MONKEY (lampfog): the CPU accessor is the live table, not a second light calculation.
let resolved = app.world().resource::<ResolvedPointLights>();
assert_eq!(resolved.as_slice().len(), 2);
assert_eq!(resolved.as_slice()[0].position, Vec3::X);
assert!((resolved.as_slice()[0].color.x - authored[0]).abs() < 1e-4);
assert!((resolved.as_slice()[1].color.x - synthetic[0]).abs() < 1e-4);
// Zero is the kill switch: the invented light commits black, the authored one is unmoved.
app.world_mut().insert_resource(FireLightGain(0.0));
@@ -2287,6 +2335,11 @@ mod tests {
let data = app.world().resource::<WowLightData>().0;
assert_eq!(data.points[3], [0.0, 0.0, 0.0, 0.0], "gain 0 = lane off");
assert!((data.points[1][0] - 2.0).abs() < 1e-4, "authored unaffected");
assert_eq!(
app.world().resource::<ResolvedPointLights>().as_slice()[1].color,
Vec3::ZERO,
"the accessor carries the same live fire-gain fold"
);
}
/// GOLDEN — MONKEY (spellLightGain): a SPELL light takes `spellLightGain` **instead of**
+1 -1
View File
@@ -39,7 +39,7 @@ pub use flicker::{flame_kind_for, flicker_seed, FlameFlicker, FlameKind, Flicker
pub use global_light::{
interior_reach, m2_light_reach, new_shared_light_buffer, DynamicInteriors, FireLightGain,
ClaimFade, LightLane, LightLitRooms, LightReach, LightRooms, RoomClaimTable,
SharedLightBuffer,
ResolvedPointLight, ResolvedPointLights, SharedLightBuffer,
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
WorldShadowActive,
};