Post-Effekt-Kette als Gerüst, Dither als erstes Glied

Bildweite Effekte gehören nicht in den Szenen-Shader. Cel-Ramp und
Sobel-Outline sollen dazukommen, ohne einander (oder scene.wgsl) zu
kennen — also eine Kette gleichrangiger Fullscreen-Pässe zwischen
Szene und Blit, Ping-Pong über zwei interne Targets.

Der Vertrag ist für alle gleich: Farbtextur rein (group 0/binding 0),
Farbe raus. Die Vertex-Stufe teilen sich alle über fullscreen.wgsl,
damit eine Effekt-Datei nur aus ihrem fs_main besteht. Ein neuer Effekt
ist eine .wgsl plus eine Zeile in EFFECTS. Bei 640×480 kostet ein
eigener Pass je Effekt praktisch nichts — deshalb kein Über-Shader.

Erster Bewohner ist die RGB555-Quantisierung samt Bayer-Dither, aus
scene.wgsl herausgelöst. Das UI zeichnet jetzt in einem eigenen Pass
hinter der Kette, sonst würde der Dither auch den Text quantisieren,
den er bisher nicht anfasst; damit braucht SpritePass kein
Tiefen-Attachment mehr.

Geschaltet wird über den exec-Trichter (`post`, `post <e> on|off`).
Die Namen kennt nur das Frontend: es meldet seine Kette beim Start in
engine::post an, wie render::audio seine Emitter aus engine::audio
zieht. Der Kern bekommt dadurch keine GPU zu sehen.

Das Bild soll unverändert bleiben — gleicher Bayer-Kernel, gleiche
Koordinaten, gleiche Reihenfolge relativ zum UI. Verifiziert sind
Tests, clippy und ein Start ohne Validation-Fehler; ein Pixelvergleich
steht aus.

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 8bee8322c3
commit 7f87dbca35
11 changed files with 461 additions and 95 deletions
+74 -53
View File
@@ -1,10 +1,12 @@
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
//! aus dem Renderer-Plan:
//! wgpu-Zustand: Surface, Device und der interne Render-Pfad:
//!
//! Pass 1 (intern): 640×480 RGBA8 + Depth — hier entsteht das Bild
//! ([`ScenePass`], PS1-Shader).
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
//! 4:3-Letterbox (via Viewport) auf die Surface.
//! Szene (intern): 640×480 RGBA8 + Depth — Geometrie und Textur
//! ([`ScenePass`]).
//! Post (intern): die Effekt-Kette ([`Post`]), Ping-Pong zwischen zwei
//! Targets; jeder Effekt einzeln schaltbar.
//! Overlay (intern): das UI ([`SpritePass`]) — *nach* der Kette, damit
//! Bild-Effekte nur die Welt treffen, nie den Text.
//! Blit (Fenster): Nearest-Upscale mit 4:3-Letterbox (via Viewport).
//!
//! 640×480 (VGA) statt 320×240: das Spiel ist text-getrieben — die höhere
//! Auflösung trägt den 80-Spalten-Textmodus-Look (EGA 8×14), der Lo-Fi-Rest
@@ -14,8 +16,10 @@ use std::sync::Arc;
use winit::window::Window;
use crate::engine::post::PostChain;
use crate::engine::tga::Image;
use crate::render::math::Mat4;
use crate::render::post::Post;
use crate::render::scene::{Mesh, ScenePass};
use crate::render::sprite::SpritePass;
use crate::render::ui::Ui;
@@ -34,13 +38,14 @@ pub struct Gpu {
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
internal_view: wgpu::TextureView,
depth_view: wgpu::TextureView,
scene: ScenePass,
post: Post,
sprite: SpritePass,
blit_pipeline: wgpu::RenderPipeline,
blit_bind: wgpu::BindGroup,
/// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`].
blit_binds: [wgpu::BindGroup; 2],
}
impl Gpu {
@@ -98,19 +103,20 @@ impl Gpu {
};
surface.configure(&device, &config);
let (internal_view, depth_view) = make_internal_targets(&device);
let depth_view = make_depth_target(&device);
// Pass 1: die Szene. Geometrie + Bilder kommen fertig vom Aufrufer
// (Decode in render::run); hier wird nur hochgeladen.
// 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]);
// Overlay-Pass: zeichnet nach der Szene ins selbe interne Target.
// Overlay: eigener Pass hinter der Kette, ohne Tiefe.
let sprite = SpritePass::new(
&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT,
&device, &queue, INTERNAL_FORMAT,
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
);
// Pass 2: internes Target nearest-gesampelt auf die Surface.
// Blit: das fertige interne Bild nearest-gesampelt auf die Surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
@@ -142,25 +148,29 @@ impl Gpu {
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit"),
layout: &blit_pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&internal_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let blit_layout = blit_pipeline.get_bind_group_layout(0);
let blit_bind = |view: &wgpu::TextureView| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit"),
layout: &blit_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
})
};
let blit_binds = [blit_bind(post.view(0)), blit_bind(post.view(1))];
Self {
surface, device, queue, config,
internal_view, depth_view,
scene, sprite, blit_pipeline, blit_bind,
depth_view,
scene, post, sprite, blit_pipeline, blit_binds,
}
}
@@ -182,7 +192,7 @@ impl Gpu {
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer;
/// die Projektion gehört hierher, weil sie an der internen Auflösung
/// hängt.
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
pub fn frame(&mut self, view: &Mat4, ui: &Ui, post: &PostChain) {
let proj = Mat4::perspective(
60f32.to_radians(),
INTERNAL_W as f32 / INTERNAL_H as f32,
@@ -208,12 +218,12 @@ impl Gpu {
let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default());
// Pass 1: intern.
// Szene ins erste Post-Target.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("internal"),
label: Some("scene"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.internal_view,
view: self.post.scene_target(),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
@@ -234,11 +244,33 @@ impl Gpu {
multiview_mask: None,
});
self.scene.draw(&mut pass);
// Overlay obenauf, im selben Pass (kein Tiefentest).
}
// Effekt-Kette; sie sagt, welches Target das Bild jetzt trägt.
let out = self.post.run(&mut enc, post);
// Overlay obendrauf — nach der Kette, also von keinem Effekt berührt.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("overlay"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: self.post.view(out),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.sprite.draw(&mut pass, &ui.batches);
}
// Pass 2: Letterbox-Blit aufs Fenster.
// Letterbox-Blit aufs Fenster.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit"),
@@ -260,7 +292,7 @@ impl Gpu {
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
pass.set_pipeline(&self.blit_pipeline);
pass.set_bind_group(0, &self.blit_bind, &[]);
pass.set_bind_group(0, &self.blit_binds[out], &[]);
pass.draw(0..3, 0..1);
}
@@ -269,23 +301,12 @@ impl Gpu {
}
}
fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) {
let size = wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
};
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal color"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERNAL_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal depth"),
size,
size: wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
@@ -293,7 +314,7 @@ fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::Tex
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
(color.create_view(&Default::default()), depth.create_view(&Default::default()))
depth.create_view(&Default::default())
}
/// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.