From bcd3150086d8c1d8a0937efb4bd008d1e9d726db Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 15:55:38 +0100 Subject: [PATCH] MONKEY (leftovers): sun shafts - skybox 0x8 forces them; the sky mask ignores depth-0 below the horizon 0x8: the sky's weight lifts the daylight fade so a low/just-set sun keeps full shafts. Mask: water writes no depth, so the sea under the horizon read as sky and got a shaft lift (Westfall sunset: up to 9.7 levels on the water, 0 above the horizon after). The mask pass now binds the view uniform and keeps depth-0 only where the view ray is above -0.01 elevation. Adds the leftovers-sea-sunset capture scenario. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/capture/scenarios.rs | 10 +++ crates/benilla-app/src/post/sun_shafts.rs | 86 ++++++++++++++++++--- crates/benilla-app/src/post/sun_shafts.wgsl | 21 +++-- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 8aa90ad1..7b5fdd20 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1808,6 +1808,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 0, ui: None, }, + // MONKEY (leftovers): the Westfall sea with the visible sun ~5° up on its fixed 45° bearing + // (minute 1200), framed so the sun sits above the open water: the sun-shaft sky mask test. + Scenario { + name: "leftovers-sea-sunset", + map: Some(MAP_AZEROTH), + eye: [-10500.0, 2112.0, 6.0], + look: [-10420.0, 2192.0, 8.0], + minute: 1200, + ui: None, + }, ]; /// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking diff --git a/crates/benilla-app/src/post/sun_shafts.rs b/crates/benilla-app/src/post/sun_shafts.rs index 77887be3..fdf382f2 100644 --- a/crates/benilla-app/src/post/sun_shafts.rs +++ b/crates/benilla-app/src/post/sun_shafts.rs @@ -6,6 +6,8 @@ use super::bloom::BloomLabel; use crate::video::VideoConfig; use benilla_world::{lighting::WowLighting, view::WorldCamera, wmo_portal::CameraInteriorClaim}; +// MONKEY (leftovers): LightSkybox flag 0x8. +use benilla_world::skybox::CameraSkybox; use bevy::{ core_pipeline::{ core_3d::graph::{Core3d, Node3d}, @@ -36,6 +38,12 @@ struct ShaftView { sun: Vec4, // Gamma-space sun colour; the framebuffer is still gamma-space here. color: Vec4, + // MONKEY (leftovers): the view ray's world height is `dot(horizon.xyz, (ndc.x, ndc.y, 1))` + // over `length((ndc.x * ray.x, ndc.y * ray.y, 1))`; the mask counts a depth-0 pixel as sky + // only above `horizon.w` (sin elevation). Water writes no depth, so the sea read as sky. + horizon: Vec4, + // xy = tan half-FOV per axis (1/P00, 1/P11); zw unused. + ray: Vec4, } impl Plugin for SunShaftsPlugin { @@ -79,6 +87,7 @@ fn update_views( video: Res, lighting: Res, interior: Res, + sky: Option>, mut cameras: Query< ( Entity, @@ -92,6 +101,11 @@ fn update_views( >, ) { let to_sun = lighting.celestial_dir().normalize_or_zero(); + // MONKEY (leftovers): a `0x8` sky forces the shafts: its weight lifts the daylight ramp, so + // a low or just-set sun keeps full shafts (a sun far below the horizon still ends them). + let force = sky.map_or(0.0, |s| { + s.flag_weight(benilla_formats::SKYBOX_FORCE_SUN_SHAFTS) + }); for (entity, camera, transform, projection, mut camera3d, old) in &mut cameras { let disabled = !video.sun_shafts || !camera.is_active || interior.0.is_some() || to_sun == Vec3::ZERO; @@ -121,18 +135,22 @@ fn update_views( ) .max_element(); let edge_fade = 1.0 - smoothstep(0.0, 0.18, outside); - let daylight = smoothstep(-0.02, 0.16, to_sun.y); + let daylight = smoothstep(-0.02, 0.16, to_sun.y) + .max(force.clamp(0.0, 1.0) * smoothstep(-0.12, -0.02, to_sun.y)); let strength = edge_fade * daylight; if strength <= 0.001 { commands.entity(entity).remove::(); continue; } camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits(); + let (horizon, ray) = horizon_row(transform, projection); let next = ShaftView { sun: uv.extend(strength).extend(28.0), color: Vec3::from_array(lighting.diffuse) .lerp(Vec3::ONE, 0.18) .extend(0.0), + horizon, + ray, }; if old != Some(&next) { commands.entity(entity).insert(next); @@ -140,6 +158,22 @@ fn update_views( } } +/// MONKEY (leftovers): the view ray's world-up component as a linear form in NDC, plus the per-axis +/// ray scale to normalise it. A pixel is sky only above `-0.01` (≈ 0.6° below the horizon): a sun +/// just under the horizon still finds sky next to it, the sea below it never does. +fn horizon_row(transform: &GlobalTransform, projection: &Projection) -> (Vec4, Vec4) { + let Projection::Perspective(p) = projection else { + // No horizon test: `(0, 0, 1)` is always "up". + return (Vec4::new(0.0, 0.0, 1.0, -2.0), Vec4::new(0.0, 0.0, 0.0, 0.0)); + }; + let ty = (p.fov * 0.5).tan(); + let tx = ty * p.aspect_ratio; + let rot = transform.affine().matrix3; + // View dir = right·x·tx + up·y·ty − back; only its world Y matters. + let row = Vec3::new(rot.x_axis.y * tx, rot.y_axis.y * ty, -rot.z_axis.y); + (row.extend(-0.01), Vec4::new(tx, ty, 0.0, 0.0)) +} + fn smoothstep(a: f32, b: f32, value: f32) -> f32 { let t = ((value - a) / (b - a)).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) @@ -197,13 +231,17 @@ fn init_pipeline( let mask_layouts = [false, true].map(|multisampled| { BindGroupLayoutDescriptor::new( "post_sun_shafts_mask_layout", - &BindGroupLayoutEntries::single( + // MONKEY (leftovers): + the view uniform, for the horizon row. + &BindGroupLayoutEntries::sequential( ShaderStages::FRAGMENT, - if multisampled { - texture_depth_2d_multisampled() - } else { - texture_depth_2d() - }, + ( + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + }, + uniform_buffer::(false), + ), ), ) }); @@ -302,7 +340,12 @@ fn prepare_masks( let Some(size) = camera.physical_viewport_size else { continue; }; - let rows = [shaft.sun.to_array(), shaft.color.to_array()]; + let rows = [ + shaft.sun.to_array(), + shaft.color.to_array(), + shaft.horizon.to_array(), + shaft.ray.to_array(), + ]; match uniform { Some(uniform) => queue.write_buffer(&uniform.0, 0, bytemuck::cast_slice(&rows)), None => { @@ -376,7 +419,7 @@ impl ViewNode for ShaftNode { let mask_bind = device.create_bind_group( "post_sun_shafts_mask", &cache.get_bind_group_layout(&settings.mask_layouts[multisampled]), - &BindGroupEntries::single(depth.view()), + &BindGroupEntries::sequential((depth.view(), uniform.0.as_entire_binding())), ); let out = target.post_process_write(); let bind = device.create_bind_group( @@ -444,4 +487,29 @@ mod tests { assert_eq!(smoothstep(0.0, 1.0, 2.0), 1.0); assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1.0e-6); } + + /// MONKEY (leftovers): the shader's horizon form, evaluated on the CPU, is the view ray's + /// world-up sine: the screen centre of a level camera sits on the horizon, a pitched-down one + /// sees below it, and the top/bottom edges sit at ±half the vertical FOV. + #[test] + fn horizon_row_is_the_view_ray_elevation() { + let p = PerspectiveProjection { + fov: 1.0, + aspect_ratio: 16.0 / 9.0, + ..default() + }; + let proj = Projection::Perspective(p.clone()); + let up = |t: &Transform, ndc: Vec2| { + let (h, r) = horizon_row(&GlobalTransform::from(*t), &proj); + h.truncate().dot(ndc.extend(1.0)) / (ndc * r.truncate().truncate()).extend(1.0).length() + }; + let level = Transform::default().looking_to(Vec3::X, Vec3::Y); + assert!(up(&level, Vec2::ZERO).abs() < 1e-5); + assert!((up(&level, Vec2::new(0.0, 1.0)) - 0.5f32.sin()).abs() < 1e-4); + assert!((up(&level, Vec2::new(0.0, -1.0)) + 0.5f32.sin()).abs() < 1e-4); + let down = Transform::default().looking_to(Vec3::new(1.0, -0.3, 0.0), Vec3::Y); + let dir = Vec3::new(1.0, -0.3, 0.0).normalize(); + assert!((up(&down, Vec2::ZERO) - dir.y).abs() < 1e-5); + assert!(up(&down, Vec2::ZERO) < -0.01, "sea below the horizon is not sky"); + } } diff --git a/crates/benilla-app/src/post/sun_shafts.wgsl b/crates/benilla-app/src/post/sun_shafts.wgsl index f7dec61f..845a3a88 100644 --- a/crates/benilla-app/src/post/sun_shafts.wgsl +++ b/crates/benilla-app/src/post/sun_shafts.wgsl @@ -4,6 +4,14 @@ #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +struct Shafts { + sun: vec4, + color: vec4, + // MONKEY (leftovers): world-up of the view ray in NDC (xyz) and the sky threshold (w). + horizon: vec4, + ray: vec4, +}; + #ifdef SHAFT_MASK #ifdef MULTISAMPLED @@ -11,6 +19,7 @@ #else @group(0) @binding(0) var depth: texture_depth_2d; #endif +@group(0) @binding(1) var shafts: Shafts; @fragment fn fs_mask(in: FullscreenVertexOutput) -> @location(0) vec4 { @@ -19,7 +28,12 @@ fn fs_mask(in: FullscreenVertexOutput) -> @location(0) vec4 { // MONKEY (fix-post): one sample, not a max over all of them: the march is a blur. // Only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z) is sky; a ramp on // `near / z` would move with the live `nearclip` cvar and pour shafts through far terrain. - let sky = select(0.0, 1.0, textureLoad(depth, p, 0) <= 1.0e-7); + // MONKEY (leftovers): water writes no depth, so depth 0 under the horizon is the sea (or the + // far-clipped ground), never sky. + let ndc = (vec2(p) + 0.5) / vec2(dims) * vec2(2.0, -2.0) + vec2(-1.0, 1.0); + let up = dot(shafts.horizon.xyz, vec3(ndc, 1.0)) + / length(vec3(ndc * shafts.ray.xy, 1.0)); + let sky = select(0.0, 1.0, textureLoad(depth, p, 0) <= 1.0e-7 && up > shafts.horizon.w); return vec4(sky, 0.0, 0.0, 1.0); } @@ -28,11 +42,6 @@ fn fs_mask(in: FullscreenVertexOutput) -> @location(0) vec4 { @group(0) @binding(0) var scene: texture_2d; @group(0) @binding(1) var scene_sampler: sampler; @group(0) @binding(2) var mask: texture_2d; - -struct Shafts { - sun: vec4, - color: vec4, -}; @group(0) @binding(3) var shafts: Shafts; @fragment