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(),