Blender-Import-Pipeline: glTF/OBJ, col_*-Proxies, Trigger

Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als
Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales
engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie
und schiebt das Ergebnis per Session::load_props in den State, der
Renderer baut sein Mesh (render/props.rs, verschmolzen mit der
Brush-Welt über eine geteilte Texturliste).

glTF trägt Blender-Custom-Properties (node.extras → Object::props) und
Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` =
unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe
Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf
Sichtgeometrie).

Trigger (engine/trigger.rs) hängen an der Custom Property `signal`:
mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der
Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider
= unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert
wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige
Weg in den State.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:20:00 +02:00
parent 67c4a366c7
commit f7bfdb4a0b
14 changed files with 1465 additions and 43 deletions
+95 -13
View File
@@ -19,6 +19,7 @@ mod camera;
mod font;
mod gpu;
mod math;
mod props;
mod scene;
mod sprite;
mod ui;
@@ -35,7 +36,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, map, tga};
use crate::engine::{assets, gltf, map, model, obj, tga};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
@@ -63,17 +64,28 @@ pub fn run(mut session: Session) {
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
// getrennt.
let world = map::load(&assets::path("assets/maps/test.map"));
let tex_names = brush::texture_names(&world);
let images: Vec<Image> = tex_names.iter()
.map(|n| tga::load(&assets::path(&format!("assets/textures/{n}.tga"))))
.collect();
// Blender-Modelle: alle .glb/.obj 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);
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 mesh = brush::build(&world, &tex_names, &dims);
let mut mesh = brush::build(&world, &tex_names, &dims);
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
report_map(&world, &tex_names, &mesh);
// Sim-Welt (Collision-Brushes + Spawn) in die Session einspielen — die
// Simulation gehört der Session, nicht dem Render-Frontend.
// Sim-Welt (Collision-Brushes + Spawn, dann col_*-Proxies der Modelle) in
// die Session einspielen — die Simulation gehört der Session, nicht dem
// Render-Frontend.
session.load_world(&world);
for (_, m) in &models { session.load_props(m); }
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
@@ -122,6 +134,60 @@ pub fn run(mut session: Session) {
event_loop.run_app(&mut app).expect("winit: run");
}
/// Alle Blender-Modelle (`.glb` und `.obj`) eines Verzeichnisses laden
/// (sortiert → deterministische Reihenfolge). Fehlende Verzeichnisse sind
/// kein Fehler (kein Props-Ordner = keine Props); kaputte Dateien werden
/// gemeldet und übersprungen.
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
.map(|e| e.path())
.filter(|p| matches!(p.extension().and_then(|s| s.to_str()), Some("obj" | "glb")))
.collect();
paths.sort();
let mut models = Vec::new();
for p in paths {
let path = p.to_string_lossy();
let loaded = match p.extension().and_then(|s| s.to_str()) {
Some("glb") => gltf::load(&path),
_ => obj::load(&path),
};
match loaded {
Ok(m) => {
let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
m.objects.len(), m.empties.len());
// Custom Properties sichtbar machen — noch konsumiert sie
// niemand, aber die Autorin sieht so, dass sie ankommen.
for o in &m.objects {
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
}
for e in &m.empties {
println!("[model] Empty {} @ {:?}", e.name, e.pos);
for (k, v) in &e.props { println!("[model] {} · {k} = {v}", e.name); }
}
models.push((name, m));
}
Err(e) => eprintln!("[model] {e}"),
}
}
models
}
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
fn load_texture(name: &str) -> Image {
let path = assets::path(&format!("assets/textures/{name}.tga"));
if std::path::Path::new(&path).exists() {
tga::load(&path)
} else {
eprintln!("[tex] {name}: keine Datei unter {path} — Platzhalter");
tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
}
}
/// 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();
@@ -359,7 +425,8 @@ impl App {
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
self.session.tick(&input, dt);
// Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle.
for out in self.session.tick(&input, dt) { println!("{out}"); }
}
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
// Blick beim Fortsetzen um die aufgestaute Bewegung.
@@ -391,12 +458,25 @@ impl App {
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
};
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
// löst beim Klick unten das `use` aus.
let aim: Option<String> = if play {
let p = self.session.player.pos;
let eye = [p[0], p[1] + player::EYE_HEIGHT, p[2]];
let dir = self.session.player.look_dir();
self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string)
} else {
None
};
// Mausposition über die Letterbox in interne Pixel mappen.
let cursor = ui::Cursor {
pos: self.gpu.as_ref()
.map(|g| g.map_cursor(self.cursor_win))
.unwrap_or([0.0, 0.0]),
grabbed: self.input.grabbed,
hud_interact: aim.is_some(),
};
// Overlay state-driven bauen (verzweigt nach session.mode); die
// Hover-Aktion kommt aus der Maus-Position.
@@ -404,12 +484,14 @@ impl App {
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
);
// Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion
// durch denselben exec-Trichter wie die Konsole. Klick in der freien
// Welt tut (noch) nichts — hier käme später der `use`-Raycast.
// Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter
// wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im
// Spielmodus das Fadenkreuz-Ziel als `use <name>`.
if self.pending_click {
self.pending_click = false;
if let Some(action) = screen.hover_action.clone() {
let action = screen.hover_action.clone()
.or_else(|| aim.map(|name| format!("use {name}")));
if let Some(action) = action {
let r = self.session.exec(&action);
for out in r.output { println!("{out}"); }
if r.quit { event_loop.exit(); }
+123
View File
@@ -0,0 +1,123 @@
//! Blender-Modell → Render-Geometrie.
//!
//! Gegenstück zu render::brush für den OBJ-Pfad: nimmt das neutrale
//! [`Model`] aus engine::obj und baut daraus ein [`Mesh`] (Vertices + nach
//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem
//! Buffer verschmilzt (siehe `Mesh::append`).
//!
//! Texturen: der `usemtl`-Materialname *ist* der Texturname — aufgelöst wie
//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces
//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`].
//!
//! UVs: OBJ hat den V-Ursprung unten links, unsere `tga::Image`s oben links
//! — V wird hier gespiegelt (Achsen-Konvention ist Sache des Konsumenten,
//! siehe engine::tga). Nur `visible`-Objekte werden ausgegeben; reine
//! `col_*`-Proxies sind Sache von engine::collision.
use crate::engine::model::Model;
use crate::render::scene::{Batch, Mesh, Vertex};
/// Texturname für Faces ohne `usemtl` (Material `""`).
pub const FALLBACK_TEXTURE: &str = "placeholder";
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
/// Fallback gemappt — dasselbe Kontrakt wie `brush::texture_names`.
pub fn texture_names(model: &Model) -> Vec<String> {
let mut names: Vec<String> = model.materials.iter()
.map(|m| resolve(m).to_string())
.collect();
names.sort_unstable();
names.dedup();
names
}
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.
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()];
for o in &model.objects {
if !o.visible { continue; }
let base = verts.len() as u32;
for (p, uv) in o.verts.iter().zip(&o.uvs) {
verts.push(Vertex { pos: *p, uv: [uv[0], 1.0 - uv[1]] }); // V-Flip
}
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]);
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
per_tex[ti].extend(t.map(|i| base + i as u32));
}
}
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen (wie brush).
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 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::obj;
const SAMPLE: &str = "\
o Ding
v 0 0 0
v 1 0 0
v 1 1 0
vt 0 0
vt 1 0
vt 1 1
usemtl carpet
f 1/1 2/2 3/3
o col_Proxy
v 5 0 0
v 6 0 0
v 6 1 0
f 4 5 6
";
#[test]
fn builds_only_visible_objects_with_v_flip() {
let m = obj::parse(SAMPLE);
let names = texture_names(&m);
assert_eq!(names, vec!["carpet".to_string()]);
let mesh = build(&m, &names);
// Nur „Ding" (col_Proxy ist unsichtbar): 3 Vertices, 1 Dreieck.
assert_eq!(mesh.verts.len(), 3);
assert_eq!(mesh.indices, vec![0, 1, 2]);
assert_eq!(mesh.batches.len(), 1);
// vt (1,1) → V-Flip → uv (1,0).
assert_eq!(mesh.verts[2].uv, [1.0, 0.0]);
}
#[test]
fn missing_material_maps_to_fallback() {
let m = obj::parse("o A\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n");
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
let mesh = build(&m, &texture_names(&m));
assert_eq!(mesh.batches.len(), 1);
}
#[test]
fn shares_texture_index_space_with_superset_list() {
// Obermengen-Liste (wie beim Merge mit Brush-Texturen): der Batch
// zeigt auf den richtigen Index in der geteilten Liste.
let m = obj::parse(SAMPLE);
let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()];
let mesh = build(&m, &names);
assert_eq!(mesh.batches[0].texture, 1);
}
}
+18
View File
@@ -34,6 +34,24 @@ pub(crate) struct Mesh {
pub(crate) batches: Vec<Batch>,
}
impl Mesh {
/// Ein zweites Mesh anhängen (ein Vertex-/Index-Buffer für alles).
/// Voraussetzung: beide wurden gegen *dieselbe* Texturliste gebaut —
/// die Batch-Texturindizes bleiben dann unverändert gültig; nur die
/// Buffer-Offsets verschieben sich.
pub(crate) fn append(&mut self, other: Mesh) {
let vbase = self.verts.len() as u32;
let ibase = self.indices.len() as u32;
self.verts.extend(other.verts);
self.indices.extend(other.indices.iter().map(|i| i + vbase));
self.batches.extend(other.batches.iter().map(|b| Batch {
texture: b.texture,
start: b.start + ibase,
count: b.count,
}));
}
}
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
/// von WGSL verlangen.
+6 -3
View File
@@ -30,8 +30,7 @@ pub(crate) const ORN: usize = 4;
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast.
#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3;
pub(crate) const CUR_HUD_INTERACT: usize = 3; // dito, über einem Klick-Ziel
const CURSOR_PX: f32 = 16.0;
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
@@ -61,6 +60,9 @@ pub(crate) struct Cursor {
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
/// Maus-Cursor an `pos`.
pub(crate) grabbed: bool,
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
/// Variante des HUD-Pointers.
pub(crate) hud_interact: bool,
}
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
@@ -237,7 +239,8 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
if cur.grabbed {
ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
let idx = if cur.hud_interact { CUR_HUD_INTERACT } else { CUR_HUD };
ui.cursor(idx, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
} else {
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
ui.cursor(idx, cur.pos, [1.0; 4]);