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>
This commit is contained in:
2026-08-28 23:31:49 +02:00
co-authored by Claude Opus 5
parent 7f87dbca35
commit b9258a68fd
10 changed files with 317 additions and 79 deletions
+114 -35
View File
@@ -17,7 +17,7 @@
use serde_json::Value;
use crate::engine::model::{apply_props, Empty, Model, Object, Props};
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}"))?;
@@ -77,10 +77,11 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
let mut o = Object {
name, props,
visible: true, collider: false, // bis apply_props entscheidet
verts: Vec::new(), uvs: Vec::new(),
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); }
}
@@ -117,6 +118,12 @@ fn append_mesh(
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()
@@ -128,9 +135,11 @@ fn append_mesh(
});
let base = o.verts.len();
for (p, uv) in positions.iter().zip(&uvs) {
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() {
@@ -287,6 +296,36 @@ fn mul(a: M4, b: M4) -> M4 {
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() {
@@ -326,42 +365,59 @@ mod tests {
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> {
/// 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()); }
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)
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]
@@ -398,6 +454,29 @@ mod tests {
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.