diff --git a/src/render/gpu.rs b/src/render/gpu.rs index ef45a52..9b1ed09 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -32,6 +32,11 @@ pub const INTERNAL_H: u32 = 480; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm; const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +/// Near-/Far-Ebene der Projektion. Auch die Post-Kette bekommt sie (als +/// Uniform), damit kein Effekt-Shader die Werte abschreiben muss, um die +/// Tiefe zu linearisieren — sonst lügen sie, sobald die Kamera sich ändert. +const NEAR_FAR: [f32; 2] = [0.1, 100.0]; + pub struct Gpu { surface: wgpu::Surface<'static>, device: wgpu::Device, @@ -46,6 +51,8 @@ pub struct Gpu { blit_pipeline: wgpu::RenderPipeline, /// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`]. blit_binds: [wgpu::BindGroup; 2], + /// Nullpunkt der Shader-Zeit (Post-Uniform). + start: std::time::Instant, } impl Gpu { @@ -108,7 +115,8 @@ 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], &depth_view); + let post = Post::new( + &device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view, NEAR_FAR); // Overlay: eigener Pass hinter der Kette, ohne Tiefe. let sprite = SpritePass::new( @@ -171,6 +179,7 @@ impl Gpu { surface, device, queue, config, depth_view, scene, post, sprite, blit_pipeline, blit_binds, + start: std::time::Instant::now(), } } @@ -196,10 +205,11 @@ impl Gpu { let proj = Mat4::perspective( 60f32.to_radians(), INTERNAL_W as f32 / INTERNAL_H as f32, - 0.1, 100.0, + NEAR_FAR[0], NEAR_FAR[1], ); let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5]; self.scene.prepare(&self.queue, &proj.mul(view), half_res); + self.post.prepare(&self.queue, self.start.elapsed().as_secs_f32()); self.sprite.prepare(&self.queue, &ui.verts); use wgpu::CurrentSurfaceTexture as Cst; diff --git a/src/render/normals.wgsl b/src/render/normals.wgsl index 175241f..3b030a2 100644 --- a/src/render/normals.wgsl +++ b/src/render/normals.wgsl @@ -1,5 +1,5 @@ -// 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-Effekt „normals": zeigt den G-Buffer statt des Bildes, in drei +// waagerechten Bändern — Normale, Objektnummer, Tiefe. Standardmäßig aus; // `post normals on` schaltet ihn zu. // // Kein Debug-Beiwerk, sondern die Probe aufs Exempel: Cel-Ramp und Outline @@ -9,20 +9,34 @@ @group(1) @binding(0) var gnormal: texture_2d; @group(1) @binding(1) var gdepth: texture_depth_2d; +struct Uniforms { + inv_res: vec2f, + near: f32, + far: f32, + time: f32, +}; +@group(2) @binding(0) var u: Uniforms; + @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 { + let band = i32(textureDimensions(gnormal).y) / 3; + let g = textureLoad(gnormal, px, 0); + if px.y < band { // 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; + let n = g.xyz * 2.0 - 1.0; return vec4f(n * 0.5 + 0.5, 1.0); } + if px.y < 2 * band { + // Objektnummer: benachbarte Nummern sollen *verschieden aussehen*, + // deshalb drei teilerfremde Faktoren statt eines Grauverlaufs. + let id = g.w * 255.0; + return vec4f(fract(id * vec3f(0.13, 0.37, 0.71)), 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); + // Metern alles weiß. Near/Far kommen aus dem Uniform, nicht aus dem + // Shader: sie gehören zur Projektion des Renderers. + let z = textureLoad(gdepth, px, 0); + let lin = u.near * u.far / (u.far - z * (u.far - u.near)); + return vec4f(vec3f(1.0 - lin / u.far), 1.0); } diff --git a/src/render/outline.wgsl b/src/render/outline.wgsl new file mode 100644 index 0000000..1fc3c47 --- /dev/null +++ b/src/render/outline.wgsl @@ -0,0 +1,145 @@ +// Post-Effekt „outline": Kantenlinien aus dem G-Buffer, nicht aus dem Bild. +// +// Auf dem fertigen Bild zu suchen wäre falsch — der Dither zerlegt Flächen in +// Muster, und jede Texturkante wäre eine Kante. Gesucht sind Kanten der +// *Geometrie*, also drei unabhängige Signale: +// +// Tiefe — Sprünge in der Entfernung (Silhouetten, Vorder-/Hintergrund) +// Normale — Knicke in der Fläche (Zimmerecken, Kanten eines Würfels) +// ID — Objektgrenzen, auch wenn Tiefe *und* Normale gleich sind +// (Bild an der Wand, Teppich auf dem Boden) +// +// Jedes für sich reicht; genommen wird das stärkste. Tiefe und Normale laufen +// über einen 3×3-Sobel, die ID über einen simplen Vergleich mit den vier +// direkten Nachbarn — auf Ganzzahlen ist ein Gradient sinnlos. + +@group(0) @binding(0) var src: texture_2d; + +@group(1) @binding(0) var gnormal: texture_2d; +@group(1) @binding(1) var gdepth: texture_depth_2d; + +struct Uniforms { + inv_res: vec2f, + near: f32, + far: f32, + time: f32, +}; +@group(2) @binding(0) var u: Uniforms; + +// --- Regler ------------------------------------------------------------ +// Beide Schwellen sind auf „Sobel geteilt durch 4" normiert, also grob: +// 1.0 = maximal möglicher Unterschied zwischen zwei Nachbarn. + +/// Tiefenstufe *relativ zur Entfernung* — relativ, damit eine Kante in 20 m +/// genauso zählt wie dieselbe Kante in 2 m. Das schwächste der drei Signale: +/// flach gesehene Böden ändern ihre Tiefe von Pixel zu Pixel stark, ohne dass +/// dort eine Kante wäre. Zu niedrig → der Boden bekommt Streifen. +const DEPTH_T: f32 = 0.10; + +/// Knick in der Normale. Ein rechter Winkel liegt bei ~1.4, eine sanfte +/// Wölbung weit darunter. Zu niedrig → Suzanne wird schraffiert. +const NORMAL_T: f32 = 0.40; + +/// Strichstärke in Pixeln: Abstand, in dem der Kernel abtastet. 1 = dünnste +/// Linie, 2–3 wird deutlich. Die Gradienten werden durch den Abstand geteilt, +/// damit die beiden Schwellen oben ihre Bedeutung behalten und man nicht bei +/// jeder Breitenänderung neu justieren muss. +/// +/// Verbreitert wird über den Abtastabstand, nicht durch Aufdicken der +/// gefundenen Linie: das kostet keinen einzigen zusätzlichen Texturzugriff. +/// Der Preis ist, dass Details, die schmaler als die Strichstärke sind, eine +/// doppelte Linie bekommen können — bei 1–3 Pixeln kein Thema. +const WIDTH: i32 = 2; + +/// Farbe der Linie und wie stark sie deckt (1.0 = voll). +const LINE: vec3f = vec3f(0.0); +const STRENGTH: f32 = 1.0; + +/// Objektgrenzen mitzeichnen. Aus wirkt es wie klassisches Cel-Shading +/// (nur Silhouetten und Knicke), an eher wie ein Comic-Panel, in dem jedes +/// Ding umrandet ist. +const USE_ID: bool = true; + +// --- G-Buffer-Zugriff -------------------------------------------------- + +/// Tiefe in Metern statt im perspektivisch verteilten [0,1] des Puffers. +fn linear_depth(px: vec2i) -> f32 { + let z = textureLoad(gdepth, px, 0); + return u.near * u.far / (u.far - z * (u.far - u.near)); +} + +fn normal_at(px: vec2i) -> vec3f { + return textureLoad(gnormal, px, 0).xyz * 2.0 - 1.0; +} + +fn id_at(px: vec2i) -> f32 { + return textureLoad(gnormal, px, 0).w; +} + +// --- Kantensuche ------------------------------------------------------- + +/// Sobel-Betrag über die 3×3-Nachbarschaft, auf ~0..1 normiert (geteilt +/// durch 4, die Gewichtssumme einer Kernelhälfte). +fn sobel_depth(px: vec2i) -> f32 { + var gx = 0.0; + var gy = 0.0; + // Sobel-Gewichte, Zeile für Zeile: [-1 0 1] × [1 2 1]. + for (var dy = -1; dy <= 1; dy++) { + for (var dx = -1; dx <= 1; dx++) { + let v = linear_depth(px + vec2i(dx, dy) * WIDTH); + let wx = f32(dx) * select(1.0, 2.0, dy == 0); + let wy = f32(dy) * select(1.0, 2.0, dx == 0); + gx += v * wx; + gy += v * wy; + } + } + return sqrt(gx * gx + gy * gy) / (4.0 * f32(WIDTH)); +} + +/// Dasselbe für die Normale: jede Komponente einzeln gefiltert, am Ende die +/// Länge über beide Richtungen. +fn sobel_normal(px: vec2i) -> f32 { + var gx = vec3f(0.0); + var gy = vec3f(0.0); + for (var dy = -1; dy <= 1; dy++) { + for (var dx = -1; dx <= 1; dx++) { + let v = normal_at(px + vec2i(dx, dy) * WIDTH); + let wx = f32(dx) * select(1.0, 2.0, dy == 0); + let wy = f32(dy) * select(1.0, 2.0, dx == 0); + gx += v * wx; + gy += v * wy; + } + } + return sqrt(dot(gx, gx) + dot(gy, gy)) / (4.0 * f32(WIDTH)); +} + +/// 1.0, sobald einer der vier direkten Nachbarn zu einem anderen Objekt +/// gehört. Nur ganze Pixel, keine Gradienten. +fn id_edge(px: vec2i) -> f32 { + let c = id_at(px); + let d = abs(id_at(px + vec2i( WIDTH, 0)) - c) + + abs(id_at(px + vec2i(-WIDTH, 0)) - c) + + abs(id_at(px + vec2i( 0, WIDTH)) - c) + + abs(id_at(px + vec2i( 0,-WIDTH)) - c); + // Eine Nummer Unterschied sind 1/255 — alles darüber ist eine Grenze. + return select(0.0, 1.0, d > 0.5 / 255.0); +} + +@fragment +fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let px = vec2i(pos.xy); + let color = textureLoad(src, px, 0); + + // Auf dem Hintergrund (nichts gezeichnet) keine Linie: sonst läge die + // Silhouette doppelt, je einmal innen und außen. + if id_at(px) == 0.0 { return color; } + + let depth = sobel_depth(px) / linear_depth(px); + var edge = max( + step(DEPTH_T, depth), + step(NORMAL_T, sobel_normal(px)), + ); + if USE_ID { edge = max(edge, id_edge(px)); } + + return vec4f(mix(color.rgb, LINE, edge * STRENGTH), color.a); +} diff --git a/src/render/post.rs b/src/render/post.rs index 67de317..0331d82 100644 --- a/src/render/post.rs +++ b/src/render/post.rs @@ -7,11 +7,13 @@ //! 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 2, binding 0: [`Uniforms`] — Auflösung, Near/Far, Zeit //! -//! 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`). +//! Group 1 ist der G-Buffer aus dem Szenen-Pass, group 2 das, was jeder +//! Effekt über den Frame wissen kann; beide darf ein Effekt 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`). //! //! Gerechnet wird auf zwei Ping-Pong-Targets in interner Auflösung. Bei //! 640×480 kostet ein zusätzlicher Fullscreen-Pass praktisch nichts — @@ -24,6 +26,7 @@ use crate::engine::post::PostChain; /// quantisiert die Farbtiefe und muss deshalb zuletzt laufen. const EFFECTS: &[(&str, bool, &str)] = &[ ("normals", false, include_str!("normals.wgsl")), + ("outline", true, include_str!("outline.wgsl")), ("dither", true, include_str!("dither.wgsl")), ]; @@ -33,6 +36,21 @@ pub fn effects() -> impl Iterator { EFFECTS.iter().map(|(n, on, _)| (*n, *on)) } +/// Spiegelt `Uniforms` in den Effekt-Shadern. `near`/`far` müssen zur +/// Projektion des Aufrufers passen — sie kommen von dort, damit kein Shader +/// sie abschreibt und beim nächsten Ändern der Kamera stillschweigend lügt. +/// `_pad` rundet auf 32 Byte (16er-Vielfaches, WGSL-Uniform-Regel). +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Uniforms { + inv_res: [f32; 2], + near: f32, + far: f32, + /// Sekunden seit Start — für animierte Effekte (Flackern, Wasser). + time: f32, + _pad: [f32; 3], +} + pub struct Post { /// Ping-Pong-Targets. Die Szene zeichnet in `[0]`, jeder aktive Effekt /// liest das eine und schreibt ins andere. @@ -43,6 +61,11 @@ pub struct Post { in_binds: [wgpu::BindGroup; 2], /// Normale + Tiefe (group 1) — für alle Effekte dieselbe. gbuf_bind: wgpu::BindGroup, + /// Frame-Uniforms (group 2), jeden Frame per [`Post::prepare`] gefüllt. + ubuf: wgpu::Buffer, + uniform_bind: wgpu::BindGroup, + inv_res: [f32; 2], + near_far: [f32; 2], pipelines: Vec<(&'static str, wgpu::RenderPipeline)>, } @@ -52,6 +75,7 @@ impl Post { format: wgpu::TextureFormat, size: [u32; 2], depth_view: &wgpu::TextureView, + near_far: [f32; 2], ) -> Self { let views = [make_target(device, format, size, "post a"), make_target(device, format, size, "post b")]; @@ -118,9 +142,34 @@ impl Post { ], }); + let ubuf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("post uniforms"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("post uniforms"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("post uniforms"), + layout: &uniform_layout, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: ubuf.as_entire_binding() }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("post"), - bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout)], + bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout), Some(&uniform_layout)], immediate_size: 0, }); // Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem @@ -158,7 +207,25 @@ impl Post { (*name, p) }).collect(); - Self { views, normal_view, in_binds, gbuf_bind, pipelines } + Self { + views, normal_view, in_binds, gbuf_bind, + ubuf, uniform_bind, + inv_res: [1.0 / size[0] as f32, 1.0 / size[1] as f32], + near_far, + pipelines, + } + } + + /// Frame-Uniforms hochladen — vor [`Post::run`] rufen. + pub fn prepare(&self, queue: &wgpu::Queue, time: f32) { + let u = Uniforms { + inv_res: self.inv_res, + near: self.near_far[0], + far: self.near_far[1], + time, + _pad: [0.0; 3], + }; + queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(&u)); } /// Die Ziele des Szenen-Passes: Albedo (= Anfang der Kette) und Normale. @@ -196,6 +263,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.set_bind_group(2, &self.uniform_bind, &[]); pass.draw(0..3, 0..1); src = dst; } diff --git a/src/render/props.rs b/src/render/props.rs index 8b57b54..7c89c5f 100644 --- a/src/render/props.rs +++ b/src/render/props.rs @@ -41,12 +41,17 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh { let mut verts = Vec::new(); let mut per_tex: Vec> = vec![Vec::new(); tex_names.len()]; + // Laufende Objektnummer für den G-Buffer; nur sichtbare Objekte zählen, + // unsichtbare Collider tauchen im Bild ja nie auf. Ab 1, damit die 0 im + // Buffer „kein Objekt" heißt (der Hintergrund wird auf 0 geleert). + let mut id = 1; for o in &model.objects { if !o.visible { continue; } let base = verts.len() as u32; for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) { - verts.push(Vertex { pos: *p, uv: *uv, normal: *n }); + verts.push(Vertex { pos: *p, uv: *uv, normal: *n, id }); } + id += 1; for (t, &m) in o.tris.iter().zip(&o.tri_mats) { let name = resolve(&model.materials[m]); let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; }; @@ -62,7 +67,7 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh { batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 }); indices.extend_from_slice(&list); } - Mesh { verts, indices, batches } + Mesh { verts, indices, batches, objects: id - 1 } } #[cfg(test)] @@ -107,6 +112,26 @@ mod tests { assert_eq!(mesh.verts[2].uv, [1.0, 1.0]); } + #[test] + fn visible_objects_get_distinct_ids_across_models() { + let names = texture_names(&sample()); + // Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell. + let mut m = sample(); + m.objects.insert(1, tri("Zweites", true)); + + let mut mesh = build(&m, &names); + assert_eq!(mesh.objects, 2, "nur sichtbare zählen"); + assert_eq!(mesh.verts[0].id, 1, "0 bleibt dem Hintergrund vorbehalten"); + assert_eq!(mesh.verts[3].id, 2, "zweites Objekt, nächste Nummer"); + + // Zweites Modell anhängen: seine Nummern liegen über den bisherigen, + // sonst hielte der Outline-Pass Objekte aus zwei .glb für dasselbe. + mesh.append(build(&m, &names)); + assert_eq!(mesh.objects, 4); + assert_eq!(mesh.verts[6].id, 3); + assert_eq!(mesh.verts[9].id, 4); + } + #[test] fn missing_material_maps_to_fallback() { let mut m = sample(); diff --git a/src/render/scene.rs b/src/render/scene.rs index 1dc4828..ede1953 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -17,6 +17,12 @@ pub(crate) struct Vertex { pub(crate) uv: [f32; 2], /// Welt-Normale, normiert — Eingang des G-Buffers (siehe render::post). pub(crate) normal: [f32; 3], + /// Laufende Objektnummer. Der Szenen-Pass legt sie (mod 256) ins Alpha + /// des Normal-Buffers, damit der Outline-Pass Kanten zwischen zwei + /// Objekten findet, die sich berühren und gleich ausgerichtet sind — + /// Stuhl auf Boden, Bild an Wand. Benachbarte Nummern unterscheiden sich + /// immer; erst 256 Objekte auseinander fallen zwei zusammen. + pub(crate) id: u32, } /// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer* @@ -35,6 +41,10 @@ pub(crate) struct Mesh { pub(crate) verts: Vec, pub(crate) indices: Vec, pub(crate) batches: Vec, + /// Vergebene Objektnummern (siehe [`Vertex::id`]) — [`Mesh::append`] + /// verschiebt die des Anhängsels darüber, damit Objekte aus verschiedenen + /// Modellen nicht dieselbe Nummer tragen. + pub(crate) objects: u32, } impl Mesh { @@ -45,7 +55,12 @@ impl Mesh { pub(crate) fn append(&mut self, other: Mesh) { let vbase = self.verts.len() as u32; let ibase = self.indices.len() as u32; - self.verts.extend(other.verts); + let idbase = self.objects; + self.objects += other.objects; + self.verts.extend(other.verts.into_iter().map(|v| Vertex { + id: v.id + idbase, + ..v + })); self.indices.extend(other.indices.iter().map(|i| i + vbase)); self.batches.extend(other.batches.iter().map(|b| Batch { texture: b.texture, @@ -69,7 +84,9 @@ 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, 2 => Float32x3], + attributes: &wgpu::vertex_attr_array![ + 0 => Float32x3, 1 => Float32x2, 2 => Float32x3, 3 => Uint32, + ], }; pub struct ScenePass { diff --git a/src/render/scene.wgsl b/src/render/scene.wgsl index 5beb34f..e8c1f43 100644 --- a/src/render/scene.wgsl +++ b/src/render/scene.wgsl @@ -26,13 +26,16 @@ struct VsOut { // perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln. @location(0) @interpolate(perspective) uv: vec2f, @location(1) normal: vec3f, + // Ganzzahl → flat; interpolieren würde zwischen zwei Objektnummern + // Zwischenwerte erfinden. + @location(2) @interpolate(flat) id: u32, }; 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. + // w = Objektnummer mod 256, damit der Outline-Pass auch Kanten zwischen + // gleich ausgerichteten Nachbarn findet. @location(1) normal: vec4f, }; @@ -41,6 +44,7 @@ fn vs_main( @location(0) pos: vec3f, @location(1) uv: vec2f, @location(2) normal: vec3f, + @location(3) id: u32, ) -> VsOut { var clip = u.mvp * vec4f(pos, 1.0); // Pixel-Snap: xy nach der Projektion aufs interne Raster runden und @@ -54,6 +58,7 @@ fn vs_main( out.pos = clip; out.uv = uv; out.normal = normal; + out.id = id; return out; } @@ -71,6 +76,9 @@ fn fs_main(in: VsOut) -> FsOut { 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); + // Das Ziel ist 8 Bit pro Kanal: k/255 kommt exakt wieder heraus, aber + // zwei Objekte 256 Nummern auseinander teilen sich einen Wert. Bei + // gerade ~175 Objekten in der Szene ist das Kopfrechnen, kein Problem. + out.normal = vec4f(normalize(in.normal) * 0.5 + 0.5, f32(in.id % 256u) / 255.0); return out; }