Compare commits

..
7 Commits
Author SHA1 Message Date
irrlicht 04679fa897 Carpet texture 2026-09-09 13:52:40 +02:00
irrlichtandClaude Sonnet 5 331caf417a Blender-Welten aus der .blend im Map-Ordner bauen
Analog zur Ink-Pipeline: eine .blend neben ihrer .glb im Map-Ordner
(assets/maps/<map>/<name>.blend) wird von tools/build-models.sh headless
zu <name>.glb exportiert, inkrementell per mtime. build.rs stößt das beim
cargo-Build an, sobald sich eine .blend geändert hat.

- Export: blender -b --factory-startup ... export_scene.gltf mit
  export_extras (collide/signal/role), export_apply, yup, image AUTO.
- Blender über $BLENDER oder PATH; fehlt es, wird der Schritt still
  übersprungen — die committeten .glb reichen zum Bauen/Spielen.
- Ein Export-Fehler bei vorhandenem Blender ist fatal (wie bei Ink).
- build.rs teilt sich jetzt run_script/watch-Helfer für beide Pipelines.
- Cinemaroom.blend -> assets/maps/cinema/cinema.blend verschoben und die
  .glb einmal daraus gebaut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:53:29 +02:00
irrlichtandClaude Sonnet 5 f51f4e83fd Tote Welt-TGAs und notes/artist.md-Duplikat entfernt
Die Welt-Texturen kommen jetzt aus der .glb; die alten .tga waren nur
noch Quellmaterial für Blender und liegen dort in den .blend. notes/
artist.md war ein veraltetes Duplikat von artist.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:40:25 +02:00
irrlicht 737a297436 Texture tests 2026-09-02 00:35:29 +02:00
irrlichtandClaude Sonnet 5 b72b90c50d TGA-Loader raus, alles PNG
Der einzige verbliebene TGA-Nutzen — UI, Fonts, Platzhalter — läuft jetzt
über engine::image::load (derselbe PNG-Decoder wie für die .glb).
engine::tga ist damit weg.

- image::load(path): PNG von der Platte, Format per Signatur.
- Fonts/Cursor/Ornamente/Platzhalter als .png (Fonts + UI neu aus dem
  Quellmaterial exportiert, placeholder aus der alten .tga konvertiert).
- Test loads_the_real_project_assets deckt die fünf Projekt-Assets ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:33:04 +02:00
irrlichtandClaude Sonnet 5 fc3494466e Welt-Texturen aus der .glb statt aus assets/textures/
Der Loader zieht jetzt die baseColorTexture jedes Materials aus dem
BIN-Chunk und dekodiert sie (PNG, via png-Crate) → model::Texture mit
Pixeln. Die Namenskonvention "Materialname = Dateiname" und der
assets/textures/-Pfad für Welt-Texturen entfallen; assets/textures/
bleibt nur noch für UI/Fonts/Platzhalter.

- engine::image: neuer, geteilter RGBA8-Image-Typ (aus tga.rs gezogen)
  plus PNG-Decoder. tga.rs liefert weiter denselben Typ.
- gltf: material -> texture -> image -> bufferView auflösen, Bild aus
  BIN dekodieren; fehlt ein Glied, bleibt image None (Platzhalter).
  KHR_texture_transform (Blenders Mapping-Node) wird beim Laden in die
  UVs eingebacken.
