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:
+4
-2
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+74
-53
@@ -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.
|
||||
|
||||
+8
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user