From 218ff0cfc0c94cb11974404d650a9772508fb98a Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 26 Sep 2026 15:55:15 +0100
Subject: [PATCH 1/4] MONKEY (leftovers): LightSkybox flags 0x8/0x10 plumbing;
0x10 suppresses the Modern sun-fog lobe
SKYBOX_FORCE_SUN_SHAFTS (0x8) and SKYBOX_NO_SUN_FOG (0x10) constants, CameraSkybox::flag_weight
(heaviest main layer carrying a flag). update_fog_model scales sun_fog_strength by 1 - weight of a
0x10 sky. LIGHTING.md documents both flags (0x8 consumer lands in the next commit).
Co-Authored-By: Claude Opus 5.5
---
LIGHTING.md | 2 +-
crates/benilla-formats/src/lib.rs | 6 +++-
crates/benilla-formats/src/light.rs | 6 +++-
crates/benilla-formats/src/light/skybox.rs | 4 +++
.../benilla-world/src/lighting/fog_model.rs | 5 ++++
crates/benilla-world/src/skybox.rs | 28 +++++++++++++++++++
6 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/LIGHTING.md b/LIGHTING.md
index 651e4aa7..7a182968 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -35,7 +35,7 @@ behind each constant.
| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
| Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` |
| City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` |
-| Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` |
+| Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, `0x8` forces the screen-space sun shafts through a low or just-set sun (the sky's weight lifts their daylight fade; `post/sun_shafts.rs`), `0x10` fades the Modern fog's sun-colour lobe out by the sky's weight (`lighting/fog_model.rs`), plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
`zoneSkyboxes = 0` disables only the added living-player zone slot. WMO and ghost skyboxes are
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index c47b7f3b..39c7d54d 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -135,7 +135,11 @@ pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_S
// MONKEY (fog): `LightFogBand.dbc`.
pub use light::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
// MONKEY (skybox): the skybox row and the zone walk's entries.
-pub use light::{SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
+// MONKEY (leftovers): + the 0x8 / 0x10 flags.
+pub use light::{
+ SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FORCE_SUN_SHAFTS, SKYBOX_FULL_DAY,
+ SKYBOX_KEEP_CELESTIAL, SKYBOX_NO_SUN_FOG,
+};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs
index c9a016f5..b07c4be6 100644
--- a/crates/benilla-formats/src/light.rs
+++ b/crates/benilla-formats/src/light.rs
@@ -19,7 +19,11 @@ mod tables;
pub use atmosphere::Atmosphere;
pub use fog_band::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
-pub use skybox::{SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
+// MONKEY (leftovers): + the 0x8 / 0x10 flags.
+pub use skybox::{
+ SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FORCE_SUN_SHAFTS, SKYBOX_FULL_DAY,
+ SKYBOX_KEEP_CELESTIAL, SKYBOX_NO_SUN_FOG,
+};
use atmosphere::{
FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE,
IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP,
diff --git a/crates/benilla-formats/src/light/skybox.rs b/crates/benilla-formats/src/light/skybox.rs
index ab6c319a..cca12efd 100644
--- a/crates/benilla-formats/src/light/skybox.rs
+++ b/crates/benilla-formats/src/light/skybox.rs
@@ -26,6 +26,10 @@ pub const SKYBOX_FULL_DAY: u32 = 0x1;
pub const SKYBOX_KEEP_CELESTIAL: u32 = 0x2;
/// Flag `0x4`: an alpha cone in the final fog colour draws over the model's horizon.
pub const SKYBOX_FOG_BLEND: u32 = 0x4;
+/// MONKEY (leftovers): flag `0x8`: sun shafts run under this sky even where they would fade.
+pub const SKYBOX_FORCE_SUN_SHAFTS: u32 = 0x8;
+/// MONKEY (leftovers): flag `0x10`: the fog's sun-colour lobe is off under this sky.
+pub const SKYBOX_NO_SUN_FOG: u32 = 0x10;
/// One `LightSkybox.dbc` row.
#[derive(Clone, Debug, PartialEq, Eq)]
diff --git a/crates/benilla-world/src/lighting/fog_model.rs b/crates/benilla-world/src/lighting/fog_model.rs
index 22a0d21b..4ff8249d 100644
--- a/crates/benilla-world/src/lighting/fog_model.rs
+++ b/crates/benilla-world/src/lighting/fog_model.rs
@@ -204,6 +204,8 @@ pub(super) fn update_fog_model(
eye_liquid: crate::liquid::EyeLiquid,
viewer: Res,
weather: Option>,
+ // MONKEY (leftovers): LightSkybox flag 0x10 (last frame's backdrop; the skybox resolves later).
+ sky: Option>,
mut frame: ResMut,
) {
let derived = derived_band(&lighting);
@@ -233,6 +235,9 @@ pub(super) fn update_fog_model(
};
let mut next = *frame;
fill_frame(&mut next, setting.0, &lighting, &band);
+ // MONKEY (leftovers): a `0x10` skybox fades the sun-fog lobe out by its weight.
+ let no_sun = sky.map_or(0.0, |s| s.flag_weight(benilla_formats::SKYBOX_NO_SUN_FOG));
+ next.sun_fog_strength *= 1.0 - no_sun.clamp(0.0, 1.0);
if *frame != next {
*frame = next;
}
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index 9df4635a..3029dc01 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -92,6 +92,15 @@ impl CameraSkybox {
})
}
+ /// MONKEY (leftovers): the heaviest main layer's weight among those carrying `flag`, 0 if none.
+ pub fn flag_weight(&self, flag: u32) -> f32 {
+ self.0
+ .iter()
+ .filter(|l| !l.celestial && l.flags & flag != 0)
+ .map(|l| l.weight)
+ .fold(0.0, f32::max)
+ }
+
fn layer(&self, path: &str) -> Option<&SkyboxLayer> {
self.0.iter().find(|l| l.path == path)
}
@@ -1084,4 +1093,23 @@ mod tests {
});
assert_eq!(sky.primary().map(|l| l.path.as_str()), Some("b"));
}
+
+ /// MONKEY (leftovers): a flag's weight is its heaviest main carrier; celestial layers and
+ /// layers without the flag do not count.
+ #[test]
+ fn flag_weight_is_the_heaviest_main_carrier() {
+ let with = |path, w, flags| SkyboxLayer {
+ flags,
+ ..layer(path, w)
+ };
+ let mut sky = CameraSkybox(vec![with("a", 0.3, 0x8), with("b", 0.7, 0x1), with("c", 0.5, 0x18)]);
+ sky.0.push(SkyboxLayer {
+ celestial: true,
+ ..with("d", 1.0, 0x18)
+ });
+ assert_eq!(sky.flag_weight(benilla_formats::SKYBOX_FORCE_SUN_SHAFTS), 0.5);
+ assert_eq!(sky.flag_weight(benilla_formats::SKYBOX_NO_SUN_FOG), 0.5);
+ assert_eq!(sky.flag_weight(benilla_formats::SKYBOX_FOG_BLEND), 0.0);
+ assert_eq!(CameraSkybox::default().flag_weight(0x8), 0.0);
+ }
}
From ffaa7863d3164c70209f2326612bed9a23d8006f Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 26 Sep 2026 15:55:38 +0100
Subject: [PATCH 2/4] 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
From af38c6eec8e63ea03194a1e865b73337ebf6886e Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 26 Sep 2026 16:51:30 +0100
Subject: [PATCH 3/4] MONKEY (leftovers): warm the two sun-shadow proxy lanes
in the menagerie
ShadowCasterMaterial and CutoutShadowCasterMaterial get one warm rig each (production attribute
sets, layers 0+31: the world camera mints the forward pipeline, a live sun rig's shadow views the
depth one). The cutout pair (MAY_DISCARD opaque + prepass) otherwise first compiles when the first
leaf card enters sun-rig reach, after the cover lifts. Fixes the failing
pipe_warm::menagerie::every_material_lane_has_a_warm_contributor census for real.
Co-Authored-By: Claude Opus 5.5
---
crates/benilla-app/src/pipe_warm/menagerie.rs | 51 +++++++++++++++++++
crates/benilla-app/src/world_shadow.rs | 5 +-
2 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs
index 8701048e..43b0195b 100644
--- a/crates/benilla-app/src/pipe_warm/menagerie.rs
+++ b/crates/benilla-app/src/pipe_warm/menagerie.rs
@@ -63,6 +63,11 @@ pub(super) struct WarmLanes<'w> {
/// The UI quad store (2262): the minimap interior composite's tile material is built here
/// through its own production builder, for the tile-quad warm rig below.
ui_quads: ResMut<'w, Assets>,
+ /// MONKEY (leftovers): the two sun-shadow proxy lanes, `ShadowCasterMaterial` (solid) and
+ /// `CutoutShadowCasterMaterial` (alpha-tested leaves).
+ /// `Option`: an app without the shadow plugins (tests, the glue) has no store.
+ shadow_solid: Option>>,
+ shadow_cutout: Option>>,
}
/// One portrait/paperdoll booth camera + its layer ([`crate::portrait`], 0938): the booths run
@@ -700,6 +705,52 @@ pub(super) fn spawn_menagerie(
));
count += 1;
+ // MONKEY (leftovers): the sun-shadow proxy lanes. Production proxies live on the private
+ // layer 31, which the sun rig's directional light casts from and the world camera also draws
+ // (forward discards every fragment). The solid proxy usually compiles at entry anyway (the
+ // player's own caster), but the cutout one first appears with the first leaf card in reach,
+ // which can be long after the cover lifts. Each rig sits on layers 0 and 31: the world camera
+ // mints the forward pipeline, the shadow views (when a sun rig is live) the depth one. Meshes
+ // are the production attribute sets (`empty_shadow_mesh` / `empty_cutout_mesh`), non-empty.
+ let proxy_layers = bevy::camera::visibility::RenderLayers::from_layers(&[
+ 0,
+ crate::shadow_core::PLAYER_SHADOW_LAYER,
+ ]);
+ let tri = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]];
+ let mut solid_mesh = crate::shadow_core::empty_shadow_mesh();
+ solid_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, tri.to_vec());
+ solid_mesh.insert_indices(Indices::U32(vec![0, 1, 2]));
+ let mut cutout_mesh = crate::shadow_core::empty_cutout_mesh();
+ cutout_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, tri.to_vec());
+ cutout_mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.0f32, 0.0], [1.0, 0.0], [1.0, 1.0]]);
+ cutout_mesh.insert_indices(Indices::U32(vec![0, 1, 2]));
+ if let Some(store) = lanes.shadow_solid.as_mut() {
+ let solid = store.add(crate::shadow_core::ShadowCasterMaterial {});
+ spawn_lane_rig(
+ commands,
+ cam,
+ Some(proxy_layers.clone()),
+ &meshes.add(solid_mesh),
+ None,
+ solid,
+ &mut count,
+ );
+ }
+ if let Some(store) = lanes.shadow_cutout.as_mut() {
+ // A real stand-in leaf, so the `#[texture]` binding never lands on the retry path.
+ let leaf = lanes.images.add(Image::default());
+ let cutout = store.add(crate::world_shadow::CutoutShadowCasterMaterial { leaf });
+ spawn_lane_rig(
+ commands,
+ cam,
+ Some(proxy_layers),
+ &meshes.add(cutout_mesh),
+ None,
+ cutout,
+ &mut count,
+ );
+ }
+
count
}
diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs
index e04aa5a4..8ab4a256 100644
--- a/crates/benilla-app/src/world_shadow.rs
+++ b/crates/benilla-app/src/world_shadow.rs
@@ -84,13 +84,14 @@ fn refresh_streamed_shadows(
/// `prepass_fragment_shader` makes the otherwise depth-only directional-shadow pass run a fragment —
/// the exact pair Bevy's prepass specializer requires to alpha-test in the shadow map.
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
-struct CutoutShadowCasterMaterial {
+// MONKEY (leftovers): crate-visible for the pipe_warm menagerie.
+pub(crate) struct CutoutShadowCasterMaterial {
/// The leaf sheet, bound at material binding 0/1. Reusing the SAME image handle the forward pass
/// draws makes the sampler inherit that image's clamp/repeat address mode, so the shadow
/// silhouette matches the visible foliage (a cutout card's out-of-range UVs must clamp).
#[texture(0)]
#[sampler(1)]
- leaf: Handle,
+ pub(crate) leaf: Handle,
}
impl Material for CutoutShadowCasterMaterial {
From bd756f2a72939c94888c02eec0fccbb1c24097cd Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 26 Sep 2026 17:09:13 +0100
Subject: [PATCH 4/4] MONKEY (leftovers): torchTerrainShadows evidence - census
light height, clearance, near count
lamp_terrain_occlusion_scan takes an optional light height and ray clearance, counts blocked
samples within 15 yd, prints the ground height, and ranks by the near count. Used to pick the
leftovers B1 scenes (Elwynn campfires, lampposts, bridge lamp): the gather and the terrain
receiver work, the terrain-blocked ground is grazing or at the rim, so the lit image is unchanged.
LIGHTING.md says so.
Co-Authored-By: Claude Opus 5.5
---
LIGHTING.md | 2 +-
.../src/lighting/daylight/census.rs | 21 +++++++++++++------
2 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/LIGHTING.md b/LIGHTING.md
index 7a182968..201dd43b 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -20,7 +20,7 @@ behind each constant.
| Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` |
| Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` |
| Daylight and doorways | calibrated fixtures at doors, windows and open boundaries carry daylight into a room, and doorways between rooms bleed light | `interiorDaylight`, env `WOW_DAYLIGHT`, `WOW_BLEED` |
-| Terrain in torch shadows | the ground casts into an outdoor fire's cube map, so a hill or bank blocks the fire (settled exterior slots only; `torch_terrain.rs` gathers the resident MCNK chunks inside the cube's 48 yd) | `torchTerrainShadows` (default 0, High 1), env `WOW_TORCH_TERRAIN=0\|1` |
+| Terrain in torch shadows | the ground casts into an outdoor fire's cube map, so a hill or bank blocks the fire (settled exterior slots only; `torch_terrain.rs` gathers the resident MCNK chunks inside the cube's 48 yd; the gather and the ground receiver both work, but stock Elwynn lamps/campfires show no visible change: the terrain-blocked ground is grazing (N·L ≈ 0, inside the 0.15 yd normal offset) or at the pool rim, below one 8-bit step — measured by the `lamp_terrain_occlusion_scan` census) | `torchTerrainShadows` (default 0, High 1), env `WOW_TORCH_TERRAIN=0\|1` |
| City day floor | `interiorDaylight`'s room floor also reaches Stormwind/Ironforge rooms that the portal graph connects to the sky (an exterior-facing portal, or one hop from one; `district_sky_rooms` in `daylight.rs`); before this no city room could take it | `interiorDaylight` (default 0, suggested High 0.10) |
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
diff --git a/crates/benilla-world/src/lighting/daylight/census.rs b/crates/benilla-world/src/lighting/daylight/census.rs
index 71101c25..63481c1b 100644
--- a/crates/benilla-world/src/lighting/daylight/census.rs
+++ b/crates/benilla-world/src/lighting/daylight/census.rs
@@ -306,10 +306,18 @@ fn lamp_terrain_occlusion_scan() {
.find_map(|c| benilla_formats::terrain_height_at(c, [x, y, 1.0e4]))
};
let mut rows = Vec::new();
+ // MONKEY (leftovers): optional 6th field = light height above the doodad origin (default the
+ // lamppost's 3.5 yd; a campfire's flame sits near 1 yd).
+ let lift = p.get(5).and_then(|v| v.parse::().ok()).unwrap_or(3.5);
+ // 7th field = how far the ground must rise above the ray to count (default 5 cm; the receiver's
+ // normal offset is 0.15 yd, so a grazing 5 cm crest is below what a cube map can resolve).
+ let clear = p.get(6).and_then(|v| v.parse::().ok()).unwrap_or(0.05);
for l in lamps {
- let head = Vec3::new(l[0], l[1], l[2] + 3.5);
+ let head = Vec3::new(l[0], l[1], l[2] + lift);
let (mut total, mut blocked) = (0u32, 0u32);
let mut best = (0.0f32, 0.0f32);
+ // MONKEY (leftovers): blocked samples inside 15 yd, where a fire's pool is still visible.
+ let mut near = 0u32;
for ring in 1..=10 {
let d = ring as f32 * 2.5;
for k in 0..36 {
@@ -321,22 +329,23 @@ fn lamp_terrain_occlusion_scan() {
let steps = (d / 0.5) as i32;
let hit = (1..steps).any(|s| {
let q = head.lerp(target, s as f32 / steps as f32);
- h(q.x, q.y).is_some_and(|g| g > q.z + 0.05)
+ h(q.x, q.y).is_some_and(|g| g > q.z + clear)
});
if hit {
blocked += 1;
best = (a.to_degrees(), d);
+ near += (d <= 15.0) as u32;
}
}
}
if total > 0 {
- rows.push((blocked as f32 / total as f32, l, best));
+ rows.push((blocked as f32 / total as f32, l, best, near, h(l[0], l[1]).unwrap_or(f32::NAN)));
}
}
- rows.sort_by(|a, b| b.0.total_cmp(&a.0));
- for (f, l, (a, d)) in rows.iter().take(12) {
+ rows.sort_by(|a, b| b.3.cmp(&a.3).then(b.0.total_cmp(&a.0)));
+ for (f, l, (a, d), near, g) in rows.iter().take(12) {
println!(
- "lamp ({:8.1},{:7.1},{:6.1}) terrain-blocked {:5.1}% e.g. bearing {a:5.0} deg at {d:4.1} yd",
+ "lamp ({:8.1},{:7.1},{:6.1}) ground {g:6.2} terrain-blocked {:5.1}% ({near} within 15 yd) e.g. bearing {a:5.0} deg at {d:4.1} yd",
l[0], l[1], l[2], f * 100.0
);
}