Files
wds/src/render/outline.wgsl
T
irrlichtandClaude Opus 5 8ddbd6f514 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>
2026-08-28 23:31:49 +02:00

146 lines
5.5 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}