Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04679fa897 | ||
|
|
331caf417a | ||
|
|
f51f4e83fd | ||
|
|
737a297436 | ||
|
|
b72b90c50d | ||
|
|
fc3494466e | ||
|
|
937fbd6ede | ||
|
|
307b52ae1f | ||
|
|
6e84fe9b1b | ||
|
|
236a643d04 | ||
|
|
247f7ef9fe | ||
|
|
b3d08d4a8c | ||
|
|
8ddbd6f514 | ||
|
|
b9258a68fd | ||
|
|
7f87dbca35 | ||
|
|
8bee8322c3 | ||
|
|
a3ace75c81 | ||
|
|
87500e13da | ||
|
|
bf2cb25836 | ||
|
|
50b7b86944 | ||
|
|
3571106251 |
@@ -18,6 +18,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -410,6 +416,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@@ -496,12 +511,32 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide 0.9.1",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -967,6 +1002,26 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "29.0.3"
|
version = "29.0.3"
|
||||||
@@ -1436,6 +1491,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide 0.8.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -1792,6 +1860,12 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd_cesu8"
|
name = "simd_cesu8"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2321,6 +2395,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bladeink",
|
"bladeink",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"png",
|
||||||
"pollster",
|
"pollster",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tinyaudio",
|
"tinyaudio",
|
||||||
@@ -2986,6 +3061,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bladeink = "1.2.5"
|
bladeink = "1.2.5"
|
||||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||||
|
png = "0.18.1"
|
||||||
pollster = "0.4.0"
|
pollster = "0.4.0"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
|
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
|
||||||
|
|||||||
@@ -15,3 +15,10 @@ Toolchain (Rust ≥ 1.85). Das `cargo` aus den Distributions-Paketquellen
|
|||||||
- Anwendung bauen und ausführen: `cargo run`
|
- Anwendung bauen und ausführen: `cargo run`
|
||||||
|
|
||||||
Alternativ ohne Fenster/GPU in der Konsole: `cargo run -- --cli`
|
Alternativ ohne Fenster/GPU in der Konsole: `cargo run -- --cli`
|
||||||
|
|
||||||
|
Ein anderes Welt-Verzeichnis laden (Default `assets/maps/props/`):
|
||||||
|
`cargo run -- --maps <ordner>` (Pfad relativ zum aktuellen Verzeichnis).
|
||||||
|
|
||||||
|
Assets (Ink-Dialoge, Blender-Welten) baut `cargo build` bei Bedarf selbst,
|
||||||
|
sofern die Werkzeuge da sind (`inklecate` liegt bei, Blender über `$BLENDER`
|
||||||
|
oder `PATH`). Ohne sie werden die eingecheckten `.ink.json` / `.glb` benutzt.
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
|
|||||||
|
|
||||||
## 3D / Blender
|
## 3D / Blender
|
||||||
|
|
||||||
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
|
- **Ablage:** die `.blend` in ihren Map-Ordner legen —
|
||||||
dort werden beim Start geladen.
|
`assets/maps/<map>/<name>.blend`. `cargo build` (oder
|
||||||
- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
|
`./tools/build-models.sh`) exportiert sie zu `<name>.glb` daneben; von
|
||||||
keine der Properties unten an!).
|
Hand exportieren musst du nichts. Die Welt sind alle `.glb` unter
|
||||||
|
`assets/maps/props/`; ein anderer Ordner per `cargo run -- --maps <dir>`.
|
||||||
- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m,
|
- **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.
|
Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken.
|
||||||
- **Material-Name = Textur-Name:** Material `holz` → `assets/textures/holz.tga`.
|
- **Texturen:** Bild im Material als Base Color zuweisen (PNG) — es wird
|
||||||
Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur.
|
beim Export in die `.glb` eingebettet. Damit die Datei self-contained
|
||||||
- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit.
|
bleibt: „File → External Data → Pack Resources". Fehlt das Bild,
|
||||||
|
erscheint die Platzhalter-Textur.
|
||||||
|
- **Kacheln / Skalieren:** über einen **Mapping-Node** vor dem
|
||||||
|
Image-Texture-Node (Location/Scale). Die Engine backt das
|
||||||
|
(`KHR_texture_transform`) beim Laden in die UVs ein.
|
||||||
|
- **Custom Properties** (unten) kommen automatisch mit; Modifier werden
|
||||||
|
beim Export angewandt.
|
||||||
|
|
||||||
### Custom Properties auf Mesh-Objekten
|
### Custom Properties auf Mesh-Objekten
|
||||||
|
|
||||||
@@ -67,8 +74,10 @@ Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
|
|||||||
|
|
||||||
## Testen
|
## Testen
|
||||||
|
|
||||||
- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke,
|
- `cargo run` — baut geänderte `.blend` neu und zeigt beim Start, was
|
||||||
Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen.
|
ankam: Objekte, Dreiecke, Empties und **jede Custom Property**. Fehlt
|
||||||
|
eine, sitzt sie in Blender nicht am Objekt (Object Properties → Custom
|
||||||
|
Properties), nicht am Mesh-Data.
|
||||||
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
|
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
|
||||||
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
|
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
|
||||||
Signal von Hand, `reload` lädt signals.toml neu.
|
Signal von Hand, `reload` lädt signals.toml neu.
|
||||||
|
|||||||
@@ -9,44 +9,14 @@
|
|||||||
# inc <name> [<delta>] KV-Integer +Delta (Default +1)
|
# inc <name> [<delta>] KV-Integer +Delta (Default +1)
|
||||||
# clear <name> KV-Eintrag entfernen
|
# clear <name> KV-Eintrag entfernen
|
||||||
# play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
|
# play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
|
||||||
|
# mode <play|free|menu> deferred (Frontend): Anzeige-/Eingabemodus
|
||||||
|
# post <name> <on|off> deferred (Frontend): Renderer-Post-Effekt schalten
|
||||||
#
|
#
|
||||||
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
|
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
|
||||||
# idealer Ort für KV-Defaults.
|
# idealer Ort für KV-Defaults.
|
||||||
|
|
||||||
[init]
|
[init]
|
||||||
actions = [
|
actions = [
|
||||||
"set cube_clicks 0",
|
|
||||||
"set mushrooms_collected 0",
|
|
||||||
"set quest_mushroom_active false",
|
|
||||||
"set quest_mushroom_done false",
|
|
||||||
]
|
|
||||||
|
|
||||||
[Mushroom]
|
|
||||||
actions = [
|
|
||||||
"inc mushrooms_collected",
|
|
||||||
"play_sound pickup",
|
|
||||||
]
|
|
||||||
|
|
||||||
[Person]
|
|
||||||
actions = [
|
|
||||||
"play_sound greet",
|
|
||||||
"start_ink person.ink.json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[Suzanne]
|
|
||||||
actions = [
|
|
||||||
"start_ink suzanne.ink.json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[Cube]
|
|
||||||
actions = [
|
|
||||||
"inc cube_clicks",
|
|
||||||
"start_ink cube.ink.json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[person_farewell]
|
|
||||||
actions = [
|
|
||||||
"debug_log Person hat sich verabschiedet",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tiffany]
|
[tiffany]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 347 B |
|
Before Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 427 B |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,59 +1,82 @@
|
|||||||
//! Build-Hook: kompiliert geänderte Ink-Dialoge automatisch beim Cargo-Build.
|
//! Build-Hook: baut geänderte Assets automatisch beim Cargo-Build.
|
||||||
//!
|
//!
|
||||||
//! Reines „Wann"-Stück — das eigentliche Bauen macht `tools/build-dialogues.sh`
|
//! Reines „Wann"-Stück — das eigentliche Bauen machen die Skripte unter
|
||||||
//! (und bleibt von Hand aufrufbar). Hier melden wir Cargo nur, wann es uns
|
//! `tools/` (und bleiben von Hand aufrufbar). Hier melden wir Cargo nur, wann
|
||||||
//! erneut ausführen soll, und stoßen das Skript an.
|
//! es uns erneut ausführen soll, und stoßen die Skripte an:
|
||||||
|
//!
|
||||||
|
//! * `build-dialogues.sh` — `.ink` → `.ink.json` (inklecate)
|
||||||
|
//! * `build-models.sh` — `assets/maps/**/*.blend` → `.glb` (Blender)
|
||||||
//!
|
//!
|
||||||
//! Cargo führt build.rs ausschließlich dann erneut aus, wenn eine der unten
|
//! Cargo führt build.rs ausschließlich dann erneut aus, wenn eine der unten
|
||||||
//! via `rerun-if-changed` gemeldeten Dateien sich ändert. Unveränderte
|
//! via `rerun-if-changed` gemeldeten Dateien sich ändert. Unveränderte
|
||||||
//! Rebuilds kosten also nichts; nur nach einer `.ink`-Änderung läuft
|
//! Rebuilds kosten also nichts; nur nach einer Quell-Änderung läuft das
|
||||||
//! inklecate (inkrementell nur für die betroffene Datei).
|
//! jeweilige Werkzeug (beide Skripte bauen intern inkrementell).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let root = env!("CARGO_MANIFEST_DIR");
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
let script = Path::new(root).join("tools/build-dialogues.sh");
|
|
||||||
let ink_dir = Path::new(root).join("assets/interactions");
|
|
||||||
|
|
||||||
// Wann soll Cargo build.rs erneut ausführen?
|
// --- Ink-Dialoge ------------------------------------------------------
|
||||||
println!("cargo:rerun-if-changed=tools/build-dialogues.sh");
|
println!("cargo:rerun-if-changed=tools/build-dialogues.sh");
|
||||||
println!("cargo:rerun-if-changed=tools/inklecate");
|
println!("cargo:rerun-if-changed=tools/inklecate");
|
||||||
// Verzeichnis-mtime fängt neu hinzugekommene/gelöschte .ink-Dateien.
|
// Verzeichnis-mtime fängt neu hinzugekommene/gelöschte .ink-Dateien.
|
||||||
println!("cargo:rerun-if-changed=assets/interactions");
|
println!("cargo:rerun-if-changed=assets/interactions");
|
||||||
// Inhaltsänderungen einzelner .ink-Dateien einzeln melden.
|
watch_ext(&root.join("assets/interactions"), "ink");
|
||||||
if let Ok(entries) = std::fs::read_dir(&ink_dir) {
|
run_script(&root, "tools/build-dialogues.sh", "Dialog");
|
||||||
|
|
||||||
|
// --- Blender-Welten -------------------------------------------------
|
||||||
|
println!("cargo:rerun-if-changed=tools/build-models.sh");
|
||||||
|
// Ordner-mtime fängt neue/gelöschte Maps; die .blend selbst einzeln.
|
||||||
|
// Die erzeugten .glb werden NICHT beobachtet (das Skript schreibt sie).
|
||||||
|
watch_dirs_and_ext(&root.join("assets/maps"), "blend");
|
||||||
|
run_script(&root, "tools/build-models.sh", "Modell");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jede Datei mit Endung `ext` direkt in `dir` einzeln bei Cargo anmelden.
|
||||||
|
fn watch_ext(dir: &Path, ext: &str) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let p = entry.path();
|
let p = entry.path();
|
||||||
if p.extension().and_then(|s| s.to_str()) == Some("ink") {
|
if p.extension().and_then(|s| s.to_str()) == Some(ext) {
|
||||||
println!("cargo:rerun-if-changed={}", p.display());
|
println!("cargo:rerun-if-changed={}", p.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skript ausführen. Ausgabe einsammeln, damit ein Dialog-Fehler sichtbar
|
/// Wie [`watch_ext`], aber rekursiv — und jedes Unterverzeichnis wird selbst
|
||||||
// wird (Cargo blendet Build-Script-stdout sonst aus).
|
/// beobachtet (fängt neu angelegte/entfernte Maps).
|
||||||
|
fn watch_dirs_and_ext(dir: &Path, ext: &str) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
|
println!("cargo:rerun-if-changed={}", dir.display());
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
watch_dirs_and_ext(&p, ext);
|
||||||
|
} else if p.extension().and_then(|s| s.to_str()) == Some(ext) {
|
||||||
|
println!("cargo:rerun-if-changed={}", p.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein `tools/`-Skript laufen lassen. Ausgabe einsammeln (Cargo blendet
|
||||||
|
/// Build-Script-stdout sonst aus); Fehlschlag ist fatal, ein nicht startbares
|
||||||
|
/// bash nur eine Warnung — die committeten Artefakte reichen dann.
|
||||||
|
fn run_script(root: &Path, rel: &str, label: &str) {
|
||||||
|
let script = root.join(rel);
|
||||||
match Command::new("bash").arg(&script).output() {
|
match Command::new("bash").arg(&script).output() {
|
||||||
Ok(out) if out.status.success() => { /* still bei Erfolg */ }
|
Ok(out) if out.status.success() => { /* still bei Erfolg — Detail via Hand-Aufruf */ }
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
for line in stdout.lines().chain(stderr.lines()) {
|
for line in stdout.lines().chain(stderr.lines()) {
|
||||||
println!("cargo:warning={line}");
|
println!("cargo:warning={line}");
|
||||||
}
|
}
|
||||||
panic!(
|
panic!("{label}-Build fehlgeschlagen — siehe Warnungen oben. Einzeln debuggen: ./{rel}");
|
||||||
"Dialog-Build fehlgeschlagen — siehe Warnungen oben. \
|
|
||||||
Einzeln debuggen: ./tools/build-dialogues.sh"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// bash/Skript nicht startbar (z.B. fremdes OS): den Build nicht
|
println!("cargo:warning={label}-Build übersprungen ({e}). Bei Bedarf von Hand: ./{rel}");
|
||||||
// blockieren, nur warnen — die committeten .ink.json reichen.
|
|
||||||
println!(
|
|
||||||
"cargo:warning=Dialog-Build übersprungen ({e}). \
|
|
||||||
Bei Bedarf von Hand: ./tools/build-dialogues.sh"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ mod tests {
|
|||||||
use crate::engine::model::{Empty, Props};
|
use crate::engine::model::{Empty, Props};
|
||||||
|
|
||||||
fn model(empties: Vec<Empty>) -> Model {
|
fn model(empties: Vec<Empty>) -> Model {
|
||||||
Model { objects: Vec::new(), empties, materials: Vec::new() }
|
Model { objects: Vec::new(), empties, textures: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
|
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
|
||||||
|
|||||||
@@ -10,17 +10,13 @@
|
|||||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||||
//! sofort wieder im Kontakt steckt.
|
//! sofort wieder im Kontakt steckt.
|
||||||
//!
|
//!
|
||||||
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
|
//! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::add_model`]):
|
||||||
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
|
//! jedes Objekt mit `collide`-Property wird zur **konvexen Hülle** seiner
|
||||||
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
|
//! Vertices ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf
|
||||||
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
//! also konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
||||||
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
||||||
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
||||||
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
||||||
//!
|
|
||||||
//! Headless wie der Rest von `engine`: hängt nur an `model` (Quelle der
|
|
||||||
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
|
||||||
//! Welt einmal vom Frontend aus den Modellen.
|
|
||||||
|
|
||||||
use crate::engine::model::{Model, Object};
|
use crate::engine::model::{Model, Object};
|
||||||
|
|
||||||
@@ -384,7 +380,7 @@ mod tests {
|
|||||||
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
|
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
|
||||||
],
|
],
|
||||||
empties: Vec::new(),
|
empties: Vec::new(),
|
||||||
materials: vec![String::new()],
|
textures: Vec::new(),
|
||||||
};
|
};
|
||||||
let mut w = CollisionWorld::empty();
|
let mut w = CollisionWorld::empty();
|
||||||
w.add_model(&model);
|
w.add_model(&model);
|
||||||
@@ -438,6 +434,7 @@ mod tests {
|
|||||||
name: "Zone".into(),
|
name: "Zone".into(),
|
||||||
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
|
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
|
||||||
uvs: vec![[0.0, 0.0]; 4],
|
uvs: vec![[0.0, 0.0]; 4],
|
||||||
|
normals: vec![[0.0, 0.0, 1.0]; 4],
|
||||||
tris: vec![[0, 1, 2], [0, 2, 3]],
|
tris: vec![[0, 1, 2], [0, 2, 3]],
|
||||||
tri_mats: vec![0; 2],
|
tri_mats: vec![0; 2],
|
||||||
visible: true, collider: false, props: Props::new(),
|
visible: true, collider: false, props: Props::new(),
|
||||||
|
|||||||
@@ -5,11 +5,9 @@
|
|||||||
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
|
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
|
||||||
//! konstruiert und durch die Dispatch-Pipeline gereicht.
|
//! konstruiert und durch die Dispatch-Pipeline gereicht.
|
||||||
//!
|
//!
|
||||||
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
|
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
|
||||||
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
|
//! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
|
||||||
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
|
//! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
|
||||||
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
|
|
||||||
//! testbar und kennt keines der Subsysteme.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -28,20 +26,22 @@ pub type Signals = HashMap<String, Vec<String>>;
|
|||||||
pub enum Action {
|
pub enum Action {
|
||||||
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
|
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
|
||||||
PlaySound(String),
|
PlaySound(String),
|
||||||
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
|
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
|
||||||
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
|
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
|
||||||
/// wählbar — der entsteht nur aus dem Story-Ablauf.
|
|
||||||
SetMode(ModeTarget),
|
SetMode(ModeTarget),
|
||||||
|
/// Renderer-Post-Effekt schalten. Der Kern kennt weder die Kette noch die
|
||||||
|
/// Effektnamen — das Fenster löst `name` gegen seine Kette auf (unbekannt
|
||||||
|
/// → Meldung), die CLI reicht nur die `[action]`-Zeile.
|
||||||
|
SetPost { name: String, on: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog-
|
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
|
||||||
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten
|
/// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
|
||||||
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ModeTarget {
|
pub enum ModeTarget {
|
||||||
/// First-Person-Spiel.
|
/// First-Person-Spiel.
|
||||||
Play,
|
Play,
|
||||||
/// Noclip-Debug-Flycam.
|
/// Noclip: freies Fliegen ohne Kollision.
|
||||||
Free,
|
Free,
|
||||||
/// Menü (pausiert die Welt, gibt die Maus frei).
|
/// Menü (pausiert die Welt, gibt die Maus frei).
|
||||||
Menu,
|
Menu,
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
//! glTF-Binary-Loader (.glb, Blender-Export) → geparstes [`Model`].
|
||||||
//!
|
//!
|
||||||
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
|
//! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
|
||||||
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
|
|
||||||
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
||||||
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/…
|
//! **Empties** (Nodes ohne Mesh) als Entity-Marker. Beide kommen als
|
||||||
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die
|
//! `props`/`empties` im Modell an; Bedeutung geben die Konsumenten.
|
||||||
//! Konsumenten.
|
|
||||||
//!
|
//!
|
||||||
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
||||||
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
||||||
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
||||||
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
||||||
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken —
|
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
|
||||||
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe
|
//!
|
||||||
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
|
//! Texturen: die `baseColorTexture` jedes Materials wird aus dem BIN-Chunk
|
||||||
|
//! geholt und dekodiert (PNG, wie Blender es einbettet) → `model::Texture`.
|
||||||
|
//! `KHR_texture_transform` (Blenders Mapping-Node) wird beim Laden in die UVs
|
||||||
|
//! eingebacken. Der Rest von glTFs PBR-Modell bleibt ungenutzt.
|
||||||
//!
|
//!
|
||||||
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
||||||
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
||||||
//!
|
|
||||||
//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum,
|
use std::collections::HashMap;
|
||||||
//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders
|
|
||||||
//! als beim trivialen signals-TOML-Subset).
|
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::engine::model::{apply_props, Empty, Model, Object, Props};
|
use crate::engine::image::{self, Image};
|
||||||
|
use crate::engine::model::{apply_props, fill_missing_normals, norm, Empty, Model, Object, Props, Texture};
|
||||||
|
|
||||||
pub fn load(path: &str) -> Result<Model, String> {
|
pub fn load(path: &str) -> Result<Model, String> {
|
||||||
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
||||||
@@ -59,7 +59,7 @@ pub fn parse_glb(bytes: &[u8]) -> Result<Model, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
||||||
let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() };
|
let mut model = Model { objects: Vec::new(), empties: Vec::new(), textures: Vec::new() };
|
||||||
|
|
||||||
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
|
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
|
||||||
// Welt-Transform wächst dabei Parent → Kind.
|
// Welt-Transform wächst dabei Parent → Kind.
|
||||||
@@ -69,9 +69,109 @@ fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
|||||||
let Some(i) = r.as_u64() else { continue; };
|
let Some(i) = r.as_u64() else { continue; };
|
||||||
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
|
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
|
||||||
}
|
}
|
||||||
|
resolve_textures(doc, bin, &mut model);
|
||||||
Ok(model)
|
Ok(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nach dem Walk tragen die `tri_mats` rohe glTF-Material-Indizes
|
||||||
|
/// (`usize::MAX` = kein Material). Daraus die deduplizierte Texturliste bauen
|
||||||
|
/// (erste-Sicht-Reihenfolge) und die `tri_mats` auf ihre Indizes umschreiben.
|
||||||
|
fn resolve_textures(doc: &Value, bin: &[u8], model: &mut Model) {
|
||||||
|
let mut order: Vec<usize> = Vec::new();
|
||||||
|
let mut local: HashMap<usize, usize> = HashMap::new();
|
||||||
|
for o in &model.objects {
|
||||||
|
for &m in &o.tri_mats {
|
||||||
|
local.entry(m).or_insert_with(|| { order.push(m); order.len() - 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.textures = order.iter().map(|&raw| build_texture(doc, bin, raw)).collect();
|
||||||
|
for o in &mut model.objects {
|
||||||
|
for m in &mut o.tri_mats { *m = local[m]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das `model::Texture` für einen rohen glTF-Material-Index:
|
||||||
|
/// `material → pbrMetallicRoughness.baseColorTexture → texture → image`, Bild
|
||||||
|
/// aus dem BIN-Chunk dekodiert. Fehlt ein Glied, bleibt `image: None` (der
|
||||||
|
/// Konsument nimmt seinen Platzhalter), der Schlüssel wird trotzdem gesetzt.
|
||||||
|
fn build_texture(doc: &Value, bin: &[u8], raw_mat: usize) -> Texture {
|
||||||
|
if raw_mat == usize::MAX {
|
||||||
|
return Texture { key: String::new(), image: None };
|
||||||
|
}
|
||||||
|
let mat = &doc["materials"][raw_mat];
|
||||||
|
let mat_name = mat["name"].as_str().unwrap_or("");
|
||||||
|
let tex_ref = &mat["pbrMetallicRoughness"]["baseColorTexture"];
|
||||||
|
let img_node = tex_ref["index"].as_u64()
|
||||||
|
.and_then(|t| doc["textures"][t as usize]["source"].as_u64())
|
||||||
|
.map(|s| &doc["images"][s as usize]);
|
||||||
|
let Some(img_node) = img_node else {
|
||||||
|
return Texture { key: mat_name.to_string(), image: None };
|
||||||
|
};
|
||||||
|
let key = img_node["name"].as_str()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(mat_name)
|
||||||
|
.to_string();
|
||||||
|
let image = decode_embedded_image(doc, bin, img_node, &key);
|
||||||
|
Texture { key, image }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein `images[]`-Node aus dem BIN-Chunk dekodieren. Externe Dateien und
|
||||||
|
/// Daten-URIs unterstützt der GLB-Pfad nicht — sie werden gemeldet.
|
||||||
|
fn decode_embedded_image(doc: &Value, bin: &[u8], node: &Value, key: &str) -> Option<Image> {
|
||||||
|
let Some(view) = node["bufferView"].as_u64() else {
|
||||||
|
eprintln!("[tex] {key}: Bild ohne bufferView (externe Datei/Daten-URI wird nicht gelesen)");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let bytes = match buffer_view_bytes(doc, bin, view as usize) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { eprintln!("[tex] {key}: {e}"); return None; }
|
||||||
|
};
|
||||||
|
match image::decode(bytes, node["mimeType"].as_str().unwrap_or("")) {
|
||||||
|
Ok(img) => Some(img),
|
||||||
|
Err(e) => { eprintln!("[tex] {key}: {e}"); None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rohe Bytes eines bufferViews (ohne Accessor-Stride) aus dem BIN-Chunk.
|
||||||
|
fn buffer_view_bytes<'a>(doc: &Value, bin: &'a [u8], view: usize) -> Result<&'a [u8], String> {
|
||||||
|
let v = &doc["bufferViews"][view];
|
||||||
|
if v.is_null() { return Err(format!("bufferView {view} fehlt")); }
|
||||||
|
let off = v["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let len = v["byteLength"].as_u64().ok_or("bufferView ohne byteLength")? as usize;
|
||||||
|
bin.get(off..off + len).ok_or_else(|| "bufferView ragt aus dem BIN-Chunk".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KHR_texture_transform einer Textur-Referenz — hier landet Blenders
|
||||||
|
/// Mapping-Node. Fehlt die Extension: `None`.
|
||||||
|
struct UvTransform {
|
||||||
|
offset: [f32; 2],
|
||||||
|
scale: [f32; 2],
|
||||||
|
rotation: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uv_transform(tex_ref: &Value) -> Option<UvTransform> {
|
||||||
|
let x = &tex_ref["extensions"]["KHR_texture_transform"];
|
||||||
|
if x.is_null() { return None; }
|
||||||
|
Some(UvTransform {
|
||||||
|
offset: vecn::<2>(&x["offset"], [0.0, 0.0]),
|
||||||
|
scale: vecn::<2>(&x["scale"], [1.0, 1.0]),
|
||||||
|
rotation: x["rotation"].as_f64().unwrap_or(0.0) as f32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UvTransform {
|
||||||
|
/// `uv' = translation · rotation · scale · uv` (KHR-Referenz: die
|
||||||
|
/// Rotationsmatrix dreht mit negativem Winkel).
|
||||||
|
fn apply(&self, uv: [f32; 2]) -> [f32; 2] {
|
||||||
|
let (a, b) = (uv[0] * self.scale[0], uv[1] * self.scale[1]);
|
||||||
|
let (s, c) = self.rotation.sin_cos();
|
||||||
|
[
|
||||||
|
c * a + s * b + self.offset[0],
|
||||||
|
-s * a + c * b + self.offset[1],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> {
|
fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> {
|
||||||
let node = &doc["nodes"][idx];
|
let node = &doc["nodes"][idx];
|
||||||
if node.is_null() { return Err(format!("Node {idx} fehlt")); }
|
if node.is_null() { return Err(format!("Node {idx} fehlt")); }
|
||||||
@@ -84,10 +184,11 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
|
|||||||
let mut o = Object {
|
let mut o = Object {
|
||||||
name, props,
|
name, props,
|
||||||
visible: true, collider: false, // bis apply_props entscheidet
|
visible: true, collider: false, // bis apply_props entscheidet
|
||||||
verts: Vec::new(), uvs: Vec::new(),
|
verts: Vec::new(), uvs: Vec::new(), normals: Vec::new(),
|
||||||
tris: Vec::new(), tri_mats: Vec::new(),
|
tris: Vec::new(), tri_mats: Vec::new(),
|
||||||
};
|
};
|
||||||
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
append_mesh(doc, bin, mesh as usize, world, &mut o)?;
|
||||||
|
fill_missing_normals(&mut o); // Primitives ohne NORMAL
|
||||||
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
|
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
|
||||||
if !o.tris.is_empty() { model.objects.push(o); }
|
if !o.tris.is_empty() { model.objects.push(o); }
|
||||||
}
|
}
|
||||||
@@ -110,8 +211,7 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
|
|||||||
/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein
|
/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein
|
||||||
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
|
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
|
||||||
fn append_mesh(
|
fn append_mesh(
|
||||||
doc: &Value, bin: &[u8], mesh: usize, world: M4,
|
doc: &Value, bin: &[u8], mesh: usize, world: M4, o: &mut Object,
|
||||||
o: &mut Object, materials: &mut Vec<String>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
|
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
|
||||||
if prim["mode"].as_u64().unwrap_or(4) != 4 {
|
if prim["mode"].as_u64().unwrap_or(4) != 4 {
|
||||||
@@ -124,20 +224,32 @@ fn append_mesh(
|
|||||||
Some(a) => read_floats::<2>(doc, bin, a as usize)?,
|
Some(a) => read_floats::<2>(doc, bin, a as usize)?,
|
||||||
None => vec![[0.0, 0.0]; positions.len()],
|
None => vec![[0.0, 0.0]; positions.len()],
|
||||||
};
|
};
|
||||||
|
// Ohne NORMAL bleibt der Nullvektor stehen; fill_missing_normals
|
||||||
|
// rechnet ihn später aus den Flächen.
|
||||||
|
let normals = match prim["attributes"]["NORMAL"].as_u64() {
|
||||||
|
Some(a) => read_floats::<3>(doc, bin, a as usize)?,
|
||||||
|
None => vec![[0.0, 0.0, 0.0]; positions.len()],
|
||||||
|
};
|
||||||
|
|
||||||
// Material-Name → geteilte Namensliste (wie usemtl); ohne Material "".
|
// Rohen glTF-Material-Index behalten; resolve_textures baut daraus
|
||||||
let mat_name = prim["material"].as_u64()
|
// nach dem Walk die deduplizierte Texturliste und schreibt die
|
||||||
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
// tri_mats auf deren Indizes um. usize::MAX = kein Material.
|
||||||
.unwrap_or("");
|
let raw_mat = prim["material"].as_u64().map_or(usize::MAX, |m| m as usize);
|
||||||
let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| {
|
|
||||||
materials.push(mat_name.to_string());
|
|
||||||
materials.len() - 1
|
|
||||||
});
|
|
||||||
|
|
||||||
let base = o.verts.len();
|
let base = o.verts.len();
|
||||||
for (p, uv) in positions.iter().zip(&uvs) {
|
let nm = normal_matrix(world);
|
||||||
|
for ((p, uv), n) in positions.iter().zip(&uvs).zip(&normals) {
|
||||||
o.verts.push(transform(world, *p));
|
o.verts.push(transform(world, *p));
|
||||||
o.uvs.push(*uv);
|
o.uvs.push(*uv);
|
||||||
|
o.normals.push(norm(transform3(nm, *n)));
|
||||||
|
}
|
||||||
|
// KHR_texture_transform (Blenders Mapping-Node) in die UVs dieses
|
||||||
|
// Primitives einbacken — der Shader bleibt UV-agnostisch.
|
||||||
|
if raw_mat != usize::MAX {
|
||||||
|
let tex_ref = &doc["materials"][raw_mat]["pbrMetallicRoughness"]["baseColorTexture"];
|
||||||
|
if let Some(xf) = uv_transform(tex_ref) {
|
||||||
|
for uv in &mut o.uvs[base..] { *uv = xf.apply(*uv); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let idxs: Vec<usize> = match prim["indices"].as_u64() {
|
let idxs: Vec<usize> = match prim["indices"].as_u64() {
|
||||||
@@ -149,7 +261,7 @@ fn append_mesh(
|
|||||||
return Err(format!("{}: Index außerhalb der Positionen", o.name));
|
return Err(format!("{}: Index außerhalb der Positionen", o.name));
|
||||||
}
|
}
|
||||||
o.tris.push([base + t[0], base + t[1], base + t[2]]);
|
o.tris.push([base + t[0], base + t[1], base + t[2]]);
|
||||||
o.tri_mats.push(mi);
|
o.tri_mats.push(raw_mat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -294,6 +406,36 @@ fn mul(a: M4, b: M4) -> M4 {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalen-Transform zur Welt-Matrix: die Kofaktormatrix ihres 3×3-Teils
|
||||||
|
/// (zeilenweise). Das ist die inverse Transponierte bis auf den Faktor
|
||||||
|
/// `det` — den frisst das anschließende Normieren, und im Gegensatz zur
|
||||||
|
/// Matrix selbst hält sie Normalen auch bei ungleichmäßiger Skalierung
|
||||||
|
/// senkrecht auf der Fläche. Bei Spiegelungen (det < 0) dreht sie die
|
||||||
|
/// Normale korrekt mit.
|
||||||
|
fn normal_matrix(m: M4) -> [f32; 9] {
|
||||||
|
let a = |r: usize, c: usize| m[c * 4 + r];
|
||||||
|
let cof = |r: usize, c: usize| {
|
||||||
|
let (r0, r1) = ((r + 1) % 3, (r + 2) % 3);
|
||||||
|
let (c0, c1) = ((c + 1) % 3, (c + 2) % 3);
|
||||||
|
a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
|
||||||
|
};
|
||||||
|
let mut out = [0.0; 9];
|
||||||
|
for r in 0..3 {
|
||||||
|
for c in 0..3 { out[r * 3 + c] = cof(r, c); }
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Richtungsvektor mit einer 3×3-Matrix (zeilenweise) drehen — ohne
|
||||||
|
/// Translation, anders als [`transform`].
|
||||||
|
fn transform3(m: [f32; 9], v: [f32; 3]) -> [f32; 3] {
|
||||||
|
let mut out = [0.0; 3];
|
||||||
|
for (r, o) in out.iter_mut().enumerate() {
|
||||||
|
*o = m[r * 3] * v[0] + m[r * 3 + 1] * v[1] + m[r * 3 + 2] * v[2];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
|
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
|
||||||
let mut out = [0.0; 3];
|
let mut out = [0.0; 3];
|
||||||
for (r, o) in out.iter_mut().enumerate() {
|
for (r, o) in out.iter_mut().enumerate() {
|
||||||
@@ -333,14 +475,22 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein
|
/// Diagonale Normale — unter ungleichmäßiger Skalierung unterscheidet
|
||||||
/// Mesh-Node mit Translation und extras, ein Empty mit extras.
|
/// sie die korrekte Kofaktor-Transform von der (falschen) Matrix.
|
||||||
fn sample_glb() -> Vec<u8> {
|
const DIAG: f32 = std::f32::consts::FRAC_1_SQRT_2;
|
||||||
|
|
||||||
|
/// Ein Dreieck (Positionen + UVs + Normalen + u16-Indizes) im BIN-Chunk.
|
||||||
|
fn sample_bin() -> Vec<u8> {
|
||||||
let mut bin: Vec<u8> = Vec::new();
|
let mut bin: Vec<u8> = Vec::new();
|
||||||
for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); }
|
for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); }
|
||||||
for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); }
|
for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); }
|
||||||
|
for _ in 0..3 { for f in [DIAG, DIAG, 0.0] { bin.extend(f.to_le_bytes()); } }
|
||||||
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
|
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
|
||||||
let json = r#"{
|
bin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesh-Node mit Translation und extras, ein skaliertes Kind, ein Empty.
|
||||||
|
const SAMPLE_JSON: &str = r#"{
|
||||||
"asset": {"version": "2.0"},
|
"asset": {"version": "2.0"},
|
||||||
"scene": 0,
|
"scene": 0,
|
||||||
"scenes": [{"nodes": [0, 2]}],
|
"scenes": [{"nodes": [0, 2]}],
|
||||||
@@ -348,27 +498,36 @@ mod tests {
|
|||||||
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
||||||
"extras": {"signal": "tiffany", "hp": 3},
|
"extras": {"signal": "tiffany", "hp": 3},
|
||||||
"children": [1]},
|
"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]}
|
{"name": "spawn", "translation": [1, 2, 3]}
|
||||||
],
|
],
|
||||||
"meshes": [{"primitives": [{
|
"meshes": [{"primitives": [{
|
||||||
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
|
"attributes": {"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2},
|
||||||
"indices": 2, "material": 0
|
"indices": 3, "material": 0
|
||||||
}]}],
|
}]}],
|
||||||
"materials": [{"name": "carpet"}],
|
"materials": [{"name": "carpet"}],
|
||||||
"accessors": [
|
"accessors": [
|
||||||
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
|
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
|
||||||
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
|
{"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": [
|
"bufferViews": [
|
||||||
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||||||
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
|
{"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]
|
#[test]
|
||||||
@@ -382,7 +541,10 @@ mod tests {
|
|||||||
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
|
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
|
||||||
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
|
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
|
||||||
assert!(!d.visible && !d.collider);
|
assert!(!d.visible && !d.collider);
|
||||||
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
// Material "carpet" ohne baseColorTexture → Schlüssel gesetzt, kein Bild.
|
||||||
|
assert_eq!(m.textures.len(), 1);
|
||||||
|
assert_eq!(m.textures[0].key, "carpet");
|
||||||
|
assert!(m.textures[0].image.is_none());
|
||||||
|
|
||||||
// Kind erbt die Parent-Transform (10, 0, 0);
|
// Kind erbt die Parent-Transform (10, 0, 0);
|
||||||
// collide="proxy" → unsichtbarer Collider.
|
// collide="proxy" → unsichtbarer Collider.
|
||||||
@@ -405,6 +567,29 @@ mod tests {
|
|||||||
assert_eq!(e.pos, [1.0, 2.0, 3.0]);
|
assert_eq!(e.pos, [1.0, 2.0, 3.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normals_survive_nonuniform_scale() {
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
// „Ding": nur Translation → Normale unverändert.
|
||||||
|
let n = m.objects[0].normals[0];
|
||||||
|
assert!((n[0] - DIAG).abs() < 1e-5 && (n[1] - DIAG).abs() < 1e-5, "{n:?}");
|
||||||
|
|
||||||
|
// „Kind": Skalierung [2,1,1]. Richtig ist die inverse Transponierte
|
||||||
|
// (x wird gestaucht → [0.447, 0.894, 0]); die Matrix selbst ergäbe
|
||||||
|
// fälschlich [0.894, 0.447, 0], die Normale stünde schief zur Fläche.
|
||||||
|
let n = m.objects[1].normals[0];
|
||||||
|
assert!((n[0] - 0.4472).abs() < 1e-3, "x sollte gestaucht sein: {n:?}");
|
||||||
|
assert!((n[1] - 0.8944).abs() < 1e-3, "y sollte überwiegen: {n:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_normals_are_computed_from_faces() {
|
||||||
|
let m = parse_glb(&sample_glb_without_normals()).unwrap();
|
||||||
|
// Dreieck liegt in der XY-Ebene, CCW → Normale +Z.
|
||||||
|
assert_eq!(m.objects[0].normals[0], [0.0, 0.0, 1.0]);
|
||||||
|
assert!(m.objects[0].normals.iter().all(|n| *n != [0.0; 3]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn uvs_pass_through_unchanged() {
|
fn uvs_pass_through_unchanged() {
|
||||||
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
|
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
|
||||||
@@ -419,4 +604,66 @@ mod tests {
|
|||||||
v1[4] = 1; // Version patchen
|
v1[4] = 1; // Version patchen
|
||||||
assert!(parse_glb(&v1).is_err());
|
assert!(parse_glb(&v1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_png_is_decoded_and_uv_transform_baked_in() {
|
||||||
|
// 2×2-RGB-PNG (schwarz, rot, grün, blau) in den BIN-Chunk legen.
|
||||||
|
let mut png_bytes = Vec::new();
|
||||||
|
{
|
||||||
|
let mut enc = png::Encoder::new(&mut png_bytes, 2, 2);
|
||||||
|
enc.set_color(png::ColorType::Rgb);
|
||||||
|
enc.set_depth(png::BitDepth::Eight);
|
||||||
|
enc.write_header().unwrap()
|
||||||
|
.write_image_data(&[0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let mut bin = sample_bin();
|
||||||
|
while !bin.len().is_multiple_of(4) { bin.push(0); }
|
||||||
|
let img_off = bin.len();
|
||||||
|
bin.extend_from_slice(&png_bytes);
|
||||||
|
|
||||||
|
let json = format!(r#"{{
|
||||||
|
"asset": {{"version": "2.0"}},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [{{"nodes": [0]}}],
|
||||||
|
"nodes": [{{"name": "Boden", "mesh": 0}}],
|
||||||
|
"meshes": [{{"primitives": [{{
|
||||||
|
"attributes": {{"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2}},
|
||||||
|
"indices": 3, "material": 0
|
||||||
|
}}]}}],
|
||||||
|
"materials": [{{"name": "Bodenmat", "pbrMetallicRoughness": {{"baseColorTexture": {{
|
||||||
|
"index": 0,
|
||||||
|
"extensions": {{"KHR_texture_transform": {{"scale": [2.0, 4.0], "offset": [0.5, 0.0]}}}}
|
||||||
|
}}}}}}],
|
||||||
|
"textures": [{{"source": 0}}],
|
||||||
|
"images": [{{"name": "boden_tex", "mimeType": "image/png", "bufferView": 4}}],
|
||||||
|
"accessors": [
|
||||||
|
{{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}},
|
||||||
|
{{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}},
|
||||||
|
{{"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": 36}},
|
||||||
|
{{"buffer": 0, "byteOffset": 96, "byteLength": 6}},
|
||||||
|
{{"buffer": 0, "byteOffset": {img_off}, "byteLength": {png_len}}}
|
||||||
|
],
|
||||||
|
"buffers": [{{"byteLength": {bin_len}}}]
|
||||||
|
}}"#, img_off = img_off, png_len = png_bytes.len(), bin_len = bin.len());
|
||||||
|
|
||||||
|
let m = parse_glb(&glb(&json, &bin)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(m.textures.len(), 1);
|
||||||
|
let t = &m.textures[0];
|
||||||
|
assert_eq!(t.key, "boden_tex"); // glTF-Bildname, nicht Materialname
|
||||||
|
let img = t.image.as_ref().expect("PNG dekodiert");
|
||||||
|
assert_eq!((img.width, img.height), (2, 2));
|
||||||
|
assert_eq!(&img.rgba[0..8], &[0, 0, 0, 255, 255, 0, 0, 255]);
|
||||||
|
|
||||||
|
// KHR_texture_transform eingebacken: UV (1,1) → (1·2 + 0.5, 1·4) = (2.5, 4.0).
|
||||||
|
let uv = m.objects[0].uvs[2];
|
||||||
|
assert!((uv[0] - 2.5).abs() < 1e-5 && (uv[1] - 4.0).abs() < 1e-5, "{uv:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! RGBA8-`Image` — der geteilte Pixel-Container der Engine, dazu der Decoder
|
||||||
|
//! für eingebettete glTF-Bilder. Immer 32 bit, Ursprung oben-links, fertig
|
||||||
|
//! konvertiert; der Aufrufer macht nur noch das Sampling (und damit die
|
||||||
|
//! UV-Konvention) selbst.
|
||||||
|
//!
|
||||||
|
//! [`decode`] liest die Bilder, die Blender beim glTF-Export in die `.glb`
|
||||||
|
//! packt; [`load`] dieselbe Sorte Bild von der Platte (UI, Fonts,
|
||||||
|
//! Platzhalter). Beides PNG — ein Decoder für alles.
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Image {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// `width * height * 4`, Ursprung oben-links.
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine PNG-Datei von der Platte laden.
|
||||||
|
pub fn load(path: &str) -> Result<Image, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| format!("image load {path}: {e}"))?;
|
||||||
|
decode(&bytes, "").map_err(|e| format!("image {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein eingebettetes glTF-Bild dekodieren. `mime` ist der glTF-`mimeType`
|
||||||
|
/// (darf leer sein — dann entscheidet die Datei-Signatur). Unterstützt wird
|
||||||
|
/// nur PNG; JPEG/WebP werden gemeldet, nicht dekodiert.
|
||||||
|
pub fn decode(bytes: &[u8], mime: &str) -> Result<Image, String> {
|
||||||
|
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||||
|
let is_png = mime == "image/png" || (mime.is_empty() && bytes.starts_with(PNG_MAGIC));
|
||||||
|
if !is_png {
|
||||||
|
let what = if mime.is_empty() { "unbekanntes Format" } else { mime };
|
||||||
|
return Err(format!("{what} — nur PNG (in Blender „Images: PNG“ beim glTF-Export)"));
|
||||||
|
}
|
||||||
|
decode_png(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_png(bytes: &[u8]) -> Result<Image, String> {
|
||||||
|
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||||
|
// EXPAND hebt Palette/Low-Bit-Grau auf 8 bit, ALPHA erzwingt einen
|
||||||
|
// Alpha-Kanal, STRIP_16 wirft die zweite Byte-Hälfte weg. Damit bleiben
|
||||||
|
// als Ausgabe nur noch RGBA8 und Grau+Alpha8.
|
||||||
|
decoder.set_transformations(
|
||||||
|
png::Transformations::EXPAND | png::Transformations::ALPHA | png::Transformations::STRIP_16,
|
||||||
|
);
|
||||||
|
let mut reader = decoder.read_info().map_err(|e| format!("PNG: {e}"))?;
|
||||||
|
let mut buf = vec![0u8; reader.output_buffer_size().ok_or("PNG: Bild zu groß")?];
|
||||||
|
let info = reader.next_frame(&mut buf).map_err(|e| format!("PNG: {e}"))?;
|
||||||
|
let (width, height) = (info.width, info.height);
|
||||||
|
let src = &buf[..info.buffer_size()];
|
||||||
|
let n = (width as usize) * (height as usize);
|
||||||
|
|
||||||
|
let rgba = match info.color_type {
|
||||||
|
png::ColorType::Rgba => src.to_vec(),
|
||||||
|
png::ColorType::GrayscaleAlpha => {
|
||||||
|
let mut out = vec![0u8; n * 4];
|
||||||
|
for i in 0..n {
|
||||||
|
let (v, a) = (src[2 * i], src[2 * i + 1]);
|
||||||
|
let o = i * 4;
|
||||||
|
out[o] = v; out[o + 1] = v; out[o + 2] = v; out[o + 3] = a;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
other => return Err(format!("PNG: unerwarteter Ausgabetyp {other:?}")),
|
||||||
|
};
|
||||||
|
Ok(Image { width, height, rgba })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Ein `w`×`h`-PNG mit gegebenem Farbtyp im Speicher erzeugen.
|
||||||
|
fn png(w: u32, h: u32, color: png::ColorType, data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut enc = png::Encoder::new(&mut out, w, h);
|
||||||
|
enc.set_color(color);
|
||||||
|
enc.set_depth(png::BitDepth::Eight);
|
||||||
|
enc.write_header().unwrap().write_image_data(data).unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rgb_png_gets_opaque_alpha() {
|
||||||
|
// 2×1, zwei RGB-Pixel.
|
||||||
|
let bytes = png(2, 1, png::ColorType::Rgb, &[10, 20, 30, 40, 50, 60]);
|
||||||
|
let img = decode(&bytes, "image/png").unwrap();
|
||||||
|
assert_eq!((img.width, img.height), (2, 1));
|
||||||
|
assert_eq!(img.rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grayscale_png_expands_to_rgba() {
|
||||||
|
let bytes = png(2, 1, png::ColorType::Grayscale, &[128, 255]);
|
||||||
|
let img = decode(&bytes, "").unwrap(); // mime leer → Signatur greift
|
||||||
|
assert_eq!(img.rgba, vec![128, 128, 128, 255, 255, 255, 255, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_png_is_reported_not_decoded() {
|
||||||
|
assert!(decode(b"\xff\xd8\xff\xe0junk", "image/jpeg").is_err());
|
||||||
|
assert!(decode(b"whatever", "").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_the_real_project_assets() {
|
||||||
|
for (path, w, h) in [
|
||||||
|
("assets/textures/placeholder.png", 256, 256),
|
||||||
|
("assets/textures/fonts/oldschool-ega-8x14.png", 256, 112),
|
||||||
|
("assets/textures/fonts/oldschool-cga-8x8.png", 256, 64),
|
||||||
|
("assets/textures/ui/cursors.png", 32, 32),
|
||||||
|
("assets/textures/ui/panel_ornaments.png", 32, 32),
|
||||||
|
] {
|
||||||
|
let img = load(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
assert_eq!((img.width, img.height), (w, h), "{path}");
|
||||||
|
assert_eq!(img.rgba.len(), (w * h * 4) as usize, "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
|
//! Dünner Adapter um die `bladeink`-Crate — und die einzige Stelle im Kern,
|
||||||
|
//! die sie kennt. `to_ink`/`from_ink` rechnen zwischen `kv::Value` und Inks
|
||||||
|
//! `ValueType` um; alles andere spricht nur den neutralen KV-Typ.
|
||||||
//!
|
//!
|
||||||
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
||||||
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
||||||
@@ -20,8 +22,9 @@
|
|||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
|
|
||||||
use bladeink::story::Story as BladeStory;
|
use bladeink::story::Story as BladeStory;
|
||||||
|
use bladeink::value_type::ValueType;
|
||||||
|
|
||||||
use crate::engine::kv::Store;
|
use crate::engine::kv::{Store, Value};
|
||||||
|
|
||||||
pub enum StoryState {
|
pub enum StoryState {
|
||||||
Text(String),
|
Text(String),
|
||||||
@@ -140,7 +143,7 @@ impl Story {
|
|||||||
if name.starts_with('_') { continue; } // skript-lokal
|
if name.starts_with('_') { continue; } // skript-lokal
|
||||||
// set_variable schlägt fehl wenn der Name kein deklariertes
|
// set_variable schlägt fehl wenn der Name kein deklariertes
|
||||||
// VAR im Skript ist — still ignorieren.
|
// VAR im Skript ist — still ignorieren.
|
||||||
let _ = self.inner.set_variable(name, value);
|
let _ = self.inner.set_variable(name, &to_ink(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,17 +152,39 @@ impl Story {
|
|||||||
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte.
|
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte.
|
||||||
fn sync_to_kv(&self, kv: &mut Store) {
|
fn sync_to_kv(&self, kv: &mut Store) {
|
||||||
for name in &self.vars {
|
for name in &self.vars {
|
||||||
if let Some(v) = self.inner.get_variable(name) {
|
if let Some(v) = self.inner.get_variable(name).as_ref().and_then(from_ink) {
|
||||||
kv.insert(name.clone(), v);
|
kv.insert(name.clone(), v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Value`] → Inks `ValueType`. Zusammen mit [`from_ink`] die einzige Stelle,
|
||||||
|
/// an der der Kern-KV die Story-Crate berührt.
|
||||||
|
fn to_ink(v: &Value) -> ValueType {
|
||||||
|
match v {
|
||||||
|
Value::Bool(b) => ValueType::Bool(*b),
|
||||||
|
Value::Int(i) => ValueType::Int(*i),
|
||||||
|
Value::Float(f) => ValueType::Float(*f),
|
||||||
|
Value::Str(s) => ValueType::new(s.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inks `ValueType` → [`Value`]. `None` für Ink-eigene Typen (Listen, Divert-
|
||||||
|
/// und Variablen-Referenzen), die im KV nichts verloren haben.
|
||||||
|
fn from_ink(v: &ValueType) -> Option<Value> {
|
||||||
|
Some(match v {
|
||||||
|
ValueType::Bool(b) => Value::Bool(*b),
|
||||||
|
ValueType::Int(i) => Value::Int(*i),
|
||||||
|
ValueType::Float(f) => Value::Float(*f),
|
||||||
|
ValueType::String(s) => Value::Str(s.string.clone()),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use bladeink::value_type::ValueType;
|
|
||||||
|
|
||||||
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
|
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
|
||||||
// VAR snaks = 0
|
// VAR snaks = 0
|
||||||
@@ -183,7 +208,7 @@ mod tests {
|
|||||||
let mut kv = Store::new();
|
let mut kv = Store::new();
|
||||||
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||||
assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
|
assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
|
||||||
assert!(matches!(kv["snaks"], ValueType::Int(1)));
|
assert!(matches!(kv["snaks"], Value::Int(1)));
|
||||||
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
|
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +216,7 @@ mod tests {
|
|||||||
fn kv_values_flow_into_the_script() {
|
fn kv_values_flow_into_the_script() {
|
||||||
// Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`).
|
// Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`).
|
||||||
let mut kv = Store::new();
|
let mut kv = Store::new();
|
||||||
kv.insert("snaks".into(), ValueType::Int(7));
|
kv.insert("snaks".into(), Value::Int(7));
|
||||||
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||||
match s.cont(&mut kv) {
|
match s.cont(&mut kv) {
|
||||||
StoryState::Text(t) => assert_eq!(t, "S=7"),
|
StoryState::Text(t) => assert_eq!(t, "S=7"),
|
||||||
|
|||||||
@@ -3,40 +3,67 @@
|
|||||||
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
|
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
|
||||||
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
|
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
|
||||||
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
|
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
|
||||||
//! [`crate::engine::ink`]). Damit der KV authoritativ bleibt: externe Actions
|
//! [`crate::engine::ink`]). Damit der KV autoritativ bleibt: externe Actions
|
||||||
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
|
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
|
||||||
//!
|
//!
|
||||||
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung
|
//! [`Value`] ist ein eigener, Ink- und frontend-neutraler Typ. Die Umrechnung
|
||||||
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig).
|
//! auf `bladeink::ValueType` passiert allein an der Ink-Grenze in
|
||||||
|
//! [`crate::engine::ink`] — so hängt der Kern-KV nicht an der Story-Crate.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use bladeink::value_type::ValueType;
|
pub type Store = HashMap<String, Value>;
|
||||||
|
|
||||||
pub type Store = HashMap<String, ValueType>;
|
/// Ein KV-Wert. Deckt die Skalare ab, die signals.toml-Actions und Ink-`VAR`s
|
||||||
|
/// austauschen; Ink-eigene Typen (Listen, Divert-Referenzen) haben hier
|
||||||
|
/// bewusst keinen Platz.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Value {
|
||||||
|
Bool(bool),
|
||||||
|
Int(i32),
|
||||||
|
Float(f32),
|
||||||
|
Str(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Parst einen Action-Arg-String zu einem `ValueType`. Reihenfolge:
|
impl Value {
|
||||||
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
|
/// Als Ganzzahl lesen (für `inc`): `Bool` zählt als 0/1, `Float` schneidet
|
||||||
pub fn parse_value(s: &str) -> ValueType {
|
/// ab, `Str` hat keinen Zahlwert. Spiegelt die Coercion, die vorher
|
||||||
let s = s.trim();
|
/// bladeink lieferte.
|
||||||
match s {
|
pub fn as_int(&self) -> Option<i32> {
|
||||||
"true" => return ValueType::Bool(true),
|
match self {
|
||||||
"false" => return ValueType::Bool(false),
|
Value::Bool(b) => Some(*b as i32),
|
||||||
_ => {}
|
Value::Int(i) => Some(*i),
|
||||||
|
Value::Float(f) => Some(*f as i32),
|
||||||
|
Value::Str(_) => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(i) = s.parse::<i32>() { return ValueType::Int(i); }
|
|
||||||
if let Ok(f) = s.parse::<f32>() { return ValueType::Float(f); }
|
|
||||||
ValueType::new(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
|
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
|
||||||
pub fn format_value(v: &ValueType) -> String {
|
impl fmt::Display for Value {
|
||||||
match v {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
ValueType::Bool(b) => b.to_string(),
|
match self {
|
||||||
ValueType::Int(i) => i.to_string(),
|
Value::Bool(b) => write!(f, "{b}"),
|
||||||
ValueType::Float(f) => f.to_string(),
|
Value::Int(i) => write!(f, "{i}"),
|
||||||
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()),
|
Value::Float(x) => write!(f, "{x}"),
|
||||||
|
Value::Str(s) => write!(f, "{s}"),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parst einen Action-Arg-String zu einem [`Value`]. Reihenfolge:
|
||||||
|
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
|
||||||
|
pub fn parse_value(s: &str) -> Value {
|
||||||
|
let s = s.trim();
|
||||||
|
match s {
|
||||||
|
"true" => return Value::Bool(true),
|
||||||
|
"false" => return Value::Bool(false),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Ok(i) = s.parse::<i32>() { return Value::Int(i); }
|
||||||
|
if let Ok(x) = s.parse::<f32>() { return Value::Float(x); }
|
||||||
|
Value::Str(s.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_set(args: &str, store: &mut Store) {
|
pub fn apply_set(args: &str, store: &mut Store) {
|
||||||
@@ -51,8 +78,8 @@ pub fn apply_inc(args: &str, store: &mut Store) {
|
|||||||
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
|
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
let delta = delta.trim().parse::<i32>().unwrap_or(1);
|
let delta = delta.trim().parse::<i32>().unwrap_or(1);
|
||||||
let cur = store.get(name).and_then(|v| v.coerce_to_int().ok()).unwrap_or(0);
|
let cur = store.get(name).and_then(Value::as_int).unwrap_or(0);
|
||||||
store.insert(name.to_string(), ValueType::Int(cur + delta));
|
store.insert(name.to_string(), Value::Int(cur + delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_clear(args: &str, store: &mut Store) {
|
pub fn apply_clear(args: &str, store: &mut Store) {
|
||||||
@@ -65,29 +92,37 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_value_types() {
|
fn parse_value_types() {
|
||||||
assert!(matches!(parse_value("true"), ValueType::Bool(true)));
|
assert!(matches!(parse_value("true"), Value::Bool(true)));
|
||||||
assert!(matches!(parse_value("false"), ValueType::Bool(false)));
|
assert!(matches!(parse_value("false"), Value::Bool(false)));
|
||||||
assert!(matches!(parse_value("42"), ValueType::Int(42)));
|
assert!(matches!(parse_value("42"), Value::Int(42)));
|
||||||
assert!(matches!(parse_value("-7"), ValueType::Int(-7)));
|
assert!(matches!(parse_value("-7"), Value::Int(-7)));
|
||||||
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5));
|
assert!(matches!(parse_value("1.5"), Value::Float(f) if f == 1.5));
|
||||||
assert!(matches!(parse_value("hallo"), ValueType::String(_)));
|
assert!(matches!(parse_value("hallo"), Value::Str(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn as_int_coerces_like_ink_did() {
|
||||||
|
assert_eq!(Value::Bool(true).as_int(), Some(1));
|
||||||
|
assert_eq!(Value::Int(-7).as_int(), Some(-7));
|
||||||
|
assert_eq!(Value::Float(2.9).as_int(), Some(2));
|
||||||
|
assert_eq!(Value::Str("x".into()).as_int(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_inc_clear_roundtrip() {
|
fn set_inc_clear_roundtrip() {
|
||||||
let mut s = Store::new();
|
let mut s = Store::new();
|
||||||
apply_set("clicks 3", &mut s);
|
apply_set("clicks 3", &mut s);
|
||||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 3);
|
assert_eq!(s["clicks"].as_int().unwrap(), 3);
|
||||||
|
|
||||||
apply_inc("clicks", &mut s);
|
apply_inc("clicks", &mut s);
|
||||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
|
assert_eq!(s["clicks"].as_int().unwrap(), 4);
|
||||||
|
|
||||||
apply_inc("clicks -2", &mut s);
|
apply_inc("clicks -2", &mut s);
|
||||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2);
|
assert_eq!(s["clicks"].as_int().unwrap(), 2);
|
||||||
|
|
||||||
// inc auf unbekannten Key startet bei 0
|
// inc auf unbekannten Key startet bei 0
|
||||||
apply_inc("fresh 5", &mut s);
|
apply_inc("fresh 5", &mut s);
|
||||||
assert_eq!(s["fresh"].coerce_to_int().unwrap(), 5);
|
assert_eq!(s["fresh"].as_int().unwrap(), 5);
|
||||||
|
|
||||||
apply_clear("clicks", &mut s);
|
apply_clear("clicks", &mut s);
|
||||||
assert!(!s.contains_key("clicks"));
|
assert!(!s.contains_key("clicks"));
|
||||||
@@ -99,4 +134,11 @@ mod tests {
|
|||||||
apply_set("only_name", &mut s);
|
apply_set("only_name", &mut s);
|
||||||
assert!(s.is_empty());
|
assert!(s.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_is_plain_scalar() {
|
||||||
|
assert_eq!(Value::Bool(true).to_string(), "true");
|
||||||
|
assert_eq!(Value::Int(-3).to_string(), "-3");
|
||||||
|
assert_eq!(Value::Str("hi".into()).to_string(), "hi");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,42 +3,38 @@
|
|||||||
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
|
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
|
||||||
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
|
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
|
||||||
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
|
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
|
||||||
//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module,
|
|
||||||
//! die `engine` konsumieren; die Richtung kehrt sich nie um.
|
|
||||||
//!
|
//!
|
||||||
//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"):
|
//! Interner Abhängigkeitsgraph (azyklisch halten, Pfeil = „benutzt"):
|
||||||
//!
|
//!
|
||||||
//! kv ← ink ← game ← story_ctrl ← signals
|
//! kv ← ink ← game ← story_ctrl ← signals
|
||||||
//! ↑ ↑
|
//! ↑ ↑
|
||||||
//! kv assets
|
//! kv assets
|
||||||
//!
|
//!
|
||||||
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
|
//! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
|
||||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
|
||||||
//! signals::dispatch zu rufen).
|
//! rufen.
|
||||||
//!
|
//!
|
||||||
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
|
//! `gltf`, `image` und `wav` sind reine Decoder (Bytes → owned Daten);
|
||||||
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
//! `gltf` ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag
|
||||||
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
|
//! zwischen Loader und seinen Konsumenten (collision, trigger, audio,
|
||||||
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
//! render::props) — samt der Welt-Texturen, die als PNG in der `.glb` liegen.
|
||||||
//! einem Export) und produziert das neutrale `model::Model`. `audio` ist
|
//! `image` dekodiert PNG (eingebettet wie von der Platte) und stellt den
|
||||||
//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber
|
//! RGBA8-`Image`-Typ für alle.
|
||||||
//! render::audio.
|
//! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player`
|
||||||
//!
|
//! die First-Person-Physik auf der Welt aus `collision`. Post-Effekte kennt
|
||||||
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
//! der Kern nur als `game::Action::SetPost` — die Kette lebt in render::post.
|
||||||
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
|
|
||||||
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod collision;
|
pub mod collision;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod gltf;
|
pub mod gltf;
|
||||||
|
pub mod image;
|
||||||
pub mod ink;
|
pub mod ink;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
pub mod story_ctrl;
|
pub mod story_ctrl;
|
||||||
pub mod tga;
|
|
||||||
pub mod trigger;
|
pub mod trigger;
|
||||||
pub mod wav;
|
pub mod wav;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
//! Neutrales 3D-Modell — das gemeinsame Ergebnis des Blender-Loaders
|
//! Das geparste Blender-Modell: Ergebnis des Loaders (engine::gltf) und der
|
||||||
//! (engine::gltf). Konsumenten (render::props, collision, trigger) kennen
|
//! Vertrag, auf den sich seine Konsumenten stützen — collision, trigger,
|
||||||
//! nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen egal — so
|
//! audio, render::props und session::load_props arbeiten alle auf dieser
|
||||||
//! bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
|
//! einen Struktur, statt selbst glTF zu lesen. Eine Entkopplungsgrenze
|
||||||
|
//! zwischen dem einen Loader und seinen Konsumenten, keine Format-Abstraktion:
|
||||||
|
//! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind
|
||||||
|
//! Geschichte), `gltf` der einzige Produzent.
|
||||||
|
//!
|
||||||
|
//! Texturen kommen als Pixel mit — der Loader zieht sie aus der `.glb`
|
||||||
|
//! (baseColorTexture, PNG-Chunk im BIN), siehe [`Texture`]. Keine externe
|
||||||
|
//! Namenskonvention mehr.
|
||||||
//!
|
//!
|
||||||
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
|
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
|
||||||
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
|
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
|
||||||
@@ -15,12 +22,14 @@
|
|||||||
//!
|
//!
|
||||||
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
|
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
|
||||||
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
|
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
|
||||||
//! die Loader bleiben frei von Signal-Wissen.
|
//! der Loader bleibt frei von Signal-Wissen.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::engine::image::Image;
|
||||||
|
|
||||||
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
|
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
|
||||||
/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer.
|
/// Werte zu Strings vereinheitlicht.
|
||||||
pub type Props = HashMap<String, String>;
|
pub type Props = HashMap<String, String>;
|
||||||
|
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
@@ -28,10 +37,22 @@ pub struct Model {
|
|||||||
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
|
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
|
||||||
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
|
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
|
||||||
pub empties: Vec<Empty>,
|
pub empties: Vec<Empty>,
|
||||||
/// Distinkte Materialnamen in Auftrittsreihenfolge; `Object::tri_mats`
|
/// Distinkte Texturen in Auftrittsreihenfolge; `Object::tri_mats`
|
||||||
/// indiziert hierhin. `""` steht für „Faces ohne Material" — der
|
/// indiziert hierhin.
|
||||||
/// Konsument wählt dafür seinen Fallback (z.B. Platzhalter-Textur).
|
pub textures: Vec<Texture>,
|
||||||
pub materials: Vec<String>,
|
}
|
||||||
|
|
||||||
|
/// Eine baseColorTexture aus der `.glb` (bzw. ein Material ohne Bild).
|
||||||
|
pub struct Texture {
|
||||||
|
/// Dedup-/Log-Schlüssel: der glTF-Bildname (Blender: Name des
|
||||||
|
/// Bild-Datablocks), sonst der Materialname, sonst `""`. Texturen mit
|
||||||
|
/// gleichem Schlüssel — auch über mehrere `.glb` — teilen sich auf der
|
||||||
|
/// GPU eine Textur.
|
||||||
|
pub key: String,
|
||||||
|
/// Dekodierte Pixel (RGBA8, Ursprung oben links). `None`: Material ohne
|
||||||
|
/// baseColorTexture oder unlesbares Bild → der Konsument nimmt seine
|
||||||
|
/// Platzhalter-Textur.
|
||||||
|
pub image: Option<Image>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Object {
|
pub struct Object {
|
||||||
@@ -40,10 +61,13 @@ pub struct Object {
|
|||||||
/// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit).
|
/// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit).
|
||||||
pub verts: Vec<[f32; 3]>,
|
pub verts: Vec<[f32; 3]>,
|
||||||
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
|
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
|
||||||
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
|
/// `image::Image`-Konvention, direkt sampelbar ohne Flip.
|
||||||
pub uvs: Vec<[f32; 2]>,
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
/// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im
|
||||||
|
/// Modell, rechnet der Loader sie aus den Flächen.
|
||||||
|
pub normals: Vec<[f32; 3]>,
|
||||||
pub tris: Vec<[usize; 3]>,
|
pub tris: Vec<[usize; 3]>,
|
||||||
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
|
/// Textur-Index je Dreieck, parallel zu `tris` (→ `Model::textures`).
|
||||||
pub tri_mats: Vec<usize>,
|
pub tri_mats: Vec<usize>,
|
||||||
pub visible: bool,
|
pub visible: bool,
|
||||||
pub collider: bool,
|
pub collider: bool,
|
||||||
@@ -77,6 +101,39 @@ pub const SIGNAL_PROP: &str = "signal";
|
|||||||
pub const ROLE_PROP: &str = "role";
|
pub const ROLE_PROP: &str = "role";
|
||||||
pub const ROLE_SPAWN: &str = "spawn";
|
pub const ROLE_SPAWN: &str = "spawn";
|
||||||
|
|
||||||
|
/// Auf Länge 1 bringen; der Nullvektor bleibt der Nullvektor (Sentinel für
|
||||||
|
/// „noch keine Normale" in [`fill_missing_normals`]).
|
||||||
|
pub fn norm(v: [f32; 3]) -> [f32; 3] {
|
||||||
|
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||||
|
if l > 0.0 { [v[0] / l, v[1] / l, v[2] / l] } else { v }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fehlende Normalen (Nullvektoren) aus den angrenzenden Flächen füllen —
|
||||||
|
/// glTF erlaubt Meshes ohne `NORMAL`. Die Flächennormalen werden je Vertex
|
||||||
|
/// aufsummiert, was an Kanten weich mittelt; Alternative wäre, die Vertices
|
||||||
|
/// je Fläche aufzuspalten (hartes Flat-Shading) — dafür ist der Look hier
|
||||||
|
/// nicht empfindlich genug.
|
||||||
|
pub fn fill_missing_normals(o: &mut Object) {
|
||||||
|
if !o.normals.contains(&[0.0; 3]) { return; }
|
||||||
|
let mut acc = vec![[0.0f32; 3]; o.verts.len()];
|
||||||
|
for t in &o.tris {
|
||||||
|
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
|
||||||
|
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||||
|
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||||||
|
let n = [
|
||||||
|
u[1] * v[2] - u[2] * v[1],
|
||||||
|
u[2] * v[0] - u[0] * v[2],
|
||||||
|
u[0] * v[1] - u[1] * v[0],
|
||||||
|
];
|
||||||
|
for &i in t {
|
||||||
|
for k in 0..3 { acc[i][k] += n[k]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, n) in o.normals.iter_mut().enumerate() {
|
||||||
|
if *n == [0.0; 3] { *n = norm(acc[i]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
||||||
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
||||||
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
|
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
|
||||||
@@ -119,6 +176,11 @@ pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal
|
|||||||
let mut o = Object {
|
let mut o = Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
uvs: vec![[0.0, 0.0]; verts.len()],
|
uvs: vec![[0.0, 0.0]; verts.len()],
|
||||||
|
// Vom Mittelpunkt nach außen — die Collider-Tests rendern nie, aber
|
||||||
|
// so ist die Richtung wenigstens plausibel.
|
||||||
|
normals: verts.iter()
|
||||||
|
.map(|v| norm([v[0] - min[0] - 0.5, v[1] - min[1] - 0.5, v[2] - min[2] - 0.5]))
|
||||||
|
.collect(),
|
||||||
verts, tris,
|
verts, tris,
|
||||||
tri_mats: vec![0; n],
|
tri_mats: vec![0; n],
|
||||||
visible: true, collider: false, props,
|
visible: true, collider: false, props,
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
//! First-Person-Player: headless Bewegungsphysik (Stufe 1).
|
//! First-Person-Player: headless Bewegungsphysik.
|
||||||
//!
|
//!
|
||||||
//! Bewusst frontend- und GPU-frei — wie der Rest von `engine`. Der Renderer
|
//! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] und gleitet an
|
||||||
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`),
|
//! Treffern entlang (move-and-slide). Getract wird der Box-Mittelpunkt
|
||||||
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal
|
//! (Fußpunkt + halbe Höhe), danach geht es zurück auf den Fußpunkt.
|
||||||
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
//! Bodenkontakt fällt aus nach oben zeigenden Trefferflächen ab. `fly` ist der
|
||||||
|
//! Noclip-Gegenpart ohne Welt.
|
||||||
//!
|
//!
|
||||||
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
//! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
|
||||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
|
//! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
|
||||||
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
|
//! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
|
||||||
//! Nutzer echte dot/cross/normalize braucht.
|
//! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
|
||||||
//!
|
|
||||||
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
|
||||||
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
|
||||||
//! Getract wird der Box-Mittelpunkt (Fußpunkt + halbe Höhe), danach geht es
|
|
||||||
//! zurück auf den Fußpunkt. Bodenkontakt fällt aus nach oben zeigenden
|
|
||||||
//! Trefferflächen ab.
|
|
||||||
//!
|
|
||||||
//! Konvention wie `render::math::view`/`camera.rs`: yaw=0 blickt −Z, positiver
|
|
||||||
//! Yaw dreht nach links, positiver Pitch hebt den Blick. Die Bewegung ist
|
|
||||||
//! horizontal aus dem Yaw (ohne Pitch) — Hochschauen lässt einen nicht abheben.
|
|
||||||
|
|
||||||
use crate::engine::collision::CollisionWorld;
|
use crate::engine::collision::CollisionWorld;
|
||||||
|
|
||||||
@@ -26,19 +17,24 @@ use crate::engine::collision::CollisionWorld;
|
|||||||
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
pub const EYE_HEIGHT: f32 = 1.6;
|
pub const EYE_HEIGHT: f32 = 1.6;
|
||||||
|
|
||||||
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
/// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
|
||||||
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
|
||||||
const GRAVITY: f32 = 20.0;
|
const GRAVITY: f32 = 20.0;
|
||||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
const WALK_SPEED: f32 = 5.0;
|
const WALK_SPEED: f32 = 5.0;
|
||||||
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe.
|
||||||
const JUMP_SPEED: f32 = 7.0;
|
const JUMP_SPEED: f32 = 7.0;
|
||||||
|
/// Flug-Geschwindigkeit im Noclip (units/s).
|
||||||
|
const FLY_SPEED: f32 = 4.0;
|
||||||
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
||||||
const PITCH_LIMIT: f32 = 1.55;
|
const PITCH_LIMIT: f32 = 1.55;
|
||||||
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
||||||
/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft.
|
/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft.
|
||||||
pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
||||||
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok).
|
/// Grenzwinkel für begehbaren Boden, als Kosinus (= Y-Komponente der
|
||||||
|
/// Trefferormale einer Fläche mit diesem Neigungswinkel). 0.7 ≈ **45°**:
|
||||||
|
/// flacher wird gelaufen, steiler wird gerutscht. Das ist *der* Regler fürs
|
||||||
|
/// Hangverhalten — Wunschwinkel als Kosinus eintragen (30° → 0.866,
|
||||||
|
/// 50° → 0.643, 60° → 0.5).
|
||||||
const GROUND_NORMAL_Y: f32 = 0.7;
|
const GROUND_NORMAL_Y: f32 = 0.7;
|
||||||
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
||||||
const MAX_SLIDES: u32 = 4;
|
const MAX_SLIDES: u32 = 4;
|
||||||
@@ -77,48 +73,68 @@ impl Player {
|
|||||||
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
|
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/
|
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
|
||||||
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den
|
/// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
|
||||||
/// Point-and-Click-Strahl aus der Bildmitte.
|
|
||||||
pub fn look_dir(&self) -> [f32; 3] {
|
pub fn look_dir(&self) -> [f32; 3] {
|
||||||
let (sy, cy) = self.yaw.sin_cos();
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
let (sp, cp) = self.pitch.sin_cos();
|
let (sp, cp) = self.pitch.sin_cos();
|
||||||
[-sy * cp, sp, -cy * cp]
|
[-sy * cp, sp, -cy * cp]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Horizontales Rechts (ohne Pitch) — Strafen kippt nie mit dem Blick.
|
||||||
|
pub fn right_dir(&self) -> [f32; 3] {
|
||||||
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
|
[cy, 0.0, -sy]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noclip: frei durch die Welt, ohne Kollision und ohne Gravitation.
|
||||||
|
/// Vorwärts folgt dem vollen Blick, `up` ist Welt-Y (Auf-/Absteigen bleibt
|
||||||
|
/// unabhängig vom Blickwinkel).
|
||||||
|
pub fn fly(&mut self, fwd: f32, right: f32, up: f32, dt: f32) {
|
||||||
|
let (f, r) = (self.look_dir(), self.right_dir());
|
||||||
|
for i in 0..3 {
|
||||||
|
self.pos[i] += (f[i] * fwd + r[i] * right) * FLY_SPEED * dt;
|
||||||
|
}
|
||||||
|
self.pos[1] += up * FLY_SPEED * dt;
|
||||||
|
self.vel = [0.0; 3];
|
||||||
|
self.grounded = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
||||||
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
||||||
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
||||||
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
|
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
|
||||||
let was_grounded = self.grounded;
|
let was_grounded = self.grounded;
|
||||||
|
|
||||||
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0,
|
// Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
|
||||||
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach.
|
// mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
|
||||||
|
// Hochschauen nicht abhebt.
|
||||||
let (sy, cy) = self.yaw.sin_cos();
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
let f = [-sy, -cy]; // (x, z)
|
let r = self.right_dir();
|
||||||
let r = [ cy, -sy]; // (x, z)
|
self.vel[0] = (-sy * fwd + r[0] * right) * WALK_SPEED;
|
||||||
|
self.vel[2] = (-cy * fwd + r[2] * right) * WALK_SPEED;
|
||||||
|
|
||||||
// Wunschgeschwindigkeit sofort setzen (ohne Beschleunigung/Reibung —
|
// Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
|
||||||
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst.
|
// der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
|
||||||
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED;
|
// hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
|
||||||
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED;
|
// aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
|
||||||
|
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
|
||||||
// Sprung vor der Gravitation, nur vom Boden (Vorframe-`grounded`) —
|
|
||||||
// kein Doppelsprung.
|
|
||||||
let jumped = jump && was_grounded;
|
let jumped = jump && was_grounded;
|
||||||
if jumped {
|
if jumped {
|
||||||
self.vel[1] = JUMP_SPEED;
|
self.vel[1] = JUMP_SPEED;
|
||||||
self.grounded = false;
|
self.grounded = false;
|
||||||
}
|
} else if was_grounded {
|
||||||
|
self.vel[1] = 0.0;
|
||||||
|
} else {
|
||||||
self.vel[1] -= GRAVITY * dt;
|
self.vel[1] -= GRAVITY * dt;
|
||||||
|
}
|
||||||
|
|
||||||
self.move_and_slide(world, dt);
|
self.move_and_slide(world, dt);
|
||||||
|
|
||||||
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen
|
// Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
|
||||||
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter,
|
// knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
|
||||||
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über
|
// ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
|
||||||
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim
|
// Rampen-Absprünge mehr.
|
||||||
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
|
|
||||||
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
|
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
|
||||||
self.snap_to_ground(world);
|
self.snap_to_ground(world);
|
||||||
}
|
}
|
||||||
@@ -142,11 +158,9 @@ impl Player {
|
|||||||
///
|
///
|
||||||
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
|
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
|
||||||
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
|
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
|
||||||
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und
|
/// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
|
||||||
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box-
|
/// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
|
||||||
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst
|
/// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
|
||||||
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
|
|
||||||
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
|
|
||||||
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
|
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
|
||||||
let half = HALF_EXTENTS;
|
let half = HALF_EXTENTS;
|
||||||
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
|
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
|
||||||
@@ -346,6 +360,22 @@ mod tests {
|
|||||||
assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]);
|
assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fly_ignores_gravity_and_geometry() {
|
||||||
|
// Noclip kennt die Welt gar nicht: nach unten durch jeden Boden, und
|
||||||
|
// vorwärts dem vollen Blick nach — ohne Gravitation.
|
||||||
|
let mut p = Player::new([0.0, 2.0, 0.0]);
|
||||||
|
p.fly(0.0, 0.0, -1.0, 1.0);
|
||||||
|
assert!(p.pos[1] < -1.0, "sollte durch den Boden sinken: {}", p.pos[1]);
|
||||||
|
assert_eq!(p.vel, [0.0; 3], "Flug trägt keine Geschwindigkeit");
|
||||||
|
|
||||||
|
p.pitch = std::f32::consts::FRAC_PI_2; // gerade nach oben schauen
|
||||||
|
let y0 = p.pos[1];
|
||||||
|
p.fly(1.0, 0.0, 0.0, 1.0);
|
||||||
|
assert!(p.pos[1] > y0 + 1.0, "vorwärts folgt dem Blick: {y0} -> {}", p.pos[1]);
|
||||||
|
assert!(!p.grounded);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wall_blocks_horizontal_movement() {
|
fn wall_blocks_horizontal_movement() {
|
||||||
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
|
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
|
||||||
@@ -462,6 +492,77 @@ mod tests {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keilförmige Rampe, die in +X mit `deg` Grad ansteigt (Fuß bei x=0,
|
||||||
|
/// Länge 10). Bergab ist damit -X.
|
||||||
|
fn slope_world(deg: f32) -> CollisionWorld {
|
||||||
|
let (s, c) = deg.to_radians().sin_cos();
|
||||||
|
collision::from_brushes(vec![vec![
|
||||||
|
collision::Plane { n: [-s, c, 0.0], d: 0.0 }, // Rampenoberfläche
|
||||||
|
collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 }, // Unterseite (y≥0)
|
||||||
|
collision::Plane { n: [1.0, 0.0, 0.0], d: 10.0 }, // x≤10
|
||||||
|
collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 }, // x≥0
|
||||||
|
collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 },
|
||||||
|
collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 },
|
||||||
|
]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auf eine Rampe fallen lassen und setzen — gibt den Ruhepunkt zurück.
|
||||||
|
fn settle(w: &CollisionWorld, x: f32, deg: f32) -> Player {
|
||||||
|
// Über der Fläche starten — mit Abstand, sonst steckt die aufgeblasene
|
||||||
|
// Box schon in der Rampe und die Trace lässt sie durchfallen.
|
||||||
|
let y = x * deg.to_radians().tan() + 1.0;
|
||||||
|
let mut p = Player::new([x, y, 0.0]);
|
||||||
|
for _ in 0..60 { p.step(w, 0.0, 0.0, false, 1.0 / 60.0); }
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stands_still_on_walkable_slope() {
|
||||||
|
// 30° < Grenzwinkel: ohne Eingabe darf der Spieler nicht abwärts
|
||||||
|
// kriechen — sonst ist der Hangabtrieb zurück.
|
||||||
|
let w = slope_world(30.0);
|
||||||
|
let mut p = settle(&w, 5.0, 30.0);
|
||||||
|
assert!(p.grounded, "sollte auf der 30°-Rampe stehen: {:?}", p.pos);
|
||||||
|
let (x0, y0) = (p.pos[0], p.pos[1]);
|
||||||
|
for _ in 0..300 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // 5 Sekunden
|
||||||
|
assert!((p.pos[0] - x0).abs() < 0.01, "kein Hangabtrieb: x {x0} -> {}", p.pos[0]);
|
||||||
|
assert!((p.pos[1] - y0).abs() < 0.01, "keine Höhendrift: y {y0} -> {}", p.pos[1]);
|
||||||
|
assert!(p.grounded, "bleibt am Boden");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slides_down_steep_slope() {
|
||||||
|
// 60° > Grenzwinkel: hier soll er abrutschen (bergab = -X).
|
||||||
|
let w = slope_world(60.0);
|
||||||
|
let mut p = settle(&w, 5.0, 60.0);
|
||||||
|
assert!(!p.grounded, "zu steil zum Stehen: {:?}", p.pos);
|
||||||
|
let (x0, y0) = (p.pos[0], p.pos[1]);
|
||||||
|
for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); }
|
||||||
|
assert!(p.pos[0] < x0 - 0.5, "sollte hangabwärts rutschen: x {x0} -> {}", p.pos[0]);
|
||||||
|
assert!(p.pos[1] < y0 - 0.5, "und dabei sinken: y {y0} -> {}", p.pos[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walks_up_and_down_walkable_slope() {
|
||||||
|
// Der stehenbleibende Spieler muss trotzdem beide Richtungen laufen.
|
||||||
|
let w = slope_world(30.0);
|
||||||
|
let mut up = settle(&w, 5.0, 30.0);
|
||||||
|
up.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X, bergauf
|
||||||
|
let y0 = up.pos[1];
|
||||||
|
for _ in 0..60 { up.step(&w, 1.0, 0.0, false, 1.0 / 60.0); }
|
||||||
|
assert!(up.pos[1] > y0 + 0.5, "bergauf steigt y: {y0} -> {}", up.pos[1]);
|
||||||
|
assert!(up.grounded, "bleibt bergauf am Boden");
|
||||||
|
|
||||||
|
let mut down = settle(&w, 5.0, 30.0);
|
||||||
|
down.yaw = std::f32::consts::FRAC_PI_2; // vorwärts → -X, bergab
|
||||||
|
let y0 = down.pos[1];
|
||||||
|
for i in 0..60 {
|
||||||
|
down.step(&w, 1.0, 0.0, false, 1.0 / 60.0);
|
||||||
|
assert!(down.grounded, "bergab am Boden kleben (i={i}, {:?})", down.pos);
|
||||||
|
}
|
||||||
|
assert!(down.pos[1] < y0 - 0.5, "bergab sinkt y: {y0} -> {}", down.pos[1]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn walks_down_slope_across_seams_at_60fps() {
|
fn walks_down_slope_across_seams_at_60fps() {
|
||||||
let w = slope_between_floors();
|
let w = slope_between_floors();
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
//! `assets/sounds/{name}.wav`
|
//! `assets/sounds/{name}.wav`
|
||||||
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
||||||
//! Eingabemodus wechseln
|
//! Eingabemodus wechseln
|
||||||
|
//! `post <name> <on|off>` → Action::SetPost (deferred): Renderer-
|
||||||
|
//! Post-Effekt schalten (Name = Kettenglied
|
||||||
|
//! im Fenster-Frontend)
|
||||||
//!
|
//!
|
||||||
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
|
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
|
||||||
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
|
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
|
||||||
@@ -101,11 +104,29 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
|
|||||||
"mode" => if let Some(t) = ModeTarget::parse(args) {
|
"mode" => if let Some(t) = ModeTarget::parse(args) {
|
||||||
ctx.actions.push(Action::SetMode(t));
|
ctx.actions.push(Action::SetMode(t));
|
||||||
},
|
},
|
||||||
|
"post" => if let Some((name, on)) = parse_post_arg(args) {
|
||||||
|
ctx.actions.push(Action::SetPost { name, on });
|
||||||
|
},
|
||||||
_ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
|
_ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `"<name> on"` / `"<name> off"` → `(name, bool)`; sonst `None`. Kein
|
||||||
|
/// nackter Toggle — eine geskriptete Schaltung soll lesbar sagen, wohin sie
|
||||||
|
/// schaltet. Geteilt von der Dispatch-Pipeline (`post`-Verb) und dem
|
||||||
|
/// `post`-Konsolenbefehl in `session`.
|
||||||
|
pub fn parse_post_arg(args: &str) -> Option<(String, bool)> {
|
||||||
|
let (name, state) = args.trim().split_once(char::is_whitespace)?;
|
||||||
|
let on = match state.trim() {
|
||||||
|
"on" => true,
|
||||||
|
"off" => false,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let name = name.trim();
|
||||||
|
(!name.is_empty()).then(|| (name.to_string(), on))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
|
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
|
||||||
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
|
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
|
||||||
/// das Format ist trivial und die Engine bleibt dependency-arm.
|
/// das Format ist trivial und die Engine bleibt dependency-arm.
|
||||||
@@ -188,7 +209,7 @@ mod tests {
|
|||||||
let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
|
let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
|
||||||
dispatch("pickup", &mut ctx);
|
dispatch("pickup", &mut ctx);
|
||||||
|
|
||||||
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
|
assert_eq!(game.kv["items"].as_int().unwrap(), 1);
|
||||||
assert_eq!(game.actions, vec![
|
assert_eq!(game.actions, vec![
|
||||||
Action::PlaySound("Mushroom.005".into()),
|
Action::PlaySound("Mushroom.005".into()),
|
||||||
]);
|
]);
|
||||||
@@ -208,6 +229,23 @@ mod tests {
|
|||||||
assert!(game.actions.is_empty());
|
assert!(game.actions.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_verb_queues_setpost_action() {
|
||||||
|
let mut game = Game::new(Signals::new());
|
||||||
|
let mut ctx = game.action_ctx(None);
|
||||||
|
dispatch("post outline off", &mut ctx);
|
||||||
|
assert_eq!(game.actions, vec![
|
||||||
|
Action::SetPost { name: "outline".into(), on: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fehlendes/ungültiges on|off → kein Action (stilles No-Op wie `mode`).
|
||||||
|
game.actions.clear();
|
||||||
|
let mut ctx = game.action_ctx(None);
|
||||||
|
dispatch("post outline", &mut ctx);
|
||||||
|
dispatch("post outline wat", &mut ctx);
|
||||||
|
assert!(game.actions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typo_verb_in_table_is_tolerated_and_reported() {
|
fn typo_verb_in_table_is_tolerated_and_reported() {
|
||||||
// Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
|
// Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
|
||||||
@@ -221,7 +259,7 @@ mod tests {
|
|||||||
let mut ctx = game.action_ctx(None);
|
let mut ctx = game.action_ctx(None);
|
||||||
dispatch("thing", &mut ctx);
|
dispatch("thing", &mut ctx);
|
||||||
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
|
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
|
||||||
assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter");
|
assert!(matches!(game.kv["ok"], kv::Value::Bool(true)), "gültige Action wirkt weiter");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -230,7 +268,7 @@ mod tests {
|
|||||||
let mut ctx = game.action_ctx(None);
|
let mut ctx = game.action_ctx(None);
|
||||||
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
|
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
|
||||||
dispatch("set has_key true", &mut ctx);
|
dispatch("set has_key true", &mut ctx);
|
||||||
assert!(game.kv["has_key"].coerce_to_bool().unwrap());
|
assert!(matches!(game.kv["has_key"], kv::Value::Bool(true)));
|
||||||
|
|
||||||
// Unbekanntes Verb ist ein stilles No-Op.
|
// Unbekanntes Verb ist ein stilles No-Op.
|
||||||
let mut ctx = game.action_ctx(None);
|
let mut ctx = game.action_ctx(None);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
|
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
|
||||||
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
|
//! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
|
||||||
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
|
//! in seine Darstellung.
|
||||||
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
|
|
||||||
//!
|
//!
|
||||||
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
|
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
|
||||||
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
|
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
//! TGA-Decoder → fertig dekodiertes RGBA8-`Image`.
|
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
//!
|
|
||||||
//! 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
|
|
||||||
//! Font-Maps. Paletten-TGAs gibt es nicht mehr (wds ist 15-bit True-Color).
|
|
||||||
|
|
||||||
pub struct Image {
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
/// `width * height * 4`, Ursprung oben-links.
|
|
||||||
pub rgba: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(path: &str) -> Result<Image, String> {
|
|
||||||
let bytes = std::fs::read(path).map_err(|e| format!("tga load {path}: {e}"))?;
|
|
||||||
decode(&bytes).map_err(|e| format!("tga {path}: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode(d: &[u8]) -> Result<Image, String> {
|
|
||||||
if d.len() < 18 { return Err("Header zu kurz".into()); }
|
|
||||||
let id_len = d[0] as usize;
|
|
||||||
let cmap_type = d[1];
|
|
||||||
let image_type = d[2];
|
|
||||||
let width = u16::from_le_bytes([d[12], d[13]]) as u32;
|
|
||||||
let height = u16::from_le_bytes([d[14], d[15]]) as u32;
|
|
||||||
let depth = d[16];
|
|
||||||
let descriptor = d[17];
|
|
||||||
|
|
||||||
if cmap_type != 0 {
|
|
||||||
return Err("Color-Map-TGAs nicht unterstützt (True-Color only)".into());
|
|
||||||
}
|
|
||||||
// Pixeldaten beginnen nach Header + ID-Feld (Color-Map ist leer).
|
|
||||||
let off = 18 + id_len;
|
|
||||||
|
|
||||||
let channels = match (image_type, depth) {
|
|
||||||
(2 | 10, 24) => 3,
|
|
||||||
(2 | 10, 32) => 4,
|
|
||||||
(3 | 11, 8) => 1,
|
|
||||||
(t, b) => return Err(format!("nicht unterstützt: Typ {t}, {b} bpp")),
|
|
||||||
};
|
|
||||||
let n = (width as usize) * (height as usize);
|
|
||||||
if n == 0 { return Err("Nullgröße".into()); }
|
|
||||||
|
|
||||||
// Rohpixel (channels Bytes je Pixel), bei RLE entpackt.
|
|
||||||
let raw_len = n * channels;
|
|
||||||
let raw: Vec<u8> = match image_type {
|
|
||||||
2 | 3 => d.get(off..off + raw_len).ok_or("Pixeldaten zu kurz")?.to_vec(),
|
|
||||||
10 | 11 => decode_rle(d.get(off..).ok_or("RLE-Daten fehlen")?, raw_len, channels)?,
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// → RGBA8. True-Color liegt im TGA als BGR(A) vor; Graustufen replizieren.
|
|
||||||
let mut rgba = vec![0u8; n * 4];
|
|
||||||
for i in 0..n {
|
|
||||||
let (r, g, b, a) = match channels {
|
|
||||||
1 => { let v = raw[i]; (v, v, v, 255) }
|
|
||||||
3 => (raw[i * 3 + 2], raw[i * 3 + 1], raw[i * 3], 255),
|
|
||||||
4 => (raw[i * 4 + 2], raw[i * 4 + 1], raw[i * 4], raw[i * 4 + 3]),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
let o = i * 4;
|
|
||||||
rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ursprung oben-links erzwingen. Descriptor-Bit 5 gesetzt = top-origin.
|
|
||||||
if (descriptor >> 5) & 1 == 0 {
|
|
||||||
flip_vertical(&mut rgba, width as usize, height as usize);
|
|
||||||
}
|
|
||||||
Ok(Image { width, height, rgba })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TGA-RLE: Pakete operieren auf ganzen Pixeln (`ch` Bytes).
|
|
||||||
fn decode_rle(src: &[u8], raw_len: usize, ch: usize) -> Result<Vec<u8>, String> {
|
|
||||||
let mut out = Vec::with_capacity(raw_len);
|
|
||||||
let mut si = 0;
|
|
||||||
while out.len() < raw_len {
|
|
||||||
let header = *src.get(si).ok_or("RLE: Quelle zu kurz")?;
|
|
||||||
si += 1;
|
|
||||||
let count = (header & 0x7f) as usize + 1;
|
|
||||||
if header & 0x80 != 0 {
|
|
||||||
// RLE-Paket: ein Pixel count-mal.
|
|
||||||
let px = src.get(si..si + ch).ok_or("RLE: Pixel zu kurz")?;
|
|
||||||
si += ch;
|
|
||||||
for _ in 0..count { out.extend_from_slice(px); }
|
|
||||||
} else {
|
|
||||||
// Raw-Paket: count Pixel am Stück.
|
|
||||||
let bytes = count * ch;
|
|
||||||
let chunk = src.get(si..si + bytes).ok_or("RLE: Chunk zu kurz")?;
|
|
||||||
si += bytes;
|
|
||||||
out.extend_from_slice(chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.truncate(raw_len);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flip_vertical(rgba: &mut [u8], w: usize, h: usize) {
|
|
||||||
let row = w * 4;
|
|
||||||
for y in 0..h / 2 {
|
|
||||||
let (a, b) = (y * row, (h - 1 - y) * row);
|
|
||||||
for x in 0..row { rgba.swap(a + x, b + x); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bgr_to_rgba_uncompressed() {
|
|
||||||
// Minimal-TGA: Typ 2, 2×1, 24 bpp, bottom-origin (descriptor 0).
|
|
||||||
// Zwei Pixel BGR (10,20,30) und (40,50,60).
|
|
||||||
let mut d = vec![0u8; 18];
|
|
||||||
d[2] = 2; d[12] = 2; d[14] = 1; d[16] = 24;
|
|
||||||
d.extend_from_slice(&[10, 20, 30, 40, 50, 60]);
|
|
||||||
let img = decode(&d).unwrap();
|
|
||||||
assert_eq!((img.width, img.height), (2, 1));
|
|
||||||
assert_eq!(&img.rgba[0..4], &[30, 20, 10, 255]);
|
|
||||||
assert_eq!(&img.rgba[4..8], &[60, 50, 40, 255]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rle_truecolor_expands() {
|
|
||||||
// Typ 10, 4×1, 24 bpp: ein RLE-Paket (4× BGR 1,2,3).
|
|
||||||
let mut d = vec![0u8; 18];
|
|
||||||
d[2] = 10; d[12] = 4; d[14] = 1; d[16] = 24;
|
|
||||||
d.push(0x80 | 3); // RLE, count = 4
|
|
||||||
d.extend_from_slice(&[1, 2, 3]); // ein Pixel
|
|
||||||
let img = decode(&d).unwrap();
|
|
||||||
assert_eq!(img.rgba.len(), 4 * 4);
|
|
||||||
assert!(img.rgba.chunks(4).all(|p| p == [3, 2, 1, 255]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decodes_real_placeholder() {
|
|
||||||
let bytes = std::fs::read("assets/textures/placeholder.tga").unwrap();
|
|
||||||
let img = decode(&bytes).unwrap();
|
|
||||||
assert_eq!((img.width, img.height), (256, 256));
|
|
||||||
assert_eq!(img.rgba.len(), 256 * 256 * 4);
|
|
||||||
assert!(img.rgba.chunks(4).all(|p| p[3] == 255)); // 24bpp → Alpha 255
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -100,7 +100,7 @@ mod tests {
|
|||||||
use crate::engine::model::{test_cube as cube, Object};
|
use crate::engine::model::{test_cube as cube, Object};
|
||||||
|
|
||||||
fn model(objects: Vec<Object>) -> Model {
|
fn model(objects: Vec<Object>) -> Model {
|
||||||
Model { objects, empties: Vec::new(), materials: vec![String::new()] }
|
Model { objects, empties: Vec::new(), textures: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
|
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
|
||||||
//!
|
//!
|
||||||
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
|
//! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
|
||||||
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
|
//! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
|
||||||
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
|
|
||||||
//! File wird gemeldet und bleibt still, nie fatal.
|
|
||||||
//!
|
//!
|
||||||
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
|
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
|
||||||
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
|
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends
|
//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends
|
||||||
//! steuern dieselbe Session: das Fenster (`render`, Default) und die
|
//! steuern dieselbe Session: das Fenster (`render`, Default) und die
|
||||||
//! Konsolen-REPL (`cli`, via `--cli`).
|
//! Konsolen-REPL (`cli`, via `--cli`).
|
||||||
|
//!
|
||||||
|
//! Flags: `--cli` startet die Konsolen-REPL statt des Fensters; `--maps <dir>`
|
||||||
|
//! überschreibt das Verzeichnis der Welt-`.glb` (Pfad wie angegeben, also
|
||||||
|
//! relativ zum CWD).
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod engine;
|
mod engine;
|
||||||
@@ -15,11 +19,22 @@ use engine::assets;
|
|||||||
use session::Session;
|
use session::Session;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let signals_path = assets::path("assets/signals.toml");
|
let signals_path = assets::path("assets/signals.toml");
|
||||||
let session = Session::new(signals_path.clone());
|
let session = Session::new(signals_path.clone());
|
||||||
if std::env::args().any(|a| a == "--cli") {
|
if args.iter().any(|a| a == "--cli") {
|
||||||
cli::run(session, &signals_path);
|
cli::run(session, &signals_path);
|
||||||
} else {
|
} else {
|
||||||
render::run(session);
|
let maps_dir = arg_value(&args, "--maps")
|
||||||
|
.unwrap_or_else(|| assets::path(render::DEFAULT_MAPS_DIR));
|
||||||
|
render::run(session, &maps_dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wert eines `--flag <wert>`-Paars aus `args`, falls das Flag vorkommt und
|
||||||
|
/// ein Wort folgt.
|
||||||
|
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
||||||
|
args.iter().position(|a| a == flag)
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
|
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
|
||||||
//! dieses Modul nie).
|
//! dieses Modul nie).
|
||||||
//!
|
//!
|
||||||
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
|
//! Kein Panic: kein Gerät oder fehlende/kaputte WAVs sind eine Meldung und
|
||||||
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
|
//! Stille, nie fatal ([`Audio::open`] liefert `None`, alle Aufrufe darauf
|
||||||
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
|
//! entfallen still beim Aufrufer).
|
||||||
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
|
|
||||||
//!
|
//!
|
||||||
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
|
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
|
||||||
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
|
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
|
||||||
|
|||||||
@@ -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:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
//! seinen GPU-Textur-Index, damit `ui::Ui::text` ohne Zusatzargumente damit
|
//! seinen GPU-Textur-Index, damit `ui::Ui::text` ohne Zusatzargumente damit
|
||||||
//! zeichnen kann.
|
//! zeichnen kann.
|
||||||
|
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::image::Image;
|
||||||
|
|
||||||
const COLS: u32 = 32;
|
const COLS: u32 = 32;
|
||||||
|
|
||||||
@@ -47,11 +47,11 @@ pub(crate) struct Fonts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Font {
|
impl Font {
|
||||||
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.tga`, 256×112).
|
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.png`, 256×112).
|
||||||
pub(crate) fn ega(tex: usize, atlas: &Image) -> Font {
|
pub(crate) fn ega(tex: usize, atlas: &Image) -> Font {
|
||||||
Font::measure(8.0, 14.0, 256.0, 112.0, tex, atlas)
|
Font::measure(8.0, 14.0, 256.0, 112.0, tex, atlas)
|
||||||
}
|
}
|
||||||
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.tga`, 256×64).
|
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.png`, 256×64).
|
||||||
pub(crate) fn cga(tex: usize, atlas: &Image) -> Font {
|
pub(crate) fn cga(tex: usize, atlas: &Image) -> Font {
|
||||||
Font::measure(8.0, 8.0, 256.0, 64.0, tex, atlas)
|
Font::measure(8.0, 8.0, 256.0, 64.0, tex, atlas)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
//! wgpu-Zustand: Surface, Device und der interne Render-Pfad:
|
||||||
//! aus dem Renderer-Plan:
|
|
||||||
//!
|
//!
|
||||||
//! Pass 1 (intern): 640×480 RGBA8 + Depth — hier entsteht das Bild
|
//! Szene (intern): 640×480 RGBA8 + Depth — Geometrie und Textur
|
||||||
//! ([`ScenePass`], PS1-Shader).
|
//! ([`ScenePass`]).
|
||||||
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
//! Post (intern): die Effekt-Kette ([`Post`]), Ping-Pong zwischen zwei
|
||||||
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
//! Targets; jeder Effekt einzeln schaltbar.
|
||||||
|
//! Overlay (intern): das UI ([`SpritePass`]) — *nach* der Kette, damit
|
||||||
|
//! Bild-Effekte nur die Welt treffen, nie den Text.
|
||||||
|
//! Blit (Fenster): Nearest-Upscale mit 4:3-Letterbox (via Viewport).
|
||||||
//!
|
//!
|
||||||
//! 640×480 (VGA) statt 320×240: das Spiel ist text-getrieben — die höhere
|
//! 640×480 (VGA) statt 320×240: das Spiel ist text-getrieben — die höhere
|
||||||
//! Auflösung trägt den 80-Spalten-Textmodus-Look (EGA 8×14), der Lo-Fi-Rest
|
//! Auflösung trägt den 80-Spalten-Textmodus-Look (EGA 8×14), der Lo-Fi-Rest
|
||||||
@@ -14,8 +16,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
|
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::image::Image;
|
||||||
use crate::render::math::Mat4;
|
use crate::render::math::Mat4;
|
||||||
|
use crate::render::post::{Post, PostChain};
|
||||||
use crate::render::scene::{Mesh, ScenePass};
|
use crate::render::scene::{Mesh, ScenePass};
|
||||||
use crate::render::sprite::SpritePass;
|
use crate::render::sprite::SpritePass;
|
||||||
use crate::render::ui::Ui;
|
use crate::render::ui::Ui;
|
||||||
@@ -28,19 +31,27 @@ pub const INTERNAL_H: u32 = 480;
|
|||||||
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
|
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
|
||||||
const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||||
|
|
||||||
|
/// Near-/Far-Ebene der Projektion. Auch die Post-Kette bekommt sie (als
|
||||||
|
/// Uniform), damit kein Effekt-Shader die Werte abschreiben muss, um die
|
||||||
|
/// Tiefe zu linearisieren — sonst lügen sie, sobald die Kamera sich ändert.
|
||||||
|
const NEAR_FAR: [f32; 2] = [0.1, 100.0];
|
||||||
|
|
||||||
pub struct Gpu {
|
pub struct Gpu {
|
||||||
surface: wgpu::Surface<'static>,
|
surface: wgpu::Surface<'static>,
|
||||||
device: wgpu::Device,
|
device: wgpu::Device,
|
||||||
queue: wgpu::Queue,
|
queue: wgpu::Queue,
|
||||||
config: wgpu::SurfaceConfiguration,
|
config: wgpu::SurfaceConfiguration,
|
||||||
|
|
||||||
internal_view: wgpu::TextureView,
|
|
||||||
depth_view: wgpu::TextureView,
|
depth_view: wgpu::TextureView,
|
||||||
|
|
||||||
scene: ScenePass,
|
scene: ScenePass,
|
||||||
|
post: Post,
|
||||||
sprite: SpritePass,
|
sprite: SpritePass,
|
||||||
blit_pipeline: wgpu::RenderPipeline,
|
blit_pipeline: wgpu::RenderPipeline,
|
||||||
blit_bind: wgpu::BindGroup,
|
/// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`].
|
||||||
|
blit_binds: [wgpu::BindGroup; 2],
|
||||||
|
/// Nullpunkt der Shader-Zeit (Post-Uniform).
|
||||||
|
start: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Gpu {
|
impl Gpu {
|
||||||
@@ -98,19 +109,21 @@ impl Gpu {
|
|||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
let (internal_view, depth_view) = make_internal_targets(&device);
|
let depth_view = make_depth_target(&device);
|
||||||
|
|
||||||
// Pass 1: die Szene. Geometrie + Bilder kommen fertig vom Aufrufer
|
// Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
|
||||||
// (Decode in render::run); hier wird nur hochgeladen.
|
// render::run); hier wird nur hochgeladen.
|
||||||
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
|
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
|
||||||
|
let post = Post::new(
|
||||||
|
&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view, NEAR_FAR);
|
||||||
|
|
||||||
// Overlay-Pass: zeichnet nach der Szene ins selbe interne Target.
|
// Overlay: eigener Pass hinter der Kette, ohne Tiefe.
|
||||||
let sprite = SpritePass::new(
|
let sprite = SpritePass::new(
|
||||||
&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT,
|
&device, &queue, INTERNAL_FORMAT,
|
||||||
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
|
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pass 2: internes Target nearest-gesampelt auf die Surface.
|
// Blit: das fertige interne Bild nearest-gesampelt auf die Surface.
|
||||||
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
label: Some("blit"),
|
label: Some("blit"),
|
||||||
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
|
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
|
||||||
@@ -142,25 +155,30 @@ impl Gpu {
|
|||||||
min_filter: wgpu::FilterMode::Nearest,
|
min_filter: wgpu::FilterMode::Nearest,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
let blit_layout = blit_pipeline.get_bind_group_layout(0);
|
||||||
|
let blit_bind = |view: &wgpu::TextureView| {
|
||||||
|
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("blit"),
|
label: Some("blit"),
|
||||||
layout: &blit_pipeline.get_bind_group_layout(0),
|
layout: &blit_layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 0,
|
binding: 0,
|
||||||
resource: wgpu::BindingResource::TextureView(&internal_view),
|
resource: wgpu::BindingResource::TextureView(view),
|
||||||
},
|
},
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 1,
|
binding: 1,
|
||||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
};
|
||||||
|
let blit_binds = [blit_bind(post.view(0)), blit_bind(post.view(1))];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
surface, device, queue, config,
|
surface, device, queue, config,
|
||||||
internal_view, depth_view,
|
depth_view,
|
||||||
scene, sprite, blit_pipeline, blit_bind,
|
scene, post, sprite, blit_pipeline, blit_binds,
|
||||||
|
start: std::time::Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,17 +197,18 @@ impl Gpu {
|
|||||||
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
|
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer (Flycam);
|
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer;
|
||||||
/// die Projektion gehört hierher, weil sie an der internen Auflösung
|
/// die Projektion gehört hierher, weil sie an der internen Auflösung
|
||||||
/// hängt.
|
/// hängt.
|
||||||
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
|
pub fn frame(&mut self, view: &Mat4, ui: &Ui, post: &PostChain) {
|
||||||
let proj = Mat4::perspective(
|
let proj = Mat4::perspective(
|
||||||
60f32.to_radians(),
|
60f32.to_radians(),
|
||||||
INTERNAL_W as f32 / INTERNAL_H as f32,
|
INTERNAL_W as f32 / INTERNAL_H as f32,
|
||||||
0.1, 100.0,
|
NEAR_FAR[0], NEAR_FAR[1],
|
||||||
);
|
);
|
||||||
let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5];
|
let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5];
|
||||||
self.scene.prepare(&self.queue, &proj.mul(view), half_res);
|
self.scene.prepare(&self.queue, &proj.mul(view), half_res);
|
||||||
|
self.post.prepare(&self.queue, self.start.elapsed().as_secs_f32());
|
||||||
self.sprite.prepare(&self.queue, &ui.verts);
|
self.sprite.prepare(&self.queue, &ui.verts);
|
||||||
|
|
||||||
use wgpu::CurrentSurfaceTexture as Cst;
|
use wgpu::CurrentSurfaceTexture as Cst;
|
||||||
@@ -208,19 +227,22 @@ impl Gpu {
|
|||||||
let surface_view = frame.texture.create_view(&Default::default());
|
let surface_view = frame.texture.create_view(&Default::default());
|
||||||
let mut enc = self.device.create_command_encoder(&Default::default());
|
let mut enc = self.device.create_command_encoder(&Default::default());
|
||||||
|
|
||||||
// Pass 1: intern.
|
// Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
|
||||||
|
// zweite Attachment.
|
||||||
{
|
{
|
||||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let [albedo, normal] = self.post.scene_targets();
|
||||||
label: Some("internal"),
|
let attach = |view| Some(wgpu::RenderPassColorAttachment {
|
||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
view,
|
||||||
view: &self.internal_view,
|
|
||||||
depth_slice: None,
|
depth_slice: None,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
store: wgpu::StoreOp::Store,
|
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 {
|
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||||
view: &self.depth_view,
|
view: &self.depth_view,
|
||||||
depth_ops: Some(wgpu::Operations {
|
depth_ops: Some(wgpu::Operations {
|
||||||
@@ -234,11 +256,33 @@ impl Gpu {
|
|||||||
multiview_mask: None,
|
multiview_mask: None,
|
||||||
});
|
});
|
||||||
self.scene.draw(&mut pass);
|
self.scene.draw(&mut pass);
|
||||||
// Overlay obenauf, im selben Pass (kein Tiefentest).
|
}
|
||||||
|
|
||||||
|
// Effekt-Kette; sie sagt, welches Target das Bild jetzt trägt.
|
||||||
|
let out = self.post.run(&mut enc, post);
|
||||||
|
|
||||||
|
// Overlay obendrauf — nach der Kette, also von keinem Effekt berührt.
|
||||||
|
{
|
||||||
|
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("overlay"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: self.post.view(out),
|
||||||
|
depth_slice: None,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Load,
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
multiview_mask: None,
|
||||||
|
});
|
||||||
self.sprite.draw(&mut pass, &ui.batches);
|
self.sprite.draw(&mut pass, &ui.batches);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: Letterbox-Blit aufs Fenster.
|
// Letterbox-Blit aufs Fenster.
|
||||||
{
|
{
|
||||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
label: Some("blit"),
|
label: Some("blit"),
|
||||||
@@ -260,7 +304,7 @@ impl Gpu {
|
|||||||
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
|
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
|
||||||
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
|
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
|
||||||
pass.set_pipeline(&self.blit_pipeline);
|
pass.set_pipeline(&self.blit_pipeline);
|
||||||
pass.set_bind_group(0, &self.blit_bind, &[]);
|
pass.set_bind_group(0, &self.blit_binds[out], &[]);
|
||||||
pass.draw(0..3, 0..1);
|
pass.draw(0..3, 0..1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,31 +313,21 @@ impl Gpu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) {
|
fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
|
||||||
let size = wgpu::Extent3d {
|
|
||||||
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
|
|
||||||
};
|
|
||||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label: Some("internal color"),
|
|
||||||
size,
|
|
||||||
mip_level_count: 1,
|
|
||||||
sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: INTERNAL_FORMAT,
|
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
|
||||||
view_formats: &[],
|
|
||||||
});
|
|
||||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
label: Some("internal depth"),
|
label: Some("internal depth"),
|
||||||
size,
|
size: wgpu::Extent3d {
|
||||||
|
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
mip_level_count: 1,
|
mip_level_count: 1,
|
||||||
sample_count: 1,
|
sample_count: 1,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
dimension: wgpu::TextureDimension::D2,
|
||||||
format: DEPTH_FORMAT,
|
format: DEPTH_FORMAT,
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
// TEXTURE_BINDING: die Post-Kette liest die Tiefe (Outline, Nebel).
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
(color.create_view(&Default::default()), depth.create_view(&Default::default()))
|
depth.create_view(&Default::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.
|
/// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
|
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
|
||||||
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective.
|
//! statt glam: nötig sind nur Multiply, View und Perspective.
|
||||||
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
|
|
||||||
//! ist nur der GPU-Pfad.)
|
|
||||||
//!
|
//!
|
||||||
//! Konventionen:
|
//! Konventionen:
|
||||||
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export)
|
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie der Blender-Export)
|
||||||
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
|
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
|
||||||
//! bytemuck-kompatibel zu WGSL `mat4x4f`
|
//! bytemuck-kompatibel zu WGSL `mat4x4f`
|
||||||
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
|
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
|
||||||
@@ -67,8 +65,7 @@ impl Mat4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
|
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
|
||||||
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z,
|
/// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick.
|
||||||
/// positiver Pitch hebt den Blick.
|
|
||||||
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
|
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
|
||||||
Mat4::rot_x(-pitch)
|
Mat4::rot_x(-pitch)
|
||||||
.mul(&Mat4::rot_y(-yaw))
|
.mul(&Mat4::rot_y(-yaw))
|
||||||
@@ -104,8 +101,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn view_yaw_quarter_turn_looks_minus_x() {
|
fn view_yaw_quarter_turn_looks_minus_x() {
|
||||||
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf
|
// yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera.
|
||||||
// -X liegt dann vor der Kamera.
|
|
||||||
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
|
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
|
||||||
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
|
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
|
||||||
}
|
}
|
||||||
@@ -116,6 +112,30 @@ mod tests {
|
|||||||
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
|
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Bewegungs-Basis des Players muss exakt zu der Richtung passen, die
|
||||||
|
/// `view` als „vor der Kamera" definiert — sonst driften Steuerung und Bild
|
||||||
|
/// auseinander. Ein Punkt `d` voraus muss im View-Space auf (0, 0, -d)
|
||||||
|
/// landen, `right_dir` auf +X und ohne Y-Anteil.
|
||||||
|
#[test]
|
||||||
|
fn view_matches_player_basis() {
|
||||||
|
use crate::engine::player::Player;
|
||||||
|
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
|
||||||
|
let mut p = Player::new([1.0, 2.0, 3.0]);
|
||||||
|
(p.yaw, p.pitch) = (yaw, pitch);
|
||||||
|
let v = Mat4::view(p.pos, p.yaw, p.pitch);
|
||||||
|
|
||||||
|
let f = p.look_dir();
|
||||||
|
let ahead = [p.pos[0] + f[0] * 5.0, p.pos[1] + f[1] * 5.0, p.pos[2] + f[2] * 5.0, 1.0];
|
||||||
|
assert!(close(v.transform(ahead), [0.0, 0.0, -5.0, 1.0]),
|
||||||
|
"look_dir bei yaw={yaw} pitch={pitch}: {:?}", v.transform(ahead));
|
||||||
|
|
||||||
|
let r = p.right_dir();
|
||||||
|
assert!(r[1].abs() < 1e-6, "right_dir darf kein Y haben: {r:?}");
|
||||||
|
let side = v.transform([p.pos[0] + r[0] * 5.0, p.pos[1], p.pos[2] + r[2] * 5.0, 1.0]);
|
||||||
|
assert!(side[0] > 4.0, "right_dir sollte nach View-+X zeigen: {side:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn perspective_maps_near_far_to_0_1() {
|
fn perspective_maps_near_far_to_0_1() {
|
||||||
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
|
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
||||||
//!
|
//!
|
||||||
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
|
|
||||||
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
|
|
||||||
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
|
|
||||||
//! Als Nächstes: OBJ-Szene (Schritt 5).
|
|
||||||
//!
|
|
||||||
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
||||||
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
||||||
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
||||||
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
|
//! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
|
||||||
//! anderen Zustand als die Konsole.
|
|
||||||
//!
|
//!
|
||||||
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
||||||
//! deren Schnittstellen — nie umgekehrt.
|
//! deren Schnittstellen — nie umgekehrt.
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod camera;
|
|
||||||
mod font;
|
mod font;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
mod math;
|
mod math;
|
||||||
|
mod post;
|
||||||
mod props;
|
mod props;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod sprite;
|
mod sprite;
|
||||||
@@ -35,46 +29,55 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|||||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||||
use winit::window::{CursorGrabMode, Window, WindowId};
|
use winit::window::{CursorGrabMode, Window, WindowId};
|
||||||
|
|
||||||
|
use crate::engine::image::{self, Image};
|
||||||
use crate::engine::player;
|
use crate::engine::player;
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::{assets, gltf, model, wav};
|
||||||
use crate::engine::{assets, gltf, model, tga, wav};
|
|
||||||
use crate::session::{FrameInput, Mode, Session};
|
use crate::session::{FrameInput, Mode, Session};
|
||||||
use camera::Camera;
|
|
||||||
use gpu::Gpu;
|
use gpu::Gpu;
|
||||||
use math::Mat4;
|
use math::Mat4;
|
||||||
use scene::Mesh;
|
use scene::Mesh;
|
||||||
|
|
||||||
/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
|
|
||||||
/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
|
|
||||||
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
|
|
||||||
/// Welt-Einheiten pro Sekunde.
|
|
||||||
const MOVE_SPEED: f32 = 4.0;
|
|
||||||
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
|
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
|
||||||
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
|
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
|
||||||
/// folgen (wie Source/HL2). Größer = weicher, aber träger.
|
/// folgen. Größer = weicher, aber träger.
|
||||||
const EYE_SMOOTH_TAU: f32 = 0.06;
|
const EYE_SMOOTH_TAU: f32 = 0.06;
|
||||||
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
||||||
const EYE_MAX_LAG: f32 = 0.6;
|
const EYE_MAX_LAG: f32 = 0.6;
|
||||||
|
|
||||||
pub fn run(mut session: Session) {
|
/// Default-Verzeichnis der Welt-`.glb` (relativ zum Asset-Basisverzeichnis).
|
||||||
|
/// `main` löst es auf und lässt `--maps <dir>` es überschreiben.
|
||||||
|
pub const DEFAULT_MAPS_DIR: &str = "assets/maps/props";
|
||||||
|
|
||||||
|
pub fn run(mut session: Session, maps_dir: &str) {
|
||||||
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
|
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
|
||||||
// Dialog öffnen — dann startet die Welt eben pausiert).
|
// Dialog öffnen — dann startet die Welt eben pausiert).
|
||||||
for line in session.start() { println!("{line}"); }
|
for line in session.start() { println!("{line}"); }
|
||||||
|
|
||||||
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
// Die Welt sind alle .glb unter `maps_dir` (Default DEFAULT_MAPS_DIR, per
|
||||||
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
// `--maps <dir>` überschrieben). Decode (CPU) hier, Upload (GPU) später in
|
||||||
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
|
// `Gpu` — die beiden bleiben getrennt.
|
||||||
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
|
let models = load_models(maps_dir);
|
||||||
// nie fatal.
|
|
||||||
let models = load_models(&assets::path("assets/maps/props"));
|
|
||||||
|
|
||||||
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
|
// Eine geteilte Textur-Schlüsselliste über alle Modelle, damit ihre
|
||||||
// denselben Index-Raum benutzen und zu einem Buffer verschmelzen können.
|
// Meshes denselben Index-Raum benutzen und zu einem Buffer verschmelzen.
|
||||||
let mut tex_names: Vec<String> = Vec::new();
|
let mut tex_names: Vec<String> = Vec::new();
|
||||||
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
|
for (_, m) in &models { tex_names.extend(props::texture_keys(m)); }
|
||||||
tex_names.sort_unstable();
|
tex_names.sort_unstable();
|
||||||
tex_names.dedup();
|
tex_names.dedup();
|
||||||
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
|
|
||||||
|
// Die Pixel liegen in den `.glb` selbst; gleicher Schlüssel = eine Textur
|
||||||
|
// (erstes Vorkommen gewinnt). Schlüssel ohne Bild treffen den Platzhalter
|
||||||
|
// (Projekt-Asset, darf hart scheitern).
|
||||||
|
let mut by_key: HashMap<&str, &Image> = HashMap::new();
|
||||||
|
for (_, m) in &models {
|
||||||
|
for t in &m.textures {
|
||||||
|
if let Some(img) = &t.image { by_key.entry(props::texture_key(t)).or_insert(img); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let placeholder = placeholder();
|
||||||
|
let images: Vec<Image> = tex_names.iter()
|
||||||
|
.map(|k| by_key.get(k.as_str()).map_or_else(|| placeholder.clone(), |i| (*i).clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut mesh = Mesh::default();
|
let mut mesh = Mesh::default();
|
||||||
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
||||||
@@ -98,18 +101,17 @@ pub fn run(mut session: Session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||||||
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
|
||||||
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
|
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
|
||||||
// Luminanz.
|
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
|
||||||
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
|
// Installation kaputt und es darf scheitern.
|
||||||
// ist die Installation kaputt, da darf es scheitern.
|
|
||||||
let load_keyed = |p: &str| {
|
let load_keyed = |p: &str| {
|
||||||
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
ui::key_luminance(&image::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
||||||
};
|
};
|
||||||
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
|
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
|
||||||
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
|
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
|
||||||
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
|
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.png");
|
||||||
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.tga");
|
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.png");
|
||||||
let fonts = font::Fonts {
|
let fonts = font::Fonts {
|
||||||
ega: font::Font::ega(ui::FONT_EGA, &ega_img),
|
ega: font::Font::ega(ui::FONT_EGA, &ega_img),
|
||||||
cga: font::Font::cga(ui::FONT_CGA, &cga_img),
|
cga: font::Font::cga(ui::FONT_CGA, &cga_img),
|
||||||
@@ -118,8 +120,8 @@ pub fn run(mut session: Session) {
|
|||||||
ui::white_pixel(),
|
ui::white_pixel(),
|
||||||
ega_img,
|
ega_img,
|
||||||
cga_img,
|
cga_img,
|
||||||
load_keyed("assets/textures/ui/cursors.tga"),
|
load_keyed("assets/textures/ui/cursors.png"),
|
||||||
load_keyed("assets/textures/ui/panel_ornaments.tga"),
|
load_keyed("assets/textures/ui/panel_ornaments.png"),
|
||||||
];
|
];
|
||||||
|
|
||||||
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
|
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
|
||||||
@@ -168,8 +170,8 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
|||||||
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
||||||
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
||||||
m.objects.len(), m.empties.len());
|
m.objects.len(), m.empties.len());
|
||||||
// Custom Properties sichtbar machen — noch konsumiert sie
|
// Properties mitloggen: so sieht die Autorin, ob ihr Export
|
||||||
// niemand, aber die Autorin sieht so, dass sie ankommen.
|
// `collide`, `signal` und `role` mitgenommen hat.
|
||||||
for o in &m.objects {
|
for o in &m.objects {
|
||||||
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
||||||
}
|
}
|
||||||
@@ -185,9 +187,6 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
|||||||
models
|
models
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
|
|
||||||
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
|
|
||||||
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
|
|
||||||
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
|
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
|
||||||
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
|
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
|
||||||
fn load_sound(name: &str) -> Option<audio::Sound> {
|
fn load_sound(name: &str) -> Option<audio::Sound> {
|
||||||
@@ -200,18 +199,12 @@ fn load_sound(name: &str) -> Option<audio::Sound> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Welt-Textur laden. Autorinnen-Content: fehlt oder klemmt die Datei, wird
|
/// Die Platzhalter-Textur für Schlüssel ohne eigenes Bild in der `.glb`.
|
||||||
/// gemeldet und der Platzhalter genommen — der selbst ist Projekt-Asset und
|
/// Projekt-Asset: fehlt sie, ist die Installation kaputt und es darf scheitern.
|
||||||
/// darf hart scheitern.
|
fn placeholder() -> Image {
|
||||||
fn load_texture(name: &str) -> Image {
|
let name = props::FALLBACK_TEXTURE;
|
||||||
match tga::load(&assets::path(&format!("assets/textures/{name}.tga"))) {
|
image::load(&assets::path(&format!("assets/textures/{name}.png")))
|
||||||
Ok(img) => img,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[tex] {e} — Platzhalter");
|
|
||||||
tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
|
|
||||||
.expect("Platzhalter-Textur (Projekt-Asset)")
|
.expect("Platzhalter-Textur (Projekt-Asset)")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
||||||
@@ -238,8 +231,6 @@ struct App {
|
|||||||
gpu: Option<Gpu>,
|
gpu: Option<Gpu>,
|
||||||
session: Session,
|
session: Session,
|
||||||
rx: Receiver<String>,
|
rx: Receiver<String>,
|
||||||
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
|
|
||||||
camera: Camera,
|
|
||||||
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
||||||
eye_y: f32,
|
eye_y: f32,
|
||||||
input: Input,
|
input: Input,
|
||||||
@@ -259,6 +250,9 @@ struct App {
|
|||||||
/// melden, nicht jeden Klick).
|
/// melden, nicht jeden Klick).
|
||||||
audio: Option<audio::Audio>,
|
audio: Option<audio::Audio>,
|
||||||
sfx_cache: HashMap<String, Option<audio::Sound>>,
|
sfx_cache: HashMap<String, Option<audio::Sound>>,
|
||||||
|
/// Post-Effekt-Kette (Renderer-Zustand, nicht im Kern). Pro Frame aus
|
||||||
|
/// `session.take_post()` nachgeführt, dann an `gpu.frame`.
|
||||||
|
post: post::PostChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -271,15 +265,12 @@ impl App {
|
|||||||
fonts: font::Fonts,
|
fonts: font::Fonts,
|
||||||
audio: Option<audio::Audio>,
|
audio: Option<audio::Audio>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
|
|
||||||
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
|
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
|
||||||
Self {
|
Self {
|
||||||
window: None,
|
window: None,
|
||||||
gpu: None,
|
gpu: None,
|
||||||
session,
|
session,
|
||||||
rx,
|
rx,
|
||||||
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
|
|
||||||
camera: Camera::new([0.0, 1.0, 4.0]),
|
|
||||||
eye_y,
|
eye_y,
|
||||||
input: Input::default(),
|
input: Input::default(),
|
||||||
last: None,
|
last: None,
|
||||||
@@ -291,12 +282,16 @@ impl App {
|
|||||||
fonts,
|
fonts,
|
||||||
audio,
|
audio,
|
||||||
sfx_cache: HashMap::new(),
|
sfx_cache: HashMap::new(),
|
||||||
|
// Die Post-Kette gehört dem Renderer, nicht dem Kern: geschaltet
|
||||||
|
// wird sie über `Action::SetPost` (Trigger/Ink/Konsole), die die
|
||||||
|
// Session als `take_post`-Queue durchreicht.
|
||||||
|
post: post::PostChain::defaults(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
|
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
|
||||||
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
|
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
|
||||||
// Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI).
|
// Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
|
||||||
fn set_grab(&mut self, grab: bool) {
|
fn set_grab(&mut self, grab: bool) {
|
||||||
let Some(window) = &self.window else { return; };
|
let Some(window) = &self.window else { return; };
|
||||||
if grab {
|
if grab {
|
||||||
@@ -366,7 +361,7 @@ impl ApplicationHandler for App {
|
|||||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||||
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
||||||
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
||||||
// Flycam-Greifen.
|
// Maus-Greifen.
|
||||||
self.pending_click = true;
|
self.pending_click = true;
|
||||||
}
|
}
|
||||||
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
||||||
@@ -404,7 +399,7 @@ impl App {
|
|||||||
KeyCode::KeyS => self.input.back = down,
|
KeyCode::KeyS => self.input.back = down,
|
||||||
KeyCode::KeyA => self.input.left = down,
|
KeyCode::KeyA => self.input.left = down,
|
||||||
KeyCode::KeyD => self.input.right = down,
|
KeyCode::KeyD => self.input.right = down,
|
||||||
// Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke,
|
// Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
|
||||||
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
|
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
|
||||||
KeyCode::Space => {
|
KeyCode::Space => {
|
||||||
self.input.up = down;
|
self.input.up = down;
|
||||||
@@ -416,38 +411,28 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
// dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
|
|
||||||
// während die Konsole den Dialog treibt.
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
||||||
|
|
||||||
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
|
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
|
||||||
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
|
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
|
||||||
// gibt es keine parallele Lock-Logik.
|
|
||||||
let fly = matches!(self.session.mode, Mode::Free);
|
let fly = matches!(self.session.mode, Mode::Free);
|
||||||
let play = matches!(self.session.mode, Mode::FirstPerson);
|
let play = matches!(self.session.mode, Mode::FirstPerson);
|
||||||
let grab = fly || play;
|
let grab = fly || play;
|
||||||
if grab != self.input.grabbed { self.set_grab(grab); }
|
if grab != self.input.grabbed { self.set_grab(grab); }
|
||||||
|
|
||||||
if fly {
|
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
|
||||||
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
|
// die Session an ihrem Modus. Betretens-Zonen melden ihre
|
||||||
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
|
// Signal-Ausgaben wie exec-Befehle.
|
||||||
let fwd = axis(self.input.fwd, self.input.back);
|
|
||||||
let 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.
|
|
||||||
let input = FrameInput {
|
let input = FrameInput {
|
||||||
fwd: axis(self.input.fwd, self.input.back),
|
fwd: axis(self.input.fwd, self.input.back),
|
||||||
right: axis(self.input.right, self.input.left),
|
right: axis(self.input.right, self.input.left),
|
||||||
|
up: axis(self.input.up, self.input.down),
|
||||||
jump: self.input.jump,
|
jump: self.input.jump,
|
||||||
look_dx: self.input.mouse_dx,
|
look_dx: self.input.mouse_dx,
|
||||||
look_dy: self.input.mouse_dy,
|
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}"); }
|
for out in self.session.tick(&input, dt) { println!("{out}"); }
|
||||||
}
|
|
||||||
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||||||
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
||||||
self.input.mouse_dx = 0.0;
|
self.input.mouse_dx = 0.0;
|
||||||
@@ -467,11 +452,17 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
|
// Post-Effekt-Schaltungen (SetPost-Actions von Trigger/Ink/Konsole)
|
||||||
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
|
// auf die eigene Kette anwenden; unbekannter Name wird gemeldet, wie
|
||||||
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
|
// ein fehlendes WAV bei play_sound.
|
||||||
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
|
for (name, on) in self.session.take_post() {
|
||||||
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
|
if !self.post.set(&name, on) {
|
||||||
|
eprintln!("[post] unbekannter Effekt {name:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
|
||||||
if play && self.session.player.grounded {
|
if play && self.session.player.grounded {
|
||||||
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
|
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
|
||||||
@@ -481,16 +472,12 @@ impl App {
|
|||||||
self.eye_y = target_eye;
|
self.eye_y = target_eye;
|
||||||
}
|
}
|
||||||
|
|
||||||
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
|
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
|
||||||
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
|
// nur die Sim ein, nicht die Sicht.
|
||||||
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
|
|
||||||
// Sicht bleibt die First-Person-Sicht.
|
|
||||||
let view = if fly {
|
|
||||||
self.camera.view()
|
|
||||||
} else {
|
|
||||||
let p = self.session.player.pos;
|
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
|
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
|
||||||
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
||||||
// löst beim Klick unten das `use` aus.
|
// löst beim Klick unten das `use` aus.
|
||||||
@@ -530,7 +517,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.post);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! 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 [`PostChain`], die Trigger, Ink-Tags und die Konsole per
|
||||||
|
//! `game::Action::SetPost` erreichen. Der Kern kennt die Kette nicht.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
/// 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")),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// An/Aus je Post-Effekt, in Ketten-Reihenfolge. Das Fenster hält sie als
|
||||||
|
/// eigenen Zustand (nicht der Kern), wendet die per `Session::take_post`
|
||||||
|
/// aufgelaufenen `SetPost`-Schaltungen jeden Frame an und gibt die Kette an
|
||||||
|
/// [`Post::run`].
|
||||||
|
pub struct PostChain {
|
||||||
|
effects: Vec<(String, bool)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostChain {
|
||||||
|
/// Die Kette aus [`EFFECTS`] mit ihren Startzuständen.
|
||||||
|
pub fn defaults() -> Self {
|
||||||
|
Self {
|
||||||
|
effects: EFFECTS.iter().map(|(n, on, _)| ((*n).to_string(), *on)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled(&self, name: &str) -> bool {
|
||||||
|
self.effects.iter().any(|(n, on)| n == name && *on)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effekt `name` auf `on` setzen. Rückgabe: `false`, wenn kein Effekt so
|
||||||
|
/// heißt — der Aufrufer meldet das (wie ein fehlendes WAV bei `play_sound`).
|
||||||
|
pub fn set(&mut self, name: &str, on: bool) -> bool {
|
||||||
|
match self.effects.iter_mut().find(|(n, _)| n == name) {
|
||||||
|
Some(e) => { e.1 = on; true }
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_follow_effects_table() {
|
||||||
|
let c = PostChain::defaults();
|
||||||
|
// Startzustände aus EFFECTS.
|
||||||
|
assert!(!c.enabled("normals"));
|
||||||
|
assert!(c.enabled("outline"));
|
||||||
|
assert!(c.enabled("dither"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_switches_and_reports_unknown() {
|
||||||
|
let mut c = PostChain::defaults();
|
||||||
|
assert!(c.set("outline", false));
|
||||||
|
assert!(!c.enabled("outline"));
|
||||||
|
assert!(c.set("outline", true));
|
||||||
|
assert!(c.enabled("outline"));
|
||||||
|
assert!(!c.set("bloom", true), "unbekannter Effekt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_effect_is_off() {
|
||||||
|
assert!(!PostChain::defaults().enabled("bloom"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,45 @@
|
|||||||
//! Blender-Modell → Render-Geometrie.
|
//! Blender-Modell → Render-Geometrie.
|
||||||
//!
|
//!
|
||||||
//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein
|
//! Nimmt das geparste [`Model`] aus engine::gltf und baut daraus ein
|
||||||
//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere
|
//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere
|
||||||
//! Modelle verschmelzen über `Mesh::append` zu einem Buffer.
|
//! Modelle verschmelzen über `Mesh::append` zu einem Buffer.
|
||||||
//!
|
//!
|
||||||
//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst zu
|
//! Texturen liegen als Pixel im Modell (`model::Texture`, aus der `.glb`).
|
||||||
//! `assets/textures/{name}.tga` (macht der Aufrufer). Faces ohne Material
|
//! Hier wird nur nach ihrem Schlüssel gruppiert; Texturen ohne Bild —
|
||||||
//! (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
//! Material ohne baseColorTexture oder unlesbar — landen unter
|
||||||
|
//! [`FALLBACK_TEXTURE`], dessen Pixel der Aufrufer stellt.
|
||||||
//!
|
//!
|
||||||
//! UVs kommen unverändert durch (Modell und `tga::Image` teilen den
|
//! UVs kommen unverändert durch (Modell und `Image` teilen den
|
||||||
//! Oben-links-Ursprung). Nur `visible`-Objekte werden ausgegeben;
|
//! Oben-links-Ursprung; `KHR_texture_transform` hat der Loader schon
|
||||||
|
//! eingebacken). Nur `visible`-Objekte werden ausgegeben;
|
||||||
//! Collider-/Zonen-Volumen sind Sache von engine::collision und
|
//! Collider-/Zonen-Volumen sind Sache von engine::collision und
|
||||||
//! engine::trigger.
|
//! engine::trigger.
|
||||||
|
|
||||||
use crate::engine::model::Model;
|
use crate::engine::model::{Model, Texture};
|
||||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
use crate::render::scene::{Batch, Mesh, Vertex};
|
||||||
|
|
||||||
/// Texturname für Faces ohne `usemtl` (Material `""`).
|
/// Schlüssel für Texturen ohne eigenes Bild; der Aufrufer lädt die Pixel
|
||||||
|
/// dazu (`assets/textures/placeholder.png`).
|
||||||
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
||||||
|
|
||||||
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
/// Distinkte Textur-Schlüssel des Modells (sortiert), bildlose bereits auf
|
||||||
/// Fallback gemappt.
|
/// den Fallback gemappt.
|
||||||
pub fn texture_names(model: &Model) -> Vec<String> {
|
pub fn texture_keys(model: &Model) -> Vec<String> {
|
||||||
let mut names: Vec<String> = model.materials.iter()
|
let mut keys: Vec<String> = model.textures.iter()
|
||||||
.map(|m| resolve(m).to_string())
|
.map(|t| texture_key(t).to_string())
|
||||||
.collect();
|
.collect();
|
||||||
names.sort_unstable();
|
keys.sort_unstable();
|
||||||
names.dedup();
|
keys.dedup();
|
||||||
names
|
keys
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve(material: &str) -> &str {
|
/// Gruppierungs-Schlüssel einer Textur: ihr eigener, sofern sie Pixel hat —
|
||||||
if material.is_empty() { FALLBACK_TEXTURE } else { material }
|
/// sonst der Fallback. Der Renderer sammelt darüber die Pixel je Schlüssel.
|
||||||
|
pub fn texture_key(t: &Texture) -> &str {
|
||||||
|
match &t.image {
|
||||||
|
Some(_) if !t.key.is_empty() => &t.key,
|
||||||
|
_ => FALLBACK_TEXTURE,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur-
|
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur-
|
||||||
@@ -41,14 +49,19 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
|||||||
let mut verts = Vec::new();
|
let mut verts = Vec::new();
|
||||||
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
||||||
|
|
||||||
|
// Laufende Objektnummer für den G-Buffer; nur sichtbare Objekte zählen,
|
||||||
|
// unsichtbare Collider tauchen im Bild ja nie auf. Ab 1, damit die 0 im
|
||||||
|
// Buffer „kein Objekt" heißt (der Hintergrund wird auf 0 geleert).
|
||||||
|
let mut id = 1;
|
||||||
for o in &model.objects {
|
for o in &model.objects {
|
||||||
if !o.visible { continue; }
|
if !o.visible { continue; }
|
||||||
let base = verts.len() as u32;
|
let base = verts.len() as u32;
|
||||||
for (p, uv) in o.verts.iter().zip(&o.uvs) {
|
for ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
|
||||||
verts.push(Vertex { pos: *p, uv: *uv });
|
verts.push(Vertex { pos: *p, uv: *uv, normal: *n, id });
|
||||||
}
|
}
|
||||||
|
id += 1;
|
||||||
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
|
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
|
||||||
let name = resolve(&model.materials[m]);
|
let name = texture_key(&model.textures[m]);
|
||||||
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
|
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
|
||||||
per_tex[ti].extend(t.map(|i| base + i as u32));
|
per_tex[ti].extend(t.map(|i| base + i as u32));
|
||||||
}
|
}
|
||||||
@@ -62,20 +75,32 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
|||||||
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
|
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
|
||||||
indices.extend_from_slice(&list);
|
indices.extend_from_slice(&list);
|
||||||
}
|
}
|
||||||
Mesh { verts, indices, batches }
|
Mesh { verts, indices, batches, objects: id - 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::engine::image::Image;
|
||||||
use crate::engine::model::{Model, Object, Props};
|
use crate::engine::model::{Model, Object, Props};
|
||||||
|
|
||||||
|
/// 1×1-Platzhalterbild, damit eine Textur „Pixel hat".
|
||||||
|
fn pixel() -> Image {
|
||||||
|
Image { width: 1, height: 1, rgba: vec![255, 255, 255, 255] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Textur mit Bild und Schlüssel `key`.
|
||||||
|
fn tex(key: &str) -> Texture {
|
||||||
|
Texture { key: key.into(), image: Some(pixel()) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein Dreieck mit UVs; `visible` steuert, ob es gerendert werden darf.
|
/// Ein Dreieck mit UVs; `visible` steuert, ob es gerendert werden darf.
|
||||||
fn tri(name: &str, visible: bool) -> Object {
|
fn tri(name: &str, visible: bool) -> Object {
|
||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
|
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
|
||||||
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
|
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
|
||||||
|
normals: vec![[0.0, 0.0, 1.0]; 3],
|
||||||
tris: vec![[0, 1, 2]],
|
tris: vec![[0, 1, 2]],
|
||||||
tri_mats: vec![0],
|
tri_mats: vec![0],
|
||||||
visible, collider: !visible,
|
visible, collider: !visible,
|
||||||
@@ -87,14 +112,14 @@ mod tests {
|
|||||||
Model {
|
Model {
|
||||||
objects: vec![tri("Ding", true), tri("Proxy", false)],
|
objects: vec![tri("Ding", true), tri("Proxy", false)],
|
||||||
empties: Vec::new(),
|
empties: Vec::new(),
|
||||||
materials: vec!["carpet".to_string()],
|
textures: vec![tex("carpet")],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_only_visible_objects() {
|
fn builds_only_visible_objects() {
|
||||||
let m = sample();
|
let m = sample();
|
||||||
let names = texture_names(&m);
|
let names = texture_keys(&m);
|
||||||
assert_eq!(names, vec!["carpet".to_string()]);
|
assert_eq!(names, vec!["carpet".to_string()]);
|
||||||
let mesh = build(&m, &names);
|
let mesh = build(&m, &names);
|
||||||
|
|
||||||
@@ -107,11 +132,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_material_maps_to_fallback() {
|
fn visible_objects_get_distinct_ids_across_models() {
|
||||||
|
let names = texture_keys(&sample());
|
||||||
|
// Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell.
|
||||||
let mut m = sample();
|
let mut m = sample();
|
||||||
m.materials = vec![String::new()]; // Faces ohne Material
|
m.objects.insert(1, tri("Zweites", true));
|
||||||
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
|
||||||
let mesh = build(&m, &texture_names(&m));
|
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 imageless_texture_maps_to_fallback() {
|
||||||
|
let mut m = sample();
|
||||||
|
// Material ohne baseColorTexture: Schlüssel gesetzt, aber kein Bild.
|
||||||
|
m.textures = vec![Texture { key: "nackt".into(), image: None }];
|
||||||
|
assert_eq!(texture_keys(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
||||||
|
let mesh = build(&m, &texture_keys(&m));
|
||||||
assert_eq!(mesh.batches.len(), 1);
|
assert_eq!(mesh.batches.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
||||||
//!
|
//!
|
||||||
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
|
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
|
||||||
//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group +
|
//! render::props; Bilder als RGBA8-`Image`, aus der `.glb` dekodiert). Pro
|
||||||
//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan).
|
//! Textur eine Bind-Group + ein Draw-Batch („pro Textur ein Draw" wie im Plan).
|
||||||
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
||||||
|
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::image::Image;
|
||||||
use crate::render::math::Mat4;
|
use crate::render::math::Mat4;
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -15,6 +15,14 @@ use crate::render::math::Mat4;
|
|||||||
pub(crate) struct Vertex {
|
pub(crate) struct Vertex {
|
||||||
pub(crate) pos: [f32; 3],
|
pub(crate) pos: [f32; 3],
|
||||||
pub(crate) uv: [f32; 2],
|
pub(crate) uv: [f32; 2],
|
||||||
|
/// Welt-Normale, normiert — Eingang des G-Buffers (siehe render::post).
|
||||||
|
pub(crate) normal: [f32; 3],
|
||||||
|
/// Laufende Objektnummer. Der Szenen-Pass legt sie (mod 256) ins Alpha
|
||||||
|
/// des Normal-Buffers, damit der Outline-Pass Kanten zwischen zwei
|
||||||
|
/// Objekten findet, die sich berühren und gleich ausgerichtet sind —
|
||||||
|
/// Stuhl auf Boden, Bild an Wand. Benachbarte Nummern unterscheiden sich
|
||||||
|
/// immer; erst 256 Objekte auseinander fallen zwei zusammen.
|
||||||
|
pub(crate) id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
|
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
|
||||||
@@ -33,6 +41,10 @@ pub(crate) struct Mesh {
|
|||||||
pub(crate) verts: Vec<Vertex>,
|
pub(crate) verts: Vec<Vertex>,
|
||||||
pub(crate) indices: Vec<u32>,
|
pub(crate) indices: Vec<u32>,
|
||||||
pub(crate) batches: Vec<Batch>,
|
pub(crate) batches: Vec<Batch>,
|
||||||
|
/// Vergebene Objektnummern (siehe [`Vertex::id`]) — [`Mesh::append`]
|
||||||
|
/// verschiebt die des Anhängsels darüber, damit Objekte aus verschiedenen
|
||||||
|
/// Modellen nicht dieselbe Nummer tragen.
|
||||||
|
pub(crate) objects: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mesh {
|
impl Mesh {
|
||||||
@@ -43,7 +55,12 @@ impl Mesh {
|
|||||||
pub(crate) fn append(&mut self, other: Mesh) {
|
pub(crate) fn append(&mut self, other: Mesh) {
|
||||||
let vbase = self.verts.len() as u32;
|
let vbase = self.verts.len() as u32;
|
||||||
let ibase = self.indices.len() as u32;
|
let ibase = self.indices.len() as u32;
|
||||||
self.verts.extend(other.verts);
|
let idbase = self.objects;
|
||||||
|
self.objects += other.objects;
|
||||||
|
self.verts.extend(other.verts.into_iter().map(|v| Vertex {
|
||||||
|
id: v.id + idbase,
|
||||||
|
..v
|
||||||
|
}));
|
||||||
self.indices.extend(other.indices.iter().map(|i| i + vbase));
|
self.indices.extend(other.indices.iter().map(|i| i + vbase));
|
||||||
self.batches.extend(other.batches.iter().map(|b| Batch {
|
self.batches.extend(other.batches.iter().map(|b| Batch {
|
||||||
texture: b.texture,
|
texture: b.texture,
|
||||||
@@ -67,7 +84,9 @@ struct Uniforms {
|
|||||||
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||||||
array_stride: size_of::<Vertex>() as u64,
|
array_stride: size_of::<Vertex>() as u64,
|
||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
|
attributes: &wgpu::vertex_attr_array![
|
||||||
|
0 => Float32x3, 1 => Float32x2, 2 => Float32x3, 3 => Uint32,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ScenePass {
|
pub struct ScenePass {
|
||||||
@@ -106,7 +125,10 @@ impl ScenePass {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("fs_main"),
|
entry_point: Some("fs_main"),
|
||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
targets: &[Some(color_format.into())],
|
// Zwei Ziele (MRT): Albedo und der Normal-/ID-Buffer. Beide
|
||||||
|
// im internen Format; zusammen sind sie der G-Buffer, aus dem
|
||||||
|
// die Post-Kette Beleuchtung und Kanten rechnet.
|
||||||
|
targets: &[Some(color_format.into()), Some(color_format.into())],
|
||||||
}),
|
}),
|
||||||
// Back-face Culling: glTF-Dreiecke sind CCW von außen
|
// Back-face Culling: glTF-Dreiecke sind CCW von außen
|
||||||
// gewickelt — die Außenflächen sind front-facing, Rückseiten
|
// gewickelt — die Außenflächen sind front-facing, Rückseiten
|
||||||
@@ -168,7 +190,7 @@ impl ScenePass {
|
|||||||
});
|
});
|
||||||
let tex_layout = pipeline.get_bind_group_layout(1);
|
let tex_layout = pipeline.get_bind_group_layout(1);
|
||||||
let tex_binds = images.iter()
|
let tex_binds = images.iter()
|
||||||
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
|
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "scene texture", true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -196,20 +218,26 @@ impl ScenePass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige
|
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
|
||||||
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle
|
/// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
|
||||||
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch.
|
/// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
|
||||||
fn upload_texture(
|
/// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
|
||||||
|
pub(crate) fn upload_texture(
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
layout: &wgpu::BindGroupLayout,
|
layout: &wgpu::BindGroupLayout,
|
||||||
sampler: &wgpu::Sampler,
|
sampler: &wgpu::Sampler,
|
||||||
img: &Image,
|
img: &Image,
|
||||||
|
label: &str,
|
||||||
|
mips: bool,
|
||||||
) -> wgpu::BindGroup {
|
) -> wgpu::BindGroup {
|
||||||
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
|
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
|
||||||
let mip_level_count = 32 - img.width.max(img.height).leading_zeros();
|
let mip_level_count = match mips {
|
||||||
|
true => 32 - img.width.max(img.height).leading_zeros(),
|
||||||
|
false => 1,
|
||||||
|
};
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
label: Some("scene texture"),
|
label: Some(label),
|
||||||
size: wgpu::Extent3d {
|
size: wgpu::Extent3d {
|
||||||
width: img.width,
|
width: img.width,
|
||||||
height: img.height,
|
height: img.height,
|
||||||
@@ -251,7 +279,7 @@ fn upload_texture(
|
|||||||
|
|
||||||
let view = texture.create_view(&Default::default());
|
let view = texture.create_view(&Default::default());
|
||||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("scene texture"),
|
label: Some(label),
|
||||||
layout,
|
layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch
|
// Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
|
||||||
// gerechnet statt nachgestellt:
|
// dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
|
||||||
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter
|
// Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
|
||||||
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
|
// render::post. Der Pixel-Snap im Vertex-Shader (PS1-Vertex-Jitter) gehört
|
||||||
// - 1-Bit-Alpha-Test (Cutouts), kein Blending
|
// dagegen hierher, weil er Geometrie ist.
|
||||||
|
//
|
||||||
|
// 1-Bit-Alpha-Test (Cutouts), kein Blending.
|
||||||
//
|
//
|
||||||
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
|
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
|
||||||
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
|
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
|
||||||
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen
|
// frei beweglichen Kamera und großen Faces zu störend.
|
||||||
// Pixel-Snap und Dither.
|
|
||||||
|
|
||||||
struct Uniforms {
|
struct Uniforms {
|
||||||
mvp: mat4x4f,
|
mvp: mat4x4f,
|
||||||
@@ -24,10 +25,27 @@ struct VsOut {
|
|||||||
@builtin(position) pos: vec4f,
|
@builtin(position) pos: vec4f,
|
||||||
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
|
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
|
||||||
@location(0) @interpolate(perspective) uv: vec2f,
|
@location(0) @interpolate(perspective) uv: vec2f,
|
||||||
|
@location(1) normal: vec3f,
|
||||||
|
// Ganzzahl → flat; interpolieren würde zwischen zwei Objektnummern
|
||||||
|
// Zwischenwerte erfinden.
|
||||||
|
@location(2) @interpolate(flat) id: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FsOut {
|
||||||
|
@location(0) albedo: vec4f,
|
||||||
|
// xyz = Normale, aus [-1,1] in [0,1] geschoben (das Ziel ist unsigned).
|
||||||
|
// w = Objektnummer mod 256, damit der Outline-Pass auch Kanten zwischen
|
||||||
|
// gleich ausgerichteten Nachbarn findet.
|
||||||
|
@location(1) normal: vec4f,
|
||||||
};
|
};
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
|
fn vs_main(
|
||||||
|
@location(0) pos: vec3f,
|
||||||
|
@location(1) uv: vec2f,
|
||||||
|
@location(2) normal: vec3f,
|
||||||
|
@location(3) id: u32,
|
||||||
|
) -> VsOut {
|
||||||
var clip = u.mvp * vec4f(pos, 1.0);
|
var clip = u.mvp * vec4f(pos, 1.0);
|
||||||
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
|
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
|
||||||
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
|
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
|
||||||
@@ -39,20 +57,11 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
|
|||||||
var out: VsOut;
|
var out: VsOut;
|
||||||
out.pos = clip;
|
out.pos = clip;
|
||||||
out.uv = uv;
|
out.uv = uv;
|
||||||
|
out.normal = normal;
|
||||||
|
out.id = id;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering.
|
|
||||||
fn bayer4(px: vec2u) -> f32 {
|
|
||||||
var m = array<u32, 16>(
|
|
||||||
0u, 8u, 2u, 10u,
|
|
||||||
12u, 4u, 14u, 6u,
|
|
||||||
3u, 11u, 1u, 9u,
|
|
||||||
15u, 7u, 13u, 5u,
|
|
||||||
);
|
|
||||||
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
|
// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
|
||||||
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
|
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
|
||||||
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
|
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
|
||||||
@@ -60,13 +69,16 @@ fn bayer4(px: vec2u) -> f32 {
|
|||||||
const MIP_BIAS: f32 = -1.0;
|
const MIP_BIAS: f32 = -1.0;
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
fn fs_main(in: VsOut) -> FsOut {
|
||||||
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
|
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
|
||||||
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
|
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
|
||||||
|
|
||||||
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
|
var out: FsOut;
|
||||||
// Verläufe zerfallen ins typische Dither-Muster.
|
out.albedo = vec4f(texel.rgb, 1.0);
|
||||||
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0;
|
// Interpolieren verkürzt die Normale — vor dem Kodieren neu normieren.
|
||||||
let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0));
|
// Das Ziel ist 8 Bit pro Kanal: k/255 kommt exakt wieder heraus, aber
|
||||||
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
|
// zwei Objekte 256 Nummern auseinander teilen sich einen Wert. Bei
|
||||||
|
// gerade ~175 Objekten in der Szene ist das Kopfrechnen, kein Problem.
|
||||||
|
out.normal = vec4f(normalize(in.normal) * 0.5 + 0.5, f32(in.id % 256u) / 255.0);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
//! Sprite-Pass: das 2D-Overlay (UI) als getintete Textur-Quads.
|
//! Sprite-Pass: das 2D-Overlay (UI) als getintete Textur-Quads.
|
||||||
//!
|
//!
|
||||||
|
//! Zeichnet in einem eigenen Pass *hinter* der Post-Kette: Bild-Effekte
|
||||||
|
//! (Farbtiefe, Dither, später Outline) treffen so nur die Welt, nie den Text.
|
||||||
|
//!
|
||||||
//! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
|
//! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
|
||||||
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
|
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
|
||||||
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
|
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
|
||||||
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
|
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
|
||||||
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU).
|
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
|
||||||
|
//! dieselbe `scene::upload_texture`, nur ohne Mipchain.
|
||||||
|
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::image::Image;
|
||||||
|
use crate::render::scene::upload_texture;
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
@@ -58,7 +63,6 @@ impl SpritePass {
|
|||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
color_format: wgpu::TextureFormat,
|
color_format: wgpu::TextureFormat,
|
||||||
depth_format: wgpu::TextureFormat,
|
|
||||||
internal: [f32; 2],
|
internal: [f32; 2],
|
||||||
textures: &[Image],
|
textures: &[Image],
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -87,16 +91,9 @@ impl SpritePass {
|
|||||||
})],
|
})],
|
||||||
}),
|
}),
|
||||||
primitive: wgpu::PrimitiveState::default(),
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
// Der interne Pass hat ein Tiefen-Attachment → die Pipeline muss
|
// Eigener Pass hinter der Post-Kette, ohne Tiefen-Attachment:
|
||||||
// ein kompatibles Format deklarieren. UI testet/schreibt aber
|
// das Overlay liegt per Reihenfolge obenauf.
|
||||||
// keine Tiefe (Always, kein Write) → liegt immer obenauf.
|
depth_stencil: None,
|
||||||
depth_stencil: Some(wgpu::DepthStencilState {
|
|
||||||
format: depth_format,
|
|
||||||
depth_write_enabled: Some(false),
|
|
||||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
|
||||||
stencil: wgpu::StencilState::default(),
|
|
||||||
bias: wgpu::DepthBiasState::default(),
|
|
||||||
}),
|
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState::default(),
|
||||||
multiview_mask: None,
|
multiview_mask: None,
|
||||||
cache: None,
|
cache: None,
|
||||||
@@ -134,7 +131,7 @@ impl SpritePass {
|
|||||||
});
|
});
|
||||||
let tex_layout = pipeline.get_bind_group_layout(1);
|
let tex_layout = pipeline.get_bind_group_layout(1);
|
||||||
let tex_binds = textures.iter()
|
let tex_binds = textures.iter()
|
||||||
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
|
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "sprite texture", false))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
|
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
|
||||||
@@ -160,48 +157,3 @@ impl SpritePass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die Bind-Group (Textur +
|
|
||||||
/// Sampler, group 1) bauen.
|
|
||||||
fn upload_texture(
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
layout: &wgpu::BindGroupLayout,
|
|
||||||
sampler: &wgpu::Sampler,
|
|
||||||
img: &Image,
|
|
||||||
) -> wgpu::BindGroup {
|
|
||||||
let size = wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 };
|
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label: Some("sprite texture"),
|
|
||||||
size,
|
|
||||||
mip_level_count: 1,
|
|
||||||
sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
||||||
view_formats: &[],
|
|
||||||
});
|
|
||||||
queue.write_texture(
|
|
||||||
wgpu::TexelCopyTextureInfo {
|
|
||||||
texture: &texture,
|
|
||||||
mip_level: 0,
|
|
||||||
origin: wgpu::Origin3d::ZERO,
|
|
||||||
aspect: wgpu::TextureAspect::All,
|
|
||||||
},
|
|
||||||
&img.rgba,
|
|
||||||
wgpu::TexelCopyBufferLayout {
|
|
||||||
offset: 0,
|
|
||||||
bytes_per_row: Some(img.width * 4),
|
|
||||||
rows_per_image: Some(img.height),
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
let view = texture.create_view(&Default::default());
|
|
||||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("sprite texture"),
|
|
||||||
layout,
|
|
||||||
entries: &[
|
|
||||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
|
||||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln
|
// 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln
|
||||||
// (Ursprung oben-links), getintet. Zeichnet ins interne Target *nach* der
|
// (Ursprung oben-links), getintet. Läuft als letzter interner Pass, hinter
|
||||||
// Szene, ohne Tiefentest → liegt immer obenauf. Echtes Alpha-Blending
|
// der Post-Kette → liegt immer obenauf und bleibt von Bild-Effekten
|
||||||
// (anders als der 1-Bit-Alphatest der Welt), damit Panels und Glyph-Ränder
|
// unberührt. Echtes Alpha-Blending (anders als der 1-Bit-Alphatest der
|
||||||
// weich überblenden.
|
// Welt), damit Panels und Glyph-Ränder weich überblenden.
|
||||||
|
|
||||||
struct Uniforms {
|
struct Uniforms {
|
||||||
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
|
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
//! UI-Overlay: **state-driven** und **immediate-mode**.
|
//! UI-Overlay: **state-driven** und **immediate-mode**.
|
||||||
//!
|
//!
|
||||||
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
|
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
|
||||||
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
|
//! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
|
||||||
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
|
//! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
|
||||||
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
|
//! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
|
||||||
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
|
//! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
|
||||||
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
|
//! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
|
||||||
//! einziger Trichter in den State, kein UI-Backdoor.
|
|
||||||
//!
|
//!
|
||||||
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
||||||
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
||||||
|
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::image::Image;
|
||||||
use crate::render::font::{self, Font, Fonts};
|
use crate::render::font::{self, Font, Fonts};
|
||||||
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
||||||
use crate::session::{Dialog, Mode, Session};
|
use crate::session::{Dialog, Mode, Session};
|
||||||
@@ -57,7 +56,7 @@ pub(crate) fn key_luminance(img: &Image) -> Image {
|
|||||||
pub(crate) struct Cursor {
|
pub(crate) struct Cursor {
|
||||||
/// Position in internen Pixeln (über die Letterbox gemappt).
|
/// Position in internen Pixeln (über die Letterbox gemappt).
|
||||||
pub(crate) pos: [f32; 2],
|
pub(crate) pos: [f32; 2],
|
||||||
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
/// Maus gefangen (Spiel oder Noclip) → First-Person-Pointer mittig statt
|
||||||
/// Maus-Cursor an `pos`.
|
/// Maus-Cursor an `pos`.
|
||||||
pub(crate) grabbed: bool,
|
pub(crate) grabbed: bool,
|
||||||
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
||||||
@@ -225,12 +224,12 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
|
|||||||
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
|
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
|
||||||
let f = &fonts.ega;
|
let f = &fonts.ega;
|
||||||
|
|
||||||
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
// Bei gefangener Maus zählt die freie Sicht → kein Panel.
|
||||||
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
||||||
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
||||||
if !cur.grabbed {
|
if !cur.grabbed {
|
||||||
match &session.mode {
|
match &session.mode {
|
||||||
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
|
// Spiel und Noclip: nur HUD/Cursor, kein Panel (beide laufen
|
||||||
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
|
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
|
||||||
Mode::FirstPerson | Mode::Free => {}
|
Mode::FirstPerson | Mode::Free => {}
|
||||||
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||||||
|
|||||||
@@ -4,21 +4,17 @@ use crate::engine::game::{Action, Game, ModeTarget};
|
|||||||
use crate::engine::ink::StoryState;
|
use crate::engine::ink::StoryState;
|
||||||
use crate::engine::player::{self, Player};
|
use crate::engine::player::{self, Player};
|
||||||
use crate::engine::trigger::Triggers;
|
use crate::engine::trigger::Triggers;
|
||||||
use crate::engine::{kv, model, signals, story_ctrl};
|
use crate::engine::{model, signals, story_ctrl};
|
||||||
|
|
||||||
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
|
||||||
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
const LOOK_SENS: f32 = 0.0025;
|
||||||
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
|
|
||||||
pub const LOOK_SENS: f32 = 0.0025;
|
|
||||||
|
|
||||||
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
|
/// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
|
||||||
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
|
/// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
|
||||||
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
|
/// (bei 1/60 geschriebenen) Player-Tests.
|
||||||
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
|
|
||||||
const FIXED_DT: f32 = 1.0 / 60.0;
|
const FIXED_DT: f32 = 1.0 / 60.0;
|
||||||
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
|
/// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
|
||||||
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
|
/// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
|
||||||
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
|
|
||||||
const MAX_ACCUM: f32 = 0.25;
|
const MAX_ACCUM: f32 = 0.25;
|
||||||
|
|
||||||
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
|
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
|
||||||
@@ -48,25 +44,30 @@ pub struct Session {
|
|||||||
/// Empties, siehe engine::audio).
|
/// Empties, siehe engine::audio).
|
||||||
emitters: Emitters,
|
emitters: Emitters,
|
||||||
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
|
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
|
||||||
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt
|
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
|
||||||
/// sie liegen (ihr reicht die `[action]`-Textzeile).
|
/// `[action]`-Textzeile.
|
||||||
pending_sounds: Vec<String>,
|
pending_sounds: Vec<String>,
|
||||||
|
/// Von `Action::SetPost` aufgelaufene Post-Effekt-Schaltungen (Name,
|
||||||
|
/// Zielzustand) — das Fenster zieht sie per [`Session::take_post`] und
|
||||||
|
/// wendet sie auf seine Kette an; der CLI reicht die `[action]`-Textzeile.
|
||||||
|
pending_post: Vec<(String, bool)>,
|
||||||
signals_path: String,
|
signals_path: String,
|
||||||
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
||||||
sim_accum: f32,
|
sim_accum: f32,
|
||||||
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
|
/// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
|
||||||
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
|
/// einen Frame ohne Sim-Schritt und verfällt.
|
||||||
/// nächsten Sub-Schritt konsumiert.
|
|
||||||
pending_jump: bool,
|
pending_jump: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
|
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
|
||||||
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
|
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
|
||||||
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1].
|
/// sind rohe Maus-Pixel, `fwd`/`right`/`up` Tastenachsen in [-1, 1]; `up`
|
||||||
|
/// wirkt nur im Noclip.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct FrameInput {
|
pub struct FrameInput {
|
||||||
pub fwd: f32,
|
pub fwd: f32,
|
||||||
pub right: f32,
|
pub right: f32,
|
||||||
|
pub up: f32,
|
||||||
pub jump: bool,
|
pub jump: bool,
|
||||||
pub look_dx: f32,
|
pub look_dx: f32,
|
||||||
pub look_dy: f32,
|
pub look_dy: f32,
|
||||||
@@ -97,19 +98,17 @@ impl Session {
|
|||||||
triggers: Triggers::new(),
|
triggers: Triggers::new(),
|
||||||
emitters: Emitters::new(),
|
emitters: Emitters::new(),
|
||||||
pending_sounds: Vec::new(),
|
pending_sounds: Vec::new(),
|
||||||
|
pending_post: Vec::new(),
|
||||||
signals_path,
|
signals_path,
|
||||||
sim_accum: 0.0,
|
sim_accum: 0.0,
|
||||||
pending_jump: false,
|
pending_jump: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
|
/// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
|
||||||
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
|
/// ist Sache von render::props): Collider aus `collide`, Trigger aus
|
||||||
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
|
/// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
|
||||||
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
|
/// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
|
||||||
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
|
|
||||||
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
|
|
||||||
/// desselben Modells ist Sache des Renderers (render::props).
|
|
||||||
pub fn load_props(&mut self, model: &model::Model) {
|
pub fn load_props(&mut self, model: &model::Model) {
|
||||||
self.collision.add_model(model);
|
self.collision.add_model(model);
|
||||||
self.triggers.add_model(model);
|
self.triggers.add_model(model);
|
||||||
@@ -139,13 +138,28 @@ impl Session {
|
|||||||
std::mem::take(&mut self.pending_sounds)
|
std::mem::take(&mut self.pending_sounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aufgelaufene Post-Effekt-Schaltungen (`SetPost`-Actions) abholen — jeden
|
||||||
|
/// Frame vom Fenster-Frontend, das sie auf seine Kette anwendet. Headless
|
||||||
|
/// bleibt die Queue liegen (die CLI hat keine Kette) und wird nie gezogen.
|
||||||
|
pub fn take_post(&mut self) -> Vec<(String, bool)> {
|
||||||
|
std::mem::take(&mut self.pending_post)
|
||||||
|
}
|
||||||
|
|
||||||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||||||
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
|
/// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
|
||||||
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
|
/// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
|
||||||
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
|
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
|
||||||
/// zeigt sie wie `exec`-Ausgaben an.
|
/// zeigt sie wie `exec`-Ausgaben an.
|
||||||
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
|
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
|
||||||
if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); }
|
match self.mode {
|
||||||
|
Mode::FirstPerson => {}
|
||||||
|
Mode::Free => {
|
||||||
|
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
|
||||||
|
self.player.fly(input.fwd, input.right, input.up, dt);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
_ => return Vec::new(),
|
||||||
|
}
|
||||||
|
|
||||||
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
||||||
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
||||||
@@ -217,13 +231,13 @@ impl Session {
|
|||||||
|
|
||||||
fn command(&mut self, line: &str) -> ExecResult {
|
fn command(&mut self, line: &str) -> ExecResult {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
match line {
|
let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||||
"" => ExecResult::empty(),
|
match (verb, args.trim()) {
|
||||||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
("", _) => ExecResult::empty(),
|
||||||
"menu" => {
|
("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
|
||||||
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
|
("menu", _) => {
|
||||||
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
|
// Menü öffnen bzw. ins Spiel zurück; „Fortsetzen" landet immer
|
||||||
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
|
// im Spielmodus, nie im Noclip. (Dialog wird anders geroutet.)
|
||||||
let t = match self.mode {
|
let t = match self.mode {
|
||||||
Mode::Menu => ModeTarget::Play,
|
Mode::Menu => ModeTarget::Play,
|
||||||
_ => ModeTarget::Menu,
|
_ => ModeTarget::Menu,
|
||||||
@@ -231,56 +245,46 @@ impl Session {
|
|||||||
self.set_mode(t);
|
self.set_mode(t);
|
||||||
ExecResult::empty()
|
ExecResult::empty()
|
||||||
}
|
}
|
||||||
"noclip" => {
|
("help", _) => ExecResult::lines(help()),
|
||||||
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
|
("kv", _) => ExecResult::lines(self.dump_kv()),
|
||||||
// umschalten.
|
("reload", _) => {
|
||||||
let t = match self.mode {
|
|
||||||
Mode::FirstPerson => ModeTarget::Free,
|
|
||||||
_ => ModeTarget::Play,
|
|
||||||
};
|
|
||||||
self.set_mode(t);
|
|
||||||
ExecResult::empty()
|
|
||||||
}
|
|
||||||
"help" => ExecResult::lines(help()),
|
|
||||||
"kv" => ExecResult::lines(self.dump_kv()),
|
|
||||||
"reload" => {
|
|
||||||
self.game.signals = signals::load_signals(&self.signals_path);
|
self.game.signals = signals::load_signals(&self.signals_path);
|
||||||
ExecResult::lines(vec![
|
ExecResult::lines(vec![
|
||||||
format!("{} Signale geladen.", self.game.signals.len()),
|
format!("{} Signale geladen.", self.game.signals.len()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
_ => {
|
// Modus über dieselbe Action wie das `mode`-Verb aus
|
||||||
if let Some(m) = line.strip_prefix("mode ") {
|
// signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
|
||||||
// Modus explizit setzen — über dieselbe Action wie das
|
("mode", m) => match ModeTarget::parse(m) {
|
||||||
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
|
|
||||||
// Signals genau einen Wechsel-Pfad teilen.
|
|
||||||
match ModeTarget::parse(m) {
|
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
self.game.actions.push(Action::SetMode(t));
|
self.game.actions.push(Action::SetMode(t));
|
||||||
ExecResult::lines(self.drain_actions())
|
ExecResult::lines(self.drain_actions())
|
||||||
}
|
}
|
||||||
None => ExecResult::lines(vec![
|
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
|
||||||
"Modus: play | free | menu".into(),
|
},
|
||||||
]),
|
// Post-Effekt über dieselbe Action wie das `post`-Verb aus
|
||||||
|
// signals.toml/Ink-Tags. Das Fenster löst den Namen gegen seine
|
||||||
|
// Kette auf; headless bleibt es bei der `[action]`-Zeile.
|
||||||
|
("post", args) => match signals::parse_post_arg(args) {
|
||||||
|
Some((name, on)) => {
|
||||||
|
self.game.actions.push(Action::SetPost { name, on });
|
||||||
|
ExecResult::lines(self.drain_actions())
|
||||||
}
|
}
|
||||||
} else if let Some(sig) = line.strip_prefix("signal ") {
|
None => ExecResult::lines(vec!["post <name> on|off".into()]),
|
||||||
ExecResult::lines(self.fire(sig.trim(), None))
|
},
|
||||||
} else if let Some(name) = line.strip_prefix("use ") {
|
("signal", sig) => ExecResult::lines(self.fire(sig, None)),
|
||||||
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
|
// Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
|
||||||
// hier durch): Klick-Ziele mit `signal`-Property feuern
|
// `signal`-Property, sonst den suffix-befreiten Objektnamen.
|
||||||
// dieses Signal; sonst den vom Blender-Suffix befreiten
|
// `$self` ist immer der volle Name.
|
||||||
// Objektnamen. $self ist immer der volle Name.
|
("use", name) => {
|
||||||
let name = name.trim();
|
|
||||||
let key = self.triggers.signal_for(name)
|
let key = self.triggers.signal_for(name)
|
||||||
.unwrap_or_else(|| signals::signal_key(name))
|
.unwrap_or_else(|| signals::signal_key(name))
|
||||||
.to_string();
|
.to_string();
|
||||||
ExecResult::lines(self.fire(&key, Some(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"),
|
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
|
||||||
])
|
]),
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,15 +368,19 @@ impl Session {
|
|||||||
self.set_mode(t);
|
self.set_mode(t);
|
||||||
out.push(format!("[action] mode {}", t.label()));
|
out.push(format!("[action] mode {}", t.label()));
|
||||||
}
|
}
|
||||||
|
Action::SetPost { name, on } => {
|
||||||
|
out.push(format!("[action] post {name} {}", if on { "on" } else { "off" }));
|
||||||
|
self.pending_post.push((name, on));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
|
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
|
||||||
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
|
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu` und die
|
||||||
/// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus
|
/// `SetMode`-Action laufen beide hier durch. (Der Dialog-Modus entsteht
|
||||||
/// entsteht ausschließlich in `step` aus dem Story-Ablauf.)
|
/// ausschließlich in `step` aus dem Story-Ablauf.)
|
||||||
fn set_mode(&mut self, t: ModeTarget) {
|
fn set_mode(&mut self, t: ModeTarget) {
|
||||||
self.mode = match t {
|
self.mode = match t {
|
||||||
ModeTarget::Play => Mode::FirstPerson,
|
ModeTarget::Play => Mode::FirstPerson,
|
||||||
@@ -385,7 +393,7 @@ impl Session {
|
|||||||
let mut keys: Vec<&String> = self.game.kv.keys().collect();
|
let mut keys: Vec<&String> = self.game.kv.keys().collect();
|
||||||
keys.sort();
|
keys.sort();
|
||||||
keys.into_iter()
|
keys.into_iter()
|
||||||
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k])))
|
.map(|k| format!(" {k} = {}", self.game.kv[k]))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,8 +406,8 @@ fn help() -> Vec<String> {
|
|||||||
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
||||||
" kv KV-Store anzeigen".into(),
|
" kv KV-Store anzeigen".into(),
|
||||||
" menu Menü öffnen / ins Spiel zurück".into(),
|
" menu Menü öffnen / ins Spiel zurück".into(),
|
||||||
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
|
" mode <m> Modus setzen: play | free (Noclip) | menu".into(),
|
||||||
" mode <m> Modus direkt setzen: play | free | menu".into(),
|
" post <e> on|off Post-Effekt schalten (Fenster-Frontend)".into(),
|
||||||
" reload signals.toml neu laden".into(),
|
" reload signals.toml neu laden".into(),
|
||||||
" quit beenden".into(),
|
" quit beenden".into(),
|
||||||
]
|
]
|
||||||
@@ -408,6 +416,7 @@ fn help() -> Vec<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::engine::kv::Value;
|
||||||
|
|
||||||
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
|
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
|
||||||
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
|
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
|
||||||
@@ -432,7 +441,7 @@ mod tests {
|
|||||||
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
|
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
|
||||||
let r = s.exec("signal set has_key true");
|
let r = s.exec("signal set has_key true");
|
||||||
assert!(!r.quit);
|
assert!(!r.quit);
|
||||||
assert!(s.game.kv["has_key"].coerce_to_bool().unwrap());
|
assert!(matches!(s.game.kv["has_key"], Value::Bool(true)));
|
||||||
assert!(matches!(s.mode, Mode::Free));
|
assert!(matches!(s.mode, Mode::Free));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +457,7 @@ mod tests {
|
|||||||
assert!(matches!(s.mode, Mode::Menu));
|
assert!(matches!(s.mode, Mode::Menu));
|
||||||
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
|
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
|
||||||
s.exec("signal set in_menu true");
|
s.exec("signal set in_menu true");
|
||||||
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
|
assert!(matches!(s.game.kv["in_menu"], Value::Bool(true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -475,16 +484,6 @@ mod tests {
|
|||||||
assert!(matches!(s.mode, Mode::FirstPerson));
|
assert!(matches!(s.mode, Mode::FirstPerson));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn noclip_toggles_play_and_free() {
|
|
||||||
let mut s = empty_session();
|
|
||||||
s.mode = Mode::FirstPerson;
|
|
||||||
s.exec("noclip");
|
|
||||||
assert!(matches!(s.mode, Mode::Free));
|
|
||||||
s.exec("noclip");
|
|
||||||
assert!(matches!(s.mode, Mode::FirstPerson));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixed_timestep_is_framerate_independent() {
|
fn fixed_timestep_is_framerate_independent() {
|
||||||
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
|
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
|
||||||
@@ -525,13 +524,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tick_only_runs_in_first_person() {
|
fn menu_pauses_the_sim() {
|
||||||
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
|
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
s.mode = Mode::Menu;
|
s.mode = Mode::Menu;
|
||||||
let before = s.player.pos;
|
let before = s.player.pos;
|
||||||
s.tick(&FrameInput::default(), 0.1);
|
s.tick(&FrameInput::default(), 0.1);
|
||||||
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
|
assert_eq!(s.player.pos, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noclip_flies_the_player_without_physics() {
|
||||||
|
// Free bewegt denselben Spieler, nur ohne Welt: `up` steigt, und beim
|
||||||
|
// Zurückschalten steht er dort, wo er hingeflogen ist.
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.mode = Mode::Free;
|
||||||
|
s.tick(&FrameInput { up: 1.0, ..Default::default() }, 1.0);
|
||||||
|
assert!(s.player.pos[1] > 1.5, "sollte gestiegen sein: {}", s.player.pos[1]);
|
||||||
|
assert!(!s.player.grounded);
|
||||||
|
|
||||||
|
let flown_to = s.player.pos;
|
||||||
|
s.exec("mode play");
|
||||||
|
assert_eq!(s.player.pos, flown_to, "Noclip-Ende teleportiert nicht zurück");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
|
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
|
||||||
@@ -545,7 +558,7 @@ mod tests {
|
|||||||
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
|
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
|
||||||
],
|
],
|
||||||
empties: Vec::new(),
|
empties: Vec::new(),
|
||||||
materials: vec![String::new()],
|
textures: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +572,7 @@ mod tests {
|
|||||||
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
|
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
|
||||||
// Flanke genau einmal (danach: drin = still).
|
// Flanke genau einmal (danach: drin = still).
|
||||||
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
||||||
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");
|
assert!(matches!(s.game.kv["zone_hit"], Value::Bool(true)), "Zone sollte gefeuert haben");
|
||||||
|
|
||||||
// Weiterlaufen in der Zone darf nicht erneut feuern.
|
// Weiterlaufen in der Zone darf nicht erneut feuern.
|
||||||
s.game.kv.remove("zone_hit");
|
s.game.kv.remove("zone_hit");
|
||||||
@@ -574,7 +587,7 @@ mod tests {
|
|||||||
s.mode = Mode::FirstPerson;
|
s.mode = Mode::FirstPerson;
|
||||||
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
|
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
|
||||||
s.exec("use Kiste");
|
s.exec("use Kiste");
|
||||||
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
assert!(matches!(s.game.kv["kiste_geklickt"], Value::Bool(true)));
|
||||||
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
||||||
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
||||||
let r = s.exec("use Irgendwas.003");
|
let r = s.exec("use Irgendwas.003");
|
||||||
@@ -615,11 +628,37 @@ mod tests {
|
|||||||
props: crate::engine::model::Props::new(),
|
props: crate::engine::model::Props::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
materials: Vec::new(),
|
textures: Vec::new(),
|
||||||
});
|
});
|
||||||
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
|
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_command_queues_setpost_for_the_frontend() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.mode = Mode::Free;
|
||||||
|
// Der Befehl kennt die Kette nicht — er reiht die Schaltung ein, das
|
||||||
|
// Fenster zieht sie per take_post().
|
||||||
|
let r = s.exec("post outline off");
|
||||||
|
assert_eq!(r.output, vec!["[action] post outline off"]);
|
||||||
|
assert_eq!(s.take_post(), vec![("outline".to_string(), false)]);
|
||||||
|
assert!(s.take_post().is_empty(), "abgeholt = geleert");
|
||||||
|
|
||||||
|
// Fehlendes/ungültiges on|off → Nutzungshinweis, nichts eingereiht.
|
||||||
|
assert_eq!(s.exec("post outline").output, vec!["post <name> on|off"]);
|
||||||
|
assert_eq!(s.exec("post outline wat").output, vec!["post <name> on|off"]);
|
||||||
|
assert!(s.take_post().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_signal_from_table_queues_setpost() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.mode = Mode::Free;
|
||||||
|
// Ink-Tag-/signals.toml-Pfad: `post`-Verb direkt als Action.
|
||||||
|
s.exec("signal post dither on");
|
||||||
|
assert_eq!(s.take_post(), vec![("dither".to_string(), true)]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_command_reports_and_stays_free() {
|
fn unknown_command_reports_and_stays_free() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
|
|||||||
@@ -27,3 +27,29 @@ Das Skript sucht den Ink-Compiler in dieser Reihenfolge:
|
|||||||
1. `$INKLECATE` (Pfad zum Binary)
|
1. `$INKLECATE` (Pfad zum Binary)
|
||||||
2. `tools/inklecate` (mitgeliefert)
|
2. `tools/inklecate` (mitgeliefert)
|
||||||
3. `inklecate` im `PATH`
|
3. `inklecate` im `PATH`
|
||||||
|
|
||||||
|
## build-models.sh
|
||||||
|
|
||||||
|
Exportiert die Blender-Welten zu den `.glb`, die das Spiel lädt.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools/build-models.sh # nur Geändertes neu bauen
|
||||||
|
./tools/build-models.sh -f # alles neu bauen
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow für die Autorin: `.blend` in den jeweiligen Map-Ordner legen —
|
||||||
|
`assets/maps/<map>/<name>.blend` — Skript laufen lassen, fertig. Die
|
||||||
|
`.glb` landen direkt daneben (`assets/maps/<map>/<name>.glb`) und werden
|
||||||
|
mit ins Repo eingecheckt, damit ein frischer Clone ohne Blender spielbar
|
||||||
|
ist. Ein `.glb` ohne `.blend` daneben (z.B. `props/testscene.glb`) bleibt
|
||||||
|
unangetastet.
|
||||||
|
|
||||||
|
**Automatisch beim Build:** `cargo build`/`cargo run` ruft dieses Skript
|
||||||
|
über `build.rs` selbst auf, sobald sich eine `.blend` geändert hat.
|
||||||
|
|
||||||
|
Blender wird über `$BLENDER` oder den `PATH` gefunden. Fehlt es, wird der
|
||||||
|
Schritt übersprungen (kein Fehler — die committeten `.glb` reichen).
|
||||||
|
Bundeln geht nicht, dafür ist Blender zu groß.
|
||||||
|
|
||||||
|
Tipp: in Blender „File → External Data → Pack Resources", dann liegen die
|
||||||
|
Texturen in der `.blend` und der Export ist self-contained.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Exportiert alle Blender-Welten unter assets/maps/ zu den .glb, die das
|
||||||
|
# Spiel lädt.
|
||||||
|
#
|
||||||
|
# Für die Autorin: Leg deine .blend in den jeweiligen Map-Ordner
|
||||||
|
# assets/maps/<map>/<name>.blend
|
||||||
|
# und führ danach dieses Skript aus:
|
||||||
|
# ./tools/build-models.sh
|
||||||
|
# Fertig — die .glb landen direkt daneben (assets/maps/<map>/<name>.glb).
|
||||||
|
#
|
||||||
|
# Standardmäßig wird nur exportiert, was sich seit dem letzten Mal geändert
|
||||||
|
# hat. Mit -f / --force wird alles neu gebaut.
|
||||||
|
#
|
||||||
|
# Tipp: in Blender „File -> External Data -> Pack Resources", dann sind die
|
||||||
|
# Texturen in der .blend und der Export ist self-contained.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Repo-Wurzel relativ zum Skript bestimmen — egal, von wo aufgerufen.
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
maps_dir="$root/assets/maps"
|
||||||
|
|
||||||
|
force=0
|
||||||
|
case "${1:-}" in
|
||||||
|
-f|--force) force=1 ;;
|
||||||
|
"") ;;
|
||||||
|
*) echo "Unbekannte Option: $1 (nur -f/--force)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# blender finden: $BLENDER -> PATH. Bundeln geht nicht (zu groß) — fehlt es,
|
||||||
|
# ist das kein Fehler: die committeten .glb reichen zum Bauen und Spielen.
|
||||||
|
if [ -n "${BLENDER:-}" ] && [ -x "$BLENDER" ]; then
|
||||||
|
blender="$BLENDER"
|
||||||
|
elif command -v blender >/dev/null 2>&1; then
|
||||||
|
blender="$(command -v blender)"
|
||||||
|
else
|
||||||
|
echo " übersprungen blender nicht gefunden (\$BLENDER oder PATH) — .glb bleiben wie committet" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$maps_dir" ]; then
|
||||||
|
echo "Kein Map-Ordner: $maps_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Export als Python-Einzeiler. filepath ohne Endung — export_format='GLB'
|
||||||
|
# hängt .glb an. export_extras trägt collide/signal/role mit.
|
||||||
|
gltf_expr='
|
||||||
|
import bpy, sys
|
||||||
|
out = sys.argv[-1]
|
||||||
|
bpy.ops.export_scene.gltf(
|
||||||
|
filepath=out,
|
||||||
|
export_format="GLB",
|
||||||
|
export_extras=True,
|
||||||
|
export_yup=True,
|
||||||
|
export_apply=True,
|
||||||
|
export_image_format="AUTO",
|
||||||
|
)'
|
||||||
|
|
||||||
|
built=0 skipped=0 failed=0
|
||||||
|
while IFS= read -r -d '' src; do
|
||||||
|
out="${src%.blend}.glb"
|
||||||
|
rel="${src#"$root"/}"
|
||||||
|
|
||||||
|
# Inkrementell: überspringen, wenn .glb existiert und neuer ist.
|
||||||
|
if [ "$force" -eq 0 ] && [ -f "$out" ] && [ "$out" -nt "$src" ]; then
|
||||||
|
printf ' aktuell %s\n' "$rel"
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if log="$("$blender" -b --factory-startup "$src" \
|
||||||
|
--python-expr "$gltf_expr" -- "${out%.glb}" 2>&1)"; then
|
||||||
|
printf ' gebaut %s\n' "$rel"
|
||||||
|
built=$((built + 1))
|
||||||
|
else
|
||||||
|
printf ' FEHLER %s\n' "$rel"
|
||||||
|
printf '%s\n' "$log" | sed 's/^/ /'
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$maps_dir" -type f -name '*.blend' -print0 | sort -z)
|
||||||
|
|
||||||
|
echo "—— $built gebaut, $skipped aktuell, $failed fehlgeschlagen ——"
|
||||||
|
[ "$failed" -eq 0 ]
|
||||||