From 8a5f43dcd28e9e041b53df59fbb621340ad61890 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 17 Oct 2026 12:37:45 +0300 Subject: [PATCH] =?UTF-8?q?A26:=20=D0=BA=D0=B0=D0=B4=D1=80=D1=8B=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=E2=80=94=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спека декодера взята из настоящего 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) — против clippy::large_enum_variant. Все 23 сьюта и 29 картовых тестов зелёные. --- crates/client/src/main.rs | 4 +- crates/client/src/map.rs | 35 ++-- crates/engine/src/model.rs | 97 ++++++--- crates/engine/src/world.rs | 323 +++++++++++++++++++++++++----- crates/engine/tests/world_real.rs | 294 +++++++++++++++++++++++++++ crates/formats/src/mdl.rs | 189 +++++++++-------- docs/PARITY.md | 43 +++- 7 files changed, 802 insertions(+), 183 deletions(-) diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 345563e..42884be 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -39,7 +39,7 @@ enum Command { Menu(menu::MenuArgs), /// Загрузить карту и отрисовать её (полёт камеры) - Map(map::MapArgs), + Map(Box), /// Сетевые операции (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), diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 45b9390..c92c872 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -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, @@ -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, 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, 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, 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(); diff --git a/crates/engine/src/model.rs b/crates/engine/src/model.rs index c08a214..cced7e5 100644 --- a/crates/engine/src/model.rs +++ b/crates/engine/src/model.rs @@ -69,6 +69,8 @@ pub struct Model { pub sequences: Vec, /// Кости скелета. pub bones: Vec, + /// Данные анимаций: свои кадры и кадры включённых моделей (A26). + pub anim_data: Vec>, /// Привязка вершин к костям, по одной на `vertices`. pub skins: Vec, } @@ -111,6 +113,7 @@ pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result Result 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 { - 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> = 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, diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index c450d70..7ddd1f3 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -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 = 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> = 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, + /// Файл `.ani` — кадры вынесенных анимаций (у библиотек вроде `m_anm`). + pub ani: Option>, + /// Блоки анимационного файла: смещения данных. + pub blocks: Vec<(i32, i32)>, + /// Описания анимаций. + pub anims: Vec, + /// Скелет модели: позы покоя и масштабы каналов. + pub bones: Vec, +} + +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, /// Привязка вершин к костям, по одной на `positions`. pub skins: Vec, + /// Данные анимаций: своя модель и включённые библиотеки (A26). + pub anim: Vec>, /// Нормали в локальных координатах модели. 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, diff --git a/crates/engine/tests/world_real.rs b/crates/engine/tests/world_real.rs index 680f813..d1797d6 100644 --- a/crates/engine/tests/world_real.rs +++ b/crates/engine/tests/world_real.rs @@ -164,3 +164,297 @@ fn gm_flatgrass_displacements() { sky.vertices.len() ); } + +/// Читает MDL анимационной библиотеки игрока и её `.ani`. +fn player_anim_library(vfs: &VirtualFs) -> Option<(Vec, Vec)> { + 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 = 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 = + 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 = 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 = 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} в позе покоя" + ); +} diff --git a/crates/formats/src/mdl.rs b/crates/formats/src/mdl.rs index 8dab58f..d6a44c1 100644 --- a/crates/formats/src/mdl.rs +++ b/crates/formats/src/mdl.rs @@ -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; 6], + pub channels: [Option; 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 { + /// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`; + /// см. `extract_anim_value`. + pub fn animation_tracks(data: &[u8], start: usize) -> Vec { 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}"); } } diff --git a/docs/PARITY.md b/docs/PARITY.md index 15c70ff..ab52e17 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -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]`.