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:
2026-07-16 19:35:02 +02:00
parent 186ee9a817
commit 56d92cdb46
13 changed files with 94 additions and 898 deletions
-411
View File
@@ -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
View File
@@ -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<String> = Vec::new();
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
tex_names.sort_unstable();
tex_names.dedup();
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)); }
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.
+12 -13
View File
@@ -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<String> {
let mut names: Vec<String> = 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<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 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);
+8 -10
View File
@@ -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<Vertex>,
pub(crate) indices: Vec<u32>,
@@ -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()