diff --git a/LIGHTING.md b/LIGHTING.md index 03ec8577..49c1ac3f 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` | @@ -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-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/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/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 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 { 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/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 ); } 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); + } }