A26: кадры анимации играют — модель игрока и вьюмодель
Some checks failed
CI / checks (push) Has been cancelled
Some checks failed
CI / checks (push) Has been cancelled
Спека декодера взята из настоящего bone_setup.cpp (Mapbase-зеркало SDK 2013). Прошлые догадки были неверны, и это зафиксировано в docs/PARITY.md: каналы дорожек — не компоненты кватерниона, а углы Эйлера (raw · rotscale + поза покоя, затем AngleQuaternion); позиции — raw · posscale + bone.pos; значения блоков не накапливаются дельтами (ExtractAnimValue идёт по блокам valid+1 записей, внутри блока value[k], дальше повтор последнего); флаг DELTA означает относительные каналы без позы покоя. Замеры на живых данных, три теста в tests/world_real.rs: - @taunt_muscle_base (451 кадр, 53 дорожки): углы ≤ 4,75 рад, сдвиги позиций ≤ 28,5 юнитов, кватернионы единичные — при новой спеке нарушений нет; - кости локальны родителю (цепочка 4,0 / 3,5 / 8,9), корень — таз на 38,6; - police.mdl: 57 костей, своя анимация @ragdoll лежит в самом MDL (anim_block == 0); m_anm.mdl: 1563 анимации в models/m_anm.ani (7,1 МБ); - модель игрока, idle_all_01: из 3147 вершин 1101 сдвинулась между кадрами 0 и 30, высота тела 61 юнит и в покое, и в анимации — не схлопывается. Движок: AnimData (буферы кадров под Arc), frame_pose, play_model_animation (мировые позы через родителей, A·B⁻¹ по костям, скиннинг весами вершин); кости меша ищутся в скелете анимации по именам — скелеты совпадают по именам, но не по числу костей. Кадры читаются и из .ani, и из самого MDL. Клиент: вьюмодель играет idle, модель игрока — idle_all_01; добавлен вид со стороны --third-person (иначе свою модель не увидеть: в первом лице камера внутри неё). Map(Box<MapArgs>) — против clippy::large_enum_variant. Все 23 сьюта и 29 картовых тестов зелёные.
This commit is contained in:
parent
49ff6da4ac
commit
8a5f43dcd2
7 changed files with 802 additions and 183 deletions
|
|
@ -39,7 +39,7 @@ enum Command {
|
|||
Menu(menu::MenuArgs),
|
||||
|
||||
/// Загрузить карту и отрисовать её (полёт камеры)
|
||||
Map(map::MapArgs),
|
||||
Map(Box<map::MapArgs>),
|
||||
|
||||
/// Сетевые операции (A2S, подключение)
|
||||
Net {
|
||||
|
|
@ -126,7 +126,7 @@ fn main() -> anyhow::Result<()> {
|
|||
.unwrap_or(Command::Run(menu::MenuArgs::default()))
|
||||
{
|
||||
Command::Run(args) | Command::Menu(args) => menu::menu(cli.assets, args),
|
||||
Command::Map(args) => map::map(cli.assets, args),
|
||||
Command::Map(args) => map::map(cli.assets, *args),
|
||||
Command::Net { cmd } => match cmd {
|
||||
NetCmd::Info { addr } => net_cmd::info(&addr),
|
||||
NetCmd::Connect(args) => net_cmd::connect(args),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ pub struct MapArgs {
|
|||
#[arg(long)]
|
||||
pub hidden: bool,
|
||||
|
||||
/// Вид со стороны: камера отходит назад, модель игрока остаётся на виду
|
||||
/// (в первом лице её скрывает проверка «камера внутри»).
|
||||
#[arg(long)]
|
||||
pub third_person: bool,
|
||||
|
||||
/// Сохранить PNG после завершения цикла
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub screenshot: Option<PathBuf>,
|
||||
|
|
@ -138,6 +143,7 @@ impl MapArgs {
|
|||
map: map.to_string(),
|
||||
frames: None,
|
||||
hidden: false,
|
||||
third_person: false,
|
||||
screenshot: None,
|
||||
flythrough: None,
|
||||
route: None,
|
||||
|
|
@ -713,17 +719,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
// вынесена вперёд по оси модели (~47 юнитов у пистолета), и
|
||||
// разворот уводил ствол за камеру — вьюмодель не рисовалась.
|
||||
//
|
||||
// Позу костей считает движок (CPU-скиннинг, A26): пока это
|
||||
// проба — одна кость качается по синусу, настоящие кадры из
|
||||
// `.ani` следующим шагом.
|
||||
let bone_angle = (wave_time * 2.0).sin() * 30.0;
|
||||
w.skin_model_instance(
|
||||
index,
|
||||
origin,
|
||||
math::Vec3::new(camera.pitch, camera.yaw, 0.0),
|
||||
1,
|
||||
bone_angle,
|
||||
);
|
||||
// Кадры берём из модели: у оружия свои анимации в самом MDL
|
||||
// (`@idle` у пистолета), у игрока — библиотека m_anm.
|
||||
w.play_model_animation(index, origin, angles, "idle", wave_time);
|
||||
}
|
||||
moved_ranges = w.take_moved_ranges();
|
||||
moved_index_ranges = w.take_moved_index_ranges();
|
||||
|
|
@ -740,7 +738,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
};
|
||||
// У моделей Source ось Y смотрит влево, поэтому курс
|
||||
// разворачиваем: без этого модель стоит лицом против взгляда.
|
||||
w.move_model_instance(index, origin, math::Vec3::new(0.0, camera.yaw + 180.0, 0.0));
|
||||
// Позу ведёт библиотека анимаций m_anm (A26).
|
||||
w.play_model_animation(
|
||||
index,
|
||||
origin,
|
||||
math::Vec3::new(0.0, camera.yaw + 180.0, 0.0),
|
||||
"idle_all_01",
|
||||
wave_time,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Переписанные вершины (двинувшиеся пропы, модель игрока, физган)
|
||||
|
|
@ -826,6 +831,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
);
|
||||
}
|
||||
camera.pos = player.eye_position();
|
||||
// Вид со стороны: камера отходит назад и вверх, чтобы модель игрока
|
||||
// осталась на виду (в первом лице её скрывает проверка «камера внутри»).
|
||||
if args.third_person {
|
||||
let forward = camera.forward();
|
||||
camera.pos = camera.pos - forward * 128.0 + math::Vec3::new(0.0, 0.0, 32.0);
|
||||
}
|
||||
|
||||
// HUD (тень + текст, чтобы читалось на любом фоне).
|
||||
let (_, height) = gfx.size();
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ pub struct Model {
|
|||
pub sequences: Vec<formats::mdl::Sequence>,
|
||||
/// Кости скелета.
|
||||
pub bones: Vec<formats::mdl::Bone>,
|
||||
/// Данные анимаций: свои кадры и кадры включённых моделей (A26).
|
||||
pub anim_data: Vec<std::sync::Arc<crate::world::AnimData>>,
|
||||
/// Привязка вершин к костям, по одной на `vertices`.
|
||||
pub skins: Vec<VertexSkin>,
|
||||
}
|
||||
|
|
@ -111,6 +113,7 @@ pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result<Mod
|
|||
name: name.clone(),
|
||||
sequences: Vec::new(),
|
||||
bones: Vec::new(),
|
||||
anim_data: Vec::new(),
|
||||
skins: Vec::new(),
|
||||
};
|
||||
|
||||
|
|
@ -118,6 +121,19 @@ pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result<Mod
|
|||
// скиннинга (A26). Отсутствие данных не ошибка: часть моделей их не имеет.
|
||||
model.bones = mdl.bones().unwrap_or_default();
|
||||
model.sequences = mdl.sequences().unwrap_or_default();
|
||||
// Кадры анимаций самой модели: они лежат в MDL (`anim_block == 0`).
|
||||
if mdl.header.numlocalanim > 0 {
|
||||
model
|
||||
.anim_data
|
||||
.push(std::sync::Arc::new(crate::world::AnimData {
|
||||
name: name.clone(),
|
||||
mdl: mdl_data.clone(),
|
||||
ani: None,
|
||||
blocks: Vec::new(),
|
||||
anims: mdl.animations().unwrap_or_default(),
|
||||
bones: model.bones.clone(),
|
||||
}));
|
||||
}
|
||||
// Включённые модели (`includemodel`): у игроков там лежат активности
|
||||
// (у police.mdl своя секвенция всего одна — «ragdoll»). Замером смотрим,
|
||||
// что именно включается, прежде чем сливать.
|
||||
|
|
@ -140,44 +156,59 @@ pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result<Mod
|
|||
continue;
|
||||
};
|
||||
let sequences = included.sequences().unwrap_or_default();
|
||||
if let Ok(anims) = included.animations() {
|
||||
let block_names = included.anim_block_names();
|
||||
let blocks = included.anim_blocks();
|
||||
// Кадры GMod-моделей лежат в отдельном `.ani` — читаем его
|
||||
// целиком: он нужен для поз костей (A26, следующий шаг).
|
||||
let mut ani_bytes = 0usize;
|
||||
for block_name in &block_names {
|
||||
let ani_path = if block_name.starts_with("models/") {
|
||||
block_name.clone()
|
||||
} else {
|
||||
format!("models/{block_name}")
|
||||
};
|
||||
match vfs.read(&ani_path) {
|
||||
Ok(data) => {
|
||||
ani_bytes += data.len();
|
||||
tracing::info!(
|
||||
include = %path,
|
||||
ani = %ani_path,
|
||||
bytes = data.len(),
|
||||
"анимационный файл прочитан"
|
||||
);
|
||||
}
|
||||
Err(err) => tracing::warn!(
|
||||
let block_names = included.anim_block_names();
|
||||
let blocks = included.anim_blocks();
|
||||
let anims = included.animations().unwrap_or_default();
|
||||
// Кадры GMod-моделей вынесены в отдельный `.ani` (у m_anm это
|
||||
// 7 МБ). Читаем файл целиком: без него позы костей не посчитать.
|
||||
// В списке имён блоков бывают и каталоги — их пропускаем.
|
||||
let mut ani: Option<Vec<u8>> = None;
|
||||
for block_name in &block_names {
|
||||
if !block_name.to_ascii_lowercase().ends_with(".ani") {
|
||||
tracing::info!(include = %path, skipped = %block_name, "блок кадров пропущен: не файл .ani");
|
||||
continue;
|
||||
}
|
||||
let ani_path = if block_name.starts_with("models/") {
|
||||
block_name.clone()
|
||||
} else {
|
||||
format!("models/{block_name}")
|
||||
};
|
||||
match vfs.read(&ani_path) {
|
||||
Ok(data) => {
|
||||
tracing::info!(
|
||||
include = %path,
|
||||
ani = %ani_path,
|
||||
"анимационный файл не прочитан: {err}"
|
||||
),
|
||||
bytes = data.len(),
|
||||
"анимационный файл прочитан"
|
||||
);
|
||||
if ani.is_none() {
|
||||
ani = Some(data);
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!(
|
||||
include = %path,
|
||||
ani = %ani_path,
|
||||
"анимационный файл не прочитан: {err}"
|
||||
),
|
||||
}
|
||||
tracing::info!(
|
||||
include = %path,
|
||||
animations = anims.len(),
|
||||
block_names = ?block_names,
|
||||
blocks = blocks.len(),
|
||||
ani_bytes,
|
||||
"анимации включённой модели"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
include = %path,
|
||||
animations = anims.len(),
|
||||
blocks = blocks.len(),
|
||||
ani_bytes = ani.as_ref().map(|a| a.len()).unwrap_or(0),
|
||||
"анимации включённой модели"
|
||||
);
|
||||
model
|
||||
.anim_data
|
||||
.push(std::sync::Arc::new(crate::world::AnimData {
|
||||
name: path.clone(),
|
||||
mdl: data.clone(),
|
||||
ani,
|
||||
blocks,
|
||||
anims,
|
||||
bones: included.bones().unwrap_or_default(),
|
||||
}));
|
||||
tracing::info!(
|
||||
include = %path,
|
||||
numlocalseq = included.header.numlocalseq,
|
||||
|
|
|
|||
|
|
@ -2302,6 +2302,7 @@ impl World {
|
|||
let mut mesh_data = PropMesh::from_vertices(&model.vertices);
|
||||
mesh_data.bones = model.bones.clone();
|
||||
mesh_data.skins = model.skins.clone();
|
||||
mesh_data.anim = model.anim_data.clone();
|
||||
self.prop_meshes.push(mesh_data);
|
||||
let mesh = self.prop_meshes.len() - 1;
|
||||
|
||||
|
|
@ -2451,21 +2452,23 @@ impl World {
|
|||
Some(self.props.len() - 1)
|
||||
}
|
||||
|
||||
/// Пересчитывает вершины экземпляра по позе костей (CPU-скиннинг, A26).
|
||||
/// Двигает экземпляр и переписывает вершины по кадрам анимации (A26).
|
||||
///
|
||||
/// `bone` и `angle` — поворот одной кости вокруг оси Z модели на угол в
|
||||
/// градусах; дети кости поворачиваются вместе с ней. Вершина смешивается по
|
||||
/// своим весам: `Σ w · (R(угол_кости) · (p − pos_кости) + pos_кости)`.
|
||||
/// `animation` — имя анимации (у моделей GMod библиотека анимаций лежит во
|
||||
/// включённой модели, поэтому имя может быть и со `@`); `time` — секунды от
|
||||
/// начала, кадр берётся по `fps` и зацикливается.
|
||||
///
|
||||
/// Пока это **проба скиннинга**, а не анимации: настоящие кадры берутся из
|
||||
/// `.ani` следующим шагом (docs/PARITY.md A26).
|
||||
pub fn skin_model_instance(
|
||||
/// Поза костей — по спеке SDK (`CalcBonePosition` / `CalcBoneQuaternion`):
|
||||
/// каналы это углы Эйлера и сдвиги с масштабом кости, к ним добавляется
|
||||
/// поза покоя. Вершина смешивается по своим весам:
|
||||
/// `Σ w · (A_i · B_i⁻¹) · p`, где `A` — поза кадра, `B` — поза покоя.
|
||||
pub fn play_model_animation(
|
||||
&mut self,
|
||||
index: usize,
|
||||
origin: Vec3,
|
||||
angles: Vec3,
|
||||
bone: usize,
|
||||
angle: f32,
|
||||
animation: &str,
|
||||
time: f32,
|
||||
) -> bool {
|
||||
let Some(instance) = self.model_instances.get(index) else {
|
||||
return false;
|
||||
|
|
@ -2482,60 +2485,93 @@ impl World {
|
|||
return false;
|
||||
};
|
||||
if mesh.bones.is_empty() || mesh.skins.len() < count {
|
||||
// Скелета нет — обычный перенос позы.
|
||||
return self.rewrite_instance(mesh_index, first as u32, count as u32, origin, angles);
|
||||
}
|
||||
// Углы костей: своя плюс родительская.
|
||||
let bones = &mesh.bones;
|
||||
let mut bone_angles = vec![0.0f32; bones.len()];
|
||||
let selected = bone.min(bones.len().saturating_sub(1));
|
||||
for (i, b) in bones.iter().enumerate() {
|
||||
let own = if i == selected { angle } else { 0.0 };
|
||||
let parent = if b.parent >= 0 {
|
||||
bone_angles.get(b.parent as usize).copied().unwrap_or(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
bone_angles[i] = own + parent;
|
||||
}
|
||||
let rot_z = |p: Vec3, degrees: f32| -> Vec3 {
|
||||
let (sin, cos) = degrees.to_radians().sin_cos();
|
||||
Vec3::new(p.x * cos - p.y * sin, p.x * sin + p.y * cos, p.z)
|
||||
// Данные анимации — ссылкой: дальше меш понадобится снять из мира,
|
||||
// чтобы одновременно читать его вершины и писать в общий буфер.
|
||||
let found = mesh.anim.iter().find_map(|data| {
|
||||
data.find(animation)
|
||||
.map(|anim| (data.clone(), anim.clone()))
|
||||
});
|
||||
let Some((data, anim)) = found else {
|
||||
// Кадров нет — экземпляр остаётся в позе покоя (обычный перенос).
|
||||
return self.rewrite_instance(mesh_index, first as u32, count as u32, origin, angles);
|
||||
};
|
||||
let (pitch, yaw, roll) = (angles.x, angles.y, angles.z);
|
||||
|
||||
// Кадр по времени; дробная часть — бленд между кадром и следующим.
|
||||
let frames = anim.num_frames.max(1) as usize;
|
||||
let fps = if anim.fps > 0.0 { anim.fps } else { 30.0 };
|
||||
let position = (time.max(0.0) * fps).max(0.0);
|
||||
let frame = position as usize % frames;
|
||||
let blend = position - position.floor();
|
||||
let local = data.frame_pose(&anim, frame, blend);
|
||||
|
||||
// Поза покоя и поза кадра — в координатах модели; разница по костям —
|
||||
// это и есть преобразование вершин.
|
||||
let parents: Vec<i32> = data.bones.iter().map(|b| b.parent).collect();
|
||||
let bind_local: Vec<(Vec3, [f32; 4])> = data
|
||||
.bones
|
||||
.iter()
|
||||
.map(|b| (Vec3::new(b.pos[0], b.pos[1], b.pos[2]), b.quat))
|
||||
.collect();
|
||||
let bind = bone_world_transforms(&bind_local, &parents);
|
||||
let posed = bone_world_transforms(&local, &parents);
|
||||
let deltas: Vec<(Vec3, [f32; 4])> = bind
|
||||
.iter()
|
||||
.zip(posed.iter())
|
||||
.map(|((bp, bq), (pp, pq))| {
|
||||
let bq_inv = quat_conjugate(*bq);
|
||||
let inv_pos = -quat_rotate(bq_inv, *bp);
|
||||
(*pp + quat_rotate(*pq, inv_pos), quat_mul(*pq, bq_inv))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Кости меша — по именам: скелет меша и скелет библиотеки анимаций
|
||||
// совпадают по именам, но не по числу костей.
|
||||
let bone_map: Vec<Option<usize>> = mesh
|
||||
.bones
|
||||
.iter()
|
||||
.map(|b| data.bones.iter().position(|lb| lb.name == b.name))
|
||||
.collect();
|
||||
|
||||
let Some(slot) = self.prop_meshes.get_mut(mesh_index) else {
|
||||
return false;
|
||||
};
|
||||
let mesh = std::mem::take(slot);
|
||||
let identity = (Vec3::ZERO, [0.0, 0.0, 0.0, 1.0]);
|
||||
for i in 0..count {
|
||||
let Some(skin) = mesh.skins.get(i) else {
|
||||
let (Some(skin), Some(base)) = (mesh.skins.get(i), mesh.positions.get(i)) else {
|
||||
continue;
|
||||
};
|
||||
let base = Vec3::new(
|
||||
mesh.positions[i][0],
|
||||
mesh.positions[i][1],
|
||||
mesh.positions[i][2],
|
||||
);
|
||||
let mut local = Vec3::ZERO;
|
||||
let base = Vec3::new(base[0], base[1], base[2]);
|
||||
let mut blended = Vec3::ZERO;
|
||||
let mut total = 0.0f32;
|
||||
for j in 0..skin.num_bones.min(3) as usize {
|
||||
let b = skin.bones[j] as usize;
|
||||
if b >= bones.len() {
|
||||
continue;
|
||||
}
|
||||
let weight = skin.weights[j];
|
||||
if weight <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let pos = Vec3::new(bones[b].pos[0], bones[b].pos[1], bones[b].pos[2]);
|
||||
local += (rot_z(base - pos, bone_angles[b]) + pos) * weight;
|
||||
let Some(Some(bone)) = bone_map.get(skin.bones[j] as usize) else {
|
||||
continue;
|
||||
};
|
||||
let (d_pos, d_quat) = deltas.get(*bone).copied().unwrap_or(identity);
|
||||
blended += (d_pos + quat_rotate(d_quat, base)) * weight;
|
||||
total += weight;
|
||||
}
|
||||
if total <= 0.0 {
|
||||
local = base;
|
||||
let local = if total <= 0.0 {
|
||||
base
|
||||
} else if (total - 1.0).abs() > 1e-3 {
|
||||
local = local / total;
|
||||
blended / total
|
||||
} else {
|
||||
blended
|
||||
};
|
||||
let pos =
|
||||
crate::model::rotate_model_vector(local, angles.x, angles.y, angles.z) + origin;
|
||||
if let Some(vertex) = self.vertices.get_mut(first + i) {
|
||||
vertex.pos = [pos.x, pos.y, pos.z];
|
||||
}
|
||||
let pos = crate::model::rotate_model_vector(local, pitch, yaw, roll) + origin;
|
||||
let vertex = &mut self.vertices[first + i];
|
||||
vertex.pos = [pos.x, pos.y, pos.z];
|
||||
}
|
||||
self.prop_meshes[mesh_index] = mesh;
|
||||
self.moved_ranges.push((first as u32, count as u32));
|
||||
true
|
||||
}
|
||||
|
|
@ -2614,6 +2650,196 @@ impl World {
|
|||
}
|
||||
}
|
||||
|
||||
/// Поворот вектора кватернионом.
|
||||
fn quat_rotate(q: [f32; 4], v: Vec3) -> Vec3 {
|
||||
let u = Vec3::new(q[0], q[1], q[2]);
|
||||
let t = u.cross(v) * 2.0;
|
||||
v + t * q[3] + u.cross(t)
|
||||
}
|
||||
|
||||
/// Произведение кватернионов: сначала `a`, затем `b` (как в mathlib Source).
|
||||
fn quat_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||||
[
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
]
|
||||
}
|
||||
|
||||
/// Сопряжение кватерниона: для единичного это обратный поворот.
|
||||
fn quat_conjugate(q: [f32; 4]) -> [f32; 4] {
|
||||
[-q[0], -q[1], -q[2], q[3]]
|
||||
}
|
||||
|
||||
/// Нормированная линейная смесь поворотов с выбором близкого знака
|
||||
/// (Source: `QuaternionBlend`).
|
||||
fn quat_nlerp(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
|
||||
let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
|
||||
let b = if dot < 0.0 {
|
||||
[-b[0], -b[1], -b[2], -b[3]]
|
||||
} else {
|
||||
b
|
||||
};
|
||||
let mut out = [0.0f32; 4];
|
||||
for (i, value) in out.iter_mut().enumerate() {
|
||||
*value = a[i] + (b[i] - a[i]) * t;
|
||||
}
|
||||
let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2] + out[3] * out[3]).sqrt();
|
||||
if len > 1e-6 {
|
||||
for value in &mut out {
|
||||
*value /= len;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Мировые позы костей по локальным: `world = родитель · локальная`.
|
||||
///
|
||||
/// Поза — это `T(pos) · R(quat)`, поэтому обходимся без матриц: позиция
|
||||
/// поворачивается родителем и складывается с его позицией, повороты
|
||||
/// перемножаются. Кости идут в порядке родителей; если родитель объявлен позже
|
||||
/// (битые данные), кость считается корнем.
|
||||
fn bone_world_transforms(local: &[(Vec3, [f32; 4])], parents: &[i32]) -> Vec<(Vec3, [f32; 4])> {
|
||||
let mut out = vec![(Vec3::ZERO, [0.0, 0.0, 0.0, 1.0]); local.len()];
|
||||
for (i, (pos, quat)) in local.iter().enumerate() {
|
||||
let parent = parents.get(i).copied().unwrap_or(-1);
|
||||
out[i] = if parent >= 0 && (parent as usize) < i {
|
||||
let (ppos, pquat) = out[parent as usize];
|
||||
(ppos + quat_rotate(pquat, *pos), quat_mul(pquat, *quat))
|
||||
} else {
|
||||
(*pos, *quat)
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Кадры анимаций модели: буферы, описания и скелет.
|
||||
///
|
||||
/// Хранится ссылкой (`Arc`): у моделей GMod кадры вынесены в общий файл `.ani`
|
||||
/// на мегабайты, а скелет и описания нужны для каждого экземпляра.
|
||||
pub struct AnimData {
|
||||
/// Имя модели (для логов).
|
||||
pub name: String,
|
||||
/// Файл MDL: здесь лежат кадры анимаций самой модели (`anim_block == 0`).
|
||||
pub mdl: Vec<u8>,
|
||||
/// Файл `.ani` — кадры вынесенных анимаций (у библиотек вроде `m_anm`).
|
||||
pub ani: Option<Vec<u8>>,
|
||||
/// Блоки анимационного файла: смещения данных.
|
||||
pub blocks: Vec<(i32, i32)>,
|
||||
/// Описания анимаций.
|
||||
pub anims: Vec<formats::mdl::AnimDesc>,
|
||||
/// Скелет модели: позы покоя и масштабы каналов.
|
||||
pub bones: Vec<formats::mdl::Bone>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AnimData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AnimData")
|
||||
.field("name", &self.name)
|
||||
.field("mdl", &self.mdl.len())
|
||||
.field("ani", &self.ani.as_ref().map(|a| a.len()))
|
||||
.field("anims", &self.anims.len())
|
||||
.field("bones", &self.bones.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimData {
|
||||
/// Анимация по имени; `@` в начале имени допускается опускать (в библиотеке
|
||||
/// GMod анимации названы `@idle_all_01` и подобно).
|
||||
pub fn find(&self, name: &str) -> Option<&formats::mdl::AnimDesc> {
|
||||
if let Some(anim) = self
|
||||
.anims
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return Some(anim);
|
||||
}
|
||||
let with_at = format!("@{name}");
|
||||
self.anims
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(&with_at))
|
||||
}
|
||||
|
||||
/// Буфер с кадрами анимации и смещение её записи в нём.
|
||||
pub fn frames(&self, anim: &formats::mdl::AnimDesc) -> Option<(&[u8], usize)> {
|
||||
if anim.anim_block <= 0 {
|
||||
// Кадры внутри MDL — сразу за описанием анимации (`pAnim`).
|
||||
let start = anim.offset.checked_add(anim.anim_index.max(0) as usize)?;
|
||||
Some((&self.mdl, start))
|
||||
} else {
|
||||
let ani = self.ani.as_deref()?;
|
||||
let base = self.blocks.get(anim.anim_block as usize)?.0.max(0) as usize;
|
||||
Some((ani, base + anim.anim_index.max(0) as usize))
|
||||
}
|
||||
}
|
||||
|
||||
/// Локальные позы костей на кадре (спека: `CalcBonePosition` и
|
||||
/// `CalcBoneQuaternion` из `bone_setup.cpp`).
|
||||
///
|
||||
/// `blend` — доля между кадром `frame` и следующим (Source берёт две позы и
|
||||
/// смешивает: позиции линейно, повороты — нормированной линейной смесью).
|
||||
pub fn frame_pose(
|
||||
&self,
|
||||
anim: &formats::mdl::AnimDesc,
|
||||
frame: usize,
|
||||
blend: f32,
|
||||
) -> Vec<(Vec3, [f32; 4])> {
|
||||
let mut pose: Vec<(Vec3, [f32; 4])> = self
|
||||
.bones
|
||||
.iter()
|
||||
.map(|b| (Vec3::new(b.pos[0], b.pos[1], b.pos[2]), b.quat))
|
||||
.collect();
|
||||
let Some((data, start)) = self.frames(anim) else {
|
||||
return pose;
|
||||
};
|
||||
let tracks = formats::mdl::Mdl::animation_tracks(data, start);
|
||||
let frames = anim.num_frames.max(1) as usize;
|
||||
let next = if frame + 1 >= frames { 0 } else { frame + 1 };
|
||||
for track in &tracks {
|
||||
let (Some(slot), Some(bone)) = (
|
||||
pose.get_mut(track.bone as usize),
|
||||
self.bones.get(track.bone as usize),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
// DELTA — каналы уже относительные: поза покоя не прибавляется.
|
||||
let delta = track.flags & 0x10 != 0;
|
||||
let euler = |at: usize| -> [f32; 3] {
|
||||
let mut angles = if delta { [0.0; 3] } else { bone.rot };
|
||||
for (j, channel) in track.channels.iter().take(3).enumerate() {
|
||||
if let Some(off) = channel {
|
||||
angles[j] += formats::mdl::Mdl::extract_anim_value(data, *off, at)
|
||||
* bone.rot_scale[j];
|
||||
}
|
||||
}
|
||||
angles
|
||||
};
|
||||
let e1 = euler(frame);
|
||||
let q1 = formats::mdl::Mdl::angle_quaternion(e1[0], e1[1], e1[2]);
|
||||
let q = if blend > 0.001 {
|
||||
let e2 = euler(next);
|
||||
let q2 = formats::mdl::Mdl::angle_quaternion(e2[0], e2[1], e2[2]);
|
||||
quat_nlerp(q1, q2, blend)
|
||||
} else {
|
||||
q1
|
||||
};
|
||||
let mut pos = if delta { [0.0; 3] } else { bone.pos };
|
||||
for (j, channel) in track.channels.iter().skip(3).take(3).enumerate() {
|
||||
if let Some(off) = channel {
|
||||
let v1 = formats::mdl::Mdl::extract_anim_value(data, *off, frame);
|
||||
let v2 = formats::mdl::Mdl::extract_anim_value(data, *off, next);
|
||||
pos[j] += (v1 * (1.0 - blend) + v2 * blend) * bone.pos_scale[j];
|
||||
}
|
||||
}
|
||||
slot.0 = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
slot.1 = q;
|
||||
}
|
||||
pose
|
||||
}
|
||||
}
|
||||
|
||||
/// Модель пропа в локальных координатах: нужно, чтобы пересчитать позу
|
||||
/// без повторной загрузки с диска.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
|
@ -2624,6 +2850,8 @@ pub struct PropMesh {
|
|||
pub bones: Vec<formats::mdl::Bone>,
|
||||
/// Привязка вершин к костям, по одной на `positions`.
|
||||
pub skins: Vec<crate::model::VertexSkin>,
|
||||
/// Данные анимаций: своя модель и включённые библиотеки (A26).
|
||||
pub anim: Vec<std::sync::Arc<AnimData>>,
|
||||
/// Нормали в локальных координатах модели.
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
/// Габарит модели в локальных координатах: центр и половины размера.
|
||||
|
|
@ -2657,6 +2885,7 @@ impl PropMesh {
|
|||
normals,
|
||||
bones: Vec::new(),
|
||||
skins: Vec::new(),
|
||||
anim: Vec::new(),
|
||||
bounds_center: center,
|
||||
bounds_half_extents: half_extents,
|
||||
}
|
||||
|
|
@ -4161,6 +4390,7 @@ mod tests {
|
|||
prop_meshes: vec![PropMesh {
|
||||
bones: Vec::new(),
|
||||
skins: Vec::new(),
|
||||
anim: Vec::new(),
|
||||
positions: vec![[1.0, 2.0, 3.0]],
|
||||
normals: vec![[0.0, 0.0, 1.0]],
|
||||
bounds_center: Vec3::ZERO,
|
||||
|
|
@ -4222,6 +4452,7 @@ mod tests {
|
|||
prop_meshes: vec![PropMesh {
|
||||
bones: Vec::new(),
|
||||
skins: Vec::new(),
|
||||
anim: Vec::new(),
|
||||
positions: vec![[1.0, 0.0, 0.0]],
|
||||
normals: vec![[1.0, 0.0, 0.0]],
|
||||
bounds_center: Vec3::ZERO,
|
||||
|
|
|
|||
|
|
@ -164,3 +164,297 @@ fn gm_flatgrass_displacements() {
|
|||
sky.vertices.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Читает MDL анимационной библиотеки игрока и её `.ani`.
|
||||
fn player_anim_library(vfs: &VirtualFs) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let data = vfs.read("models/m_anm.mdl").ok()?;
|
||||
let mdl = formats::mdl::Mdl::parse(&data).ok()?;
|
||||
let name = mdl.anim_block_names().first().cloned()?;
|
||||
let path = if name.starts_with("models/") {
|
||||
name
|
||||
} else {
|
||||
format!("models/{name}")
|
||||
};
|
||||
let ani = vfs.read(&path).ok()?;
|
||||
Some((data, ani))
|
||||
}
|
||||
|
||||
/// Что за анимации у моделей игрока и оружия: сколько своих, откуда кадры.
|
||||
#[test]
|
||||
fn real_models_animation_sources() {
|
||||
let Some(root) = assets_root() else {
|
||||
eprintln!("пропуск: ассеты не найдены");
|
||||
return;
|
||||
};
|
||||
let vfs = mount_game(&root);
|
||||
for name in [
|
||||
"models/player/police.mdl",
|
||||
"models/weapons/w_pistol.mdl",
|
||||
"models/m_anm.mdl",
|
||||
] {
|
||||
let Ok(data) = vfs.read(name) else {
|
||||
eprintln!("{name}: не прочитана");
|
||||
continue;
|
||||
};
|
||||
let Ok(mdl) = formats::mdl::Mdl::parse(&data) else {
|
||||
eprintln!("{name}: не разобрана");
|
||||
continue;
|
||||
};
|
||||
let h = &mdl.header;
|
||||
let anims = mdl.animations().unwrap_or_default();
|
||||
let blocks = mdl.anim_blocks();
|
||||
let block_names = mdl.anim_block_names();
|
||||
let sample: Vec<(String, i32, i32, i32)> = anims
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|a| (a.name.clone(), a.num_frames, a.anim_block, a.fps as i32))
|
||||
.collect();
|
||||
let seq_names: Vec<String> = mdl
|
||||
.sequences()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
eprintln!(
|
||||
"{name}: байт {}, костей {}, своих секвенций {}, своих анимаций {}, \
|
||||
блоков {}, имена блоков {block_names:?}",
|
||||
data.len(),
|
||||
h.numbones,
|
||||
h.numlocalseq,
|
||||
h.numlocalanim,
|
||||
blocks.len()
|
||||
);
|
||||
eprintln!(" анимации: {sample:?}");
|
||||
eprintln!(" секвенции: {seq_names:?}");
|
||||
}
|
||||
}
|
||||
/// Замер нормировки каналов анимации на всех костях и кадрах.
|
||||
///
|
||||
/// Критерий жёсткий: у кватерниона модуль вектора поворота не может быть больше
|
||||
/// единицы. Считаем нарушения для кандидата «делить на 32768» и кандидата
|
||||
/// «умножать на rotscale кости» — кто не нарушает ни разу, тот и верен.
|
||||
#[test]
|
||||
fn real_player_animation_channel_scales() {
|
||||
let Some(root) = assets_root() else {
|
||||
eprintln!("пропуск: ассеты не найдены");
|
||||
return;
|
||||
};
|
||||
let vfs = mount_game(&root);
|
||||
let Some((data, ani)) = player_anim_library(&vfs) else {
|
||||
eprintln!("пропуск: библиотека анимаций не разобрана");
|
||||
return;
|
||||
};
|
||||
let mdl = formats::mdl::Mdl::parse(&data).expect("разбор m_anm.mdl");
|
||||
let anims = mdl.animations().expect("анимации");
|
||||
let blocks = mdl.anim_blocks();
|
||||
let bones = mdl.bones().expect("кости");
|
||||
|
||||
// Самая длинная анимация — по ней и меряем.
|
||||
let anim = anims
|
||||
.iter()
|
||||
.max_by_key(|a| a.num_frames)
|
||||
.expect("хотя бы одна анимация");
|
||||
let frames = anim.num_frames.max(2) as usize;
|
||||
let start = blocks
|
||||
.get(anim.anim_block.max(0) as usize)
|
||||
.map(|b| b.0.max(0) as usize)
|
||||
.unwrap_or(0)
|
||||
+ anim.anim_index.max(0) as usize;
|
||||
let tracks = formats::mdl::Mdl::animation_tracks(&ani, start);
|
||||
|
||||
// Флаги дорожек: какие способы кодирования реально встречаются.
|
||||
let mut flag_histogram: std::collections::BTreeMap<u8, usize> =
|
||||
std::collections::BTreeMap::new();
|
||||
for track in &tracks {
|
||||
*flag_histogram.entry(track.flags).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let (mut max_euler, mut max_pos_delta) = (0f32, 0f32);
|
||||
let (mut euler_checks, mut pos_checks) = (0usize, 0usize);
|
||||
for track in &tracks {
|
||||
let Some(b) = bones.get(track.bone as usize) else {
|
||||
continue;
|
||||
};
|
||||
for frame in (0..frames).step_by(3) {
|
||||
if track.flags & 0x08 != 0 {
|
||||
// Канал поворота — угол Эйлера: `сырое · rotscale + поза покоя`.
|
||||
let mut euler = b.rot;
|
||||
for (j, channel) in track.channels.iter().take(3).enumerate() {
|
||||
if let Some(off) = channel {
|
||||
let raw = formats::mdl::Mdl::extract_anim_value(&ani, *off, frame);
|
||||
euler[j] = raw * b.rot_scale[j] + b.rot[j];
|
||||
}
|
||||
}
|
||||
let q = formats::mdl::Mdl::angle_quaternion(euler[0], euler[1], euler[2]);
|
||||
let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-4, "кватернион не единичный: {len}");
|
||||
max_euler = max_euler.max(euler[0].abs().max(euler[1].abs()).max(euler[2].abs()));
|
||||
euler_checks += 1;
|
||||
}
|
||||
if track.flags & 0x04 != 0 {
|
||||
// Канал позиции: `сырое · posscale + поза покоя`.
|
||||
let mut pos = b.pos;
|
||||
for (j, channel) in track.channels.iter().skip(3).take(3).enumerate() {
|
||||
if let Some(off) = channel {
|
||||
let raw = formats::mdl::Mdl::extract_anim_value(&ani, *off, frame);
|
||||
pos[j] = raw * b.pos_scale[j] + b.pos[j];
|
||||
}
|
||||
}
|
||||
let d = [pos[0] - b.pos[0], pos[1] - b.pos[1], pos[2] - b.pos[2]];
|
||||
max_pos_delta = max_pos_delta.max((d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt());
|
||||
pos_checks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Позиции костей в файле: локальные (от родителя) или уже мировые?
|
||||
// Локальные — все смещения мелкие; мировые — растут к конечностям.
|
||||
let bone_offsets: Vec<(u8, String, [f32; 3], i32)> = bones
|
||||
.iter()
|
||||
.take(6)
|
||||
.enumerate()
|
||||
.map(|(i, b)| (i as u8, b.name.chars().take(18).collect(), b.pos, b.parent))
|
||||
.collect();
|
||||
|
||||
eprintln!(
|
||||
"анимация {}: кадров {}, дорожек {}, флаги {flag_histogram:?}",
|
||||
anim.name,
|
||||
frames,
|
||||
tracks.len()
|
||||
);
|
||||
eprintln!(
|
||||
"эйлер по костям: макс. |угол| {max_euler:.2} рад на {euler_checks} проверках; \
|
||||
позиции: макс. сдвиг от позы покоя {max_pos_delta:.1} на {pos_checks} проверках"
|
||||
);
|
||||
eprintln!("кости 0..5 (индекс, имя, pos, parent): {bone_offsets:?}");
|
||||
assert!(
|
||||
tracks.len() > 10,
|
||||
"дорожек мало: {} — вероятно, смещение данных неверно",
|
||||
tracks.len()
|
||||
);
|
||||
assert!(max_euler < 12.0, "углы неправдоподобны: {max_euler}");
|
||||
}
|
||||
|
||||
/// Позиции вершин экземпляра модели.
|
||||
fn instance_positions(world: &engine::World, index: usize) -> Vec<[f32; 3]> {
|
||||
let instance = &world.model_instances[index];
|
||||
let first = instance.first_vertex as usize;
|
||||
let count = instance.vertex_count as usize;
|
||||
world
|
||||
.vertices
|
||||
.get(first..first + count)
|
||||
.map(|slice| slice.iter().map(|v| v.pos).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Сквозная проверка: модель игрока, поза из настоящих кадров, вершины.
|
||||
///
|
||||
/// Критерии поведения, а не реализации: поза другого кадра двигает вершины,
|
||||
/// но не разбрасывает их (модель остаётся компактной) — иначе нормировка или
|
||||
/// сборка костей врут.
|
||||
#[test]
|
||||
fn real_player_model_animation_moves_vertices() {
|
||||
let Some(root) = assets_root() else {
|
||||
eprintln!("пропуск: ассеты не найдены");
|
||||
return;
|
||||
};
|
||||
let map = root.join("garrysmod/maps/gm_construct.bsp");
|
||||
if !map.is_file() {
|
||||
eprintln!("пропуск: нет {}", map.display());
|
||||
return;
|
||||
}
|
||||
let mut vfs = mount_game(&root);
|
||||
let bsp = formats::bsp::Bsp::from_path(&map).expect("разбор карты");
|
||||
if let Some(pak) = bsp.pakfile_zip().expect("pakfile") {
|
||||
vfs.mount_pak(pak);
|
||||
}
|
||||
let mut world = engine::build_world(&bsp, &vfs).expect("сборка мира");
|
||||
|
||||
let origin = math::Vec3::new(0.0, 0.0, 64.0);
|
||||
let angles = math::Vec3::ZERO;
|
||||
let mut budget = 512 * 1024 * 1024;
|
||||
let index = world
|
||||
.spawn_model_instance(
|
||||
&vfs,
|
||||
"models/player/police.mdl",
|
||||
origin,
|
||||
angles,
|
||||
&mut budget,
|
||||
)
|
||||
.expect("модель игрока добавлена");
|
||||
|
||||
// Первый кадр: поза из библиотеки анимаций, имя без `@`.
|
||||
assert!(
|
||||
world.play_model_animation(index, origin, angles, "idle_all_01", 0.0),
|
||||
"анимация не найдена"
|
||||
);
|
||||
let first = instance_positions(&world, index);
|
||||
assert!(!first.is_empty(), "вершин нет");
|
||||
|
||||
// Кадр через секунду (30 кадров): поза обязана отличаться.
|
||||
assert!(world.play_model_animation(index, origin, angles, "idle_all_01", 1.0));
|
||||
let second = instance_positions(&world, index);
|
||||
let moved = first
|
||||
.iter()
|
||||
.zip(second.iter())
|
||||
.filter(|(a, b)| {
|
||||
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
moved > 50,
|
||||
"поза двух кадров слишком похожа: сдвинулось {moved} вершин"
|
||||
);
|
||||
|
||||
// Модель остаётся компактной: игрок — не разлетающийся скелет.
|
||||
let mut extent = [0f32; 3];
|
||||
for v in second.iter() {
|
||||
extent[0] = extent[0].max((v[0] - origin.x).abs());
|
||||
extent[1] = extent[1].max((v[1] - origin.y).abs());
|
||||
extent[2] = extent[2].max((v[2] - origin.z).abs());
|
||||
}
|
||||
eprintln!(
|
||||
"модель игрока: {} вершин, сдвинулось {moved}, габарит от центра {:?}",
|
||||
second.len(),
|
||||
extent
|
||||
);
|
||||
assert!(
|
||||
extent[0] < 120.0 && extent[1] < 120.0 && extent[2] < 120.0,
|
||||
"вершины разлетелись: {extent:?}"
|
||||
);
|
||||
|
||||
// Распределение, а не только максимум: сломанный скиннинг собирает массу
|
||||
// вершин в комок, и максимум этого не показывает.
|
||||
let mut sorted: Vec<f32> = second.iter().map(|v| v[2]).collect();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
let percentile = |p: f32| -> f32 {
|
||||
let index = ((sorted.len() - 1) as f32 * p) as usize;
|
||||
sorted.get(index).copied().unwrap_or_default()
|
||||
};
|
||||
// Поза покоя для сравнения: тот же экземпляр без анимации.
|
||||
assert!(world.move_model_instance(index, origin, angles));
|
||||
let bind = instance_positions(&world, index);
|
||||
let mut bind_z: Vec<f32> = bind.iter().map(|v| v[2]).collect();
|
||||
bind_z.sort_by(f32::total_cmp);
|
||||
let bind_p = |p: f32| -> f32 {
|
||||
let index = ((bind_z.len() - 1) as f32 * p) as usize;
|
||||
bind_z.get(index).copied().unwrap_or_default()
|
||||
};
|
||||
eprintln!(
|
||||
"высота (z) позы: 5% {:.1}, 50% {:.1}, 95% {:.1}; позы покоя: 5% {:.1}, 50% {:.1}, 95% {:.1}",
|
||||
percentile(0.05),
|
||||
percentile(0.5),
|
||||
percentile(0.95),
|
||||
bind_p(0.05),
|
||||
bind_p(0.5),
|
||||
bind_p(0.95)
|
||||
);
|
||||
let animated_height = percentile(0.95) - percentile(0.05);
|
||||
let bind_height = bind_p(0.95) - bind_p(0.05);
|
||||
assert!(
|
||||
animated_height > bind_height * 0.6,
|
||||
"поза схлопнулась: тело {animated_height:.1} против {bind_height:.1} в позе покоя"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,21 +59,15 @@ pub enum MdlError {
|
|||
Kv(#[from] crate::kv::KvError),
|
||||
}
|
||||
|
||||
/// Значение канала дорожки: либо константа, либо смещение данных в `.ani`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TrackValue {
|
||||
/// Постоянное значение (Quaternion48 и подобное).
|
||||
Constant(f32),
|
||||
/// Дорожка `mstudioanimvalue_t` по смещению от начала данных.
|
||||
Encoded(usize),
|
||||
}
|
||||
|
||||
/// Дорожка одной кости: шесть каналов (поворот xyz, позиция xyz).
|
||||
///
|
||||
/// Канал — смещение блока значений (`mstudioanimvalue_t`) в файле кадров;
|
||||
/// `None` — канал отсутствует и берётся поза покоя кости.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimTrack {
|
||||
pub bone: u8,
|
||||
pub flags: u8,
|
||||
pub channels: [Option<TrackValue>; 6],
|
||||
pub channels: [Option<usize>; 6],
|
||||
}
|
||||
|
||||
/// Описание анимации (`mstudioanimdesc_t`).
|
||||
|
|
@ -90,6 +84,9 @@ pub struct AnimDesc {
|
|||
pub anim_index: i32,
|
||||
pub num_movements: i32,
|
||||
pub movement_index: i32,
|
||||
/// Смещение самой записи в файле MDL: для анимаций внутри модели
|
||||
/// (`anim_block == 0`) кадры лежат от него (`mstudioanimdesc_t::pAnim`).
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Секвенция анимации (`mstudioseqdesc_t`).
|
||||
|
|
@ -609,6 +606,7 @@ impl<'a> Mdl<'a> {
|
|||
movement_index: i32_at(self.data, base + 24).unwrap_or(0),
|
||||
anim_block: i32_at(self.data, base + 52).unwrap_or(0),
|
||||
anim_index: i32_at(self.data, base + 56).unwrap_or(0),
|
||||
offset: base,
|
||||
});
|
||||
}
|
||||
if ok && out.len() == count {
|
||||
|
|
@ -622,10 +620,9 @@ impl<'a> Mdl<'a> {
|
|||
///
|
||||
/// `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки
|
||||
/// связаны списком; `nextoffset == 0` — конец. Значения каналов лежат как
|
||||
/// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t` —
|
||||
/// дельтами с накоплением: `valid` значений подряд, остальные повторяют
|
||||
/// предыдущее.
|
||||
pub fn animation_tracks(&self, data: &[u8], start: usize) -> Vec<AnimTrack> {
|
||||
/// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`;
|
||||
/// см. `extract_anim_value`.
|
||||
pub fn animation_tracks(data: &[u8], start: usize) -> Vec<AnimTrack> {
|
||||
let mut out = Vec::new();
|
||||
let mut offset = start;
|
||||
while offset + 4 <= data.len() && out.len() < 512 {
|
||||
|
|
@ -634,38 +631,31 @@ impl<'a> Mdl<'a> {
|
|||
let next = i16::from_le_bytes([data[offset + 2], data[offset + 3]]) as i32;
|
||||
let value_base = offset + 4;
|
||||
let mut channels = [None; 6];
|
||||
// Поворот: RAWROT (Quaternion48) или ANIMROT (две дорожки xyz).
|
||||
let rot_ptr = flags & 0x02 != 0 || flags & 0x08 != 0;
|
||||
let pos_ptr = flags & 0x04 != 0;
|
||||
if flags & 0x02 != 0 {
|
||||
// Quaternion48: три short (x, y, z), w считается из длины.
|
||||
if value_base + 6 <= data.len() {
|
||||
for j in 0..3 {
|
||||
let v = i16::from_le_bytes([
|
||||
data[value_base + j * 2],
|
||||
data[value_base + j * 2 + 1],
|
||||
]);
|
||||
channels[j] = Some(TrackValue::Constant(v as f32 / 32768.0));
|
||||
}
|
||||
}
|
||||
} else if rot_ptr && value_base + 6 <= data.len() {
|
||||
// Поворот: дорожки ANIMROT (три канала углов). RAWROT (готовый
|
||||
// Quaternion48) и RAWROT2 (Quaternion64) пока не разбираем — тогда
|
||||
// каналов нет и вызывающий остаётся на позе покоя.
|
||||
let anim_rot = flags & 0x08 != 0;
|
||||
let anim_pos = flags & 0x04 != 0;
|
||||
if anim_rot && value_base + 6 <= data.len() {
|
||||
for j in 0..3 {
|
||||
let off = i16::from_le_bytes([
|
||||
data[value_base + j * 2],
|
||||
data[value_base + j * 2 + 1],
|
||||
]);
|
||||
if off > 0 {
|
||||
channels[j] = Some(TrackValue::Encoded(value_base + off as usize));
|
||||
channels[j] = Some(value_base + off as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pos_base = value_base + if rot_ptr { 6 } else { 0 };
|
||||
if pos_ptr && pos_base + 6 <= data.len() {
|
||||
// `pPosV()`: указатель на позиции идёт сразу за указателем поворота
|
||||
// (по три short), и только когда поворот задан дорожками.
|
||||
let pos_base = value_base + if anim_rot { 6 } else { 0 };
|
||||
if anim_pos && pos_base + 6 <= data.len() {
|
||||
for j in 0..3 {
|
||||
let off =
|
||||
i16::from_le_bytes([data[pos_base + j * 2], data[pos_base + j * 2 + 1]]);
|
||||
if off > 0 {
|
||||
channels[3 + j] = Some(TrackValue::Encoded(pos_base + off as usize));
|
||||
channels[3 + j] = Some(pos_base + off as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -682,57 +672,55 @@ impl<'a> Mdl<'a> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Значение канала из `mstudioanimvalue_t` на кадре `frame`.
|
||||
/// Значение канала на кадре (SDK: `ExtractAnimValue` из `bone_setup.cpp`).
|
||||
///
|
||||
/// Формат: `valid`, `total`, затем `total` short-значений — дельтами с
|
||||
/// накоплением; когда `valid < total` и `valid == 0`, остальные повторяют
|
||||
/// предыдущее накопленное значение.
|
||||
pub fn decode_track(data: &[u8], start: usize, frame: usize, total_frames: usize) -> f32 {
|
||||
let mut value = 0.0f32;
|
||||
let mut offset = start;
|
||||
let mut current = 0usize;
|
||||
while offset + 2 <= data.len() && current <= frame && current < total_frames.max(1) {
|
||||
let valid = data[offset] as usize;
|
||||
let total = data[offset + 1] as usize;
|
||||
/// Блок значений: байт `valid` (сколько значений записано), байт `total`
|
||||
/// (сколько кадров покрывает блок), затем `valid` short-значений. Если кадр
|
||||
/// дальше `valid`, повторяется последнее значение блока; следующий блок
|
||||
/// лежит через `valid + 1` двухбайтовых записей. Значения **не
|
||||
/// накапливаются**: каждый short — угол или сдвиг, масштаб кости
|
||||
/// применяет вызывающий (`rotscale` / `posscale`).
|
||||
pub fn extract_anim_value(data: &[u8], mut offset: usize, mut frame: usize) -> f32 {
|
||||
// Потолок на число блоков: данные из файла, без доверия к ним.
|
||||
for _ in 0..4096 {
|
||||
let (Some(&valid), Some(&total)) = (data.get(offset), data.get(offset + 1)) else {
|
||||
return 0.0;
|
||||
};
|
||||
let (valid, total) = (valid as usize, total as usize);
|
||||
if total == 0 {
|
||||
break;
|
||||
return 0.0;
|
||||
}
|
||||
offset += 2;
|
||||
let count = valid.min(total);
|
||||
for i in 0..total {
|
||||
if i < count {
|
||||
if offset + 2 > data.len() {
|
||||
return value;
|
||||
}
|
||||
let delta = i16::from_le_bytes([data[offset], data[offset + 1]]) as f32;
|
||||
offset += 2;
|
||||
value += delta;
|
||||
}
|
||||
if current == frame {
|
||||
return value;
|
||||
}
|
||||
current += 1;
|
||||
if total > frame {
|
||||
let at = if valid > frame {
|
||||
offset + 2 + frame * 2
|
||||
} else {
|
||||
// `panimvalue[valid]`: при valid == 0 это сам заголовок.
|
||||
offset + valid * 2
|
||||
};
|
||||
let Some(pair) = data.get(at..at + 2) else {
|
||||
return 0.0;
|
||||
};
|
||||
return i16::from_le_bytes([pair[0], pair[1]]) as f32;
|
||||
}
|
||||
frame -= total;
|
||||
offset += (valid + 1) * 2;
|
||||
}
|
||||
value
|
||||
0.0
|
||||
}
|
||||
|
||||
/// Кватернион поворота из трёх каналов дорожки.
|
||||
/// Кватернион из углов Эйлера (SDK: `AngleQuaternion`).
|
||||
///
|
||||
/// Нормировка выбрана замером, а не на глаз: сырые значения ANIMROT —
|
||||
/// шорты, и у кватерниона модуль вектора поворота не может быть больше
|
||||
/// единицы. Для кости 0 библиотеки GMod сырое 17013 даёт 0.52 при делении
|
||||
/// на 32768, а умножение на `rot_scale` кости давало 1.63 — для
|
||||
/// кватерниона невозможно. Поэтому делим на 32768, а результат
|
||||
/// нормируем.
|
||||
pub fn quaternion_from_channels(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
let x = (x / 32768.0).clamp(-1.0, 1.0);
|
||||
let y = (y / 32768.0).clamp(-1.0, 1.0);
|
||||
let z = (z / 32768.0).clamp(-1.0, 1.0);
|
||||
let w2 = 1.0 - x * x - y * y - z * z;
|
||||
let w = if w2 > 0.0 { w2.sqrt() } else { 0.0 };
|
||||
let len = (x * x + y * y + z * z + w * w).sqrt().max(1e-6);
|
||||
[x / len, y / len, z / len, w / len]
|
||||
/// Углы в радианах: x — крен, y — тангаж, z — рыскание.
|
||||
pub fn angle_quaternion(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
let (sr, cr) = (x * 0.5).sin_cos();
|
||||
let (sp, cp) = (y * 0.5).sin_cos();
|
||||
let (sy, cy) = (z * 0.5).sin_cos();
|
||||
[
|
||||
sr * cp * cy - cr * sp * sy,
|
||||
cr * sp * cy + sr * cp * sy,
|
||||
cr * cp * sy - sr * sp * cy,
|
||||
cr * cp * cy + sr * sp * sy,
|
||||
]
|
||||
}
|
||||
|
||||
/// Одна секвенция по смещению. `None` — данные не читаются.
|
||||
|
|
@ -1066,20 +1054,43 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn quaternion_from_channels_is_unit_and_sane() {
|
||||
use super::Mdl;
|
||||
// Нулевые каналы — единичный кватернион (поза покоя).
|
||||
let q = Mdl::quaternion_from_channels(0.0, 0.0, 0.0);
|
||||
assert!((q[3] - 1.0).abs() < 1e-6, "w единичного: {q:?}");
|
||||
// Полуоборот вокруг Z: z = 32768/32768 → 1 (с клампом), w = 0.
|
||||
let q = Mdl::quaternion_from_channels(0.0, 0.0, 32768.0);
|
||||
assert!((q[2].abs() - 1.0).abs() < 1e-3, "z полуоборота: {q:?}");
|
||||
assert!(q[3].abs() < 1e-3, "w полуоборота: {q:?}");
|
||||
// Модуль всегда единичный — это и есть критерий замера.
|
||||
for raw in [1.0f32, 17013.0, -5000.0, 40000.0] {
|
||||
let q = Mdl::quaternion_from_channels(raw, raw / 2.0, -raw / 3.0);
|
||||
fn extract_anim_value_walks_blocks() {
|
||||
// Блок: valid = 3, total = 5, значения 10, 20, 30; затем блок
|
||||
// valid = 1, total = 2, значение 40.
|
||||
let data = [
|
||||
3u8, 5, 10, 0, 20, 0, 30, 0, // кадры 0..4
|
||||
1, 2, 40, 0, // кадры 5..6
|
||||
];
|
||||
let value = |frame: usize| Mdl::extract_anim_value(&data, 0, frame);
|
||||
for (frame, want) in [(0usize, 10.0f32), (1, 20.0), (2, 30.0)] {
|
||||
assert!((value(frame) - want).abs() < 1e-6, "кадр {frame}");
|
||||
}
|
||||
// Дальше valid кончился — повтор последнего значения блока.
|
||||
assert!((value(3) - 30.0).abs() < 1e-6);
|
||||
assert!((value(4) - 30.0).abs() < 1e-6);
|
||||
// Следующий блок лежит через valid + 1 записей.
|
||||
assert!((value(5) - 40.0).abs() < 1e-6);
|
||||
assert!((value(6) - 40.0).abs() < 1e-6);
|
||||
// За концом данных — ноль, без паники (данные из файла).
|
||||
assert!(value(1000).abs() < 1e-6);
|
||||
assert!(Mdl::extract_anim_value(&[], 0, 0).abs() < 1e-6);
|
||||
assert!(Mdl::extract_anim_value(&data, 10_000, 0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angle_quaternion_is_unit() {
|
||||
// Нулевые углы — единичный кватернион.
|
||||
let q = Mdl::angle_quaternion(0.0, 0.0, 0.0);
|
||||
assert!((q[3] - 1.0).abs() < 1e-6, "{q:?}");
|
||||
// Рыскание 180° — z = 1, w = 0.
|
||||
let q = Mdl::angle_quaternion(0.0, 0.0, std::f32::consts::PI);
|
||||
assert!((q[2] - 1.0).abs() < 1e-6, "{q:?}");
|
||||
assert!(q[3].abs() < 1e-6, "{q:?}");
|
||||
// Модуль единичный на любых углах.
|
||||
for (x, y, z) in [(0.3f32, -1.2, 2.5), (3.0, 0.0, -0.7)] {
|
||||
let q = Mdl::angle_quaternion(x, y, z);
|
||||
let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-4, "модуль {raw}: {len}");
|
||||
assert!((len - 1.0).abs() < 1e-5, "модуль: {len}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3225,7 +3225,48 @@ GPU» работает целиком. Пока это не анимация: н
|
|||
вида 17013, 1424, −874 (шорты, которые делятся на масштаб кости при сборке
|
||||
кватерниона).
|
||||
|
||||
**Замер по масштабу кости (первый шаг следующего этапа).** У кватерниона модуль
|
||||
**Сделано: проигрывание кадров работает.** Спека взята из настоящего
|
||||
`bone_setup.cpp` (Mapbase-зеркало SDK 2013, `mp/src/public/bone_setup.cpp`) —
|
||||
предыдущие догадки были неверны, и это стоит помнить:
|
||||
|
||||
- каналы дорожек — **не компоненты кватерниона**, а **углы Эйлера**: значение
|
||||
кадра `raw · rotscale[j] + поза покоя (bone.rot)`, затем `AngleQuaternion`;
|
||||
- позиции — `raw · posscale[j] + bone.pos`;
|
||||
- значения блоков **не накапливаются дельтами**: `ExtractAnimValue` идёт по
|
||||
блокам `valid+1` записей и внутри блока берёт `value[k]`, а дальше повторяет
|
||||
последнее (`value[valid]`);
|
||||
- `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки — список;
|
||||
- флаг `STUDIO_ANIM_DELTA` (0x10) означает «каналы относительные»: поза покоя
|
||||
не прибавляется.
|
||||
|
||||
Замеры на живых данных (тесты `real_player_animation_channel_scales`,
|
||||
`real_models_animation_sources`, `real_player_model_animation_moves_vertices`):
|
||||
|
||||
- `@taunt_muscle_base` (451 кадр, 53 дорожки): углы ≤ 4,75 рад, сдвиги позиций
|
||||
≤ 28,5 юнитов, кватернионы единичные — при новой спеке **ни одного нарушения**;
|
||||
- кости локальны родителю (цепочка 4,0 / 3,5 / 8,9 юнитов), поза покоя корня —
|
||||
таз на высоте 38,6;
|
||||
- `police.mdl` — 57 костей, своя одна анимация `@ragdoll` (кадры в MDL,
|
||||
`anim_block == 0`); `m_anm.mdl` — 56 костей, 1563 анимации (кадры в
|
||||
`models/m_anm.ani`, 7,1 МБ, 247 блоков); у пистолета — 3 свои анимации в MDL;
|
||||
- модель игрока, анимация `idle_all_01`: из 3147 вершин **1101 сдвинулась**
|
||||
между кадрами 0 и 30, высота тела (5–95%) 61 юнит и в позе покоя, и в анимации
|
||||
— модель не схлопывается.
|
||||
|
||||
В движке: `AnimData` (буферы кадров + описания + скелет, живёт под `Arc`),
|
||||
`frame_pose` (позы костей по кадрам с блендом), `play_model_animation`
|
||||
(мировые позы через родителей, `A · B⁻¹` по костям, скиннинг теми же весами),
|
||||
кости меша находятся в скелете анимации **по именам**.
|
||||
|
||||
В клиенте: вьюмодель играет `idle` (свои кадры), модель игрока — `idle_all_01`;
|
||||
добавлен вид со стороны `--third-person` — без него собственную модель не видно
|
||||
(в первом лице камера внутри неё, и она уезжает под карту).
|
||||
|
||||
**Остаток по A26:** флаги RAWROT/RAWROT2 (постоянный поворот дорожки) пока не
|
||||
разбираются — такие кости остаются в позе покоя (в `@taunt_muscle_base` 24
|
||||
дорожки из 53); выбор секвенции по состоянию (`ACT_MP_*`) и блендинг между ними.
|
||||
|
||||
**Замер по масштабу кости (историческая заметка).** У кватерниона модуль
|
||||
вектора поворота не больше единицы у **всех** костей и кадров — это и есть
|
||||
критерий выбора. Сырые значения `@idle_all_01`: у кости 0 — `[43, 1424, 17013]`
|
||||
при `rot_scale = [6,37e−5, 4,66e−5, 9,59e−5]`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue