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>
146 lines
5.5 KiB
WebGPU Shading Language
146 lines
5.5 KiB
WebGPU Shading Language
// 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);
|
||
}
|