diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 6ddf02d..ce4780e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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; diff --git a/src/engine/post.rs b/src/engine/post.rs new file mode 100644 index 0000000..733197b --- /dev/null +++ b/src/engine/post.rs @@ -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) -> Option { + 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 { + 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")); + } +} diff --git a/src/render/dither.wgsl b/src/render/dither.wgsl new file mode 100644 index 0000000..ce57b26 --- /dev/null +++ b/src/render/dither.wgsl @@ -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; + +// 4×4-Bayer-Matrix (Werte 0..15). +fn bayer4(px: vec2u) -> f32 { + var m = array( + 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); +} diff --git a/src/render/fullscreen.wgsl b/src/render/fullscreen.wgsl new file mode 100644 index 0000000..6de2294 --- /dev/null +++ b/src/render/fullscreen.wgsl @@ -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(-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; +} diff --git a/src/render/gpu.rs b/src/render/gpu.rs index 98d25cf..a58f34b 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -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. diff --git a/src/render/mod.rs b/src/render/mod.rs index 9c2ff0f..fc807ad 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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); + } } } diff --git a/src/render/post.rs b/src/render/post.rs new file mode 100644 index 0000000..40ee389 --- /dev/null +++ b/src/render/post.rs @@ -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 { + 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), + }], + }) +} diff --git a/src/render/scene.wgsl b/src/render/scene.wgsl index e86b6d5..18d6089 100644 --- a/src/render/scene.wgsl +++ b/src/render/scene.wgsl @@ -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( - 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); } diff --git a/src/render/sprite.rs b/src/render/sprite.rs index 6a6f02d..ee1bace 100644 --- a/src/render/sprite.rs +++ b/src/render/sprite.rs @@ -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, diff --git a/src/render/sprite.wgsl b/src/render/sprite.wgsl index cb181c6..84d161d 100644 --- a/src/render/sprite.wgsl +++ b/src/render/sprite.wgsl @@ -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 diff --git a/src/session.rs b/src/session.rs index be82d19..9e7879e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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 [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 { + if args.is_empty() { + let lines: Vec = 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 [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 { vec![ " signal Signal feuern oder Action direkt ausführen".into(), @@ -381,6 +417,7 @@ fn help() -> Vec { " kv KV-Store anzeigen".into(), " menu Menü öffnen / ins Spiel zurück".into(), " mode Modus setzen: play | free (Noclip) | menu".into(), + " post [ 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();