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:
+114
-35
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user