diff --git a/Cargo.lock b/Cargo.lock
index 6977e87..5ebb956 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -18,6 +18,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -410,6 +416,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "crc32fast"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -496,12 +511,32 @@ dependencies = [
"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]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "foldhash"
version = "0.1.5"
@@ -967,6 +1002,26 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "naga"
version = "29.0.3"
@@ -1436,6 +1491,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "polling"
version = "3.11.0"
@@ -1792,6 +1860,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
[[package]]
name = "simd_cesu8"
version = "1.1.1"
@@ -2321,6 +2395,7 @@ version = "0.1.0"
dependencies = [
"bladeink",
"bytemuck",
+ "png",
"pollster",
"serde_json",
"tinyaudio",
@@ -2986,6 +3061,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/Cargo.toml b/Cargo.toml
index d3a41d1..6618da8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
bladeink = "1.2.5"
bytemuck = { version = "1.25.0", features = ["derive"] }
+png = "0.18.1"
pollster = "0.4.0"
serde_json = "1"
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
diff --git a/artist.md b/artist.md
index 4403569..fb9d84f 100644
--- a/artist.md
+++ b/artist.md
@@ -7,13 +7,18 @@ Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
dort werden beim Start geladen (anderer Ordner: `cargo run -- --maps
`).
-- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
- keine der Properties unten an!).
+- **Beim Export anhaken:** „Include → Custom Properties" (sonst fehlen die
+ Properties unten) **und** „Include → Textures" / „Data → Images"
+ (sonst fehlen die Texturen).
- **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.
+- **Texturen:** Bild im Material als Base Color zuweisen — es wird beim
+ Export in die `.glb` eingebettet und mitgeladen (kein `assets/textures/`
+ mehr). Format **PNG** (Export „Images: PNG" oder „Automatic"). 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 auf Mesh-Objekten
diff --git a/src/engine/audio.rs b/src/engine/audio.rs
index 97c2195..610f58c 100644
--- a/src/engine/audio.rs
+++ b/src/engine/audio.rs
@@ -79,7 +79,7 @@ mod tests {
use crate::engine::model::{Empty, Props};
fn model(empties: Vec) -> 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 {
diff --git a/src/engine/collision.rs b/src/engine/collision.rs
index 0d272eb..c05922b 100644
--- a/src/engine/collision.rs
+++ b/src/engine/collision.rs
@@ -380,7 +380,7 @@ mod tests {
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
],
empties: Vec::new(),
- materials: vec![String::new()],
+ textures: Vec::new(),
};
let mut w = CollisionWorld::empty();
w.add_model(&model);
diff --git a/src/engine/gltf.rs b/src/engine/gltf.rs
index f6bcd7f..1de7c47 100644
--- a/src/engine/gltf.rs
+++ b/src/engine/gltf.rs
@@ -10,14 +10,21 @@
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
-//! 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,
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
+use std::collections::HashMap;
+
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 {
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 {
}
fn build(doc: &Value, bin: &[u8]) -> Result {
- 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
// Welt-Transform wächst dabei Parent → Kind.
@@ -62,9 +69,109 @@ fn build(doc: &Value, bin: &[u8]) -> Result {
let Some(i) = r.as_u64() else { continue; };
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
}
+ resolve_textures(doc, bin, &mut 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 = Vec::new();
+ let mut local: HashMap = 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 {
+ 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 {
+ 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> {
let node = &doc["nodes"][idx];
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(),
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)
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
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
fn append_mesh(
- doc: &Value, bin: &[u8], mesh: usize, world: M4,
- o: &mut Object, materials: &mut Vec,
+ doc: &Value, bin: &[u8], mesh: usize, world: M4, o: &mut Object,
) -> Result<(), String> {
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
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()],
};
- // Material-Name → geteilte Namensliste; ohne Material "".
- let mat_name = prim["material"].as_u64()
- .and_then(|m| doc["materials"][m as usize]["name"].as_str())
- .unwrap_or("");
- let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| {
- materials.push(mat_name.to_string());
- materials.len() - 1
- });
+ // Rohen glTF-Material-Index behalten; resolve_textures baut daraus
+ // nach dem Walk die deduplizierte Texturliste und schreibt die
+ // tri_mats auf deren Indizes um. usize::MAX = kein Material.
+ let raw_mat = prim["material"].as_u64().map_or(usize::MAX, |m| m as usize);
let base = o.verts.len();
let nm = normal_matrix(world);
@@ -141,6 +243,14 @@ fn append_mesh(
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 = match prim["indices"].as_u64() {
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));
}
o.tris.push([base + t[0], base + t[1], base + t[2]]);
- o.tri_mats.push(mi);
+ o.tri_mats.push(raw_mat);
}
}
Ok(())
@@ -431,7 +541,10 @@ mod tests {
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
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);
// collide="proxy" → unsichtbarer Collider.
@@ -491,4 +604,66 @@ mod tests {
v1[4] = 1; // Version patchen
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:?}");
+ }
}
diff --git a/src/engine/image.rs b/src/engine/image.rs
new file mode 100644
index 0000000..fdc6755
--- /dev/null
+++ b/src/engine/image.rs
@@ -0,0 +1,97 @@
+//! 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 (PNG). TGA-Dateien (UI, Fonts) laufen über [`super::tga`] und liefern
+//! denselben `Image`-Typ.
+
+#[derive(Clone)]
+pub struct Image {
+ pub width: u32,
+ pub height: u32,
+ /// `width * height * 4`, Ursprung oben-links.
+ pub rgba: Vec,
+}
+
+/// 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 {
+ 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 {
+ 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 {
+ 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());
+ }
+}
diff --git a/src/engine/mod.rs b/src/engine/mod.rs
index d1e36bf..eee0a99 100644
--- a/src/engine/mod.rs
+++ b/src/engine/mod.rs
@@ -14,9 +14,11 @@
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! rufen.
//!
-//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
-//! ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag zwischen
-//! Loader und seinen Konsumenten (collision, trigger, audio, render::props).
+//! `gltf`, `tga`, `image` und `wav` sind reine Decoder (Bytes → owned Daten);
+//! `gltf` ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag
+//! zwischen Loader und seinen Konsumenten (collision, trigger, audio,
+//! render::props) — samt der Welt-Texturen, die als PNG in der `.glb` liegen
+//! (dekodiert über `image`, das den RGBA8-`Image`-Typ auch für `tga` stellt).
//! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player`
//! 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.
@@ -26,6 +28,7 @@ pub mod audio;
pub mod collision;
pub mod game;
pub mod gltf;
+pub mod image;
pub mod ink;
pub mod kv;
pub mod model;
diff --git a/src/engine/model.rs b/src/engine/model.rs
index b1ec5d6..ec70c28 100644
--- a/src/engine/model.rs
+++ b/src/engine/model.rs
@@ -6,6 +6,10 @@
//! 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
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
//! [`apply_props`]:
@@ -22,6 +26,8 @@
use std::collections::HashMap;
+use crate::engine::image::Image;
+
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
/// Werte zu Strings vereinheitlicht.
pub type Props = HashMap;
@@ -31,10 +37,22 @@ pub struct Model {
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
pub empties: Vec,
- /// Distinkte Materialnamen in Auftrittsreihenfolge; `Object::tri_mats`
- /// indiziert hierhin. `""` steht für „Faces ohne Material" — der
- /// Konsument wählt dafür seinen Fallback (z.B. Platzhalter-Textur).
- pub materials: Vec,
+ /// Distinkte Texturen in Auftrittsreihenfolge; `Object::tri_mats`
+ /// indiziert hierhin.
+ pub textures: Vec,
+}
+
+/// 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,
}
pub struct Object {
@@ -49,7 +67,7 @@ pub struct Object {
/// Modell, rechnet der Loader sie aus den Flächen.
pub normals: Vec<[f32; 3]>,
pub tris: Vec<[usize; 3]>,
- /// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
+ /// Textur-Index je Dreieck, parallel zu `tris` (→ `Model::textures`).
pub tri_mats: Vec,
pub visible: bool,
pub collider: bool,
diff --git a/src/engine/tga.rs b/src/engine/tga.rs
index 889d82f..081e54a 100644
--- a/src/engine/tga.rs
+++ b/src/engine/tga.rs
@@ -1,19 +1,17 @@
-//! TGA-Decoder → fertig dekodiertes RGBA8-`Image`.
+//! TGA-Decoder → fertig dekodiertes RGBA8-[`Image`] (der Typ lebt in
+//! [`super::image`], geteilt mit dem PNG-Pfad).
//!
//! 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.
+//! Zuständig für UI- und Font-Assets; Welt-Texturen kommen als PNG aus der
+//! `.glb` (siehe [`super::gltf`]).
//!
//! 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,
-}
+use crate::engine::image::Image;
pub fn load(path: &str) -> Result {
let bytes = std::fs::read(path).map_err(|e| format!("tga load {path}: {e}"))?;
diff --git a/src/engine/trigger.rs b/src/engine/trigger.rs
index 0317663..c46d1e2 100644
--- a/src/engine/trigger.rs
+++ b/src/engine/trigger.rs
@@ -100,7 +100,7 @@ mod tests {
use crate::engine::model::{test_cube as cube, Object};
fn model(objects: Vec