From 56d92cdb46fbfee9b6ddf626ae72148fb381eb85 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Thu, 16 Jul 2026 19:35:02 +0200 Subject: [PATCH] Map-Loader entfernt: glTF ist das einzige 3D-Format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/maps/props/testscene.glb | Bin 0 -> 7684 bytes assets/maps/test.map | 48 ---- assets/maps/test02.map | 84 ------- src/engine/collision.rs | 80 +------ src/engine/map.rs | 190 --------------- src/engine/mod.rs | 14 +- src/engine/model.rs | 6 + src/engine/player.rs | 10 +- src/render/brush.rs | 411 -------------------------------- src/render/mod.rs | 42 +--- src/render/props.rs | 25 +- src/render/scene.rs | 18 +- src/session.rs | 64 ++--- 13 files changed, 94 insertions(+), 898 deletions(-) create mode 100644 assets/maps/props/testscene.glb delete mode 100644 assets/maps/test.map delete mode 100644 assets/maps/test02.map delete mode 100644 src/engine/map.rs delete mode 100644 src/render/brush.rs diff --git a/assets/maps/props/testscene.glb b/assets/maps/props/testscene.glb new file mode 100644 index 0000000000000000000000000000000000000000..25a4536ec98d0946e0c8faeada2a67046dee4989 GIT binary patch literal 7684 zcmeHLOK;mo5Z)%~yJ?!XX>WzVQx1&Eha^irq;}i{b(>g)Z6pN(!=NPE79f!ZMdbm( zfPO-cIrb;?*i-EMv(7B{qs6z>9NGdQV9xuS+1c4O?c>q@gDXOaYu^d+$LB))yw}~C zY0hNghT8f}J9a%ca6&)O*0oXn)@OQ z9CDdyeSb6>4k+3r@Uc5Nk=P3G9szljwIaYI=%|7xjyKq;4zLmdd({DUBA{Cx&`1P4 zNd*M{i2F4;b)NI`P<6m;#h7d&!9huYuY^{2-to;#5{(sL&6 zmOt`?2Totk$y1}=jksQHUV%T=V2aj(>bEwx&Io3k`aJPb-rS`&R!B>m6{n?MFK(*Y zEM4t^t~q_$@QEKphrBmEI&y=@L-)BHmA*ed^*uTo`!7%FPHCD}OQ%b9>d|d)QL3@? zf^K43H(kaCfsKcBnLoWliaSQVsS`uWP{wMH?{C@irdrHFU8>4a7EV{~^5Iq{J2fsF zE|qeHHZ4|V-`m=JxVf7FpcZ5S@>@jhuvJ6iE|HEpWm2mu+C;~uDo=*>OO0}TmGAl=cj%ihv%ObOr)LA7wyGA~{Rq2ZtDNn#M8_Cx~RhqI@nr}Jr zX70%6O)MW`n@+Wy&E!@twa_JwM(ILV8hP4JIzg8g%i?7wFS6dt(0%B7$02VP%*{2vVwTLkmI_{L(K;4`?NthI zH{NF1rfH>;mvt?(ZnI&ou<*==nJr_5?Ah>kXNByU;w`gH!@bz}vbATjTlOlOo(*rc zR>+<$zSE>J`p3oFW@Clynd0q+y+Za(@%EaP8QS>Vt24iF(CrR=bse1jbVG=H+dK4E zYwZoa&WUbT@azkpBA#&wFUHK{RZi)X$5W2Do-!Uauul<>dVupLT7$#~&#@)E1gF>h zhlraKCO@E&6uo1glyAZ(6`zX(pE8`34Y?D!;FF>WpL10^sPfJA$-(*kg8D~#rhjfk z6!OL0q#iQth=B|{^ei(b+nFxnGY>oTkl_y;$go3yarcUNUwkUA(9cKW1M#uAMekeU zOYyb%R@|ibP4T(-OxzGx>3vlQ@uB!iT&MSS@t*ibd?K#V`OSJE-!_^~u3`bwU3M0Td^3w)MghaNKHu^l*&VTYb&-fwr6iyx=*Pw9V13hE#GpC+PdY+__q;&8BRlW(IRQ$X7|99t~ z_nRM{w=y0x#y&;7#CfxTlf%ncrO)HJ4v9mwj7JUZQ^cd57>DwvdDIZ&C?DvfYTnI1 z_aVLm)cIx|?qjBW=f1j#=9y)_Ul;X|`95Yn`*I9q*r8{+M!sxkx`@v*?9fAoKX4$! z4n51bt9k#+_;|)>K8koj4`akQIXvcyee!txeZlpV@u-1)ig?r$, } -/// Die statische Kollisionswelt: alle soliden Brushes der Map. +/// Die statische Kollisionswelt: alle soliden Volumen der geladenen Modelle. pub struct CollisionWorld { brushes: Vec, } @@ -58,21 +52,7 @@ pub struct Hit { } impl CollisionWorld { - /// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert - /// eine Ebene (aus drei Punkten, nach Engine-Koords gedreht). - pub fn build(world: &Map) -> Self { - let mut brushes = Vec::new(); - for ent in &world.entities { - for b in &ent.brushes { - if b.faces.len() < 4 { continue; } // kein geschlossenes Volumen - let planes = b.faces.iter().map(|f| plane_from(&f.plane)).collect(); - brushes.push(ConvexBrush { planes }); - } - } - Self { brushes } - } - - /// Leere Welt (keine Brushes) — Default, bis eine Map geladen ist. + /// Leere Welt — Ausgangszustand, bis Modelle geladen sind. pub fn empty() -> Self { 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. -/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die -/// 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) --------------- +// --- 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] } @@ -483,29 +452,4 @@ mod tests { 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"); - } } diff --git a/src/engine/map.rs b/src/engine/map.rs deleted file mode 100644 index 54027ea..0000000 --- a/src/engine/map.rs +++ /dev/null @@ -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, -} - -#[derive(Default)] -pub struct Entity { - pub props: HashMap, - pub brushes: Vec, -} - -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, -} - -/// 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 = None; - let mut cur_brush: Option = 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 { - 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> = 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]); - } -} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b184a09..022787f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -16,16 +16,15 @@ //! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst //! signals::dispatch zu rufen). //! -//! `map`, `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen -//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes -//! Frontend per Pull konsumiert. `map` ist zugleich der Anfang des headless -//! Datenmodells (Phase 2 des Renderer-Plans); `gltf` ist der Blender-Pfad -//! (Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus +//! `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an +//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes +//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first: +//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus //! einem Export) und produziert das neutrale `model::Model`. //! //! `player` ist die First-Person-Physik, aus der der Renderer seine View -//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt -//! für den Swept-AABB-Trace in `player::step`. Beide bleiben headless. +//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den +//! Swept-AABB-Trace in `player::step`. Beide bleiben headless. pub mod assets; pub mod collision; @@ -33,7 +32,6 @@ pub mod game; pub mod gltf; pub mod ink; pub mod kv; -pub mod map; pub mod model; pub mod player; pub mod signals; diff --git a/src/engine/model.rs b/src/engine/model.rs index a8938b7..69123cb 100644 --- a/src/engine/model.rs +++ b/src/engine/model.rs @@ -71,6 +71,12 @@ pub const COLLIDE_PROP: &str = "collide"; /// (einmal je Eintritt; erneut erst nach Verlassen) 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 /// starten sichtbar und ohne Collider): `collide` schaltet den Collider, /// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert. diff --git a/src/engine/player.rs b/src/engine/player.rs index f9d78d3..82de847 100644 --- a/src/engine/player.rs +++ b/src/engine/player.rs @@ -6,9 +6,9 @@ //! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um. //! //! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten- -//! Arithmetik. Vektor-Helfer werden nicht geteilt (brush.rs hält seine eigenen -//! privat) — erst wenn Stufe 2 (Brush-Collision) echte dot/cross/normalize an -//! mehreren Stellen braucht, lohnt ein gemeinsames Modul. +//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine +//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter +//! Nutzer echte dot/cross/normalize braucht. //! //! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die //! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide). @@ -23,13 +23,13 @@ use crate::engine::collision::CollisionWorld; /// 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; /// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges /// Spielgefühl — wie die meisten Shooter. Tuning-Wert. 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; /// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert. const JUMP_SPEED: f32 = 7.0; diff --git a/src/render/brush.rs b/src/render/brush.rs deleted file mode 100644 index c6c122e..0000000 --- a/src/render/brush.rs +++ /dev/null @@ -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 { - let mut names: Vec = 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 = 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![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>) { - let planes: Vec = 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 { - 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, - per_tex: &mut [Vec], -) { - 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)); - } -} diff --git a/src/render/mod.rs b/src/render/mod.rs index 5bd9012..6849194 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -14,7 +14,6 @@ //! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert //! deren Schnittstellen — nie umgekehrt. -mod brush; mod camera; mod font; mod gpu; @@ -36,7 +35,7 @@ use winit::window::{CursorGrabMode, Window, WindowId}; use crate::engine::player; 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 camera::Camera; use gpu::Gpu; @@ -62,29 +61,26 @@ pub fn run(mut session: Session) { // CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen // auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben - // getrennt. - let world = map::load(&assets::path("assets/maps/test.map")); - // Blender-Modelle: alle .glb unter assets/maps/props/. Autorinnen- - // Content — ein kaputtes File wird gemeldet und übersprungen, nie fatal. + // getrennt. Die Welt sind alle .glb unter assets/maps/props/ — + // Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen, + // nie fatal. let models = load_models(&assets::path("assets/maps/props")); - // Eine geteilte Texturliste für Brush- und Modell-Geometrie, damit beide - // Meshes denselben Index-Raum benutzen und verschmelzen können. - let mut tex_names = brush::texture_names(&world); + // Eine geteilte Texturliste über alle Modelle, damit ihre Meshes + // denselben Index-Raum benutzen und zu einem Buffer verschmelzen können. + let mut tex_names: Vec = Vec::new(); for (_, m) in &models { tex_names.extend(props::texture_names(m)); } tex_names.sort_unstable(); tex_names.dedup(); let images: Vec = 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)); } - 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 - // Modelle) in die Session einspielen — die Simulation gehört der - // Session, nicht dem Render-Frontend. - session.load_world(&world); + // Sim-Welt (Collider, Trigger, Spawn-Empty) in die Session einspielen — + // die Simulation gehört der Session, nicht dem Render-Frontend. for (_, m) in &models { session.load_props(m); } // 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), /// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen /// den Frames akkumuliert und nach dem Anwenden genullt. diff --git a/src/render/props.rs b/src/render/props.rs index 231c99e..2f23c19 100644 --- a/src/render/props.rs +++ b/src/render/props.rs @@ -1,13 +1,12 @@ //! Blender-Modell → Render-Geometrie. //! -//! Gegenstück zu render::brush für den Modell-Pfad: nimmt das neutrale -//! [`Model`] aus engine::gltf und baut daraus ein [`Mesh`] (Vertices + nach -//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem -//! Buffer verschmilzt (siehe `Mesh::append`). +//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein +//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere +//! Modelle verschmelzen über `Mesh::append` zu einem Buffer. //! -//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst wie -//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces -//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`]. +//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst zu +//! `assets/textures/{name}.tga` (macht der Aufrufer). Faces ohne Material +//! (`""`) laufen unter [`FALLBACK_TEXTURE`]. //! //! 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 @@ -22,7 +21,7 @@ use crate::render::scene::{Batch, Mesh, Vertex}; pub const FALLBACK_TEXTURE: &str = "placeholder"; /// 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 { let mut names: Vec = model.materials.iter() .map(|m| resolve(m).to_string()) @@ -36,9 +35,9 @@ fn resolve(material: &str) -> &str { if material.is_empty() { FALLBACK_TEXTURE } else { material } } -/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt (wie bei -/// `brush::build`) die Textur-Indizes vor — darf eine Obermenge sein, so -/// teilen Brush- und Modell-Geometrie eine Texturliste. +/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur- +/// Indizes vor — darf eine Obermenge sein, so teilen alle Modelle eine +/// gemeinsame Texturliste. pub fn build(model: &Model, tex_names: &[String]) -> Mesh { let mut verts = Vec::new(); let mut per_tex: Vec> = 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 batches = Vec::new(); for (ti, list) in per_tex.into_iter().enumerate() { @@ -119,7 +118,7 @@ mod tests { #[test] 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. let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()]; let mesh = build(&sample(), &names); diff --git a/src/render/scene.rs b/src/render/scene.rs index f46f82e..0f2f7d5 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -1,9 +1,9 @@ //! Szenen-Pass: zeichnet die 3D-Welt ins interne Target. //! -//! Stand Schritt 5: Geometrie + Texturen kommen vom Aufrufer (Brush-Welt -//! aus der `.map`, siehe render::brush; Bilder aus engine::tga). Pro Textur -//! eine Bind-Group + ein Draw-Batch (Material-Batching „pro Textur ein -//! Draw" wie im Plan). Die Shader (scene.wgsl) sind die echten PS1-Shader. +//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via +//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group + +//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan). +//! Die Shader (scene.wgsl) sind die echten PS1-Shader. use wgpu::util::DeviceExt; @@ -28,6 +28,7 @@ pub(crate) struct Batch { /// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach /// Textur gruppiert; `batches` zeigt in diese Reihenfolge. +#[derive(Default)] pub(crate) struct Mesh { pub(crate) verts: Vec, pub(crate) indices: Vec, @@ -107,12 +108,9 @@ impl ScenePass { compilation_options: Default::default(), targets: &[Some(color_format.into())], }), - // Back-face Culling: Brush-Polygone werden CCW um ihre nach - // außen zeigende Normale gewickelt (siehe render::brush), und - // die Z-up→Y-up-Drehung erhält die Orientierung (det +1). Also - // 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.) + // Back-face Culling: glTF-Dreiecke sind CCW von außen + // gewickelt — die Außenflächen sind front-facing, Rückseiten + // fallen weg. (Front-Face bleibt der wgpu-Default CCW.) primitive: wgpu::PrimitiveState { cull_mode: Some(wgpu::Face::Back), ..Default::default() diff --git a/src/session.rs b/src/session.rs index 0853df8..4a2c733 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,7 +1,6 @@ use crate::engine::collision::CollisionWorld; use crate::engine::game::{Action, Game, ModeTarget}; use crate::engine::ink::StoryState; -use crate::engine::map::{self, Map}; use crate::engine::player::{self, Player}; use crate::engine::trigger::Triggers; use crate::engine::{kv, model, signals, story_ctrl}; @@ -83,8 +82,8 @@ impl Session { Self { game, mode: Mode::Menu, - // Default-Welt, bis `load_world` eine Map einspielt (die CLI - // braucht keine — sie tickt nie). + // Default-Spawn, bis ein Modell einen `role=spawn`-Empty + // mitbringt (die CLI braucht keine Welt — sie tickt nie). player: Player::new([0.0, 1.0, 0.0]), collision: CollisionWorld::empty(), triggers: Triggers::new(), @@ -94,23 +93,21 @@ impl Session { } } - /// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den - /// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim - /// Start gerufen. - pub fn load_world(&mut self, map: &Map) { - self.collision = CollisionWorld::build(map); - self.triggers = Triggers::new(); - self.player = Player::new(player_spawn(map)); - } - - /// 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). + /// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro + /// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`- + /// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/ + /// Zonen) und der Spieler-Spawn (Empty mit `role = spawn`; bringen + /// mehrere Modelle einen mit, gewinnt der zuletzt geladene). Die + /// Sichtgeometrie desselben Modells ist Sache des Renderers + /// (render::props). pub fn load_props(&mut self, model: &model::Model) { self.collision.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 @@ -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 = 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 { vec![ " signal 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); } + #[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] fn unknown_command_reports_and_stays_free() { let mut s = empty_session();