feat: Enhance model and texture loading with improved error handling and new features
- Introduced `LoadedModel` and `LoadedTexture` structs for better encapsulation of model and texture data. - Added functions to load models and textures from archives, including support for resolving textures based on materials and wear entries. - Implemented error handling for missing textures, materials, and wear entries. - Updated the rendering pipeline to support texture loading and binding, including command-line arguments for texture customization. - Enhanced the `texm` crate with new decoding capabilities for various pixel formats, including indexed textures. - Added tests for texture decoding and loading to ensure reliability and correctness. - Updated documentation to reflect changes in the material and texture resolution process.
This commit is contained in:
@@ -5,7 +5,7 @@ CPU-подготовка draw-данных для моделей `MSH`.
|
|||||||
Покрывает:
|
Покрывает:
|
||||||
|
|
||||||
- обход `node -> slot -> batch`;
|
- обход `node -> slot -> batch`;
|
||||||
- раскрытие индексов в triangle-list (`Vec<[f32;3]>`);
|
- раскрытие индексов в triangle-list (`position + uv0`);
|
||||||
- расчёт bounds по вершинам.
|
- расчёт bounds по вершинам.
|
||||||
|
|
||||||
Тесты:
|
Тесты:
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
use msh_core::Model;
|
use msh_core::Model;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RenderVertex {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub uv0: [f32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RenderMesh {
|
pub struct RenderMesh {
|
||||||
pub vertices: Vec<[f32; 3]>,
|
pub vertices: Vec<RenderVertex>,
|
||||||
pub batch_count: usize,
|
pub batch_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +24,7 @@ impl RenderMesh {
|
|||||||
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
|
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
let mut batch_count = 0usize;
|
let mut batch_count = 0usize;
|
||||||
|
let uv0 = model.uv0.as_ref();
|
||||||
|
|
||||||
for node_index in 0..model.node_count {
|
for node_index in 0..model.node_count {
|
||||||
let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
|
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 {
|
let Some(pos) = model.positions.get(final_idx) else {
|
||||||
continue;
|
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;
|
batch_count += 1;
|
||||||
}
|
}
|
||||||
@@ -80,5 +95,25 @@ pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
|
|||||||
Some((min_v, max_v))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -74,9 +74,17 @@ fn build_render_mesh_for_real_models() {
|
|||||||
if !mesh.vertices.is_empty() {
|
if !mesh.vertices.is_empty() {
|
||||||
meshes_non_empty += 1;
|
meshes_non_empty += 1;
|
||||||
}
|
}
|
||||||
if compute_bounds(&mesh.vertices).is_some() {
|
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
|
||||||
bounds_non_empty += 1;
|
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.0, [-2.0, -1.0, 0.5]);
|
||||||
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ demo = ["dep:sdl2", "dep:glow", "dep:image"]
|
|||||||
msh-core = { path = "../msh-core" }
|
msh-core = { path = "../msh-core" }
|
||||||
nres = { path = "../nres" }
|
nres = { path = "../nres" }
|
||||||
render-core = { path = "../render-core" }
|
render-core = { path = "../render-core" }
|
||||||
|
texm = { path = "../texm" }
|
||||||
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
glow = { version = "0.16", optional = true }
|
glow = { version = "0.16", optional = true }
|
||||||
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
|
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
|
||||||
|
- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
|
||||||
- Служить минимальным reference-приложением.
|
- Служить минимальным reference-приложением.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
@@ -26,6 +27,20 @@ cargo run -p render-demo --features demo -- \
|
|||||||
- `--width`, `--height` (опционально, default `1280x720`).
|
- `--width`, `--height` (опционально, default `1280x720`).
|
||||||
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
|
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
|
||||||
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
|
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
|
||||||
|
- `--texture <name>`: явное имя `Texm` (override авто-резолва).
|
||||||
|
- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
|
||||||
|
- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
|
||||||
|
- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.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"
|
--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).
|
- Используется только базовая texture-фаза (без полной material/fx анимации).
|
||||||
- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list.
|
- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list (позиции+UV).
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
use msh_core::{parse_model_payload, Model};
|
use msh_core::{parse_model_payload, Model};
|
||||||
use nres::Archive;
|
use nres::{Archive, EntryRef};
|
||||||
use std::path::Path;
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Nres(nres::error::Error),
|
Nres(nres::error::Error),
|
||||||
Msh(msh_core::error::Error),
|
Msh(msh_core::error::Error),
|
||||||
|
Texm(texm::error::Error),
|
||||||
|
Io(std::io::Error),
|
||||||
NoMshEntries,
|
NoMshEntries,
|
||||||
ModelNotFound(String),
|
ModelNotFound(String),
|
||||||
|
NoTexmEntries,
|
||||||
|
TextureNotFound(String),
|
||||||
|
MaterialNotFound(String),
|
||||||
|
WearNotFound(String),
|
||||||
|
InvalidWear(String),
|
||||||
|
InvalidMaterial(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<nres::error::Error> for Error {
|
impl From<nres::error::Error> for Error {
|
||||||
@@ -22,9 +34,38 @@ impl From<msh_core::error::Error> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<texm::error::Error> for Error {
|
||||||
|
fn from(value: texm::error::Error) -> Self {
|
||||||
|
Self::Texm(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
|
#[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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_model_with_name_from_archive(
|
||||||
|
path: &Path,
|
||||||
|
model_name: Option<&str>,
|
||||||
|
) -> Result<LoadedModel> {
|
||||||
let archive = Archive::open_path(path)?;
|
let archive = Archive::open_path(path)?;
|
||||||
let mut msh_entries = Vec::new();
|
let mut msh_entries = Vec::new();
|
||||||
for entry in archive.entries() {
|
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
|
msh_entries[0].0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let target_name = archive
|
||||||
|
.get(target_id)
|
||||||
|
.map(|entry| entry.meta.name.clone())
|
||||||
|
.unwrap_or_else(|| String::from("<unknown>"));
|
||||||
let payload = archive.read(target_id)?;
|
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<Model> {
|
||||||
|
Ok(load_model_with_name_from_archive(path, model_name)?.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<Option<LoadedTexture>> {
|
||||||
|
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<PathBuf>,
|
||||||
|
) -> Result<LoadedTexture> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
let parent = model_archive_path.parent()?;
|
||||||
|
Some(parent.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
|
||||||
|
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<u8>, 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<EntryRef<'a>> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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::<usize>()
|
||||||
|
.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<Option<String>> {
|
||||||
|
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<LoadedTexture> {
|
||||||
|
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<LoadedTexture> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
@@ -98,6 +444,19 @@ mod tests {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
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]
|
#[test]
|
||||||
fn load_model_from_real_archive() {
|
fn load_model_from_real_archive() {
|
||||||
let Some(path) = archive_with_msh() else {
|
let Some(path) = archive_with_msh() else {
|
||||||
@@ -110,4 +469,59 @@ mod tests {
|
|||||||
assert!(!model.positions.is_empty());
|
assert!(!model.positions.is_empty());
|
||||||
assert!(!model.indices.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use glow::HasContext as _;
|
use glow::HasContext as _;
|
||||||
use render_core::{build_render_mesh, compute_bounds};
|
use render_core::{build_render_mesh, compute_bounds_for_mesh};
|
||||||
use render_demo::load_model_from_archive;
|
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -14,6 +14,15 @@ struct Args {
|
|||||||
capture: Option<PathBuf>,
|
capture: Option<PathBuf>,
|
||||||
angle: Option<f32>,
|
angle: Option<f32>,
|
||||||
spin_rate: f32,
|
spin_rate: f32,
|
||||||
|
texture: Option<String>,
|
||||||
|
texture_archive: Option<PathBuf>,
|
||||||
|
material_archive: Option<PathBuf>,
|
||||||
|
wear: Option<String>,
|
||||||
|
no_texture: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GpuTexture {
|
||||||
|
handle: glow::NativeTexture,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<Args, String> {
|
fn parse_args() -> Result<Args, String> {
|
||||||
@@ -26,6 +35,11 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
let mut capture = None;
|
let mut capture = None;
|
||||||
let mut angle = None;
|
let mut angle = None;
|
||||||
let mut spin_rate = 0.35f32;
|
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);
|
let mut it = std::env::args().skip(1);
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
@@ -104,6 +118,33 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
.parse::<f32>()
|
.parse::<f32>()
|
||||||
.map_err(|_| String::from("invalid --spin-rate value"))?;
|
.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" => {
|
"--help" | "-h" => {
|
||||||
print_help();
|
print_help();
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
@@ -125,6 +166,11 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
capture,
|
capture,
|
||||||
angle,
|
angle,
|
||||||
spin_rate,
|
spin_rate,
|
||||||
|
texture,
|
||||||
|
texture_archive,
|
||||||
|
material_archive,
|
||||||
|
wear,
|
||||||
|
no_texture,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +179,7 @@ fn print_help() {
|
|||||||
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]"
|
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]"
|
||||||
);
|
);
|
||||||
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
|
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
|
||||||
|
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -152,24 +199,34 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(args: Args) -> Result<(), String> {
|
fn run(args: Args) -> Result<(), String> {
|
||||||
let model = load_model_from_archive(&args.archive, args.model.as_deref()).map_err(|err| {
|
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
|
||||||
|
.map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"failed to load model from archive {}: {err:?}",
|
"failed to load model from archive {}: {err:?}",
|
||||||
args.archive.display()
|
args.archive.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
|
||||||
let mesh = build_render_mesh(&model, args.lod, args.group);
|
|
||||||
if mesh.vertices.is_empty() {
|
if mesh.vertices.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"model has no renderable triangles for lod={} group={}",
|
"model has no renderable triangles for lod={} group={}",
|
||||||
args.lod, args.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"));
|
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 = [
|
let center = [
|
||||||
0.5 * (bounds_min[0] + bounds_max[0]),
|
0.5 * (bounds_min[0] + bounds_max[0]),
|
||||||
0.5 * (bounds_min[1] + bounds_max[1]),
|
0.5 * (bounds_min[1] + bounds_max[1]),
|
||||||
@@ -224,9 +281,13 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
video.gl_set_swap_interval(1)
|
video.gl_set_swap_interval(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3);
|
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
|
||||||
for pos in &mesh.vertices {
|
for vertex in &mesh.vertices {
|
||||||
vertices_flat.extend_from_slice(pos);
|
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 {
|
let gl = unsafe {
|
||||||
@@ -235,27 +296,41 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
|
|
||||||
let program = unsafe { create_program(&gl)? };
|
let program = unsafe { create_program(&gl)? };
|
||||||
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
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") }
|
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||||
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
|
.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())? };
|
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||||
unsafe {
|
unsafe {
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
gl.buffer_data_u8_slice(
|
gl.buffer_data_u8_slice(
|
||||||
glow::ARRAY_BUFFER,
|
glow::ARRAY_BUFFER,
|
||||||
cast_slice_u8(&vertices_flat),
|
cast_slice_u8(&vertex_data),
|
||||||
glow::STATIC_DRAW,
|
glow::STATIC_DRAW,
|
||||||
);
|
);
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
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() {
|
let result = if let Some(capture_path) = args.capture.as_ref() {
|
||||||
run_capture(
|
run_capture(
|
||||||
&gl,
|
&gl,
|
||||||
program,
|
program,
|
||||||
u_mvp.as_ref(),
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
a_pos,
|
a_pos,
|
||||||
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
gpu_texture.as_ref(),
|
||||||
mesh.vertices.len(),
|
mesh.vertices.len(),
|
||||||
&args,
|
&args,
|
||||||
center,
|
center,
|
||||||
@@ -269,8 +344,12 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
&gl,
|
&gl,
|
||||||
program,
|
program,
|
||||||
u_mvp.as_ref(),
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
a_pos,
|
a_pos,
|
||||||
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
gpu_texture.as_ref(),
|
||||||
mesh.vertices.len(),
|
mesh.vertices.len(),
|
||||||
&args,
|
&args,
|
||||||
center,
|
center,
|
||||||
@@ -279,6 +358,9 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
|
if let Some(texture) = gpu_texture {
|
||||||
|
gl.delete_texture(texture.handle);
|
||||||
|
}
|
||||||
gl.delete_buffer(vbo);
|
gl.delete_buffer(vbo);
|
||||||
gl.delete_program(program);
|
gl.delete_program(program);
|
||||||
}
|
}
|
||||||
@@ -286,13 +368,82 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, 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<GpuTexture, String> {
|
||||||
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_capture(
|
fn run_capture(
|
||||||
gl: &glow::Context,
|
gl: &glow::Context,
|
||||||
program: glow::NativeProgram,
|
program: glow::NativeProgram,
|
||||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
a_pos: u32,
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
vbo: glow::NativeBuffer,
|
vbo: glow::NativeBuffer,
|
||||||
|
texture: Option<&GpuTexture>,
|
||||||
vertex_count: usize,
|
vertex_count: usize,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
center: [f32; 3],
|
center: [f32; 3],
|
||||||
@@ -306,8 +457,12 @@ fn run_capture(
|
|||||||
gl,
|
gl,
|
||||||
program,
|
program,
|
||||||
u_mvp,
|
u_mvp,
|
||||||
|
u_use_tex,
|
||||||
|
u_tex,
|
||||||
a_pos,
|
a_pos,
|
||||||
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
texture,
|
||||||
vertex_count,
|
vertex_count,
|
||||||
args.width,
|
args.width,
|
||||||
args.height,
|
args.height,
|
||||||
@@ -328,8 +483,12 @@ fn run_interactive(
|
|||||||
gl: &glow::Context,
|
gl: &glow::Context,
|
||||||
program: glow::NativeProgram,
|
program: glow::NativeProgram,
|
||||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
a_pos: u32,
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
vbo: glow::NativeBuffer,
|
vbo: glow::NativeBuffer,
|
||||||
|
texture: Option<&GpuTexture>,
|
||||||
vertex_count: usize,
|
vertex_count: usize,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
center: [f32; 3],
|
center: [f32; 3],
|
||||||
@@ -359,7 +518,21 @@ fn run_interactive(
|
|||||||
let mvp = compute_mvp(w, h, center, camera_distance, angle);
|
let mvp = compute_mvp(w, h, center, camera_distance, angle);
|
||||||
|
|
||||||
unsafe {
|
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();
|
window.gl_swap_window();
|
||||||
}
|
}
|
||||||
@@ -389,8 +562,12 @@ unsafe fn draw_frame(
|
|||||||
gl: &glow::Context,
|
gl: &glow::Context,
|
||||||
program: glow::NativeProgram,
|
program: glow::NativeProgram,
|
||||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
a_pos: u32,
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
vbo: glow::NativeBuffer,
|
vbo: glow::NativeBuffer,
|
||||||
|
texture: Option<&GpuTexture>,
|
||||||
vertex_count: usize,
|
vertex_count: usize,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
@@ -409,16 +586,30 @@ unsafe fn draw_frame(
|
|||||||
gl.use_program(Some(program));
|
gl.use_program(Some(program));
|
||||||
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
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.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
gl.enable_vertex_attrib_array(a_pos);
|
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(
|
gl.draw_arrays(
|
||||||
glow::TRIANGLES,
|
glow::TRIANGLES,
|
||||||
0,
|
0,
|
||||||
vertex_count.min(i32::MAX as usize) as i32,
|
vertex_count.min(i32::MAX as usize) as i32,
|
||||||
);
|
);
|
||||||
|
gl.disable_vertex_attrib_array(a_uv);
|
||||||
gl.disable_vertex_attrib_array(a_pos);
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
gl.use_program(None);
|
gl.use_program(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,16 +666,24 @@ fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), S
|
|||||||
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> {
|
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> {
|
||||||
let vs_src = r#"
|
let vs_src = r#"
|
||||||
attribute vec3 a_pos;
|
attribute vec3 a_pos;
|
||||||
|
attribute vec2 a_uv;
|
||||||
uniform mat4 u_mvp;
|
uniform mat4 u_mvp;
|
||||||
|
varying vec2 v_uv;
|
||||||
void main() {
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let fs_src = r#"
|
let fs_src = r#"
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
varying vec2 v_uv;
|
||||||
void main() {
|
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);
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ pub enum Error {
|
|||||||
expected_end: usize,
|
expected_end: usize,
|
||||||
actual_size: usize,
|
actual_size: usize,
|
||||||
},
|
},
|
||||||
|
MipIndexOutOfRange {
|
||||||
|
requested: usize,
|
||||||
|
mip_count: usize,
|
||||||
|
},
|
||||||
|
MipDataOutOfBounds {
|
||||||
|
offset: usize,
|
||||||
|
size: usize,
|
||||||
|
payload_size: usize,
|
||||||
|
},
|
||||||
InvalidPageMagic,
|
InvalidPageMagic,
|
||||||
InvalidPageSize {
|
InvalidPageSize {
|
||||||
expected: usize,
|
expected: usize,
|
||||||
@@ -50,6 +59,21 @@ impl fmt::Display for Error {
|
|||||||
f,
|
f,
|
||||||
"Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}"
|
"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::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"),
|
||||||
Self::InvalidPageSize { expected, actual } => {
|
Self::InvalidPageSize { expected, actual } => {
|
||||||
write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
|
write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ impl Texture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DecodedMip {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba8: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
|
pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
|
||||||
if payload.len() < 32 {
|
if payload.len() < 32 {
|
||||||
return Err(Error::HeaderTooSmall {
|
return Err(Error::HeaderTooSmall {
|
||||||
@@ -195,6 +202,81 @@ pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result<DecodedMip> {
|
||||||
|
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<Vec<PageRect>> {
|
fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> {
|
||||||
if core_end == payload.len() {
|
if core_end == payload.len() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -254,5 +336,86 @@ fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
|
|||||||
Ok(i16::from_le_bytes(arr))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -115,6 +115,26 @@ fn texm_parse_minimal_argb8888_no_page() {
|
|||||||
assert!(parsed.page_rects.is_empty());
|
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]
|
#[test]
|
||||||
fn texm_parse_indexed_with_page_chunk() {
|
fn texm_parse_indexed_with_page_chunk() {
|
||||||
let mut payload = Vec::new();
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ Material pipeline на кадре:
|
|||||||
4. Резолвятся ссылки на texture/lightmap.
|
4. Резолвятся ссылки на texture/lightmap.
|
||||||
5. Невалидные ссылки обрабатываются fallback-стратегией.
|
5. Невалидные ссылки обрабатываются fallback-стратегией.
|
||||||
|
|
||||||
|
Практическая цепочка привязки для большинства `*.msh` ассетов из `*.rlb`:
|
||||||
|
|
||||||
|
1. Для модели выбирается одноимённый `WEAR` (`<model_stem>.wea`).
|
||||||
|
2. Из `WEAR` берётся material-слот (по имени, `legacyId` не участвует в выборе).
|
||||||
|
3. В `Material.lib` ищется `MAT0` по имени (`DEFAULT`, затем индекс `0` как fallback).
|
||||||
|
4. Из выбранной material-фазы берётся `textureName`.
|
||||||
|
5. `Texm` ищется в `Textures.lib` (и/или lightmap-архиве для lightmap-ветки).
|
||||||
|
|
||||||
## 6. Texture path
|
## 6. Texture path
|
||||||
|
|
||||||
При резолве текстуры:
|
При резолве текстуры:
|
||||||
|
|||||||
@@ -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;
|
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
|
## 5. `Page` chunk
|
||||||
|
|
||||||
```c
|
```c
|
||||||
|
|||||||
Reference in New Issue
Block a user