From e6e8cfdca174a5b804017675bb07989830eb83db Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 9 Nov 2026 17:44:01 -0700 Subject: [PATCH] weather: rain lands on what is DRAWN, not on the city's invisible lids grom, 2026-11-09: *"in lots of the city rain doesn't fall all the way to the ground, almost like it hits an invisible roof"*. There is one, it is literal, and this is it. **The defect, measured.** Two capture shots of one fixed pose three clock steps apart isolate the rain exactly -- the scene cancels, only drops move. Over a Stormwind street, by sixths of the frame from sky to foreground: 2.46% 1.18% 0.08% 0.000% 0.000% 0.000% Nothing below the horizon. No streaks, no splashes. The ground oracle said why: 75% of its cells reported ground at or OVER the eye, median +11.5 yd, the camera's own cell among them, and the ray stopped on a collider whose layer was `Walk` and whose box is a kilometre square -- Stormwind's own walking trimesh -- while the terrain leg reported real ground 43.5 yd BELOW. Looking straight up from that stand draws open sky. The face is not rendered. **Why there is such a face.** `examples/wmo_faces` (added here) reads the MOPY census off the model: `stormwind.wmo` carries **63,583 faces flagged `COLLISION` with no `RENDER`, 16,000 of them horizontal** -- invisible walls, and the lids over its streets. A body must stop on those. A raindrop must not, and the reference says so in its own structure: its BSP ray walk rejects `0x08` where its walking box query keeps it, which this tree had already written down for the footprint ray (`FOOTPRINT_REJECT`). **The fix.** A WMO's camera/line-of-sight faces split at load into the half that is drawn and the collision-only sheets, no triangle duplicated, each spawned on its own layer. `camera_filter` takes both, so the camera is what it was. A new `weather_filter` takes the drawn half plus the default layer, and both weather oracles use it: the drop's ground probe and the wetness grid, which was being shaded dry by the same lids. **The CAMERA set, and the first attempt proves why it matters.** I split the walking set first. It was wrong: a vanilla WMO draws its world with `DETAIL` faces (`DETAIL|RENDER` is Stormwind's largest class at 327,700) and the walking gather drops `DETAIL`, so the floor you stand on is in that set only as its collision sheet. Stripping the sheets took the street with them and the oracle moved to -43.5 -- the canal floor under the city, with the rain falling through the pavement. The camera gather keeps `DETAIL`, so its drawn half is the surface the eye actually sees. **After**, same pose, same bands: 3.05% 2.44% 2.47% 1.04% 0.26% 0.16% and the oracle now stops at -1.4 yd on the drawn camera layer: the street. Rain reaches the ground, splash rings land on the pavement, and the pavement is wet. Gates: `benilla-world --lib` is 686 passed / 6 failed, byte for byte what clean HEAD gives (checked by stashing); `benilla-formats`'s `blob_shadow_box` red is pre-existing the same way. fmt and clippy clean on the three crates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/capture/scenarios.rs | 12 ++ crates/benilla-app/src/minimap/interior.rs | 2 + crates/benilla-assets/src/wmo.rs | 35 +++- crates/benilla-formats/examples/wmo_faces.rs | 153 ++++++++++++++++++ crates/benilla-formats/src/lib.rs | 15 +- .../benilla-formats/src/models/collision.rs | 67 ++++++++ crates/benilla-world/src/collision.rs | 37 ++++- .../src/terrain_stream/spawn/mod.rs | 21 ++- .../benilla-world/src/weather/precip/mod.rs | 2 +- crates/benilla-world/src/weather/shelter.rs | 8 +- .../benilla-world/src/wmo_portal/audit/mod.rs | 1 + .../benilla-world/src/wmo_portal/interior.rs | 1 + crates/benilla-world/src/wmo_portal/mod.rs | 1 + 13 files changed, 339 insertions(+), 16 deletions(-) create mode 100644 crates/benilla-formats/examples/wmo_faces.rs diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 20438a31..5d66a112 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1377,6 +1377,18 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: None, }, + // Straight up from `grom-rain-street`'s stand. The ground oracle stops a raindrop 11.5 yd + // over this eye (`weather::precip`'s census); this scene asks whether anything is DRAWN + // there, which is the whole question -- a face that collides and is not rendered is an + // invisible roof, and a visible one means the rain is landing where it should. + Scenario { + name: "grom-rain-up", + map: Some(MAP_AZEROTH), + eye: [-8574.0, 536.0, 103.0], + look: [-8573.0, 537.0, 160.0], + minute: 720, + ui: None, + }, Scenario { name: "grom-rain-back", map: Some(MAP_AZEROTH), diff --git a/crates/benilla-app/src/minimap/interior.rs b/crates/benilla-app/src/minimap/interior.rs index e7d05045..e13e3bf8 100644 --- a/crates/benilla-app/src/minimap/interior.rs +++ b/crates/benilla-app/src/minimap/interior.rs @@ -205,6 +205,7 @@ mod tests { collision_bounds: None, collision: None, collision_camera: None, + collision_camera_hidden: None, doodads: Vec::new(), doodad_sets: Vec::new(), lights: Vec::new(), @@ -293,6 +294,7 @@ mod tests { collision_bounds: None, collision: None, collision_camera: None, + collision_camera_hidden: None, doodads: Vec::new(), doodad_sets: Vec::new(), lights: Vec::new(), diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 77ea067c..32361f3b 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -7,7 +7,7 @@ use crate::column_grid::ColumnGrid; use std::sync::Arc; use benilla_formats::{ - accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision, + accumulate_wmo_group_camera_collision_split, accumulate_wmo_group_camera_only_collision, accumulate_wmo_group_collision, parse_wmo_lights, parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes, CollisionMesh, FootprintTris, LiquidMesh, WmoDoodad, WmoDoodadSet, WmoFog, @@ -72,8 +72,15 @@ pub struct WmoModel { pub collision_bounds: Option<([f32; 3], [f32; 3])>, /// Walking collision across all groups in WMO-local coords, DETAIL faces excluded. pub collision: Option, - /// Camera and line-of-sight collision: DETAIL kept, NOCAMCOLLIDE dropped. + /// Camera and line-of-sight collision: DETAIL kept, NOCAMCOLLIDE dropped — the half that is + /// **drawn**, which is every surface the eye sees: streets, roofs, awnings, railings. pub collision_camera: Option, + /// The camera set's **collision-only sheets** (MOPY `COLLISION`, `0x08`): faces that stop a + /// body and are never drawn — invisible walls, and the lids over a city's streets. Held apart + /// from [`Self::collision_camera`] so the weather's ground oracle can ask what a raindrop + /// would land ON rather than what would stop one + /// (`benilla_formats::accumulate_wmo_group_camera_collision_split`). + pub collision_camera_hidden: Option, /// The root's MODD doodads in WMO model space, spawned by doodad set. pub doodads: Vec, /// The MODS ranges into [`Self::doodads`]: set 0 always, plus the one a placement picks. @@ -407,6 +414,8 @@ impl AssetLoader for WmoModelLoader { vec![Vec::new(); root.group_count() as usize]; let mut col_pos: Vec<[f32; 3]> = Vec::new(); let mut col_idx: Vec = Vec::new(); + let mut hid_pos: Vec<[f32; 3]> = Vec::new(); + let mut hid_idx: Vec = Vec::new(); let mut cam_pos: Vec<[f32; 3]> = Vec::new(); let mut cam_idx: Vec = Vec::new(); let mut group_doodad_refs: Vec> = vec![Vec::new(); root.group_count() as usize]; @@ -448,7 +457,22 @@ impl AssetLoader for WmoModelLoader { let base = col_pos.len() as u32; col_pos.extend_from_slice(&gpos); col_idx.extend(gidx.iter().map(|i| i + base)); - accumulate_wmo_group_camera_collision(&gbytes, &mut cam_pos, &mut cam_idx); + // The camera gather splits: what is drawn, and the collision-only sheets. Their + // union is the set it always was, and the weather's ground oracle takes the drawn + // half alone. + let (mut dcam_pos, mut dcam_idx): (Vec<[f32; 3]>, Vec) = (Vec::new(), Vec::new()); + let (mut hcam_pos, mut hcam_idx): (Vec<[f32; 3]>, Vec) = (Vec::new(), Vec::new()); + accumulate_wmo_group_camera_collision_split( + &gbytes, + (&mut dcam_pos, &mut dcam_idx), + (&mut hcam_pos, &mut hcam_idx), + ); + let cbase = cam_pos.len() as u32; + cam_pos.extend_from_slice(&dcam_pos); + cam_idx.extend(dcam_idx.iter().map(|i| i + cbase)); + let hbase = hid_pos.len() as u32; + hid_pos.extend_from_slice(&hcam_pos); + hid_idx.extend(hcam_idx.iter().map(|i| i + hbase)); let (mut dpos, mut didx): (Vec<[f32; 3]>, Vec) = (Vec::new(), Vec::new()); accumulate_wmo_group_camera_only_collision(&gbytes, &mut dpos, &mut didx); if let Some(tris) = group_camera_only_tris.get_mut(gi as usize) { @@ -543,6 +567,10 @@ impl AssetLoader for WmoModelLoader { positions: col_pos, indices: col_idx, }); + let collision_camera_hidden = (!hid_idx.is_empty()).then_some(CollisionMesh { + positions: hid_pos, + indices: hid_idx, + }); let collision_camera = (!cam_idx.is_empty()).then_some(CollisionMesh { positions: cam_pos, indices: cam_idx, @@ -571,6 +599,7 @@ impl AssetLoader for WmoModelLoader { collision_bounds, collision, collision_camera, + collision_camera_hidden, doodads: root.doodads().to_vec(), doodad_sets: root.doodad_sets().to_vec(), lights: parse_wmo_lights(&bytes), diff --git a/crates/benilla-formats/examples/wmo_faces.rs b/crates/benilla-formats/examples/wmo_faces.rs new file mode 100644 index 00000000..bca1f916 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_faces.rs @@ -0,0 +1,153 @@ +//! `cargo run -p benilla-formats --example wmo_faces -- [wmo path]` — a WMO's MOPY face census: +//! how many faces carry each flag combination, how many of those are horizontal, and the heights +//! the horizontal ones cluster at. +//! +//! Written to answer one question (2026-11-09). Rain over a Stormwind street dies 11.5 yd above +//! the eye on the city's **walking** collider while open sky is drawn overhead, so the face it +//! dies on collides and is not rendered. MOPY `0x08` is the reference's own name for exactly that +//! — `models/wmo/group.rs`'s `FOOTPRINT_REJECT` records that the reference's BSP ray walk +//! (`0x6b92b0`/`0x6bc370`) rejects it, "so of a floor's coplanar render and collision sheets only +//! the render one is sampled" — and this turns the guess into a reading. +//! +//! Reads the three chunks it needs out of the group files itself (MOPY, MOVI, MOVT) rather than +//! widening the crate's API for a diagnostic. +#![allow(clippy::cast_possible_truncation)] +use std::collections::BTreeMap; + +use anyhow::Result; + +/// A WMO chunk's payload, by its on-disk reversed magic. +fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { + let mut at = 0usize; + while at + 8 <= bytes.len() { + let id = &bytes[at..at + 4]; + let size = u32::from_le_bytes([bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]]) + as usize; + let body = at + 8; + if body + size > bytes.len() { + return None; + } + if id == magic { + return Some(&bytes[body..body + size]); + } + // MOGP wraps the group's own chunks: step INTO it, past its 68-byte header. + at = if id == b"PGOM" { + body + 68 + } else { + body + size + }; + } + None +} + +/// MOPY bits, by the names `models/collision.rs` and `models/wmo/group.rs` use for them. +fn flag_names(f: u8) -> String { + const BITS: [(u8, &str); 8] = [ + (0x01, "UNK01"), + (0x02, "NOCAMCOLLIDE"), + (0x04, "DETAIL"), + (0x08, "COLLISION"), + (0x10, "HINT"), + (0x20, "RENDER"), + (0x40, "UNK40"), + (0x80, "COLLIDE_HIT"), + ]; + let set: Vec<&str> = BITS + .iter() + .filter(|(b, _)| f & b != 0) + .map(|(_, n)| *n) + .collect(); + if set.is_empty() { + "none".into() + } else { + set.join("|") + } +} + +fn main() -> Result<()> { + let data = benilla_formats::wow_data().expect("set WOW_DATA"); + let mut chain = benilla_formats::Chain::open(&data)?; + let raw: String = std::env::args() + .nth(1) + .unwrap_or_else(|| "world\\wmo\\azeroth\\buildings\\stormwind\\stormwind.wmo".into()); + let root_path = raw.to_ascii_lowercase(); + let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); + + // flags -> (faces, horizontal faces, 1-yd bins of the horizontal ones by model z) + let mut by_flags: BTreeMap)> = BTreeMap::new(); + let mut groups = 0usize; + for gi in 0..512u32 { + let group_path = format!("{stem}_{gi:03}.wmo"); + let Ok(gbytes) = chain.read_file(&group_path) else { + continue; + }; + groups += 1; + let (Some(mopy), Some(movi), Some(movt)) = ( + chunk(&gbytes, b"YPOM"), + chunk(&gbytes, b"IVOM"), + chunk(&gbytes, b"TVOM"), + ) else { + continue; + }; + let verts: Vec<[f32; 3]> = movt + .as_chunks::<12>() + .0 + .iter() + .map(|c| { + let f = |i: usize| f32::from_le_bytes([c[i], c[i + 1], c[i + 2], c[i + 3]]); + [f(0), f(4), f(8)] + }) + .collect(); + let idx: Vec = movi + .as_chunks::<2>() + .0 + .iter() + .map(|c| usize::from(u16::from_le_bytes([c[0], c[1]]))) + .collect(); + for (ti, tri) in idx.as_chunks::<3>().0.iter().enumerate() { + let Some(m) = mopy.as_chunks::<2>().0.get(ti) else { + continue; + }; + let flags = m[0]; + let Some(v): Option> = + tri.iter().map(|&i| verts.get(i).copied()).collect() + else { + continue; + }; + let e1 = [v[1][0] - v[0][0], v[1][1] - v[0][1], v[1][2] - v[0][2]]; + let e2 = [v[2][0] - v[0][0], v[2][1] - v[0][1], v[2][2] - v[0][2]]; + // WMO model space is Z-up, like the ADT's. + let n = [ + e1[1] * e2[2] - e1[2] * e2[1], + e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0], + ]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let entry = by_flags.entry(flags).or_default(); + entry.0 += 1; + if len > 1e-6 && (n[2] / len).abs() > 0.95 { + entry.1 += 1; + let z = (v[0][2] + v[1][2] + v[2][2]) / 3.0; + let band = z.floor() as i32; + *entry.2.entry(band).or_default() += 1; + } + } + } + println!("{root_path}: {groups} group files read"); + println!("\n MOPY name faces horizontal tallest flat bands (model z: faces)"); + for (flags, (n, h, bins)) in &by_flags { + let mut top: Vec<(i32, usize)> = bins.iter().map(|(z, c)| (*z, *c)).collect(); + top.sort_by_key(|&(_, c)| std::cmp::Reverse(c)); + let bands = top + .iter() + .take(4) + .map(|(z, c)| format!("{z}: {c}")) + .collect::>() + .join(", "); + println!( + " 0x{flags:02x} {:<24} {n:>7} {h:>12} {bands}", + flag_names(*flags) + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 860ccc1c..b131af88 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -272,13 +272,14 @@ mod value_track; pub use value_track::{TrackValue, ValueTrack}; mod models; pub use models::{ - accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision, - accumulate_wmo_group_collision, authored_half_height, batch_footprint, glue_art_extent, - hand_grip_finger_poses, load_m2_animation_summary, load_m2_bone_spins, load_m2_bounds, - load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model, load_wmo, - load_wmo_collision_tris, m2_bone_spins, m2_owner_reach, m2_ribbon_emitter_count, - m2_sequence_visible_textures, m2_texture_transform_count, non_separable_billboard_bones, - owner_last_rung, owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary, + accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_collision_split, + accumulate_wmo_group_camera_only_collision, accumulate_wmo_group_collision, + authored_half_height, batch_footprint, glue_art_extent, hand_grip_finger_poses, + load_m2_animation_summary, load_m2_bone_spins, load_m2_bounds, load_m2_collision_hull, + load_m2_mesh, load_m2_mesh_skinned, load_object_model, load_wmo, load_wmo_collision_tris, + m2_bone_spins, m2_owner_reach, m2_ribbon_emitter_count, m2_sequence_visible_textures, + m2_texture_transform_count, non_separable_billboard_bones, owner_last_rung, + owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_pane_cameras, diff --git a/crates/benilla-formats/src/models/collision.rs b/crates/benilla-formats/src/models/collision.rs index 60bd6881..7616c984 100644 --- a/crates/benilla-formats/src/models/collision.rs +++ b/crates/benilla-formats/src/models/collision.rs @@ -80,6 +80,14 @@ pub fn parse_m2_collision_hull(bytes: &[u8]) -> Result { const MOPY_DETAIL: u8 = 0x04; /// MOPY `NOCAMCOLLIDE`, the face bit the camera and line-of-sight gather skips. const MOPY_NOCAMCOLLIDE: u8 = 0x02; +/// MOPY `COLLISION`, a **collision-only sheet**: a face that stops a body and is never drawn. +/// +/// The reference draws a line here that a body and a ray fall on opposite sides of, and +/// `models/wmo/group.rs`'s `FOOTPRINT_REJECT` already records it: its BSP ray walk +/// (`0x6b92b0`/`0x6bc370`) rejects `0x08`, *"so of a floor's coplanar render and collision sheets +/// only the render one is sampled"*, while the walking box query (`0x6bca50`, mask `0x84`) keeps +/// it, because an invisible wall must still stop the player. +const MOPY_COLLISION: u8 = 0x08; /// A WMO's walking-collidable triangles across every group file, in raw WMO-local coordinates: a /// face collides unless it is `DETAIL` ([`MOPY_DETAIL`]). Wider than the vmangos vmap rule @@ -116,6 +124,65 @@ pub fn accumulate_wmo_group_collision( accumulate_wmo_group_faces(group_bytes, MOPY_DETAIL, 0, positions, indices); } +/// One group's camera/line-of-sight faces in one parse, split into the ones that are **drawn** and +/// the **collision-only sheets** ([`MOPY_COLLISION`]). Their union is exactly +/// [`accumulate_wmo_group_camera_collision`]'s set, so a consumer that queries both collides with +/// what it always did; a consumer that queries only the drawn half passes through the invisible +/// ones. +/// +/// **The camera set and not the walking one, and that is the whole point.** A vanilla WMO draws +/// its world with `DETAIL` faces and collides it with `COLLISION` sheets — in `stormwind.wmo`, +/// `DETAIL|RENDER` is the largest class at 327,700 faces, and 63,583 more carry `COLLISION` with +/// no `RENDER`. The walking gather drops `DETAIL`, so the floor a player stands on is in it only +/// as its collision sheet: split THAT and a raindrop falls through the pavement. The camera +/// gather keeps `DETAIL`, so its drawn half is the surface you can actually see — streets, roofs, +/// awnings, railings — which is what a drop should land on. +/// +/// Measured both ways (2026-11-09): rain over a Stormwind street was dying 11.5 yd up on an +/// invisible lid; splitting the walking set moved the ground to 43.5 yd DOWN, the canal floor +/// under the city, because the street itself had gone with it. +pub fn accumulate_wmo_group_camera_collision_split( + group_bytes: &[u8], + drawn: (&mut Vec<[f32; 3]>, &mut Vec), + hidden: (&mut Vec<[f32; 3]>, &mut Vec), +) { + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return; + }; + let (drawn_pos, drawn_idx) = drawn; + let (hidden_pos, hidden_idx) = hidden; + // One remap per output: the same source vertex can land in both halves, as two vertices. + let (mut drawn_map, mut hidden_map): (HashMap, HashMap) = + (HashMap::new(), HashMap::new()); + let n_tri = group.vertex_indices.len() / 3; + for t in 0..n_tri { + let Some(mopy) = group.material_info.get(t) else { + continue; + }; + if mopy.flags & MOPY_NOCAMCOLLIDE != 0 { + continue; + } + let (positions, indices, map) = if mopy.flags & MOPY_COLLISION == 0 { + (&mut *drawn_pos, &mut *drawn_idx, &mut drawn_map) + } else { + (&mut *hidden_pos, &mut *hidden_idx, &mut hidden_map) + }; + for k in 0..3 { + let Some(&vidx) = group.vertex_indices.get(t * 3 + k) else { + continue; + }; + let Some(p) = group.vertex_positions.get(vidx as usize) else { + continue; + }; + let global = *map.entry(vidx).or_insert_with(|| { + positions.push([p.x, p.y, p.z]); + (positions.len() - 1) as u32 + }); + indices.push(global); + } + } +} + /// Append one WMO group file's camera and line-of-sight triangles: every face but `NOCAMCOLLIDE`, /// so a `DETAIL` overhang the player walks under (forge pipes) still stops the camera. pub fn accumulate_wmo_group_camera_collision( diff --git a/crates/benilla-world/src/collision.rs b/crates/benilla-world/src/collision.rs index fcbb5f59..4c94c5b6 100644 --- a/crates/benilla-world/src/collision.rs +++ b/crates/benilla-world/src/collision.rs @@ -33,6 +33,11 @@ pub(crate) enum CollisionLayer { /// collision queries (`0x50e5ec`), and `0x69cc13` reads that nibble to gate the chunk's four /// MCLQ slots (`0x10000` river/lake, `0x20000` ocean, `0x40000` magma, `0x80000` slime). Liquid, + /// A WMO's **collision-only** camera faces (MOPY `COLLISION`): the half of the camera/LOS + /// collider that is never drawn — invisible walls, and the lids over a city's streets. Its own + /// layer so the camera can take it and the weather's ray can leave it + /// ([`WorldCollision::weather_filter`]); declared last so no existing layer's bit moves. + CameraHidden, } /// The camera probe's radius (yd) against the solid world. Deviation: the reference's camera @@ -45,6 +50,12 @@ pub(crate) fn walk_layers() -> CollisionLayers { CollisionLayers::new(CollisionLayer::Walk, LayerMask::ALL) } +/// `CollisionLayers` for the camera collider's collision-only half, which the weather's ground +/// oracle leaves out ([`WorldCollision::weather_filter`]). +pub(crate) fn camera_hidden_layers() -> CollisionLayers { + CollisionLayers::new(CollisionLayer::CameraHidden, LayerMask::ALL) +} + /// `CollisionLayers` for a WMO's camera/LOS collider, which the body query never hits. pub(crate) fn camera_layers() -> CollisionLayers { CollisionLayers::new(CollisionLayer::Camera, LayerMask::ALL) @@ -127,12 +138,36 @@ impl WorldCollision<'_, '_> { )) } + /// What a **raindrop** lands on: the DRAWN world, and nothing else. + /// + /// The camera's faces minus its collision-only sheets ([`CollisionLayer::CameraHidden`]), plus + /// the default layer's terrain and doodads. Not the body's world, for two reasons measured on + /// one Stormwind street (2026-11-09): + /// + /// * The body's set keeps `COLLISION` sheets, so drops were dying 11.5 yd above the pavement + /// on an invisible lid with open sky drawn overhead — grom: *"in lots of the city rain + /// doesn't fall all the way to the ground, almost like it hits an invisible roof"*. + /// * The body's set also DROPS `DETAIL`, which in a vanilla WMO is the drawn world: strip the + /// sheets from it and the street goes too, and the drops fall 43 yd through the pavement to + /// the canal floor. The camera's set keeps `DETAIL`, so its drawn half is every surface the + /// eye sees. + /// + /// The reference draws the same line: its BSP ray walk rejects `0x08` where its walking box + /// query keeps it (`benilla_formats`'s `accumulate_wmo_group_camera_collision_split`). + pub fn weather_filter() -> SpatialQueryFilter { + SpatialQueryFilter::from_mask(LayerMask( + CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(), + )) + } + /// What the camera collides with: the default layer and the WMO camera/LOS colliders, so it /// hits DETAIL faces the body passes and passes NOCAMCOLLIDE faces the body stands on. The /// solid world only: the waterline rides [`waterline_ray`](Self::waterline_ray). pub(crate) fn camera_filter() -> SpatialQueryFilter { SpatialQueryFilter::from_mask(LayerMask( - CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(), + CollisionLayer::Default.to_bits() + | CollisionLayer::Camera.to_bits() + | CollisionLayer::CameraHidden.to_bits(), )) } diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 8e9da4cd..14e3ca18 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -21,7 +21,9 @@ use benilla_formats::{world_to_tile, M2Bounds}; use bevy::camera::primitives::Aabb; use bevy::prelude::*; -use crate::collision::{camera_layers, walk_layers, GroundDecalSurface, PickOccluder}; +use crate::collision::{ + camera_hidden_layers, camera_layers, walk_layers, GroundDecalSurface, PickOccluder, +}; use crate::doodad_anim::wants_rig; use crate::interact::WorldObject; use crate::lighting::SharedLightBuffer; @@ -581,6 +583,23 @@ pub(super) fn spawn_loaded_placements( .id(), ); } + // The camera faces' collision-only half, on its own layer: the camera takes it + // (`camera_filter`) and the weather's ground oracle does not, so a raindrop + // falls through the lids over a city's streets and still lands on every + // surface the eye can see. + if let Some((verts, tris)) = + placement_collider_data(m.collision_camera_hidden.as_ref(), &p.transform) + { + ents.push( + commands + .spawn(PendingCollider::new( + build_collider_task(verts, tris), + Some(camera_hidden_layers()), + true, + )) + .id(), + ); + } // Interior MOLT lights (forge fire, inn fireplaces, chapel candles) — the radiating // sources that light nearby NPCs/doodads AND the building's own walls/floor over // their baked MOCV (decision 0273). diff --git a/crates/benilla-world/src/weather/precip/mod.rs b/crates/benilla-world/src/weather/precip/mod.rs index 4fad6910..3550a3b5 100644 --- a/crates/benilla-world/src/weather/precip/mod.rs +++ b/crates/benilla-world/src/weather/precip/mod.rs @@ -287,7 +287,7 @@ fn simulate_precip( }); wind.update(wind_pos, facing, viewer.planar_speed, dt); - let filter = crate::collision::WorldCollision::body_filter(); + let filter = crate::collision::WorldCollision::weather_filter(); let Precip { rain, snow, diff --git a/crates/benilla-world/src/weather/shelter.rs b/crates/benilla-world/src/weather/shelter.rs index 9d0067dd..4979375d 100644 --- a/crates/benilla-world/src/weather/shelter.rs +++ b/crates/benilla-world/src/weather/shelter.rs @@ -4,8 +4,10 @@ //! (`wet_hook.wgsl`) keeps ground and walls under porches, bridges and roofs dry. //! //! Source: CPU vertical rays against the collision world, the same `SpatialQuery` and -//! [`WorldCollision::body_filter`](crate::collision::WorldCollision::body_filter) the precip -//! ground oracle lands drops on roofs with (`precip/pool.rs`, `HeightCache`). It is the cheapest +//! [`WorldCollision::weather_filter`](crate::collision::WorldCollision::weather_filter) the precip +//! ground oracle lands drops on roofs with (`precip/pool.rs`, `HeightCache`) — the body's world +//! minus the collision-only sheets, so a street is not shaded dry by a lid nobody can see. It is +//! the cheapest //! robust source: the colliders already exist (terrain, WMO groups, collidable doodads), cost //! nothing to build, and see exactly the roofs the rain itself sees. Each cell casts down from //! [`LIFT`] yd above the camera: the first hit is the top surface; then it keeps casting from just @@ -465,7 +467,7 @@ fn tick_shelter( let cam = cam.unwrap_or_default(); grid.active = true; grid.follow(cam); - let filter = crate::collision::WorldCollision::body_filter(); + let filter = crate::collision::WorldCollision::weather_filter(); let x0 = grid.cast_y; grid.step(|xz| cast_column(&spatial, &filter, xz, x0)); let header = grid.header(); diff --git a/crates/benilla-world/src/wmo_portal/audit/mod.rs b/crates/benilla-world/src/wmo_portal/audit/mod.rs index 1120a623..ba9cb3cb 100644 --- a/crates/benilla-world/src/wmo_portal/audit/mod.rs +++ b/crates/benilla-world/src/wmo_portal/audit/mod.rs @@ -259,6 +259,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject { collision_bounds, collision: None, collision_camera: None, + collision_camera_hidden: None, doodads: Vec::new(), doodad_sets: Vec::new(), lights: Vec::new(), diff --git a/crates/benilla-world/src/wmo_portal/interior.rs b/crates/benilla-world/src/wmo_portal/interior.rs index 26c8c3f4..8b82b58c 100644 --- a/crates/benilla-world/src/wmo_portal/interior.rs +++ b/crates/benilla-world/src/wmo_portal/interior.rs @@ -669,6 +669,7 @@ mod tests { collision_bounds: None, collision: None, collision_camera: None, + collision_camera_hidden: None, doodads: Vec::new(), doodad_sets: Vec::new(), lights: Vec::new(), diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index 072e5e85..d355fb2f 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -1505,6 +1505,7 @@ mod tests { collision_bounds: None, collision: None, collision_camera: None, + collision_camera_hidden: None, doodads: Vec::new(), doodad_sets: Vec::new(), lights: Vec::new(),