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
//! sofort wieder im Kontakt steckt.
//!
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
//! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::add_model`]):
//! jedes Objekt mit `collide`-Property wird zur **konvexen Hülle** seiner
//! Vertices ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf
//! also konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
//! 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};
@@ -438,6 +434,7 @@ mod tests {
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]],
uvs: vec![[0.0, 0.0]; 4],
normals: vec![[0.0, 0.0, 1.0]; 4],
tris: vec![[0, 1, 2], [0, 2, 3]],
tri_mats: vec![0; 2],
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()`
//! konstruiert und durch die Dispatch-Pipeline gereicht.
//!
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
//! testbar und kennt keines der Subsysteme.
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
//! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
//! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
use std::collections::HashMap;
@@ -28,20 +26,18 @@ pub type Signals = HashMap<String, Vec<String>>;
pub enum Action {
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
PlaySound(String),
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
/// wählbar — der entsteht nur aus dem Story-Ablauf.
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
SetMode(ModeTarget),
}
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog-
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
/// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeTarget {
/// First-Person-Spiel.
Play,
/// Noclip-Debug-Flycam.
/// Noclip: freies Fliegen ohne Kollision.
Free,
/// Menü (pausiert die Welt, gibt die Maus frei).
Menu,
+99 -27
View File
@@ -1,30 +1,23 @@
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
//!
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
//! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/…
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die
//! Konsumenten.
//! **Empties** (Nodes ohne Mesh) als Entity-Marker. Beide kommen als
//! `props`/`empties` im Modell an; Bedeutung geben die Konsumenten.
//!
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken —
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
//!
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
//! 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 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> {
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 {
name, props,
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(),
};
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)
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)?,
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()
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
.unwrap_or("");
@@ -135,9 +135,11 @@ fn append_mesh(
});
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.uvs.push(*uv);
o.normals.push(norm(transform3(nm, *n)));
}
let idxs: Vec<usize> = match prim["indices"].as_u64() {
@@ -294,6 +296,36 @@ fn mul(a: M4, b: M4) -> M4 {
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] {
let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() {
@@ -333,14 +365,22 @@ mod tests {
out
}
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein
/// Mesh-Node mit Translation und extras, ein Empty mit extras.
fn sample_glb() -> Vec<u8> {
/// Diagonale Normale — unter ungleichmäßiger Skalierung unterscheidet
/// sie die korrekte Kofaktor-Transform von der (falschen) Matrix.
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();
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 _ 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()); }
let json = r#"{
bin
}
/// Mesh-Node mit Translation und extras, ein skaliertes Kind, ein Empty.
const SAMPLE_JSON: &str = r#"{
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 2]}],
@@ -348,27 +388,36 @@ mod tests {
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
"extras": {"signal": "tiffany", "hp": 3},
"children": [1]},
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}},
{"name": "Kind", "mesh": 0, "scale": [2, 1, 1],
"extras": {"collide": "proxy"}},
{"name": "spawn", "translation": [1, 2, 3]}
],
"meshes": [{"primitives": [{
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
"indices": 2, "material": 0
"attributes": {"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2},
"indices": 3, "material": 0
}]}],
"materials": [{"name": "carpet"}],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
{"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"}
{"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"},
{"bufferView": 3, "componentType": 5123, "count": 3, "type": "SCALAR"}
],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
{"buffer": 0, "byteOffset": 60, "byteLength": 6}
{"buffer": 0, "byteOffset": 60, "byteLength": 36},
{"buffer": 0, "byteOffset": 96, "byteLength": 6}
],
"buffers": [{"byteLength": 66}]
"buffers": [{"byteLength": 102}]
}"#;
glb(json, &bin)
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]
@@ -405,6 +454,29 @@ mod tests {
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]
fn uvs_pass_through_unchanged() {
// 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
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
//! [`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 assets
//!
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
//! signals::dispatch zu rufen).
//! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! rufen.
//!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
//! einem Export) und produziert das neutrale `model::Model`. `audio` ist
//! 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.
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
//! die Emitter-Logik zum Frontend-Treiber render::audio, `post` analog die
//! Effekt-Schalter zu render::post, `player` die First-Person-Physik auf der
//! Welt aus `collision`.
pub mod assets;
pub mod audio;
@@ -37,6 +29,7 @@ pub mod ink;
pub mod kv;
pub mod model;
pub mod player;
pub mod post;
pub mod signals;
pub mod story_ctrl;
pub mod tga;
+42 -1
View File
@@ -20,7 +20,7 @@
use std::collections::HashMap;
/// 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 struct Model {
@@ -42,6 +42,9 @@ pub struct Object {
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
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]>,
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
pub tri_mats: Vec<usize>,
@@ -77,6 +80,39 @@ pub const SIGNAL_PROP: &str = "signal";
pub const ROLE_PROP: &str = "role";
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
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
/// `"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 {
name: name.into(),
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,
tri_mats: vec![0; n],
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
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`),
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
//! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] 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. `fly` ist der
//! Noclip-Gegenpart ohne Welt.
//!
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
//! Nutzer echte dot/cross/normalize braucht.
//!
//! 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.
//! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
//! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
//! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
//! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
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).
pub const EYE_HEIGHT: f32 = 1.6;
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
/// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
const GRAVITY: f32 = 20.0;
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
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;
/// 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`).
const PITCH_LIMIT: f32 = 1.55;
/// 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.
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;
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
const MAX_SLIDES: u32 = 4;
@@ -77,48 +73,68 @@ impl Player {
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
}
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den
/// Point-and-Click-Strahl aus der Bildmitte.
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
/// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
pub fn look_dir(&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) — 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,
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
let was_grounded = self.grounded;
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0,
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach.
// Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
// mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
// Hochschauen nicht abhebt.
let (sy, cy) = self.yaw.sin_cos();
let f = [-sy, -cy]; // (x, z)
let r = [ cy, -sy]; // (x, z)
let r = self.right_dir();
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 —
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst.
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED;
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED;
// Sprung vor der Gravitation, nur vom Boden (Vorframe-`grounded`) —
// kein Doppelsprung.
// Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
// der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
// hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
// aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
let jumped = jump && was_grounded;
if jumped {
self.vel[1] = JUMP_SPEED;
self.grounded = false;
}
} else if was_grounded {
self.vel[1] = 0.0;
} else {
self.vel[1] -= GRAVITY * dt;
}
self.move_and_slide(world, dt);
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter,
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
// Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
// knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
// ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
// Rampen-Absprünge mehr.
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
self.snap_to_ground(world);
}
@@ -142,11 +158,9 @@ impl Player {
///
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box-
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
/// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
/// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
/// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
let half = HALF_EXTENTS;
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]);
}
#[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]
fn wall_blocks_horizontal_movement() {
// 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]
fn walks_down_slope_across_seams_at_60fps() {
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
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
//! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
//! in seine Darstellung.
//!
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
//! 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,
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die
//! 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.
//! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm.
//!
//! 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
+2 -4
View File
@@ -1,9 +1,7 @@
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
//!
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
//! File wird gemeldet und bleibt still, nie fatal.
//! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
//! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
//!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! 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
//! dieses Modul nie).
//!
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
//! Kein Panic: kein Gerät oder fehlende/kaputte WAVs sind eine Meldung und
//! Stille, nie fatal ([`Audio::open`] liefert `None`, alle Aufrufe darauf
//! entfallen still beim Aufrufer).
//!
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
//! - 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;
}
+84 -49
View File
@@ -1,10 +1,12 @@
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
//! aus dem Renderer-Plan:
//! wgpu-Zustand: Surface, Device und der interne Render-Pfad:
//!
//! Pass 1 (intern): 640×480 RGBA8 + Depth — hier entsteht das Bild
//! ([`ScenePass`], PS1-Shader).
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
//! 4:3-Letterbox (via Viewport) auf die Surface.
//! Szene (intern): 640×480 RGBA8 + Depth — Geometrie und Textur
//! ([`ScenePass`]).
//! Post (intern): die Effekt-Kette ([`Post`]), Ping-Pong zwischen zwei
//! Targets; jeder Effekt einzeln schaltbar.
//! Overlay (intern): das UI ([`SpritePass`]) — *nach* der Kette, damit
//! Bild-Effekte nur die Welt treffen, nie den Text.
//! Blit (Fenster): Nearest-Upscale mit 4:3-Letterbox (via Viewport).
//!
//! 640×480 (VGA) statt 320×240: das Spiel ist text-getrieben — die höhere
//! Auflösung trägt den 80-Spalten-Textmodus-Look (EGA 8×14), der Lo-Fi-Rest
@@ -14,8 +16,10 @@ use std::sync::Arc;
use winit::window::Window;
use crate::engine::post::PostChain;
use crate::engine::tga::Image;
use crate::render::math::Mat4;
use crate::render::post::Post;
use crate::render::scene::{Mesh, ScenePass};
use crate::render::sprite::SpritePass;
use crate::render::ui::Ui;
@@ -28,19 +32,27 @@ pub const INTERNAL_H: u32 = 480;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
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 {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
internal_view: wgpu::TextureView,
depth_view: wgpu::TextureView,
scene: ScenePass,
post: Post,
sprite: SpritePass,
blit_pipeline: wgpu::RenderPipeline,
blit_bind: wgpu::BindGroup,
/// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`].
blit_binds: [wgpu::BindGroup; 2],
/// Nullpunkt der Shader-Zeit (Post-Uniform).
start: std::time::Instant,
}
impl Gpu {
@@ -98,19 +110,21 @@ impl Gpu {
};
surface.configure(&device, &config);
let (internal_view, depth_view) = make_internal_targets(&device);
let depth_view = make_depth_target(&device);
// Pass 1: die Szene. Geometrie + Bilder kommen fertig vom Aufrufer
// (Decode in render::run); hier wird nur hochgeladen.
// Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
// render::run); hier wird nur hochgeladen.
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
let post = Post::new(
&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &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(
&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT,
&device, &queue, INTERNAL_FORMAT,
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
);
// Pass 2: internes Target nearest-gesampelt auf die Surface.
// Blit: das fertige interne Bild nearest-gesampelt auf die Surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
@@ -142,25 +156,30 @@ impl Gpu {
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
let blit_layout = blit_pipeline.get_bind_group_layout(0);
let blit_bind = |view: &wgpu::TextureView| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit"),
layout: &blit_pipeline.get_bind_group_layout(0),
layout: &blit_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&internal_view),
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
})
};
let blit_binds = [blit_bind(post.view(0)), blit_bind(post.view(1))];
Self {
surface, device, queue, config,
internal_view, depth_view,
scene, sprite, blit_pipeline, blit_bind,
depth_view,
scene, post, sprite, blit_pipeline, blit_binds,
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)]
}
/// 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
/// hängt.
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
pub fn frame(&mut self, view: &Mat4, ui: &Ui, post: &PostChain) {
let proj = Mat4::perspective(
60f32.to_radians(),
INTERNAL_W as f32 / INTERNAL_H as f32,
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];
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);
use wgpu::CurrentSurfaceTexture as Cst;
@@ -208,19 +228,22 @@ impl Gpu {
let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default());
// Pass 1: intern.
// Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
// zweite Attachment.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("internal"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.internal_view,
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 {
label: Some("scene"),
color_attachments: &[attach(albedo), attach(normal)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
@@ -234,11 +257,33 @@ impl Gpu {
multiview_mask: None,
});
self.scene.draw(&mut pass);
// Overlay obenauf, im selben Pass (kein Tiefentest).
}
// Effekt-Kette; sie sagt, welches Target das Bild jetzt trägt.
let out = self.post.run(&mut enc, post);
// Overlay obendrauf — nach der Kette, also von keinem Effekt berührt.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("overlay"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: self.post.view(out),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.sprite.draw(&mut pass, &ui.batches);
}
// Pass 2: Letterbox-Blit aufs Fenster.
// Letterbox-Blit aufs Fenster.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit"),
@@ -260,7 +305,7 @@ impl Gpu {
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
pass.set_pipeline(&self.blit_pipeline);
pass.set_bind_group(0, &self.blit_bind, &[]);
pass.set_bind_group(0, &self.blit_binds[out], &[]);
pass.draw(0..3, 0..1);
}
@@ -269,31 +314,21 @@ impl Gpu {
}
}
fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) {
let size = wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
};
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal color"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERNAL_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal depth"),
size,
size: wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
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: &[],
});
(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.
+28 -8
View File
@@ -1,10 +1,8 @@
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective.
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
//! ist nur der GPU-Pfad.)
//! statt glam: nötig sind nur Multiply, View und Perspective.
//!
//! 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]`,
//! bytemuck-kompatibel zu WGSL `mat4x4f`
//! - 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
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z,
/// positiver Pitch hebt den Blick.
/// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick.
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
Mat4::rot_x(-pitch)
.mul(&Mat4::rot_y(-yaw))
@@ -104,8 +101,7 @@ mod tests {
#[test]
fn view_yaw_quarter_turn_looks_minus_x() {
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf
// -X liegt dann vor der Kamera.
// yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera.
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]));
}
@@ -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]));
}
/// 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]
fn perspective_maps_near_far_to_0_1() {
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
+34 -69
View File
@@ -1,24 +1,18 @@
//! 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,
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
//! anderen Zustand als die Konsole.
//! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
//!
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt.
mod audio;
mod camera;
mod font;
mod gpu;
mod math;
mod post;
mod props;
mod scene;
mod sprite;
@@ -39,33 +33,28 @@ use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
use math::Mat4;
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
/// 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;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6;
pub fn run(mut session: Session) {
// Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird
// sie über denselben exec-Trichter wie alles andere (`post`).
for (name, on) in post::effects() { session.post.register(name, on); }
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); }
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
// nie fatal.
// Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier,
// Upload (GPU) später in `Gpu` — die beiden bleiben getrennt.
let models = load_models(&assets::path("assets/maps/props"));
// 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,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
// Luminanz.
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
// ist die Installation kaputt, da darf es scheitern.
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// Installation kaputt und es darf scheitern.
let load_keyed = |p: &str| {
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();
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
m.objects.len(), m.empties.len());
// Custom Properties sichtbar machen — noch konsumiert sie
// niemand, aber die Autorin sieht so, dass sie ankommen.
// Properties mitloggen: so sieht die Autorin, ob ihr Export
// `collide`, `signal` und `role` mitgenommen hat.
for o in &m.objects {
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
}
/// 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 —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> {
@@ -238,8 +223,6 @@ struct App {
gpu: Option<Gpu>,
session: Session,
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.
eye_y: f32,
input: Input,
@@ -271,15 +254,12 @@ impl App {
fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self {
window: None,
gpu: None,
session,
rx,
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
camera: Camera::new([0.0, 1.0, 4.0]),
eye_y,
input: Input::default(),
last: None,
@@ -296,7 +276,7 @@ impl App {
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
// 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) {
let Some(window) = &self.window else { return; };
if grab {
@@ -366,7 +346,7 @@ impl ApplicationHandler for App {
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
// Klick nur vormerken; der nächste redraw entscheidet (mit der
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
// Flycam-Greifen.
// Maus-Greifen.
self.pending_click = true;
}
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
@@ -404,7 +384,7 @@ impl App {
KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = 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.
KeyCode::Space => {
self.input.up = down;
@@ -416,38 +396,28 @@ impl App {
}
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 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
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
// gibt es keine parallele Lock-Logik.
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::FirstPerson);
let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); }
if fly {
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
let fwd = axis(self.input.fwd, self.input.back);
let right = axis(self.input.right, self.input.left);
let up = axis(self.input.up, self.input.down);
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
} else if play {
// Eingaben einsammeln und die Simulation in der Session treiben.
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
// die Session an ihrem Modus. Betretens-Zonen melden ihre
// Signal-Ausgaben wie exec-Befehle.
let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left),
up: axis(self.input.up, self.input.down),
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
// Blick beim Fortsetzen um die aufgestaute Bewegung.
self.input.mouse_dx = 0.0;
@@ -467,11 +437,8 @@ impl App {
}
}
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// 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.
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// der Luft und außerhalb des Spiels folgt sie exakt.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
if play && self.session.player.grounded {
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
@@ -481,16 +448,12 @@ impl App {
self.eye_y = target_eye;
}
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
// Sicht bleibt die First-Person-Sicht.
let view = if fly {
self.camera.view()
} else {
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
// nur die Sim ein, nicht die Sicht.
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
};
let view = 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
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
// 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::render::scene::{Batch, Mesh, Vertex};
/// Texturname für Faces ohne `usemtl` (Material `""`).
/// Texturname für Faces ohne Material (`""`).
pub const FALLBACK_TEXTURE: &str = "placeholder";
/// 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 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 {
if !o.visible { continue; }
let base = verts.len() as u32;
for (p, uv) in o.verts.iter().zip(&o.uvs) {
verts.push(Vertex { pos: *p, uv: *uv });
for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
verts.push(Vertex { pos: *p, uv: *uv, normal: *n, id });
}
id += 1;
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]);
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 });
indices.extend_from_slice(&list);
}
Mesh { verts, indices, batches }
Mesh { verts, indices, batches, objects: id - 1 }
}
#[cfg(test)]
@@ -76,6 +81,7 @@ mod tests {
name: name.into(),
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]],
normals: vec![[0.0, 0.0, 1.0]; 3],
tris: vec![[0, 1, 2]],
tri_mats: vec![0],
visible, collider: !visible,
@@ -106,6 +112,26 @@ mod tests {
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]
fn missing_material_maps_to_fallback() {
let mut m = sample();
+39 -11
View File
@@ -15,6 +15,14 @@ use crate::render::math::Mat4;
pub(crate) struct Vertex {
pub(crate) pos: [f32; 3],
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*
@@ -33,6 +41,10 @@ pub(crate) struct Mesh {
pub(crate) verts: Vec<Vertex>,
pub(crate) indices: Vec<u32>,
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 {
@@ -43,7 +55,12 @@ impl Mesh {
pub(crate) fn append(&mut self, other: Mesh) {
let vbase = self.verts.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.batches.extend(other.batches.iter().map(|b| Batch {
texture: b.texture,
@@ -67,7 +84,9 @@ struct Uniforms {
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64,
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 {
@@ -106,7 +125,10 @@ impl ScenePass {
module: &shader,
entry_point: Some("fs_main"),
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
// 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_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();
Self {
@@ -196,20 +218,26 @@ impl ScenePass {
}
}
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch.
fn upload_texture(
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
/// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
/// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
/// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
pub(crate) fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
label: &str,
mips: bool,
) -> wgpu::BindGroup {
// 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 {
label: Some("scene texture"),
label: Some(label),
size: wgpu::Extent3d {
width: img.width,
height: img.height,
@@ -251,7 +279,7 @@ fn upload_texture(
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene texture"),
label: Some(label),
layout,
entries: &[
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
// gerechnet statt nachgestellt:
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
// - 1-Bit-Alpha-Test (Cutouts), kein Blending
// Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
// dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
// Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
// render::post. Der Pixel-Snap im Vertex-Shader (PS1-Vertex-Jitter) gehört
// dagegen hierher, weil er Geometrie ist.
//
// 1-Bit-Alpha-Test (Cutouts), kein Blending.
//
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen
// Pixel-Snap und Dither.
// frei beweglichen Kamera und großen Faces zu störend.
struct Uniforms {
mvp: mat4x4f,
@@ -24,10 +25,27 @@ struct VsOut {
@builtin(position) pos: vec4f,
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
@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
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);
// 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
@@ -39,20 +57,11 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
var out: VsOut;
out.pos = clip;
out.uv = uv;
out.normal = normal;
out.id = id;
return out;
}
// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering.
fn bayer4(px: vec2u) -> f32 {
var m = array<u32, 16>(
0u, 8u, 2u, 10u,
12u, 4u, 14u, 6u,
3u, 11u, 1u, 9u,
15u, 7u, 13u, 5u,
);
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
}
// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
@@ -60,13 +69,16 @@ fn bayer4(px: vec2u) -> f32 {
const MIP_BIAS: f32 = -1.0;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4f {
fn fs_main(in: VsOut) -> FsOut {
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
// Verläufe zerfallen ins typische Dither-Muster.
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0;
let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0));
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
var out: FsOut;
out.albedo = vec4f(texel.rgb, 1.0);
// Interpolieren verkürzt die Normale — vor dem Kodieren neu normieren.
// Das Ziel ist 8 Bit pro Kanal: k/255 kommt exakt wieder heraus, aber
// 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.
//!
//! Zeichnet in einem eigenen Pass *hinter* der Post-Kette: Bild-Effekte
//! (Farbtiefe, Dither, später Outline) treffen so nur die Welt, nie den Text.
//!
//! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
//! 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::render::scene::upload_texture;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -58,7 +63,6 @@ impl SpritePass {
device: &wgpu::Device,
queue: &wgpu::Queue,
color_format: wgpu::TextureFormat,
depth_format: wgpu::TextureFormat,
internal: [f32; 2],
textures: &[Image],
) -> Self {
@@ -87,16 +91,9 @@ impl SpritePass {
})],
}),
primitive: wgpu::PrimitiveState::default(),
// Der interne Pass hat ein Tiefen-Attachment → die Pipeline muss
// ein kompatibles Format deklarieren. UI testet/schreibt aber
// keine Tiefe (Always, kein Write) → liegt immer obenauf.
depth_stencil: Some(wgpu::DepthStencilState {
format: depth_format,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
// Eigener Pass hinter der Post-Kette, ohne Tiefen-Attachment:
// das Overlay liegt per Reihenfolge obenauf.
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
@@ -134,7 +131,7 @@ impl SpritePass {
});
let tex_layout = pipeline.get_bind_group_layout(1);
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();
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
// (Ursprung oben-links), getintet. Zeichnet ins interne Target *nach* der
// Szene, ohne Tiefentest → liegt immer obenauf. Echtes Alpha-Blending
// (anders als der 1-Bit-Alphatest der Welt), damit Panels und Glyph-Ränder
// weich überblenden.
// (Ursprung oben-links), getintet. Läuft als letzter interner Pass, hinter
// der Post-Kette → liegt immer obenauf und bleibt von Bild-Effekten
// unberührt. Echtes Alpha-Blending (anders als der 1-Bit-Alphatest der
// Welt), damit Panels und Glyph-Ränder weich überblenden.
struct Uniforms {
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
+9 -10
View File
@@ -1,12 +1,11 @@
//! UI-Overlay: **state-driven** und **immediate-mode**.
//!
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
//! einziger Trichter in den State, kein UI-Backdoor.
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
//! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
//! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
//! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
//! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
//! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
//!
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
//! 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 {
/// Position in internen Pixeln (über die Letterbox gemappt).
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`.
pub(crate) grabbed: bool,
/// 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.
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
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
if !cur.grabbed {
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).
Mode::FirstPerson | Mode::Free => {}
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
+126 -86
View File
@@ -3,22 +3,19 @@ use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player};
use crate::engine::post::PostChain;
use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
/// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
/// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
/// (bei 1/60 geschriebenen) Player-Tests.
const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
/// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
/// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25;
/// 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
/// Empties, siehe engine::audio).
emitters: Emitters,
/// An/Aus der Render-Post-Effekte. Das Fenster-Frontend meldet seine Kette
/// beim Start an und liest sie jeden Frame; die CLI lässt sie leer.
pub post: PostChain,
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt
/// sie liegen (ihr reicht die `[action]`-Textzeile).
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
/// `[action]`-Textzeile.
pending_sounds: Vec<String>,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
/// nächsten Sub-Schritt konsumiert.
/// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
/// einen Frame ohne Sim-Schritt und verfällt.
pending_jump: bool,
}
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// 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)]
pub struct FrameInput {
pub fwd: f32,
pub right: f32,
pub up: f32,
pub jump: bool,
pub look_dx: f32,
pub look_dy: f32,
@@ -96,6 +97,7 @@ impl Session {
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
emitters: Emitters::new(),
post: PostChain::new(),
pending_sounds: Vec::new(),
signals_path,
sim_accum: 0.0,
@@ -103,13 +105,10 @@ impl Session {
}
}
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
/// 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).
/// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
/// ist Sache von render::props): Collider aus `collide`, Trigger aus
/// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
/// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model);
self.triggers.add_model(model);
@@ -140,12 +139,20 @@ impl Session {
}
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
/// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
/// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
/// zeigt sie wie `exec`-Ausgaben an.
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
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
@@ -217,13 +224,13 @@ impl Session {
fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim();
match line {
"" => ExecResult::empty(),
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
"menu" => {
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
match (verb, args.trim()) {
("", _) => ExecResult::empty(),
("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
("menu", _) => {
// Menü öffnen bzw. ins Spiel zurück; „Fortsetzen" landet immer
// im Spielmodus, nie im Noclip. (Dialog wird anders geroutet.)
let t = match self.mode {
Mode::Menu => ModeTarget::Play,
_ => ModeTarget::Menu,
@@ -231,56 +238,63 @@ impl Session {
self.set_mode(t);
ExecResult::empty()
}
"noclip" => {
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
// umschalten.
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" => {
("help", _) => ExecResult::lines(help()),
("kv", _) => ExecResult::lines(self.dump_kv()),
("reload", _) => {
self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()),
])
}
_ => {
if let Some(m) = line.strip_prefix("mode ") {
// Modus explizit setzen — über dieselbe Action wie das
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
// Signals genau einen Wechsel-Pfad teilen.
match ModeTarget::parse(m) {
// Modus über dieselbe Action wie das `mode`-Verb aus
// signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
("mode", m) => match ModeTarget::parse(m) {
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();
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())))
} else {
ExecResult::lines(vec![
}
_ => 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.
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
/// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus
/// entsteht ausschließlich in `step` aus dem Story-Ablauf.)
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu` und die
/// `SetMode`-Action laufen beide hier durch. (Der Dialog-Modus entsteht
/// ausschließlich in `step` aus dem Story-Ablauf.)
fn set_mode(&mut self, t: ModeTarget) {
self.mode = match t {
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> {
vec![
" 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(),
" kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(),
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
" mode <m> Modus direkt setzen: play | free | menu".into(),
" mode <m> Modus setzen: play | free (Noclip) | menu".into(),
" post [<e> on|off] Post-Effekte anzeigen / schalten".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
@@ -475,16 +493,6 @@ mod tests {
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]
fn fixed_timestep_is_framerate_independent() {
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
@@ -525,13 +533,27 @@ mod tests {
}
#[test]
fn tick_only_runs_in_first_person() {
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
fn menu_pauses_the_sim() {
let mut s = empty_session();
s.mode = Mode::Menu;
let before = s.player.pos;
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,
@@ -620,6 +642,24 @@ mod tests {
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
}
#[test]
fn post_command_switches_registered_effects() {
let mut s = empty_session();
// Ohne Frontend ist die Kette leer — der Befehl sagt das, statt zu lügen.
assert_eq!(s.exec("post").output.len(), 1);
assert!(!s.post.enabled("dither"));
s.post.register("dither", true);
assert_eq!(s.exec("post").output.len(), 1, "eine Zeile je Effekt");
s.exec("post dither off");
assert!(!s.post.enabled("dither"));
s.exec("post dither");
assert!(s.post.enabled("dither"), "ohne Zustand togglet es");
// Unbekannter Effekt meldet sich und lässt die Kette in Ruhe.
assert_eq!(s.exec("post bloom on").output.len(), 1);
assert!(s.post.enabled("dither"));
}
#[test]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();