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:
@@ -434,6 +434,7 @@ mod tests {
|
||||
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]],
|
||||
uvs: vec![[0.0, 0.0]; 4],
|
||||
normals: vec![[0.0, 0.0, 1.0]; 4],
|
||||
tris: vec![[0, 1, 2], [0, 2, 3]],
|
||||
tri_mats: vec![0; 2],
|
||||
visible: true, collider: false, props: Props::new(),
|
||||
|
||||
+93
-14
@@ -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,14 +365,22 @@ 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#"{
|
||||
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]}],
|
||||
@@ -341,27 +388,36 @@ mod tests {
|
||||
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
||||
"extras": {"signal": "tiffany", "hp": 3},
|
||||
"children": [1]},
|
||||
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}},
|
||||
{"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},
|
||||
"indices": 2, "material": 0
|
||||
"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": 5123, "count": 3, "type": "SCALAR"}
|
||||
{"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": 6}
|
||||
{"buffer": 0, "byteOffset": 60, "byteLength": 36},
|
||||
{"buffer": 0, "byteOffset": 96, "byteLength": 6}
|
||||
],
|
||||
"buffers": [{"byteLength": 66}]
|
||||
"buffers": [{"byteLength": 102}]
|
||||
}"#;
|
||||
glb(json, &bin)
|
||||
|
||||
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.
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct Object {
|
||||
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
|
||||
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
|
||||
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]>,
|
||||
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
|
||||
pub tri_mats: Vec<usize>,
|
||||
@@ -77,6 +80,39 @@ pub const SIGNAL_PROP: &str = "signal";
|
||||
pub const ROLE_PROP: &str = "role";
|
||||
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
|
||||
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
||||
/// `"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 {
|
||||
name: name.into(),
|
||||
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,
|
||||
tri_mats: vec![0; n],
|
||||
visible: true, collider: false, props,
|
||||
|
||||
+12
-8
@@ -108,7 +108,7 @@ impl Gpu {
|
||||
// Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
|
||||
// render::run); hier wird nur hochgeladen.
|
||||
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.
|
||||
let sprite = SpritePass::new(
|
||||
@@ -218,19 +218,22 @@ impl Gpu {
|
||||
let surface_view = frame.texture.create_view(&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 mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("scene"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: self.post.scene_target(),
|
||||
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 {
|
||||
label: Some("scene"),
|
||||
color_attachments: &[attach(albedo), attach(normal)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
@@ -311,7 +314,8 @@ fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
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: &[],
|
||||
});
|
||||
depth.create_view(&Default::default())
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ const EYE_MAX_LAG: f32 = 0.6;
|
||||
pub fn run(mut session: Session) {
|
||||
// Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird
|
||||
// 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
|
||||
// Dialog öffnen — dann startet die Welt eben pausiert).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+74
-15
@@ -2,8 +2,14 @@
|
||||
//!
|
||||
//! Jeder Effekt ist eine eigene `.wgsl` mit *nur* einem `fs_main`; die
|
||||
//! Vertex-Stufe teilen sich alle über `fullscreen.wgsl`. Der Vertrag ist
|
||||
//! immer derselbe: eine Farbtextur rein (group 0, binding 0), eine Farbe
|
||||
//! raus — kein Effekt kennt einen anderen, keiner darf einen anderen
|
||||
//! immer derselbe:
|
||||
//!
|
||||
//! 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`]
|
||||
//! auftaucht; geschaltet wird er über `engine::post` (Konsole: `post`).
|
||||
//!
|
||||
@@ -14,23 +20,29 @@
|
||||
|
||||
use crate::engine::post::PostChain;
|
||||
|
||||
/// Die Kette in Ausführungsreihenfolge. `dither` quantisiert die Farbtiefe
|
||||
/// und muss deshalb zuletzt laufen.
|
||||
const EFFECTS: &[(&str, &str)] = &[
|
||||
("dither", include_str!("dither.wgsl")),
|
||||
/// Die Kette in Ausführungsreihenfolge, mit ihrem Startzustand. `dither`
|
||||
/// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
|
||||
const EFFECTS: &[(&str, bool, &str)] = &[
|
||||
("normals", false, include_str!("normals.wgsl")),
|
||||
("dither", true, include_str!("dither.wgsl")),
|
||||
];
|
||||
|
||||
/// Die Effektnamen — das Frontend meldet sie beim Start in der Session an.
|
||||
pub fn names() -> impl Iterator<Item = &'static str> {
|
||||
EFFECTS.iter().map(|(n, _)| *n)
|
||||
/// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der
|
||||
/// Session an.
|
||||
pub fn effects() -> impl Iterator<Item = (&'static str, bool)> {
|
||||
EFFECTS.iter().map(|(n, on, _)| (*n, *on))
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
/// Ping-Pong-Targets. Die Szene zeichnet in `[0]`, jeder aktive Effekt
|
||||
/// liest das eine und schreibt ins andere.
|
||||
views: [wgpu::TextureView; 2],
|
||||
/// Eingangs-Bind-Group je Target.
|
||||
/// Zweites Ziel des Szenen-Passes (Normale + ID).
|
||||
normal_view: wgpu::TextureView,
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
@@ -39,9 +51,11 @@ impl Post {
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
size: [u32; 2],
|
||||
depth_view: &wgpu::TextureView,
|
||||
) -> Self {
|
||||
let views = [make_target(device, format, size, "post a"),
|
||||
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 —
|
||||
// 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]),
|
||||
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 {
|
||||
label: Some("post"),
|
||||
bind_group_layouts: &[Some(&bind_layout)],
|
||||
bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
// Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem
|
||||
@@ -73,7 +129,7 @@ impl Post {
|
||||
label: Some("fullscreen"),
|
||||
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 {
|
||||
label: Some(name),
|
||||
source: wgpu::ShaderSource::Wgsl((*src).into()),
|
||||
@@ -102,11 +158,13 @@ impl Post {
|
||||
(*name, p)
|
||||
}).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.
|
||||
pub fn scene_target(&self) -> &wgpu::TextureView { &self.views[0] }
|
||||
/// Die Ziele des Szenen-Passes: Albedo (= Anfang der Kette) und Normale.
|
||||
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] }
|
||||
|
||||
@@ -137,6 +195,7 @@ impl Post {
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &self.in_binds[src], &[]);
|
||||
pass.set_bind_group(1, &self.gbuf_bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
src = dst;
|
||||
}
|
||||
|
||||
+3
-2
@@ -44,8 +44,8 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
||||
for o in &model.objects {
|
||||
if !o.visible { continue; }
|
||||
let base = verts.len() as u32;
|
||||
for (p, uv) in o.verts.iter().zip(&o.uvs) {
|
||||
verts.push(Vertex { pos: *p, uv: *uv });
|
||||
for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
|
||||
verts.push(Vertex { pos: *p, uv: *uv, normal: *n });
|
||||
}
|
||||
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
|
||||
let name = resolve(&model.materials[m]);
|
||||
@@ -76,6 +76,7 @@ mod tests {
|
||||
name: name.into(),
|
||||
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]],
|
||||
normals: vec![[0.0, 0.0, 1.0]; 3],
|
||||
tris: vec![[0, 1, 2]],
|
||||
tri_mats: vec![0],
|
||||
visible, collider: !visible,
|
||||
|
||||
+7
-2
@@ -15,6 +15,8 @@ use crate::render::math::Mat4;
|
||||
pub(crate) struct Vertex {
|
||||
pub(crate) pos: [f32; 3],
|
||||
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*
|
||||
@@ -67,7 +69,7 @@ struct Uniforms {
|
||||
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||||
array_stride: size_of::<Vertex>() as u64,
|
||||
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 {
|
||||
@@ -106,7 +108,10 @@ impl ScenePass {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
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
|
||||
// gewickelt — die Außenflächen sind front-facing, Rückseiten
|
||||
|
||||
+27
-7
@@ -1,7 +1,8 @@
|
||||
// Szenen-Pipeline: Geometrie und Textur, sonst nichts. Der Pixel-Snap im
|
||||
// Vertex-Shader (PS1-Vertex-Jitter) gehört hierher, weil er Geometrie ist;
|
||||
// alles Bildweite — Farbtiefe, Dither, später Cel-Ramp und Outline — läuft
|
||||
// als eigenes Kettenglied in render::post.
|
||||
// Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
|
||||
// dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
|
||||
// Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
|
||||
// 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.
|
||||
//
|
||||
@@ -24,10 +25,23 @@ struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
|
||||
@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
|
||||
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);
|
||||
// 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
|
||||
@@ -39,6 +53,7 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
|
||||
var out: VsOut;
|
||||
out.pos = clip;
|
||||
out.uv = uv;
|
||||
out.normal = normal;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -49,8 +64,13 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
|
||||
const MIP_BIAS: f32 = -1.0;
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
||||
fn fs_main(in: VsOut) -> FsOut {
|
||||
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user