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:
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user