Collider via collide-Property + Quickhull, OBJ-Pfad entfernt
col_*-Namenspräfix raus: Rollen steuern nur noch Custom Properties (collide=1 sichtbar+Collider, "proxy" unsichtbar, signal wie gehabt). Collider sind die konvexe Hülle der Vertices — konkave Meshes erlaubt. glb ist der einzige Modell-Pfad; suzanne.obj → suzanne.glb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+233
-71
@@ -14,20 +14,21 @@
|
||||
//! sofort wieder im Kontakt steckt.
|
||||
//!
|
||||
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
|
||||
//! und konvexe `col_*`-Proxies aus Blender-OBJ-Exporten ([`add_model`]).
|
||||
//! Beide enden als dasselbe Ebenen-Set — die Trace kennt keine Herkunft.
|
||||
//! Proxy-Ebenen werden aus den Dreiecks-Flächen abgeleitet (koplanare
|
||||
//! dedupliziert) und um die sechs achsparallelen AABB-Ebenen ergänzt
|
||||
//! (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 Proxies wieder stramm.
|
||||
//! und Collider-Objekte (`collide`-Property) aus Blender-Modellen
|
||||
//! ([`add_model`]). Beide enden als dasselbe Ebenen-Set — die Trace kennt
|
||||
//! keine Herkunft. Modell-Ebenen sind die **konvexe Hülle** der 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 `map`/`obj` (Quellen
|
||||
//! Headless wie der Rest von `engine`: hängt nur an `map`/`model` (Quellen
|
||||
//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
||||
//! Welt einmal vom Frontend aus Map und Modellen.
|
||||
|
||||
use crate::engine::map::{self, Map};
|
||||
use crate::engine::model::Model;
|
||||
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.
|
||||
@@ -76,14 +77,15 @@ impl CollisionWorld {
|
||||
Self { brushes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Collision-Proxies eines Blender-Modells hinzufügen: jedes Objekt mit
|
||||
/// `collider = true` wird als konvexer Brush übernommen (siehe
|
||||
/// [`object_planes`]).
|
||||
/// 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; }
|
||||
if let Some(planes) = object_planes(o) {
|
||||
self.brushes.push(ConvexBrush { planes });
|
||||
match object_planes(o) {
|
||||
Some(planes) => self.brushes.push(ConvexBrush { planes }),
|
||||
None => eprintln!("[collision] {}: kein Volumen — ignoriert", o.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,47 +104,173 @@ impl CollisionWorld {
|
||||
}
|
||||
}
|
||||
|
||||
/// Das **konvexe** Ebenen-Set eines Modell-Objekts: Ebenen aus den Dreiecks-
|
||||
/// Flächen (koplanare dedupliziert) plus die sechs achsparallelen AABB-Ebenen
|
||||
/// (Bevel-Ebenen, siehe Modul-Doc). `None`, wenn kein geschlossenes Volumen
|
||||
/// entsteht. Konkave Objekte sind Autorinnen-Fehler und ergeben zu große
|
||||
/// Volumen — die Konvention heißt deshalb `col_*`-*Boxen/Prismen*.
|
||||
pub(crate) fn object_planes(o: &crate::engine::model::Object) -> Option<Vec<Plane>> {
|
||||
let mut planes: Vec<Plane> = Vec::new();
|
||||
let (mut bb_min, mut bb_max) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
/// 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 t in &o.tris {
|
||||
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
|
||||
// OBJ-/glTF-Winding ist CCW von außen → cross(b−a, c−a) zeigt nach
|
||||
// außen. Degenerierte Dreiecke (Länge ~0) überspringen.
|
||||
let n = cross(sub(b, a), sub(c, a));
|
||||
if dot(n, n) < 1e-12 { continue; }
|
||||
let n = normalize(n);
|
||||
let d = dot(n, a);
|
||||
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - d).abs() < 1e-4) {
|
||||
planes.push(Plane { n, d });
|
||||
}
|
||||
}
|
||||
// Axiale Bevel-Ebenen (dedupe fängt achsparallele Duplikate).
|
||||
for k in 0..3 {
|
||||
let mut n = [0.0f32; 3];
|
||||
n[k] = 1.0;
|
||||
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - bb_max[k]).abs() < 1e-4) {
|
||||
planes.push(Plane { n, d: bb_max[k] });
|
||||
}
|
||||
push_plane(&mut planes, n, bb_max[k]);
|
||||
n[k] = -1.0;
|
||||
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d + bb_min[k]).abs() < 1e-4) {
|
||||
planes.push(Plane { n, d: -bb_min[k] });
|
||||
}
|
||||
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(b−a, c−a) 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 {
|
||||
@@ -278,40 +406,22 @@ mod tests {
|
||||
assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac);
|
||||
}
|
||||
|
||||
// Einheitswürfel [0,1]³ als col_-Proxy, Quads CCW von außen (wie ein
|
||||
// Blender-Export), plus ein vis_-Objekt, das keinen Collider ergeben darf.
|
||||
const PROXY_OBJ: &str = "\
|
||||
o col_Box
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 1 1 0
|
||||
v 0 1 0
|
||||
v 0 0 1
|
||||
v 1 0 1
|
||||
v 1 1 1
|
||||
v 0 1 1
|
||||
f 5 6 7 8
|
||||
f 1 4 3 2
|
||||
f 2 3 7 6
|
||||
f 1 5 8 4
|
||||
f 4 8 7 3
|
||||
f 1 2 6 5
|
||||
o vis_Deko
|
||||
v 5 5 5
|
||||
v 6 5 5
|
||||
v 6 6 5
|
||||
f 9 10 11
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn obj_proxy_becomes_convex_brush() {
|
||||
use crate::engine::obj;
|
||||
let model = obj::parse(PROXY_OBJ);
|
||||
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 col_Box (vis_ trägt nicht bei); 12 Dreiecks-Ebenen dedupen zu 6,
|
||||
// die axialen Bevel-Ebenen sind mit ihnen identisch (achsparallel).
|
||||
// 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);
|
||||
|
||||
@@ -321,6 +431,58 @@ f 9 10 11
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_brushes_from_map() {
|
||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||
|
||||
Reference in New Issue
Block a user