Some checks are pending
CI / checks (push) Waiting to run
Model теперь несёт skins: Vec<VertexSkin> — привязку вершин к костям из VVD (до трёх костей с весами и их число), по одной записи на вершину. Индексы глобальные (после fixup VTX), считаться по ним будет матрица скиннинга вместе со скелетом Model::bones. Замер на живых моделях (лог «модель загружена»): companion_doll 1471 вершин / 1471 привязок, стол 136/136, диван 556/556 — привязка есть у всех вершин; у пропов при этом один корневой узел (bones=1). Модель игрока police.mdl — 57 костей. Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
//! Сборка мешей моделей (MDL + VVD + VTX) для рендера.
|
||
//!
|
||
//! v0: статическая поза (bind pose), LOD 0, первая модель каждого бодипарта.
|
||
//! Скиннинг/анимации — следующие итерации.
|
||
|
||
use crate::world::{BaseTexture, Material, TextureData, load_texture, resolve_basetexture};
|
||
use formats::mdl::Mdl;
|
||
use formats::vtx::Vtx;
|
||
use formats::vvd::Vvd;
|
||
use fs::VirtualFs;
|
||
use math::Vec3;
|
||
use std::collections::HashMap;
|
||
|
||
/// Ошибка загрузки модели.
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum ModelError {
|
||
#[error("файл: {0}")]
|
||
Fs(#[from] fs::FsError),
|
||
#[error("MDL: {0}")]
|
||
Mdl(String),
|
||
#[error("VVD: {0}")]
|
||
Vvd(String),
|
||
#[error("VTX: {0}")]
|
||
Vtx(String),
|
||
#[error("геометрия: {0}")]
|
||
Geometry(String),
|
||
}
|
||
|
||
/// Привязка вершины к костям (до трёх, как `mstudiovertex_t`).
|
||
///
|
||
/// Индексы костей — **глобальные** (после fixup VTX), по ним считается
|
||
/// матрица скиннинга вместе со скелетом из `Model::bones` (A26).
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||
pub struct VertexSkin {
|
||
pub bones: [u8; 3],
|
||
pub weights: [f32; 3],
|
||
pub num_bones: u8,
|
||
}
|
||
|
||
/// Вершина модели.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||
#[repr(C)]
|
||
pub struct ModelVertex {
|
||
pub pos: [f32; 3],
|
||
pub normal: [f32; 3],
|
||
pub uv: [f32; 2],
|
||
}
|
||
|
||
/// Диапазон индексов с одним материалом.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct ModelBatch {
|
||
pub material: usize,
|
||
pub first_index: u32,
|
||
pub index_count: u32,
|
||
}
|
||
|
||
/// Собранная модель.
|
||
pub struct Model {
|
||
pub vertices: Vec<ModelVertex>,
|
||
pub indices: Vec<u32>,
|
||
pub batches: Vec<ModelBatch>,
|
||
pub materials: Vec<Material>,
|
||
pub bounds_min: Vec3,
|
||
pub bounds_max: Vec3,
|
||
/// Имя (путь MDL).
|
||
pub name: String,
|
||
/// Секвенции анимации (пока только имена и метаданные: скиннинг
|
||
/// следующим шагом A26).
|
||
pub sequences: Vec<formats::mdl::Sequence>,
|
||
/// Кости скелета.
|
||
pub bones: Vec<formats::mdl::Bone>,
|
||
/// Привязка вершин к костям, по одной на `vertices`.
|
||
pub skins: Vec<VertexSkin>,
|
||
}
|
||
|
||
/// Загружает модель по пути `models/...mdl`.
|
||
pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result<Model, ModelError> {
|
||
let name = normalize_model_name(name);
|
||
let mdl_data = vfs.read(&name)?;
|
||
let mdl = Mdl::parse(&mdl_data).map_err(|e| ModelError::Mdl(e.to_string()))?;
|
||
|
||
let base = name.strip_suffix(".mdl").unwrap_or(&name).to_string();
|
||
let vvd_data = vfs
|
||
.read(&format!("{base}.vvd"))
|
||
.map_err(|_| ModelError::Vvd(format!("{base}.vvd не найден")))?;
|
||
let vvd = Vvd::parse(&vvd_data).map_err(|e| ModelError::Vvd(e.to_string()))?;
|
||
|
||
// VTX: сначала dx90, потом обычный.
|
||
let vtx_data = vfs
|
||
.read(&format!("{base}.dx90.vtx"))
|
||
.or_else(|_| vfs.read(&format!("{base}.vtx")))
|
||
.map_err(|_| ModelError::Vtx(format!("{base}.vtx не найден")))?;
|
||
let vtx = Vtx::parse(&vtx_data).map_err(|e| ModelError::Vtx(e.to_string()))?;
|
||
|
||
let vertices = vvd
|
||
.vertices(0)
|
||
.map_err(|e| ModelError::Vvd(e.to_string()))?;
|
||
let textures = mdl.textures().map_err(|e| ModelError::Mdl(e.to_string()))?;
|
||
let cd_textures = mdl.cd_textures().unwrap_or_default();
|
||
let body_parts = vtx
|
||
.body_parts()
|
||
.map_err(|e| ModelError::Vtx(e.to_string()))?;
|
||
|
||
let mut model = Model {
|
||
vertices: Vec::new(),
|
||
indices: Vec::new(),
|
||
batches: Vec::new(),
|
||
materials: Vec::new(),
|
||
bounds_min: Vec3::splat(f32::MAX),
|
||
bounds_max: Vec3::splat(f32::MIN),
|
||
name: name.clone(),
|
||
sequences: Vec::new(),
|
||
bones: Vec::new(),
|
||
skins: Vec::new(),
|
||
};
|
||
|
||
// Скелет и секвенции: их читает `formats`, движку они нужны для
|
||
// скиннинга (A26). Отсутствие данных не ошибка: часть моделей их не имеет.
|
||
model.bones = mdl.bones().unwrap_or_default();
|
||
model.sequences = mdl.sequences().unwrap_or_default();
|
||
if !model.skins.is_empty() {
|
||
let skinned = model
|
||
.skins
|
||
.iter()
|
||
.filter(|s| s.num_bones > 0 && s.weights.iter().any(|w| *w > 0.0))
|
||
.count();
|
||
tracing::info!(
|
||
name = %name,
|
||
vertices = model.skins.len(),
|
||
skinned,
|
||
"модель: привязка вершин к костям"
|
||
);
|
||
}
|
||
if !model.sequences.is_empty() {
|
||
let sample: Vec<&str> = model
|
||
.sequences
|
||
.iter()
|
||
.take(6)
|
||
.map(|s| s.name.as_str())
|
||
.collect();
|
||
tracing::info!(
|
||
name = %name,
|
||
bones = model.bones.len(),
|
||
sequences = model.sequences.len(),
|
||
?sample,
|
||
"модель: скелет и секвенции"
|
||
);
|
||
}
|
||
|
||
let mut material_cache: HashMap<u16, usize> = HashMap::new();
|
||
let checker = crate::world::checker_texture("<модель>");
|
||
|
||
// Материал по индексу текстуры MDL.
|
||
let mut resolve_material =
|
||
|tex_index: u16, model: &mut Model, cache: &mut HashMap<u16, usize>| -> usize {
|
||
if let Some(&index) = cache.get(&tex_index) {
|
||
return index;
|
||
}
|
||
let tex_name = textures
|
||
.get(tex_index as usize)
|
||
.map(|t| t.name.clone())
|
||
.unwrap_or_default();
|
||
// Имя в MDL может быть относительным: ищем по cd-каталогам модели,
|
||
// затем как есть.
|
||
let mut candidates: Vec<String> = Vec::new();
|
||
for cd in &cd_textures {
|
||
let cd = cd.replace('\\', "/");
|
||
let cd = cd
|
||
.strip_prefix("materials/")
|
||
.unwrap_or(&cd)
|
||
.trim_end_matches('/');
|
||
candidates.push(format!("{cd}/{tex_name}"));
|
||
}
|
||
candidates.push(tex_name.clone());
|
||
|
||
let base_name = candidates.iter().find_map(|candidate| {
|
||
match resolve_basetexture(vfs, candidate, 0) {
|
||
BaseTexture::Texture(base) => Some(base),
|
||
_ => None,
|
||
}
|
||
});
|
||
let texture: Option<TextureData> = base_name
|
||
.as_deref()
|
||
.and_then(|b| load_texture(vfs, b, budget));
|
||
let (texture, fallback) = match texture {
|
||
Some(texture) => (texture, false),
|
||
None => (checker.clone(), true),
|
||
};
|
||
let index = model.materials.len();
|
||
model.materials.push(Material {
|
||
name: if tex_name.is_empty() {
|
||
format!("<{}>", model.name)
|
||
} else {
|
||
tex_name
|
||
},
|
||
texture,
|
||
fallback,
|
||
water: None,
|
||
});
|
||
cache.insert(tex_index, index);
|
||
index
|
||
};
|
||
|
||
// Бодипарты: берём все, но у каждого — модель 0 и LOD 0.
|
||
for body_part in &body_parts {
|
||
let Some(model0) = body_part.models.first() else {
|
||
continue;
|
||
};
|
||
let Some(lod) = model0.lods.first() else {
|
||
continue;
|
||
};
|
||
for (mesh_index, mesh) in lod.meshes.iter().enumerate() {
|
||
let tex_index = vtx
|
||
.material_replacement(mesh_index)
|
||
.filter(|&t| (t as usize) < textures.len())
|
||
.unwrap_or(mesh_index as u16);
|
||
let material = resolve_material(tex_index, &mut model, &mut material_cache);
|
||
|
||
let first_index = model.indices.len() as u32;
|
||
for group in &mesh.strip_groups {
|
||
// Локальные вершины группы: orig_mesh_vert_id → VVD.
|
||
let mut remap: Vec<u32> = Vec::with_capacity(group.vertices.len());
|
||
for vertex in &group.vertices {
|
||
let source = vertices.get(vertex.orig_mesh_vert_id as usize);
|
||
let Some(source) = source else {
|
||
remap.push(u32::MAX);
|
||
continue;
|
||
};
|
||
let index = model.vertices.len() as u32;
|
||
model.vertices.push(ModelVertex {
|
||
pos: source.position,
|
||
normal: source.normal,
|
||
uv: source.texcoord,
|
||
});
|
||
model.skins.push(VertexSkin {
|
||
bones: source.bones,
|
||
weights: source.weights,
|
||
num_bones: source.num_bones,
|
||
});
|
||
let pos = Vec3::new(source.position[0], source.position[1], source.position[2]);
|
||
model.bounds_min = model.bounds_min.min(pos);
|
||
model.bounds_max = model.bounds_max.max(pos);
|
||
remap.push(index);
|
||
}
|
||
for tri in group.indices.chunks_exact(3) {
|
||
let a = remap.get(tri[0] as usize).copied().unwrap_or(u32::MAX);
|
||
let b = remap.get(tri[1] as usize).copied().unwrap_or(u32::MAX);
|
||
let c = remap.get(tri[2] as usize).copied().unwrap_or(u32::MAX);
|
||
if a == u32::MAX || b == u32::MAX || c == u32::MAX {
|
||
continue;
|
||
}
|
||
model.indices.extend_from_slice(&[a, b, c]);
|
||
}
|
||
}
|
||
let index_count = model.indices.len() as u32 - first_index;
|
||
if index_count > 0 {
|
||
model.batches.push(ModelBatch {
|
||
material,
|
||
first_index,
|
||
index_count,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if model.vertices.is_empty() {
|
||
return Err(ModelError::Geometry("нет вершин".to_string()));
|
||
}
|
||
if !model.bounds_min.is_finite() || !model.bounds_max.is_finite() {
|
||
model.bounds_min = Vec3::splat(-16.0);
|
||
model.bounds_max = Vec3::splat(16.0);
|
||
}
|
||
tracing::info!(
|
||
name = %name,
|
||
vertices = model.vertices.len(),
|
||
skins = model.skins.len(),
|
||
bones = model.bones.len(),
|
||
sequences = model.sequences.len(),
|
||
"модель загружена"
|
||
);
|
||
Ok(model)
|
||
}
|
||
|
||
/// Нормализует имя модели: `\` → `/`, lowercase, без ведущего `/`.
|
||
pub fn normalize_model_name(name: &str) -> String {
|
||
let name = name.replace('\\', "/");
|
||
let name = name.trim_start_matches('/').to_ascii_lowercase();
|
||
if name.ends_with(".mdl") {
|
||
name
|
||
} else {
|
||
format!("{name}.mdl")
|
||
}
|
||
}
|
||
|
||
/// Поворот вектора модели углами Source (pitch/yaw/roll, градусы).
|
||
pub fn rotate_model_vector(v: Vec3, pitch: f32, yaw: f32, roll: f32) -> Vec3 {
|
||
let (sp, cp) = pitch.to_radians().sin_cos();
|
||
let (sy, cy) = yaw.to_radians().sin_cos();
|
||
let (sr, cr) = roll.to_radians().sin_cos();
|
||
|
||
// Колонки: forward, left, up (AngleVectors из Source).
|
||
let f = Vec3::new(cp * cy, cp * sy, -sp);
|
||
let l = Vec3::new(sr * sp * cy - cr * sy, sr * sp * sy + cr * cy, sr * cp);
|
||
let u = Vec3::new(cr * sp * cy + sr * sy, cr * sp * sy - sr * cy, cr * cp);
|
||
f * v.x + l * v.y + u * v.z
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn rotate_identity() {
|
||
let v = Vec3::new(1.0, 2.0, 3.0);
|
||
let r = rotate_model_vector(v, 0.0, 0.0, 0.0);
|
||
assert!((r - v).length() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_yaw_90() {
|
||
// yaw=90: +X (вперёд) → +Y (влево).
|
||
let r = rotate_model_vector(Vec3::new(1.0, 0.0, 0.0), 0.0, 90.0, 0.0);
|
||
assert!((r - Vec3::new(0.0, 1.0, 0.0)).length() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_names() {
|
||
assert_eq!(normalize_model_name("models\\a\\b.mdl"), "models/a/b.mdl");
|
||
assert_eq!(normalize_model_name("MODELS/A.MDL"), "models/a.mdl");
|
||
assert_eq!(normalize_model_name("models/a"), "models/a.mdl");
|
||
}
|
||
}
|