Compare commits

...
9 Commits
Author SHA1 Message Date
irrlichtandClaude Opus 5 8ddbd6f514 Outline-Pass: Kanten aus dem G-Buffer
Erster Effekt, der das Bild verändert. Gesucht werden Kanten der
Geometrie, nicht des Bildes — auf dem fertigen Bild zu suchen wäre
falsch, weil der Dither Flächen in Muster zerlegt und jede Texturkante
als Kante zählte. Drei unabhängige Signale, genommen wird das stärkste:
Tiefe (Silhouetten) und Normale (Knicke) über einen 3x3-Sobel, die
Objekt-ID über einen Vergleich mit den direkten Nachbarn. Läuft vor
dither, damit die Linie mitquantisiert wird wie alles andere.

Zwei Vorarbeiten, die nur für diesen Pass existieren und deshalb hier
mit drinstehen:

Der Post-Vertrag bekommt group 2 mit Uniforms (inv_res, near, far,
time). Das räumt near/far aus normals.wgsl heraus, wo ich sie als
Literale abgeschrieben hatte — der Debug-View hätte stillschweigend
gelogen, sobald jemand die Projektion ändert, ausgerechnet das
Werkzeug, dem man beim Debuggen glauben muss. Sie kommen jetzt aus
NEAR_FAR in gpu.rs, derselben Konstante wie die Projektion. time ist
Vorrat für animierte Effekte; jetzt mitgenommen, weil eine spätere
Vertragsänderung alle Bind-Group-Layouts anfasst.

Die Objektnummer füllt das reservierte Alpha des Normal-Buffers. Sie
läuft fortlaufend statt als Namens-Hash: benachbarte Nummern
unterscheiden sich damit garantiert, ein Hash hätte mit ~1/256 still
kollidiert. Mesh::append verschiebt die Nummern des Anhängsels, sonst
trügen die ersten Objekte zweier .glb dieselbe und die Kante zwischen
ihnen fehlte. Ab 1, damit die 0 "kein Objekt" heißt — der Outline-Pass
zeichnet auf dem Hintergrund nichts, sonst läge jede Silhouette
doppelt.

Nicht getrennt committet, weil Uniform und ID für sich nichts leisten:
vor diesem Pass liest sie niemand außer dem Debug-View.

Strichstärke geht über den Abtastabstand des Kernels (WIDTH, aktuell 2)
statt über nachträgliches Aufdicken — das kostet keinen zusätzlichen
Texturzugriff, wo Dilatation das Neunfache gebraucht hätte. Die
Gradienten werden durch den Abstand geteilt, damit die Schwellwerte
beim Breitenwechsel ihre Bedeutung behalten.

