G-Buffer: Normalen aus glTF bis in den Post-Vertrag

Cel-Ramp und ein Sobel, der auch Innenkanten findet, brauchen beide
Normalen — die gab es in der Pipeline bisher nirgends. Der Strang geht
quer durch alle Schichten, deshalb als eigener Schritt, bevor ein
Effekt darauf zugreift.

Loader: NORMAL wird gelesen und mit der Kofaktormatrix des 3x3-Teils
transformiert, nicht mit der Welt-Matrix. Das ist die inverse
Transponierte bis auf den Faktor det, den das Normieren frisst — nur so
bleibt eine Normale bei ungleichmäßiger Skalierung senkrecht auf ihrer
Fläche, und bei Spiegelungen dreht sie korrekt mit. glTF erlaubt Meshes
ohne NORMAL; die rechnet fill_missing_normals aus den angrenzenden
Flächen (Nullvektor als Sentinel). Beide Szenen-Modelle bringen eigene
Normalen mit, der Fallback ist reine Absicherung.

Szenen-Pass schreibt jetzt zwei Ziele: Albedo und n*0.5+0.5. Das Alpha
des Normal-Buffers ist für eine Objekt-ID reserviert und noch leer. Die
Depth-Textur bekommt TEXTURE_BINDING, und der Post-Vertrag wächst um
group 1 (Normale + Tiefe), die jeder Effekt ignorieren darf — dither
tut das.

Statt eines cel.wgsl-Durchreichers gibt es normals.wgsl, per Default
aus: `post normals on` zeigt oben die Normalen, unten die linearisierte
Tiefe. Ein Effekt, der nichts tut, wäre von einem kaputten nicht zu
unterscheiden; dieser hier trennt später "Loader kaputt" von "Effekt
kaputt".

