From 1895461d5cf12fb876b45a2ca012b917254f5811 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 05:05:55 +0100 Subject: [PATCH] MONKEY (visualfix): volumetric haze meets the ocean at its surface, not the seabed Water writes no depth, so the haze pass measured the seabed behind it, and water with no seabed behind it (depth 0 below the horizon) read as sky and took no haze. The underwater slope of an island drew a hard-edged wedge across the sea (water-ocean at High). With an ocean footprint in reach and the eye above sea level, a point below sea level is now hazed at the ocean plane. Samples past the cascade's far bound take the ray's mean covered visibility instead of 0. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/volumetric_fog.rs | 56 +++++++++++++++++++++--- crates/benilla-world/src/liquid/query.rs | 10 +++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs index 52448ece..374b99f5 100644 --- a/crates/benilla-app/src/volumetric_fog.rs +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -155,6 +155,11 @@ impl Plugin for VolumetricFogPlugin { } } +/// MONKEY (visualfix): how near (yd, WoW XY) an ocean footprint must come to arm the sea plane. +const SEA_PLANE_REACH: f32 = 400.0; +/// MONKEY (visualfix): the eye must be this far above sea level (yd) for the plane to apply. +const SEA_LEVEL_MIN_EYE: f32 = 0.25; + fn density(minute: f32, weather: f32, indoors: bool) -> f32 { let dawn = (1.0 - ((minute - 390.0) / 90.0).abs()).clamp(0.0, 1.0); let dawn = dawn * dawn * (3.0 - 2.0 * dawn); @@ -249,6 +254,8 @@ fn update_fog( With, >, suns: Query<(Entity, &DirectionalLight), With>, + // MONKEY (visualfix): ocean surfaces, for the haze's sea-level plane. + water: Query<&benilla_world::liquid::WaterChunkInfo>, ) { 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); @@ -303,13 +310,24 @@ fn update_fog( continue; } let fog = Vec3::from_array(lighting.fog_color); + // MONKEY (visualfix): 1 when the eye is above an ocean in reach. Water writes no depth, so + // without this the haze measured the SEABED behind it, and water over no seabed read as + // sky and took none: the underwater slope of an island drew a hard wedge across the sea. + let sea = camera_transform.map_or(0.0, |t| { + let eye = benilla_assets::coords::bevy_to_wow(t.translation()); + let over = eye[2] > SEA_LEVEL_MIN_EYE + && water + .iter() + .any(|w| w.ocean_within(eye[0], eye[1], SEA_PLANE_REACH)); + if over && tier != 0 { 1.0 } else { 0.0 } + }); // 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)); commands.entity(entity).insert(FogView { 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_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, sea), lamp_positions: lamps.positions, lamp_colors: lamps.colors, color_density: fog.extend( @@ -602,7 +620,8 @@ fn visibility(light_index: u32, p: vec3) -> f32 { i32((*light).depth_texture_base_index + cascade), ndc.z); } // Unknown outside caster coverage is not evidence of direct sunlight. - return 0.0; + // MONKEY (visualfix): -1 marks "unknown"; the march fills it from the ray's covered samples. + return -1.0; } // MONKEY (lampfog): a mild Henyey-Greenstein lobe. The inverse-square term is integrated exactly; @@ -661,7 +680,21 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { let z = textureLoad(depth, xy, 0); let uv = (pixel.xy - view.viewport.xy) / view.viewport.zw; 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; + var delta = q.xyz / q.w - view.world_position; + // MONKEY (visualfix): over an ocean, a point below sea level (seabed, or no depth at all under + // the horizon) is hazed at the ocean surface (Bevy y = 0), where the eye actually meets it. + var sea_surface = false; + if (fog.lamp_meta.w > 0.5 && view.world_position.y > 0.0) { + let down = normalize(delta); + let below = (view.world_position + delta).y < 0.0; + if (down.y < -0.0001 && (below || z <= 0.0)) { + let to_sea = view.world_position.y / -down.y; + if (to_sea < length(delta)) { + delta = down * to_sea; + sea_surface = true; + } + } + } let distance = min(length(delta), 150.0); if (distance <= 10.0 && fog.lamp_meta.x < 0.5) { return source; } let ray = normalize(delta); @@ -674,7 +707,7 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { 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; } + if (z <= 0.0 && !sea_surface) { 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); } @@ -694,12 +727,23 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { 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; } + // MONKEY (visualfix): a sample past the cascade (beyond shadowDistance) takes the + // mean of this ray's covered samples, not 0: a hard 0 cut the sun lobe into rings + // of the far-bound step. A ray that starts in a dark room stays dark. + var known = 0.0; + var known_count = 0.0; 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 seen = visibility(light_index, view.world_position + ray*t); + if (seen >= 0.0) { + known += seen; + known_count += 1.0; + } else { + seen = select(0.0, known / known_count, known_count > 0.0); + } + scatter += seen * exp(-fog.color_density.w * fog_path(t)) * density * step_size; } } let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter; diff --git a/crates/benilla-world/src/liquid/query.rs b/crates/benilla-world/src/liquid/query.rs index 6e56bfaf..9583613d 100644 --- a/crates/benilla-world/src/liquid/query.rs +++ b/crates/benilla-world/src/liquid/query.rs @@ -240,6 +240,16 @@ impl LiquidGrid { } impl WaterChunkInfo { + /// MONKEY (visualfix): an ocean surface (pinned to z = 0) whose footprint comes within `reach` + /// yards of the WoW XY, for the volumetric haze's sea-level plane. + pub fn ocean_within(&self, x: f32, y: f32, reach: f32) -> bool { + matches!(self.kind, LiquidKind::Ocean) + && x > self.min_x - reach + && x < self.max_x + reach + && y > self.min_y - reach + && y < self.max_y + reach + } + /// The grid's highest wet vertex, which `super::real_data` shows is not the surface. #[cfg(test)] pub(super) fn chunk_max_z(&self) -> f32 {