9db3929750
col_*-Namenspräfix raus: Rollen steuern nur noch Custom Properties (collide=1 sichtbar+Collider, "proxy" unsichtbar, signal wie gehabt). Collider sind die konvexe Hülle der Vertices — konkave Meshes erlaubt. glb ist der einzige Modell-Pfad; suzanne.obj → suzanne.glb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
427 lines
17 KiB
Rust
427 lines
17 KiB
Rust
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
||
//!
|
||
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
|
||
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
|
||
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
||
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/…
|
||
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die
|
||
//! Konsumenten.
|
||
//!
|
||
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
||
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
||
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
||
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
||
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken —
|
||
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe
|
||
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
|
||
//!
|
||
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
||
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
||
//!
|
||
//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum,
|
||
//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders
|
||
//! als beim trivialen signals-TOML-Subset).
|
||
|
||
use serde_json::Value;
|
||
|
||
use crate::engine::model::{apply_props, Empty, Model, Object, Props};
|
||
|
||
pub fn load(path: &str) -> Result<Model, String> {
|
||
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
||
parse_glb(&bytes).map_err(|e| format!("gltf {path}: {e}"))
|
||
}
|
||
|
||
/// GLB-Container: 12-Byte-Header (`glTF`, Version 2, Gesamtlänge), dann
|
||
/// Chunks aus Länge/Typ/Daten. Wir brauchen JSON (`JSON`) und BIN (`BIN\0`).
|
||
pub fn parse_glb(bytes: &[u8]) -> Result<Model, String> {
|
||
if bytes.len() < 12 || &bytes[0..4] != b"glTF" {
|
||
return Err("kein GLB (Magic fehlt) — in Blender als „glTF Binary (.glb)“ exportieren".into());
|
||
}
|
||
let version = u32_at(bytes, 4)?;
|
||
if version != 2 { return Err(format!("glTF-Version {version}, unterstützt ist 2")); }
|
||
|
||
let (mut json, mut bin): (Option<&[u8]>, &[u8]) = (None, &[]);
|
||
let mut off = 12;
|
||
while off + 8 <= bytes.len() {
|
||
let len = u32_at(bytes, off)? as usize;
|
||
let ty = &bytes[off + 4..off + 8];
|
||
let data = bytes.get(off + 8..off + 8 + len).ok_or("Chunk länger als Datei")?;
|
||
match ty {
|
||
b"JSON" => json = Some(data),
|
||
b"BIN\0" => bin = data,
|
||
_ => {} // unbekannte Chunks per Spec ignorieren
|
||
}
|
||
off += 8 + len.next_multiple_of(4); // Chunks sind 4-Byte-aligned
|
||
}
|
||
let json = json.ok_or("kein JSON-Chunk")?;
|
||
let doc: Value = serde_json::from_slice(json).map_err(|e| format!("JSON: {e}"))?;
|
||
build(&doc, bin)
|
||
}
|
||
|
||
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
||
let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() };
|
||
|
||
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
|
||
// Welt-Transform wächst dabei Parent → Kind.
|
||
let scene = doc["scene"].as_u64().unwrap_or(0) as usize;
|
||
let roots = doc["scenes"][scene]["nodes"].as_array().cloned().unwrap_or_default();
|
||
for r in roots {
|
||
let Some(i) = r.as_u64() else { continue; };
|
||
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
|
||
}
|
||
Ok(model)
|
||
}
|
||
|
||
fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> {
|
||
let node = &doc["nodes"][idx];
|
||
if node.is_null() { return Err(format!("Node {idx} fehlt")); }
|
||
let world = mul(parent, node_local(node));
|
||
let name = node["name"].as_str().map_or_else(|| format!("node{idx}"), str::to_string);
|
||
let props = extras_props(&node["extras"]);
|
||
|
||
match node["mesh"].as_u64() {
|
||
Some(mesh) => {
|
||
let mut o = Object {
|
||
name, props,
|
||
visible: true, collider: false, // bis apply_props entscheidet
|
||
verts: Vec::new(), uvs: Vec::new(),
|
||
tris: Vec::new(), tri_mats: Vec::new(),
|
||
};
|
||
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
||
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
|
||
if !o.tris.is_empty() { model.objects.push(o); }
|
||
}
|
||
// Node ohne Mesh = Empty (Entity-Marker); Position aus der
|
||
// Welt-Transform (Translationsspalte).
|
||
None => model.empties.push(Empty {
|
||
name, props,
|
||
pos: [world[12], world[13], world[14]],
|
||
}),
|
||
}
|
||
|
||
for c in node["children"].as_array().into_iter().flatten() {
|
||
if let Some(ci) = c.as_u64() {
|
||
walk_node(doc, bin, ci as usize, world, model)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein
|
||
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
|
||
fn append_mesh(
|
||
doc: &Value, bin: &[u8], mesh: usize, world: M4,
|
||
o: &mut Object, materials: &mut Vec<String>,
|
||
) -> Result<(), String> {
|
||
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
|
||
if prim["mode"].as_u64().unwrap_or(4) != 4 {
|
||
eprintln!("[gltf] {}: Primitive-Mode ≠ Dreiecke — übersprungen", o.name);
|
||
continue;
|
||
}
|
||
let Some(pos_acc) = prim["attributes"]["POSITION"].as_u64() else { continue; };
|
||
let positions = read_floats::<3>(doc, bin, pos_acc as usize)?;
|
||
let uvs = match prim["attributes"]["TEXCOORD_0"].as_u64() {
|
||
Some(a) => read_floats::<2>(doc, bin, a as usize)?,
|
||
None => vec![[0.0, 0.0]; positions.len()],
|
||
};
|
||
|
||
// Material-Name → geteilte Namensliste (wie usemtl); ohne Material "".
|
||
let mat_name = prim["material"].as_u64()
|
||
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
||
.unwrap_or("");
|
||
let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| {
|
||
materials.push(mat_name.to_string());
|
||
materials.len() - 1
|
||
});
|
||
|
||
let base = o.verts.len();
|
||
for (p, uv) in positions.iter().zip(&uvs) {
|
||
o.verts.push(transform(world, *p));
|
||
o.uvs.push(*uv);
|
||
}
|
||
// glTF-UVs haben den Ursprung oben links — auf die Modell-Konvention
|
||
// (unten links, wie OBJ) spiegeln, damit der Render-Konsument beide
|
||
// Pfade gleich behandelt.
|
||
for uv in &mut o.uvs[base..] { uv[1] = 1.0 - uv[1]; }
|
||
|
||
let idxs: Vec<usize> = match prim["indices"].as_u64() {
|
||
Some(a) => read_indices(doc, bin, a as usize)?,
|
||
None => (0..positions.len()).collect(), // non-indexed: sequenziell
|
||
};
|
||
for t in idxs.chunks_exact(3) {
|
||
if t.iter().any(|&i| i >= positions.len()) {
|
||
return Err(format!("{}: Index außerhalb der Positionen", o.name));
|
||
}
|
||
o.tris.push([base + t[0], base + t[1], base + t[2]]);
|
||
o.tri_mats.push(mi);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `node.extras` (Objekt) → Props; Skalare werden zu Strings vereinheitlicht,
|
||
/// verschachtelte Werte als kompaktes JSON durchgereicht.
|
||
fn extras_props(extras: &Value) -> Props {
|
||
let mut props = Props::new();
|
||
if let Some(map) = extras.as_object() {
|
||
for (k, v) in map {
|
||
let s = match v {
|
||
Value::String(s) => s.clone(),
|
||
other => other.to_string(),
|
||
};
|
||
props.insert(k.clone(), s);
|
||
}
|
||
}
|
||
props
|
||
}
|
||
|
||
// --- Accessor-Dekodierung ----------------------------------------------------
|
||
|
||
/// Bytes eines Accessors samt Element-Stride auflösen (BufferView-Offset,
|
||
/// Accessor-Offset, optionaler byteStride — sonst dicht gepackt).
|
||
fn accessor_bytes<'a>(doc: &Value, bin: &'a [u8], acc: usize, elem_size: usize)
|
||
-> Result<(&'a [u8], usize, usize), String>
|
||
{
|
||
let a = &doc["accessors"][acc];
|
||
let count = a["count"].as_u64().ok_or("Accessor ohne count")? as usize;
|
||
let view = a["bufferView"].as_u64().ok_or("Accessor ohne bufferView")? as usize;
|
||
let v = &doc["bufferViews"][view];
|
||
let v_off = v["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||
let a_off = a["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||
let stride = v["byteStride"].as_u64().map_or(elem_size, |s| s as usize);
|
||
let start = v_off + a_off;
|
||
let need = start + stride * count.saturating_sub(1) + elem_size;
|
||
if need > bin.len() { return Err("Accessor ragt aus dem BIN-Chunk".into()); }
|
||
Ok((&bin[start..], stride, count))
|
||
}
|
||
|
||
/// N-Komponenten-float-Accessor (POSITION: N=3, TEXCOORD: N=2) lesen.
|
||
fn read_floats<const N: usize>(doc: &Value, bin: &[u8], acc: usize) -> Result<Vec<[f32; N]>, String> {
|
||
let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0);
|
||
if ctype != 5126 {
|
||
return Err(format!("Accessor {acc}: componentType {ctype}, erwartet float (5126)"));
|
||
}
|
||
let (bytes, stride, count) = accessor_bytes(doc, bin, acc, N * 4)?;
|
||
let mut out = Vec::with_capacity(count);
|
||
for i in 0..count {
|
||
let mut e = [0.0f32; N];
|
||
for (k, v) in e.iter_mut().enumerate() {
|
||
let o = i * stride + k * 4;
|
||
*v = f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
|
||
}
|
||
out.push(e);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Index-Accessor lesen (u8/u16/u32 → usize).
|
||
fn read_indices(doc: &Value, bin: &[u8], acc: usize) -> Result<Vec<usize>, String> {
|
||
let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0);
|
||
let size = match ctype {
|
||
5121 => 1, // u8
|
||
5123 => 2, // u16
|
||
5125 => 4, // u32
|
||
_ => return Err(format!("Index-Accessor {acc}: componentType {ctype}")),
|
||
};
|
||
let (bytes, stride, count) = accessor_bytes(doc, bin, acc, size)?;
|
||
let mut out = Vec::with_capacity(count);
|
||
for i in 0..count {
|
||
let o = i * stride;
|
||
out.push(match size {
|
||
1 => bytes[o] as usize,
|
||
2 => u16::from_le_bytes(bytes[o..o + 2].try_into().unwrap()) as usize,
|
||
_ => u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as usize,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
// --- Transform-Helfer (4×4 column-major, wie glTFs `matrix`) -----------------
|
||
|
||
type M4 = [f32; 16];
|
||
|
||
const IDENTITY: M4 = [
|
||
1.0, 0.0, 0.0, 0.0,
|
||
0.0, 1.0, 0.0, 0.0,
|
||
0.0, 0.0, 1.0, 0.0,
|
||
0.0, 0.0, 0.0, 1.0,
|
||
];
|
||
|
||
/// Lokale Transform eines Nodes: explizite `matrix` oder T·R·S
|
||
/// (glTF-Reihenfolge; fehlende Anteile sind Identität).
|
||
fn node_local(node: &Value) -> M4 {
|
||
if let Some(m) = node["matrix"].as_array() {
|
||
let mut out = IDENTITY;
|
||
for (i, v) in m.iter().take(16).enumerate() {
|
||
out[i] = v.as_f64().unwrap_or(0.0) as f32;
|
||
}
|
||
return out;
|
||
}
|
||
let t = vecn::<3>(&node["translation"], [0.0, 0.0, 0.0]);
|
||
let q = vecn::<4>(&node["rotation"], [0.0, 0.0, 0.0, 1.0]);
|
||
let s = vecn::<3>(&node["scale"], [1.0, 1.0, 1.0]);
|
||
|
||
// Rotationsmatrix aus dem Quaternion (x, y, z, w), Spalten skaliert,
|
||
// Translation in die vierte Spalte — direkt komponiertes T·R·S.
|
||
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
|
||
let r = [
|
||
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)],
|
||
[2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)],
|
||
[2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)],
|
||
]; // r[spalte][zeile]
|
||
let mut out = IDENTITY;
|
||
for c in 0..3 {
|
||
for row in 0..3 {
|
||
out[c * 4 + row] = r[c][row] * s[c];
|
||
}
|
||
}
|
||
out[12] = t[0]; out[13] = t[1]; out[14] = t[2];
|
||
out
|
||
}
|
||
|
||
fn vecn<const N: usize>(v: &Value, default: [f32; N]) -> [f32; N] {
|
||
let Some(arr) = v.as_array() else { return default; };
|
||
let mut out = default;
|
||
for (o, x) in out.iter_mut().zip(arr) {
|
||
if let Some(f) = x.as_f64() { *o = f as f32; }
|
||
}
|
||
out
|
||
}
|
||
|
||
fn mul(a: M4, b: M4) -> M4 {
|
||
let mut out = [0.0; 16];
|
||
for c in 0..4 {
|
||
for r in 0..4 {
|
||
out[c * 4 + r] = (0..4).map(|k| a[k * 4 + r] * b[c * 4 + k]).sum();
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
|
||
let mut out = [0.0; 3];
|
||
for (r, o) in out.iter_mut().enumerate() {
|
||
*o = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r];
|
||
}
|
||
out
|
||
}
|
||
|
||
fn u32_at(bytes: &[u8], off: usize) -> Result<u32, String> {
|
||
bytes.get(off..off + 4)
|
||
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||
.ok_or_else(|| "Datei zu kurz".into())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// GLB aus JSON-Text und BIN-Daten zusammensetzen (mit 4-Byte-Padding),
|
||
/// wie es ein Exporter täte.
|
||
fn glb(json: &str, bin: &[u8]) -> Vec<u8> {
|
||
let mut j = json.as_bytes().to_vec();
|
||
while !j.len().is_multiple_of(4) { j.push(b' '); }
|
||
let mut b = bin.to_vec();
|
||
while !b.len().is_multiple_of(4) { b.push(0); }
|
||
let total = 12 + 8 + j.len() + 8 + b.len();
|
||
let mut out = Vec::new();
|
||
out.extend(b"glTF");
|
||
out.extend(2u32.to_le_bytes());
|
||
out.extend((total as u32).to_le_bytes());
|
||
out.extend((j.len() as u32).to_le_bytes());
|
||
out.extend(b"JSON");
|
||
out.extend(j);
|
||
out.extend((b.len() as u32).to_le_bytes());
|
||
out.extend(b"BIN\0");
|
||
out.extend(b);
|
||
out
|
||
}
|
||
|
||
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein
|
||
/// Mesh-Node mit Translation und extras, ein Empty mit extras.
|
||
fn sample_glb() -> Vec<u8> {
|
||
let mut bin: Vec<u8> = Vec::new();
|
||
for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); }
|
||
for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); }
|
||
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
|
||
let json = r#"{
|
||
"asset": {"version": "2.0"},
|
||
"scene": 0,
|
||
"scenes": [{"nodes": [0, 2]}],
|
||
"nodes": [
|
||
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
||
"extras": {"signal": "tiffany", "hp": 3},
|
||
"children": [1]},
|
||
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}},
|
||
{"name": "spawn", "translation": [1, 2, 3]}
|
||
],
|
||
"meshes": [{"primitives": [{
|
||
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
|
||
"indices": 2, "material": 0
|
||
}]}],
|
||
"materials": [{"name": "carpet"}],
|
||
"accessors": [
|
||
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
|
||
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
|
||
{"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"}
|
||
],
|
||
"bufferViews": [
|
||
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
|
||
{"buffer": 0, "byteOffset": 60, "byteLength": 6}
|
||
],
|
||
"buffers": [{"byteLength": 66}]
|
||
}"#;
|
||
glb(json, &bin)
|
||
}
|
||
|
||
#[test]
|
||
fn parses_geometry_with_baked_transforms() {
|
||
let m = parse_glb(&sample_glb()).unwrap();
|
||
assert_eq!(m.objects.len(), 2);
|
||
|
||
let d = &m.objects[0];
|
||
assert_eq!(d.name, "Ding");
|
||
assert_eq!(d.tris, vec![[0, 1, 2]]);
|
||
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
|
||
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
|
||
assert!(!d.visible && !d.collider);
|
||
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
||
|
||
// Kind erbt die Parent-Transform (10, 0, 0);
|
||
// collide="proxy" → unsichtbarer Collider.
|
||
let k = &m.objects[1];
|
||
assert_eq!(k.name, "Kind");
|
||
assert!(!k.visible && k.collider);
|
||
assert_eq!(k.verts[0], [10.0, 0.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn extras_become_props_and_empties_are_captured() {
|
||
let m = parse_glb(&sample_glb()).unwrap();
|
||
let d = &m.objects[0];
|
||
assert_eq!(d.props.get("signal").map(String::as_str), Some("tiffany"));
|
||
assert_eq!(d.props.get("hp").map(String::as_str), Some("3")); // Zahl → String
|
||
|
||
assert_eq!(m.empties.len(), 1);
|
||
let e = &m.empties[0];
|
||
assert_eq!(e.name, "spawn");
|
||
assert_eq!(e.pos, [1.0, 2.0, 3.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn uv_origin_is_flipped_to_model_convention() {
|
||
// glTF-UV (1,1) (oben-links-Ursprung) → Modell-Konvention (1,0).
|
||
let m = parse_glb(&sample_glb()).unwrap();
|
||
assert_eq!(m.objects[0].uvs[2], [1.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_non_glb_and_wrong_version() {
|
||
assert!(parse_glb(b"PNG...whatever").is_err());
|
||
let mut v1 = sample_glb();
|
||
v1[4] = 1; // Version patchen
|
||
assert!(parse_glb(&v1).is_err());
|
||
}
|
||
}
|