diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index bc03017f..e8b7b0fa 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -496,6 +496,7 @@ volumetricFog = "Volumetric Fog", -- MONKEY (post): HDR excess + halo quality. bloom = "Bloom", + sunShafts = "Sun Shafts", fogOff = "Off", waterQuality = "Water Quality", waterClassic = "Classic", @@ -537,6 +538,8 @@ -- MONKEY (post): Off guarantees the pre-HDR image. BLOOM = "Makes fire, lava, spells and lit windows carry a soft halo. Low uses a smaller blur chain; High is smoother. Off leaves the original image unchanged.", + SUN_SHAFTS = + "Draws sunlight through gaps in trees and buildings. Scene depth blocks the rays; they fade at night, off-screen and indoors.", VOLUMETRIC_FOG = "Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.", WATER_QUALITY = @@ -623,6 +626,7 @@ lightingQuality = "Качество освещения", -- MONKEY (post): Russian HDR excess + halo quality row. bloom = "Свечение", + sunShafts = "Солнечные лучи", -- MONKEY (volumetric fog): Russian atmospheric quality row. volumetricFog = "Объёмный туман", fogOff = "Выкл", @@ -667,6 +671,8 @@ -- MONKEY (post): localised bloom effect and Off contract. BLOOM = "Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.", + SUN_SHAFTS = + "Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.", -- MONKEY (volumetric fog): localised effect and cost explanation. VOLUMETRIC_FOG = "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.", @@ -805,7 +811,7 @@ -- three of eighteen shadow settings and no hint that the other fifteen existed. AdvancedGraphics = { -- MONKEY (volumetric fog): include in refresh, defaults and search. - "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", + "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", @@ -2628,12 +2634,23 @@ }) - + + - + + OptionsRow_OnLoad(self, "sunShafts", BENILLA_ADVGFX.sunShafts, + "BENILLA_TOOLTIP_SUN_SHAFTS") + + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index ff3c06ce..2a4f80b5 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -923,6 +923,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), // MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical. ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), + ours("sunShafts", "1", "benilla's own: depth-occluded screen-space sun shafts"), ours( "waterQuality", "1", @@ -2012,6 +2013,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "0"), // MONKEY (post): no HDR lift or post pass is the byte-identical baseline. ("bloom", "0"), + ("sunShafts", "0"), ("lavaLightGain", "0"), ("fireLightGain", "0"), ("nightGain", "1.0"), @@ -2037,6 +2039,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "1"), // MONKEY (post): quarter-resolution halo. ("bloom", "1"), + ("sunShafts", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2065,6 +2068,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "1"), // MONKEY (post): quarter-resolution halo. ("bloom", "1"), + ("sunShafts", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2094,6 +2098,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "1"), // MONKEY (post): half-resolution halo. ("bloom", "2"), + ("sunShafts", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2908,12 +2913,13 @@ mod tests { 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] = [ + let lighting: [(&str, f32); 35] = [ ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), // MONKEY (post): weld registry and renderer defaults. ("bloom", shadows.bloom as f32), + ("sunShafts", flag(shadows.sun_shafts)), ("lavaLightGain", shadows.lava_light_gain), // The two sun lanes and the cascade they share. ("worldShadows", flag(shadows.world_shadows)), @@ -2966,10 +2972,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 — 35 rows, 35 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"); + assert_eq!(welded.len(), 35, "the lighting lane welds 35 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), @@ -3147,6 +3153,7 @@ mod tests { "volumetricFog" => video.volumetric_fog as f32, // MONKEY (post): the post lane's live tier. "bloom" => video.bloom as f32, + "sunShafts" => video.sun_shafts as u32 as f32, "lavaLightGain" => video.lava_light_gain, "fireLightGain" => video.fire_light_gain, "moonShadowStrength" => video.moon_shadow_strength, @@ -3999,7 +4006,7 @@ mod tests { strings.sort_unstable(); // the list is the claim, not where the rows sit in the table assert_eq!( strings, - vec!["gxApi", "gxResolution", "realmList", "realmName"] + vec!["gxApi", "gxResolution", "lightingQuality", "realmList", "realmName"] ); let default_of = |name: &str| { REGISTERED diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs index 0fc62e25..3d61ee58 100644 --- a/crates/benilla-app/src/post/mod.rs +++ b/crates/benilla-app/src/post/mod.rs @@ -1,11 +1,13 @@ //! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp. mod bloom; +mod sun_shafts; pub(crate) struct PostPlugin; impl bevy::prelude::Plugin for PostPlugin { fn build(&self, app: &mut bevy::prelude::App) { app.add_plugins(bloom::BloomPlugin); + app.add_plugins(sun_shafts::SunShaftsPlugin); } } diff --git a/crates/benilla-app/src/post/sun_shafts.rs b/crates/benilla-app/src/post/sun_shafts.rs new file mode 100644 index 00000000..f509fd48 --- /dev/null +++ b/crates/benilla-app/src/post/sun_shafts.rs @@ -0,0 +1,315 @@ +//! MONKEY (post): screen-space sun shafts from the visible sky depth. + +use super::bloom::BloomLabel; +use crate::video::VideoConfig; +use benilla_world::{lighting::WowLighting, view::WorldCamera, wmo_portal::CameraInteriorClaim}; +use bevy::{ + core_pipeline::{ + core_3d::graph::{Core3d, Node3d}, + FullscreenShader, + }, + ecs::query::QueryItem, + prelude::*, + render::{ + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + view::{Msaa, ViewDepthTexture, ViewTarget}, + Render, RenderApp, RenderStartup, RenderSystems, + }, +}; + +pub(super) struct SunShaftsPlugin; + +#[derive(Component, Clone, Copy, PartialEq, ExtractComponent, ShaderType)] +struct ShaftView { + // xy = sun UV, z = faded strength, w = sample count. + sun: Vec4, + // Gamma-space sun colour; the framebuffer is still gamma-space here. + color: Vec4, +} + +impl Plugin for SunShaftsPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(ExtractComponentPlugin::::default()) + .add_systems(Last, update_views); + if !app.is_plugin_added::() { + return; + } + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl( + include_str!("sun_shafts.wgsl"), + "post/sun_shafts.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(ShaftShader(shader)) + .init_resource::>() + .add_systems(RenderStartup, init_pipeline) + .add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare)) + .add_render_graph_node::>(Core3d, ShaftLabel) + .add_render_graph_edges( + Core3d, + (Node3d::StartMainPassPostProcessing, ShaftLabel, BloomLabel), + ); + } +} + +fn update_views( + mut commands: Commands, + video: Res, + lighting: Res, + interior: Res, + mut cameras: Query< + ( + Entity, + &Camera, + &GlobalTransform, + &Projection, + &mut Camera3d, + Option<&ShaftView>, + ), + With, + >, +) { + let to_sun = lighting.celestial_dir().normalize_or_zero(); + 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; + if disabled { + if old.is_some() { + commands.entity(entity).remove::(); + } + continue; + } + let far = match projection { + Projection::Perspective(p) => p.far, + _ => 3000.0, + }; + let world = transform.translation() + to_sun * far * 0.85; + let (Ok(screen), Some(size)) = ( + camera.world_to_viewport(transform, world), + camera.logical_viewport_size(), + ) else { + commands.entity(entity).remove::(); + continue; + }; + let uv = screen / size; + // Keep a small off-screen skirt so shafts leave the frame continuously. + let outside = Vec2::new( + (-uv.x).max(uv.x - 1.0).max(0.0), + (-uv.y).max(uv.y - 1.0).max(0.0), + ) + .max_element(); + let edge_fade = 1.0 - smoothstep(0.0, 0.18, outside); + let daylight = smoothstep(-0.02, 0.16, 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 next = ShaftView { + sun: uv.extend(strength).extend(28.0), + color: Vec3::from_array(lighting.diffuse) + .lerp(Vec3::ONE, 0.18) + .extend(0.0), + }; + if old != Some(&next) { + commands.entity(entity).insert(next); + } + } +} + +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) +} + +#[derive(Resource)] +struct ShaftShader(Handle); + +#[derive(Resource)] +struct ShaftPipeline { + layouts: [BindGroupLayoutDescriptor; 2], + shader: Handle, + fullscreen: FullscreenShader, + sampler: Sampler, +} + +#[derive(Component)] +struct ViewShaftPipeline(CachedRenderPipelineId); + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct ShaftLabel; + +fn init_pipeline( + mut commands: Commands, + device: Res, + shader: Res, + fullscreen: Res, +) { + let layouts = [false, true].map(|multisampled| { + BindGroupLayoutDescriptor::new( + "post_sun_shafts_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + }, + uniform_buffer::(false), + ), + ), + ) + }); + commands.insert_resource(ShaftPipeline { + layouts, + shader: shader.0.clone(), + fullscreen: fullscreen.clone(), + sampler: device.create_sampler(&SamplerDescriptor { + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + ..default() + }), + }); +} + +impl SpecializedRenderPipeline for ShaftPipeline { + type Key = (TextureFormat, bool); + + fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor { + RenderPipelineDescriptor { + label: Some("post_sun_shafts".into()), + layout: vec![self.layouts[multisampled as usize].clone()], + vertex: self.fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: self.shader.clone(), + shader_defs: multisampled + .then(|| "MULTISAMPLED".into()) + .into_iter() + .collect(), + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + } + } +} + +fn prepare_pipelines( + mut commands: Commands, + cache: Res, + pipeline: Res, + mut specialized: ResMut>, + views: Query<(Entity, &ViewTarget, &Msaa), With>, +) { + for (entity, target, msaa) in &views { + let id = specialized.specialize( + &cache, + &pipeline, + (target.main_texture_format(), msaa.samples() > 1), + ); + commands.entity(entity).insert(ViewShaftPipeline(id)); + } +} + +#[derive(Default)] +struct ShaftNode; + +impl ViewNode for ShaftNode { + type ViewQuery = ( + &'static ViewTarget, + &'static ViewDepthTexture, + &'static ShaftView, + &'static ViewShaftPipeline, + ); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (target, depth, shaft, id): QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, + ) -> Result<(), NodeRunError> { + let cache = world.resource::(); + let Some(pipeline) = cache.get_render_pipeline(id.0) else { + return Ok(()); + }; + if !depth + .texture + .usage() + .contains(TextureUsages::TEXTURE_BINDING) + { + return Ok(()); + } + let settings = world.resource::(); + let device = context.render_device(); + let mut uniform = UniformBuffer::from(*shaft); + uniform.write_buffer(device, world.resource::()); + let out = target.post_process_write(); + let bind = device.create_bind_group( + "post_sun_shafts", + &cache.get_bind_group_layout( + &settings.layouts[(depth.texture.sample_count() > 1) as usize], + ), + &BindGroupEntries::sequential(( + out.source, + &settings.sampler, + depth.view(), + uniform.binding().unwrap(), + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("post_sun_shafts"), + color_attachments: &[Some(RenderPassColorAttachment { + view: out.destination, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "post_sun_shafts"); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edge_and_night_fades_are_bounded() { + assert_eq!(smoothstep(0.0, 1.0, -1.0), 0.0); + 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); + } +} diff --git a/crates/benilla-app/src/post/sun_shafts.wgsl b/crates/benilla-app/src/post/sun_shafts.wgsl new file mode 100644 index 00000000..c511d20c --- /dev/null +++ b/crates/benilla-app/src/post/sun_shafts.wgsl @@ -0,0 +1,53 @@ +// MONKEY (post): screen-space radial blur through sky pixels in the scene depth. + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var scene: texture_2d; +@group(0) @binding(1) var scene_sampler: sampler; +#ifdef MULTISAMPLED +@group(0) @binding(2) var depth: texture_depth_multisampled_2d; +#else +@group(0) @binding(2) var depth: texture_depth_2d; +#endif + +struct Shafts { + sun: vec4, + color: vec4, +}; +@group(0) @binding(3) var shafts: Shafts; + +fn scene_depth(uv: vec2) -> f32 { + let dims = textureDimensions(depth); + let p = vec2(clamp(uv, vec2(0.0), vec2(0.999999)) * vec2(dims)); +#ifdef MULTISAMPLED + var d = 0.0; + for (var sample = 0u; sample < textureNumSamples(depth); sample++) { + // Reversed Z: any covered sample is enough to block the sky ray. + d = max(d, textureLoad(depth, p, i32(sample))); + } + return d; +#else + return textureLoad(depth, p, 0); +#endif +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let base = textureSample(scene, scene_sampler, in.uv); + let count = u32(shafts.sun.w); + let step_uv = (shafts.sun.xy - in.uv) * (0.88 / shafts.sun.w); + var uv = in.uv; + var decay = 1.0; + var light = 0.0; + var norm = 0.0; + for (var i = 0u; i < 32u; i++) { + if (i >= count) { break; } + uv += step_uv; + decay *= 0.965; + let sky = 1.0 - smoothstep(0.000002, 0.00008, scene_depth(uv)); + light += sky * decay; + norm += decay; + } + let rays = light / max(norm, 0.0001); + return vec4(base.rgb + shafts.color.rgb * rays * shafts.sun.z * 0.16, base.a); +} diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index adf4e587..d1492045 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -224,6 +224,8 @@ pub(crate) struct VideoConfig { pub(crate) volumetric_fog: u8, // MONKEY (post): emissive HDR + bloom quality, 0 Off / 1 Low / 2 High. pub(crate) bloom: u8, + // MONKEY (post): depth-occluded radial sun shafts. + pub(crate) sun_shafts: bool, /// 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, @@ -471,6 +473,8 @@ impl Default for VideoConfig { volumetric_fog: 1, // MONKEY (post): the shipped High graphics preset uses the full-resolution tier. bloom: 2, + // MONKEY (post): part of the shipped High graphics preset. + sun_shafts: true, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -553,6 +557,8 @@ pub(crate) fn on_cvar( "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8, // MONKEY (post): constrain UI/console writes to the supported bloom tiers. "bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8, + // MONKEY (post): the shafts lane is binary. + "sunshafts" => cfg.sun_shafts = v != 0.0, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(),