Der Loader zieht jetzt die baseColorTexture jedes Materials aus dem BIN-Chunk und dekodiert sie (PNG, via png-Crate) → model::Texture mit Pixeln. Die Namenskonvention "Materialname = Dateiname" und der assets/textures/-Pfad für Welt-Texturen entfallen; assets/textures/ bleibt nur noch für UI/Fonts/Platzhalter. - engine::image: neuer, geteilter RGBA8-Image-Typ (aus tga.rs gezogen) plus PNG-Decoder. tga.rs liefert weiter denselben Typ. - gltf: material -> texture -> image -> bufferView auflösen, Bild aus BIN dekodieren; fehlt ein Glied, bleibt image None (Platzhalter). KHR_texture_transform (Blenders Mapping-Node) wird beim Laden in die UVs eingebacken. - model::Model: materials: Vec<String> -> textures: Vec<Texture>. - render: die Pixel je Schlüssel aus den Modellen sammeln (erstes Vorkommen gewinnt, Schlüssel ohne Bild -> Platzhalter). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
219 lines
9.0 KiB
Rust
219 lines
9.0 KiB
Rust
//! Das geparste Blender-Modell: Ergebnis des Loaders (engine::gltf) und der
|
||
//! Vertrag, auf den sich seine Konsumenten stützen — collision, trigger,
|
||
//! audio, render::props und session::load_props arbeiten alle auf dieser
|
||
//! einen Struktur, statt selbst glTF zu lesen. Eine Entkopplungsgrenze
|
||
//! zwischen dem einen Loader und seinen Konsumenten, keine Format-Abstraktion:
|
||
//! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind
|
||
//! Geschichte), `gltf` der einzige Produzent.
|
||
//!
|
||
//! Texturen kommen als Pixel mit — der Loader zieht sie aus der `.glb`
|
||
//! (baseColorTexture, PNG-Chunk im BIN), siehe [`Texture`]. Keine externe
|
||
//! Namenskonvention mehr.
|
||
//!
|
||
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
|
||
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
|
||
//! [`apply_props`]:
|
||
//! `collide = true` → sichtbar **und** Collider (konvexe Hülle des
|
||
//! Meshes, siehe collision::object_planes)
|
||
//! `collide = "proxy"` → unsichtbarer Collider (reines Kollisionsvolumen)
|
||
//! `signal = <name>` → Trigger; mit Collider Klick-Ziel, ohne Collider
|
||
//! unsichtbare Betretens-Zone (engine::trigger)
|
||
//! nichts davon → nur sichtbar
|
||
//!
|
||
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
|
||
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
|
||
//! der Loader bleibt frei von Signal-Wissen.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use crate::engine::image::Image;
|
||
|
||
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
|
||
/// Werte zu Strings vereinheitlicht.
|
||
pub type Props = HashMap<String, String>;
|
||
|
||
pub struct Model {
|
||
pub objects: Vec<Object>,
|
||
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
|
||
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
|
||
pub empties: Vec<Empty>,
|
||
/// Distinkte Texturen in Auftrittsreihenfolge; `Object::tri_mats`
|
||
/// indiziert hierhin.
|
||
pub textures: Vec<Texture>,
|
||
}
|
||
|
||
/// Eine baseColorTexture aus der `.glb` (bzw. ein Material ohne Bild).
|
||
pub struct Texture {
|
||
/// Dedup-/Log-Schlüssel: der glTF-Bildname (Blender: Name des
|
||
/// Bild-Datablocks), sonst der Materialname, sonst `""`. Texturen mit
|
||
/// gleichem Schlüssel — auch über mehrere `.glb` — teilen sich auf der
|
||
/// GPU eine Textur.
|
||
pub key: String,
|
||
/// Dekodierte Pixel (RGBA8, Ursprung oben links). `None`: Material ohne
|
||
/// baseColorTexture oder unlesbares Bild → der Konsument nimmt seine
|
||
/// Platzhalter-Textur.
|
||
pub image: Option<Image>,
|
||
}
|
||
|
||
pub struct Object {
|
||
pub name: String,
|
||
/// Positionen in Engine-Koords (Y-up, Blick −Z), Welt-Raum (Transforms
|
||
/// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit).
|
||
pub verts: Vec<[f32; 3]>,
|
||
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
|
||
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
|
||
pub uvs: Vec<[f32; 2]>,
|
||
/// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im
|
||
/// Modell, rechnet der Loader sie aus den Flächen.
|
||
pub normals: Vec<[f32; 3]>,
|
||
pub tris: Vec<[usize; 3]>,
|
||
/// Textur-Index je Dreieck, parallel zu `tris` (→ `Model::textures`).
|
||
pub tri_mats: Vec<usize>,
|
||
pub visible: bool,
|
||
pub collider: bool,
|
||
pub props: Props,
|
||
}
|
||
|
||
pub struct Empty {
|
||
pub name: String,
|
||
/// Welt-Position (Engine-Koords).
|
||
pub pos: [f32; 3],
|
||
pub props: Props,
|
||
}
|
||
|
||
/// Custom-Property-Schlüssel, der ein Objekt zum Collider macht (siehe
|
||
/// Modul-Doc): truthy (`true`, `1`, …) = sichtbar + Collider, `"proxy"` =
|
||
/// unsichtbarer Collider. `0`/`false`/`no`/leer zählen als aus, damit die
|
||
/// Autorin die Property abschalten kann, ohne sie zu löschen.
|
||
pub const COLLIDE_PROP: &str = "collide";
|
||
|
||
/// Custom-Property-Schlüssel, der ein Objekt zum Trigger macht (Wert =
|
||
/// Signal-Name für `signals::dispatch`). Die Collider-Frage entscheidet die
|
||
/// Semantik — anfassbar = klickbar, durchlaufbar = Betretens-Zone:
|
||
/// `collide` + `signal` → solide **und** klickbar (Point-and-Click)
|
||
/// nur `signal` → unsichtbare Trigger-Zone, feuert beim Betreten
|
||
/// (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";
|
||
|
||
/// Auf Länge 1 bringen; der Nullvektor bleibt der Nullvektor (Sentinel für
|
||
/// „noch keine Normale" in [`fill_missing_normals`]).
|
||
pub fn norm(v: [f32; 3]) -> [f32; 3] {
|
||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||
if l > 0.0 { [v[0] / l, v[1] / l, v[2] / l] } else { v }
|
||
}
|
||
|
||
/// Fehlende Normalen (Nullvektoren) aus den angrenzenden Flächen füllen —
|
||
/// glTF erlaubt Meshes ohne `NORMAL`. Die Flächennormalen werden je Vertex
|
||
/// aufsummiert, was an Kanten weich mittelt; Alternative wäre, die Vertices
|
||
/// je Fläche aufzuspalten (hartes Flat-Shading) — dafür ist der Look hier
|
||
/// nicht empfindlich genug.
|
||
pub fn fill_missing_normals(o: &mut Object) {
|
||
if !o.normals.contains(&[0.0; 3]) { return; }
|
||
let mut acc = vec![[0.0f32; 3]; o.verts.len()];
|
||
for t in &o.tris {
|
||
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
|
||
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||
let n = [
|
||
u[1] * v[2] - u[2] * v[1],
|
||
u[2] * v[0] - u[0] * v[2],
|
||
u[0] * v[1] - u[1] * v[0],
|
||
];
|
||
for &i in t {
|
||
for k in 0..3 { acc[i][k] += n[k]; }
|
||
}
|
||
}
|
||
for (i, n) in o.normals.iter_mut().enumerate() {
|
||
if *n == [0.0; 3] { *n = norm(acc[i]); }
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
pub fn apply_props(o: &mut Object) {
|
||
if let Some(v) = o.props.get(COLLIDE_PROP) {
|
||
let v = v.to_ascii_lowercase();
|
||
if !matches!(v.as_str(), "" | "0" | "false" | "no") {
|
||
o.collider = true;
|
||
if v == "proxy" { o.visible = false; }
|
||
}
|
||
}
|
||
if !o.collider && o.props.contains_key(SIGNAL_PROP) {
|
||
o.visible = false;
|
||
}
|
||
}
|
||
|
||
/// Test-Helfer: Einheitswürfel-Objekt bei `min..min+1` (Quads CCW von außen,
|
||
/// Fan-trianguliert wie der Loader) mit optionalen `collide`-/`signal`-
|
||
/// Properties — geteilt von trigger-/session-/collision-Tests.
|
||
#[cfg(test)]
|
||
pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal: Option<&str>) -> Object {
|
||
let v = |dx: f32, dy: f32, dz: f32| [min[0] + dx, min[1] + dy, min[2] + dz];
|
||
let verts = vec![
|
||
v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0),
|
||
v(0.0, 0.0, 1.0), v(1.0, 0.0, 1.0), v(1.0, 1.0, 1.0), v(0.0, 1.0, 1.0),
|
||
];
|
||
let quads = [
|
||
[4, 5, 6, 7], [0, 3, 2, 1], [1, 2, 6, 5],
|
||
[0, 4, 7, 3], [3, 7, 6, 2], [0, 1, 5, 4],
|
||
];
|
||
let mut tris = Vec::new();
|
||
for q in quads {
|
||
tris.push([q[0], q[1], q[2]]);
|
||
tris.push([q[0], q[2], q[3]]);
|
||
}
|
||
let n = tris.len();
|
||
let mut props = Props::new();
|
||
if let Some(c) = collide { props.insert(COLLIDE_PROP.into(), c.into()); }
|
||
if let Some(s) = signal { props.insert(SIGNAL_PROP.into(), s.into()); }
|
||
let mut o = Object {
|
||
name: name.into(),
|
||
uvs: vec![[0.0, 0.0]; verts.len()],
|
||
// Vom Mittelpunkt nach außen — die Collider-Tests rendern nie, aber
|
||
// so ist die Richtung wenigstens plausibel.
|
||
normals: verts.iter()
|
||
.map(|v| norm([v[0] - min[0] - 0.5, v[1] - min[1] - 0.5, v[2] - min[2] - 0.5]))
|
||
.collect(),
|
||
verts, tris,
|
||
tri_mats: vec![0; n],
|
||
visible: true, collider: false, props,
|
||
};
|
||
apply_props(&mut o);
|
||
o
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn collide_prop_rules() {
|
||
// truthy → sichtbar + Collider; "proxy" → unsichtbarer Collider.
|
||
let o = test_cube("Kiste", [0.0; 3], Some("true"), None);
|
||
assert!(o.visible && o.collider);
|
||
let o = test_cube("Wand", [0.0; 3], Some("proxy"), None);
|
||
assert!(!o.visible && o.collider);
|
||
// Abschaltwerte zählen als aus; ohne Property nur sichtbar.
|
||
let o = test_cube("Aus", [0.0; 3], Some("0"), None);
|
||
assert!(o.visible && !o.collider);
|
||
let o = test_cube("Deko", [0.0; 3], None, None);
|
||
assert!(o.visible && !o.collider);
|
||
}
|
||
|
||
#[test]
|
||
fn signal_without_collider_is_invisible_zone() {
|
||
let o = test_cube("Zone", [0.0; 3], None, Some("betreten"));
|
||
assert!(!o.visible && !o.collider);
|
||
// Mit Collider bleibt das Objekt sichtbar (Klick-Ziel).
|
||
let o = test_cube("Kiste", [0.0; 3], Some("1"), Some("kiste"));
|
||
assert!(o.visible && o.collider);
|
||
}
|
||
}
|