gmod-rs/crates/engine/src/model.rs
Niko Marmeladkov f8db4cf73c
Some checks are pending
CI / checks (push) Waiting to run
A26 шаг б: кости и веса вершин в модели
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 обновлён.
2026-10-17 11:19:21 +03:00

332 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Сборка мешей моделей (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");
}
}