Outline-Pass: Kanten aus dem G-Buffer

Erster Effekt, der das Bild verändert. Gesucht werden Kanten der
Geometrie, nicht des Bildes — auf dem fertigen Bild zu suchen wäre
falsch, weil der Dither Flächen in Muster zerlegt und jede Texturkante
als Kante zählte. Drei unabhängige Signale, genommen wird das stärkste:
Tiefe (Silhouetten) und Normale (Knicke) über einen 3x3-Sobel, die
Objekt-ID über einen Vergleich mit den direkten Nachbarn. Läuft vor
dither, damit die Linie mitquantisiert wird wie alles andere.

Zwei Vorarbeiten, die nur für diesen Pass existieren und deshalb hier
mit drinstehen:

Der Post-Vertrag bekommt group 2 mit Uniforms (inv_res, near, far,
time). Das räumt near/far aus normals.wgsl heraus, wo ich sie als
Literale abgeschrieben hatte — der Debug-View hätte stillschweigend
gelogen, sobald jemand die Projektion ändert, ausgerechnet das
Werkzeug, dem man beim Debuggen glauben muss. Sie kommen jetzt aus
NEAR_FAR in gpu.rs, derselben Konstante wie die Projektion. time ist
Vorrat für animierte Effekte; jetzt mitgenommen, weil eine spätere
Vertragsänderung alle Bind-Group-Layouts anfasst.

Die Objektnummer füllt das reservierte Alpha des Normal-Buffers. Sie
läuft fortlaufend statt als Namens-Hash: benachbarte Nummern
unterscheiden sich damit garantiert, ein Hash hätte mit ~1/256 still
kollidiert. Mesh::append verschiebt die Nummern des Anhängsels, sonst
trügen die ersten Objekte zweier .glb dieselbe und die Kante zwischen
ihnen fehlte. Ab 1, damit die 0 "kein Objekt" heißt — der Outline-Pass
zeichnet auf dem Hintergrund nichts, sonst läge jede Silhouette
doppelt.

Nicht getrennt committet, weil Uniform und ID für sich nichts leisten:
vor diesem Pass liest sie niemand außer dem Debug-View.

Strichstärke geht über den Abtastabstand des Kernels (WIDTH, aktuell 2)
statt über nachträgliches Aufdicken — das kostet keinen zusätzlichen
Texturzugriff, wo Dilatation das Neunfache gebraucht hätte. Die
Gradienten werden durch den Abstand geteilt, damit die Schwellwerte
beim Breitenwechsel ihre Bedeutung behalten.

DEPTH_T und NORMAL_T sind aus der Kernel-Normierung hergeleitet, nicht
an der Szene erprobt; Fehlerbilder und Richtung stehen als Kommentar
daneben. normals.wgsl zeigt jetzt drei Bänder (Normale, ID, Tiefe).

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 b9258a68fd
commit 8ddbd6f514
7 changed files with 313 additions and 26 deletions
+12 -2
View File
@@ -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;
+24 -10
View File
@@ -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<f32>;
@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<uniform> 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.
// 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 near = 0.1;
let far = 100.0;
let lin = near * far / (far - z * (far - near));
return vec4f(vec3f(1.0 - lin / far), 1.0);
let lin = u.near * u.far / (u.far - z * (u.far - u.near));
return vec4f(vec3f(1.0 - lin / u.far), 1.0);
}
+145
View File
@@ -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<f32>;
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@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<uniform> 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);
}
+74 -6
View File
@@ -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<Item = (&'static str, bool)> {
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::<Uniforms>() 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;
}
+27 -2
View File
@@ -41,12 +41,17 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
let mut verts = Vec::new();
let mut per_tex: Vec<Vec<u32>> = 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();
+19 -2
View File
@@ -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<Vertex>,
pub(crate) indices: Vec<u32>,
pub(crate) batches: Vec<Batch>,
/// 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::<Vertex>() 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 {
+11 -3
View File
@@ -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;
}