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:
@@ -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