Das Standard-Bild bleibt unverändert — die Normalen werden geschrieben,
aber von keinem aktiven Effekt gelesen.

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 7f87dbca35
commit b9258a68fd
10 changed files with 317 additions and 79 deletions
+16 -12
View File
@@ -108,7 +108,7 @@ 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]);
let post = Post::new(&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view);
// Overlay: eigener Pass hinter der Kette, ohne Tiefe.
let sprite = SpritePass::new(
@@ -218,19 +218,22 @@ impl Gpu {
let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default());
// Szene ins erste Post-Target.
// Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
// zweite Attachment.
{
let [albedo, normal] = self.post.scene_targets();
let attach = |view| Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
});
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: self.post.scene_target(),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
color_attachments: &[attach(albedo), attach(normal)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
@@ -311,7 +314,8 @@ fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
// TEXTURE_BINDING: die Post-Kette liest die Tiefe (Outline, Nebel).
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
depth.create_view(&Default::default())
+1 -1
View File
@@ -47,7 +47,7 @@ const EYE_MAX_LAG: f32 = 0.6;
pub fn run(mut session: Session) {
// Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird
// sie über denselben exec-Trichter wie alles andere (`post`).
for name in post::names() { session.post.register(name, true); }
for (name, on) in post::effects() { session.post.register(name, on); }
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert).
+28
View File
@@ -0,0 +1,28 @@
// 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 normals on` schaltet ihn zu.
//
// Kein Debug-Beiwerk, sondern die Probe aufs Exempel: Cel-Ramp und Outline
// rechnen aus genau diesen Puffern. Sieht das Bild hier falsch aus, liegt der
// Fehler im Loader oder im Szenen-Pass, nicht im Effekt.
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@group(1) @binding(1) var gdepth: texture_depth_2d;
@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 {
// 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;
return vec4f(n * 0.5 + 0.5, 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);
}
+77 -18
View File
@@ -2,8 +2,14 @@
//!
//! Jeder Effekt ist eine eigene `.wgsl` mit *nur* einem `fs_main`; die
//! Vertex-Stufe teilen sich alle über `fullscreen.wgsl`. Der Vertrag ist
//! immer derselbe: eine Farbtextur rein (group 0, binding 0), eine Farbe
//! raus — kein Effekt kennt einen anderen, keiner darf einen anderen
//! immer derselbe:
//!
//! 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 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`).
//!
@@ -14,24 +20,30 @@
use crate::engine::post::PostChain;
/// Die Kette in Ausführungsreihenfolge. `dither` quantisiert die Farbtiefe
/// und muss deshalb zuletzt laufen.
const EFFECTS: &[(&str, &str)] = &[
("dither", include_str!("dither.wgsl")),
/// Die Kette in Ausführungsreihenfolge, mit ihrem Startzustand. `dither`
/// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
const EFFECTS: &[(&str, bool, &str)] = &[
("normals", false, include_str!("normals.wgsl")),
("dither", true, include_str!("dither.wgsl")),
];
/// Die Effektnamen — das Frontend meldet sie beim Start in der Session an.
pub fn names() -> impl Iterator<Item = &'static str> {
EFFECTS.iter().map(|(n, _)| *n)
/// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der
/// Session an.
pub fn effects() -> impl Iterator<Item = (&'static str, bool)> {
EFFECTS.iter().map(|(n, on, _)| (*n, *on))
}
pub struct Post {
/// Ping-Pong-Targets. Die Szene zeichnet in `[0]`, jeder aktive Effekt
/// liest das eine und schreibt ins andere.
views: [wgpu::TextureView; 2],
/// Eingangs-Bind-Group je Target.
in_binds: [wgpu::BindGroup; 2],
pipelines: Vec<(&'static str, wgpu::RenderPipeline)>,
views: [wgpu::TextureView; 2],
/// Zweites Ziel des Szenen-Passes (Normale + ID).
normal_view: wgpu::TextureView,
/// Eingangs-Bind-Group je Target (group 0).
in_binds: [wgpu::BindGroup; 2],
/// Normale + Tiefe (group 1) — für alle Effekte dieselbe.
gbuf_bind: wgpu::BindGroup,
pipelines: Vec<(&'static str, wgpu::RenderPipeline)>,
}
impl Post {
@@ -39,9 +51,11 @@ impl Post {
device: &wgpu::Device,
format: wgpu::TextureFormat,
size: [u32; 2],
depth_view: &wgpu::TextureView,
) -> Self {
let views = [make_target(device, format, size, "post a"),
make_target(device, format, size, "post b")];
let normal_view = make_target(device, format, size, "gbuffer normal");
// Expliziter Layout statt `layout: None`: *der* Vertrag der Kette —
// alle Effekte teilen ihn, damit dieselben Bind-Groups für jede
@@ -62,9 +76,51 @@ impl Post {
let in_binds = [make_bind(device, &bind_layout, &views[0]),
make_bind(device, &bind_layout, &views[1])];
// Der G-Buffer. Beide Texturen werden per `textureLoad` gelesen
// (1:1, kein Sampler) — deshalb steht hier keiner.
let gbuf_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post gbuffer"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let gbuf_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post gbuffer"),
layout: &gbuf_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&normal_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("post"),
bind_group_layouts: &[Some(&bind_layout)],
bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout)],
immediate_size: 0,
});
// Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem
@@ -73,7 +129,7 @@ impl Post {
label: Some("fullscreen"),
source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen.wgsl").into()),
});
let pipelines = EFFECTS.iter().map(|(name, src)| {
let pipelines = EFFECTS.iter().map(|(name, _, src)| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(name),
source: wgpu::ShaderSource::Wgsl((*src).into()),
@@ -102,11 +158,13 @@ impl Post {
(*name, p)
}).collect();
Self { views, in_binds, pipelines }
Self { views, normal_view, in_binds, gbuf_bind, pipelines }
}
/// Target, in das die Szene zeichnet — der Anfang der Kette.
pub fn scene_target(&self) -> &wgpu::TextureView { &self.views[0] }
/// Die Ziele des Szenen-Passes: Albedo (= Anfang der Kette) und Normale.
pub fn scene_targets(&self) -> [&wgpu::TextureView; 2] {
[&self.views[0], &self.normal_view]
}
pub fn view(&self, i: usize) -> &wgpu::TextureView { &self.views[i] }
@@ -137,6 +195,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.draw(0..3, 0..1);
src = dst;
}
+3 -2
View File
@@ -44,8 +44,8 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
for o in &model.objects {
if !o.visible { continue; }
let base = verts.len() as u32;
for (p, uv) in o.verts.iter().zip(&o.uvs) {
verts.push(Vertex { pos: *p, uv: *uv });
for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
verts.push(Vertex { pos: *p, uv: *uv, normal: *n });
}
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]);
@@ -76,6 +76,7 @@ mod tests {
name: name.into(),
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
normals: vec![[0.0, 0.0, 1.0]; 3],
tris: vec![[0, 1, 2]],
tri_mats: vec![0],
visible, collider: !visible,
+9 -4
View File
@@ -13,8 +13,10 @@ use crate::render::math::Mat4;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct Vertex {
pub(crate) pos: [f32; 3],
pub(crate) uv: [f32; 2],
pub(crate) pos: [f32; 3],
pub(crate) uv: [f32; 2],
/// Welt-Normale, normiert — Eingang des G-Buffers (siehe render::post).
pub(crate) normal: [f32; 3],
}
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
@@ -67,7 +69,7 @@ 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],
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x3],
};
pub struct ScenePass {
@@ -106,7 +108,10 @@ impl ScenePass {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(color_format.into())],
// Zwei Ziele (MRT): Albedo und der Normal-/ID-Buffer. Beide
// im internen Format; zusammen sind sie der G-Buffer, aus dem
// die Post-Kette Beleuchtung und Kanten rechnet.
targets: &[Some(color_format.into()), Some(color_format.into())],
}),
// Back-face Culling: glTF-Dreiecke sind CCW von außen
// gewickelt — die Außenflächen sind front-facing, Rückseiten
+27 -7
View File
@@ -1,7 +1,8 @@
// Szenen-Pipeline: Geometrie und Textur, sonst nichts. Der Pixel-Snap im
// Vertex-Shader (PS1-Vertex-Jitter) gehört hierher, weil er Geometrie ist;
// alles Bildweite — Farbtiefe, Dither, später Cel-Ramp und Outline — läuft
// als eigenes Kettenglied in render::post.
// Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
// dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
// Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
// render::post. Der Pixel-Snap im Vertex-Shader (PS1-Vertex-Jitter) gehört
// dagegen hierher, weil er Geometrie ist.
//
// 1-Bit-Alpha-Test (Cutouts), kein Blending.
//
@@ -24,10 +25,23 @@ struct VsOut {
@builtin(position) pos: vec4f,
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
@location(0) @interpolate(perspective) uv: vec2f,
@location(1) normal: vec3f,
};
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.
@location(1) normal: vec4f,
};
@vertex
fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
fn vs_main(
@location(0) pos: vec3f,
@location(1) uv: vec2f,
@location(2) normal: vec3f,
) -> VsOut {
var clip = u.mvp * vec4f(pos, 1.0);
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
@@ -39,6 +53,7 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
var out: VsOut;
out.pos = clip;
out.uv = uv;
out.normal = normal;
return out;
}
@@ -49,8 +64,13 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
const MIP_BIAS: f32 = -1.0;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4f {
fn fs_main(in: VsOut) -> FsOut {
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
return vec4f(texel.rgb, 1.0);
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);
return out;
}