- model::Model: materials: Vec<String> -> textures: Vec<Texture>.
- render: die Pixel je Schlüssel aus den Modellen sammeln (erstes
  Vorkommen gewinnt, Schlüssel ohne Bild -> Platzhalter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:29:46 +02:00
irrlichtandClaude Sonnet 5 937fbd6ede maps-Verzeichnis per --maps <dir> statt hartkodiert
Das Fenster-Frontend lud die Welt-.glb aus dem festen String
"assets/maps/props". Jetzt ist das nur noch der Default
(render::DEFAULT_MAPS_DIR); `main` parst `--maps <dir>` und reicht das
Verzeichnis an render::run weiter. Der übergebene Pfad wird wie angegeben
benutzt (relativ zum CWD), der Default wie bisher relativ zum
Asset-Basisverzeichnis.

Damit lässt sich z.B. `assets/maps/inactive` ohne Neubau laden. Die CLI
lädt weiterhin keine Modelle und ignoriert das Flag.

README + artist.md ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 23:24:17 +02:00
44 changed files with 728 additions and 357 deletions
Generated
+81
View File
@@ -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"
+1
View File
@@ -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"] }
+7
View File
@@ -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.
+18 -9
View File
@@ -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.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+54 -31
View File
@@ -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");
for entry in entries.flatten() {
let p = entry.path(); // --- Blender-Welten -------------------------------------------------
if p.extension().and_then(|s| s.to_str()) == Some("ink") { println!("cargo:rerun-if-changed=tools/build-models.sh");
println!("cargo:rerun-if-changed={}", p.display()); // 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() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some(ext) {
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"
);
} }
} }
} }
-77
View File
@@ -1,77 +0,0 @@
Alles Inhaltliche kommt aus vier Quellen:
**Blender** (Welt),
**Ink** (Dialoge/Story),
**signals.toml** (Verdrahtung),
**Assets-Ordner** (Texturen/Sounds). Fehler in Inhalten stürzen nie ab — sie werden beim
Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
## 3D / Blender
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
dort werden beim Start geladen.
- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
keine der Properties unten an!).
- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m,
Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken.
- **Material-Name = Textur-Name:** Material `holz` → `assets/textures/holz.tga`.
Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur.
- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit.
### Custom Properties auf Mesh-Objekten
| Property | Wirkung |
|---|---|
| *(keine)* | nur sichtbar, kein Anfassen |
| `collide = 1` | sichtbar **und** solide (Spieler kollidiert mit der konvexen Hülle des Meshes — Dellen/Löcher werden überspannt) |
| `collide = proxy` | unsichtbares Kollisionsvolumen (für Fein-Collider neben Sichtgeometrie) |
| `signal = <name>` **mit** collide | Klick-Ziel: Anklicken feuert das Signal `<name>` |
| `signal = <name>` **ohne** collide | unsichtbare Trigger-Zone: Betreten feuert `<name>` einmal (erneut erst nach Verlassen; „nur einmal überhaupt" selbst per KV-Flag bauen) |
- `collide = 0` schaltet ab, ohne die Property zu löschen.
- Objektname ist der Interact-Key: Blender-Suffixe (`Ding.001`) werden beim
Signal-Lookup automatisch gestrippt — `[Ding]` in signals.toml trifft alle.
### Custom Properties auf Empties
| Property | Wirkung |
|---|---|
| `role = spawn` | Spieler-Startpunkt (Fußpunkt = Empty-Position) |
| `sound = <name>` | Ambient-Loop `assets/sounds/<name>.wav` an dieser Position |
| `radius = <m>` | Hörradius des Sounds (linear leiser bis Stille; Default 8) |
## Audio
- **Format:** WAV, **mono, 16 bit, 44100 Hz** — nichts anderes. Ablage in
`assets/sounds/`, angesprochen ohne Endung (`pickup` → `pickup.wav`).
- **Ambient:** per Empty (siehe oben), loopt immer — Loop-Punkt sauber schneiden.
- **Einmal-SFX:** per Action `play_sound <name>` (aus signals.toml oder Ink-Tag).
## Dialoge / Ink
- `.ink` nach `assets/interactions/` — wird beim Build automatisch
kompiliert (bei Bedarf von Hand: `./tools/build-dialogues.sh`).
- Gestartet per Action `start_ink <datei>.ink.json`.
- **Variablen:** alle Ink-`VAR` landen automatisch im Spiel-KV und synchen
in beide Richtungen — `_`-Prefix (`VAR _tmp = 0`) bleibt Ink-lokal.
- **Tags feuern Actions:** `# set has_key true`, `# play_sound klick`,
`# mode play` … (gleiche Verben wie signals.toml). Unbekannte Tags sind
erlaubt (reine Metadaten).
## signals.toml (`assets/signals.toml`)
- Verdrahtet Signal → Aktionen. Signal-Namen: Objektname (Klick), Wert der
`signal`-Property (Zonen/Klick-Ziele), oder frei (aus Ink).
- Verben: `start_ink`, `set`, `inc`, `clear`, `debug_log`, `play_sound`,
`mode play|free|menu`.
- `$self` = auslösender Objektname (für generische Einträge wie
`set $self true`).
- `[init]` feuert einmal beim Start — Ort für KV-Defaults.
## Testen
- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke,
Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen.
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
Signal von Hand, `reload` lädt signals.toml neu.
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -380,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);
+191 -16
View File
@@ -10,14 +10,21 @@
//! 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.
//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt. //!
//! 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.
use std::collections::HashMap;
use serde_json::Value; use serde_json::Value;
use crate::engine::model::{apply_props, fill_missing_normals, norm, 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}"))?;
@@ -52,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.
@@ -62,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")); }
@@ -80,7 +187,7 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
verts: Vec::new(), uvs: Vec::new(), normals: 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 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); }
@@ -104,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 {
@@ -125,14 +231,10 @@ fn append_mesh(
None => vec![[0.0, 0.0, 0.0]; positions.len()], None => vec![[0.0, 0.0, 0.0]; positions.len()],
}; };
// Material-Name → geteilte Namensliste; 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();
let nm = normal_matrix(world); let nm = normal_matrix(world);
@@ -141,6 +243,14 @@ fn append_mesh(
o.uvs.push(*uv); o.uvs.push(*uv);
o.normals.push(norm(transform3(nm, *n))); 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() {
Some(a) => read_indices(doc, bin, a as usize)?, Some(a) => read_indices(doc, bin, a as usize)?,
@@ -151,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(())
@@ -431,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.
@@ -491,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:?}");
}
} }
+118
View File
@@ -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}");
}
}
}
+7 -4
View File
@@ -14,9 +14,12 @@
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu //! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! rufen. //! rufen.
//! //!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf` //! `gltf`, `image` und `wav` sind reine Decoder (Bytes → owned Daten);
//! ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag zwischen //! `gltf` ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag
//! Loader und seinen Konsumenten (collision, trigger, audio, render::props). //! zwischen Loader und seinen Konsumenten (collision, trigger, audio,
//! render::props) — samt der Welt-Texturen, die als PNG in der `.glb` liegen.
//! `image` dekodiert PNG (eingebettet wie von der Platte) und stellt den
//! RGBA8-`Image`-Typ für alle.
//! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player` //! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player`
//! die First-Person-Physik auf der Welt aus `collision`. Post-Effekte kennt //! die First-Person-Physik auf der Welt aus `collision`. Post-Effekte kennt
//! der Kern nur als `game::Action::SetPost` — die Kette lebt in render::post. //! der Kern nur als `game::Action::SetPost` — die Kette lebt in render::post.
@@ -26,12 +29,12 @@ 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;
+24 -6
View File
@@ -6,6 +6,10 @@
//! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind //! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind
//! Geschichte), `gltf` der einzige Produzent. //! 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
//! [`apply_props`]: //! [`apply_props`]:
@@ -22,6 +26,8 @@
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. /// Werte zu Strings vereinheitlicht.
pub type Props = HashMap<String, String>; pub type Props = HashMap<String, String>;
@@ -31,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 {
@@ -43,13 +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 /// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im
/// Modell, rechnet der Loader sie aus den Flächen. /// Modell, rechnet der Loader sie aus den Flächen.
pub normals: Vec<[f32; 3]>, 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,
-146
View File
@@ -1,146 +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, nur das Sampling (und damit die UV-Konvention) gehört ihm.
//!
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
//! 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
}
}
+1 -1
View File
@@ -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]
+17 -2
View File
@@ -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()
}
+3 -3
View File
@@ -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)
} }
+1 -1
View File
@@ -16,7 +16,7 @@ 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::post::{Post, PostChain};
use crate::render::scene::{Mesh, ScenePass}; use crate::render::scene::{Mesh, ScenePass};
+39 -27
View File
@@ -29,9 +29,9 @@ 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 gpu::Gpu; use gpu::Gpu;
use math::Mat4; use math::Mat4;
@@ -44,22 +44,40 @@ 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}"); }
// Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier, // Die Welt sind alle .glb unter `maps_dir` (Default DEFAULT_MAPS_DIR, per
// Upload (GPU) später in `Gpu` — die beiden bleiben getrennt. // `--maps <dir>` überschrieben). Decode (CPU) hier, Upload (GPU) später in
let models = load_models(&assets::path("assets/maps/props")); // `Gpu` — die beiden bleiben getrennt.
let models = load_models(maps_dir);
// 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)); }
@@ -88,12 +106,12 @@ pub fn run(mut session: Session) {
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die // Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// Installation kaputt und es darf scheitern. // Installation kaputt und es darf 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),
@@ -102,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
@@ -181,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, .expect("Platzhalter-Textur (Projekt-Asset)")
Err(e) => {
eprintln!("[tex] {e} — Platzhalter");
tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
.expect("Platzhalter-Textur (Projekt-Asset)")
}
}
} }
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events), /// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
+45 -25
View File
@@ -4,34 +4,42 @@
//! [`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 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-
@@ -53,7 +61,7 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
} }
id += 1; 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));
} }
@@ -73,8 +81,19 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
#[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 {
@@ -93,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);
@@ -114,7 +133,7 @@ mod tests {
#[test] #[test]
fn visible_objects_get_distinct_ids_across_models() { fn visible_objects_get_distinct_ids_across_models() {
let names = texture_names(&sample()); let names = texture_keys(&sample());
// Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell. // Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell.
let mut m = sample(); let mut m = sample();
m.objects.insert(1, tri("Zweites", true)); m.objects.insert(1, tri("Zweites", true));
@@ -133,11 +152,12 @@ mod tests {
} }
#[test] #[test]
fn missing_material_maps_to_fallback() { fn imageless_texture_maps_to_fallback() {
let mut m = sample(); let mut m = sample();
m.materials = vec![String::new()]; // Faces ohne Material // Material ohne baseColorTexture: Schlüssel gesetzt, aber kein Bild.
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]); m.textures = vec![Texture { key: "nackt".into(), image: None }];
let mesh = build(&m, &texture_names(&m)); 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);
} }
+3 -3
View File
@@ -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)]
+1 -1
View File
@@ -10,7 +10,7 @@
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über //! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
//! dieselbe `scene::upload_texture`, nur ohne Mipchain. //! dieselbe `scene::upload_texture`, nur ohne Mipchain.
use crate::engine::tga::Image; use crate::engine::image::Image;
use crate::render::scene::upload_texture; use crate::render::scene::upload_texture;
#[repr(C)] #[repr(C)]
+1 -1
View File
@@ -10,7 +10,7 @@
//! 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};
+2 -2
View File
@@ -558,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(),
} }
} }
@@ -628,7 +628,7 @@ 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");
} }
+26
View File
@@ -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.
+86
View File
@@ -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 ]