// 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); }