diff --git a/src/engine/collision.rs b/src/engine/collision.rs index 3b3c7f2..0d272eb 100644 --- a/src/engine/collision.rs +++ b/src/engine/collision.rs @@ -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(), diff --git a/src/engine/gltf.rs b/src/engine/gltf.rs index b16e941..5af7784 100644 --- a/src/engine/gltf.rs +++ b/src/engine/gltf.rs @@ -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 { 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 = 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 { + /// 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 { let mut bin: Vec = 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 { 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 { + 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. diff --git a/src/engine/model.rs b/src/engine/model.rs index 02c0b82..405d1ec 100644 --- a/src/engine/model.rs +++ b/src/engine/model.rs @@ -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, @@ -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, diff --git a/src/render/gpu.rs b/src/render/gpu.rs index a58f34b..ef45a52 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -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 [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: &[Some(wgpu::RenderPassColorAttachment { - 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, - }, - })], + 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()) diff --git a/src/render/mod.rs b/src/render/mod.rs index fc807ad..4247339 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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). diff --git a/src/render/normals.wgsl b/src/render/normals.wgsl new file mode 100644 index 0000000..175241f --- /dev/null +++ b/src/render/normals.wgsl @@ -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; +@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); +} diff --git a/src/render/post.rs b/src/render/post.rs index 40ee389..67de317 100644 --- a/src/render/post.rs +++ b/src/render/post.rs @@ -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,24 +20,30 @@ 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 { - EFFECTS.iter().map(|(n, _)| *n) +/// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der +/// Session an. +pub fn effects() -> impl Iterator { + 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. - in_binds: [wgpu::BindGroup; 2], - pipelines: Vec<(&'static str, wgpu::RenderPipeline)>, + views: [wgpu::TextureView; 2], + /// 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)>, } impl Post { @@ -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; } diff --git a/src/render/props.rs b/src/render/props.rs index fb04807..8b57b54 100644 --- a/src/render/props.rs +++ b/src/render/props.rs @@ -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, diff --git a/src/render/scene.rs b/src/render/scene.rs index 11a6918..1dc4828 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -13,8 +13,10 @@ use crate::render::math::Mat4; #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] pub(crate) struct Vertex { - pub(crate) pos: [f32; 3], - pub(crate) uv: [f32; 2], + 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::() 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 diff --git a/src/render/scene.wgsl b/src/render/scene.wgsl index 18d6089..5beb34f 100644 --- a/src/render/scene.wgsl +++ b/src/render/scene.wgsl @@ -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; }