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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-09 17:44:01 -07:00
co-authored by Claude Opus 5
parent 6d896b1218
commit e6e8cfdca1
13 changed files with 339 additions and 16 deletions
@@ -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),
@@ -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(),
+32 -3
View File
@@ -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<CollisionMesh>,
/// 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<CollisionMesh>,
/// 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<CollisionMesh>,
/// The root's MODD doodads in WMO model space, spawned by doodad set.
pub doodads: Vec<WmoDoodad>,
/// 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<u32> = Vec::new();
let mut hid_pos: Vec<[f32; 3]> = Vec::new();
let mut hid_idx: Vec<u32> = Vec::new();
let mut cam_pos: Vec<[f32; 3]> = Vec::new();
let mut cam_idx: Vec<u32> = Vec::new();
let mut group_doodad_refs: Vec<Vec<u16>> = 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<u32>) = (Vec::new(), Vec::new());
let (mut hcam_pos, mut hcam_idx): (Vec<[f32; 3]>, Vec<u32>) = (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<u32>) = (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),
@@ -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<u8, (usize, usize, BTreeMap<i32, usize>)> = 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<usize> = 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<Vec<[f32; 3]>> =
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::<Vec<_>>()
.join(", ");
println!(
" 0x{flags:02x} {:<24} {n:>7} {h:>12} {bands}",
flag_names(*flags)
);
}
Ok(())
}
+8 -7
View File
@@ -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,
@@ -80,6 +80,14 @@ pub fn parse_m2_collision_hull(bytes: &[u8]) -> Result<CollisionMesh> {
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<u32>),
hidden: (&mut Vec<[f32; 3]>, &mut Vec<u32>),
) {
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<u16, u32>, HashMap<u16, u32>) =
(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(
+36 -1
View File
@@ -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(),
))
}
@@ -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).
@@ -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,
+5 -3
View File
@@ -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();
@@ -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(),
@@ -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(),
@@ -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(),