First Person Controller

This commit is contained in:
2026-06-14 16:20:47 +02:00
parent 902da64588
commit a8bfaaee36
9 changed files with 921 additions and 38 deletions
+235
View File
@@ -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(ca, ba)` 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");
}
}