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
+1
View File
@@ -434,6 +434,7 @@ mod tests {
name: "Zone".into(), name: "Zone".into(),
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0]; 4], uvs: vec![[0.0, 0.0]; 4],
normals: vec![[0.0, 0.0, 1.0]; 4],
tris: vec![[0, 1, 2], [0, 2, 3]], tris: vec![[0, 1, 2], [0, 2, 3]],
tri_mats: vec![0; 2], tri_mats: vec![0; 2],
visible: true, collider: false, props: Props::new(), visible: true, collider: false, props: Props::new(),
+114 -35
View File
@@ -17,7 +17,7 @@
use serde_json::Value; 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> { pub fn load(path: &str) -> Result<Model, String> {
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?; 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 { let mut o = Object {
name, props, name, props,
visible: true, collider: false, // bis apply_props entscheidet 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(), tris: Vec::new(), tri_mats: Vec::new(),
}; };
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?; 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) apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
if !o.tris.is_empty() { model.objects.push(o); } 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)?, Some(a) => read_floats::<2>(doc, bin, a as usize)?,
None => vec![[0.0, 0.0]; positions.len()], 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 "". // Material-Name → geteilte Namensliste; ohne Material "".
let mat_name = prim["material"].as_u64() let mat_name = prim["material"].as_u64()
@@ -128,9 +135,11 @@ fn append_mesh(
}); });
let base = o.verts.len(); 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.verts.push(transform(world, *p));
o.uvs.push(*uv); o.uvs.push(*uv);
o.normals.push(norm(transform3(nm, *n)));
} }
let idxs: Vec<usize> = match prim["indices"].as_u64() { let idxs: Vec<usize> = match prim["indices"].as_u64() {
@@ -287,6 +296,36 @@ fn mul(a: M4, b: M4) -> M4 {
out 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] { fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
let mut out = [0.0; 3]; let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() { for (r, o) in out.iter_mut().enumerate() {
@@ -326,42 +365,59 @@ mod tests {
out out
} }
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein /// Diagonale Normale — unter ungleichmäßiger Skalierung unterscheidet
/// Mesh-Node mit Translation und extras, ein Empty mit extras. /// sie die korrekte Kofaktor-Transform von der (falschen) Matrix.
fn sample_glb() -> Vec<u8> { 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(); 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, 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 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()); } for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
let json = r#"{ bin
"asset": {"version": "2.0"}, }
"scene": 0,
"scenes": [{"nodes": [0, 2]}], /// Mesh-Node mit Translation und extras, ein skaliertes Kind, ein Empty.
"nodes": [ const SAMPLE_JSON: &str = r#"{
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0], "asset": {"version": "2.0"},
"extras": {"signal": "tiffany", "hp": 3}, "scene": 0,
"children": [1]}, "scenes": [{"nodes": [0, 2]}],
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}}, "nodes": [
{"name": "spawn", "translation": [1, 2, 3]} {"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
], "extras": {"signal": "tiffany", "hp": 3},
"meshes": [{"primitives": [{ "children": [1]},
"attributes": {"POSITION": 0, "TEXCOORD_0": 1}, {"name": "Kind", "mesh": 0, "scale": [2, 1, 1],
"indices": 2, "material": 0 "extras": {"collide": "proxy"}},
}]}], {"name": "spawn", "translation": [1, 2, 3]}
"materials": [{"name": "carpet"}], ],
"accessors": [ "meshes": [{"primitives": [{
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, "attributes": {"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2},
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}, "indices": 3, "material": 0
{"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"} }]}],
], "materials": [{"name": "carpet"}],
"bufferViews": [ "accessors": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
{"buffer": 0, "byteOffset": 36, "byteLength": 24}, {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
{"buffer": 0, "byteOffset": 60, "byteLength": 6} {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"},
], {"bufferView": 3, "componentType": 5123, "count": 3, "type": "SCALAR"}
"buffers": [{"byteLength": 66}] ],
}"#; "bufferViews": [
glb(json, &bin) {"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] #[test]
@@ -398,6 +454,29 @@ mod tests {
assert_eq!(e.pos, [1.0, 2.0, 3.0]); 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] #[test]
fn uvs_pass_through_unchanged() { fn uvs_pass_through_unchanged() {
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip. // glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
+41
View File
@@ -42,6 +42,9 @@ pub struct Object {
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich /// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip. /// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
pub uvs: Vec<[f32; 2]>, 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]>, pub tris: Vec<[usize; 3]>,
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`). /// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
pub tri_mats: Vec<usize>, pub tri_mats: Vec<usize>,
@@ -77,6 +80,39 @@ pub const SIGNAL_PROP: &str = "signal";
pub const ROLE_PROP: &str = "role"; pub const ROLE_PROP: &str = "role";
pub const ROLE_SPAWN: &str = "spawn"; 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 /// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider, /// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert. /// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
@@ -119,6 +155,11 @@ pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal
let mut o = Object { let mut o = Object {
name: name.into(), name: name.into(),
uvs: vec![[0.0, 0.0]; verts.len()], 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, verts, tris,
tri_mats: vec![0; n], tri_mats: vec![0; n],
visible: true, collider: false, props, visible: true, collider: false, props,
+16 -12
View File
@@ -108,7 +108,7 @@ impl Gpu {
// Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in // Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
// render::run); hier wird nur hochgeladen. // render::run); hier wird nur hochgeladen.
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images); let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
let post = Post::new(&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H]); let post = Post::new(&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view);
// Overlay: eigener Pass hinter der Kette, ohne Tiefe. // Overlay: eigener Pass hinter der Kette, ohne Tiefe.
let sprite = SpritePass::new( let sprite = SpritePass::new(
@@ -218,19 +218,22 @@ impl Gpu {
let surface_view = frame.texture.create_view(&Default::default()); let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default()); let mut enc = self.device.create_command_encoder(&Default::default());
// Szene ins erste Post-Target. // Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
// zweite Attachment.
{ {
let [albedo, normal] = self.post.scene_targets();
let attach = |view| Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
});
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene"), label: Some("scene"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[attach(albedo), attach(normal)],
view: self.post.scene_target(),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view, view: &self.depth_view,
depth_ops: Some(wgpu::Operations { depth_ops: Some(wgpu::Operations {
@@ -311,7 +314,8 @@ fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT, format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, // TEXTURE_BINDING: die Post-Kette liest die Tiefe (Outline, Nebel).
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[], view_formats: &[],
}); });
depth.create_view(&Default::default()) depth.create_view(&Default::default())
+1 -1
View File
@@ -47,7 +47,7 @@ const EYE_MAX_LAG: f32 = 0.6;
pub fn run(mut session: Session) { pub fn run(mut session: Session) {
// Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird // Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird
// sie über denselben exec-Trichter wie alles andere (`post`). // sie über denselben exec-Trichter wie alles andere (`post`).
for name in post::names() { session.post.register(name, true); } for (name, on) in post::effects() { session.post.register(name, on); }
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen // Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert). // Dialog öffnen — dann startet die Welt eben pausiert).
+28
View File
@@ -0,0 +1,28 @@
// Post-Effekt „normals": zeigt den G-Buffer statt des Bildes — Normale als
// RGB, in der unteren Bildhälfte die Tiefe als Grauwert. Standardmäßig aus;
// `post normals on` schaltet ihn zu.
//
// Kein Debug-Beiwerk, sondern die Probe aufs Exempel: Cel-Ramp und Outline
// rechnen aus genau diesen Puffern. Sieht das Bild hier falsch aus, liegt der
// Fehler im Loader oder im Szenen-Pass, nicht im Effekt.
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@group(1) @binding(1) var gdepth: texture_depth_2d;
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2i(pos.xy);
let half = i32(textureDimensions(gnormal).y) / 2;
if px.y < half {
// Kodierung rückgängig: [0,1] → [-1,1] → wieder in [0,1] zum Ansehen.
let n = textureLoad(gnormal, px, 0).xyz * 2.0 - 1.0;
return vec4f(n * 0.5 + 0.5, 1.0);
}
// Tiefe ist perspektivisch verteilt — linearisieren, sonst ist ab zwei
// Metern alles weiß. Muss zu Mat4::perspective passen.
let z = textureLoad(gdepth, px, 0);
let near = 0.1;
let far = 100.0;
let lin = near * far / (far - z * (far - near));
return vec4f(vec3f(1.0 - lin / far), 1.0);
}
+77 -18
View File
@@ -2,8 +2,14 @@
//! //!
//! Jeder Effekt ist eine eigene `.wgsl` mit *nur* einem `fs_main`; die //! Jeder Effekt ist eine eigene `.wgsl` mit *nur* einem `fs_main`; die
//! Vertex-Stufe teilen sich alle über `fullscreen.wgsl`. Der Vertrag ist //! Vertex-Stufe teilen sich alle über `fullscreen.wgsl`. Der Vertrag ist
//! immer derselbe: eine Farbtextur rein (group 0, binding 0), eine Farbe //! immer derselbe:
//! raus — kein Effekt kennt einen anderen, keiner darf einen anderen //!
//! group 0, binding 0: Farbe des vorherigen Kettenglieds
//! group 1, binding 0: Normale (`n*0.5+0.5`, w = Objekt-ID, reserviert)
//! group 1, binding 1: Tiefe
//!
//! Group 1 ist der G-Buffer aus dem Szenen-Pass; ein Effekt darf ihn
//! ignorieren. Kein Effekt kennt einen anderen, keiner darf einen anderen
//! voraussetzen. Ein Effekt wird hinzugefügt, indem er in [`EFFECTS`] //! voraussetzen. Ein Effekt wird hinzugefügt, indem er in [`EFFECTS`]
//! auftaucht; geschaltet wird er über `engine::post` (Konsole: `post`). //! auftaucht; geschaltet wird er über `engine::post` (Konsole: `post`).
//! //!
@@ -14,24 +20,30 @@
use crate::engine::post::PostChain; use crate::engine::post::PostChain;
/// Die Kette in Ausführungsreihenfolge. `dither` quantisiert die Farbtiefe /// Die Kette in Ausführungsreihenfolge, mit ihrem Startzustand. `dither`
/// und muss deshalb zuletzt laufen. /// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
const EFFECTS: &[(&str, &str)] = &[ const EFFECTS: &[(&str, bool, &str)] = &[
("dither", include_str!("dither.wgsl")), ("normals", false, include_str!("normals.wgsl")),
("dither", true, include_str!("dither.wgsl")),
]; ];
/// Die Effektnamen — das Frontend meldet sie beim Start in der Session an. /// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der
pub fn names() -> impl Iterator<Item = &'static str> { /// Session an.
EFFECTS.iter().map(|(n, _)| *n) pub fn effects() -> impl Iterator<Item = (&'static str, bool)> {
EFFECTS.iter().map(|(n, on, _)| (*n, *on))
} }
pub struct Post { pub struct Post {
/// Ping-Pong-Targets. Die Szene zeichnet in `[0]`, jeder aktive Effekt /// Ping-Pong-Targets. Die Szene zeichnet in `[0]`, jeder aktive Effekt
/// liest das eine und schreibt ins andere. /// liest das eine und schreibt ins andere.
views: [wgpu::TextureView; 2], views: [wgpu::TextureView; 2],
/// Eingangs-Bind-Group je Target. /// Zweites Ziel des Szenen-Passes (Normale + ID).
in_binds: [wgpu::BindGroup; 2], normal_view: wgpu::TextureView,
pipelines: Vec<(&'static str, wgpu::RenderPipeline)>, /// Eingangs-Bind-Group je Target (group 0).
in_binds: [wgpu::BindGroup; 2],
/// Normale + Tiefe (group 1) — für alle Effekte dieselbe.
gbuf_bind: wgpu::BindGroup,
pipelines: Vec<(&'static str, wgpu::RenderPipeline)>,
} }
impl Post { impl Post {
@@ -39,9 +51,11 @@ impl Post {
device: &wgpu::Device, device: &wgpu::Device,
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
size: [u32; 2], size: [u32; 2],
depth_view: &wgpu::TextureView,
) -> Self { ) -> Self {
let views = [make_target(device, format, size, "post a"), let views = [make_target(device, format, size, "post a"),
make_target(device, format, size, "post b")]; make_target(device, format, size, "post b")];
let normal_view = make_target(device, format, size, "gbuffer normal");
// Expliziter Layout statt `layout: None`: *der* Vertrag der Kette — // Expliziter Layout statt `layout: None`: *der* Vertrag der Kette —
// alle Effekte teilen ihn, damit dieselben Bind-Groups für jede // alle Effekte teilen ihn, damit dieselben Bind-Groups für jede
@@ -62,9 +76,51 @@ impl Post {
let in_binds = [make_bind(device, &bind_layout, &views[0]), let in_binds = [make_bind(device, &bind_layout, &views[0]),
make_bind(device, &bind_layout, &views[1])]; make_bind(device, &bind_layout, &views[1])];
// Der G-Buffer. Beide Texturen werden per `textureLoad` gelesen
// (1:1, kein Sampler) — deshalb steht hier keiner.
let gbuf_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post gbuffer"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let gbuf_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post gbuffer"),
layout: &gbuf_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&normal_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("post"), label: Some("post"),
bind_group_layouts: &[Some(&bind_layout)], bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout)],
immediate_size: 0, immediate_size: 0,
}); });
// Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem // Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem
@@ -73,7 +129,7 @@ impl Post {
label: Some("fullscreen"), label: Some("fullscreen"),
source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen.wgsl").into()), source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen.wgsl").into()),
}); });
let pipelines = EFFECTS.iter().map(|(name, src)| { let pipelines = EFFECTS.iter().map(|(name, _, src)| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(name), label: Some(name),
source: wgpu::ShaderSource::Wgsl((*src).into()), source: wgpu::ShaderSource::Wgsl((*src).into()),
@@ -102,11 +158,13 @@ impl Post {
(*name, p) (*name, p)
}).collect(); }).collect();
Self { views, in_binds, pipelines } Self { views, normal_view, in_binds, gbuf_bind, pipelines }
} }
/// Target, in das die Szene zeichnet — der Anfang der Kette. /// Die Ziele des Szenen-Passes: Albedo (= Anfang der Kette) und Normale.
pub fn scene_target(&self) -> &wgpu::TextureView { &self.views[0] } pub fn scene_targets(&self) -> [&wgpu::TextureView; 2] {
[&self.views[0], &self.normal_view]
}
pub fn view(&self, i: usize) -> &wgpu::TextureView { &self.views[i] } pub fn view(&self, i: usize) -> &wgpu::TextureView { &self.views[i] }
@@ -137,6 +195,7 @@ impl Post {
}); });
pass.set_pipeline(pipeline); pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.in_binds[src], &[]); pass.set_bind_group(0, &self.in_binds[src], &[]);
pass.set_bind_group(1, &self.gbuf_bind, &[]);
pass.draw(0..3, 0..1); pass.draw(0..3, 0..1);
src = dst; src = dst;
} }
+3 -2
View File
@@ -44,8 +44,8 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
for o in &model.objects { for o in &model.objects {
if !o.visible { continue; } if !o.visible { continue; }
let base = verts.len() as u32; let base = verts.len() as u32;
for (p, uv) in o.verts.iter().zip(&o.uvs) { for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
verts.push(Vertex { pos: *p, uv: *uv }); verts.push(Vertex { pos: *p, uv: *uv, normal: *n });
} }
for (t, &m) in o.tris.iter().zip(&o.tri_mats) { for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]); let name = resolve(&model.materials[m]);
@@ -76,6 +76,7 @@ mod tests {
name: name.into(), name: name.into(),
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
normals: vec![[0.0, 0.0, 1.0]; 3],
tris: vec![[0, 1, 2]], tris: vec![[0, 1, 2]],
tri_mats: vec![0], tri_mats: vec![0],
visible, collider: !visible, visible, collider: !visible,
+9 -4
View File
@@ -13,8 +13,10 @@ use crate::render::math::Mat4;
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct Vertex { pub(crate) struct Vertex {
pub(crate) pos: [f32; 3], pub(crate) pos: [f32; 3],
pub(crate) uv: [f32; 2], pub(crate) uv: [f32; 2],
/// Welt-Normale, normiert — Eingang des G-Buffers (siehe render::post).
pub(crate) normal: [f32; 3],
} }
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer* /// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
@@ -67,7 +69,7 @@ struct Uniforms {
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64, array_stride: size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex, step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2], attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x3],
}; };
pub struct ScenePass { pub struct ScenePass {
@@ -106,7 +108,10 @@ impl ScenePass {
module: &shader, module: &shader,
entry_point: Some("fs_main"), entry_point: Some("fs_main"),
compilation_options: Default::default(), compilation_options: Default::default(),
targets: &[Some(color_format.into())], // Zwei Ziele (MRT): Albedo und der Normal-/ID-Buffer. Beide
// im internen Format; zusammen sind sie der G-Buffer, aus dem
// die Post-Kette Beleuchtung und Kanten rechnet.
targets: &[Some(color_format.into()), Some(color_format.into())],
}), }),
// Back-face Culling: glTF-Dreiecke sind CCW von außen // Back-face Culling: glTF-Dreiecke sind CCW von außen
// gewickelt — die Außenflächen sind front-facing, Rückseiten // gewickelt — die Außenflächen sind front-facing, Rückseiten
+27 -7
View File
@@ -1,7 +1,8 @@
// Szenen-Pipeline: Geometrie und Textur, sonst nichts. Der Pixel-Snap im // Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
// Vertex-Shader (PS1-Vertex-Jitter) gehört hierher, weil er Geometrie ist; // dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
// alles Bildweite — Farbtiefe, Dither, später Cel-Ramp und Outline — läuft // Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
// als eigenes Kettenglied in render::post. // render::post. Der Pixel-Snap im Vertex-Shader (PS1-Vertex-Jitter) gehört
// dagegen hierher, weil er Geometrie ist.
// //
// 1-Bit-Alpha-Test (Cutouts), kein Blending. // 1-Bit-Alpha-Test (Cutouts), kein Blending.
// //
@@ -24,10 +25,23 @@ struct VsOut {
@builtin(position) pos: vec4f, @builtin(position) pos: vec4f,
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln. // perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
@location(0) @interpolate(perspective) uv: vec2f, @location(0) @interpolate(perspective) uv: vec2f,
@location(1) normal: vec3f,
};
struct FsOut {
@location(0) albedo: vec4f,
// xyz = Normale, aus [-1,1] in [0,1] geschoben (das Ziel ist unsigned).
// w ist für eine Objekt-ID reserviert, damit der Outline-Pass später auch
// Kanten zwischen gleich ausgerichteten Nachbarn findet — noch ungefüllt.
@location(1) normal: vec4f,
}; };
@vertex @vertex
fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut { fn vs_main(
@location(0) pos: vec3f,
@location(1) uv: vec2f,
@location(2) normal: vec3f,
) -> VsOut {
var clip = u.mvp * vec4f(pos, 1.0); var clip = u.mvp * vec4f(pos, 1.0);
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und // Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde // zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
@@ -39,6 +53,7 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
var out: VsOut; var out: VsOut;
out.pos = clip; out.pos = clip;
out.uv = uv; out.uv = uv;
out.normal = normal;
return out; return out;
} }
@@ -49,8 +64,13 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
const MIP_BIAS: f32 = -1.0; const MIP_BIAS: f32 = -1.0;
@fragment @fragment
fn fs_main(in: VsOut) -> @location(0) vec4f { fn fs_main(in: VsOut) -> FsOut {
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS); let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
return vec4f(texel.rgb, 1.0);
var out: FsOut;
out.albedo = vec4f(texel.rgb, 1.0);
// Interpolieren verkürzt die Normale — vor dem Kodieren neu normieren.
out.normal = vec4f(normalize(in.normal) * 0.5 + 0.5, 0.0);
return out;
} }