First Person Controller
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
//! Brush-Collision: Swept-AABB-Trace gegen konvexe Brushes (Quake-Hull-Idee).
|
||||
//!
|
||||
//! Ein Brush ist der Schnitt seiner Halbräume `{ n·x ≤ d }` (n nach außen) —
|
||||
//! genau die Ebenen, die auch render::brush rekonstruiert, hier aber in
|
||||
//! **Engine-Koords** und **vollständig** (keine Koplanar-Elimination, kein
|
||||
//! Culling: für Collision zählt das ganze solide Volumen).
|
||||
//!
|
||||
//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den
|
||||
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
||||
//! Box-Halbgröße nach außen (`d' = d + |n|·half`). Damit wird Box-vs-Brush zur
|
||||
//! Punkt-vs-aufgeblasener-Brush-Frage, und die Trace ist ein simpler
|
||||
//! Halbraum-Clip des Segments (Eintritts-/Austritts-Bruch). Ein `SKIN` hält
|
||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||
//! sofort wieder im Kontakt steckt.
|
||||
//!
|
||||
//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen
|
||||
//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in
|
||||
//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`.
|
||||
|
||||
use crate::engine::map::{self, Map};
|
||||
|
||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
||||
const SKIN: f32 = 0.01;
|
||||
|
||||
/// Eine nach außen orientierte Ebene `n·x ≤ d` (innen = Halbraum).
|
||||
pub(crate) struct Plane {
|
||||
pub(crate) n: [f32; 3],
|
||||
pub(crate) d: f32,
|
||||
}
|
||||
|
||||
/// Ein konvexer Brush = Schnitt seiner Halbräume.
|
||||
struct ConvexBrush {
|
||||
planes: Vec<Plane>,
|
||||
}
|
||||
|
||||
/// Die statische Kollisionswelt: alle soliden Brushes der Map.
|
||||
pub struct CollisionWorld {
|
||||
brushes: Vec<ConvexBrush>,
|
||||
}
|
||||
|
||||
/// Ergebnis einer Trace: Bruchteil entlang des Segments bis zum Kontakt und
|
||||
/// die nach außen zeigende Trefferebenen-Normale (zum Gleiten/Boden-Erkennen).
|
||||
pub struct Hit {
|
||||
pub frac: f32,
|
||||
pub normal: [f32; 3],
|
||||
}
|
||||
|
||||
impl CollisionWorld {
|
||||
/// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert
|
||||
/// eine Ebene (aus drei Punkten, nach Engine-Koords gedreht).
|
||||
pub fn build(world: &Map) -> Self {
|
||||
let mut brushes = Vec::new();
|
||||
for ent in &world.entities {
|
||||
for b in &ent.brushes {
|
||||
if b.faces.len() < 4 { continue; } // kein geschlossenes Volumen
|
||||
let planes = b.faces.iter().map(|f| plane_from(&f.plane)).collect();
|
||||
brushes.push(ConvexBrush { planes });
|
||||
}
|
||||
}
|
||||
Self { brushes }
|
||||
}
|
||||
|
||||
/// Leere Welt (keine Brushes) — Default, bis eine Map geladen ist.
|
||||
pub fn empty() -> Self {
|
||||
Self { brushes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte)
|
||||
/// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`.
|
||||
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||
let mut nearest: Option<Hit> = None;
|
||||
for b in &self.brushes {
|
||||
if let Some(h) = trace_brush(b, start, end, half) {
|
||||
if nearest.as_ref().map_or(true, |n| h.frac < n.frac) {
|
||||
nearest = Some(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
nearest
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen.
|
||||
/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt
|
||||
/// (dann nicht blocken — sonst bliebe der Player hängen).
|
||||
fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||
let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch
|
||||
let mut leave = 1.0f32; // kleinster Austritts-Bruch
|
||||
let mut normal = [0.0f32; 3];
|
||||
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
|
||||
let mut started_outside = false;
|
||||
|
||||
for p in &b.planes {
|
||||
// Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski).
|
||||
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
|
||||
let ds = dot(p.n, start) - d;
|
||||
let de = dot(p.n, end) - d;
|
||||
|
||||
if ds > 0.0 { started_outside = true; }
|
||||
if ds > 0.0 && de > 0.0 { return None; } // ganz außerhalb dieser Ebene
|
||||
if ds <= 0.0 && de <= 0.0 { continue; } // ganz innerhalb dieser Ebene
|
||||
|
||||
if ds > de {
|
||||
// Eintritt (außen → innen): SKIN-Rückzug, damit der Mittelpunkt
|
||||
// knapp vor der Fläche stoppt. `enter` darf dabei leicht negativ
|
||||
// werden (Kontakt liegt im SKIN-Band) — das wird unten auf 0
|
||||
// geklemmt, nicht verworfen, sonst rutschte ein ruhender Körper
|
||||
// im Folgeframe durch die Fläche.
|
||||
let f = (ds - SKIN) / (ds - de);
|
||||
if f > enter { enter = f; normal = p.n; entered = true; }
|
||||
} else {
|
||||
// Austritt (innen → außen).
|
||||
let f = ds / (ds - de);
|
||||
if f < leave { leave = f; }
|
||||
}
|
||||
}
|
||||
|
||||
// Treffer nur, wenn der Start außerhalb lag (sonst säße man fest), eine
|
||||
// Eintrittsebene existiert und das Eintrittsintervall vor dem Segmentende
|
||||
// beginnt.
|
||||
if started_outside && entered && enter < leave && enter < 1.0 {
|
||||
Some(Hit { frac: enter.max(0.0), normal })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Ebene aus drei Face-Punkten (Quake-Reihenfolge), gedreht nach Engine-Koords.
|
||||
/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die
|
||||
/// Orientierung erhält (det +1), zeigt `cross(c−a, b−a)` weiter nach außen.
|
||||
fn plane_from(p: &[[f32; 3]; 3]) -> Plane {
|
||||
let a = map::to_engine(p[0]);
|
||||
let b = map::to_engine(p[1]);
|
||||
let c = map::to_engine(p[2]);
|
||||
let n = normalize(cross(sub(c, a), sub(b, a)));
|
||||
Plane { n, d: dot(n, a) }
|
||||
}
|
||||
|
||||
// --- kleine Vektor-Helfer (privat, wie render::brush; ein gemeinsames
|
||||
// engine::vec3 lohnt erst, falls ein dritter Nutzer auftaucht) ---------------
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
||||
|
||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn normalize(a: [f32; 3]) -> [f32; 3] {
|
||||
let len = dot(a, a).sqrt();
|
||||
if len > 0.0 { [a[0] / len, a[1] / len, a[2] / len] } else { a }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn aabb_brush(min: [f32; 3], max: [f32; 3]) -> Vec<Plane> {
|
||||
vec![
|
||||
Plane { n: [ 1.0, 0.0, 0.0], d: max[0] }, Plane { n: [-1.0, 0.0, 0.0], d: -min[0] },
|
||||
Plane { n: [0.0, 1.0, 0.0], d: max[1] }, Plane { n: [0.0, -1.0, 0.0], d: -min[1] },
|
||||
Plane { n: [0.0, 0.0, 1.0], d: max[2] }, Plane { n: [0.0, 0.0, -1.0], d: -min[2] },
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_brushes(brushes: Vec<Vec<Plane>>) -> CollisionWorld {
|
||||
CollisionWorld {
|
||||
brushes: brushes.into_iter().map(|planes| ConvexBrush { planes }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Punktförmige Box (half=0) → reine Strahl-vs-Brush-Trace.
|
||||
const PT: [f32; 3] = [0.0, 0.0, 0.0];
|
||||
|
||||
#[test]
|
||||
fn trace_hits_near_face_with_normal() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Von x=-1 nach x=3 (Gesamtweg 4), trifft die −X-Fläche bei x≈0.
|
||||
let h = w.trace([-1.0, 1.0, 1.0], [3.0, 1.0, 1.0], PT).unwrap();
|
||||
assert!((h.frac - 0.25).abs() < 0.02, "frac={}", h.frac);
|
||||
assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_misses_returns_none() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Läuft oberhalb der Box vorbei.
|
||||
assert!(w.trace([-1.0, 5.0, 1.0], [3.0, 5.0, 1.0], PT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_starting_inside_does_not_block() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Start mitten im Brush → kein Hit (sonst säße man fest).
|
||||
assert!(w.trace([1.0, 1.0, 1.0], [5.0, 1.0, 1.0], PT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_expands_by_half_extents() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Box mit halber Breite 0.5: Kontakt schon bei x≈-0.5 statt 0.
|
||||
let h = w.trace([-2.0, 1.0, 1.0], [2.0, 1.0, 1.0], [0.5, 0.5, 0.5]).unwrap();
|
||||
// Weg 4, Kontakt bei x≈-0.5 → frac≈(−0.5−(−2))/4 = 0.375.
|
||||
assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_brushes_from_map() {
|
||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||
let src = r#"
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
{
|
||||
( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let m = map::parse(src);
|
||||
let w = CollisionWorld::build(&m);
|
||||
assert_eq!(w.brushes.len(), 1);
|
||||
assert_eq!(w.brushes[0].planes.len(), 6);
|
||||
// Engine-Koords des Quake-Würfels [0,64]³: x[0,2], y[0,2], z[-2,0]
|
||||
// (Drehung (x,z,−y)·1/32). Strahl von außerhalb (−X) hindurch.
|
||||
let h = w.trace([-1.0, 1.0, -1.0], [3.0, 1.0, -1.0], PT);
|
||||
assert!(h.is_some(), "Strahl sollte den Brush treffen");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user