Files
wds/src/engine/gltf.rs
T
irrlichtandClaude Opus 5 b9258a68fd G-Buffer: Normalen aus glTF bis in den Post-Vertrag
Cel-Ramp und ein Sobel, der auch Innenkanten findet, brauchen beide
Normalen — die gab es in der Pipeline bisher nirgends. Der Strang geht
quer durch alle Schichten, deshalb als eigener Schritt, bevor ein
Effekt darauf zugreift.

Loader: NORMAL wird gelesen und mit der Kofaktormatrix des 3x3-Teils
transformiert, nicht mit der Welt-Matrix. Das ist die inverse
Transponierte bis auf den Faktor det, den das Normieren frisst — nur so
bleibt eine Normale bei ungleichmäßiger Skalierung senkrecht auf ihrer
Fläche, und bei Spiegelungen dreht sie korrekt mit. glTF erlaubt Meshes
ohne NORMAL; die rechnet fill_missing_normals aus den angrenzenden
Flächen (Nullvektor als Sentinel). Beide Szenen-Modelle bringen eigene
Normalen mit, der Fallback ist reine Absicherung.

Szenen-Pass schreibt jetzt zwei Ziele: Albedo und n*0.5+0.5. Das Alpha
des Normal-Buffers ist für eine Objekt-ID reserviert und noch leer. Die
Depth-Textur bekommt TEXTURE_BINDING, und der Post-Vertrag wächst um
group 1 (Normale + Tiefe), die jeder Effekt ignorieren darf — dither
tut das.

Statt eines cel.wgsl-Durchreichers gibt es normals.wgsl, per Default
aus: `post normals on` zeigt oben die Normalen, unten die linearisierte
Tiefe. Ein Effekt, der nichts tut, wäre von einem kaputten nicht zu
unterscheiden; dieser hier trennt später "Loader kaputt" von "Effekt
kaputt".

Das Standard-Bild bleibt unverändert — die Normalen werden geschrieben,
aber von keinem aktiven Effekt gelesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00

495 lines
19 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.
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
//!
//! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
//! **Empties** (Nodes ohne Mesh) als Entity-Marker. 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.
//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
//!
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
use serde_json::Value;
use crate::engine::model::{apply_props, fill_missing_normals, norm, 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(), normals: Vec::new(),
tris: Vec::new(), tri_mats: Vec::new(),
};
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
fill_missing_normals(&mut o); // Primitives ohne NORMAL
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()],
};
// Ohne NORMAL bleibt der Nullvektor stehen; fill_missing_normals
// rechnet ihn später aus den Flächen.
let normals = match prim["attributes"]["NORMAL"].as_u64() {
Some(a) => read_floats::<3>(doc, bin, a as usize)?,
None => vec![[0.0, 0.0, 0.0]; positions.len()],
};
// Material-Name → geteilte Namensliste; 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();
let nm = normal_matrix(world);
for ((p, uv), n) in positions.iter().zip(&uvs).zip(&normals) {
o.verts.push(transform(world, *p));
o.uvs.push(*uv);
o.normals.push(norm(transform3(nm, *n)));
}
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
}
/// Normalen-Transform zur Welt-Matrix: die Kofaktormatrix ihres 3×3-Teils
/// (zeilenweise). Das ist die inverse Transponierte bis auf den Faktor
/// `det` — den frisst das anschließende Normieren, und im Gegensatz zur
/// Matrix selbst hält sie Normalen auch bei ungleichmäßiger Skalierung
/// senkrecht auf der Fläche. Bei Spiegelungen (det < 0) dreht sie die
/// Normale korrekt mit.
fn normal_matrix(m: M4) -> [f32; 9] {
let a = |r: usize, c: usize| m[c * 4 + r];
let cof = |r: usize, c: usize| {
let (r0, r1) = ((r + 1) % 3, (r + 2) % 3);
let (c0, c1) = ((c + 1) % 3, (c + 2) % 3);
a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
};
let mut out = [0.0; 9];
for r in 0..3 {
for c in 0..3 { out[r * 3 + c] = cof(r, c); }
}
out
}
/// Richtungsvektor mit einer 3×3-Matrix (zeilenweise) drehen — ohne
/// Translation, anders als [`transform`].
fn transform3(m: [f32; 9], v: [f32; 3]) -> [f32; 3] {
let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() {
*o = m[r * 3] * v[0] + m[r * 3 + 1] * v[1] + m[r * 3 + 2] * v[2];
}
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
}
/// Diagonale Normale — unter ungleichmäßiger Skalierung unterscheidet
/// sie die korrekte Kofaktor-Transform von der (falschen) Matrix.
const DIAG: f32 = std::f32::consts::FRAC_1_SQRT_2;
/// Ein Dreieck (Positionen + UVs + Normalen + u16-Indizes) im BIN-Chunk.
fn sample_bin() -> 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 _ in 0..3 { for f in [DIAG, DIAG, 0.0] { bin.extend(f.to_le_bytes()); } }
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
bin
}
/// Mesh-Node mit Translation und extras, ein skaliertes Kind, ein Empty.
const SAMPLE_JSON: &str = 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, "scale": [2, 1, 1],
"extras": {"collide": "proxy"}},
{"name": "spawn", "translation": [1, 2, 3]}
],
"meshes": [{"primitives": [{
"attributes": {"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2},
"indices": 3, "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": 5126, "count": 3, "type": "VEC3"},
{"bufferView": 3, "componentType": 5123, "count": 3, "type": "SCALAR"}
],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
{"buffer": 0, "byteOffset": 60, "byteLength": 36},
{"buffer": 0, "byteOffset": 96, "byteLength": 6}
],
"buffers": [{"byteLength": 102}]
}"#;
fn sample_glb() -> Vec<u8> { glb(SAMPLE_JSON, &sample_bin()) }
/// Dasselbe Modell ohne `NORMAL` — glTF erlaubt das, der Loader muss die
/// Normalen dann selbst rechnen.
fn sample_glb_without_normals() -> Vec<u8> {
glb(&SAMPLE_JSON.replace(r#", "NORMAL": 2"#, ""), &sample_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 normals_survive_nonuniform_scale() {
let m = parse_glb(&sample_glb()).unwrap();
// „Ding": nur Translation → Normale unverändert.
let n = m.objects[0].normals[0];
assert!((n[0] - DIAG).abs() < 1e-5 && (n[1] - DIAG).abs() < 1e-5, "{n:?}");
// „Kind": Skalierung [2,1,1]. Richtig ist die inverse Transponierte
// (x wird gestaucht → [0.447, 0.894, 0]); die Matrix selbst ergäbe
// fälschlich [0.894, 0.447, 0], die Normale stünde schief zur Fläche.
let n = m.objects[1].normals[0];
assert!((n[0] - 0.4472).abs() < 1e-3, "x sollte gestaucht sein: {n:?}");
assert!((n[1] - 0.8944).abs() < 1e-3, "y sollte überwiegen: {n:?}");
}
#[test]
fn missing_normals_are_computed_from_faces() {
let m = parse_glb(&sample_glb_without_normals()).unwrap();
// Dreieck liegt in der XY-Ebene, CCW → Normale +Z.
assert_eq!(m.objects[0].normals[0], [0.0, 0.0, 1.0]);
assert!(m.objects[0].normals.iter().all(|n| *n != [0.0; 3]));
}
#[test]
fn uvs_pass_through_unchanged() {
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
let m = parse_glb(&sample_glb()).unwrap();
assert_eq!(m.objects[0].uvs[2], [1.0, 1.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());
}
}