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>
This commit is contained in:
Binary file not shown.
@@ -1,48 +0,0 @@
|
|||||||
// Game: Generic
|
|
||||||
// Format: Standard
|
|
||||||
// entity 0
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
"wad" ""
|
|
||||||
// brush 0
|
|
||||||
{
|
|
||||||
( -80 -16 -32 ) ( -80 -15 -32 ) ( -80 -16 -31 ) 256_arcade_carpet 96 -16 0 0.2 0.2
|
|
||||||
( -112 -16 -32 ) ( -112 -16 -31 ) ( -111 -16 -32 ) 256_arcade_carpet 80 -16 0 0.2 0.2
|
|
||||||
( -112 -16 -32 ) ( -111 -16 -32 ) ( -112 -15 -32 ) 256_arcade_carpet 80 -96 0 0.2 0.2
|
|
||||||
( 48 96 -16 ) ( 48 97 -16 ) ( 49 96 -16 ) 256_arcade_carpet 5.3333435 -57.379303 0 0.2 0.2
|
|
||||||
( 48 112 -16 ) ( 49 112 -16 ) ( 48 112 -15 ) 256_arcade_carpet 80 -16 0 0.2 0.2
|
|
||||||
( 80 96 -16 ) ( 80 96 -15 ) ( 80 97 -16 ) 256_arcade_carpet 96 -16 0 0.2 0.2
|
|
||||||
}
|
|
||||||
// brush 1
|
|
||||||
{
|
|
||||||
( -48 0 4 ) ( -48 1 4 ) ( -48 0 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 48 -16 -16 ) ( 47 -16 -16 ) ( 48 -16 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 48 -16 -16 ) ( 48 -15 -16 ) ( 47 -16 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -48 0 80 ) ( -49 0 80 ) ( -48 1 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -48 0 4 ) ( -48 0 5 ) ( -49 0 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 48 -16 -16 ) ( 48 -16 -15 ) ( 48 -15 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
}
|
|
||||||
// brush 2
|
|
||||||
{
|
|
||||||
( -91.71281292110203 32 4 ) ( -91.21281292110203 32.86602540378445 4 ) ( -91.71281292110203 32 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -30.430780618346965 -21.856406460551035 -16 ) ( -31.296806022131403 -21.356406460551035 -16 ) ( -30.430780618346965 -21.856406460551035 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -30.430780618346965 -21.856406460551035 -16 ) ( -29.930780618346965 -20.990381056766598 -16 ) ( -31.296806022131403 -21.356406460551035 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -105.56921938165303 40 80 ) ( -106.43524478543748 40.500000000000014 80 ) ( -105.06921938165303 40.86602540378446 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -105.56921938165303 40 4 ) ( -105.56921938165303 40 5 ) ( -106.43524478543748 40.500000000000014 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -44.28718707889798 -13.856406460551028 -16 ) ( -44.28718707889798 -13.856406460551028 -15 ) ( -43.78718707889798 -12.99038105676659 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
}
|
|
||||||
// brush 3
|
|
||||||
{
|
|
||||||
( 36.28718707889797 0 4 ) ( 35.787187078897965 0.866025403784441 4 ) ( 36.28718707889797 0 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 22.430780618346965 -8 4 ) ( 22.430780618346965 -8 5 ) ( 21.56475521456251 -8.499999999999979 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 113.56921938165306 26.14359353944895 -16 ) ( 113.06921938165306 27.0096189432334 -16 ) ( 112.70319397786861 25.643593539448965 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 22.430780618346965 -8 80 ) ( 21.56475521456251 -8.499999999999979 80 ) ( 21.930780618346944 -7.1339745962155305 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 113.56921938165306 26.14359353944895 -16 ) ( 112.70319397786861 25.643593539448965 -16 ) ( 113.56921938165306 26.14359353944895 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 99.71281292110204 18.143593539448965 -16 ) ( 99.71281292110204 18.143593539448965 -15 ) ( 99.21281292110204 19.009618943233413 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// entity 1
|
|
||||||
{
|
|
||||||
"classname" "info_player_start"
|
|
||||||
"origin" "2 52 8"
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// Game: Generic
|
|
||||||
// Format: Standard
|
|
||||||
// entity 0
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
"wad" ""
|
|
||||||
// brush 0
|
|
||||||
{
|
|
||||||
( -144 -64 -112 ) ( -144 -63 -112 ) ( -144 -64 -111 ) placeholder 0 -64 0 0.2 0.2
|
|
||||||
( -144 -304 -112 ) ( -144 -304 -111 ) ( -143 -304 -112 ) placeholder 224 -64 0 0.2 0.2
|
|
||||||
( -144 -64 -112 ) ( -143 -64 -112 ) ( -144 -63 -112 ) placeholder 224 0 0 0.2 0.2
|
|
||||||
( -16 64 -80 ) ( -16 65 -80 ) ( -15 64 -80 ) placeholder 224 0 0 0.2 0.2
|
|
||||||
( -16 192 -80 ) ( -15 192 -80 ) ( -16 192 -79 ) placeholder 224 -64 0 0.2 0.2
|
|
||||||
( 304 64 -80 ) ( 304 64 -79 ) ( 304 65 -80 ) placeholder 0 -64 0 0.2 0.2
|
|
||||||
}
|
|
||||||
// brush 1
|
|
||||||
{
|
|
||||||
( 0 -128 -16 ) ( 0 -127 -16 ) ( 0 -128 -15 ) placeholder 0 0 0 1 1
|
|
||||||
( 0 -128 -16 ) ( 0 -128 -15 ) ( 1 -128 -16 ) placeholder -32 0 0 1 1
|
|
||||||
( 0 -128 -16 ) ( 1 -128 -16 ) ( 0 -127 -16 ) placeholder -32 0 0 1 1
|
|
||||||
( 64 -96 0 ) ( 64 -95 0 ) ( 65 -96 0 ) placeholder -32 0 0 1 1
|
|
||||||
( 64 -96 0 ) ( 65 -96 0 ) ( 64 -96 1 ) placeholder -32 0 0 1 1
|
|
||||||
( 64 -96 0 ) ( 64 -96 1 ) ( 64 -95 0 ) placeholder 0 0 0 1 1
|
|
||||||
}
|
|
||||||
// brush 2
|
|
||||||
{
|
|
||||||
( 64 -144 -32 ) ( 64 -143 -32 ) ( 64 -144 -31 ) placeholder -16 -16 0 1 1
|
|
||||||
( 64 -144 -32 ) ( 64 -144 -31 ) ( 65 -144 -32 ) placeholder -32 -16 0 1 1
|
|
||||||
( 64 -144 -32 ) ( 65 -144 -32 ) ( 64 -143 -32 ) placeholder -32 16 0 1 1
|
|
||||||
( 192 -80 -16 ) ( 192 -79 -16 ) ( 193 -80 -16 ) placeholder -32 16 0 1 1
|
|
||||||
( 192 -80 -16 ) ( 193 -80 -16 ) ( 192 -80 -15 ) placeholder -32 -16 0 1 1
|
|
||||||
( 192 -80 -16 ) ( 192 -80 -15 ) ( 192 -79 -16 ) placeholder -16 -16 0 1 1
|
|
||||||
}
|
|
||||||
// brush 3
|
|
||||||
{
|
|
||||||
( 80 16 32 ) ( 80 15 32 ) ( 80 16 31 ) placeholder 0 -16 180 1 1
|
|
||||||
( 144 -96 16 ) ( 145 -96 16 ) ( 144 -96 15 ) placeholder 16 -16 0 1 -1
|
|
||||||
( 144 -96 16 ) ( 144 -97 16 ) ( 145 -96 16 ) placeholder 16 0 0 1 -1
|
|
||||||
( 80 16 32 ) ( 81 16 32 ) ( 80 15 32 ) placeholder 16 0 0 1 -1
|
|
||||||
( 80 16 32 ) ( 80 16 31 ) ( 81 16 32 ) placeholder 16 -16 0 1 -1
|
|
||||||
( 144 -96 16 ) ( 144 -96 15 ) ( 144 -97 16 ) placeholder 0 -16 180 1 1
|
|
||||||
}
|
|
||||||
// brush 4
|
|
||||||
{
|
|
||||||
( 224 -48 -80 ) ( 224 -47 -80 ) ( 224 -48 -79 ) placeholder -144 -96 0 1 1
|
|
||||||
( 224 -48 -80 ) ( 224 -48 -79 ) ( 225 -48 -80 ) placeholder -32 -96 0 1 1
|
|
||||||
( 224 -48 -80 ) ( 225 -48 -80 ) ( 224 -47 -80 ) placeholder -32 144 0 1 1
|
|
||||||
( 288 16 -64 ) ( 288 17 -64 ) ( 289 16 -64 ) placeholder -32 144 0 1 1
|
|
||||||
( 288 16 -64 ) ( 289 16 -64 ) ( 288 16 -63 ) placeholder -32 -96 0 1 1
|
|
||||||
( 288 16 -64 ) ( 288 16 -63 ) ( 288 17 -64 ) placeholder -144 -96 0 1 1
|
|
||||||
}
|
|
||||||
// brush 5
|
|
||||||
{
|
|
||||||
( -144 -560 -80 ) ( -144 -559 -80 ) ( -144 -560 -79 ) placeholder -80 96 0 0.2 0.2
|
|
||||||
( -144 -320 -80 ) ( -144 -320 -79 ) ( -143 -320 -80 ) placeholder -32 96 0 0.2 0.2
|
|
||||||
( -144 -560 -80 ) ( -143 -560 -80 ) ( -144 -559 -80 ) placeholder -32 80 0 0.2 0.2
|
|
||||||
( -16 -432 16 ) ( -16 -431 16 ) ( -15 -432 16 ) placeholder -32 80 0 0.2 0.2
|
|
||||||
( -16 -304 -48 ) ( -15 -304 -48 ) ( -16 -304 -47 ) placeholder -32 96 0 0.2 0.2
|
|
||||||
( 304 -432 -48 ) ( 304 -432 -47 ) ( 304 -431 -48 ) placeholder -80 96 0 0.2 0.2
|
|
||||||
}
|
|
||||||
// brush 6
|
|
||||||
{
|
|
||||||
( -160 -240 -16 ) ( -160 -241 -16 ) ( -160 -240 -17 ) placeholder -240 96 180 1 1
|
|
||||||
( -64 -304 -32 ) ( -63 -304 -32 ) ( -64 -304 -33 ) placeholder 208 96 0 1 -1
|
|
||||||
( -64 -352 -80 ) ( -64 -353 -80 ) ( -63 -352 -80 ) placeholder 208 240 0 1 -1
|
|
||||||
( -160 -240 16 ) ( -159 -240 16 ) ( -160 -241 16 ) placeholder 208 240 0 1 -1
|
|
||||||
( -160 192 -16 ) ( -160 192 -17 ) ( -159 192 -16 ) placeholder 208 96 0 1 -1
|
|
||||||
( -144 -352 -32 ) ( -144 -352 -33 ) ( -144 -353 -32 ) placeholder -240 96 180 1 1
|
|
||||||
}
|
|
||||||
// brush 7
|
|
||||||
{
|
|
||||||
( 176 -144 -80 ) ( 176 -143 -80 ) ( 176 -144 -79 ) placeholder 176 0 0 1 1
|
|
||||||
( 176 -144 -80 ) ( 176 -144 -79 ) ( 177 -144 -80 ) placeholder 16 0 0 1 1
|
|
||||||
( 176 -144 -80 ) ( 177 -144 -80 ) ( 176 -143 -80 ) placeholder 16 -176 0 1 1
|
|
||||||
( 240 -80 -48 ) ( 240 -79 -48 ) ( 241 -80 -48 ) placeholder 16 -176 0 1 1
|
|
||||||
( 240 -80 -64 ) ( 241 -80 -64 ) ( 240 -80 -63 ) placeholder 16 0 0 1 1
|
|
||||||
( 240 -80 -64 ) ( 240 -80 -63 ) ( 240 -79 -64 ) placeholder 176 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// entity 1
|
|
||||||
{
|
|
||||||
"classname" "info_player_start"
|
|
||||||
"origin" "112 -48 56"
|
|
||||||
}
|
|
||||||
+12
-68
@@ -1,9 +1,6 @@
|
|||||||
//! Brush-Collision: Swept-AABB-Trace gegen konvexe Brushes (Quake-Hull-Idee).
|
//! Collision: Swept-AABB-Trace gegen konvexe Volumen (Quake-Hull-Idee —
|
||||||
//!
|
//! „Brush" heißt hier: der Schnitt seiner Halbräume `{ n·x ≤ d }`,
|
||||||
//! Ein Brush ist der Schnitt seiner Halbräume `{ n·x ≤ d }` (n nach außen) —
|
//! 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
|
//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den
|
||||||
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
||||||
@@ -13,21 +10,18 @@
|
|||||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||||
//! sofort wieder im Kontakt steckt.
|
//! sofort wieder im Kontakt steckt.
|
||||||
//!
|
//!
|
||||||
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
|
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
|
||||||
//! und Collider-Objekte (`collide`-Property) aus Blender-Modellen
|
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
|
||||||
//! ([`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
|
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
|
||||||
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
||||||
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
||||||
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
||||||
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
||||||
//!
|
//!
|
||||||
//! Headless wie der Rest von `engine`: hängt nur an `map`/`model` (Quellen
|
//! Headless wie der Rest von `engine`: hängt nur an `model` (Quelle der
|
||||||
//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
||||||
//! Welt einmal vom Frontend aus Map und Modellen.
|
//! Welt einmal vom Frontend aus den Modellen.
|
||||||
|
|
||||||
use crate::engine::map::{self, Map};
|
|
||||||
use crate::engine::model::{Model, Object};
|
use crate::engine::model::{Model, Object};
|
||||||
|
|
||||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||||
@@ -45,7 +39,7 @@ struct ConvexBrush {
|
|||||||
planes: Vec<Plane>,
|
planes: Vec<Plane>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die statische Kollisionswelt: alle soliden Brushes der Map.
|
/// Die statische Kollisionswelt: alle soliden Volumen der geladenen Modelle.
|
||||||
pub struct CollisionWorld {
|
pub struct CollisionWorld {
|
||||||
brushes: Vec<ConvexBrush>,
|
brushes: Vec<ConvexBrush>,
|
||||||
}
|
}
|
||||||
@@ -58,21 +52,7 @@ pub struct Hit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CollisionWorld {
|
impl CollisionWorld {
|
||||||
/// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert
|
/// Leere Welt — Ausgangszustand, bis Modelle geladen sind.
|
||||||
/// 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 {
|
pub fn empty() -> Self {
|
||||||
Self { brushes: Vec::new() }
|
Self { brushes: Vec::new() }
|
||||||
}
|
}
|
||||||
@@ -325,19 +305,8 @@ pub(crate) fn trace_planes(planes: &[Plane], start: [f32; 3], end: [f32; 3], hal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ebene aus drei Face-Punkten (Quake-Reihenfolge), gedreht nach Engine-Koords.
|
// --- kleine Vektor-Helfer (privat; ein gemeinsames engine::vec3 lohnt
|
||||||
/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die
|
// erst, falls ein dritter Nutzer auftaucht) ----------------------------------
|
||||||
/// 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 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 dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
||||||
@@ -483,29 +452,4 @@ mod tests {
|
|||||||
assert!(object_planes(&empty).is_none());
|
assert!(object_planes(&empty).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
//! Quake-`.map`-Parser (Standard-Format, wie TrenchBroom es mit dem
|
|
||||||
//! Generic-Game exportiert).
|
|
||||||
//!
|
|
||||||
//! Struktur: eine Datei ist eine Liste von Entities `{...}`. Eine Entity
|
|
||||||
//! hält Key/Value-Properties (`"key" "value"`) und null oder mehr Brushes
|
|
||||||
//! `{...}`. Ein Brush ist eine Liste von Faces; jede Face ist eine Ebene
|
|
||||||
//! aus drei Punkten plus Textur-Ausrichtung:
|
|
||||||
//!
|
|
||||||
//! ( x y z ) ( x y z ) ( x y z ) TEXTUR offX offY rot scaleX scaleY
|
|
||||||
//!
|
|
||||||
//! Hier wird nur geparst — Geometrie bleibt im Quake-Koordinatensystem
|
|
||||||
//! (Z-up, Brush = Schnitt der Halbräume); die Umrechnung ins Engine-System
|
|
||||||
//! und der Halbraum-Schnitt sind Sache des Konsumenten (render::brush).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs::read_to_string;
|
|
||||||
|
|
||||||
pub struct Map {
|
|
||||||
pub entities: Vec<Entity>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Entity {
|
|
||||||
pub props: HashMap<String, String>,
|
|
||||||
pub brushes: Vec<Brush>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Entity {
|
|
||||||
// Genutzt für Spawn-Auflösung (info_player_start) und künftige
|
|
||||||
// Entity-/Signal-Bindung.
|
|
||||||
pub fn classname(&self) -> Option<&str> {
|
|
||||||
self.props.get("classname").map(String::as_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Brush {
|
|
||||||
pub faces: Vec<Face>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eine Brush-Face: Ebene aus drei Punkten (Quake-Reihenfolge, im
|
|
||||||
/// Uhrzeigersinn von vorn gesehen) plus Textur-Ausrichtung. Die
|
|
||||||
/// Ausrichtungsfelder werden erst vom Textur-Schritt gebraucht.
|
|
||||||
pub struct Face {
|
|
||||||
pub plane: [[f32; 3]; 3],
|
|
||||||
pub texture: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub offset: [f32; 2],
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub rotation: f32,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub scale: [f32; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme).
|
|
||||||
/// Single Source für Render- (render::brush) und Collision-Pfad
|
|
||||||
/// (engine::collision), damit beide denselben Maßstab benutzen.
|
|
||||||
pub const MAP_SCALE: f32 = 1.0 / 32.0;
|
|
||||||
|
|
||||||
/// Quake-Koords (Z-up) → Engine-Koords (Y-up, Blick −Z), skaliert:
|
|
||||||
/// `(x, y, z) → (x, z, −y) · MAP_SCALE`. Die Drehung erhält die Orientierung
|
|
||||||
/// (det +1) — nach außen zeigende Normalen bleiben außen.
|
|
||||||
pub fn to_engine(p: [f32; 3]) -> [f32; 3] {
|
|
||||||
[p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(path: &str) -> Map {
|
|
||||||
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
|
|
||||||
parse(&src)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(src: &str) -> Map {
|
|
||||||
let mut entities = Vec::new();
|
|
||||||
let mut cur_entity: Option<Entity> = None;
|
|
||||||
let mut cur_brush: Option<Brush> = None;
|
|
||||||
|
|
||||||
for raw in src.lines() {
|
|
||||||
// Kommentar (`// …`) abschneiden, dann trimmen.
|
|
||||||
let line = match raw.split_once("//") {
|
|
||||||
Some((code, _)) => code.trim(),
|
|
||||||
None => raw.trim(),
|
|
||||||
};
|
|
||||||
if line.is_empty() { continue; }
|
|
||||||
|
|
||||||
match line {
|
|
||||||
"{" => {
|
|
||||||
// Erstes `{` öffnet eine Entity, ein weiteres einen Brush.
|
|
||||||
if cur_entity.is_none() {
|
|
||||||
cur_entity = Some(Entity::default());
|
|
||||||
} else {
|
|
||||||
cur_brush = Some(Brush { faces: Vec::new() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"}" => {
|
|
||||||
if let Some(brush) = cur_brush.take() {
|
|
||||||
cur_entity.as_mut().unwrap().brushes.push(brush);
|
|
||||||
} else if let Some(entity) = cur_entity.take() {
|
|
||||||
entities.push(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(brush) = cur_brush.as_mut() {
|
|
||||||
if let Some(face) = parse_face(line) {
|
|
||||||
brush.faces.push(face);
|
|
||||||
}
|
|
||||||
} else if let Some(entity) = cur_entity.as_mut()
|
|
||||||
&& let Some((k, v)) = parse_property(line) {
|
|
||||||
entity.props.insert(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Map { entities }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `"key" "value"` — Wert darf leer sein.
|
|
||||||
fn parse_property(line: &str) -> Option<(String, String)> {
|
|
||||||
let mut it = line.split('"');
|
|
||||||
it.next()?; // vor dem ersten "
|
|
||||||
let key = it.next()?; // key
|
|
||||||
it.next()?; // zwischen den Paaren
|
|
||||||
let val = it.next()?; // value
|
|
||||||
Some((key.to_string(), val.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy`
|
|
||||||
#[allow(clippy::needless_range_loop)] // Punkt-/Komponenten-Index steuert zugleich den Token-Cursor `i`
|
|
||||||
fn parse_face(line: &str) -> Option<Face> {
|
|
||||||
let t: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
let mut i = 0;
|
|
||||||
let mut plane = [[0.0f32; 3]; 3];
|
|
||||||
for p in 0..3 {
|
|
||||||
if *t.get(i)? != "(" { return None; }
|
|
||||||
for k in 0..3 {
|
|
||||||
plane[p][k] = t.get(i + 1 + k)?.parse().ok()?;
|
|
||||||
}
|
|
||||||
if *t.get(i + 4)? != ")" { return None; }
|
|
||||||
i += 5;
|
|
||||||
}
|
|
||||||
let texture = (*t.get(i)?).to_string();
|
|
||||||
let nums: Option<Vec<f32>> = t[i + 1..i + 6].iter().map(|s| s.parse().ok()).collect();
|
|
||||||
let n = nums?;
|
|
||||||
Some(Face {
|
|
||||||
plane,
|
|
||||||
texture,
|
|
||||||
offset: [n[0], n[1]],
|
|
||||||
rotation: n[2],
|
|
||||||
scale: [n[3], n[4]],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const SAMPLE: &str = r#"
|
|
||||||
// entity 0
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
"wad" ""
|
|
||||||
// brush 0
|
|
||||||
{
|
|
||||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) placeholder 32 -32 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) placeholder -16 -32 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) placeholder -16 -32 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) placeholder -16 -32 0 1 1
|
|
||||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) placeholder -16 -32 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) placeholder 32 -32 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_entity_brush_and_face() {
|
|
||||||
let m = parse(SAMPLE);
|
|
||||||
assert_eq!(m.entities.len(), 1);
|
|
||||||
let e = &m.entities[0];
|
|
||||||
assert_eq!(e.classname(), Some("worldspawn"));
|
|
||||||
assert_eq!(e.props.get("wad").map(String::as_str), Some(""));
|
|
||||||
assert_eq!(e.brushes.len(), 1);
|
|
||||||
let b = &e.brushes[0];
|
|
||||||
assert_eq!(b.faces.len(), 6);
|
|
||||||
|
|
||||||
let f = &b.faces[0];
|
|
||||||
assert_eq!(f.texture, "placeholder");
|
|
||||||
assert_eq!(f.plane[0], [-48.0, -64.0, -48.0]);
|
|
||||||
assert_eq!(f.plane[2], [-48.0, -64.0, -47.0]);
|
|
||||||
assert_eq!(f.offset, [32.0, -32.0]);
|
|
||||||
assert_eq!(f.scale, [1.0, 1.0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-8
@@ -16,16 +16,15 @@
|
|||||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
||||||
//! signals::dispatch zu rufen).
|
//! signals::dispatch zu rufen).
|
||||||
//!
|
//!
|
||||||
//! `map`, `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen
|
//! `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an
|
||||||
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
||||||
//! Frontend per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
|
||||||
//! Datenmodells (Phase 2 des Renderer-Plans); `gltf` ist der Blender-Pfad
|
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
||||||
//! (Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
|
||||||
//! einem Export) und produziert das neutrale `model::Model`.
|
//! einem Export) und produziert das neutrale `model::Model`.
|
||||||
//!
|
//!
|
||||||
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
||||||
//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt
|
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
|
||||||
//! für den Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod collision;
|
pub mod collision;
|
||||||
@@ -33,7 +32,6 @@ pub mod game;
|
|||||||
pub mod gltf;
|
pub mod gltf;
|
||||||
pub mod ink;
|
pub mod ink;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod map;
|
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ pub const COLLIDE_PROP: &str = "collide";
|
|||||||
/// (einmal je Eintritt; erneut erst nach Verlassen)
|
/// (einmal je Eintritt; erneut erst nach Verlassen)
|
||||||
pub const SIGNAL_PROP: &str = "signal";
|
pub const SIGNAL_PROP: &str = "signal";
|
||||||
|
|
||||||
|
/// Custom-Property-Schlüssel für Empty-Rollen (der Entity-Kanal):
|
||||||
|
/// `role = "spawn"` markiert den Spieler-Startpunkt (Fußpunkt =
|
||||||
|
/// Empty-Position). Weitere Rollen geben künftige Konsumenten.
|
||||||
|
pub const ROLE_PROP: &str = "role";
|
||||||
|
pub const ROLE_SPAWN: &str = "spawn";
|
||||||
|
|
||||||
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
||||||
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
||||||
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
|
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
||||||
//!
|
//!
|
||||||
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
||||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (brush.rs hält seine eigenen
|
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
|
||||||
//! privat) — erst wenn Stufe 2 (Brush-Collision) echte dot/cross/normalize an
|
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
|
||||||
//! mehreren Stellen braucht, lohnt ein gemeinsames Modul.
|
//! Nutzer echte dot/cross/normalize braucht.
|
||||||
//!
|
//!
|
||||||
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
||||||
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
||||||
@@ -23,13 +23,13 @@
|
|||||||
use crate::engine::collision::CollisionWorld;
|
use crate::engine::collision::CollisionWorld;
|
||||||
|
|
||||||
/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf
|
/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf
|
||||||
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
pub const EYE_HEIGHT: f32 = 1.6;
|
pub const EYE_HEIGHT: f32 = 1.6;
|
||||||
|
|
||||||
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
||||||
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
||||||
const GRAVITY: f32 = 20.0;
|
const GRAVITY: f32 = 20.0;
|
||||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
const WALK_SPEED: f32 = 5.0;
|
const WALK_SPEED: f32 = 5.0;
|
||||||
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
||||||
const JUMP_SPEED: f32 = 7.0;
|
const JUMP_SPEED: f32 = 7.0;
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
//! Brush → Render-Geometrie.
|
|
||||||
//!
|
|
||||||
//! Ein Quake-Brush ist der Schnitt der Halbräume seiner Face-Ebenen. Aus
|
|
||||||
//! den Ebenen rekonstruieren wir das konvexe Polyeder (Standard-Verfahren:
|
|
||||||
//! alle Ebenen-Tripel schneiden, nur Punkte behalten, die in *allen*
|
|
||||||
//! Halbräumen liegen), bilden pro Face das Polygon und fächern es zu
|
|
||||||
//! Dreiecken. Indizes werden nach Textur gruppiert (ein Draw-Batch je
|
|
||||||
//! Textur).
|
|
||||||
//!
|
|
||||||
//! Koplanar-Elimination: stoßen zwei Brushes aneinander, teilen sie eine
|
|
||||||
//! Fläche — beide Faces liegen auf derselben Ebene, mit entgegengesetzten
|
|
||||||
//! Normalen, und sind nie sichtbar. Wir verwerfen jede Face, die von einer
|
|
||||||
//! solchen gegenüberliegenden Face vollständig überdeckt wird (hinter ihr
|
|
||||||
//! liegt dann garantiert solides Brush-Inneres). Zusammen mit dem Back-face
|
|
||||||
//! Culling der Pipeline bleibt so nur die Außenhaut übrig — ohne BSP.
|
|
||||||
//!
|
|
||||||
//! UVs nach dem Standard-Map-Format: die Textur-Achsen ergeben sich aus der
|
|
||||||
//! dominanten Flächennormale (Quake-`baseaxis`-Tabelle), dann Rotation,
|
|
||||||
//! Skalierung und Offset der Face. Das erzeugt das klassische Quake-
|
|
||||||
//! Verziehen auf schrägen Flächen — gewollt fürs PS1-Gefühl.
|
|
||||||
//!
|
|
||||||
//! Achsen: Quake ist Z-up, unsere Engine Y-up mit Blick -Z. UVs werden im
|
|
||||||
//! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position
|
|
||||||
//! danach umgerechnet: `(qx, qy, qz) → (qx, qz, -qy)`, mal `MAP_SCALE`.
|
|
||||||
|
|
||||||
use crate::engine::map::{self, Face, Map};
|
|
||||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
|
||||||
|
|
||||||
/// Punkt liegt „auf" einer Ebene / „innerhalb" eines Halbraums (Quake-Units).
|
|
||||||
const ON_EPS: f32 = 1e-2;
|
|
||||||
|
|
||||||
struct Plane {
|
|
||||||
n: [f32; 3], // nach außen, im Quake-System
|
|
||||||
d: f32, // n · x = d
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein fertig rekonstruiertes Face-Polygon (Quake-Koords, CCW um `n`),
|
|
||||||
/// vor der UV-/Engine-Umrechnung. Hält die Ebene für die Koplanar-Prüfung
|
|
||||||
/// und einen Verweis auf die Quell-Face (Textur, Offsets, Skalierung).
|
|
||||||
struct FacePoly<'a> {
|
|
||||||
n: [f32; 3],
|
|
||||||
d: f32,
|
|
||||||
poly: Vec<[f32; 3]>,
|
|
||||||
face: &'a Face,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Distinkte Texturnamen in der Map (sortiert) — die Indizes hier sind die
|
|
||||||
/// Textur-Indizes, auf die `Batch::texture` und der Bilder-Vec verweisen.
|
|
||||||
pub fn texture_names(map: &Map) -> Vec<String> {
|
|
||||||
let mut names: Vec<String> = map.entities.iter()
|
|
||||||
.flat_map(|e| &e.brushes)
|
|
||||||
.flat_map(|b| &b.faces)
|
|
||||||
.map(|f| f.texture.clone())
|
|
||||||
.collect();
|
|
||||||
names.sort_unstable();
|
|
||||||
names.dedup();
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alle Brushes zu einem Mesh. `tex_names` (aus [`texture_names`]) gibt die
|
|
||||||
/// Textur-Indizes, `dims` die Pixelmaße je Textur (für UV-Normalisierung).
|
|
||||||
pub fn build(map: &Map, tex_names: &[String], dims: &[(u32, u32)]) -> Mesh {
|
|
||||||
// 1) Alle Face-Polygone rekonstruieren (noch in Quake-Koords).
|
|
||||||
let mut faces: Vec<FacePoly> = Vec::new();
|
|
||||||
for ent in &map.entities {
|
|
||||||
for brush in &ent.brushes {
|
|
||||||
collect_faces(&brush.faces, &mut faces);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Koplanar verdeckte Faces markieren.
|
|
||||||
let hidden = mark_hidden(&faces);
|
|
||||||
|
|
||||||
// 3) Überlebende mit UV in den Vertex-Buffer ausgeben, nach Textur
|
|
||||||
// gruppiert.
|
|
||||||
let mut verts = Vec::new();
|
|
||||||
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
|
||||||
for (i, fp) in faces.iter().enumerate() {
|
|
||||||
if hidden[i] { continue; }
|
|
||||||
emit_face(fp, tex_names, dims, &mut verts, &mut per_tex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen.
|
|
||||||
let mut indices = Vec::new();
|
|
||||||
let mut batches = Vec::new();
|
|
||||||
for (ti, list) in per_tex.into_iter().enumerate() {
|
|
||||||
if list.is_empty() { continue; }
|
|
||||||
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
|
|
||||||
indices.extend_from_slice(&list);
|
|
||||||
}
|
|
||||||
Mesh { verts, indices, batches }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Die Face-Polygone *eines* Brushes rekonstruieren und an `out` anhängen.
|
|
||||||
fn collect_faces<'a>(faces: &'a [Face], out: &mut Vec<FacePoly<'a>>) {
|
|
||||||
let planes: Vec<Plane> = faces.iter().map(|f| plane(&f.plane)).collect();
|
|
||||||
let n = planes.len();
|
|
||||||
|
|
||||||
// Gültige Eckpunkte: Schnitt je dreier Ebenen, sofern in allen
|
|
||||||
// Halbräumen.
|
|
||||||
let mut corners: Vec<[f32; 3]> = Vec::new();
|
|
||||||
for i in 0..n {
|
|
||||||
for j in (i + 1)..n {
|
|
||||||
for k in (j + 1)..n {
|
|
||||||
let Some(p) = intersect(&planes[i], &planes[j], &planes[k]) else { continue; };
|
|
||||||
if planes.iter().all(|pl| dot(pl.n, p) <= pl.d + ON_EPS)
|
|
||||||
&& !corners.iter().any(|q| dist2(*q, p) < ON_EPS * ON_EPS)
|
|
||||||
{
|
|
||||||
corners.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro Face: Eckpunkte auf der Ebene sammeln, CCW um die Normale sortieren.
|
|
||||||
for (fi, face) in faces.iter().enumerate() {
|
|
||||||
let pl = &planes[fi];
|
|
||||||
let mut poly: Vec<[f32; 3]> = corners.iter().copied()
|
|
||||||
.filter(|&p| (dot(pl.n, p) - pl.d).abs() < ON_EPS)
|
|
||||||
.collect();
|
|
||||||
if poly.len() < 3 { continue; }
|
|
||||||
|
|
||||||
let center = average(&poly);
|
|
||||||
let axis_u = any_perp(pl.n);
|
|
||||||
let axis_v = cross(pl.n, axis_u);
|
|
||||||
poly.sort_by(|a, b| {
|
|
||||||
angle(*a, center, axis_u, axis_v)
|
|
||||||
.partial_cmp(&angle(*b, center, axis_u, axis_v))
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
|
|
||||||
out.push(FacePoly { n: pl.n, d: pl.d, poly, face });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Markiert Faces, die von einer gegenüberliegenden, deckungsgleichen Face
|
|
||||||
/// (anderer Brush) vollständig überdeckt werden — also nie sichtbar sind.
|
|
||||||
fn mark_hidden(faces: &[FacePoly]) -> Vec<bool> {
|
|
||||||
let mut hidden = vec![false; faces.len()];
|
|
||||||
for i in 0..faces.len() {
|
|
||||||
let a = &faces[i];
|
|
||||||
for (j, b) in faces.iter().enumerate() {
|
|
||||||
if i == j { continue; }
|
|
||||||
// Gegenüberliegende, koplanare Ebene? (n_a ≈ -n_b und d_a ≈ -d_b,
|
|
||||||
// denn n_b·x = d_b ⇒ n_a·x = -d_b muss a.d treffen.)
|
|
||||||
if dot(a.n, b.n) > -0.999 { continue; }
|
|
||||||
if (a.d + b.d).abs() > ON_EPS { continue; }
|
|
||||||
if covered_by(&a.poly, &b.poly, a.n) {
|
|
||||||
hidden[i] = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Liegt jeder Eckpunkt von `inner` innerhalb des Polygons `outer`? Beide
|
|
||||||
/// liegen (per Aufruf) auf derselben Ebene mit Normale `n`; wir projizieren
|
|
||||||
/// in deren 2D-Basis. Testpunkte werden minimal zur Polygonmitte gezogen,
|
|
||||||
/// damit deckungsgleiche Ränder/Ecken eindeutig als „innen" zählen.
|
|
||||||
fn covered_by(inner: &[[f32; 3]], outer: &[[f32; 3]], n: [f32; 3]) -> bool {
|
|
||||||
let u = any_perp(n);
|
|
||||||
let v = cross(n, u);
|
|
||||||
let c = average(inner);
|
|
||||||
let proj = |p: [f32; 3]| -> [f32; 2] { let d = sub(p, c); [dot(d, u), dot(d, v)] };
|
|
||||||
let outer2: Vec<[f32; 2]> = outer.iter().map(|&p| proj(p)).collect();
|
|
||||||
inner.iter().all(|&p| {
|
|
||||||
let pp = [
|
|
||||||
p[0] + 1e-3 * (c[0] - p[0]),
|
|
||||||
p[1] + 1e-3 * (c[1] - p[1]),
|
|
||||||
p[2] + 1e-3 * (c[2] - p[2]),
|
|
||||||
];
|
|
||||||
in_poly_2d(proj(pp), &outer2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crossing-Number-Punkt-in-Polygon in 2D (orientierungsunabhängig).
|
|
||||||
fn in_poly_2d(p: [f32; 2], poly: &[[f32; 2]]) -> bool {
|
|
||||||
let mut inside = false;
|
|
||||||
let mut j = poly.len() - 1;
|
|
||||||
for i in 0..poly.len() {
|
|
||||||
let (a, b) = (poly[i], poly[j]);
|
|
||||||
if (a[1] > p[1]) != (b[1] > p[1]) {
|
|
||||||
let t = (p[1] - a[1]) / (b[1] - a[1]);
|
|
||||||
if p[0] < a[0] + t * (b[0] - a[0]) { inside = !inside; }
|
|
||||||
}
|
|
||||||
j = i;
|
|
||||||
}
|
|
||||||
inside
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein Face-Polygon mit UV in den Vertex-Buffer fächern.
|
|
||||||
fn emit_face(
|
|
||||||
fp: &FacePoly,
|
|
||||||
tex_names: &[String],
|
|
||||||
dims: &[(u32, u32)],
|
|
||||||
verts: &mut Vec<Vertex>,
|
|
||||||
per_tex: &mut [Vec<u32>],
|
|
||||||
) {
|
|
||||||
let Some(ti) = tex_names.iter().position(|nm| nm == &fp.face.texture) else { return; };
|
|
||||||
let (tw, th) = dims[ti];
|
|
||||||
let (su, sv) = tex_axes(fp.n, fp.face); // skalierte Textur-Achsen (Quake)
|
|
||||||
let base = verts.len() as u32;
|
|
||||||
for p in &fp.poly {
|
|
||||||
// UV in Texeln, dann auf 0..1 normalisiert.
|
|
||||||
let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32;
|
|
||||||
let v = (dot(*p, sv) + fp.face.offset[1]) / th as f32;
|
|
||||||
verts.push(Vertex { pos: map::to_engine(*p), uv: [u, v] });
|
|
||||||
}
|
|
||||||
for t in 1..(fp.poly.len() as u32 - 1) {
|
|
||||||
per_tex[ti].extend([base, base + t, base + t + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ebene aus drei Face-Punkten. Quake listet sie im Uhrzeigersinn von vorn,
|
|
||||||
/// damit `cross(c-a, b-a)` nach außen zeigt.
|
|
||||||
fn plane(p: &[[f32; 3]; 3]) -> Plane {
|
|
||||||
let n = normalize(cross(sub(p[2], p[0]), sub(p[1], p[0])));
|
|
||||||
Plane { n, d: dot(n, p[0]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schnittpunkt dreier Ebenen (Cramer); `None`, wenn sie ~parallel sind.
|
|
||||||
fn intersect(a: &Plane, b: &Plane, c: &Plane) -> Option<[f32; 3]> {
|
|
||||||
let denom = dot(a.n, cross(b.n, c.n));
|
|
||||||
if denom.abs() < 1e-6 { return None; }
|
|
||||||
let mut x = scale(cross(b.n, c.n), a.d);
|
|
||||||
x = add(x, scale(cross(c.n, a.n), b.d));
|
|
||||||
x = add(x, scale(cross(a.n, b.n), c.d));
|
|
||||||
Some(scale(x, 1.0 / denom))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Textur-Achsen (skaliert) im Quake-System nach dem Standard-Map-Format:
|
|
||||||
/// dominante Achse aus der Normale, dann Rotation und Skalierung.
|
|
||||||
fn tex_axes(n: [f32; 3], face: &Face) -> ([f32; 3], [f32; 3]) {
|
|
||||||
let (mut u, mut v) = base_axes(n);
|
|
||||||
|
|
||||||
let (sinv, cosv) = face.rotation.to_radians().sin_cos();
|
|
||||||
// Basis-Achsen sind achsen-ausgerichtet (genau eine Komponente ≠ 0);
|
|
||||||
// wir rotieren ihre beiden in-plane-Komponenten.
|
|
||||||
let su = nonzero_axis(u);
|
|
||||||
let sv = nonzero_axis(v);
|
|
||||||
for vec in [&mut u, &mut v] {
|
|
||||||
let a = cosv * vec[su] - sinv * vec[sv];
|
|
||||||
let b = sinv * vec[su] + cosv * vec[sv];
|
|
||||||
vec[su] = a;
|
|
||||||
vec[sv] = b;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sx = if face.scale[0] == 0.0 { 1.0 } else { face.scale[0] };
|
|
||||||
let sy = if face.scale[1] == 0.0 { 1.0 } else { face.scale[1] };
|
|
||||||
(scale(u, 1.0 / sx), scale(v, 1.0 / sy))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quake-`baseaxis`-Tabelle: (Normalen-Kandidat, U-Achse, V-Achse) × 6.
|
|
||||||
/// Die U/V-Achsen der am besten passenden Normale bilden die Textur-Ebene.
|
|
||||||
fn base_axes(n: [f32; 3]) -> ([f32; 3], [f32; 3]) {
|
|
||||||
const BA: [[[f32; 3]; 3]; 6] = [
|
|
||||||
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], // Boden (+Z)
|
|
||||||
[[0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], // Decke (-Z)
|
|
||||||
[[ 1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // West (+X)
|
|
||||||
[[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // Ost (-X)
|
|
||||||
[[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]], // Süd (+Y)
|
|
||||||
[[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]], // Nord (-Y)
|
|
||||||
];
|
|
||||||
let mut best = -1.0;
|
|
||||||
let mut bi = 0;
|
|
||||||
for (i, ax) in BA.iter().enumerate() {
|
|
||||||
let d = dot(n, ax[0]);
|
|
||||||
if d > best { best = d; bi = i; }
|
|
||||||
}
|
|
||||||
(BA[bi][1], BA[bi][2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- kleine Vektor-Helfer auf [f32; 3] ---------------------------------------
|
|
||||||
|
|
||||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
|
||||||
fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] + b[0], a[1] + b[1], a[2] + b[2]] }
|
|
||||||
fn scale(a: [f32; 3], s: f32) -> [f32; 3] { [a[0] * s, a[1] * s, a[2] * s] }
|
|
||||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
|
||||||
fn dist2(a: [f32; 3], b: [f32; 3]) -> f32 { let d = sub(a, b); dot(d, d) }
|
|
||||||
|
|
||||||
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 { scale(a, 1.0 / len) } else { a }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn average(pts: &[[f32; 3]]) -> [f32; 3] {
|
|
||||||
let mut c = [0.0; 3];
|
|
||||||
for p in pts { c = add(c, *p); }
|
|
||||||
scale(c, 1.0 / pts.len() as f32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index der (einzigen) Nicht-Null-Komponente einer achsen-ausgerichteten Achse.
|
|
||||||
fn nonzero_axis(a: [f32; 3]) -> usize {
|
|
||||||
if a[0] != 0.0 { 0 } else if a[1] != 0.0 { 1 } else { 2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Achse in der Ebene, möglichst weit weg von der Normale, als Sortier-Basis.
|
|
||||||
fn any_perp(n: [f32; 3]) -> [f32; 3] {
|
|
||||||
let a = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
|
|
||||||
normalize(cross(n, a))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Winkel von `p` um `center` in der (u, v)-Basis — für CCW-Sortierung.
|
|
||||||
fn angle(p: [f32; 3], center: [f32; 3], u: [f32; 3], v: [f32; 3]) -> f32 {
|
|
||||||
let d = sub(p, center);
|
|
||||||
dot(d, v).atan2(dot(d, u))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::engine::map;
|
|
||||||
|
|
||||||
// Ein achsenparalleler Quader-Brush (x[-48,80], y[-64,64], z[-48,-16]).
|
|
||||||
const BOX: &str = r#"
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
{
|
|
||||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) t 0 0 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) t 0 0 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) t 0 0 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) t 0 0 0 1 1
|
|
||||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) t 0 0 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) t 0 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn box_brush_has_cuboid_geometry() {
|
|
||||||
let m = map::parse(BOX);
|
|
||||||
let names = texture_names(&m);
|
|
||||||
assert_eq!(names, vec!["t".to_string()]);
|
|
||||||
let mesh = build(&m, &names, &[(64, 64)]);
|
|
||||||
|
|
||||||
// Quader: 6 Faces · 4 Ecken = 24 Vertices, 6 · 2 Dreiecke · 3 = 36 Indizes.
|
|
||||||
assert_eq!(mesh.verts.len(), 24);
|
|
||||||
assert_eq!(mesh.indices.len(), 36);
|
|
||||||
// Eine Textur → ein Batch über alle Indizes.
|
|
||||||
assert_eq!(mesh.batches.len(), 1);
|
|
||||||
assert_eq!((mesh.batches[0].texture, mesh.batches[0].start, mesh.batches[0].count), (0, 0, 36));
|
|
||||||
|
|
||||||
// Engine-Bounds: x[-1.5,2.5], y(=quake z)[-1.5,-0.5], z(=-quake y)[-2,2].
|
|
||||||
let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]);
|
|
||||||
for v in &mesh.verts {
|
|
||||||
for k in 0..3 {
|
|
||||||
mn[k] = mn[k].min(v.pos[k]);
|
|
||||||
mx[k] = mx[k].max(v.pos[k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let approx = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
|
||||||
assert!(approx(mn[0], -1.5) && approx(mx[0], 2.5), "x: {:?}..{:?}", mn[0], mx[0]);
|
|
||||||
assert!(approx(mn[1], -1.5) && approx(mx[1], -0.5), "y: {:?}..{:?}", mn[1], mx[1]);
|
|
||||||
assert!(approx(mn[2], -2.0) && approx(mx[2], 2.0), "z: {:?}..{:?}", mn[2], mx[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zwei achsenparallele Würfel, die sich die Ebene x=64 teilen:
|
|
||||||
// A = x[0,64], B = x[64,128], beide y[0,64] z[0,64].
|
|
||||||
const TWO_BOXES: &str = 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
|
|
||||||
}
|
|
||||||
{
|
|
||||||
( 64 0 0 ) ( 64 1 0 ) ( 64 0 1 ) t 0 0 0 1 1
|
|
||||||
( 64 0 0 ) ( 64 0 1 ) ( 65 0 0 ) t 0 0 0 1 1
|
|
||||||
( 64 0 0 ) ( 65 0 0 ) ( 64 1 0 ) t 0 0 0 1 1
|
|
||||||
( 128 64 64 ) ( 128 65 64 ) ( 129 64 64 ) t 0 0 0 1 1
|
|
||||||
( 128 64 64 ) ( 129 64 64 ) ( 128 64 65 ) t 0 0 0 1 1
|
|
||||||
( 128 64 64 ) ( 128 64 65 ) ( 128 65 64 ) t 0 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shared_faces_are_eliminated() {
|
|
||||||
let m = map::parse(TWO_BOXES);
|
|
||||||
let names = texture_names(&m);
|
|
||||||
let mesh = build(&m, &names, &[(64, 64)]);
|
|
||||||
|
|
||||||
// 2 Würfel · 6 Faces = 12, minus die 2 deckungsgleichen an x=64 → 10.
|
|
||||||
// 10 Faces · 4 Ecken = 40 Vertices, 10 · 2 Dreiecke · 3 = 60 Indizes.
|
|
||||||
// (Ohne Elimination wären es 48 Vertices / 72 Indizes.)
|
|
||||||
assert_eq!(mesh.verts.len(), 40);
|
|
||||||
assert_eq!(mesh.indices.len(), 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uvs_scale_with_texture_size() {
|
|
||||||
// Auf der +Z-Boden-Face (baseaxis U=+X, V=-Y) ist U = qx/tw, V = -qy/th
|
|
||||||
// bei Offset 0, Scale 1. Prüft, dass UVs in Texeln/Größe gerechnet werden.
|
|
||||||
let m = map::parse(BOX);
|
|
||||||
let names = texture_names(&m);
|
|
||||||
let mesh = build(&m, &names, &[(64, 64)]);
|
|
||||||
// Alle UVs endlich und im erwarteten groben Bereich (Brush ~128 units
|
|
||||||
// breit / 64 Texel → bis ~2).
|
|
||||||
assert!(mesh.verts.iter().all(|v| v.uv[0].is_finite() && v.uv[1].is_finite()));
|
|
||||||
assert!(mesh.verts.iter().any(|v| v.uv[0].abs() > 0.5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+12
-30
@@ -14,7 +14,6 @@
|
|||||||
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
||||||
//! deren Schnittstellen — nie umgekehrt.
|
//! deren Schnittstellen — nie umgekehrt.
|
||||||
|
|
||||||
mod brush;
|
|
||||||
mod camera;
|
mod camera;
|
||||||
mod font;
|
mod font;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
@@ -36,7 +35,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
|
|||||||
|
|
||||||
use crate::engine::player;
|
use crate::engine::player;
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::tga::Image;
|
||||||
use crate::engine::{assets, gltf, map, model, tga};
|
use crate::engine::{assets, gltf, model, tga};
|
||||||
use crate::session::{FrameInput, Mode, Session};
|
use crate::session::{FrameInput, Mode, Session};
|
||||||
use camera::Camera;
|
use camera::Camera;
|
||||||
use gpu::Gpu;
|
use gpu::Gpu;
|
||||||
@@ -62,29 +61,26 @@ pub fn run(mut session: Session) {
|
|||||||
|
|
||||||
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
||||||
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
||||||
// getrennt.
|
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
|
||||||
let world = map::load(&assets::path("assets/maps/test.map"));
|
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
|
||||||
// Blender-Modelle: alle .glb unter assets/maps/props/. Autorinnen-
|
// nie fatal.
|
||||||
// Content — ein kaputtes File wird gemeldet und übersprungen, nie fatal.
|
|
||||||
let models = load_models(&assets::path("assets/maps/props"));
|
let models = load_models(&assets::path("assets/maps/props"));
|
||||||
|
|
||||||
// Eine geteilte Texturliste für Brush- und Modell-Geometrie, damit beide
|
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
|
||||||
// Meshes denselben Index-Raum benutzen und verschmelzen können.
|
// denselben Index-Raum benutzen und zu einem Buffer verschmelzen können.
|
||||||
let mut tex_names = brush::texture_names(&world);
|
let mut tex_names: Vec<String> = Vec::new();
|
||||||
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
|
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
|
||||||
tex_names.sort_unstable();
|
tex_names.sort_unstable();
|
||||||
tex_names.dedup();
|
tex_names.dedup();
|
||||||
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
|
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
|
||||||
let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect();
|
|
||||||
|
|
||||||
let mut mesh = brush::build(&world, &tex_names, &dims);
|
let mut mesh = Mesh::default();
|
||||||
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
||||||
report_map(&world, &tex_names, &mesh);
|
println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke",
|
||||||
|
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
|
||||||
|
|
||||||
// Sim-Welt (Collision-Brushes + Spawn, dann Collider/Trigger der
|
// Sim-Welt (Collider, Trigger, Spawn-Empty) in die Session einspielen —
|
||||||
// Modelle) in die Session einspielen — die Simulation gehört der
|
// die Simulation gehört der Session, nicht dem Render-Frontend.
|
||||||
// Session, nicht dem Render-Frontend.
|
|
||||||
session.load_world(&world);
|
|
||||||
for (_, m) in &models { session.load_props(m); }
|
for (_, m) in &models { session.load_props(m); }
|
||||||
|
|
||||||
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||||||
@@ -184,20 +180,6 @@ fn load_texture(name: &str) -> Image {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße.
|
|
||||||
fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) {
|
|
||||||
let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum();
|
|
||||||
let faces: usize = world.entities.iter()
|
|
||||||
.flat_map(|e| &e.brushes)
|
|
||||||
.map(|b| b.faces.len())
|
|
||||||
.sum();
|
|
||||||
println!(
|
|
||||||
"[map] {} Entities, {} Brushes, {} Faces, Texturen {:?} → {} Vertices, {} Dreiecke",
|
|
||||||
world.entities.len(), brushes, faces, tex_names,
|
|
||||||
mesh.verts.len(), mesh.indices.len() / 3,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
||||||
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
|
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
|
||||||
/// den Frames akkumuliert und nach dem Anwenden genullt.
|
/// den Frames akkumuliert und nach dem Anwenden genullt.
|
||||||
|
|||||||
+12
-13
@@ -1,13 +1,12 @@
|
|||||||
//! Blender-Modell → Render-Geometrie.
|
//! Blender-Modell → Render-Geometrie.
|
||||||
//!
|
//!
|
||||||
//! Gegenstück zu render::brush für den Modell-Pfad: nimmt das neutrale
|
//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein
|
||||||
//! [`Model`] aus engine::gltf und baut daraus ein [`Mesh`] (Vertices + nach
|
//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere
|
||||||
//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem
|
//! Modelle verschmelzen über `Mesh::append` zu einem Buffer.
|
||||||
//! Buffer verschmilzt (siehe `Mesh::append`).
|
|
||||||
//!
|
//!
|
||||||
//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst wie
|
//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst zu
|
||||||
//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces
|
//! `assets/textures/{name}.tga` (macht der Aufrufer). Faces ohne Material
|
||||||
//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
//! (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
||||||
//!
|
//!
|
||||||
//! UVs: das Modell trägt den V-Ursprung unten links, unsere `tga::Image`s
|
//! UVs: das Modell trägt den V-Ursprung unten links, unsere `tga::Image`s
|
||||||
//! oben links — V wird hier gespiegelt (Achsen-Konvention ist Sache des
|
//! oben links — V wird hier gespiegelt (Achsen-Konvention ist Sache des
|
||||||
@@ -22,7 +21,7 @@ use crate::render::scene::{Batch, Mesh, Vertex};
|
|||||||
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
||||||
|
|
||||||
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
||||||
/// Fallback gemappt — derselbe Kontrakt wie `brush::texture_names`.
|
/// Fallback gemappt.
|
||||||
pub fn texture_names(model: &Model) -> Vec<String> {
|
pub fn texture_names(model: &Model) -> Vec<String> {
|
||||||
let mut names: Vec<String> = model.materials.iter()
|
let mut names: Vec<String> = model.materials.iter()
|
||||||
.map(|m| resolve(m).to_string())
|
.map(|m| resolve(m).to_string())
|
||||||
@@ -36,9 +35,9 @@ fn resolve(material: &str) -> &str {
|
|||||||
if material.is_empty() { FALLBACK_TEXTURE } else { material }
|
if material.is_empty() { FALLBACK_TEXTURE } else { material }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt (wie bei
|
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur-
|
||||||
/// `brush::build`) die Textur-Indizes vor — darf eine Obermenge sein, so
|
/// Indizes vor — darf eine Obermenge sein, so teilen alle Modelle eine
|
||||||
/// teilen Brush- und Modell-Geometrie eine Texturliste.
|
/// gemeinsame Texturliste.
|
||||||
pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
||||||
let mut verts = Vec::new();
|
let mut verts = Vec::new();
|
||||||
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
||||||
@@ -56,7 +55,7 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen (wie brush).
|
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen.
|
||||||
let mut indices = Vec::new();
|
let mut indices = Vec::new();
|
||||||
let mut batches = Vec::new();
|
let mut batches = Vec::new();
|
||||||
for (ti, list) in per_tex.into_iter().enumerate() {
|
for (ti, list) in per_tex.into_iter().enumerate() {
|
||||||
@@ -119,7 +118,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shares_texture_index_space_with_superset_list() {
|
fn shares_texture_index_space_with_superset_list() {
|
||||||
// Obermengen-Liste (wie beim Merge mit Brush-Texturen): der Batch
|
// Obermengen-Liste (wie beim Merge mehrerer Modelle): der Batch
|
||||||
// zeigt auf den richtigen Index in der geteilten Liste.
|
// zeigt auf den richtigen Index in der geteilten Liste.
|
||||||
let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()];
|
let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()];
|
||||||
let mesh = build(&sample(), &names);
|
let mesh = build(&sample(), &names);
|
||||||
|
|||||||
+8
-10
@@ -1,9 +1,9 @@
|
|||||||
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
||||||
//!
|
//!
|
||||||
//! Stand Schritt 5: Geometrie + Texturen kommen vom Aufrufer (Brush-Welt
|
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
|
||||||
//! aus der `.map`, siehe render::brush; Bilder aus engine::tga). Pro Textur
|
//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group +
|
||||||
//! eine Bind-Group + ein Draw-Batch (Material-Batching „pro Textur ein
|
//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan).
|
||||||
//! Draw" wie im Plan). Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
||||||
|
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ pub(crate) struct Batch {
|
|||||||
|
|
||||||
/// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach
|
/// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach
|
||||||
/// Textur gruppiert; `batches` zeigt in diese Reihenfolge.
|
/// Textur gruppiert; `batches` zeigt in diese Reihenfolge.
|
||||||
|
#[derive(Default)]
|
||||||
pub(crate) struct Mesh {
|
pub(crate) struct Mesh {
|
||||||
pub(crate) verts: Vec<Vertex>,
|
pub(crate) verts: Vec<Vertex>,
|
||||||
pub(crate) indices: Vec<u32>,
|
pub(crate) indices: Vec<u32>,
|
||||||
@@ -107,12 +108,9 @@ impl ScenePass {
|
|||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
targets: &[Some(color_format.into())],
|
targets: &[Some(color_format.into())],
|
||||||
}),
|
}),
|
||||||
// Back-face Culling: Brush-Polygone werden CCW um ihre nach
|
// Back-face Culling: glTF-Dreiecke sind CCW von außen
|
||||||
// außen zeigende Normale gewickelt (siehe render::brush), und
|
// gewickelt — die Außenflächen sind front-facing, Rückseiten
|
||||||
// die Z-up→Y-up-Drehung erhält die Orientierung (det +1). Also
|
// fallen weg. (Front-Face bleibt der wgpu-Default CCW.)
|
||||||
// sind die Außenflächen front-facing — Rückseiten und alle
|
|
||||||
// verdeckten Innenflächen zwischen Brushes fallen weg.
|
|
||||||
// (Front-Face bleibt der wgpu-Default CCW.)
|
|
||||||
primitive: wgpu::PrimitiveState {
|
primitive: wgpu::PrimitiveState {
|
||||||
cull_mode: Some(wgpu::Face::Back),
|
cull_mode: Some(wgpu::Face::Back),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+33
-31
@@ -1,7 +1,6 @@
|
|||||||
use crate::engine::collision::CollisionWorld;
|
use crate::engine::collision::CollisionWorld;
|
||||||
use crate::engine::game::{Action, Game, ModeTarget};
|
use crate::engine::game::{Action, Game, ModeTarget};
|
||||||
use crate::engine::ink::StoryState;
|
use crate::engine::ink::StoryState;
|
||||||
use crate::engine::map::{self, Map};
|
|
||||||
use crate::engine::player::{self, Player};
|
use crate::engine::player::{self, Player};
|
||||||
use crate::engine::trigger::Triggers;
|
use crate::engine::trigger::Triggers;
|
||||||
use crate::engine::{kv, model, signals, story_ctrl};
|
use crate::engine::{kv, model, signals, story_ctrl};
|
||||||
@@ -83,8 +82,8 @@ impl Session {
|
|||||||
Self {
|
Self {
|
||||||
game,
|
game,
|
||||||
mode: Mode::Menu,
|
mode: Mode::Menu,
|
||||||
// Default-Welt, bis `load_world` eine Map einspielt (die CLI
|
// Default-Spawn, bis ein Modell einen `role=spawn`-Empty
|
||||||
// braucht keine — sie tickt nie).
|
// mitbringt (die CLI braucht keine Welt — sie tickt nie).
|
||||||
player: Player::new([0.0, 1.0, 0.0]),
|
player: Player::new([0.0, 1.0, 0.0]),
|
||||||
collision: CollisionWorld::empty(),
|
collision: CollisionWorld::empty(),
|
||||||
triggers: Triggers::new(),
|
triggers: Triggers::new(),
|
||||||
@@ -94,23 +93,21 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den
|
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
|
||||||
/// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim
|
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
|
||||||
/// Start gerufen.
|
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
|
||||||
pub fn load_world(&mut self, map: &Map) {
|
/// Zonen) und der Spieler-Spawn (Empty mit `role = spawn`; bringen
|
||||||
self.collision = CollisionWorld::build(map);
|
/// mehrere Modelle einen mit, gewinnt der zuletzt geladene). Die
|
||||||
self.triggers = Triggers::new();
|
/// Sichtgeometrie desselben Modells ist Sache des Renderers
|
||||||
self.player = Player::new(player_spawn(map));
|
/// (render::props).
|
||||||
}
|
|
||||||
|
|
||||||
/// Sim-Seite eines Blender-Modells in die bestehende Welt einfügen —
|
|
||||||
/// nach `load_world` rufen, einmal pro Modell: Collider (`collide`-
|
|
||||||
/// Property → konvexe Hülle) und Trigger (`signal`-Property →
|
|
||||||
/// Klick-Ziele/Zonen). Die Sichtgeometrie desselben Modells ist Sache
|
|
||||||
/// des Renderers (render::props).
|
|
||||||
pub fn load_props(&mut self, model: &model::Model) {
|
pub fn load_props(&mut self, model: &model::Model) {
|
||||||
self.collision.add_model(model);
|
self.collision.add_model(model);
|
||||||
self.triggers.add_model(model);
|
self.triggers.add_model(model);
|
||||||
|
for e in &model.empties {
|
||||||
|
if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) {
|
||||||
|
self.player = Player::new(e.pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||||||
@@ -362,20 +359,6 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default.
|
|
||||||
fn player_spawn(map: &Map) -> [f32; 3] {
|
|
||||||
for e in &map.entities {
|
|
||||||
if e.classname() == Some("info_player_start")
|
|
||||||
&& let Some(origin) = e.props.get("origin") {
|
|
||||||
let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
|
||||||
if let [x, y, z] = v[..] {
|
|
||||||
return map::to_engine([x, y, z]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[0.0, 1.0, 0.0]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn help() -> Vec<String> {
|
fn help() -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
||||||
@@ -578,6 +561,25 @@ mod tests {
|
|||||||
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
|
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_empty_places_player() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
let mut props = crate::engine::model::Props::new();
|
||||||
|
props.insert("role".into(), "spawn".into());
|
||||||
|
s.load_props(&crate::engine::model::Model {
|
||||||
|
objects: Vec::new(),
|
||||||
|
empties: vec![
|
||||||
|
crate::engine::model::Empty { name: "start".into(), pos: [3.0, 0.5, -2.0], props },
|
||||||
|
crate::engine::model::Empty {
|
||||||
|
name: "deko".into(), pos: [9.0, 9.0, 9.0],
|
||||||
|
props: crate::engine::model::Props::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
materials: Vec::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_command_reports_and_stays_free() {
|
fn unknown_command_reports_and_stays_free() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
|
|||||||
Reference in New Issue
Block a user