diff --git a/crates/render-core/README.md b/crates/render-core/README.md index 1b58aec..a58f64f 100644 --- a/crates/render-core/README.md +++ b/crates/render-core/README.md @@ -5,7 +5,7 @@ CPU-подготовка draw-данных для моделей `MSH`. Покрывает: - обход `node -> slot -> batch`; -- раскрытие индексов в triangle-list (`Vec<[f32;3]>`); +- раскрытие индексов в triangle-list (`position + uv0`); - расчёт bounds по вершинам. Тесты: diff --git a/crates/render-core/src/lib.rs b/crates/render-core/src/lib.rs index 8e0b5e8..ddb93fb 100644 --- a/crates/render-core/src/lib.rs +++ b/crates/render-core/src/lib.rs @@ -1,8 +1,14 @@ use msh_core::Model; +#[derive(Clone, Debug)] +pub struct RenderVertex { + pub position: [f32; 3], + pub uv0: [f32; 2], +} + #[derive(Clone, Debug)] pub struct RenderMesh { - pub vertices: Vec<[f32; 3]>, + pub vertices: Vec, pub batch_count: usize, } @@ -18,6 +24,7 @@ impl RenderMesh { pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh { let mut vertices = Vec::new(); let mut batch_count = 0usize; + let uv0 = model.uv0.as_ref(); for node_index in 0..model.node_count { let Some(slot_idx) = model.slot_index(node_index, lod, group) else { @@ -48,7 +55,15 @@ pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh let Some(pos) = model.positions.get(final_idx) else { continue; }; - vertices.push(*pos); + let uv = uv0 + .and_then(|uvs| uvs.get(final_idx)) + .copied() + .map(|packed| [packed[0] as f32 / 1024.0, packed[1] as f32 / 1024.0]) + .unwrap_or([0.0, 0.0]); + vertices.push(RenderVertex { + position: *pos, + uv0: uv, + }); } batch_count += 1; } @@ -80,5 +95,25 @@ pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> { Some((min_v, max_v)) } +pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> { + let mut iter = vertices.iter(); + let first = iter.next()?; + let mut min_v = first.position; + let mut max_v = first.position; + + for v in iter { + for i in 0..3 { + if v.position[i] < min_v[i] { + min_v[i] = v.position[i]; + } + if v.position[i] > max_v[i] { + max_v[i] = v.position[i]; + } + } + } + + Some((min_v, max_v)) +} + #[cfg(test)] mod tests; diff --git a/crates/render-core/src/tests.rs b/crates/render-core/src/tests.rs index 9c5eb5d..22103c6 100644 --- a/crates/render-core/src/tests.rs +++ b/crates/render-core/src/tests.rs @@ -74,9 +74,17 @@ fn build_render_mesh_for_real_models() { if !mesh.vertices.is_empty() { meshes_non_empty += 1; } - if compute_bounds(&mesh.vertices).is_some() { + if compute_bounds_for_mesh(&mesh.vertices).is_some() { bounds_non_empty += 1; } + for vertex in &mesh.vertices { + assert!( + vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(), + "UV must be finite for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + } } } @@ -99,3 +107,25 @@ fn compute_bounds_handles_empty_and_non_empty() { assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); assert_eq!(bounds.1, [1.0, 5.0, 9.0]); } + +#[test] +fn compute_bounds_for_mesh_handles_empty_and_non_empty() { + assert!(compute_bounds_for_mesh(&[]).is_none()); + let bounds = compute_bounds_for_mesh(&[ + RenderVertex { + position: [1.0, 2.0, 3.0], + uv0: [0.0, 0.0], + }, + RenderVertex { + position: [-2.0, 5.0, 0.5], + uv0: [0.2, 0.3], + }, + RenderVertex { + position: [0.0, -1.0, 9.0], + uv0: [1.0, 1.0], + }, + ]) + .expect("bounds expected"); + assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); + assert_eq!(bounds.1, [1.0, 5.0, 9.0]); +} diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml index aab041d..286b48c 100644 --- a/crates/render-demo/Cargo.toml +++ b/crates/render-demo/Cargo.toml @@ -11,6 +11,7 @@ demo = ["dep:sdl2", "dep:glow", "dep:image"] msh-core = { path = "../msh-core" } nres = { path = "../nres" } render-core = { path = "../render-core" } +texm = { path = "../texm" } sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } glow = { version = "0.16", optional = true } image = { version = "0.25", optional = true, default-features = false, features = ["png"] } diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md index 0a1fb45..32d85ef 100644 --- a/crates/render-demo/README.md +++ b/crates/render-demo/README.md @@ -5,6 +5,7 @@ ## Назначение - Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах. +- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах. - Служить минимальным reference-приложением. ## Запуск @@ -26,6 +27,20 @@ cargo run -p render-demo --features demo -- \ - `--width`, `--height` (опционально, default `1280x720`). - `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах). - `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме. +- `--texture `: явное имя `Texm` (override авто-резолва). +- `--texture-archive `: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`). +- `--material-archive `: путь к `material.lib` (по умолчанию соседний `material.lib`). +- `--wear `: имя wear-entry внутри модельного архива (по умолчанию `.wea`). +- `--no-texture`: отключить текстуры и рендерить однотонным цветом. + +## Авто-резолв текстуры + +Если не передан `--texture`, демо пытается взять текстуру из игровых данных: + +1. `model.msh -> model.wea` (первый wear-материал), +2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`, +3. первая непустая `textureName` фаза материала, +4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback). ## Детерминированный снимок кадра @@ -43,7 +58,16 @@ cargo run -p render-demo --features demo -- \ --capture "target/render-parity/current/animals_a_l_01.png" ``` +Явный выбор текстуры: + +```bash +cargo run -p render-demo --features demo -- \ + --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ + --model "A_L_01.msh" \ + --texture "PG09.0" +``` + ## Ограничения -- Рендер только геометрии (без материалов/текстур/FX). -- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list. +- Используется только базовая texture-фаза (без полной material/fx анимации). +- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list (позиции+UV). diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs index 4c73c09..c5c72b5 100644 --- a/crates/render-demo/src/lib.rs +++ b/crates/render-demo/src/lib.rs @@ -1,13 +1,25 @@ use msh_core::{parse_model_payload, Model}; -use nres::Archive; -use std::path::Path; +use nres::{Archive, EntryRef}; +use std::path::{Path, PathBuf}; +use texm::{decode_mip_rgba8, parse_texm}; + +const WEAR_KIND: u32 = 0x5241_4557; +const MAT0_KIND: u32 = 0x3054_414D; #[derive(Debug)] pub enum Error { Nres(nres::error::Error), Msh(msh_core::error::Error), + Texm(texm::error::Error), + Io(std::io::Error), NoMshEntries, ModelNotFound(String), + NoTexmEntries, + TextureNotFound(String), + MaterialNotFound(String), + WearNotFound(String), + InvalidWear(String), + InvalidMaterial(String), } impl From for Error { @@ -22,9 +34,38 @@ impl From for Error { } } +impl From for Error { + fn from(value: texm::error::Error) -> Self { + Self::Texm(value) + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + pub type Result = core::result::Result; -pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { +#[derive(Clone, Debug)] +pub struct LoadedModel { + pub name: String, + pub model: Model, +} + +#[derive(Clone, Debug)] +pub struct LoadedTexture { + pub name: String, + pub width: u32, + pub height: u32, + pub rgba8: Vec, +} + +pub fn load_model_with_name_from_archive( + path: &Path, + model_name: Option<&str>, +) -> Result { let archive = Archive::open_path(path)?; let mut msh_entries = Vec::new(); for entry in archive.entries() { @@ -46,8 +87,313 @@ pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result< msh_entries[0].0 }; + let target_name = archive + .get(target_id) + .map(|entry| entry.meta.name.clone()) + .unwrap_or_else(|| String::from("")); let payload = archive.read(target_id)?; - Ok(parse_model_payload(payload.as_slice())?) + Ok(LoadedModel { + name: target_name, + model: parse_model_payload(payload.as_slice())?, + }) +} + +pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { + Ok(load_model_with_name_from_archive(path, model_name)?.model) +} + +pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result { + let archive = Archive::open_path(path)?; + if let Some(name) = texture_name { + return load_texture_from_archive_by_name(&archive, name); + } + + let mut texm_entries = archive + .entries() + .filter(|entry| entry.meta.kind == texm::TEXM_MAGIC) + .collect::>(); + if texm_entries.is_empty() { + return Err(Error::NoTexmEntries); + } + texm_entries.sort_by(|a, b| { + a.meta + .name + .to_ascii_lowercase() + .cmp(&b.meta.name.to_ascii_lowercase()) + }); + let first = texm_entries[0]; + decode_texture_entry(&archive, first) +} + +pub fn resolve_texture_for_model( + model_archive_path: &Path, + model_entry_name: &str, + texture_name_override: Option<&str>, + textures_archive_override: Option<&Path>, + material_archive_override: Option<&Path>, + wear_entry_override: Option<&str>, +) -> Result> { + if let Some(name) = texture_name_override { + return load_texture_by_name_from_candidate_archives( + name, + candidate_texture_archives(model_archive_path, textures_archive_override), + ) + .map(Some); + } + + let wear_entry_name = if let Some(name) = wear_entry_override { + name.to_string() + } else { + derive_wear_entry_name(model_entry_name).ok_or_else(|| { + Error::WearNotFound(format!( + "cannot derive WEAR name from model '{model_entry_name}'" + )) + })? + }; + + let model_archive = Archive::open_path(model_archive_path)?; + let wear_materials = parse_wear_material_names( + read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)? + .0 + .as_slice(), + )?; + let Some(primary_material) = wear_materials.first() else { + return Ok(None); + }; + + let material_path = if let Some(path) = material_archive_override { + path.to_path_buf() + } else { + sibling_archive_path(model_archive_path, "material.lib") + .ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))? + }; + let material_archive = Archive::open_path(&material_path)?; + let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?; + let material_payload = material_archive.read(material_entry.id)?.into_owned(); + let texture_name = + parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?; + let Some(texture_name) = texture_name else { + return Ok(None); + }; + + let texture = load_texture_by_name_from_candidate_archives( + &texture_name, + candidate_texture_archives(model_archive_path, textures_archive_override), + )?; + Ok(Some(texture)) +} + +fn load_texture_by_name_from_candidate_archives( + texture_name: &str, + archives: Vec, +) -> Result { + let mut last_not_found = None; + for archive_path in archives { + if !archive_path.is_file() { + continue; + } + let archive = Archive::open_path(&archive_path)?; + match load_texture_from_archive_by_name(&archive, texture_name) { + Ok(texture) => return Ok(texture), + Err(Error::TextureNotFound(name)) => { + last_not_found = Some(name); + } + Err(other) => return Err(other), + } + } + + Err(Error::TextureNotFound( + last_not_found.unwrap_or_else(|| texture_name.to_string()), + )) +} + +fn candidate_texture_archives( + model_archive_path: &Path, + textures_archive_override: Option<&Path>, +) -> Vec { + if let Some(path) = textures_archive_override { + return vec![path.to_path_buf()]; + } + + let mut out = Vec::new(); + if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") { + out.push(path); + } + if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") { + out.push(path); + } + out +} + +fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option { + let parent = model_archive_path.parent()?; + Some(parent.join(name)) +} + +fn derive_wear_entry_name(model_entry_name: &str) -> Option { + let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?; + Some(format!("{stem}.wea")) +} + +fn read_entry_by_name_kind( + archive: &Archive, + name: &str, + expected_kind: u32, +) -> Result<(Vec, String)> { + let Some(id) = archive.find(name) else { + return Err(Error::WearNotFound(name.to_string())); + }; + let Some(entry) = archive.get(id) else { + return Err(Error::WearNotFound(name.to_string())); + }; + if entry.meta.kind != expected_kind { + return Err(Error::WearNotFound(name.to_string())); + } + let payload = archive.read(id)?.into_owned(); + Ok((payload, entry.meta.name.clone())) +} + +fn find_material_entry_with_fallback<'a>( + archive: &'a Archive, + requested_name: &str, +) -> Result> { + if let Some(id) = archive.find(requested_name) { + if let Some(entry) = archive.get(id) { + if entry.meta.kind == MAT0_KIND { + return Ok(entry); + } + } + } + + if let Some(id) = archive.find("DEFAULT") { + if let Some(entry) = archive.get(id) { + if entry.meta.kind == MAT0_KIND { + return Ok(entry); + } + } + } + + let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else { + return Err(Error::MaterialNotFound(requested_name.to_string())); + }; + Ok(entry) +} + +fn parse_wear_material_names(payload: &[u8]) -> Result> { + let text = String::from_utf8_lossy(payload).replace('\r', ""); + let mut lines = text.lines(); + let Some(first) = lines.next() else { + return Err(Error::InvalidWear(String::from("WEAR payload is empty"))); + }; + let count = first + .trim() + .parse::() + .map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?; + if count == 0 { + return Err(Error::InvalidWear(String::from("wearCount must be > 0"))); + } + + let mut materials = Vec::with_capacity(count); + for idx in 0..count { + let Some(line) = lines.next() else { + return Err(Error::InvalidWear(format!( + "missing material line {idx} of {count}" + ))); + }; + let mut parts = line.split_whitespace(); + let _legacy = parts + .next() + .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?; + let name = parts + .next() + .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?; + materials.push(name.to_string()); + } + + Ok(materials) +} + +fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result> { + if payload.len() < 4 { + return Err(Error::InvalidMaterial(String::from( + "MAT0 payload is too small for header", + ))); + } + let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize; + if phase_count == 0 { + return Ok(None); + } + + let mut offset = 4usize; + if attr2 >= 2 { + offset = offset + .checked_add(2) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; + } + if attr2 >= 3 { + offset = offset + .checked_add(4) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; + } + if attr2 >= 4 { + offset = offset + .checked_add(4) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; + } + + for phase in 0..phase_count { + let phase_off = offset + .checked_add(phase.checked_mul(34).ok_or_else(|| { + Error::InvalidMaterial(String::from("MAT0 phase offset overflow")) + })?) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?; + let phase_end = phase_off + .checked_add(34) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?; + let Some(rec) = payload.get(phase_off..phase_end) else { + return Err(Error::InvalidMaterial(format!( + "MAT0 phase {phase} is out of bounds" + ))); + }; + let name_raw = &rec[18..34]; + let name_end = name_raw + .iter() + .position(|&b| b == 0) + .unwrap_or(name_raw.len()); + let name = String::from_utf8_lossy(&name_raw[..name_end]) + .trim() + .to_string(); + if !name.is_empty() { + return Ok(Some(name)); + } + } + + Ok(None) +} + +fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result { + let Some(id) = archive.find(name) else { + return Err(Error::TextureNotFound(name.to_string())); + }; + let Some(entry) = archive.get(id) else { + return Err(Error::TextureNotFound(name.to_string())); + }; + if entry.meta.kind != texm::TEXM_MAGIC { + return Err(Error::TextureNotFound(name.to_string())); + } + decode_texture_entry(archive, entry) +} + +fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result { + let payload = archive.read(entry.id)?.into_owned(); + let parsed = parse_texm(&payload)?; + let decoded = decode_mip_rgba8(&parsed, &payload, 0)?; + Ok(LoadedTexture { + name: entry.meta.name.clone(), + width: decoded.width, + height: decoded.height, + rgba8: decoded.rgba8, + }) } #[cfg(test)] @@ -98,6 +444,19 @@ mod tests { None } + fn game_root() -> Option { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata") + .join("Parkan - Iron Strategy"); + if path.is_dir() { + Some(path) + } else { + None + } + } + #[test] fn load_model_from_real_archive() { let Some(path) = archive_with_msh() else { @@ -110,4 +469,59 @@ mod tests { assert!(!model.positions.is_empty()); assert!(!model.indices.is_empty()); } + + #[test] + fn resolve_texture_for_real_model_via_wear_and_material() { + let Some(root) = game_root() else { + eprintln!( + "skipping resolve_texture_for_real_model_via_wear_and_material: no game root" + ); + return; + }; + let archive = root.join("animals.rlb"); + if !archive.is_file() { + eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb"); + return; + } + + let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh")) + .unwrap_or_else(|err| { + panic!( + "failed to load model A_L_01.msh from {}: {err:?}", + archive.display() + ) + }); + let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None) + .unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name)) + .expect("texture must be resolved for A_L_01.msh"); + assert!(texture.width > 0 && texture.height > 0); + assert_eq!( + texture.rgba8.len(), + usize::try_from(texture.width) + .ok() + .and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4)) + .unwrap_or(0) + ); + } + + #[test] + fn load_first_texture_from_real_archive() { + let Some(root) = game_root() else { + eprintln!("skipping load_first_texture_from_real_archive: no game root"); + return; + }; + let archive = root.join("textures.lib"); + if !archive.is_file() { + eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib"); + return; + } + let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| { + panic!( + "failed to load first texture from {}: {err:?}", + archive.display() + ) + }); + assert!(texture.width > 0 && texture.height > 0); + assert!(!texture.rgba8.is_empty()); + } } diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs index 5bb0a58..bb826d5 100644 --- a/crates/render-demo/src/main.rs +++ b/crates/render-demo/src/main.rs @@ -1,6 +1,6 @@ use glow::HasContext as _; -use render_core::{build_render_mesh, compute_bounds}; -use render_demo::load_model_from_archive; +use render_core::{build_render_mesh, compute_bounds_for_mesh}; +use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture}; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -14,6 +14,15 @@ struct Args { capture: Option, angle: Option, spin_rate: f32, + texture: Option, + texture_archive: Option, + material_archive: Option, + wear: Option, + no_texture: bool, +} + +struct GpuTexture { + handle: glow::NativeTexture, } fn parse_args() -> Result { @@ -26,6 +35,11 @@ fn parse_args() -> Result { let mut capture = None; let mut angle = None; let mut spin_rate = 0.35f32; + let mut texture = None; + let mut texture_archive = None; + let mut material_archive = None; + let mut wear = None; + let mut no_texture = false; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { @@ -104,6 +118,33 @@ fn parse_args() -> Result { .parse::() .map_err(|_| String::from("invalid --spin-rate value"))?; } + "--texture" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --texture"))?; + texture = Some(value); + } + "--texture-archive" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --texture-archive"))?; + texture_archive = Some(PathBuf::from(value)); + } + "--material-archive" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --material-archive"))?; + material_archive = Some(PathBuf::from(value)); + } + "--wear" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --wear"))?; + wear = Some(value); + } + "--no-texture" => { + no_texture = true; + } "--help" | "-h" => { print_help(); std::process::exit(0); @@ -125,6 +166,11 @@ fn parse_args() -> Result { capture, angle, spin_rate, + texture, + texture_archive, + material_archive, + wear, + no_texture, }) } @@ -133,6 +179,7 @@ fn print_help() { "parkan-render-demo --archive [--model ] [--lod N] [--group N] [--width W] [--height H]" ); eprintln!(" [--capture ] [--angle RAD] [--spin-rate RAD_PER_SEC]"); + eprintln!(" [--texture ] [--texture-archive ] [--material-archive ] [--wear ] [--no-texture]"); } fn main() { @@ -152,24 +199,34 @@ fn main() { } fn run(args: Args) -> Result<(), String> { - let model = load_model_from_archive(&args.archive, args.model.as_deref()).map_err(|err| { - format!( - "failed to load model from archive {}: {err:?}", - args.archive.display() - ) - })?; - - let mesh = build_render_mesh(&model, args.lod, args.group); + let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref()) + .map_err(|err| { + format!( + "failed to load model from archive {}: {err:?}", + args.archive.display() + ) + })?; + let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group); if mesh.vertices.is_empty() { return Err(format!( "model has no renderable triangles for lod={} group={}", args.lod, args.group )); } - let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else { + let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else { return Err(String::from("failed to compute mesh bounds")); }; + let resolved_texture = resolve_texture(&args, &loaded_model.name)?; + if let Some(tex) = resolved_texture.as_ref() { + println!( + "resolved texture '{}' ({}x{})", + tex.name, tex.width, tex.height + ); + } else { + println!("texture path disabled or unresolved; rendering with fallback color"); + } + let center = [ 0.5 * (bounds_min[0] + bounds_max[0]), 0.5 * (bounds_min[1] + bounds_max[1]), @@ -224,9 +281,13 @@ fn run(args: Args) -> Result<(), String> { video.gl_set_swap_interval(1) }; - let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3); - for pos in &mesh.vertices { - vertices_flat.extend_from_slice(pos); + let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5); + for vertex in &mesh.vertices { + vertex_data.push(vertex.position[0]); + vertex_data.push(vertex.position[1]); + vertex_data.push(vertex.position[2]); + vertex_data.push(vertex.uv0[0]); + vertex_data.push(vertex.uv0[1]); } let gl = unsafe { @@ -235,27 +296,41 @@ fn run(args: Args) -> Result<(), String> { let program = unsafe { create_program(&gl)? }; let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; + let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") }; + let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") }; let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") } .ok_or_else(|| String::from("shader attribute a_pos is missing"))?; + let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") } + .ok_or_else(|| String::from("shader attribute a_uv is missing"))?; let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; unsafe { gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, - cast_slice_u8(&vertices_flat), + cast_slice_u8(&vertex_data), glow::STATIC_DRAW, ); gl.bind_buffer(glow::ARRAY_BUFFER, None); } + let gpu_texture = if let Some(texture) = resolved_texture.as_ref() { + Some(unsafe { create_texture(&gl, texture)? }) + } else { + None + }; + let result = if let Some(capture_path) = args.capture.as_ref() { run_capture( &gl, program, u_mvp.as_ref(), + u_use_tex.as_ref(), + u_tex.as_ref(), a_pos, + a_uv, vbo, + gpu_texture.as_ref(), mesh.vertices.len(), &args, center, @@ -269,8 +344,12 @@ fn run(args: Args) -> Result<(), String> { &gl, program, u_mvp.as_ref(), + u_use_tex.as_ref(), + u_tex.as_ref(), a_pos, + a_uv, vbo, + gpu_texture.as_ref(), mesh.vertices.len(), &args, center, @@ -279,6 +358,9 @@ fn run(args: Args) -> Result<(), String> { }; unsafe { + if let Some(texture) = gpu_texture { + gl.delete_texture(texture.handle); + } gl.delete_buffer(vbo); gl.delete_program(program); } @@ -286,13 +368,82 @@ fn run(args: Args) -> Result<(), String> { result } +fn resolve_texture(args: &Args, model_name: &str) -> Result, String> { + if args.no_texture { + return Ok(None); + } + + match resolve_texture_for_model( + &args.archive, + model_name, + args.texture.as_deref(), + args.texture_archive.as_deref(), + args.material_archive.as_deref(), + args.wear.as_deref(), + ) { + Ok(texture) => Ok(texture), + Err(err) => { + if args.texture.is_some() + || args.texture_archive.is_some() + || args.material_archive.is_some() + || args.wear.is_some() + { + Err(format!("failed to resolve texture: {err:?}")) + } else { + eprintln!( + "warning: auto texture resolve failed ({err:?}), fallback to solid color" + ); + Ok(None) + } + } + } +} + +unsafe fn create_texture( + gl: &glow::Context, + texture: &LoadedTexture, +) -> Result { + let handle = gl.create_texture().map_err(|e| e.to_string())?; + gl.bind_texture(glow::TEXTURE_2D, Some(handle)); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::LINEAR as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MAG_FILTER, + glow::LINEAR as i32, + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32); + gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + texture.width.min(i32::MAX as u32) as i32, + texture.height.min(i32::MAX as u32) as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())), + ); + gl.bind_texture(glow::TEXTURE_2D, None); + Ok(GpuTexture { handle }) +} + #[allow(clippy::too_many_arguments)] fn run_capture( gl: &glow::Context, program: glow::NativeProgram, u_mvp: Option<&glow::NativeUniformLocation>, + u_use_tex: Option<&glow::NativeUniformLocation>, + u_tex: Option<&glow::NativeUniformLocation>, a_pos: u32, + a_uv: u32, vbo: glow::NativeBuffer, + texture: Option<&GpuTexture>, vertex_count: usize, args: &Args, center: [f32; 3], @@ -306,8 +457,12 @@ fn run_capture( gl, program, u_mvp, + u_use_tex, + u_tex, a_pos, + a_uv, vbo, + texture, vertex_count, args.width, args.height, @@ -328,8 +483,12 @@ fn run_interactive( gl: &glow::Context, program: glow::NativeProgram, u_mvp: Option<&glow::NativeUniformLocation>, + u_use_tex: Option<&glow::NativeUniformLocation>, + u_tex: Option<&glow::NativeUniformLocation>, a_pos: u32, + a_uv: u32, vbo: glow::NativeBuffer, + texture: Option<&GpuTexture>, vertex_count: usize, args: &Args, center: [f32; 3], @@ -359,7 +518,21 @@ fn run_interactive( let mvp = compute_mvp(w, h, center, camera_distance, angle); unsafe { - draw_frame(gl, program, u_mvp, a_pos, vbo, vertex_count, w, h, &mvp); + draw_frame( + gl, + program, + u_mvp, + u_use_tex, + u_tex, + a_pos, + a_uv, + vbo, + texture, + vertex_count, + w, + h, + &mvp, + ); } window.gl_swap_window(); } @@ -389,8 +562,12 @@ unsafe fn draw_frame( gl: &glow::Context, program: glow::NativeProgram, u_mvp: Option<&glow::NativeUniformLocation>, + u_use_tex: Option<&glow::NativeUniformLocation>, + u_tex: Option<&glow::NativeUniformLocation>, a_pos: u32, + a_uv: u32, vbo: glow::NativeBuffer, + texture: Option<&GpuTexture>, vertex_count: usize, width: u32, height: u32, @@ -409,16 +586,30 @@ unsafe fn draw_frame( gl.use_program(Some(program)); gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp); + let texture_enabled = texture.is_some(); + gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 }); + if let Some(tex) = texture { + gl.active_texture(glow::TEXTURE0); + gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle)); + gl.uniform_1_i32(u_tex, 0); + } else { + gl.bind_texture(glow::TEXTURE_2D, None); + } + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); gl.enable_vertex_attrib_array(a_pos); - gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 12, 0); + gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); + gl.enable_vertex_attrib_array(a_uv); + gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); gl.draw_arrays( glow::TRIANGLES, 0, vertex_count.min(i32::MAX as usize) as i32, ); + gl.disable_vertex_attrib_array(a_uv); gl.disable_vertex_attrib_array(a_pos); gl.bind_buffer(glow::ARRAY_BUFFER, None); + gl.bind_texture(glow::TEXTURE_2D, None); gl.use_program(None); } @@ -475,16 +666,24 @@ fn save_png(path: &Path, width: u32, height: u32, rgba: Vec) -> Result<(), S unsafe fn create_program(gl: &glow::Context) -> Result { let vs_src = r#" attribute vec3 a_pos; +attribute vec2 a_uv; uniform mat4 u_mvp; +varying vec2 v_uv; void main() { + v_uv = a_uv; gl_Position = u_mvp * vec4(a_pos, 1.0); } "#; let fs_src = r#" precision mediump float; +uniform sampler2D u_tex; +uniform float u_use_tex; +varying vec2 v_uv; void main() { - gl_FragColor = vec4(0.85, 0.90, 1.00, 1.0); + vec4 base = vec4(0.85, 0.90, 1.00, 1.0); + vec4 texColor = texture2D(u_tex, v_uv); + gl_FragColor = mix(base, texColor, u_use_tex); } "#; diff --git a/crates/texm/src/error.rs b/crates/texm/src/error.rs index a5dda77..38e32ca 100644 --- a/crates/texm/src/error.rs +++ b/crates/texm/src/error.rs @@ -23,6 +23,15 @@ pub enum Error { expected_end: usize, actual_size: usize, }, + MipIndexOutOfRange { + requested: usize, + mip_count: usize, + }, + MipDataOutOfBounds { + offset: usize, + size: usize, + payload_size: usize, + }, InvalidPageMagic, InvalidPageSize { expected: usize, @@ -50,6 +59,21 @@ impl fmt::Display for Error { f, "Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}" ), + Self::MipIndexOutOfRange { + requested, + mip_count, + } => write!( + f, + "Texm mip index out of range: requested={requested}, mip_count={mip_count}" + ), + Self::MipDataOutOfBounds { + offset, + size, + payload_size, + } => write!( + f, + "Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}" + ), Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"), Self::InvalidPageSize { expected, actual } => { write!(f, "invalid Page chunk size: expected={expected}, actual={actual}") diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs index c3616d5..5d8b594 100644 --- a/crates/texm/src/lib.rs +++ b/crates/texm/src/lib.rs @@ -90,6 +90,13 @@ impl Texture { } } +#[derive(Clone, Debug)] +pub struct DecodedMip { + pub width: u32, + pub height: u32, + pub rgba8: Vec, +} + pub fn parse_texm(payload: &[u8]) -> Result { if payload.len() < 32 { return Err(Error::HeaderTooSmall { @@ -195,6 +202,81 @@ pub fn parse_texm(payload: &[u8]) -> Result { }) } +pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result { + let Some(level) = texture.mip_levels.get(mip_index).copied() else { + return Err(Error::MipIndexOutOfRange { + requested: mip_index, + mip_count: texture.mip_levels.len(), + }); + }; + + let end = level + .offset + .checked_add(level.size) + .ok_or(Error::IntegerOverflow)?; + let Some(level_data) = payload.get(level.offset..end) else { + return Err(Error::MipDataOutOfBounds { + offset: level.offset, + size: level.size, + payload_size: payload.len(), + }); + }; + + let pixel_count = usize::try_from(level.width) + .ok() + .and_then(|w| { + usize::try_from(level.height) + .ok() + .map(|h| w.saturating_mul(h)) + }) + .ok_or(Error::IntegerOverflow)?; + let mut rgba = vec![0u8; pixel_count.saturating_mul(4)]; + + match texture.header.format { + PixelFormat::Indexed8 => { + let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?; + for (i, &index) in level_data.iter().enumerate() { + if i >= pixel_count { + break; + } + let poff = usize::from(index).saturating_mul(4); + if poff + 3 >= palette.len() { + continue; + } + let out = i.saturating_mul(4); + rgba[out] = palette[poff]; + rgba[out + 1] = palette[poff + 1]; + rgba[out + 2] = palette[poff + 2]; + rgba[out + 3] = palette[poff + 3]; + } + } + PixelFormat::Rgb565 => { + decode_words(level_data, pixel_count, &mut rgba, decode_rgb565); + } + PixelFormat::Rgb556 => { + decode_words(level_data, pixel_count, &mut rgba, decode_rgb556); + } + PixelFormat::Argb4444 => { + decode_words(level_data, pixel_count, &mut rgba, decode_argb4444); + } + PixelFormat::LuminanceAlpha88 => { + decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88); + } + PixelFormat::Rgb888 => { + decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x); + } + PixelFormat::Argb8888 => { + decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888); + } + } + + Ok(DecodedMip { + width: level.width, + height: level.height, + rgba8: rgba, + }) +} + fn parse_page_tail(payload: &[u8], core_end: usize) -> Result> { if core_end == payload.len() { return Ok(Vec::new()); @@ -254,5 +336,86 @@ fn read_i16(data: &[u8], offset: usize) -> Result { Ok(i16::from_le_bytes(arr)) } +fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) { + for i in 0..pixel_count { + let off = i.saturating_mul(2); + let Some(bytes) = data.get(off..off + 2) else { + break; + }; + let word = u16::from_le_bytes([bytes[0], bytes[1]]); + let px = decode(word); + let out = i.saturating_mul(4); + rgba[out..out + 4].copy_from_slice(&px); + } +} + +fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) { + for i in 0..pixel_count { + let off = i.saturating_mul(4); + let Some(bytes) = data.get(off..off + 4) else { + break; + }; + let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let px = decode(dword); + let out = i.saturating_mul(4); + rgba[out..out + 4].copy_from_slice(&px); + } +} + +fn expand5(v: u16) -> u8 { + ((u32::from(v) * 255 + 15) / 31) as u8 +} + +fn expand6(v: u16) -> u8 { + ((u32::from(v) * 255 + 31) / 63) as u8 +} + +fn expand4(v: u16) -> u8 { + (u32::from(v) * 17) as u8 +} + +fn decode_rgb565(word: u16) -> [u8; 4] { + let r = expand5((word >> 11) & 0x1F); + let g = expand6((word >> 5) & 0x3F); + let b = expand5(word & 0x1F); + [r, g, b, 255] +} + +fn decode_rgb556(word: u16) -> [u8; 4] { + let r = expand5((word >> 11) & 0x1F); + let g = expand5((word >> 6) & 0x1F); + let b = expand6(word & 0x3F); + [r, g, b, 255] +} + +fn decode_argb4444(word: u16) -> [u8; 4] { + let a = expand4((word >> 12) & 0x0F); + let r = expand4((word >> 8) & 0x0F); + let g = expand4((word >> 4) & 0x0F); + let b = expand4(word & 0x0F); + [r, g, b, a] +} + +fn decode_luminance_alpha88(word: u16) -> [u8; 4] { + let l = ((word >> 8) & 0xFF) as u8; + let a = (word & 0xFF) as u8; + [l, l, l, a] +} + +fn decode_rgb888x(dword: u32) -> [u8; 4] { + let r = (dword & 0xFF) as u8; + let g = ((dword >> 8) & 0xFF) as u8; + let b = ((dword >> 16) & 0xFF) as u8; + [r, g, b, 255] +} + +fn decode_argb8888(dword: u32) -> [u8; 4] { + let a = (dword & 0xFF) as u8; + let r = ((dword >> 8) & 0xFF) as u8; + let g = ((dword >> 16) & 0xFF) as u8; + let b = ((dword >> 24) & 0xFF) as u8; + [r, g, b, a] +} + #[cfg(test)] mod tests; diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs index d021346..3d990bf 100644 --- a/crates/texm/src/tests.rs +++ b/crates/texm/src/tests.rs @@ -115,6 +115,26 @@ fn texm_parse_minimal_argb8888_no_page() { assert!(parsed.page_rects.is_empty()); } +#[test] +fn texm_decode_minimal_argb8888_no_page() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // width + payload.extend_from_slice(&1u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&8888u32.to_le_bytes()); // format + payload.extend_from_slice(&[0x40, 0x11, 0x22, 0x33]); // A,R,G,B in little-endian order + + let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); + let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip"); + assert_eq!(decoded.width, 1); + assert_eq!(decoded.height, 1); + assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]); +} + #[test] fn texm_parse_indexed_with_page_chunk() { let mut payload = Vec::new(); @@ -148,3 +168,28 @@ fn texm_parse_indexed_with_page_chunk() { } ); } + +#[test] +fn texm_decode_indexed_with_palette() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&2u32.to_le_bytes()); // width + payload.extend_from_slice(&1u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8 + + let mut palette = [0u8; 1024]; + palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1 + palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2 + payload.extend_from_slice(&palette); + payload.extend_from_slice(&[1u8, 2u8]); // two pixels + + let parsed = parse_texm(&payload).expect("failed to parse indexed texm"); + let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm"); + assert_eq!(decoded.width, 2); + assert_eq!(decoded.height, 1); + assert_eq!(decoded.rgba8, vec![10, 20, 30, 255, 40, 50, 60, 200]); +} diff --git a/docs/specs/render.md b/docs/specs/render.md index 2994049..ea63197 100644 --- a/docs/specs/render.md +++ b/docs/specs/render.md @@ -87,6 +87,14 @@ Material pipeline на кадре: 4. Резолвятся ссылки на texture/lightmap. 5. Невалидные ссылки обрабатываются fallback-стратегией. +Практическая цепочка привязки для большинства `*.msh` ассетов из `*.rlb`: + +1. Для модели выбирается одноимённый `WEAR` (`.wea`). +2. Из `WEAR` берётся material-слот (по имени, `legacyId` не участвует в выборе). +3. В `Material.lib` ищется `MAT0` по имени (`DEFAULT`, затем индекс `0` как fallback). +4. Из выбранной material-фазы берётся `textureName`. +5. `Texm` ищется в `Textures.lib` (и/или lightmap-архиве для lightmap-ветки). + ## 6. Texture path При резолве текстуры: diff --git a/docs/specs/texture.md b/docs/specs/texture.md index 5fa1e9d..c25ec56 100644 --- a/docs/specs/texture.md +++ b/docs/specs/texture.md @@ -59,6 +59,20 @@ pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1); sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount; ``` +## 4.1. Декодирование в RGBA8 (runtime/инструменты) + +Для CPU-пути (preview, валидация, оффлайн-конвертация) используется декодирование: + +- `0` (`Indexed8`): `index -> palette[index]` (`RGBA` из палитры 256×4). +- `565`: `R5 G6 B5`, `A=255`. +- `556`: `R5 G5 B6`, `A=255`. +- `4444`: `A4 R4 G4 B4` (с расширением 4-битных каналов в 8-битные). +- `88`: `L8 A8` (`R=G=B=L`). +- `888`: `R8 G8 B8` + padding/служебный байт, `A=255`. +- `8888`: `A8 R8 G8 B8`. + +Это декодирование соответствует текущему test/demo pipeline проекта. + ## 5. `Page` chunk ```c