Blender-Import-Pipeline: glTF/OBJ, col_*-Proxies, Trigger
Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie und schiebt das Ergebnis per Session::load_props in den State, der Renderer baut sein Mesh (render/props.rs, verschmolzen mit der Brush-Welt über eine geteilte Texturliste). glTF trägt Blender-Custom-Properties (node.extras → Object::props) und Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` = unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf Sichtgeometrie). Trigger (engine/trigger.rs) hängen an der Custom Property `signal`: mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider = unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige Weg in den State. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+124
-9
@@ -13,11 +13,21 @@
|
||||
//! 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`.
|
||||
//! 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.
|
||||
//!
|
||||
//! Headless wie der Rest von `engine`: hängt nur an `map`/`obj` (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;
|
||||
|
||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
||||
@@ -66,12 +76,24 @@ 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`]).
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 let Some(h) = trace_planes(&b.planes, start, end, half)
|
||||
&& nearest.as_ref().is_none_or(|n| h.frac < n.frac) {
|
||||
nearest = Some(h);
|
||||
}
|
||||
@@ -80,17 +102,67 @@ impl CollisionWorld {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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]);
|
||||
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] });
|
||||
}
|
||||
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] });
|
||||
}
|
||||
}
|
||||
(planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen
|
||||
}
|
||||
|
||||
/// Ü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 &b.planes {
|
||||
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;
|
||||
@@ -206,6 +278,49 @@ 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);
|
||||
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).
|
||||
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 builds_brushes_from_map() {
|
||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||
|
||||
Reference in New Issue
Block a user