A26: кадры анимации играют — модель игрока и вьюмодель
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:
Niko Marmeladkov 2026-10-17 12:37:45 +03:00
parent 49ff6da4ac
commit 8a5f43dcd2
7 changed files with 802 additions and 183 deletions

View file

@ -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),

View file

@ -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();

View file

@ -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,

View file

@ -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,

View file

@ -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} в позе покоя"
);
}

View file

@ -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}");
}
}

View file

@ -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]`.