From fc3494466ec32b9b25a09e1f76a1cdcd34728e1c Mon Sep 17 00:00:00 2001 From: irrlicht Date: Wed, 2 Sep 2026 00:29:46 +0200 Subject: [PATCH] Welt-Texturen aus der .glb statt aus assets/textures/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> textures: Vec. - render: die Pixel je Schlüssel aus den Modellen sammeln (erstes Vorkommen gewinnt, Schlüssel ohne Bild -> Platzhalter). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8 --- Cargo.lock | 81 ++++++++++++++++ Cargo.toml | 1 + artist.md | 15 ++- src/engine/audio.rs | 2 +- src/engine/collision.rs | 2 +- src/engine/gltf.rs | 207 ++++++++++++++++++++++++++++++++++++---- src/engine/image.rs | 97 +++++++++++++++++++ src/engine/mod.rs | 9 +- src/engine/model.rs | 28 +++++- src/engine/tga.rs | 12 +-- src/engine/trigger.rs | 2 +- src/render/font.rs | 2 +- src/render/gpu.rs | 2 +- src/render/mod.rs | 41 ++++---- src/render/props.rs | 70 +++++++++----- src/render/scene.rs | 6 +- src/render/sprite.rs | 2 +- src/render/ui.rs | 2 +- src/session.rs | 4 +- 19 files changed, 495 insertions(+), 90 deletions(-) create mode 100644 src/engine/image.rs 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) -> Model { - Model { objects, empties: Vec::new(), materials: vec![String::new()] } + Model { objects, empties: Vec::new(), textures: Vec::new() } } #[test] diff --git a/src/render/font.rs b/src/render/font.rs index 2b90d0c..1da1a58 100644 --- a/src/render/font.rs +++ b/src/render/font.rs @@ -17,7 +17,7 @@ //! seinen GPU-Textur-Index, damit `ui::Ui::text` ohne Zusatzargumente damit //! zeichnen kann. -use crate::engine::tga::Image; +use crate::engine::image::Image; const COLS: u32 = 32; diff --git a/src/render/gpu.rs b/src/render/gpu.rs index c41edde..e4e89c3 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use winit::window::Window; -use crate::engine::tga::Image; +use crate::engine::image::Image; use crate::render::math::Mat4; use crate::render::post::{Post, PostChain}; use crate::render::scene::{Mesh, ScenePass}; diff --git a/src/render/mod.rs b/src/render/mod.rs index a8535b1..b66cca6 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -29,8 +29,8 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::keyboard::{KeyCode, PhysicalKey}; use winit::window::{CursorGrabMode, Window, WindowId}; +use crate::engine::image::Image; use crate::engine::player; -use crate::engine::tga::Image; use crate::engine::{assets, gltf, model, tga, wav}; use crate::session::{FrameInput, Mode, Session}; use gpu::Gpu; @@ -58,13 +58,26 @@ pub fn run(mut session: Session, maps_dir: &str) { // `Gpu` — die beiden bleiben getrennt. let models = load_models(maps_dir); - // Eine geteilte Texturliste über alle Modelle, damit ihre Meshes - // denselben Index-Raum benutzen und zu einem Buffer verschmelzen können. + // Eine geteilte Textur-Schlüsselliste über alle Modelle, damit ihre + // Meshes denselben Index-Raum benutzen und zu einem Buffer verschmelzen. let mut tex_names: Vec = 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.dedup(); - let images: Vec = 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 = 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(); for (_, m) in &models { mesh.append(props::build(m, &tex_names)); } @@ -186,18 +199,12 @@ fn load_sound(name: &str) -> Option { } } -/// Welt-Textur laden. Autorinnen-Content: fehlt oder klemmt die Datei, wird -/// gemeldet und der Platzhalter genommen — der selbst ist Projekt-Asset und -/// darf hart scheitern. -fn load_texture(name: &str) -> Image { - match tga::load(&assets::path(&format!("assets/textures/{name}.tga"))) { - Ok(img) => img, - Err(e) => { - eprintln!("[tex] {e} — Platzhalter"); - tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE))) - .expect("Platzhalter-Textur (Projekt-Asset)") - } - } +/// Die Platzhalter-Textur für Schlüssel ohne eigenes Bild in der `.glb`. +/// Projekt-Asset: fehlt sie, ist die Installation kaputt und es darf scheitern. +fn placeholder() -> Image { + let name = props::FALLBACK_TEXTURE; + tga::load(&assets::path(&format!("assets/textures/{name}.tga"))) + .expect("Platzhalter-Textur (Projekt-Asset)") } /// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events), diff --git a/src/render/props.rs b/src/render/props.rs index 383aa8a..6a0f2fc 100644 --- a/src/render/props.rs +++ b/src/render/props.rs @@ -4,34 +4,42 @@ //! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere //! Modelle verschmelzen über `Mesh::append` zu einem Buffer. //! -//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst zu -//! `assets/textures/{name}.tga` (macht der Aufrufer). Faces ohne Material -//! (`""`) laufen unter [`FALLBACK_TEXTURE`]. +//! Texturen liegen als Pixel im Modell (`model::Texture`, aus der `.glb`). +//! Hier wird nur nach ihrem Schlüssel gruppiert; Texturen ohne Bild — +//! 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 -//! Oben-links-Ursprung). Nur `visible`-Objekte werden ausgegeben; +//! UVs kommen unverändert durch (Modell und `Image` teilen den +//! 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 //! engine::trigger. -use crate::engine::model::Model; +use crate::engine::model::{Model, Texture}; 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.tga`). pub const FALLBACK_TEXTURE: &str = "placeholder"; -/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den -/// Fallback gemappt. -pub fn texture_names(model: &Model) -> Vec { - let mut names: Vec = model.materials.iter() - .map(|m| resolve(m).to_string()) +/// Distinkte Textur-Schlüssel des Modells (sortiert), bildlose bereits auf +/// den Fallback gemappt. +pub fn texture_keys(model: &Model) -> Vec { + let mut keys: Vec = model.textures.iter() + .map(|t| texture_key(t).to_string()) .collect(); - names.sort_unstable(); - names.dedup(); - names + keys.sort_unstable(); + keys.dedup(); + keys } -fn resolve(material: &str) -> &str { - if material.is_empty() { FALLBACK_TEXTURE } else { material } +/// Gruppierungs-Schlüssel einer Textur: ihr eigener, sofern sie Pixel hat — +/// 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- @@ -53,7 +61,7 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh { } id += 1; 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; }; 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)] mod tests { use super::*; + use crate::engine::image::Image; 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. fn tri(name: &str, visible: bool) -> Object { Object { @@ -93,14 +112,14 @@ mod tests { Model { objects: vec![tri("Ding", true), tri("Proxy", false)], empties: Vec::new(), - materials: vec!["carpet".to_string()], + textures: vec![tex("carpet")], } } #[test] fn builds_only_visible_objects() { let m = sample(); - let names = texture_names(&m); + let names = texture_keys(&m); assert_eq!(names, vec!["carpet".to_string()]); let mesh = build(&m, &names); @@ -114,7 +133,7 @@ mod tests { #[test] 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. let mut m = sample(); m.objects.insert(1, tri("Zweites", true)); @@ -133,11 +152,12 @@ mod tests { } #[test] - fn missing_material_maps_to_fallback() { + fn imageless_texture_maps_to_fallback() { let mut m = sample(); - m.materials = vec![String::new()]; // Faces ohne Material - assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]); - let mesh = build(&m, &texture_names(&m)); + // Material ohne baseColorTexture: Schlüssel gesetzt, aber kein Bild. + m.textures = vec![Texture { key: "nackt".into(), image: None }]; + assert_eq!(texture_keys(&m), vec![FALLBACK_TEXTURE.to_string()]); + let mesh = build(&m, &texture_keys(&m)); assert_eq!(mesh.batches.len(), 1); } diff --git a/src/render/scene.rs b/src/render/scene.rs index ede1953..8dd6bfd 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -1,13 +1,13 @@ //! Szenen-Pass: zeichnet die 3D-Welt ins interne Target. //! //! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via -//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group + -//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan). +//! render::props; Bilder als RGBA8-`Image`, aus der `.glb` dekodiert). Pro +//! Textur eine Bind-Group + ein Draw-Batch („pro Textur ein Draw" wie im Plan). //! Die Shader (scene.wgsl) sind die echten PS1-Shader. use wgpu::util::DeviceExt; -use crate::engine::tga::Image; +use crate::engine::image::Image; use crate::render::math::Mat4; #[repr(C)] diff --git a/src/render/sprite.rs b/src/render/sprite.rs index ee1bace..2a1186f 100644 --- a/src/render/sprite.rs +++ b/src/render/sprite.rs @@ -10,7 +10,7 @@ //! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über //! dieselbe `scene::upload_texture`, nur ohne Mipchain. -use crate::engine::tga::Image; +use crate::engine::image::Image; use crate::render::scene::upload_texture; #[repr(C)] diff --git a/src/render/ui.rs b/src/render/ui.rs index ecf76d9..03ee81c 100644 --- a/src/render/ui.rs +++ b/src/render/ui.rs @@ -10,7 +10,7 @@ //! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet, //! 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::sprite::{SpriteBatch, SpriteVertex}; use crate::session::{Dialog, Mode, Session}; diff --git a/src/session.rs b/src/session.rs index d049333..be5491d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -558,7 +558,7 @@ mod tests { "Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")), ], empties: Vec::new(), - materials: vec![String::new()], + textures: Vec::new(), } } @@ -628,7 +628,7 @@ mod tests { 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"); }