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
+4 -2
View File
@@ -16,8 +16,9 @@
//!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
//! die Emitter-Logik zum Frontend-Treiber render::audio, `player` die
//! First-Person-Physik auf der Welt aus `collision`.
//! die Emitter-Logik zum Frontend-Treiber render::audio, `post` analog die
//! Effekt-Schalter zu render::post, `player` die First-Person-Physik auf der
//! Welt aus `collision`.
pub mod assets;
pub mod audio;
@@ -28,6 +29,7 @@ pub mod ink;
pub mod kv;
pub mod model;
pub mod player;
pub mod post;
pub mod signals;
pub mod story_ctrl;
pub mod tga;
+74
View File
@@ -0,0 +1,74 @@
//! Post-Effekt-Schalter: welches Kettenglied des Renderers ist an?
//!
//! Headless-Gegenstück zu `render::post`, so wie `audio` zum Audio-Treiber.
//! Hier liegen nur die *Namen* und ihr An/Aus-Zustand — damit die Konsole sie
//! durch denselben `exec`-Trichter schalten kann wie alles andere, ohne dass
//! der Kern eine GPU kennt. Was ein Effekt tut und in welcher Reihenfolge er
//! läuft, weiß allein das Render-Frontend; es meldet seine Effekte beim Start
//! per [`PostChain::register`] an. Ohne Fenster-Frontend bleibt die Liste leer.
#[derive(Default)]
pub struct PostChain {
/// In Ketten-Reihenfolge, wie vom Frontend angemeldet.
effects: Vec<(String, bool)>,
}
impl PostChain {
pub fn new() -> Self { Self::default() }
/// Einen Effekt anmelden (Frontend beim Start, in Ketten-Reihenfolge).
pub fn register(&mut self, name: &str, on: bool) {
self.effects.push((name.to_string(), on));
}
pub fn enabled(&self, name: &str) -> bool {
self.effects.iter().any(|(n, on)| n == name && *on)
}
/// Schalten; `on = None` togglet. Gibt den neuen Zustand zurück, `None`
/// bei unbekanntem Namen.
pub fn set(&mut self, name: &str, on: Option<bool>) -> Option<bool> {
let e = self.effects.iter_mut().find(|(n, _)| n == name)?;
e.1 = on.unwrap_or(!e.1);
Some(e.1)
}
/// Alle Effekte in Ketten-Reihenfolge.
pub fn list(&self) -> impl Iterator<Item = (&str, bool)> {
self.effects.iter().map(|(n, on)| (n.as_str(), *on))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> PostChain {
let mut c = PostChain::new();
c.register("cel", false);
c.register("dither", true);
c
}
#[test]
fn keeps_registration_order() {
let c = chain();
let names: Vec<&str> = c.list().map(|(n, _)| n).collect();
assert_eq!(names, vec!["cel", "dither"]);
}
#[test]
fn set_toggles_and_reports_unknown() {
let mut c = chain();
assert_eq!(c.set("cel", Some(true)), Some(true));
assert!(c.enabled("cel"));
assert_eq!(c.set("cel", None), Some(false), "ohne Argument togglen");
assert!(!c.enabled("cel"));
assert_eq!(c.set("bloom", Some(true)), None, "unbekannt");
}
#[test]
fn unregistered_effect_is_off() {
assert!(!PostChain::new().enabled("dither"));
}
}
+30
View File
@@ -0,0 +1,30 @@
// Post-Effekt „dither": RGB555-Quantisierung mit 4×4-Bayer-Ordered-Dither —
// die PS1-Farbtiefe. Gehört ans Ende der Kette: quantisieren ist der letzte
// Schritt vor der Ausgabe.
//
// Vertex-Stufe kommt aus fullscreen.wgsl. Eingang ist das Bild des
// vorherigen Kettenglieds, 1:1 per textureLoad — kein Sampler, keine
// Filterung, ein Ziel-Pixel liest genau ein Quell-Pixel.
@group(0) @binding(0) var src: texture_2d<f32>;
// 4×4-Bayer-Matrix (Werte 0..15).
fn bayer4(px: vec2u) -> f32 {
var m = array<u32, 16>(
0u, 8u, 2u, 10u,
12u, 4u, 14u, 6u,
3u, 11u, 1u, 9u,
15u, 7u, 13u, 5u,
);
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
}
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2u(pos.xy);
let c = clamp(textureLoad(src, vec2i(px), 0).rgb, vec3f(0.0), vec3f(1.0));
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
// Verläufe zerfallen ins typische Dither-Muster.
let t = (bayer4(px) + 0.5) / 16.0;
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
}
+19
View File
@@ -0,0 +1,19 @@
// Vertex-Stufe *aller* Post-Effekte: ein Dreieck über den ganzen Schirm.
//
// Bewusst ein eigenes Modul: die Pipeline nimmt die Vertex-Stufe von hier und
// die Fragment-Stufe aus der Effekt-Datei. So besteht eine Effekt-Datei nur
// aus ihrem `fs_main` und kennt keinen anderen Shader.
struct VsOut {
@builtin(position) pos: vec4f,
};
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
var xy = array<vec2f, 3>(
vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0),
);
var o: VsOut;
o.pos = vec4f(xy[i], 0.0, 1.0);
return o;
}
+64 -43
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 {
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_pipeline.get_bind_group_layout(0),
layout: &blit_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&internal_view),
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.
+8 -1
View File
@@ -12,6 +12,7 @@ mod audio;
mod font;
mod gpu;
mod math;
mod post;
mod props;
mod scene;
mod sprite;
@@ -44,6 +45,10 @@ const EYE_SMOOTH_TAU: f32 = 0.06;
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); }
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); }
@@ -488,7 +493,9 @@ impl App {
}
}
if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &screen.ui); }
if let Some(gpu) = &mut self.gpu {
gpu.frame(&view, &screen.ui, &self.session.post);
}
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Post-Kette: eigenständige Fullscreen-Effekte zwischen Szene und Overlay.
//!
//! 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
//! voraussetzen. Ein Effekt wird hinzugefügt, indem er in [`EFFECTS`]
//! auftaucht; geschaltet wird er über `engine::post` (Konsole: `post`).
//!
//! Gerechnet wird auf zwei Ping-Pong-Targets in interner Auflösung. Bei
//! 640×480 kostet ein zusätzlicher Fullscreen-Pass praktisch nichts —
//! deshalb darf jeder Effekt seinen eigenen behalten, statt sie zu einem
//! Über-Shader zu verschmelzen.
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 Effektnamen — das Frontend meldet sie beim Start in der Session an.
pub fn names() -> impl Iterator<Item = &'static str> {
EFFECTS.iter().map(|(n, _)| *n)
}
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)>,
}
impl Post {
pub fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
size: [u32; 2],
) -> Self {
let views = [make_target(device, format, size, "post a"),
make_target(device, format, size, "post b")];
// Expliziter Layout statt `layout: None`: *der* Vertrag der Kette —
// alle Effekte teilen ihn, damit dieselben Bind-Groups für jede
// Pipeline passen.
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post input"),
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,
}],
});
let in_binds = [make_bind(device, &bind_layout, &views[0]),
make_bind(device, &bind_layout, &views[1])];
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("post"),
bind_group_layouts: &[Some(&bind_layout)],
immediate_size: 0,
});
// Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem
// anderen Modul als die Fragment-Stufe.
let fullscreen = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("fullscreen"),
source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen.wgsl").into()),
});
let pipelines = EFFECTS.iter().map(|(name, src)| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(name),
source: wgpu::ShaderSource::Wgsl((*src).into()),
});
let p = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &fullscreen,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
(*name, p)
}).collect();
Self { views, in_binds, pipelines }
}
/// Target, in das die Szene zeichnet — der Anfang der Kette.
pub fn scene_target(&self) -> &wgpu::TextureView { &self.views[0] }
pub fn view(&self, i: usize) -> &wgpu::TextureView { &self.views[i] }
/// Die aktiven Effekte der Reihe nach ausführen. Rückgabe: Index des
/// Targets, das jetzt das Bild trägt (0, wenn nichts aktiv war).
pub fn run(&self, enc: &mut wgpu::CommandEncoder, chain: &PostChain) -> usize {
let mut src = 0;
for (name, pipeline) in &self.pipelines {
if !chain.enabled(name) { continue; }
let dst = 1 - src;
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(name),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.views[dst],
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Jedes Pixel wird geschrieben — Clear ist nur der
// billigste definierte Startzustand.
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.in_binds[src], &[]);
pass.draw(0..3, 0..1);
src = dst;
}
src
}
}
fn make_target(
device: &wgpu::Device,
format: wgpu::TextureFormat,
size: [u32; 2],
label: &str,
) -> wgpu::TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
tex.create_view(&Default::default())
}
fn make_bind(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
view: &wgpu::TextureView,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post input"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
}],
})
}
+8 -24
View File
@@ -1,13 +1,13 @@
// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch
// gerechnet statt nachgestellt:
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
// - 1-Bit-Alpha-Test (Cutouts), kein Blending
// 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.
//
// 1-Bit-Alpha-Test (Cutouts), kein Blending.
//
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen
// Pixel-Snap und Dither.
// frei beweglichen Kamera und großen Faces zu störend.
struct Uniforms {
mvp: mat4x4f,
@@ -42,17 +42,6 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
return out;
}
// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering.
fn bayer4(px: vec2u) -> f32 {
var m = array<u32, 16>(
0u, 8u, 2u, 10u,
12u, 4u, 14u, 6u,
3u, 11u, 1u, 9u,
15u, 7u, 13u, 5u,
);
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
}
// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
@@ -63,10 +52,5 @@ const MIP_BIAS: f32 = -1.0;
fn fs_main(in: VsOut) -> @location(0) vec4f {
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
// Verläufe zerfallen ins typische Dither-Muster.
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0;
let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0));
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
return vec4f(texel.rgb, 1.0);
}
+6 -11
View File
@@ -1,5 +1,8 @@
//! Sprite-Pass: das 2D-Overlay (UI) als getintete Textur-Quads.
//!
//! Zeichnet in einem eigenen Pass *hinter* der Post-Kette: Bild-Effekte
//! (Farbtiefe, Dither, später Outline) treffen so nur die Welt, nie den Text.
//!
//! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
@@ -60,7 +63,6 @@ impl SpritePass {
device: &wgpu::Device,
queue: &wgpu::Queue,
color_format: wgpu::TextureFormat,
depth_format: wgpu::TextureFormat,
internal: [f32; 2],
textures: &[Image],
) -> Self {
@@ -89,16 +91,9 @@ impl SpritePass {
})],
}),
primitive: wgpu::PrimitiveState::default(),
// Der interne Pass hat ein Tiefen-Attachment → die Pipeline muss
// ein kompatibles Format deklarieren. UI testet/schreibt aber
// keine Tiefe (Always, kein Write) → liegt immer obenauf.
depth_stencil: Some(wgpu::DepthStencilState {
format: depth_format,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
// Eigener Pass hinter der Post-Kette, ohne Tiefen-Attachment:
// das Overlay liegt per Reihenfolge obenauf.
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
+4 -4
View File
@@ -1,8 +1,8 @@
// 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln
// (Ursprung oben-links), getintet. Zeichnet ins interne Target *nach* der
// Szene, ohne Tiefentest → liegt immer obenauf. Echtes Alpha-Blending
// (anders als der 1-Bit-Alphatest der Welt), damit Panels und Glyph-Ränder
// weich überblenden.
// (Ursprung oben-links), getintet. Läuft als letzter interner Pass, hinter
// der Post-Kette → liegt immer obenauf und bleibt von Bild-Effekten
// unberührt. Echtes Alpha-Blending (anders als der 1-Bit-Alphatest der
// Welt), damit Panels und Glyph-Ränder weich überblenden.
struct Uniforms {
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
+55
View File
@@ -3,6 +3,7 @@ use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player};
use crate::engine::post::PostChain;
use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl};
@@ -43,6 +44,9 @@ pub struct Session {
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
/// Empties, siehe engine::audio).
emitters: Emitters,
/// An/Aus der Render-Post-Effekte. Das Fenster-Frontend meldet seine Kette
/// beim Start an und liest sie jeden Frame; die CLI lässt sie leer.
pub post: PostChain,
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
/// `[action]`-Textzeile.
@@ -93,6 +97,7 @@ impl Session {
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
emitters: Emitters::new(),
post: PostChain::new(),
pending_sounds: Vec::new(),
signals_path,
sim_accum: 0.0,
@@ -250,6 +255,7 @@ impl Session {
}
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
},
("post", args) => ExecResult::lines(self.post_cmd(args)),
("signal", sig) => ExecResult::lines(self.fire(sig, None)),
// Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
// `signal`-Property, sonst den suffix-befreiten Objektnamen.
@@ -266,6 +272,32 @@ impl Session {
}
}
/// `post` listet die Kette, `post <name> [on|off]` schaltet sie (ohne
/// Zustand: toggle). Die Namen kommen vom Frontend — die Session validiert
/// nur gegen das, was angemeldet wurde.
fn post_cmd(&mut self, args: &str) -> Vec<String> {
if args.is_empty() {
let lines: Vec<String> = self.post.list()
.map(|(n, on)| format!(" {n:<10} {}", on_off(on)))
.collect();
return match lines.is_empty() {
true => vec!["keine Post-Effekte (nur im Fenster-Frontend)".into()],
false => lines,
};
}
let (name, state) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
let on = match state.trim() {
"" => None,
"on" | "an" => Some(true),
"off" | "aus" => Some(false),
_ => return vec!["post <name> [on|off]".into()],
};
match self.post.set(name, on) {
Some(now) => vec![format!("{name}: {}", on_off(now))],
None => vec![format!("unbekannter Effekt: {name:?} — `post` listet sie")],
}
}
fn dialog_input(&mut self, line: &str) -> ExecResult {
let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); };
let sel = if d.choices.is_empty() {
@@ -372,6 +404,10 @@ impl Session {
}
}
fn on_off(on: bool) -> &'static str {
if on { "an" } else { "aus" }
}
fn help() -> Vec<String> {
vec![
" signal <s> Signal feuern oder Action direkt ausführen".into(),
@@ -381,6 +417,7 @@ fn help() -> Vec<String> {
" kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(),
" mode <m> Modus setzen: play | free (Noclip) | menu".into(),
" post [<e> on|off] Post-Effekte anzeigen / schalten".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
@@ -605,6 +642,24 @@ mod tests {
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
}
#[test]
fn post_command_switches_registered_effects() {
let mut s = empty_session();
// Ohne Frontend ist die Kette leer — der Befehl sagt das, statt zu lügen.
assert_eq!(s.exec("post").output.len(), 1);
assert!(!s.post.enabled("dither"));
s.post.register("dither", true);
assert_eq!(s.exec("post").output.len(), 1, "eine Zeile je Effekt");
s.exec("post dither off");
assert!(!s.post.enabled("dither"));
s.exec("post dither");
assert!(s.post.enabled("dither"), "ohne Zustand togglet es");
// Unbekannter Effekt meldet sich und lässt die Kette in Ruhe.
assert_eq!(s.exec("post bloom on").output.len(), 1);
assert!(s.post.enabled("dither"));
}
#[test]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();