Files
wds/src/engine/collision.rs
T
irrlicht 56d92cdb46 Map-Loader entfernt: glTF ist das einzige 3D-Format
engine/map.rs + render/brush.rs raus, Welt kommt komplett aus .glb.
Spawn via Empty mit role=spawn statt info_player_start; testscene.glb
(Boden, Wände, Spawn) ersetzt test.map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00

456 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Collision: Swept-AABB-Trace gegen konvexe Volumen (Quake-Hull-Idee —
//! „Brush" heißt hier: der Schnitt seiner Halbräume `{ n·x ≤ d }`,
//! n nach außen).
//!
//! 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.
//!
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
//!
//! Headless wie der Rest von `engine`: hängt nur an `model` (Quelle der
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
//! Welt einmal vom Frontend aus den Modellen.
use crate::engine::model::{Model, Object};
/// 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 Volumen der geladenen Modelle.
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 {
/// Leere Welt — Ausgangszustand, bis Modelle geladen sind.
pub fn empty() -> Self {
Self { brushes: Vec::new() }
}
/// Collider eines Blender-Modells hinzufügen: jedes Objekt mit
/// `collider = true` (aus der `collide`-Property) wird als konvexer
/// Brush übernommen (siehe [`object_planes`]).
pub fn add_model(&mut self, model: &Model) {
for o in &model.objects {
if !o.collider { continue; }
match object_planes(o) {
Some(planes) => self.brushes.push(ConvexBrush { planes }),
None => eprintln!("[collision] {}: kein Volumen — ignoriert", o.name),
}
}
}
/// 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_planes(&b.planes, start, end, half)
&& nearest.as_ref().is_none_or(|n| h.frac < n.frac) {
nearest = Some(h);
}
}
nearest
}
}
/// Das konvexe Ebenen-Set eines Modell-Objekts: die **konvexe Hülle** seiner
/// Vertices ([`hull_planes`]) plus die sechs achsparallelen AABB-Ebenen
/// (Bevel-Ebenen, siehe Modul-Doc) — das Mesh darf also konkav sein. Flache
/// Objekte (kein Hüll-Volumen) fallen auf ihre Face-Ebenen zurück, mit den
/// Bevel-Ebenen ergibt das die dünne AABB-Platte (brauchbar für flächige
/// Trigger-Zonen). `None`, wenn gar kein geschlossenes Volumen entsteht.
pub(crate) fn object_planes(o: &Object) -> Option<Vec<Plane>> {
if o.verts.is_empty() { return None; }
let mut planes = hull_planes(&o.verts).unwrap_or_else(|| face_planes(o));
// Axiale Bevel-Ebenen aus der AABB (push_plane dedupt achsparallele
// Duplikate weg).
let (mut bb_min, mut bb_max) = (o.verts[0], o.verts[0]);
for v in &o.verts {
for k in 0..3 {
bb_min[k] = bb_min[k].min(v[k]);
bb_max[k] = bb_max[k].max(v[k]);
}
}
for k in 0..3 {
let mut n = [0.0f32; 3];
n[k] = 1.0;
push_plane(&mut planes, n, bb_max[k]);
n[k] = -1.0;
push_plane(&mut planes, n, -bb_min[k]);
}
(planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen
}
/// Ebene anfügen, wenn keine (nahezu) gleiche schon da ist — Koplanar-Dedupe
/// für Hüll-Dreiecke und Bevel-Ebenen.
fn push_plane(planes: &mut Vec<Plane>, n: [f32; 3], d: f32) {
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - d).abs() < 1e-4) {
planes.push(Plane { n, d });
}
}
/// Fallback für degenerierte (flache) Objekte: Ebenen direkt aus den
/// Dreiecks-Flächen (koplanare dedupliziert). OBJ-/glTF-Winding ist CCW von
/// außen → cross(ba, ca) zeigt nach außen.
fn face_planes(o: &Object) -> Vec<Plane> {
let mut planes: Vec<Plane> = Vec::new();
for t in &o.tris {
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
let n = cross(sub(b, a), sub(c, a));
if dot(n, n) < 1e-12 { continue; } // degeneriert (Länge ~0)
let n = normalize(n);
push_plane(&mut planes, n, dot(n, a));
}
planes
}
/// Toleranz der Hüllen-Konstruktion (units): Punkte näher als das an einer
/// Ebene gelten als koplanar. Absolut statt relativ — Maßstab ist fix
/// (1 unit = 1 Blender-Meter), und die Trace arbeitet mit ~SKIN-Auflösung.
const HULL_EPS: f32 = 1e-4;
/// Ein Hüll-Dreieck während der Konstruktion; `alive = false` = durch einen
/// später eingefügten Punkt ersetzt (Tombstone statt teurem Entfernen).
struct HullFace {
v: [usize; 3],
n: [f32; 3],
d: f32,
alive: bool,
}
/// Hüll-Dreieck `(x, y, z)`, so orientiert, dass der Referenzpunkt `w`
/// (ein Punkt im Inneren des Start-Simplex) hinter der Ebene liegt.
fn hull_face(pts: &[[f32; 3]], x: usize, y: usize, z: usize, w: usize) -> HullFace {
let n = normalize(cross(sub(pts[y], pts[x]), sub(pts[z], pts[x])));
let d = dot(n, pts[x]);
if dot(n, pts[w]) > d {
HullFace { v: [x, z, y], n: [-n[0], -n[1], -n[2]], d: -d, alive: true }
} else {
HullFace { v: [x, y, z], n, d, alive: true }
}
}
/// Konvexe Hülle einer Punktwolke als nach außen orientierte Ebenen
/// (inkrementeller Quickhull: Start-Tetraeder aus Extrempunkten, dann jeden
/// Außenpunkt einfügen — sichtbare Faces sterben, der Horizont wird mit
/// neuen Dreiecken zum Punkt geschlossen). `None` bei degenerierter Eingabe
/// (alle Punkte ~kollinear/koplanar) — kein Volumen zum Aufspannen.
fn hull_planes(pts: &[[f32; 3]]) -> Option<Vec<Plane>> {
if pts.len() < 4 { return None; }
// Start-Simplex: das fernste Paar unter den sechs Achsen-Extrema, dazu
// der fernste Punkt zur Kante und der fernste zur aufgespannten Ebene.
let mut ext = [0usize; 6];
for (i, p) in pts.iter().enumerate() {
for k in 0..3 {
if p[k] < pts[ext[k]][k] { ext[k] = i; }
if p[k] > pts[ext[3 + k]][k] { ext[3 + k] = i; }
}
}
let (mut a, mut b, mut best) = (0, 0, -1.0);
for &i in &ext {
for &j in &ext {
let d = sub(pts[i], pts[j]);
let d = dot(d, d);
if d > best { best = d; (a, b) = (i, j); }
}
}
if best < HULL_EPS * HULL_EPS { return None; } // alle Punkte ~identisch
let ab = sub(pts[b], pts[a]);
let (mut c, mut best) = (0, -1.0);
for (i, p) in pts.iter().enumerate() {
let x = cross(ab, sub(*p, pts[a]));
let x = dot(x, x);
if x > best { best = x; c = i; }
}
// |cross|² = Abstand² · |ab|² → Vergleich gegen EPS²·|ab|².
if best < HULL_EPS * HULL_EPS * dot(ab, ab) { return None; } // kollinear
let n = normalize(cross(ab, sub(pts[c], pts[a])));
let (mut e, mut best) = (0, -1.0);
for (i, p) in pts.iter().enumerate() {
let x = (dot(n, *p) - dot(n, pts[a])).abs();
if x > best { best = x; e = i; }
}
if best < HULL_EPS { return None; } // koplanar
// Tetraeder-Faces; als Innen-Referenz dient je der vierte Eckpunkt.
let mut faces = vec![
hull_face(pts, a, b, c, e),
hull_face(pts, a, b, e, c),
hull_face(pts, a, c, e, b),
hull_face(pts, b, c, e, a),
];
for p in 0..pts.len() {
// Faces, die den Punkt sehen (Punkt liegt vor ihrer Ebene).
let visible: Vec<usize> = faces.iter().enumerate()
.filter(|(_, f)| f.alive && dot(f.n, pts[p]) > f.d + HULL_EPS)
.map(|(i, _)| i)
.collect();
if visible.is_empty() { continue; } // schon in der Hülle
// Horizont: gerichtete Kanten sichtbarer Faces, deren Gegenkante
// nicht selbst zu einem sichtbaren Face gehört. Weil die Windings
// konsistent sind, taucht jede innere Kante genau einmal je Richtung
// auf — der Horizont ist der Rest.
let mut edges: Vec<(usize, usize)> = Vec::new();
for &fi in &visible {
let [x, y, z] = faces[fi].v;
edges.extend([(x, y), (y, z), (z, x)]);
faces[fi].alive = false;
}
for &(x, y) in &edges {
if edges.contains(&(y, x)) { continue; } // innere Kante
// Horizont-Kante behält ihre Richtung → (x, y, p) zeigt nach
// außen. Degenerierte Dreiecke (p ~auf der Kante) überspringen.
let nn = cross(sub(pts[y], pts[x]), sub(pts[p], pts[x]));
if dot(nn, nn) < 1e-12 { continue; }
let n = normalize(nn);
faces.push(HullFace { v: [x, y, p], n, d: dot(n, pts[x]), alive: true });
}
}
let mut planes: Vec<Plane> = Vec::new();
for f in faces.iter().filter(|f| f.alive) {
push_plane(&mut planes, f.n, f.d); // koplanare Hüll-Dreiecke dedupen
}
Some(planes)
}
/// Überlappt eine AABB (Mittelpunkt/Halbmaße) das konvexe Volumen? Gleiche
/// Minkowski-Aufblasung wie die Trace — „berühren" zählt als drin.
pub(crate) fn box_touches(planes: &[Plane], center: [f32; 3], half: [f32; 3]) -> bool {
planes.iter().all(|p| {
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
dot(p.n, center) <= d
})
}
/// Segment `start→end` gegen ein aufgeblasenes konvexes Ebenen-Set clippen.
/// `None`, wenn das Segment das Volumen verfehlt oder der Start schon drin
/// steckt (dann nicht blocken — sonst bliebe der Player hängen).
pub(crate) fn trace_planes(planes: &[Plane], 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 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
}
}
// --- kleine Vektor-Helfer (privat; 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 collider_object_becomes_convex_brush() {
use crate::engine::model::{test_cube, Model};
let model = Model {
objects: vec![
test_cube("Box", [0.0, 0.0, 0.0], Some("proxy"), None),
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
],
empties: Vec::new(),
materials: vec![String::new()],
};
let mut w = CollisionWorld::empty();
w.add_model(&model);
// Nur Box (Deko hat kein collide); die Hülle des Würfels dedupt auf
// 6 Ebenen, die axialen Bevel-Ebenen sind mit ihnen identisch.
assert_eq!(w.brushes.len(), 1);
assert_eq!(w.brushes[0].planes.len(), 6);
// Strahl von X hindurch: Kontakt an x≈0 mit X-Normale.
let h = w.trace([-1.0, 0.5, 0.5], [3.0, 0.5, 0.5], 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 hull_of_concave_mesh_spans_the_notch() {
// Konkaves „L": zwei Würfel-Objekte liefern die Punktwolke eines
// L-Prismas — [0,2]×[0,1]×[0,1] plus [0,1]×[1,2]×[0,1]. Die Hülle
// muss die Kerbe (x>1, y>1) mit der Schräge x+y=3 überspannen.
let mut verts = Vec::new();
for (min, max) in [([0.0f32, 0.0, 0.0], [2.0f32, 1.0, 1.0]),
([0.0, 1.0, 0.0], [1.0, 2.0, 1.0])] {
for &x in &[min[0], max[0]] {
for &y in &[min[1], max[1]] {
for &z in &[min[2], max[2]] { verts.push([x, y, z]); }
}
}
}
let planes = hull_planes(&verts).unwrap();
// 6 AABB-Seiten der Gesamtbox + 1 Schräge über der Kerbe.
assert_eq!(planes.len(), 7);
let diag = planes.iter()
.find(|p| p.n[0] > 0.5 && p.n[1] > 0.5)
.expect("Schräge über der Kerbe fehlt");
assert!((dot(diag.n, [2.0, 1.0, 0.5]) - diag.d).abs() < 1e-3,
"Schräge sollte durch (2,1,·) laufen");
// Ein Punkt in der Kerbe, unter der Schräge, liegt in der Hülle.
assert!(box_touches(&planes, [1.2, 1.2, 0.5], PT));
// Die Ecke der Kerbe (über der Schräge) liegt außerhalb.
assert!(!box_touches(&planes, [1.9, 1.9, 0.5], PT));
}
#[test]
fn degenerate_objects_fall_back_or_are_rejected() {
use crate::engine::model::{Object, Props};
// Flaches Quad in der XY-Ebene: keine Hülle, aber der Face-Ebenen-
// Fallback + Bevels ergeben die dünne Platte.
let flat = Object {
name: "Zone".into(),
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0]; 4],
tris: vec![[0, 1, 2], [0, 2, 3]],
tri_mats: vec![0; 2],
visible: true, collider: false, props: Props::new(),
};
let planes = object_planes(&flat).unwrap();
assert!(box_touches(&planes, [0.5, 0.5, 0.0], PT));
assert!(!box_touches(&planes, [0.5, 0.5, 1.0], PT));
// Ohne Vertices gibt es nichts.
let empty = Object { verts: Vec::new(), uvs: Vec::new(), tris: Vec::new(),
tri_mats: Vec::new(), ..flat };
assert!(object_planes(&empty).is_none());
}
}