DEPTH_T und NORMAL_T sind aus der Kernel-Normierung hergeleitet, nicht
an der Szene erprobt; Fehlerbilder und Richtung stehen als Kommentar
daneben. normals.wgsl zeigt jetzt drei Bänder (Normale, ID, Tiefe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlichtandClaude Opus 5 b9258a68fd G-Buffer: Normalen aus glTF bis in den Post-Vertrag
Cel-Ramp und ein Sobel, der auch Innenkanten findet, brauchen beide
Normalen — die gab es in der Pipeline bisher nirgends. Der Strang geht
quer durch alle Schichten, deshalb als eigener Schritt, bevor ein
Effekt darauf zugreift.

Loader: NORMAL wird gelesen und mit der Kofaktormatrix des 3x3-Teils
transformiert, nicht mit der Welt-Matrix. Das ist die inverse
Transponierte bis auf den Faktor det, den das Normieren frisst — nur so
bleibt eine Normale bei ungleichmäßiger Skalierung senkrecht auf ihrer
Fläche, und bei Spiegelungen dreht sie korrekt mit. glTF erlaubt Meshes
ohne NORMAL; die rechnet fill_missing_normals aus den angrenzenden
Flächen (Nullvektor als Sentinel). Beide Szenen-Modelle bringen eigene
Normalen mit, der Fallback ist reine Absicherung.

Szenen-Pass schreibt jetzt zwei Ziele: Albedo und n*0.5+0.5. Das Alpha
des Normal-Buffers ist für eine Objekt-ID reserviert und noch leer. Die
Depth-Textur bekommt TEXTURE_BINDING, und der Post-Vertrag wächst um
group 1 (Normale + Tiefe), die jeder Effekt ignorieren darf — dither
tut das.

Statt eines cel.wgsl-Durchreichers gibt es normals.wgsl, per Default
aus: `post normals on` zeigt oben die Normalen, unten die linearisierte
Tiefe. Ein Effekt, der nichts tut, wäre von einem kaputten nicht zu
unterscheiden; dieser hier trennt später "Loader kaputt" von "Effekt
kaputt".

Das Standard-Bild bleibt unverändert — die Normalen werden geschrieben,
aber von keinem aktiven Effekt gelesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlichtandClaude Opus 5 7f87dbca35 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>
2026-08-28 23:31:49 +02:00
irrlichtandClaude Opus 5 8bee8322c3 noclip gestrichen
Der Befehl war ein Toggle zwischen Play und Free — als getippter
Konsolen-Befehl die falsche Form (man sieht nicht, wohin er schaltet)
und deckungsgleich mit `mode play` / `mode free`. Keine Taste, kein
Menü-Knopf, keine Referenz in assets/ hing daran.

Damit stimmt auch die Doku von set_mode wieder: `menu` und die
SetMode-Action sind jetzt tatsächlich die einzigen Schreibpfade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlicht a3ace75c81 Collider an Cinema Scene 2026-08-28 23:31:49 +02:00
irrlicht 87500e13da Dokumentation der Blender-Interaktion 2026-08-28 23:31:48 +02:00
irrlicht bf2cb25836 map builts 2026-08-28 23:31:48 +02:00
irrlicht 50b7b86944 refactor 2026-08-28 23:31:48 +02:00
irrlicht 3571106251 block slope sliding 2026-08-28 23:31:48 +02:00
31 changed files with 1471 additions and 611 deletions
Binary file not shown.
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
Alles Inhaltliche kommt aus vier Quellen:
**Blender** (Welt),
**Ink** (Dialoge/Story),
**signals.toml** (Verdrahtung),
**Assets-Ordner** (Texturen/Sounds). Fehler in Inhalten stürzen nie ab — sie werden beim
Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
## 3D / Blender
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
dort werden beim Start geladen.
- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
keine der Properties unten an!).
- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m,
Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken.
- **Material-Name = Textur-Name:** Material `holz` → `assets/textures/holz.tga`.
Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur.
- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit.
### Custom Properties auf Mesh-Objekten
| Property | Wirkung |
|---|---|
| *(keine)* | nur sichtbar, kein Anfassen |
| `collide = 1` | sichtbar **und** solide (Spieler kollidiert mit der konvexen Hülle des Meshes — Dellen/Löcher werden überspannt) |
| `collide = proxy` | unsichtbares Kollisionsvolumen (für Fein-Collider neben Sichtgeometrie) |
| `signal = <name>` **mit** collide | Klick-Ziel: Anklicken feuert das Signal `<name>` |
| `signal = <name>` **ohne** collide | unsichtbare Trigger-Zone: Betreten feuert `<name>` einmal (erneut erst nach Verlassen; „nur einmal überhaupt" selbst per KV-Flag bauen) |
- `collide = 0` schaltet ab, ohne die Property zu löschen.
- Objektname ist der Interact-Key: Blender-Suffixe (`Ding.001`) werden beim
Signal-Lookup automatisch gestrippt — `[Ding]` in signals.toml trifft alle.
### Custom Properties auf Empties
| Property | Wirkung |
|---|---|
| `role = spawn` | Spieler-Startpunkt (Fußpunkt = Empty-Position) |
| `sound = <name>` | Ambient-Loop `assets/sounds/<name>.wav` an dieser Position |
| `radius = <m>` | Hörradius des Sounds (linear leiser bis Stille; Default 8) |
## Audio
- **Format:** WAV, **mono, 16 bit, 44100 Hz** — nichts anderes. Ablage in
`assets/sounds/`, angesprochen ohne Endung (`pickup` → `pickup.wav`).
- **Ambient:** per Empty (siehe oben), loopt immer — Loop-Punkt sauber schneiden.
- **Einmal-SFX:** per Action `play_sound <name>` (aus signals.toml oder Ink-Tag).
## Dialoge / Ink
- `.ink` nach `assets/interactions/` — wird beim Build automatisch
kompiliert (bei Bedarf von Hand: `./tools/build-dialogues.sh`).
- Gestartet per Action `start_ink <datei>.ink.json`.
- **Variablen:** alle Ink-`VAR` landen automatisch im Spiel-KV und synchen
in beide Richtungen — `_`-Prefix (`VAR _tmp = 0`) bleibt Ink-lokal.
- **Tags feuern Actions:** `# set has_key true`, `# play_sound klick`,
`# mode play` … (gleiche Verben wie signals.toml). Unbekannte Tags sind
erlaubt (reine Metadaten).
## signals.toml (`assets/signals.toml`)
- Verdrahtet Signal → Aktionen. Signal-Namen: Objektname (Klick), Wert der
`signal`-Property (Zonen/Klick-Ziele), oder frei (aus Ink).
- Verben: `start_ink`, `set`, `inc`, `clear`, `debug_log`, `play_sound`,
`mode play|free|menu`.
- `$self` = auslösender Objektname (für generische Einträge wie
`set $self true`).
- `[init]` feuert einmal beim Start — Ort für KV-Defaults.
## Testen
- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke,
Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen.
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
Signal von Hand, `reload` lädt signals.toml neu.
+5 -8
View File
@@ -10,17 +10,13 @@
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht //! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
//! sofort wieder im Kontakt steckt. //! sofort wieder im Kontakt steckt.
//! //!
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt //! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::add_model`]):
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices //! jedes Objekt mit `collide`-Property wird zur **konvexen Hülle** seiner
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also //! Vertices ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs //! also konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`- //! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die //! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm. //! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
//!
//! Headless wie der Rest von `engine`: hängt nur an `model` (Quelle der
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
//! Welt einmal vom Frontend aus den Modellen.
use crate::engine::model::{Model, Object}; use crate::engine::model::{Model, Object};
@@ -438,6 +434,7 @@ mod tests {
name: "Zone".into(), name: "Zone".into(),
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0]; 4], uvs: vec![[0.0, 0.0]; 4],
normals: vec![[0.0, 0.0, 1.0]; 4],
tris: vec![[0, 1, 2], [0, 2, 3]], tris: vec![[0, 1, 2], [0, 2, 3]],
tri_mats: vec![0; 2], tri_mats: vec![0; 2],
visible: true, collider: false, props: Props::new(), visible: true, collider: false, props: Props::new(),
+8 -12
View File
@@ -5,11 +5,9 @@
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()` //! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
//! konstruiert und durch die Dispatch-Pipeline gereicht. //! konstruiert und durch die Dispatch-Pipeline gereicht.
//! //!
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht //! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte //! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt //! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
//! testbar und kennt keines der Subsysteme.
use std::collections::HashMap; use std::collections::HashMap;
@@ -28,20 +26,18 @@ pub type Signals = HashMap<String, Vec<String>>;
pub enum Action { pub enum Action {
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen. /// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
PlaySound(String), PlaySound(String),
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend /// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht /// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
/// wählbar — der entsteht nur aus dem Story-Ablauf.
SetMode(ModeTarget), SetMode(ModeTarget),
} }
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog- /// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten /// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeTarget { pub enum ModeTarget {
/// First-Person-Spiel. /// First-Person-Spiel.
Play, Play,
/// Noclip-Debug-Flycam. /// Noclip: freies Fliegen ohne Kollision.
Free, Free,
/// Menü (pausiert die Welt, gibt die Maus frei). /// Menü (pausiert die Welt, gibt die Maus frei).
Menu, Menu,
+120 -48
View File
@@ -1,30 +1,23 @@
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`]. //! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
//! //!
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann — //! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und //! „Include → Custom Properties" anhaken → landen in `node.extras`) und
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/… //! **Empties** (Nodes ohne Mesh) als Entity-Marker. Beide kommen als
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die //! `props`/`empties` im Modell an; Bedeutung geben die Konsumenten.
//! Konsumenten.
//! //!
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen //! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren), //! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0` //! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix //! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken — //! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe //! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
//! //!
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System, //! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
//! Blenders Exporter konvertiert selbst. Maßstab 1:1. //! Blenders Exporter konvertiert selbst. Maßstab 1:1.
//!
//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum,
//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders
//! als beim trivialen signals-TOML-Subset).
use serde_json::Value; use serde_json::Value;
use crate::engine::model::{apply_props, Empty, Model, Object, Props}; use crate::engine::model::{apply_props, fill_missing_normals, norm, Empty, Model, Object, Props};
pub fn load(path: &str) -> Result<Model, String> { pub fn load(path: &str) -> Result<Model, String> {
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?; let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
@@ -84,10 +77,11 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
let mut o = Object { let mut o = Object {
name, props, name, props,
visible: true, collider: false, // bis apply_props entscheidet visible: true, collider: false, // bis apply_props entscheidet
verts: Vec::new(), uvs: Vec::new(), verts: Vec::new(), uvs: Vec::new(), normals: Vec::new(),
tris: Vec::new(), tri_mats: Vec::new(), tris: Vec::new(), tri_mats: Vec::new(),
}; };
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?; append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
fill_missing_normals(&mut o); // Primitives ohne NORMAL
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model) apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
if !o.tris.is_empty() { model.objects.push(o); } if !o.tris.is_empty() { model.objects.push(o); }
} }
@@ -124,8 +118,14 @@ fn append_mesh(
Some(a) => read_floats::<2>(doc, bin, a as usize)?, Some(a) => read_floats::<2>(doc, bin, a as usize)?,
None => vec![[0.0, 0.0]; positions.len()], None => vec![[0.0, 0.0]; positions.len()],
}; };
// Ohne NORMAL bleibt der Nullvektor stehen; fill_missing_normals
// rechnet ihn später aus den Flächen.
let normals = match prim["attributes"]["NORMAL"].as_u64() {
Some(a) => read_floats::<3>(doc, bin, a as usize)?,
None => vec![[0.0, 0.0, 0.0]; positions.len()],
};
// Material-Name → geteilte Namensliste (wie usemtl); ohne Material "". // Material-Name → geteilte Namensliste; ohne Material "".
let mat_name = prim["material"].as_u64() let mat_name = prim["material"].as_u64()
.and_then(|m| doc["materials"][m as usize]["name"].as_str()) .and_then(|m| doc["materials"][m as usize]["name"].as_str())
.unwrap_or(""); .unwrap_or("");
@@ -135,9 +135,11 @@ fn append_mesh(
}); });
let base = o.verts.len(); let base = o.verts.len();
for (p, uv) in positions.iter().zip(&uvs) { let nm = normal_matrix(world);
for ((p, uv), n) in positions.iter().zip(&uvs).zip(&normals) {
o.verts.push(transform(world, *p)); o.verts.push(transform(world, *p));
o.uvs.push(*uv); o.uvs.push(*uv);
o.normals.push(norm(transform3(nm, *n)));
} }
let idxs: Vec<usize> = match prim["indices"].as_u64() { let idxs: Vec<usize> = match prim["indices"].as_u64() {
@@ -294,6 +296,36 @@ fn mul(a: M4, b: M4) -> M4 {
out out
} }
/// Normalen-Transform zur Welt-Matrix: die Kofaktormatrix ihres 3×3-Teils
/// (zeilenweise). Das ist die inverse Transponierte bis auf den Faktor
/// `det` — den frisst das anschließende Normieren, und im Gegensatz zur
/// Matrix selbst hält sie Normalen auch bei ungleichmäßiger Skalierung
/// senkrecht auf der Fläche. Bei Spiegelungen (det < 0) dreht sie die
/// Normale korrekt mit.
fn normal_matrix(m: M4) -> [f32; 9] {
let a = |r: usize, c: usize| m[c * 4 + r];
let cof = |r: usize, c: usize| {
let (r0, r1) = ((r + 1) % 3, (r + 2) % 3);
let (c0, c1) = ((c + 1) % 3, (c + 2) % 3);
a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
};
let mut out = [0.0; 9];
for r in 0..3 {
for c in 0..3 { out[r * 3 + c] = cof(r, c); }
}
out
}
/// Richtungsvektor mit einer 3×3-Matrix (zeilenweise) drehen — ohne
/// Translation, anders als [`transform`].
fn transform3(m: [f32; 9], v: [f32; 3]) -> [f32; 3] {
let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() {
*o = m[r * 3] * v[0] + m[r * 3 + 1] * v[1] + m[r * 3 + 2] * v[2];
}
out
}
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] { fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
let mut out = [0.0; 3]; let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() { for (r, o) in out.iter_mut().enumerate() {
@@ -333,42 +365,59 @@ mod tests {
out out
} }
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein /// Diagonale Normale — unter ungleichmäßiger Skalierung unterscheidet
/// Mesh-Node mit Translation und extras, ein Empty mit extras. /// sie die korrekte Kofaktor-Transform von der (falschen) Matrix.
fn sample_glb() -> Vec<u8> { const DIAG: f32 = std::f32::consts::FRAC_1_SQRT_2;
/// Ein Dreieck (Positionen + UVs + Normalen + u16-Indizes) im BIN-Chunk.
fn sample_bin() -> Vec<u8> {
let mut bin: Vec<u8> = Vec::new(); let mut bin: Vec<u8> = Vec::new();
for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); } for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); }
for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); } for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); }
for _ in 0..3 { for f in [DIAG, DIAG, 0.0] { bin.extend(f.to_le_bytes()); } }
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); } for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
let json = r#"{ bin
"asset": {"version": "2.0"}, }
"scene": 0,
"scenes": [{"nodes": [0, 2]}], /// Mesh-Node mit Translation und extras, ein skaliertes Kind, ein Empty.
"nodes": [ const SAMPLE_JSON: &str = r#"{
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0], "asset": {"version": "2.0"},
"extras": {"signal": "tiffany", "hp": 3}, "scene": 0,
"children": [1]}, "scenes": [{"nodes": [0, 2]}],
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}}, "nodes": [
{"name": "spawn", "translation": [1, 2, 3]} {"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
], "extras": {"signal": "tiffany", "hp": 3},
"meshes": [{"primitives": [{ "children": [1]},
"attributes": {"POSITION": 0, "TEXCOORD_0": 1}, {"name": "Kind", "mesh": 0, "scale": [2, 1, 1],
"indices": 2, "material": 0 "extras": {"collide": "proxy"}},
}]}], {"name": "spawn", "translation": [1, 2, 3]}
"materials": [{"name": "carpet"}], ],
"accessors": [ "meshes": [{"primitives": [{
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, "attributes": {"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2},
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}, "indices": 3, "material": 0
{"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"} }]}],
], "materials": [{"name": "carpet"}],
"bufferViews": [ "accessors": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
{"buffer": 0, "byteOffset": 36, "byteLength": 24}, {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
{"buffer": 0, "byteOffset": 60, "byteLength": 6} {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"},
], {"bufferView": 3, "componentType": 5123, "count": 3, "type": "SCALAR"}
"buffers": [{"byteLength": 66}] ],
}"#; "bufferViews": [
glb(json, &bin) {"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
{"buffer": 0, "byteOffset": 60, "byteLength": 36},
{"buffer": 0, "byteOffset": 96, "byteLength": 6}
],
"buffers": [{"byteLength": 102}]
}"#;
fn sample_glb() -> Vec<u8> { glb(SAMPLE_JSON, &sample_bin()) }
/// Dasselbe Modell ohne `NORMAL` — glTF erlaubt das, der Loader muss die
/// Normalen dann selbst rechnen.
fn sample_glb_without_normals() -> Vec<u8> {
glb(&SAMPLE_JSON.replace(r#", "NORMAL": 2"#, ""), &sample_bin())
} }
#[test] #[test]
@@ -405,6 +454,29 @@ mod tests {
assert_eq!(e.pos, [1.0, 2.0, 3.0]); assert_eq!(e.pos, [1.0, 2.0, 3.0]);
} }
#[test]
fn normals_survive_nonuniform_scale() {
let m = parse_glb(&sample_glb()).unwrap();
// „Ding": nur Translation → Normale unverändert.
let n = m.objects[0].normals[0];
assert!((n[0] - DIAG).abs() < 1e-5 && (n[1] - DIAG).abs() < 1e-5, "{n:?}");
// „Kind": Skalierung [2,1,1]. Richtig ist die inverse Transponierte
// (x wird gestaucht → [0.447, 0.894, 0]); die Matrix selbst ergäbe
// fälschlich [0.894, 0.447, 0], die Normale stünde schief zur Fläche.
let n = m.objects[1].normals[0];
assert!((n[0] - 0.4472).abs() < 1e-3, "x sollte gestaucht sein: {n:?}");
assert!((n[1] - 0.8944).abs() < 1e-3, "y sollte überwiegen: {n:?}");
}
#[test]
fn missing_normals_are_computed_from_faces() {
let m = parse_glb(&sample_glb_without_normals()).unwrap();
// Dreieck liegt in der XY-Ebene, CCW → Normale +Z.
assert_eq!(m.objects[0].normals[0], [0.0, 0.0, 1.0]);
assert!(m.objects[0].normals.iter().all(|n| *n != [0.0; 3]));
}
#[test] #[test]
fn uvs_pass_through_unchanged() { fn uvs_pass_through_unchanged() {
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip. // glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
+10 -17
View File
@@ -3,30 +3,22 @@
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert //! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die //! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module. //! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module,
//! die `engine` konsumieren; die Richtung kehrt sich nie um.
//! //!
//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"): //! Interner Abhängigkeitsgraph (azyklisch halten, Pfeil = „benutzt"):
//! //!
//! kv ← ink ← game ← story_ctrl ← signals //! kv ← ink ← game ← story_ctrl ← signals
//! ↑ ↑ //! ↑ ↑
//! kv assets //! kv assets
//! //!
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte //! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst //! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! signals::dispatch zu rufen). //! rufen.
//! //!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen //! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes //! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first: //! die Emitter-Logik zum Frontend-Treiber render::audio, `post` analog die
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus //! Effekt-Schalter zu render::post, `player` die First-Person-Physik auf der
//! einem Export) und produziert das neutrale `model::Model`. `audio` ist //! Welt aus `collision`.
//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber
//! render::audio.
//!
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
pub mod assets; pub mod assets;
pub mod audio; pub mod audio;
@@ -37,6 +29,7 @@ pub mod ink;
pub mod kv; pub mod kv;
pub mod model; pub mod model;
pub mod player; pub mod player;
pub mod post;
pub mod signals; pub mod signals;
pub mod story_ctrl; pub mod story_ctrl;
pub mod tga; pub mod tga;
+42 -1
View File
@@ -20,7 +20,7 @@
use std::collections::HashMap; use std::collections::HashMap;
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`), /// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer. /// Werte zu Strings vereinheitlicht.
pub type Props = HashMap<String, String>; pub type Props = HashMap<String, String>;
pub struct Model { pub struct Model {
@@ -42,6 +42,9 @@ pub struct Object {
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich /// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip. /// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
pub uvs: Vec<[f32; 2]>, pub uvs: Vec<[f32; 2]>,
/// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im
/// Modell, rechnet der Loader sie aus den Flächen.
pub normals: Vec<[f32; 3]>,
pub tris: Vec<[usize; 3]>, pub tris: Vec<[usize; 3]>,
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`). /// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
pub tri_mats: Vec<usize>, pub tri_mats: Vec<usize>,
@@ -77,6 +80,39 @@ pub const SIGNAL_PROP: &str = "signal";
pub const ROLE_PROP: &str = "role"; pub const ROLE_PROP: &str = "role";
pub const ROLE_SPAWN: &str = "spawn"; pub const ROLE_SPAWN: &str = "spawn";
/// Auf Länge 1 bringen; der Nullvektor bleibt der Nullvektor (Sentinel für
/// „noch keine Normale" in [`fill_missing_normals`]).
pub fn norm(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if l > 0.0 { [v[0] / l, v[1] / l, v[2] / l] } else { v }
}
/// Fehlende Normalen (Nullvektoren) aus den angrenzenden Flächen füllen —
/// glTF erlaubt Meshes ohne `NORMAL`. Die Flächennormalen werden je Vertex
/// aufsummiert, was an Kanten weich mittelt; Alternative wäre, die Vertices
/// je Fläche aufzuspalten (hartes Flat-Shading) — dafür ist der Look hier
/// nicht empfindlich genug.
pub fn fill_missing_normals(o: &mut Object) {
if !o.normals.contains(&[0.0; 3]) { return; }
let mut acc = vec![[0.0f32; 3]; o.verts.len()];
for t in &o.tris {
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let n = [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
for &i in t {
for k in 0..3 { acc[i][k] += n[k]; }
}
}
for (i, n) in o.normals.iter_mut().enumerate() {
if *n == [0.0; 3] { *n = norm(acc[i]); }
}
}
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte /// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider, /// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert. /// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
@@ -119,6 +155,11 @@ pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal
let mut o = Object { let mut o = Object {
name: name.into(), name: name.into(),
uvs: vec![[0.0, 0.0]; verts.len()], uvs: vec![[0.0, 0.0]; verts.len()],
// Vom Mittelpunkt nach außen — die Collider-Tests rendern nie, aber
// so ist die Richtung wenigstens plausibel.
normals: verts.iter()
.map(|v| norm([v[0] - min[0] - 0.5, v[1] - min[1] - 0.5, v[2] - min[2] - 0.5]))
.collect(),
verts, tris, verts, tris,
tri_mats: vec![0; n], tri_mats: vec![0; n],
visible: true, collider: false, props, visible: true, collider: false, props,
+149 -48
View File
@@ -1,24 +1,15 @@
//! First-Person-Player: headless Bewegungsphysik (Stufe 1). //! First-Person-Player: headless Bewegungsphysik.
//! //!
//! Bewusst frontend- und GPU-frei — wie der Rest von `engine`. Der Renderer //! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] und gleitet an
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`), //! Treffern entlang (move-and-slide). Getract wird der Box-Mittelpunkt
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal //! (Fußpunkt + halbe Höhe), danach geht es zurück auf den Fußpunkt.
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um. //! Bodenkontakt fällt aus nach oben zeigenden Trefferflächen ab. `fly` ist der
//! Noclip-Gegenpart ohne Welt.
//! //!
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten- //! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine //! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter //! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
//! Nutzer echte dot/cross/normalize braucht. //! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
//!
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
//! Getract wird der Box-Mittelpunkt (Fußpunkt + halbe Höhe), danach geht es
//! zurück auf den Fußpunkt. Bodenkontakt fällt aus nach oben zeigenden
//! Trefferflächen ab.
//!
//! Konvention wie `render::math::view`/`camera.rs`: yaw=0 blickt −Z, positiver
//! Yaw dreht nach links, positiver Pitch hebt den Blick. Die Bewegung ist
//! horizontal aus dem Yaw (ohne Pitch) — Hochschauen lässt einen nicht abheben.
use crate::engine::collision::CollisionWorld; use crate::engine::collision::CollisionWorld;
@@ -26,19 +17,24 @@ use crate::engine::collision::CollisionWorld;
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter). /// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
pub const EYE_HEIGHT: f32 = 1.6; pub const EYE_HEIGHT: f32 = 1.6;
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges /// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
const GRAVITY: f32 = 20.0; const GRAVITY: f32 = 20.0;
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter). /// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
const WALK_SPEED: f32 = 5.0; const WALK_SPEED: f32 = 5.0;
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert. /// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe.
const JUMP_SPEED: f32 = 7.0; const JUMP_SPEED: f32 = 7.0;
/// Flug-Geschwindigkeit im Noclip (units/s).
const FLY_SPEED: f32 = 4.0;
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`). /// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
const PITCH_LIMIT: f32 = 1.55; const PITCH_LIMIT: f32 = 1.55;
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch. /// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft. /// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft.
pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3]; pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok). /// Grenzwinkel für begehbaren Boden, als Kosinus (= Y-Komponente der
/// Trefferormale einer Fläche mit diesem Neigungswinkel). 0.7 ≈ **45°**:
/// flacher wird gelaufen, steiler wird gerutscht. Das ist *der* Regler fürs
/// Hangverhalten — Wunschwinkel als Kosinus eintragen (30° → 0.866,
/// 50° → 0.643, 60° → 0.5).
const GROUND_NORMAL_Y: f32 = 0.7; const GROUND_NORMAL_Y: f32 = 0.7;
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich). /// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
const MAX_SLIDES: u32 = 4; const MAX_SLIDES: u32 = 4;
@@ -77,48 +73,68 @@ impl Player {
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]] [self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
} }
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/ /// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den /// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
/// Point-and-Click-Strahl aus der Bildmitte.
pub fn look_dir(&self) -> [f32; 3] { pub fn look_dir(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos(); let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos(); let (sp, cp) = self.pitch.sin_cos();
[-sy * cp, sp, -cy * cp] [-sy * cp, sp, -cy * cp]
} }
/// Horizontales Rechts (ohne Pitch) — Strafen kippt nie mit dem Blick.
pub fn right_dir(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
[cy, 0.0, -sy]
}
/// Noclip: frei durch die Welt, ohne Kollision und ohne Gravitation.
/// Vorwärts folgt dem vollen Blick, `up` ist Welt-Y (Auf-/Absteigen bleibt
/// unabhängig vom Blickwinkel).
pub fn fly(&mut self, fwd: f32, right: f32, up: f32, dt: f32) {
let (f, r) = (self.look_dir(), self.right_dir());
for i in 0..3 {
self.pos[i] += (f[i] * fwd + r[i] * right) * FLY_SPEED * dt;
}
self.pos[1] += up * FLY_SPEED * dt;
self.vel = [0.0; 3];
self.grounded = false;
}
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt, /// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true = /// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden. /// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) { pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
let was_grounded = self.grounded; let was_grounded = self.grounded;
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0, // Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach. // mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
// Hochschauen nicht abhebt.
let (sy, cy) = self.yaw.sin_cos(); let (sy, cy) = self.yaw.sin_cos();
let f = [-sy, -cy]; // (x, z) let r = self.right_dir();
let r = [ cy, -sy]; // (x, z) self.vel[0] = (-sy * fwd + r[0] * right) * WALK_SPEED;
self.vel[2] = (-cy * fwd + r[2] * right) * WALK_SPEED;
// Wunschgeschwindigkeit sofort setzen (ohne Beschleunigung/Reibung — // Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst. // der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED; // hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED; // aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
// Sprung vor der Gravitation, nur vom Boden (Vorframe-`grounded`) —
// kein Doppelsprung.
let jumped = jump && was_grounded; let jumped = jump && was_grounded;
if jumped { if jumped {
self.vel[1] = JUMP_SPEED; self.vel[1] = JUMP_SPEED;
self.grounded = false; self.grounded = false;
} else if was_grounded {
self.vel[1] = 0.0;
} else {
self.vel[1] -= GRAVITY * dt;
} }
self.vel[1] -= GRAVITY * dt;
self.move_and_slide(world, dt); self.move_and_slide(world, dt);
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen // Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter, // knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über // ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim // Rampen-Absprünge mehr.
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 { if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
self.snap_to_ground(world); self.snap_to_ground(world);
} }
@@ -142,11 +158,9 @@ impl Player {
/// ///
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich /// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder /// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und /// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box- /// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst /// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) { fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
let half = HALF_EXTENTS; let half = HALF_EXTENTS;
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
@@ -346,6 +360,22 @@ mod tests {
assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]); assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]);
} }
#[test]
fn fly_ignores_gravity_and_geometry() {
// Noclip kennt die Welt gar nicht: nach unten durch jeden Boden, und
// vorwärts dem vollen Blick nach — ohne Gravitation.
let mut p = Player::new([0.0, 2.0, 0.0]);
p.fly(0.0, 0.0, -1.0, 1.0);
assert!(p.pos[1] < -1.0, "sollte durch den Boden sinken: {}", p.pos[1]);
assert_eq!(p.vel, [0.0; 3], "Flug trägt keine Geschwindigkeit");
p.pitch = std::f32::consts::FRAC_PI_2; // gerade nach oben schauen
let y0 = p.pos[1];
p.fly(1.0, 0.0, 0.0, 1.0);
assert!(p.pos[1] > y0 + 1.0, "vorwärts folgt dem Blick: {y0} -> {}", p.pos[1]);
assert!(!p.grounded);
}
#[test] #[test]
fn wall_blocks_horizontal_movement() { fn wall_blocks_horizontal_movement() {
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen. // Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
@@ -462,6 +492,77 @@ mod tests {
]) ])
} }
/// Keilförmige Rampe, die in +X mit `deg` Grad ansteigt (Fuß bei x=0,
/// Länge 10). Bergab ist damit -X.
fn slope_world(deg: f32) -> CollisionWorld {
let (s, c) = deg.to_radians().sin_cos();
collision::from_brushes(vec![vec![
collision::Plane { n: [-s, c, 0.0], d: 0.0 }, // Rampenoberfläche
collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 }, // Unterseite (y≥0)
collision::Plane { n: [1.0, 0.0, 0.0], d: 10.0 }, // x≤10
collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 }, // x≥0
collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 },
collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 },
]])
}
/// Auf eine Rampe fallen lassen und setzen — gibt den Ruhepunkt zurück.
fn settle(w: &CollisionWorld, x: f32, deg: f32) -> Player {
// Über der Fläche starten — mit Abstand, sonst steckt die aufgeblasene
// Box schon in der Rampe und die Trace lässt sie durchfallen.
let y = x * deg.to_radians().tan() + 1.0;
let mut p = Player::new([x, y, 0.0]);
for _ in 0..60 { p.step(w, 0.0, 0.0, false, 1.0 / 60.0); }
p
}
#[test]
fn stands_still_on_walkable_slope() {
// 30° < Grenzwinkel: ohne Eingabe darf der Spieler nicht abwärts
// kriechen — sonst ist der Hangabtrieb zurück.
let w = slope_world(30.0);
let mut p = settle(&w, 5.0, 30.0);
assert!(p.grounded, "sollte auf der 30°-Rampe stehen: {:?}", p.pos);
let (x0, y0) = (p.pos[0], p.pos[1]);
for _ in 0..300 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // 5 Sekunden
assert!((p.pos[0] - x0).abs() < 0.01, "kein Hangabtrieb: x {x0} -> {}", p.pos[0]);
assert!((p.pos[1] - y0).abs() < 0.01, "keine Höhendrift: y {y0} -> {}", p.pos[1]);
assert!(p.grounded, "bleibt am Boden");
}
#[test]
fn slides_down_steep_slope() {
// 60° > Grenzwinkel: hier soll er abrutschen (bergab = -X).
let w = slope_world(60.0);
let mut p = settle(&w, 5.0, 60.0);
assert!(!p.grounded, "zu steil zum Stehen: {:?}", p.pos);
let (x0, y0) = (p.pos[0], p.pos[1]);
for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); }
assert!(p.pos[0] < x0 - 0.5, "sollte hangabwärts rutschen: x {x0} -> {}", p.pos[0]);
assert!(p.pos[1] < y0 - 0.5, "und dabei sinken: y {y0} -> {}", p.pos[1]);
}
#[test]
fn walks_up_and_down_walkable_slope() {
// Der stehenbleibende Spieler muss trotzdem beide Richtungen laufen.
let w = slope_world(30.0);
let mut up = settle(&w, 5.0, 30.0);
up.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X, bergauf
let y0 = up.pos[1];
for _ in 0..60 { up.step(&w, 1.0, 0.0, false, 1.0 / 60.0); }
assert!(up.pos[1] > y0 + 0.5, "bergauf steigt y: {y0} -> {}", up.pos[1]);
assert!(up.grounded, "bleibt bergauf am Boden");
let mut down = settle(&w, 5.0, 30.0);
down.yaw = std::f32::consts::FRAC_PI_2; // vorwärts → -X, bergab
let y0 = down.pos[1];
for i in 0..60 {
down.step(&w, 1.0, 0.0, false, 1.0 / 60.0);
assert!(down.grounded, "bergab am Boden kleben (i={i}, {:?})", down.pos);
}
assert!(down.pos[1] < y0 - 0.5, "bergab sinkt y: {y0} -> {}", down.pos[1]);
}
#[test] #[test]
fn walks_down_slope_across_seams_at_60fps() { fn walks_down_slope_across_seams_at_60fps() {
let w = slope_between_floors(); let w = slope_between_floors();
+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"));
}
}
+2 -3
View File
@@ -1,7 +1,6 @@
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise //! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState` //! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer //! in seine Darstellung.
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
//! //!
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der //! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht //! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
+1 -5
View File
@@ -2,11 +2,7 @@
//! //!
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8, //! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine //! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die //! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm.
//! RGB-Umrechnung jedem Konsumenten einzeln überließ.) Konsumenten ziehen
//! ein `Image` und legen es ab, wie sie es brauchen: Renderer → GPU-Textur,
//! UI/Font → eigene Interpretation. Die UV-/V-Achsen-Konvention ist damit
//! Sache des Konsumenten beim Sampling, nicht des Loaders.
//! //!
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24 //! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für //! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
+2 -4
View File
@@ -1,9 +1,7 @@
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples. //! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
//! //!
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die //! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten), //! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
//! File wird gemeldet und bleibt still, nie fatal.
//! //!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz, //! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV //! little-endian — das feste Format des Mixers (render::audio). „Als WAV
+3 -4
View File
@@ -2,10 +2,9 @@
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt //! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
//! dieses Modul nie). //! dieses Modul nie).
//! //!
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die //! Kein Panic: kein Gerät oder fehlende/kaputte WAVs sind eine Meldung und
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte //! Stille, nie fatal ([`Audio::open`] liefert `None`, alle Aufrufe darauf
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert //! entfallen still beim Aufrufer).
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
//! //!
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen: //! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt, //! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
-102
View File
@@ -1,102 +0,0 @@
//! Flycam: freie Debug-/Entwicklungskamera (Schritt 4). Position, Yaw und
//! Pitch in f32 — Port der irl3d-Kamera-Idee, aber ohne Q16.16: an dieser
//! Kamera hängt keine Physik, und der Renderer rechnet ohnehin f32.
//!
//! Konvention wie `math::Mat4::view`: yaw=0 blickt -Z, positiver Yaw dreht
//! nach links, positiver Pitch hebt den Blick.
use crate::render::math::Mat4;
/// Knapp unter 90°: hält den Blick aus der Senkrechten, wo Yaw bedeutungslos
/// würde (und `right`/`forward` zusammenfielen).
const PITCH_LIMIT: f32 = 1.55;
pub struct Camera {
pub pos: [f32; 3],
pub yaw: f32,
pub pitch: f32,
}
impl Camera {
pub fn new(pos: [f32; 3]) -> Self {
Self { pos, yaw: 0.0, pitch: 0.0 }
}
/// Roh-Maus-Delta (Pixel) → Yaw/Pitch. `sens` in Radiant pro Pixel.
/// Maus nach rechts dreht den Blick nach rechts (Yaw sinkt), Maus nach
/// unten senkt ihn; Pitch wird geclamped.
pub fn apply_mouse(&mut self, dx: f32, dy: f32, sens: f32) {
self.yaw -= dx * sens;
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Blickrichtung inkl. Pitch — W/S bewegen sich hier entlang.
fn forward(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
[-sy * cp, sp, -cy * cp]
}
/// Horizontales Rechts (ohne Pitch) — A/D strafen hier entlang, ohne
/// dass der Blick nach oben/unten die Bewegung kippt.
fn right(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
[cy, 0.0, -sy]
}
/// Bewegung integrieren. `fwd`/`right`/`up` je in [-1, 1] (Tastenachsen),
/// `dist` ist die Strecke diesen Frame (Speed · dt). Hoch/runter ist
/// Welt-Y, damit Auf-/Absteigen unabhängig vom Blickwinkel bleibt.
pub fn translate(&mut self, fwd: f32, right: f32, up: f32, dist: f32) {
let f = self.forward();
let r = self.right();
for i in 0..3 {
self.pos[i] += (f[i] * fwd + r[i] * right) * dist;
}
self.pos[1] += up * dist;
}
pub fn view(&self) -> Mat4 {
Mat4::view(self.pos, self.yaw, self.pitch)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Die Bewegungs-Basis (`forward`) muss exakt zu der Richtung passen,
/// die `view()` als „vor der Kamera" definiert — sonst driften
/// Steuerung und Bild auseinander. Ein Punkt `d` vor der Kamera muss
/// in View-Space auf (0, 0, -d) landen.
#[test]
fn forward_matches_view() {
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
let c = Camera { pos: [1.0, 2.0, 3.0], yaw, pitch };
let f = c.forward();
let ahead = [
c.pos[0] + f[0] * 5.0,
c.pos[1] + f[1] * 5.0,
c.pos[2] + f[2] * 5.0,
1.0,
];
let v = c.view().transform(ahead);
assert!(
(v[0]).abs() < 1e-4 && (v[1]).abs() < 1e-4 && (v[2] + 5.0).abs() < 1e-4,
"yaw={yaw} pitch={pitch}: {v:?}",
);
}
}
/// `right` muss horizontal sein und im View-Space nach +X zeigen
/// (sonst wären A/D vertauscht oder gekippt).
#[test]
fn right_is_horizontal_and_points_view_right() {
let c = Camera { pos: [0.0, 0.0, 0.0], yaw: 0.6, pitch: 0.3 };
let r = c.right();
assert!(r[1].abs() < 1e-6, "right darf kein Y haben: {r:?}");
let p = [r[0] * 5.0, r[1] * 5.0, r[2] * 5.0, 1.0];
let v = c.view().transform(p);
assert!(v[0] > 4.0, "right sollte nach View-+X zeigen: {v:?}");
}
}
+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;
}
+99 -64
View File
@@ -1,10 +1,12 @@
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad //! wgpu-Zustand: Surface, Device und der interne Render-Pfad:
//! aus dem Renderer-Plan:
//! //!
//! Pass 1 (intern): 640×480 RGBA8 + Depth — hier entsteht das Bild //! Szene (intern): 640×480 RGBA8 + Depth — Geometrie und Textur
//! ([`ScenePass`], PS1-Shader). //! ([`ScenePass`]).
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit //! Post (intern): die Effekt-Kette ([`Post`]), Ping-Pong zwischen zwei
//! 4:3-Letterbox (via Viewport) auf die Surface. //! 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 //! 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 //! 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 winit::window::Window;
use crate::engine::post::PostChain;
use crate::engine::tga::Image; use crate::engine::tga::Image;
use crate::render::math::Mat4; use crate::render::math::Mat4;
use crate::render::post::Post;
use crate::render::scene::{Mesh, ScenePass}; use crate::render::scene::{Mesh, ScenePass};
use crate::render::sprite::SpritePass; use crate::render::sprite::SpritePass;
use crate::render::ui::Ui; use crate::render::ui::Ui;
@@ -28,19 +32,27 @@ pub const INTERNAL_H: u32 = 480;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Near-/Far-Ebene der Projektion. Auch die Post-Kette bekommt sie (als
/// Uniform), damit kein Effekt-Shader die Werte abschreiben muss, um die
/// Tiefe zu linearisieren — sonst lügen sie, sobald die Kamera sich ändert.
const NEAR_FAR: [f32; 2] = [0.1, 100.0];
pub struct Gpu { pub struct Gpu {
surface: wgpu::Surface<'static>, surface: wgpu::Surface<'static>,
device: wgpu::Device, device: wgpu::Device,
queue: wgpu::Queue, queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration, config: wgpu::SurfaceConfiguration,
internal_view: wgpu::TextureView,
depth_view: wgpu::TextureView, depth_view: wgpu::TextureView,
scene: ScenePass, scene: ScenePass,
post: Post,
sprite: SpritePass, sprite: SpritePass,
blit_pipeline: wgpu::RenderPipeline, blit_pipeline: wgpu::RenderPipeline,
blit_bind: wgpu::BindGroup, /// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`].
blit_binds: [wgpu::BindGroup; 2],
/// Nullpunkt der Shader-Zeit (Post-Uniform).
start: std::time::Instant,
} }
impl Gpu { impl Gpu {
@@ -98,19 +110,21 @@ impl Gpu {
}; };
surface.configure(&device, &config); 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 // Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
// (Decode in render::run); hier wird nur hochgeladen. // render::run); hier wird nur hochgeladen.
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images); let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
let post = Post::new(
&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view, NEAR_FAR);
// Overlay-Pass: zeichnet nach der Szene ins selbe interne Target. // Overlay: eigener Pass hinter der Kette, ohne Tiefe.
let sprite = SpritePass::new( let sprite = SpritePass::new(
&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, &device, &queue, INTERNAL_FORMAT,
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures, [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 { let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"), label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()), source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
@@ -142,25 +156,30 @@ impl Gpu {
min_filter: wgpu::FilterMode::Nearest, min_filter: wgpu::FilterMode::Nearest,
..Default::default() ..Default::default()
}); });
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor { let blit_layout = blit_pipeline.get_bind_group_layout(0);
label: Some("blit"), let blit_bind = |view: &wgpu::TextureView| {
layout: &blit_pipeline.get_bind_group_layout(0), device.create_bind_group(&wgpu::BindGroupDescriptor {
entries: &[ label: Some("blit"),
wgpu::BindGroupEntry { layout: &blit_layout,
binding: 0, entries: &[
resource: wgpu::BindingResource::TextureView(&internal_view), wgpu::BindGroupEntry {
}, binding: 0,
wgpu::BindGroupEntry { resource: wgpu::BindingResource::TextureView(view),
binding: 1, },
resource: wgpu::BindingResource::Sampler(&sampler), wgpu::BindGroupEntry {
}, binding: 1,
], resource: wgpu::BindingResource::Sampler(&sampler),
}); },
],
})
};
let blit_binds = [blit_bind(post.view(0)), blit_bind(post.view(1))];
Self { Self {
surface, device, queue, config, surface, device, queue, config,
internal_view, depth_view, depth_view,
scene, sprite, blit_pipeline, blit_bind, scene, post, sprite, blit_pipeline, blit_binds,
start: std::time::Instant::now(),
} }
} }
@@ -179,17 +198,18 @@ impl Gpu {
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)] [ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
} }
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer (Flycam); /// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer;
/// die Projektion gehört hierher, weil sie an der internen Auflösung /// die Projektion gehört hierher, weil sie an der internen Auflösung
/// hängt. /// 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( let proj = Mat4::perspective(
60f32.to_radians(), 60f32.to_radians(),
INTERNAL_W as f32 / INTERNAL_H as f32, INTERNAL_W as f32 / INTERNAL_H as f32,
0.1, 100.0, NEAR_FAR[0], NEAR_FAR[1],
); );
let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5]; let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5];
self.scene.prepare(&self.queue, &proj.mul(view), half_res); self.scene.prepare(&self.queue, &proj.mul(view), half_res);
self.post.prepare(&self.queue, self.start.elapsed().as_secs_f32());
self.sprite.prepare(&self.queue, &ui.verts); self.sprite.prepare(&self.queue, &ui.verts);
use wgpu::CurrentSurfaceTexture as Cst; use wgpu::CurrentSurfaceTexture as Cst;
@@ -208,19 +228,22 @@ impl Gpu {
let surface_view = frame.texture.create_view(&Default::default()); let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default()); let mut enc = self.device.create_command_encoder(&Default::default());
// Pass 1: intern. // Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
// zweite Attachment.
{ {
let [albedo, normal] = self.post.scene_targets();
let attach = |view| Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
});
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("internal"), label: Some("scene"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[attach(albedo), attach(normal)],
view: &self.internal_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view, view: &self.depth_view,
depth_ops: Some(wgpu::Operations { depth_ops: Some(wgpu::Operations {
@@ -234,11 +257,33 @@ impl Gpu {
multiview_mask: None, multiview_mask: None,
}); });
self.scene.draw(&mut pass); 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); 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 { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit"), label: Some("blit"),
@@ -260,7 +305,7 @@ impl Gpu {
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height); 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_viewport(vx, vy, vw, vh, 0.0, 1.0);
pass.set_pipeline(&self.blit_pipeline); 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); pass.draw(0..3, 0..1);
} }
@@ -269,31 +314,21 @@ impl Gpu {
} }
} }
fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) { fn make_depth_target(device: &wgpu::Device) -> 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: &[],
});
let depth = device.create_texture(&wgpu::TextureDescriptor { let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal depth"), label: Some("internal depth"),
size, size: wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
},
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT, format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, // TEXTURE_BINDING: die Post-Kette liest die Tiefe (Outline, Nebel).
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[], 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. /// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.
+28 -8
View File
@@ -1,10 +1,8 @@
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt //! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective. //! statt glam: nötig sind nur Multiply, View und Perspective.
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
//! ist nur der GPU-Pfad.)
//! //!
//! Konventionen: //! Konventionen:
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export) //! - rechtshändig, Kamera blickt -Z, +Y oben (wie der Blender-Export)
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`, //! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
//! bytemuck-kompatibel zu WGSL `mat4x4f` //! bytemuck-kompatibel zu WGSL `mat4x4f`
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1]) //! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
@@ -67,8 +65,7 @@ impl Mat4 {
} }
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch /// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z, /// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick.
/// positiver Pitch hebt den Blick.
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 { pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
Mat4::rot_x(-pitch) Mat4::rot_x(-pitch)
.mul(&Mat4::rot_y(-yaw)) .mul(&Mat4::rot_y(-yaw))
@@ -104,8 +101,7 @@ mod tests {
#[test] #[test]
fn view_yaw_quarter_turn_looks_minus_x() { fn view_yaw_quarter_turn_looks_minus_x() {
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf // yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera.
// -X liegt dann vor der Kamera.
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0); let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0])); assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
} }
@@ -116,6 +112,30 @@ mod tests {
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0])); assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
} }
/// Die Bewegungs-Basis des Players muss exakt zu der Richtung passen, die
/// `view` als „vor der Kamera" definiert — sonst driften Steuerung und Bild
/// auseinander. Ein Punkt `d` voraus muss im View-Space auf (0, 0, -d)
/// landen, `right_dir` auf +X und ohne Y-Anteil.
#[test]
fn view_matches_player_basis() {
use crate::engine::player::Player;
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
let mut p = Player::new([1.0, 2.0, 3.0]);
(p.yaw, p.pitch) = (yaw, pitch);
let v = Mat4::view(p.pos, p.yaw, p.pitch);
let f = p.look_dir();
let ahead = [p.pos[0] + f[0] * 5.0, p.pos[1] + f[1] * 5.0, p.pos[2] + f[2] * 5.0, 1.0];
assert!(close(v.transform(ahead), [0.0, 0.0, -5.0, 1.0]),
"look_dir bei yaw={yaw} pitch={pitch}: {:?}", v.transform(ahead));
let r = p.right_dir();
assert!(r[1].abs() < 1e-6, "right_dir darf kein Y haben: {r:?}");
let side = v.transform([p.pos[0] + r[0] * 5.0, p.pos[1], p.pos[2] + r[2] * 5.0, 1.0]);
assert!(side[0] > 4.0, "right_dir sollte nach View-+X zeigen: {side:?}");
}
}
#[test] #[test]
fn perspective_maps_near_far_to_0_1() { fn perspective_maps_near_far_to_0_1() {
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0); let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
+43 -78
View File
@@ -1,24 +1,18 @@
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer. //! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
//! //!
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
//! Als Nächstes: OBJ-Szene (Schritt 5).
//!
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie, //! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und //! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die //! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen //! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
//! anderen Zustand als die Konsole.
//! //!
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert //! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt. //! deren Schnittstellen — nie umgekehrt.
mod audio; mod audio;
mod camera;
mod font; mod font;
mod gpu; mod gpu;
mod math; mod math;
mod post;
mod props; mod props;
mod scene; mod scene;
mod sprite; mod sprite;
@@ -39,33 +33,28 @@ use crate::engine::player;
use crate::engine::tga::Image; use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga, wav}; use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session}; use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu; use gpu::Gpu;
use math::Mat4; use math::Mat4;
use scene::Mesh; use scene::Mesh;
/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
/// Welt-Einheiten pro Sekunde.
const MOVE_SPEED: f32 = 4.0;
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet /// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu /// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
/// folgen (wie Source/HL2). Größer = weicher, aber träger. /// folgen. Größer = weicher, aber träger.
const EYE_SMOOTH_TAU: f32 = 0.06; const EYE_SMOOTH_TAU: f32 = 0.06;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units). /// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6; const EYE_MAX_LAG: f32 = 0.6;
pub fn run(mut session: Session) { 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, on) in post::effects() { session.post.register(name, on); }
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen // Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert). // Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); } for line in session.start() { println!("{line}"); }
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen // Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier,
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben // Upload (GPU) später in `Gpu` — die beiden bleiben getrennt.
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
// nie fatal.
let models = load_models(&assets::path("assets/maps/props")); let models = load_models(&assets::path("assets/maps/props"));
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes // Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
@@ -98,11 +87,10 @@ pub fn run(mut session: Session) {
} }
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA, // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und // FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der // und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
// Luminanz. // Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie, // Installation kaputt und es darf scheitern.
// ist die Installation kaputt, da darf es scheitern.
let load_keyed = |p: &str| { let load_keyed = |p: &str| {
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)")) ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
}; };
@@ -168,8 +156,8 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum(); let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties", println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
m.objects.len(), m.empties.len()); m.objects.len(), m.empties.len());
// Custom Properties sichtbar machen — noch konsumiert sie // Properties mitloggen: so sieht die Autorin, ob ihr Export
// niemand, aber die Autorin sieht so, dass sie ankommen. // `collide`, `signal` und `role` mitgenommen hat.
for o in &m.objects { for o in &m.objects {
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); } for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
} }
@@ -185,9 +173,6 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
models models
} }
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content — /// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz. /// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> { fn load_sound(name: &str) -> Option<audio::Sound> {
@@ -238,8 +223,6 @@ struct App {
gpu: Option<Gpu>, gpu: Option<Gpu>,
session: Session, session: Session,
rx: Receiver<String>, rx: Receiver<String>,
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
camera: Camera,
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing. /// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32, eye_y: f32,
input: Input, input: Input,
@@ -271,15 +254,12 @@ impl App {
fonts: font::Fonts, fonts: font::Fonts,
audio: Option<audio::Audio>, audio: Option<audio::Audio>,
) -> Self { ) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT; let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self { Self {
window: None, window: None,
gpu: None, gpu: None,
session, session,
rx, rx,
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
camera: Camera::new([0.0, 1.0, 4.0]),
eye_y, eye_y,
input: Input::default(), input: Input::default(),
last: None, last: None,
@@ -296,7 +276,7 @@ impl App {
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) — // Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die // wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
// Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI). // Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
fn set_grab(&mut self, grab: bool) { fn set_grab(&mut self, grab: bool) {
let Some(window) = &self.window else { return; }; let Some(window) = &self.window else { return; };
if grab { if grab {
@@ -366,7 +346,7 @@ impl ApplicationHandler for App {
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => { WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
// Klick nur vormerken; der nächste redraw entscheidet (mit der // Klick nur vormerken; der nächste redraw entscheidet (mit der
// frisch berechneten Hover-Aktion) zwischen UI-Klick und // frisch berechneten Hover-Aktion) zwischen UI-Klick und
// Flycam-Greifen. // Maus-Greifen.
self.pending_click = true; self.pending_click = true;
} }
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key), WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
@@ -404,7 +384,7 @@ impl App {
KeyCode::KeyS => self.input.back = down, KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = down, KeyCode::KeyA => self.input.left = down,
KeyCode::KeyD => self.input.right = down, KeyCode::KeyD => self.input.right = down,
// Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke, // Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste. // nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
KeyCode::Space => { KeyCode::Space => {
self.input.up = down; self.input.up = down;
@@ -416,38 +396,28 @@ impl App {
} }
fn redraw(&mut self, event_loop: &ActiveEventLoop) { fn redraw(&mut self, event_loop: &ActiveEventLoop) {
// dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
// während die Konsole den Dialog treibt.
let now = Instant::now(); let now = Instant::now();
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32()); let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen // Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So // (relativer Blick), Menü/Dialog geben sie frei fürs UI.
// gibt es keine parallele Lock-Logik.
let fly = matches!(self.session.mode, Mode::Free); let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::FirstPerson); let play = matches!(self.session.mode, Mode::FirstPerson);
let grab = fly || play; let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); } if grab != self.input.grabbed { self.set_grab(grab); }
if fly { // Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt. // die Session an ihrem Modus. Betretens-Zonen melden ihre
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS); // Signal-Ausgaben wie exec-Befehle.
let fwd = axis(self.input.fwd, self.input.back); let input = FrameInput {
let right = axis(self.input.right, self.input.left); fwd: axis(self.input.fwd, self.input.back),
let up = axis(self.input.up, self.input.down); right: axis(self.input.right, self.input.left),
self.camera.translate(fwd, right, up, MOVE_SPEED * dt); up: axis(self.input.up, self.input.down),
} else if play { jump: self.input.jump,
// Eingaben einsammeln und die Simulation in der Session treiben. look_dx: self.input.mouse_dx,
let input = FrameInput { look_dy: self.input.mouse_dy,
fwd: axis(self.input.fwd, self.input.back), };
right: axis(self.input.right, self.input.left), for out in self.session.tick(&input, dt) { println!("{out}"); }
jump: self.input.jump,
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
// Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle.
for out in self.session.tick(&input, dt) { println!("{out}"); }
}
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der // Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
// Blick beim Fortsetzen um die aufgestaute Bewegung. // Blick beim Fortsetzen um die aufgestaute Bewegung.
self.input.mouse_dx = 0.0; self.input.mouse_dx = 0.0;
@@ -467,11 +437,8 @@ impl App {
} }
} }
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf // Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu // der Luft und außerhalb des Spiels folgt sie exakt.
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT; let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
if play && self.session.player.grounded { if play && self.session.player.grounded {
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp(); let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
@@ -481,16 +448,12 @@ impl App {
self.eye_y = target_eye; self.eye_y = target_eye;
} }
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles // Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players // nur die Sim ein, nicht die Sicht.
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die let p = self.session.player.pos;
// Sicht bleibt die First-Person-Sicht. let view = Mat4::view(
let view = if fly { [p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch);
self.camera.view()
} else {
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
};
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf // Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und // Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
// löst beim Klick unten das `use` aus. // löst beim Klick unten das `use` aus.
@@ -530,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);
}
} }
} }
+42
View File
@@ -0,0 +1,42 @@
// Post-Effekt „normals": zeigt den G-Buffer statt des Bildes, in drei
// waagerechten Bändern — Normale, Objektnummer, Tiefe. Standardmäßig aus;
// `post normals on` schaltet ihn zu.
//
// Kein Debug-Beiwerk, sondern die Probe aufs Exempel: Cel-Ramp und Outline
// rechnen aus genau diesen Puffern. Sieht das Bild hier falsch aus, liegt der
// Fehler im Loader oder im Szenen-Pass, nicht im Effekt.
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@group(1) @binding(1) var gdepth: texture_depth_2d;
struct Uniforms {
inv_res: vec2f,
near: f32,
far: f32,
time: f32,
};
@group(2) @binding(0) var<uniform> u: Uniforms;
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2i(pos.xy);
let band = i32(textureDimensions(gnormal).y) / 3;
let g = textureLoad(gnormal, px, 0);
if px.y < band {
// Kodierung rückgängig: [0,1] → [-1,1] → wieder in [0,1] zum Ansehen.
let n = g.xyz * 2.0 - 1.0;
return vec4f(n * 0.5 + 0.5, 1.0);
}
if px.y < 2 * band {
// Objektnummer: benachbarte Nummern sollen *verschieden aussehen*,
// deshalb drei teilerfremde Faktoren statt eines Grauverlaufs.
let id = g.w * 255.0;
return vec4f(fract(id * vec3f(0.13, 0.37, 0.71)), 1.0);
}
// Tiefe ist perspektivisch verteilt — linearisieren, sonst ist ab zwei
// Metern alles weiß. Near/Far kommen aus dem Uniform, nicht aus dem
// Shader: sie gehören zur Projektion des Renderers.
let z = textureLoad(gdepth, px, 0);
let lin = u.near * u.far / (u.far - z * (u.far - u.near));
return vec4f(vec3f(1.0 - lin / u.far), 1.0);
}
+145
View File
@@ -0,0 +1,145 @@
// Post-Effekt „outline": Kantenlinien aus dem G-Buffer, nicht aus dem Bild.
//
// Auf dem fertigen Bild zu suchen wäre falsch — der Dither zerlegt Flächen in
// Muster, und jede Texturkante wäre eine Kante. Gesucht sind Kanten der
// *Geometrie*, also drei unabhängige Signale:
//
// Tiefe — Sprünge in der Entfernung (Silhouetten, Vorder-/Hintergrund)
// Normale — Knicke in der Fläche (Zimmerecken, Kanten eines Würfels)
// ID — Objektgrenzen, auch wenn Tiefe *und* Normale gleich sind
// (Bild an der Wand, Teppich auf dem Boden)
//
// Jedes für sich reicht; genommen wird das stärkste. Tiefe und Normale laufen
// über einen 3×3-Sobel, die ID über einen simplen Vergleich mit den vier
// direkten Nachbarn — auf Ganzzahlen ist ein Gradient sinnlos.
@group(0) @binding(0) var src: texture_2d<f32>;
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@group(1) @binding(1) var gdepth: texture_depth_2d;
struct Uniforms {
inv_res: vec2f,
near: f32,
far: f32,
time: f32,
};
@group(2) @binding(0) var<uniform> u: Uniforms;
// --- Regler ------------------------------------------------------------
// Beide Schwellen sind auf „Sobel geteilt durch 4" normiert, also grob:
// 1.0 = maximal möglicher Unterschied zwischen zwei Nachbarn.
/// Tiefenstufe *relativ zur Entfernung* — relativ, damit eine Kante in 20 m
/// genauso zählt wie dieselbe Kante in 2 m. Das schwächste der drei Signale:
/// flach gesehene Böden ändern ihre Tiefe von Pixel zu Pixel stark, ohne dass
/// dort eine Kante wäre. Zu niedrig → der Boden bekommt Streifen.
const DEPTH_T: f32 = 0.10;
/// Knick in der Normale. Ein rechter Winkel liegt bei ~1.4, eine sanfte
/// Wölbung weit darunter. Zu niedrig → Suzanne wird schraffiert.
const NORMAL_T: f32 = 0.40;
/// Strichstärke in Pixeln: Abstand, in dem der Kernel abtastet. 1 = dünnste
/// Linie, 2–3 wird deutlich. Die Gradienten werden durch den Abstand geteilt,
/// damit die beiden Schwellen oben ihre Bedeutung behalten und man nicht bei
/// jeder Breitenänderung neu justieren muss.
///
/// Verbreitert wird über den Abtastabstand, nicht durch Aufdicken der
/// gefundenen Linie: das kostet keinen einzigen zusätzlichen Texturzugriff.
/// Der Preis ist, dass Details, die schmaler als die Strichstärke sind, eine
/// doppelte Linie bekommen können — bei 1–3 Pixeln kein Thema.
const WIDTH: i32 = 2;
/// Farbe der Linie und wie stark sie deckt (1.0 = voll).
const LINE: vec3f = vec3f(0.0);
const STRENGTH: f32 = 1.0;
/// Objektgrenzen mitzeichnen. Aus wirkt es wie klassisches Cel-Shading
/// (nur Silhouetten und Knicke), an eher wie ein Comic-Panel, in dem jedes
/// Ding umrandet ist.
const USE_ID: bool = true;
// --- G-Buffer-Zugriff --------------------------------------------------
/// Tiefe in Metern statt im perspektivisch verteilten [0,1] des Puffers.
fn linear_depth(px: vec2i) -> f32 {
let z = textureLoad(gdepth, px, 0);
return u.near * u.far / (u.far - z * (u.far - u.near));
}
fn normal_at(px: vec2i) -> vec3f {
return textureLoad(gnormal, px, 0).xyz * 2.0 - 1.0;
}
fn id_at(px: vec2i) -> f32 {
return textureLoad(gnormal, px, 0).w;
}
// --- Kantensuche -------------------------------------------------------
/// Sobel-Betrag über die 3×3-Nachbarschaft, auf ~0..1 normiert (geteilt
/// durch 4, die Gewichtssumme einer Kernelhälfte).
fn sobel_depth(px: vec2i) -> f32 {
var gx = 0.0;
var gy = 0.0;
// Sobel-Gewichte, Zeile für Zeile: [-1 0 1] × [1 2 1].
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
let v = linear_depth(px + vec2i(dx, dy) * WIDTH);
let wx = f32(dx) * select(1.0, 2.0, dy == 0);
let wy = f32(dy) * select(1.0, 2.0, dx == 0);
gx += v * wx;
gy += v * wy;
}
}
return sqrt(gx * gx + gy * gy) / (4.0 * f32(WIDTH));
}
/// Dasselbe für die Normale: jede Komponente einzeln gefiltert, am Ende die
/// Länge über beide Richtungen.
fn sobel_normal(px: vec2i) -> f32 {
var gx = vec3f(0.0);
var gy = vec3f(0.0);
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
let v = normal_at(px + vec2i(dx, dy) * WIDTH);
let wx = f32(dx) * select(1.0, 2.0, dy == 0);
let wy = f32(dy) * select(1.0, 2.0, dx == 0);
gx += v * wx;
gy += v * wy;
}
}
return sqrt(dot(gx, gx) + dot(gy, gy)) / (4.0 * f32(WIDTH));
}
/// 1.0, sobald einer der vier direkten Nachbarn zu einem anderen Objekt
/// gehört. Nur ganze Pixel, keine Gradienten.
fn id_edge(px: vec2i) -> f32 {
let c = id_at(px);
let d = abs(id_at(px + vec2i( WIDTH, 0)) - c)
+ abs(id_at(px + vec2i(-WIDTH, 0)) - c)
+ abs(id_at(px + vec2i( 0, WIDTH)) - c)
+ abs(id_at(px + vec2i( 0,-WIDTH)) - c);
// Eine Nummer Unterschied sind 1/255 — alles darüber ist eine Grenze.
return select(0.0, 1.0, d > 0.5 / 255.0);
}
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2i(pos.xy);
let color = textureLoad(src, px, 0);
// Auf dem Hintergrund (nichts gezeichnet) keine Linie: sonst läge die
// Silhouette doppelt, je einmal innen und außen.
if id_at(px) == 0.0 { return color; }
let depth = sobel_depth(px) / linear_depth(px);
var edge = max(
step(DEPTH_T, depth),
step(NORMAL_T, sobel_normal(px)),
);
if USE_ID { edge = max(edge, id_edge(px)); }
return vec4f(mix(color.rgb, LINE, edge * STRENGTH), color.a);
}
+306
View File
@@ -0,0 +1,306 @@
//! 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:
//!
//! group 0, binding 0: Farbe des vorherigen Kettenglieds
//! group 1, binding 0: Normale (`n*0.5+0.5`, w = Objekt-ID, reserviert)
//! group 1, binding 1: Tiefe
//! group 2, binding 0: [`Uniforms`] — Auflösung, Near/Far, Zeit
//!
//! Group 1 ist der G-Buffer aus dem Szenen-Pass, group 2 das, was jeder
//! Effekt über den Frame wissen kann; beide darf ein Effekt ignorieren. 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, mit ihrem Startzustand. `dither`
/// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
const EFFECTS: &[(&str, bool, &str)] = &[
("normals", false, include_str!("normals.wgsl")),
("outline", true, include_str!("outline.wgsl")),
("dither", true, include_str!("dither.wgsl")),
];
/// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der
/// Session an.
pub fn effects() -> impl Iterator<Item = (&'static str, bool)> {
EFFECTS.iter().map(|(n, on, _)| (*n, *on))
}
/// Spiegelt `Uniforms` in den Effekt-Shadern. `near`/`far` müssen zur
/// Projektion des Aufrufers passen — sie kommen von dort, damit kein Shader
/// sie abschreibt und beim nächsten Ändern der Kamera stillschweigend lügt.
/// `_pad` rundet auf 32 Byte (16er-Vielfaches, WGSL-Uniform-Regel).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniforms {
inv_res: [f32; 2],
near: f32,
far: f32,
/// Sekunden seit Start — für animierte Effekte (Flackern, Wasser).
time: f32,
_pad: [f32; 3],
}
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],
/// Zweites Ziel des Szenen-Passes (Normale + ID).
normal_view: wgpu::TextureView,
/// Eingangs-Bind-Group je Target (group 0).
in_binds: [wgpu::BindGroup; 2],
/// Normale + Tiefe (group 1) — für alle Effekte dieselbe.
gbuf_bind: wgpu::BindGroup,
/// Frame-Uniforms (group 2), jeden Frame per [`Post::prepare`] gefüllt.
ubuf: wgpu::Buffer,
uniform_bind: wgpu::BindGroup,
inv_res: [f32; 2],
near_far: [f32; 2],
pipelines: Vec<(&'static str, wgpu::RenderPipeline)>,
}
impl Post {
pub fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
size: [u32; 2],
depth_view: &wgpu::TextureView,
near_far: [f32; 2],
) -> Self {
let views = [make_target(device, format, size, "post a"),
make_target(device, format, size, "post b")];
let normal_view = make_target(device, format, size, "gbuffer normal");
// 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])];
// Der G-Buffer. Beide Texturen werden per `textureLoad` gelesen
// (1:1, kein Sampler) — deshalb steht hier keiner.
let gbuf_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post gbuffer"),
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,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let gbuf_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post gbuffer"),
layout: &gbuf_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&normal_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
],
});
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("post uniforms"),
size: size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post uniforms"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post uniforms"),
layout: &uniform_layout,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: ubuf.as_entire_binding() }],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("post"),
bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout), Some(&uniform_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, normal_view, in_binds, gbuf_bind,
ubuf, uniform_bind,
inv_res: [1.0 / size[0] as f32, 1.0 / size[1] as f32],
near_far,
pipelines,
}
}
/// Frame-Uniforms hochladen — vor [`Post::run`] rufen.
pub fn prepare(&self, queue: &wgpu::Queue, time: f32) {
let u = Uniforms {
inv_res: self.inv_res,
near: self.near_far[0],
far: self.near_far[1],
time,
_pad: [0.0; 3],
};
queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(&u));
}
/// Die Ziele des Szenen-Passes: Albedo (= Anfang der Kette) und Normale.
pub fn scene_targets(&self) -> [&wgpu::TextureView; 2] {
[&self.views[0], &self.normal_view]
}
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.set_bind_group(1, &self.gbuf_bind, &[]);
pass.set_bind_group(2, &self.uniform_bind, &[]);
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),
}],
})
}
+30 -4
View File
@@ -16,7 +16,7 @@
use crate::engine::model::Model; use crate::engine::model::Model;
use crate::render::scene::{Batch, Mesh, Vertex}; use crate::render::scene::{Batch, Mesh, Vertex};
/// Texturname für Faces ohne `usemtl` (Material `""`). /// Texturname für Faces ohne Material (`""`).
pub const FALLBACK_TEXTURE: &str = "placeholder"; pub const FALLBACK_TEXTURE: &str = "placeholder";
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den /// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
@@ -41,12 +41,17 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
let mut verts = Vec::new(); let mut verts = Vec::new();
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()]; let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
// Laufende Objektnummer für den G-Buffer; nur sichtbare Objekte zählen,
// unsichtbare Collider tauchen im Bild ja nie auf. Ab 1, damit die 0 im
// Buffer „kein Objekt" heißt (der Hintergrund wird auf 0 geleert).
let mut id = 1;
for o in &model.objects { for o in &model.objects {
if !o.visible { continue; } if !o.visible { continue; }
let base = verts.len() as u32; let base = verts.len() as u32;
for (p, uv) in o.verts.iter().zip(&o.uvs) { for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
verts.push(Vertex { pos: *p, uv: *uv }); verts.push(Vertex { pos: *p, uv: *uv, normal: *n, id });
} }
id += 1;
for (t, &m) in o.tris.iter().zip(&o.tri_mats) { for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]); let name = resolve(&model.materials[m]);
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; }; let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
@@ -62,7 +67,7 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 }); batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
indices.extend_from_slice(&list); indices.extend_from_slice(&list);
} }
Mesh { verts, indices, batches } Mesh { verts, indices, batches, objects: id - 1 }
} }
#[cfg(test)] #[cfg(test)]
@@ -76,6 +81,7 @@ mod tests {
name: name.into(), name: name.into(),
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
normals: vec![[0.0, 0.0, 1.0]; 3],
tris: vec![[0, 1, 2]], tris: vec![[0, 1, 2]],
tri_mats: vec![0], tri_mats: vec![0],
visible, collider: !visible, visible, collider: !visible,
@@ -106,6 +112,26 @@ mod tests {
assert_eq!(mesh.verts[2].uv, [1.0, 1.0]); assert_eq!(mesh.verts[2].uv, [1.0, 1.0]);
} }
#[test]
fn visible_objects_get_distinct_ids_across_models() {
let names = texture_names(&sample());
// Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell.
let mut m = sample();
m.objects.insert(1, tri("Zweites", true));
let mut mesh = build(&m, &names);
assert_eq!(mesh.objects, 2, "nur sichtbare zählen");
assert_eq!(mesh.verts[0].id, 1, "0 bleibt dem Hintergrund vorbehalten");
assert_eq!(mesh.verts[3].id, 2, "zweites Objekt, nächste Nummer");
// Zweites Modell anhängen: seine Nummern liegen über den bisherigen,
// sonst hielte der Outline-Pass Objekte aus zwei .glb für dasselbe.
mesh.append(build(&m, &names));
assert_eq!(mesh.objects, 4);
assert_eq!(mesh.verts[6].id, 3);
assert_eq!(mesh.verts[9].id, 4);
}
#[test] #[test]
fn missing_material_maps_to_fallback() { fn missing_material_maps_to_fallback() {
let mut m = sample(); let mut m = sample();
+41 -13
View File
@@ -13,8 +13,16 @@ use crate::render::math::Mat4;
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct Vertex { pub(crate) struct Vertex {
pub(crate) pos: [f32; 3], pub(crate) pos: [f32; 3],
pub(crate) uv: [f32; 2], pub(crate) uv: [f32; 2],
/// Welt-Normale, normiert — Eingang des G-Buffers (siehe render::post).
pub(crate) normal: [f32; 3],
/// Laufende Objektnummer. Der Szenen-Pass legt sie (mod 256) ins Alpha
/// des Normal-Buffers, damit der Outline-Pass Kanten zwischen zwei
/// Objekten findet, die sich berühren und gleich ausgerichtet sind —
/// Stuhl auf Boden, Bild an Wand. Benachbarte Nummern unterscheiden sich
/// immer; erst 256 Objekte auseinander fallen zwei zusammen.
pub(crate) id: u32,
} }
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer* /// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
@@ -33,6 +41,10 @@ pub(crate) struct Mesh {
pub(crate) verts: Vec<Vertex>, pub(crate) verts: Vec<Vertex>,
pub(crate) indices: Vec<u32>, pub(crate) indices: Vec<u32>,
pub(crate) batches: Vec<Batch>, pub(crate) batches: Vec<Batch>,
/// Vergebene Objektnummern (siehe [`Vertex::id`]) — [`Mesh::append`]
/// verschiebt die des Anhängsels darüber, damit Objekte aus verschiedenen
/// Modellen nicht dieselbe Nummer tragen.
pub(crate) objects: u32,
} }
impl Mesh { impl Mesh {
@@ -43,7 +55,12 @@ impl Mesh {
pub(crate) fn append(&mut self, other: Mesh) { pub(crate) fn append(&mut self, other: Mesh) {
let vbase = self.verts.len() as u32; let vbase = self.verts.len() as u32;
let ibase = self.indices.len() as u32; let ibase = self.indices.len() as u32;
self.verts.extend(other.verts); let idbase = self.objects;
self.objects += other.objects;
self.verts.extend(other.verts.into_iter().map(|v| Vertex {
id: v.id + idbase,
..v
}));
self.indices.extend(other.indices.iter().map(|i| i + vbase)); self.indices.extend(other.indices.iter().map(|i| i + vbase));
self.batches.extend(other.batches.iter().map(|b| Batch { self.batches.extend(other.batches.iter().map(|b| Batch {
texture: b.texture, texture: b.texture,
@@ -67,7 +84,9 @@ struct Uniforms {
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64, array_stride: size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex, step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2], attributes: &wgpu::vertex_attr_array![
0 => Float32x3, 1 => Float32x2, 2 => Float32x3, 3 => Uint32,
],
}; };
pub struct ScenePass { pub struct ScenePass {
@@ -106,7 +125,10 @@ impl ScenePass {
module: &shader, module: &shader,
entry_point: Some("fs_main"), entry_point: Some("fs_main"),
compilation_options: Default::default(), compilation_options: Default::default(),
targets: &[Some(color_format.into())], // Zwei Ziele (MRT): Albedo und der Normal-/ID-Buffer. Beide
// im internen Format; zusammen sind sie der G-Buffer, aus dem
// die Post-Kette Beleuchtung und Kanten rechnet.
targets: &[Some(color_format.into()), Some(color_format.into())],
}), }),
// Back-face Culling: glTF-Dreiecke sind CCW von außen // Back-face Culling: glTF-Dreiecke sind CCW von außen
// gewickelt — die Außenflächen sind front-facing, Rückseiten // gewickelt — die Außenflächen sind front-facing, Rückseiten
@@ -168,7 +190,7 @@ impl ScenePass {
}); });
let tex_layout = pipeline.get_bind_group_layout(1); let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = images.iter() let tex_binds = images.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img)) .map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "scene texture", true))
.collect(); .collect();
Self { Self {
@@ -196,20 +218,26 @@ impl ScenePass {
} }
} }
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige /// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle /// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch. /// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
fn upload_texture( /// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
pub(crate) fn upload_texture(
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout, layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler, sampler: &wgpu::Sampler,
img: &Image, img: &Image,
label: &str,
mips: bool,
) -> wgpu::BindGroup { ) -> wgpu::BindGroup {
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene. // floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
let mip_level_count = 32 - img.width.max(img.height).leading_zeros(); let mip_level_count = match mips {
true => 32 - img.width.max(img.height).leading_zeros(),
false => 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor { let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene texture"), label: Some(label),
size: wgpu::Extent3d { size: wgpu::Extent3d {
width: img.width, width: img.width,
height: img.height, height: img.height,
@@ -251,7 +279,7 @@ fn upload_texture(
let view = texture.create_view(&Default::default()); let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor { device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene texture"), label: Some(label),
layout, layout,
entries: &[ entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
+37 -25
View File
@@ -1,13 +1,14 @@
// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch // Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
// gerechnet statt nachgestellt: // dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter // Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader // render::post. Der Pixel-Snap im Vertex-Shader (PS1-Vertex-Jitter) gehört
// - 1-Bit-Alpha-Test (Cutouts), kein Blending // dagegen hierher, weil er Geometrie ist.
//
// 1-Bit-Alpha-Test (Cutouts), kein Blending.
// //
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das // UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer // klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen // frei beweglichen Kamera und großen Faces zu störend.
// Pixel-Snap und Dither.
struct Uniforms { struct Uniforms {
mvp: mat4x4f, mvp: mat4x4f,
@@ -24,10 +25,27 @@ struct VsOut {
@builtin(position) pos: vec4f, @builtin(position) pos: vec4f,
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln. // perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
@location(0) @interpolate(perspective) uv: vec2f, @location(0) @interpolate(perspective) uv: vec2f,
@location(1) normal: vec3f,
// Ganzzahl → flat; interpolieren würde zwischen zwei Objektnummern
// Zwischenwerte erfinden.
@location(2) @interpolate(flat) id: u32,
};
struct FsOut {
@location(0) albedo: vec4f,
// xyz = Normale, aus [-1,1] in [0,1] geschoben (das Ziel ist unsigned).
// w = Objektnummer mod 256, damit der Outline-Pass auch Kanten zwischen
// gleich ausgerichteten Nachbarn findet.
@location(1) normal: vec4f,
}; };
@vertex @vertex
fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut { fn vs_main(
@location(0) pos: vec3f,
@location(1) uv: vec2f,
@location(2) normal: vec3f,
@location(3) id: u32,
) -> VsOut {
var clip = u.mvp * vec4f(pos, 1.0); var clip = u.mvp * vec4f(pos, 1.0);
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und // Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde // zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
@@ -39,20 +57,11 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
var out: VsOut; var out: VsOut;
out.pos = clip; out.pos = clip;
out.uv = uv; out.uv = uv;
out.normal = normal;
out.id = id;
return out; 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 // LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher. // aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr // 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
@@ -60,13 +69,16 @@ fn bayer4(px: vec2u) -> f32 {
const MIP_BIAS: f32 = -1.0; const MIP_BIAS: f32 = -1.0;
@fragment @fragment
fn fs_main(in: VsOut) -> @location(0) vec4f { fn fs_main(in: VsOut) -> FsOut {
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS); let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden → var out: FsOut;
// Verläufe zerfallen ins typische Dither-Muster. out.albedo = vec4f(texel.rgb, 1.0);
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0; // Interpolieren verkürzt die Normale — vor dem Kodieren neu normieren.
let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0)); // Das Ziel ist 8 Bit pro Kanal: k/255 kommt exakt wieder heraus, aber
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0); // zwei Objekte 256 Nummern auseinander teilen sich einen Wert. Bei
// gerade ~175 Objekten in der Szene ist das Kopfrechnen, kein Problem.
out.normal = vec4f(normalize(in.normal) * 0.5 + 0.5, f32(in.id % 256u) / 255.0);
return out;
} }
+10 -58
View File
@@ -1,12 +1,17 @@
//! Sprite-Pass: das 2D-Overlay (UI) als getintete Textur-Quads. //! 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 //! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige //! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die //! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau //! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU). //! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
//! dieselbe `scene::upload_texture`, nur ohne Mipchain.
use crate::engine::tga::Image; use crate::engine::tga::Image;
use crate::render::scene::upload_texture;
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -58,7 +63,6 @@ impl SpritePass {
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
color_format: wgpu::TextureFormat, color_format: wgpu::TextureFormat,
depth_format: wgpu::TextureFormat,
internal: [f32; 2], internal: [f32; 2],
textures: &[Image], textures: &[Image],
) -> Self { ) -> Self {
@@ -87,16 +91,9 @@ impl SpritePass {
})], })],
}), }),
primitive: wgpu::PrimitiveState::default(), primitive: wgpu::PrimitiveState::default(),
// Der interne Pass hat ein Tiefen-Attachment → die Pipeline muss // Eigener Pass hinter der Post-Kette, ohne Tiefen-Attachment:
// ein kompatibles Format deklarieren. UI testet/schreibt aber // das Overlay liegt per Reihenfolge obenauf.
// keine Tiefe (Always, kein Write) → liegt immer obenauf. depth_stencil: None,
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(),
}),
multisample: wgpu::MultisampleState::default(), multisample: wgpu::MultisampleState::default(),
multiview_mask: None, multiview_mask: None,
cache: None, cache: None,
@@ -134,7 +131,7 @@ impl SpritePass {
}); });
let tex_layout = pipeline.get_bind_group_layout(1); let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = textures.iter() let tex_binds = textures.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img)) .map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "sprite texture", false))
.collect(); .collect();
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 } Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
@@ -160,48 +157,3 @@ impl SpritePass {
} }
} }
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die Bind-Group (Textur +
/// Sampler, group 1) bauen.
fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
) -> wgpu::BindGroup {
let size = wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 };
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sprite texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&img.rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(img.width * 4),
rows_per_image: Some(img.height),
},
size,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sprite texture"),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
],
})
}
+4 -4
View File
@@ -1,8 +1,8 @@
// 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln // 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln
// (Ursprung oben-links), getintet. Zeichnet ins interne Target *nach* der // (Ursprung oben-links), getintet. Läuft als letzter interner Pass, hinter
// Szene, ohne Tiefentest → liegt immer obenauf. Echtes Alpha-Blending // der Post-Kette → liegt immer obenauf und bleibt von Bild-Effekten
// (anders als der 1-Bit-Alphatest der Welt), damit Panels und Glyph-Ränder // unberührt. Echtes Alpha-Blending (anders als der 1-Bit-Alphatest der
// weich überblenden. // Welt), damit Panels und Glyph-Ränder weich überblenden.
struct Uniforms { struct Uniforms {
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
+9 -10
View File
@@ -1,12 +1,11 @@
//! UI-Overlay: **state-driven** und **immediate-mode**. //! UI-Overlay: **state-driven** und **immediate-mode**.
//! //!
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält //! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die //! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem //! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü → //! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch //! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein //! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
//! einziger Trichter in den State, kein UI-Backdoor.
//! //!
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet, //! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`]. //! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
@@ -57,7 +56,7 @@ pub(crate) fn key_luminance(img: &Image) -> Image {
pub(crate) struct Cursor { pub(crate) struct Cursor {
/// Position in internen Pixeln (über die Letterbox gemappt). /// Position in internen Pixeln (über die Letterbox gemappt).
pub(crate) pos: [f32; 2], pub(crate) pos: [f32; 2],
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt /// Maus gefangen (Spiel oder Noclip) → First-Person-Pointer mittig statt
/// Maus-Cursor an `pos`. /// Maus-Cursor an `pos`.
pub(crate) grabbed: bool, pub(crate) grabbed: bool,
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt- /// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
@@ -225,12 +224,12 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug. // 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
let f = &fonts.ega; let f = &fonts.ega;
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel. // Bei gefangener Maus zählt die freie Sicht → kein Panel.
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier // Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
// Maus (im Dialog ist die Maus ohnehin nie gefangen). // Maus (im Dialog ist die Maus ohnehin nie gefangen).
if !cur.grabbed { if !cur.grabbed {
match &session.mode { match &session.mode {
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen // Spiel und Noclip: nur HUD/Cursor, kein Panel (beide laufen
// ohnehin gefangen — dieser Zweig greift nur im Übergang). // ohnehin gefangen — dieser Zweig greift nur im Übergang).
Mode::FirstPerson | Mode::Free => {} Mode::FirstPerson | Mode::Free => {}
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover), Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
+135 -95
View File
@@ -3,22 +3,19 @@ use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget}; use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState; use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player}; use crate::engine::player::{self, Player};
use crate::engine::post::PostChain;
use crate::engine::trigger::Triggers; use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl}; use crate::engine::{kv, model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der /// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich const LOOK_SENS: f32 = 0.0025;
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in /// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und /// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen) /// (bei 1/60 geschriebenen) Player-Tests.
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
const FIXED_DT: f32 = 1.0 / 60.0; const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir /// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon /// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25; const MAX_ACCUM: f32 = 0.25;
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele. /// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
@@ -47,26 +44,30 @@ pub struct Session {
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf /// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
/// Empties, siehe engine::audio). /// Empties, siehe engine::audio).
emitters: Emitters, 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 /// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt /// zieht sie per [`Session::take_sounds`]; der CLI reicht die
/// sie liegen (ihr reicht die `[action]`-Textzeile). /// `[action]`-Textzeile.
pending_sounds: Vec<String>, pending_sounds: Vec<String>,
signals_path: String, signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep. /// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32, sim_accum: f32,
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen /// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim /// einen Frame ohne Sim-Schritt und verfällt.
/// nächsten Sub-Schritt konsumiert.
pending_jump: bool, pending_jump: bool,
} }
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro /// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*` /// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1]. /// sind rohe Maus-Pixel, `fwd`/`right`/`up` Tastenachsen in [-1, 1]; `up`
/// wirkt nur im Noclip.
#[derive(Default)] #[derive(Default)]
pub struct FrameInput { pub struct FrameInput {
pub fwd: f32, pub fwd: f32,
pub right: f32, pub right: f32,
pub up: f32,
pub jump: bool, pub jump: bool,
pub look_dx: f32, pub look_dx: f32,
pub look_dy: f32, pub look_dy: f32,
@@ -96,6 +97,7 @@ impl Session {
collision: CollisionWorld::empty(), collision: CollisionWorld::empty(),
triggers: Triggers::new(), triggers: Triggers::new(),
emitters: Emitters::new(), emitters: Emitters::new(),
post: PostChain::new(),
pending_sounds: Vec::new(), pending_sounds: Vec::new(),
signals_path, signals_path,
sim_accum: 0.0, sim_accum: 0.0,
@@ -103,13 +105,10 @@ impl Session {
} }
} }
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro /// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`- /// ist Sache von render::props): Collider aus `collide`, Trigger aus
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/ /// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der /// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
/// desselben Modells ist Sache des Renderers (render::props).
pub fn load_props(&mut self, model: &model::Model) { pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model); self.collision.add_model(model);
self.triggers.add_model(model); self.triggers.add_model(model);
@@ -140,12 +139,20 @@ impl Session {
} }
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu /// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam /// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe: /// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend /// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
/// zeigt sie wie `exec`-Ausgaben an. /// zeigt sie wie `exec`-Ausgaben an.
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> { pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); } match self.mode {
Mode::FirstPerson => {}
Mode::Free => {
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
self.player.fly(input.fwd, input.right, input.up, dt);
return Vec::new();
}
_ => return Vec::new(),
}
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau // Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der // einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
@@ -217,13 +224,13 @@ impl Session {
fn command(&mut self, line: &str) -> ExecResult { fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim(); let line = line.trim();
match line { let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
"" => ExecResult::empty(), match (verb, args.trim()) {
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true }, ("", _) => ExecResult::empty(),
"menu" => { ("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im ("menu", _) => {
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder // Menü öffnen bzw. ins Spiel zurück; „Fortsetzen" landet immer
// Free öffnet es das Menü. (Dialog wird anders geroutet.) // im Spielmodus, nie im Noclip. (Dialog wird anders geroutet.)
let t = match self.mode { let t = match self.mode {
Mode::Menu => ModeTarget::Play, Mode::Menu => ModeTarget::Play,
_ => ModeTarget::Menu, _ => ModeTarget::Menu,
@@ -231,56 +238,63 @@ impl Session {
self.set_mode(t); self.set_mode(t);
ExecResult::empty() ExecResult::empty()
} }
"noclip" => { ("help", _) => ExecResult::lines(help()),
// Debug: zwischen First-Person (Play) und freier Flycam (Free) ("kv", _) => ExecResult::lines(self.dump_kv()),
// umschalten. ("reload", _) => {
let t = match self.mode {
Mode::FirstPerson => ModeTarget::Free,
_ => ModeTarget::Play,
};
self.set_mode(t);
ExecResult::empty()
}
"help" => ExecResult::lines(help()),
"kv" => ExecResult::lines(self.dump_kv()),
"reload" => {
self.game.signals = signals::load_signals(&self.signals_path); self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![ ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()), format!("{} Signale geladen.", self.game.signals.len()),
]) ])
} }
_ => { // Modus über dieselbe Action wie das `mode`-Verb aus
if let Some(m) = line.strip_prefix("mode ") { // signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
// Modus explizit setzen — über dieselbe Action wie das ("mode", m) => match ModeTarget::parse(m) {
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und Some(t) => {
// Signals genau einen Wechsel-Pfad teilen. self.game.actions.push(Action::SetMode(t));
match ModeTarget::parse(m) { ExecResult::lines(self.drain_actions())
Some(t) => {
self.game.actions.push(Action::SetMode(t));
ExecResult::lines(self.drain_actions())
}
None => ExecResult::lines(vec![
"Modus: play | free | menu".into(),
]),
}
} else if let Some(sig) = line.strip_prefix("signal ") {
ExecResult::lines(self.fire(sig.trim(), None))
} else if let Some(name) = line.strip_prefix("use ") {
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
// hier durch): Klick-Ziele mit `signal`-Property feuern
// dieses Signal; sonst den vom Blender-Suffix befreiten
// Objektnamen. $self ist immer der volle Name.
let name = name.trim();
let key = self.triggers.signal_for(name)
.unwrap_or_else(|| signals::signal_key(name))
.to_string();
ExecResult::lines(self.fire(&key, Some(name.to_string())))
} else {
ExecResult::lines(vec![
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
])
} }
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.
// `$self` ist immer der volle Name.
("use", name) => {
let key = self.triggers.signal_for(name)
.unwrap_or_else(|| signals::signal_key(name))
.to_string();
ExecResult::lines(self.fire(&key, Some(name.to_string())))
} }
_ => ExecResult::lines(vec![
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
]),
}
}
/// `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")],
} }
} }
@@ -370,9 +384,9 @@ impl Session {
} }
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen. /// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode` /// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu` und die
/// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus /// `SetMode`-Action laufen beide hier durch. (Der Dialog-Modus entsteht
/// entsteht ausschließlich in `step` aus dem Story-Ablauf.) /// ausschließlich in `step` aus dem Story-Ablauf.)
fn set_mode(&mut self, t: ModeTarget) { fn set_mode(&mut self, t: ModeTarget) {
self.mode = match t { self.mode = match t {
ModeTarget::Play => Mode::FirstPerson, ModeTarget::Play => Mode::FirstPerson,
@@ -390,6 +404,10 @@ impl Session {
} }
} }
fn on_off(on: bool) -> &'static str {
if on { "an" } else { "aus" }
}
fn help() -> Vec<String> { fn help() -> Vec<String> {
vec![ vec![
" signal <s> Signal feuern oder Action direkt ausführen".into(), " signal <s> Signal feuern oder Action direkt ausführen".into(),
@@ -398,8 +416,8 @@ fn help() -> Vec<String> {
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(), " feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
" kv KV-Store anzeigen".into(), " kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(), " menu Menü öffnen / ins Spiel zurück".into(),
" noclip zwischen First-Person und Debug-Flycam wechseln".into(), " mode <m> Modus setzen: play | free (Noclip) | menu".into(),
" mode <m> Modus direkt setzen: play | free | menu".into(), " post [<e> on|off] Post-Effekte anzeigen / schalten".into(),
" reload signals.toml neu laden".into(), " reload signals.toml neu laden".into(),
" quit beenden".into(), " quit beenden".into(),
] ]
@@ -475,16 +493,6 @@ mod tests {
assert!(matches!(s.mode, Mode::FirstPerson)); assert!(matches!(s.mode, Mode::FirstPerson));
} }
#[test]
fn noclip_toggles_play_and_free() {
let mut s = empty_session();
s.mode = Mode::FirstPerson;
s.exec("noclip");
assert!(matches!(s.mode, Mode::Free));
s.exec("noclip");
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test] #[test]
fn fixed_timestep_is_framerate_independent() { fn fixed_timestep_is_framerate_independent() {
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine // Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
@@ -525,13 +533,27 @@ mod tests {
} }
#[test] #[test]
fn tick_only_runs_in_first_person() { fn menu_pauses_the_sim() {
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
let mut s = empty_session(); let mut s = empty_session();
s.mode = Mode::Menu; s.mode = Mode::Menu;
let before = s.player.pos; let before = s.player.pos;
s.tick(&FrameInput::default(), 0.1); s.tick(&FrameInput::default(), 0.1);
assert_eq!(s.player.pos, before, "Menü pausiert die Sim"); assert_eq!(s.player.pos, before);
}
#[test]
fn noclip_flies_the_player_without_physics() {
// Free bewegt denselben Spieler, nur ohne Welt: `up` steigt, und beim
// Zurückschalten steht er dort, wo er hingeflogen ist.
let mut s = empty_session();
s.mode = Mode::Free;
s.tick(&FrameInput { up: 1.0, ..Default::default() }, 1.0);
assert!(s.player.pos[1] > 1.5, "sollte gestiegen sein: {}", s.player.pos[1]);
assert!(!s.player.grounded);
let flown_to = s.player.pos;
s.exec("mode play");
assert_eq!(s.player.pos, flown_to, "Noclip-Ende teleportiert nicht zurück");
} }
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel, /// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
@@ -620,6 +642,24 @@ mod tests {
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt"); 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] #[test]
fn unknown_command_reports_and_stays_free() { fn unknown_command_reports_and_stays_free() {
let mut s = empty_session(); let mut s = empty_session();