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:
+95
-13
@@ -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(); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user