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:
+16
-12
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user