Files
wds/src/engine/model.rs
T
irrlichtandClaude Sonnet 5 fc3494466e Welt-Texturen aus der .glb statt aus assets/textures/
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
2026-09-02 00:29:46 +02:00

219 lines
9.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}