A26: постоянные дорожки RAW и выбор анимации по состоянию

- formats: Quaternion48/Quaternion64/Vector48 (RAWROT, RAWROT2, RAWPOS),
  приоритет постоянных значений в frame_pose;
- engine::animation: выбор idle/walk/run/crouch/jump по скорости, приседу,
  земле и восьми направлениям относительно взгляда, сохранение фазы walk↔run;
- клиент: модель игрока играет выбранное имя, вьюмодель качается на ходу,
  отстаёт при повороте и проигрывает fire по ЛКМ; центр анимированной позы
  компенсирует RAWPOS корня;
- тесты: RAW на v_pistol/m_anm, имена и проигрывание секвенций, чистый выбор
  и фаза; docs A26 обновлены.
This commit is contained in:
Niko Marmeladkov 2026-10-23 14:02:05 +03:00
parent f58eafeb46
commit d42d6464f6
8 changed files with 1312 additions and 55 deletions

View file

@ -498,6 +498,39 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// проверять физган кадром и скриптом, а не руками. // проверять физган кадром и скриптом, а не руками.
let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some(); let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some();
// Анимация вьюмодели (A26): покой и выстрел. Имена у моделей разные
// (`v_pistol` — `idle01`/`fire`, `w_pistol` — `idle`/`attack_player`),
// поэтому берём первую существующую.
let (view_idle_animation, view_fire_animation, view_fire_duration) =
if let Some(index) = view_model_instance {
let w = world.borrow();
let idle = ["idle01", "idle"]
.into_iter()
.find(|name| w.model_has_animation(index, name));
let fire = ["fire", "fire1", "attack_player"]
.into_iter()
.find(|name| w.model_has_animation(index, name));
let duration = fire
.and_then(|name| w.model_animation_duration(index, name))
.unwrap_or(0.4);
(idle, fire, duration)
} else {
(None, None, 0.4)
};
// Отсчёт одноразовой анимации выстрела: сбрасывается по её длительности.
let mut view_fire_time: Option<f32> = None;
// Покачивание вьюмодели при ходьбе и инерционный сдвиг при повороте
// (лёгкий аналог `cl_bob`/viewmodel sway из Source).
let mut view_bob_phase = 0.0f32;
let mut view_sway_right = 0.0f32;
let mut view_sway_up = 0.0f32;
let mut prev_view_yaw = camera.yaw;
let mut prev_view_pitch = camera.pitch;
// Анимация модели игрока по состоянию (A26): тип и фаза.
let mut player_animation = engine::AnimationState::default();
'running: loop { 'running: loop {
for event in events.poll_iter() { for event in events.poll_iter() {
match event { match event {
@ -539,6 +572,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
while log.len() > 8 { while log.len() > 8 {
log.pop_front(); log.pop_front();
} }
} else if view_fire_animation.is_some() {
// Под прицелом нет тела — ЛКМ стреляет: одноразовая
// анимация вьюмодели, без оружия и урона (A23).
view_fire_time = Some(0.0);
log.push_back("выстрел".to_string());
while log.len() > 8 {
log.pop_front();
}
} }
} }
Event::MouseButtonUp { Event::MouseButtonUp {
@ -664,6 +705,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let dt = last.elapsed().as_secs_f32().min(0.1); let dt = last.elapsed().as_secs_f32().min(0.1);
last = Instant::now(); last = Instant::now();
// Одноразовая анимация выстрела: тикает и гаснет по длительности.
if let Some(time) = view_fire_time {
let time = time + dt;
view_fire_time = (time < view_fire_duration).then_some(time);
}
// Физика идёт до ходьбы игрока: игрок должен упираться в уже // Физика идёт до ходьбы игрока: игрок должен упираться в уже
// сдвинутые пропы, а не в прошлые. // сдвинутые пропы, а не в прошлые.
wave_time += dt; wave_time += dt;
@ -788,6 +835,52 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
camera.pos = camera.pos - forward * 128.0 + math::Vec3::new(0.0, 0.0, 32.0); camera.pos = camera.pos - forward * 128.0 + math::Vec3::new(0.0, 0.0, 32.0);
} }
// Покачивание вьюмодели: амплитуда от фактической скорости, фаза — от
// времени ходьбы. Инерция при повороте: ствол отстаёт от камеры и
// плавно догоняет (как viewmodel sway в Source).
let horizontal_speed =
(player.velocity.x * player.velocity.x + player.velocity.y * player.velocity.y).sqrt();
let bob_amplitude = if player.on_ground && !player.noclip {
(horizontal_speed / engine::player::RUN_SPEED).clamp(0.0, 1.0)
} else {
0.0
};
view_bob_phase =
(view_bob_phase + dt * (6.0 + 6.0 * bob_amplitude)).rem_euclid(std::f32::consts::TAU);
let bob_right = view_bob_phase.sin() * 1.8 * bob_amplitude;
let bob_up = (view_bob_phase * 2.0).sin() * 1.2 * bob_amplitude;
let yaw_delta = (camera.yaw - prev_view_yaw + 540.0).rem_euclid(360.0) - 180.0;
let pitch_delta = camera.pitch - prev_view_pitch;
prev_view_yaw = camera.yaw;
prev_view_pitch = camera.pitch;
let sway_blend = 1.0 - (-dt * 12.0).exp();
view_sway_right += (yaw_delta * 0.03 - view_sway_right) * sway_blend;
view_sway_up += (pitch_delta * 0.03 - view_sway_up) * sway_blend;
view_sway_right = view_sway_right.clamp(-4.0, 4.0);
view_sway_up = view_sway_up.clamp(-3.0, 3.0);
// Состояние анимации модели игрока: стоя/шаг/бег/присед/воздух и
// направление относительно взгляда. Фаза живёт в `player_animation`.
let player_pose = player_model_instance.and_then(|index| {
let w = world.borrow();
player_animation.update(
dt,
engine::AnimationContext {
velocity: if player.noclip {
math::Vec3::ZERO
} else {
player.velocity
},
on_ground: player.noclip || player.on_ground,
ducked: player.ducked,
sprint: player.sprint,
yaw: camera.yaw,
},
|name| w.model_has_animation(index, name),
)
});
// Модели — после движения и камеры: вьюмодель обязана стоять на глазах // Модели — после движения и камеры: вьюмодель обязана стоять на глазах
// этого кадра, иначе она отстаёт при ходьбе и «плывёт» при повороте // этого кадра, иначе она отстаёт при ходьбе и «плывёт» при повороте
// (жалоба пользователя). Раньше это шло до `player.update`. // (жалоба пользователя). Раньше это шло до `player.update`.
@ -807,10 +900,30 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
angles.y, angles.y,
angles.z, angles.z,
); );
let origin = eye + forward * view_distance + right * 5.0 let lateral = 5.0 + bob_right + view_sway_right;
- math::Vec3::new(0.0, 0.0, 8.0) let vertical = -8.0 + bob_up + view_sway_up;
- centre; let target = eye
w.play_model_animation(index, origin, angles, "idle01", wave_time); + forward * view_distance
+ right * lateral
+ math::Vec3::new(0.0, 0.0, vertical);
let mut origin = target - centre;
// Выстрел — одноразовая анимация; иначе покой.
let (animation, time) = match (view_fire_animation, view_fire_time) {
(Some(name), Some(time)) => (name, time),
_ => (view_idle_animation.unwrap_or("idle01"), wave_time),
};
w.play_model_animation(index, origin, angles, animation, time);
// Анимация с RAWPOS корня сдвигает весь меш (у `v_pistol` —
// вниз на 68 юнитов): возвращаем центр позы в точку прицела,
// иначе ствол уезжает из кадра. Для моделей без такого сдвига
// поправка нулевая и второй проход не делается.
if let Some(posed_centre) = w.model_instance_center(index) {
let correction = target - posed_centre;
if correction.length_squared() > 1.0 {
origin += correction;
w.play_model_animation(index, origin, angles, animation, time);
}
}
} }
if let Some(index) = player_model_instance { if let Some(index) = player_model_instance {
let inside = camera.pos.distance(player.eye_position()) < 48.0; let inside = camera.pos.distance(player.eye_position()) < 48.0;
@ -819,13 +932,17 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
} else { } else {
player.position player.position
}; };
w.play_model_animation( let angles = math::Vec3::new(0.0, camera.yaw + 180.0, 0.0);
index, match &player_pose {
origin, Some(pose) => {
math::Vec3::new(0.0, camera.yaw + 180.0, 0.0), w.play_model_animation(index, origin, angles, &pose.name, pose.time);
"idle_all_01", }
wave_time, // Анимации нет — экземпляр всё равно обязан стоять на месте
); // игрока, иначе после движения останется в старой точке.
None => {
w.move_model_instance(index, origin, angles);
}
}
} }
moved_ranges = w.take_moved_ranges(); moved_ranges = w.take_moved_ranges();
moved_index_ranges = w.take_moved_index_ranges(); moved_index_ranges = w.take_moved_index_ranges();

View file

@ -0,0 +1,433 @@
//! Выбор анимации модели игрока по состоянию (A26).
//!
//! Состояние берётся из `Player` (скорость, земля/воздух, присед, бег), а
//! направление — из горизонтальной скорости относительно взгляда. Секвенции
//! GMod (`m_anm.mdl`) размечены по восьми направлениям: `a_WalkN`, `a_WalkNE`,
//! …, `a_RunN`, `a_CwalkN`. Клиент только проигрывает выбранное имя, поэтому
//! сам выбор — чистая функция и проверяется без ассетов.
use math::Vec3;
/// Ниже этой горизонтальной скорости игрок считается стоящим.
pub const MOVING_SPEED: f32 = 5.0;
/// Порог «это уже бег» для выбора `a_Run*` без флага `sprint`.
///
/// 250 — шаг, 520 — бег; середина разделяет их с запасом на разгон.
pub const RUN_ANIM_SPEED: f32 = 320.0;
/// Стоячая анимация (имя в библиотеке GMod — `@idle_all_01`).
pub const IDLE_ANIMATION: &str = "idle_all_01";
/// Стоячая анимация в приседе.
pub const CROUCH_IDLE_ANIMATION: &str = "@cidle_all";
/// Анимации прыжка: первая существующая в модели. У GMod они per-weapon, а
/// оружия в руках пока нет — начинаем с кулаков, затем пистолет, затем общая
/// дельта прыжка.
pub const AIR_ANIMATIONS: [&str; 3] = ["@jump_fist", "@jump_pistol", "a_gmod_jump_delta"];
/// Направление движения в системе модели (N — вперёд по взгляду).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MoveDirection {
#[default]
N,
NE,
E,
SE,
S,
SW,
W,
NW,
}
impl MoveDirection {
/// Все направления по часовой стрелке от «вперёд».
pub const ALL: [MoveDirection; 8] = [
MoveDirection::N,
MoveDirection::NE,
MoveDirection::E,
MoveDirection::SE,
MoveDirection::S,
MoveDirection::SW,
MoveDirection::W,
MoveDirection::NW,
];
/// Суффикс анимации GMod (`a_WalkN`, `a_WalkNE`, …).
pub fn suffix(self) -> &'static str {
match self {
MoveDirection::N => "N",
MoveDirection::NE => "NE",
MoveDirection::E => "E",
MoveDirection::SE => "SE",
MoveDirection::S => "S",
MoveDirection::SW => "SW",
MoveDirection::W => "W",
MoveDirection::NW => "NW",
}
}
/// Направление горизонтальной скорости относительно взгляда (`yaw`, как в
/// Source: 0 — +X, +Y — влево).
///
/// Берём угол между вектором скорости и «вперёд» и округляем до 45°:
/// сектор 0 — вперёд (N), +1 — влево-вперёд (NW), −1 — вправо-вперёд (NE).
/// Так стрейф вправо даёт `E`, влево — `W`.
pub fn from_velocity(velocity: Vec3, yaw: f32) -> Self {
let (x, y) = (velocity.x, velocity.y);
if x * x + y * y < 1e-6 {
return MoveDirection::N;
}
let relative = y.atan2(x) - yaw.to_radians();
let sector = (relative / std::f32::consts::FRAC_PI_4).round() as i32;
// Целые сектора: 0 N, 1 NW, 2 W, 3 SW, 4 S, 5 SE, 6 E, 7 NE.
match sector.rem_euclid(8) {
0 => MoveDirection::N,
1 => MoveDirection::NW,
2 => MoveDirection::W,
3 => MoveDirection::SW,
4 => MoveDirection::S,
5 => MoveDirection::SE,
6 => MoveDirection::E,
_ => MoveDirection::NE,
}
}
}
/// Тип движения — определяет и имя секвенции, и момент сброса фазы.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnimationKind {
Idle,
Walk,
Run,
CrouchIdle,
CrouchWalk,
Air,
}
impl AnimationKind {
/// Группа фазы: walk ↔ run продолжают анимацию с того же места (у них
/// совпадает цикл шага), остальные типы начинают с нуля.
fn phase_group(self) -> u8 {
match self {
AnimationKind::Idle => 0,
AnimationKind::Walk | AnimationKind::Run => 1,
AnimationKind::CrouchIdle => 2,
AnimationKind::CrouchWalk => 3,
AnimationKind::Air => 4,
}
}
/// Имя анимации по типу и направлению.
pub fn animation_name(self, direction: MoveDirection) -> String {
match self {
AnimationKind::Idle => IDLE_ANIMATION.to_string(),
AnimationKind::CrouchIdle => CROUCH_IDLE_ANIMATION.to_string(),
AnimationKind::Walk => format!("a_Walk{}", direction.suffix()),
AnimationKind::Run => format!("a_Run{}", direction.suffix()),
AnimationKind::CrouchWalk => format!("a_Cwalk{}", direction.suffix()),
AnimationKind::Air => AIR_ANIMATIONS[0].to_string(),
}
}
}
/// Всё, что нужно для выбора анимации из состояния игрока.
#[derive(Debug, Clone, Copy)]
pub struct AnimationContext {
pub velocity: Vec3,
pub on_ground: bool,
pub ducked: bool,
pub sprint: bool,
/// Курс взгляда в градусах (Source: 0 — +X, +Y — влево).
pub yaw: f32,
}
/// Результат выбора: что играть и в какую сторону.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationChoice {
pub kind: AnimationKind,
pub direction: MoveDirection,
pub name: String,
}
/// Выбирает анимацию по состоянию игрока.
pub fn select_animation(context: AnimationContext) -> AnimationChoice {
let speed =
(context.velocity.x * context.velocity.x + context.velocity.y * context.velocity.y).sqrt();
let moving = speed > MOVING_SPEED;
let direction = MoveDirection::from_velocity(context.velocity, context.yaw);
let kind = if !context.on_ground {
AnimationKind::Air
} else if context.ducked {
if moving {
AnimationKind::CrouchWalk
} else {
AnimationKind::CrouchIdle
}
} else if moving {
if context.sprint || speed >= RUN_ANIM_SPEED {
AnimationKind::Run
} else {
AnimationKind::Walk
}
} else {
AnimationKind::Idle
};
AnimationChoice {
kind,
direction,
name: kind.animation_name(direction),
}
}
/// Что проигрывать в этом кадре и с каким временем.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationPose {
pub name: String,
pub time: f32,
}
/// Состояние проигрывания: выбор по кадру и сохранение фазы.
#[derive(Debug, Clone, Default)]
pub struct AnimationState {
kind: Option<AnimationKind>,
name: String,
time: f32,
}
impl AnimationState {
/// Обновляет выбор на `dt` секунд.
///
/// `available` сообщает, есть ли анимация в модели: если выбранной нет,
/// остаётся текущая (для прыжка перебираются `AIR_ANIMATIONS`). Возвращает
/// `None`, только когда играть нечего вовсе.
pub fn update<F>(
&mut self,
dt: f32,
context: AnimationContext,
available: F,
) -> Option<AnimationPose>
where
F: Fn(&str) -> bool,
{
let dt = dt.max(0.0);
let choice = select_animation(context);
let mut name = choice.name;
if choice.kind == AnimationKind::Air {
match AIR_ANIMATIONS.iter().find(|candidate| available(candidate)) {
Some(candidate) => name = (*candidate).to_string(),
None => {
// Прыжковой анимации нет — оставляем текущую, фазу не рвём.
if self.name.is_empty() {
return None;
}
self.time += dt;
return Some(AnimationPose {
name: self.name.clone(),
time: self.time,
});
}
}
} else if !available(&name) {
if self.name.is_empty() {
return None;
}
self.time += dt;
return Some(AnimationPose {
name: self.name.clone(),
time: self.time,
});
}
let reset = self
.kind
.is_none_or(|kind| kind.phase_group() != choice.kind.phase_group());
if reset {
self.time = 0.0;
}
self.time += dt;
self.kind = Some(choice.kind);
self.name = name.clone();
Some(AnimationPose {
name,
time: self.time,
})
}
/// Текущее имя анимации (пусто, пока ничего не выбрано).
pub fn name(&self) -> &str {
&self.name
}
/// Текущая фаза в секундах.
pub fn time(&self) -> f32 {
self.time
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(
velocity: Vec3,
on_ground: bool,
ducked: bool,
sprint: bool,
yaw: f32,
) -> AnimationContext {
AnimationContext {
velocity,
on_ground,
ducked,
sprint,
yaw,
}
}
fn choice(
velocity: Vec3,
on_ground: bool,
ducked: bool,
sprint: bool,
yaw: f32,
) -> AnimationChoice {
select_animation(context(velocity, on_ground, ducked, sprint, yaw))
}
#[test]
fn standing_is_idle() {
let picked = choice(Vec3::ZERO, true, false, false, 0.0);
assert_eq!(picked.kind, AnimationKind::Idle);
assert_eq!(picked.name, IDLE_ANIMATION);
}
#[test]
fn walking_and_running_forward() {
let walk = choice(Vec3::new(200.0, 0.0, 0.0), true, false, false, 0.0);
assert_eq!(walk.kind, AnimationKind::Walk);
assert_eq!(walk.name, "a_WalkN");
let run = choice(Vec3::new(400.0, 0.0, 0.0), true, false, false, 0.0);
assert_eq!(run.kind, AnimationKind::Run);
assert_eq!(run.name, "a_RunN");
// Флаг бега важнее фактической скорости (разгон ещё не закончен).
let sprint = choice(Vec3::new(260.0, 0.0, 0.0), true, false, true, 0.0);
assert_eq!(sprint.name, "a_RunN");
}
#[test]
fn directions_are_relative_to_view() {
// Смотрим на +X: вперёд N, влево W, вправо E, назад S.
assert_eq!(
MoveDirection::from_velocity(Vec3::new(100.0, 0.0, 0.0), 0.0),
MoveDirection::N
);
assert_eq!(
MoveDirection::from_velocity(Vec3::new(0.0, 100.0, 0.0), 0.0),
MoveDirection::W
);
assert_eq!(
MoveDirection::from_velocity(Vec3::new(0.0, -100.0, 0.0), 0.0),
MoveDirection::E
);
assert_eq!(
MoveDirection::from_velocity(Vec3::new(-100.0, 0.0, 0.0), 0.0),
MoveDirection::S
);
assert_eq!(
MoveDirection::from_velocity(Vec3::new(100.0, -100.0, 0.0), 0.0),
MoveDirection::NE
);
assert_eq!(
MoveDirection::from_velocity(Vec3::new(100.0, 100.0, 0.0), 0.0),
MoveDirection::NW
);
assert_eq!(
MoveDirection::from_velocity(Vec3::new(-100.0, -100.0, 0.0), 0.0),
MoveDirection::SE
);
// При курсе 90° вперёд — это +Y, а не +X.
assert_eq!(
MoveDirection::from_velocity(Vec3::new(0.0, 100.0, 0.0), 90.0),
MoveDirection::N
);
// Стрейф вправо при курсе 90° — +X.
assert_eq!(
MoveDirection::from_velocity(Vec3::new(100.0, 0.0, 0.0), 90.0),
MoveDirection::E
);
}
#[test]
fn crouch_and_air() {
let crouch = choice(Vec3::ZERO, true, true, false, 0.0);
assert_eq!(crouch.kind, AnimationKind::CrouchIdle);
assert_eq!(crouch.name, CROUCH_IDLE_ANIMATION);
let crouch_walk = choice(Vec3::new(0.0, -150.0, 0.0), true, true, false, 0.0);
assert_eq!(crouch_walk.kind, AnimationKind::CrouchWalk);
assert_eq!(crouch_walk.name, "a_CwalkE");
let air = choice(Vec3::new(100.0, 0.0, 100.0), false, false, false, 0.0);
assert_eq!(air.kind, AnimationKind::Air);
assert_eq!(air.name, AIR_ANIMATIONS[0]);
}
#[test]
fn walk_run_keep_phase_and_types_reset() {
let mut state = AnimationState::default();
let walk = context(Vec3::new(200.0, 0.0, 0.0), true, false, false, 0.0);
let run = context(Vec3::new(400.0, 0.0, 0.0), true, false, false, 0.0);
let idle = context(Vec3::ZERO, true, false, false, 0.0);
let available = |_: &str| true;
let first = state.update(0.2, walk, available).expect("шаг");
assert_eq!(first.name, "a_WalkN");
assert!((first.time - 0.2).abs() < 1e-5);
// walk → run: фаза продолжается, а не начинается заново.
let second = state.update(0.1, run, available).expect("бег");
assert_eq!(second.name, "a_RunN");
assert!((second.time - 0.3).abs() < 1e-5);
// Смена направления внутри бега фазу тоже не сбрасывает.
let strafe = context(Vec3::new(0.0, -400.0, 0.0), true, false, false, 0.0);
let third = state.update(0.1, strafe, available).expect("стрейф");
assert_eq!(third.name, "a_RunE");
assert!((third.time - 0.4).abs() < 1e-5);
// Другой тип (стоя) — сброс.
let fourth = state.update(0.1, idle, available).expect("стоим");
assert!((fourth.time - 0.1).abs() < 1e-5);
}
#[test]
fn missing_air_animation_keeps_current() {
let mut state = AnimationState::default();
let walk = context(Vec3::new(200.0, 0.0, 0.0), true, false, false, 0.0);
let air = context(Vec3::new(200.0, 0.0, 100.0), false, false, false, 0.0);
let idle = context(Vec3::ZERO, true, false, false, 0.0);
state.update(0.2, walk, |_| true).expect("шаг");
// Прыжковых анимаций нет: остаётся последняя, время продолжается.
let pose = state
.update(0.1, air, |name| name == "a_WalkN")
.expect("остаёмся на ходьбе");
assert_eq!(pose.name, "a_WalkN");
assert!((pose.time - 0.3).abs() < 1e-5);
// А если есть вторая из списка — берём её.
let pose = state
.update(0.1, air, |name| name == "@jump_pistol" || name == "a_WalkN")
.expect("прыжок");
assert_eq!(pose.name, "@jump_pistol");
// Нечего играть вовсе.
let mut empty = AnimationState::default();
assert!(empty.update(0.1, idle, |_| false).is_none());
}
}

View file

@ -1,6 +1,7 @@
//! `engine` — клиентская логика мира: сборка геометрии из BSP, камера, //! `engine` — клиентская логика мира: сборка геометрии из BSP, камера,
//! система сущностей и игрок. //! система сущностей и игрок.
pub mod animation;
pub mod camera; pub mod camera;
pub mod constraints; pub mod constraints;
pub mod entity; pub mod entity;
@ -9,6 +10,10 @@ pub mod physics;
pub mod player; pub mod player;
pub mod world; pub mod world;
pub use animation::{
AnimationChoice, AnimationContext, AnimationKind, AnimationPose, AnimationState, MoveDirection,
select_animation,
};
pub use camera::FlyCamera; pub use camera::FlyCamera;
pub use entity::{Entity, EntityFlags, EntityIndex, EntityWorld, MoveType}; pub use entity::{Entity, EntityFlags, EntityIndex, EntityWorld, MoveType};
pub use model::{Model, ModelVertex, load_model}; pub use model::{Model, ModelVertex, load_model};

View file

@ -2452,6 +2452,30 @@ impl World {
Some(self.props.len() - 1) Some(self.props.len() - 1)
} }
/// Есть ли у экземпляра анимация с таким именем (своя или включённой
/// модели). Нужно клиенту, чтобы выбрать существующую секвенцию состояния
/// и не сбрасывать модель в позу покоя из-за опечатки в имени.
pub fn model_has_animation(&self, index: usize, animation: &str) -> bool {
let Some(instance) = self.model_instances.get(index) else {
return false;
};
self.prop_meshes
.get(instance.mesh)
.is_some_and(|mesh| mesh.anim.iter().any(|data| data.find(animation).is_some()))
}
/// Длительность анимации в секундах (для одноразовых проигрываний).
pub fn model_animation_duration(&self, index: usize, animation: &str) -> Option<f32> {
let instance = self.model_instances.get(index)?;
let mesh = self.prop_meshes.get(instance.mesh)?;
let anim = mesh
.anim
.iter()
.find_map(|data| data.find(animation).map(|anim| (anim, data)))?;
let fps = if anim.0.fps > 0.0 { anim.0.fps } else { 30.0 };
Some(anim.0.num_frames.max(1) as f32 / fps)
}
/// Двигает экземпляр и переписывает вершины по кадрам анимации (A26). /// Двигает экземпляр и переписывает вершины по кадрам анимации (A26).
/// ///
/// `animation` — имя анимации (у моделей GMod библиотека анимаций лежит во /// `animation` — имя анимации (у моделей GMod библиотека анимаций лежит во
@ -2576,6 +2600,25 @@ impl World {
true true
} }
/// Центр текущих (уже скиненных) вершин экземпляра в мировых координатах.
///
/// Нужен вьюмодели: анимация с `RAWPOS` корня сдвигает весь меш, и
/// привязка к центру габарита должна учитывать позу, а не позу покоя.
pub fn model_instance_center(&self, index: usize) -> Option<Vec3> {
let instance = self.model_instances.get(index)?;
let first = instance.first_vertex as usize;
let count = instance.vertex_count as usize;
let vertices = self.vertices.get(first..first + count)?;
if vertices.is_empty() {
return None;
}
let mut sum = Vec3::ZERO;
for vertex in vertices {
sum += Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]);
}
Some(sum / count as f32)
}
/// Двигает экземпляр модели без сущности. /// Двигает экземпляр модели без сущности.
pub fn move_model_instance(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool { pub fn move_model_instance(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool {
let Some(instance) = self.model_instances.get(index) else { let Some(instance) = self.model_instances.get(index) else {
@ -2827,35 +2870,48 @@ impl AnimData {
) else { ) else {
continue; continue;
}; };
// DELTA — каналы уже относительные: поза покоя не прибавляется. // `CalcBoneQuaternion`: постоянный RAWROT/RAWROT2 важнее дорожек и
let delta = track.flags & 0x10 != 0; // позы покоя; иначе — дорожки углов или поза покоя.
let euler = |at: usize| -> [f32; 3] { let q = if let Some(raw) = track.raw_rot {
let mut angles = if delta { [0.0; 3] } else { bone.rot }; raw
for (j, channel) in track.channels.iter().take(3).enumerate() { } else {
// DELTA — каналы уже относительные: поза покоя не прибавляется.
let delta = track.flags & formats::mdl::STUDIO_ANIM_DELTA != 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]);
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
}
};
// `CalcBonePosition`: постоянный RAWPOS — готовые координаты.
let pos = if let Some(raw) = track.raw_pos {
raw
} else {
let delta = track.flags & formats::mdl::STUDIO_ANIM_DELTA != 0;
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 { if let Some(off) = channel {
angles[j] += formats::mdl::Mdl::extract_anim_value(data, *off, at) let v1 = formats::mdl::Mdl::extract_anim_value(data, *off, frame);
* bone.rot_scale[j]; let v2 = formats::mdl::Mdl::extract_anim_value(data, *off, next);
pos[j] += (v1 * (1.0 - blend) + v2 * blend) * bone.pos_scale[j];
} }
} }
angles pos
}; };
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.0 = Vec3::new(pos[0], pos[1], pos[2]);
slot.1 = q; slot.1 = q;
} }

View file

@ -438,12 +438,39 @@ fn real_view_model_skin_stays_coherent() {
let bind = instance_positions(&world, index); let bind = instance_positions(&world, index);
assert!(world.play_model_animation(index, origin, math::Vec3::ZERO, "idle01", 0.0)); assert!(world.play_model_animation(index, origin, math::Vec3::ZERO, "idle01", 0.0));
let posed = instance_positions(&world, index); let posed = instance_positions(&world, index);
let centroid = |points: &[[f32; 3]]| -> [f32; 3] {
let mut sum = [0.0f32; 3];
for p in points {
for (j, value) in sum.iter_mut().enumerate() {
*value += p[j];
}
}
let count = points.len().max(1) as f32;
[sum[0] / count, sum[1] / count, sum[2] / count]
};
let (bind_centre, posed_centre) = (centroid(&bind), centroid(&posed));
let mut max_delta = 0.0f32; let mut max_delta = 0.0f32;
// Смещение относительно центра: RAWPOS корня двигает весь меш целиком,
// поэтому «разваливание» меряем после вычитания общего сдвига.
let mut max_centred_delta = 0.0f32;
let mut moved = 0usize; let mut moved = 0usize;
for (a, b) in bind.iter().zip(posed.iter()) { for (a, b) in bind.iter().zip(posed.iter()) {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
max_delta = max_delta.max(dist); max_delta = max_delta.max(dist);
let da = [
a[0] - bind_centre[0],
a[1] - bind_centre[1],
a[2] - bind_centre[2],
];
let db = [
b[0] - posed_centre[0],
b[1] - posed_centre[1],
b[2] - posed_centre[2],
];
let dd = [da[0] - db[0], da[1] - db[1], da[2] - db[2]];
max_centred_delta =
max_centred_delta.max((dd[0] * dd[0] + dd[1] * dd[1] + dd[2] * dd[2]).sqrt());
if dist > 4.0 { if dist > 4.0 {
moved += 1; moved += 1;
} }
@ -459,15 +486,16 @@ fn real_view_model_skin_stays_coherent() {
[max[0] - min[0], max[1] - min[1], max[2] - min[2]] [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
}; };
eprintln!( eprintln!(
"вьюмодель: {} вершин, сдвинулось >4 юнитов {moved}, макс. сдвиг {max_delta:.1}; \ "вьюмодель: {} вершин, сдвинулось >4 юнитов {moved}, макс. сдвиг {max_delta:.1}, \
габарит покоя {:?}, позы {:?}", после вычитания центра {max_centred_delta:.1}; габарит покоя {:?}, позы {:?}",
bind.len(), bind.len(),
extent(&bind), extent(&bind),
extent(&posed) extent(&posed)
); );
assert!(max_delta < 100.0, "меш уехал: макс. сдвиг {max_delta:.1}");
assert!( assert!(
max_delta < 80.0, max_centred_delta < 40.0,
"меш разваливается: макс. сдвиг {max_delta:.1}" "меш разваливается: после вычитания центра {max_centred_delta:.1}"
); );
} }
@ -526,7 +554,362 @@ fn real_weapon_model_structure() {
} }
} }
/// Позиции вершин экземпляра модели. /// RAW-флаги дорожек (`RAWPOS`/`RAWROT`/`RAWROT2`) на живых данных.
///
/// Постоянные дорожки не имеют каналов, поэтому раньше такие кости застревали в
/// позе покоя. Критерии: все встреченные флаги известны; кватернионы единичные;
/// позиции конечны и в масштабе модели; хотя бы одна постоянная дорожка
/// отличается от позы покоя — значит, распаковка действительно применена.
#[test]
fn real_animation_raw_flags_decode() {
use formats::mdl::{
Mdl, STUDIO_ANIM_ANIMPOS, STUDIO_ANIM_ANIMROT, STUDIO_ANIM_DELTA, STUDIO_ANIM_RAWPOS,
STUDIO_ANIM_RAWROT, STUDIO_ANIM_RAWROT2,
};
let Some(root) = assets_root() else {
eprintln!("пропуск: ассеты не найдены");
return;
};
let vfs = mount_game(&root);
let known = STUDIO_ANIM_RAWPOS
| STUDIO_ANIM_RAWROT
| STUDIO_ANIM_ANIMPOS
| STUDIO_ANIM_ANIMROT
| STUDIO_ANIM_DELTA
| STUDIO_ANIM_RAWROT2;
let check_tracks = |tracks: &[formats::mdl::AnimTrack],
bones: &[formats::mdl::Bone],
label: &str|
-> (usize, usize, usize) {
let (mut raw_rot, mut raw_pos, mut changed) = (0usize, 0usize, 0usize);
for track in tracks {
assert_eq!(
track.flags & !known,
0,
"{label}: неизвестный флаг 0x{:02x}",
track.flags
);
if let Some(q) = track.raw_rot {
raw_rot += 1;
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,
"{label}: кватернион не единичный: {len} (кость {})",
track.bone
);
assert!(
q.iter().all(|v| v.is_finite()),
"{label}: NaN в кватернионе"
);
if let Some(bone) = bones.get(track.bone as usize) {
let dot = q[0] * bone.quat[0]
+ q[1] * bone.quat[1]
+ q[2] * bone.quat[2]
+ q[3] * bone.quat[3];
if dot.abs() < 1.0 - 1e-4 {
changed += 1;
}
}
}
if let Some(p) = track.raw_pos {
raw_pos += 1;
assert!(p.iter().all(|v| v.is_finite()), "{label}: NaN в позиции");
assert!(
p.iter().all(|v| v.abs() < 1000.0),
"{label}: позиция вне масштаба модели: {p:?}"
);
}
if track.flags & STUDIO_ANIM_RAWROT != 0 {
assert!(
track.raw_rot.is_some(),
"{label}: RAWROT кости {} не разобран",
track.bone
);
}
if track.flags & STUDIO_ANIM_RAWROT2 != 0 {
assert!(
track.raw_rot.is_some(),
"{label}: RAWROT2 кости {} не разобран",
track.bone
);
}
if track.flags & STUDIO_ANIM_RAWPOS != 0 {
assert!(
track.raw_pos.is_some(),
"{label}: RAWPOS кости {} не разобран",
track.bone
);
}
}
(raw_rot, raw_pos, changed)
};
// Пистолет: короткая своя анимация с RAWROT (6 байт) и RAWPOS (half×3).
let data = vfs.read("models/weapons/v_pistol.mdl").expect("v_pistol");
let mdl = Mdl::parse(&data).expect("разбор v_pistol");
let anim = mdl
.animations()
.expect("анимации v_pistol")
.into_iter()
.find(|a| a.name.eq_ignore_ascii_case("@idle01"))
.expect("@idle01");
let start = anim.offset + anim.anim_index.max(0) as usize;
let tracks = Mdl::animation_tracks(&data, start);
let bones = mdl.bones().expect("кости v_pistol");
let (raw_rot, raw_pos, changed) = check_tracks(&tracks, &bones, "v_pistol @idle01");
eprintln!(
"v_pistol @idle01: дорожек {}, RAWROT {}, RAWPOS {}, отличаются от позы покоя {}",
tracks.len(),
raw_rot,
raw_pos,
changed
);
assert_eq!(tracks.len(), 10, "у @idle01 ожидалось 10 дорожек");
assert!(raw_rot > 0, "в @idle01 нет разобранных RAWROT");
assert!(raw_pos > 0, "в @idle01 нет разобранных RAWPOS");
assert!(changed > 0, "постоянные повороты совпали с позой покоя");
// Библиотека GMod: 24 дорожки с Quaternion64 (флаг 0x20) из 53.
let Some((data, ani)) = player_anim_library(&vfs) else {
eprintln!("пропуск: библиотека анимаций не разобрана");
return;
};
let mdl = Mdl::parse(&data).expect("разбор m_anm");
let anim = mdl
.animations()
.expect("анимации m_anm")
.into_iter()
.find(|a| a.name.eq_ignore_ascii_case("@taunt_muscle_base"))
.expect("@taunt_muscle_base");
let blocks = mdl.anim_blocks();
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 = Mdl::animation_tracks(&ani, start);
let bones = mdl.bones().expect("кости m_anm");
let (raw_rot, raw_pos, changed) = check_tracks(&tracks, &bones, "m_anm @taunt_muscle_base");
let rawrot2 = tracks
.iter()
.filter(|t| t.flags & STUDIO_ANIM_RAWROT2 != 0)
.count();
eprintln!(
"m_anm @taunt_muscle_base: дорожек {}, RAWROT2 {}, RAWROT {}, RAWPOS {}, \
отличаются от позы покоя {}",
tracks.len(),
rawrot2,
raw_rot,
raw_pos,
changed
);
assert_eq!(
tracks.len(),
53,
"у @taunt_muscle_base ожидалось 53 дорожки"
);
assert_eq!(rawrot2, 24, "дорожек с Quaternion64 должно быть 24");
assert_eq!(raw_rot, 24, "все RAWROT2 должны разбираться в raw_rot");
assert!(changed > 0, "постоянные повороты совпали с позой покоя");
}
/// Имена, которые выбирает `engine::animation`, существуют в библиотеке GMod.
///
/// Чистая функция проверяется без ассетов, но имя может разойтись с данными
/// (`a_WalkN` против `walk_all`), поэтому сверяем весь набор направлений.
#[test]
fn real_locomotion_animation_names_exist() {
let Some(root) = assets_root() else {
eprintln!("пропуск: ассеты не найдены");
return;
};
let vfs = mount_game(&root);
let data = vfs.read("models/m_anm.mdl").expect("m_anm.mdl");
let mdl = formats::mdl::Mdl::parse(&data).expect("разбор m_anm");
let names: std::collections::HashSet<String> = mdl
.animations()
.expect("анимации")
.into_iter()
.map(|anim| anim.name.trim_start_matches('@').to_ascii_lowercase())
.collect();
let mut wanted = vec![
engine::animation::IDLE_ANIMATION.to_string(),
engine::animation::CROUCH_IDLE_ANIMATION
.trim_start_matches('@')
.to_string(),
];
for direction in engine::MoveDirection::ALL {
for kind in [
engine::AnimationKind::Walk,
engine::AnimationKind::Run,
engine::AnimationKind::CrouchWalk,
] {
wanted.push(kind.animation_name(direction));
}
}
for name in engine::animation::AIR_ANIMATIONS {
wanted.push(name.trim_start_matches('@').to_string());
}
let missing: Vec<&String> = wanted
.iter()
.filter(|name| !names.contains(&name.to_ascii_lowercase()))
.collect();
eprintln!(
"выбор по состоянию: проверено {} имён, нет в m_anm: {missing:?}",
wanted.len()
);
assert!(
missing.is_empty(),
"анимаций для выбора состояния нет в библиотеке: {missing:?}"
);
}
/// Анимации, которые выбирает состояние, действительно двигают модель игрока.
///
/// Функция выбора и наличие имён проверяются отдельно; здесь — что каждая
/// выбранная секвенция даёт различимую позу и не разваливает скелет.
#[test]
fn real_player_locomotion_animations_move_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("модель игрока добавлена");
for animation in [
"a_WalkN",
"a_WalkE",
"a_RunN",
"a_RunW",
"a_CwalkN",
"@cidle_all",
"@jump_fist",
] {
assert!(
world.play_model_animation(index, origin, angles, animation, 0.0),
"{animation}: анимация не найдена"
);
let first = instance_positions(&world, index);
assert!(
world.play_model_animation(index, origin, angles, animation, 0.5),
"{animation}: второй кадр не сыграл"
);
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();
let extent = second.iter().fold(0.0f32, |max, v| {
max.max((v[0] - origin.x).abs())
.max((v[1] - origin.y).abs())
.max((v[2] - origin.z).abs())
});
eprintln!("{animation}: сдвинулось {moved}, вылет от центра {extent:.1}");
assert!(
moved > 50,
"{animation}: поза почти не изменилась (сдвинулось {moved})"
);
assert!(
extent < 120.0,
"{animation}: вершины разлетелись ({extent:.1})"
);
}
}
/// Анимация с RAWROT2 двигает вершины: постоянные дорожки больше не держат
/// кости в позе покоя.
#[test]
fn real_player_taunt_moves_vertices_with_raw_tracks() {
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, "@taunt_muscle_base", 0.0),
"анимация @taunt_muscle_base не найдена"
);
let first = instance_positions(&world, index);
assert!(world.play_model_animation(index, origin, angles, "@taunt_muscle_base", 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();
let extent = second.iter().fold(0.0f32, |max, v| {
max.max((v[0] - origin.x).abs())
.max((v[1] - origin.y).abs())
.max((v[2] - origin.z).abs())
});
eprintln!(
"@taunt_muscle_base: {} вершин, между кадрами 0 и 30 сдвинулось {moved}, \
максимальный вылет от центра {extent:.1}",
first.len()
);
assert!(
moved > 50,
"поза с RAWROT2 не меняется: сдвинулось {moved} вершин"
);
assert!(extent < 120.0, "вершины разлетелись: {extent:.1}");
}
fn instance_positions(world: &engine::World, index: usize) -> Vec<[f32; 3]> { fn instance_positions(world: &engine::World, index: usize) -> Vec<[f32; 3]> {
let instance = &world.model_instances[index]; let instance = &world.model_instances[index];
let first = instance.first_vertex as usize; let first = instance.first_vertex as usize;

View file

@ -59,15 +59,33 @@ pub enum MdlError {
Kv(#[from] crate::kv::KvError), Kv(#[from] crate::kv::KvError),
} }
/// Флаги `mstudioanim_t` (студия: `STUDIO_ANIM_*`).
///
/// `RAWPOS`/`RAWROT`/`RAWROT2` — постоянные значения на всю анимацию, лежащие
/// прямо в записи дорожки; остальные — дорожки значений (`mstudioanimvalue_t`).
pub const STUDIO_ANIM_RAWPOS: u8 = 0x01;
pub const STUDIO_ANIM_RAWROT: u8 = 0x02;
pub const STUDIO_ANIM_ANIMPOS: u8 = 0x04;
pub const STUDIO_ANIM_ANIMROT: u8 = 0x08;
pub const STUDIO_ANIM_DELTA: u8 = 0x10;
pub const STUDIO_ANIM_RAWROT2: u8 = 0x20;
/// Дорожка одной кости: шесть каналов (поворот xyz, позиция xyz). /// Дорожка одной кости: шесть каналов (поворот xyz, позиция xyz).
/// ///
/// Канал — смещение блока значений (`mstudioanimvalue_t`) в файле кадров; /// Канал — смещение блока значений (`mstudioanimvalue_t`) в файле кадров;
/// `None` — канал отсутствует и берётся поза покоя кости. /// `None` — канал отсутствует и берётся поза покоя кости. Постоянные значения
/// (`RAWPOS`/`RAWROT`/`RAWROT2`) разбираются сразу в `raw_pos`/`raw_rot`:
/// у них нет дорожек, поза одна на всю анимацию.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AnimTrack { pub struct AnimTrack {
pub bone: u8, pub bone: u8,
pub flags: u8, pub flags: u8,
pub channels: [Option<usize>; 6], pub channels: [Option<usize>; 6],
/// Готовый кватернион из `Quaternion48`/`Quaternion64` (приоритет у
/// `RAWROT`, как в `CalcBoneQuaternion`).
pub raw_rot: Option<[f32; 4]>,
/// Готовая позиция из `Vector48` (три half-float).
pub raw_pos: Option<[f32; 3]>,
} }
/// Описание анимации (`mstudioanimdesc_t`). /// Описание анимации (`mstudioanimdesc_t`).
@ -621,7 +639,9 @@ impl<'a> Mdl<'a> {
/// `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки /// `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки
/// связаны списком; `nextoffset == 0` — конец. Значения каналов лежат как /// связаны списком; `nextoffset == 0` — конец. Значения каналов лежат как
/// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`; /// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`;
/// см. `extract_anim_value`. /// см. `extract_anim_value`. Постоянные значения (`RAW*`) лежат в самой
/// записи дорожки и разбираются здесь же (SDK: `CalcBoneQuaternion`,
/// `CalcBonePosition`).
pub fn animation_tracks(data: &[u8], start: usize) -> Vec<AnimTrack> { pub fn animation_tracks(data: &[u8], start: usize) -> Vec<AnimTrack> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut offset = start; let mut offset = start;
@ -631,11 +651,29 @@ impl<'a> Mdl<'a> {
let next = i16::from_le_bytes([data[offset + 2], data[offset + 3]]) as i32; let next = i16::from_le_bytes([data[offset + 2], data[offset + 3]]) as i32;
let value_base = offset + 4; let value_base = offset + 4;
let mut channels = [None; 6]; let mut channels = [None; 6];
// Поворот: дорожки ANIMROT (три канала углов). RAWROT (готовый
// Quaternion48) и RAWROT2 (Quaternion64) пока не разбираем — тогда // Постоянный поворот: `pQuat48`/`pQuat64` начинаются сразу за
// каналов нет и вызывающий остаётся на позе покоя. // заголовком дорожки. Если заданы оба флага, SDK берёт Quaternion48.
let anim_rot = flags & 0x08 != 0; let raw_rot = if flags & STUDIO_ANIM_RAWROT != 0 {
let anim_pos = flags & 0x04 != 0; Self::quaternion48(data.get(value_base..value_base + 6))
} else if flags & STUDIO_ANIM_RAWROT2 != 0 {
Self::quaternion64(data.get(value_base..value_base + 8))
} else {
None
};
// `pPos` идёт за поворотом с учётом размера обоих RAW-флагов.
let raw_rot_bytes = usize::from(flags & STUDIO_ANIM_RAWROT != 0) * 6
+ usize::from(flags & STUDIO_ANIM_RAWROT2 != 0) * 8;
let raw_pos = if flags & STUDIO_ANIM_RAWPOS != 0 {
Self::vector48(data.get(value_base + raw_rot_bytes..value_base + raw_rot_bytes + 6))
} else {
None
};
// Поворот: дорожки ANIMROT (три канала углов).
let anim_rot = flags & STUDIO_ANIM_ANIMROT != 0;
let anim_pos = flags & STUDIO_ANIM_ANIMPOS != 0;
if anim_rot && value_base + 6 <= data.len() { if anim_rot && value_base + 6 <= data.len() {
for j in 0..3 { for j in 0..3 {
let off = i16::from_le_bytes([ let off = i16::from_le_bytes([
@ -663,6 +701,8 @@ impl<'a> Mdl<'a> {
bone, bone,
flags, flags,
channels, channels,
raw_rot,
raw_pos,
}); });
if next <= 0 { if next <= 0 {
break; break;
@ -672,6 +712,52 @@ impl<'a> Mdl<'a> {
out out
} }
/// `Quaternion48` (SDK: `compressed_vector.h`): 6 байт, x/y по 16 бит со
/// знаком, z — 15 бит, старший бит — знак `w`. `w` восстанавливается из
/// единичной длины, поэтому кватернион и его отрицание дают один поворот.
pub fn quaternion48(bytes: Option<&[u8]>) -> Option<[f32; 4]> {
let bytes = bytes?;
let x_raw = u16::from_le_bytes([*bytes.first()?, *bytes.get(1)?]);
let y_raw = u16::from_le_bytes([*bytes.get(2)?, *bytes.get(3)?]);
let z_raw = u16::from_le_bytes([*bytes.get(4)?, *bytes.get(5)?]);
let x = (x_raw as i32 - 32768) as f32 * (1.0 / 32768.0);
let y = (y_raw as i32 - 32768) as f32 * (1.0 / 32768.0);
let z = ((z_raw & 0x7fff) as i32 - 16384) as f32 * (1.0 / 16384.0);
let w = unit_w(x, y, z)?;
Some(normalize_quat([
x,
y,
z,
if z_raw & 0x8000 != 0 { -w } else { w },
]))
}
/// `Quaternion64` (SDK: `compressed_vector.h`): 8 байт, x/y/z по 21 биту,
/// старший бит — знак `w`.
pub fn quaternion64(bytes: Option<&[u8]>) -> Option<[f32; 4]> {
let bytes = bytes?;
let mut raw = [0u8; 8];
raw.copy_from_slice(bytes.get(..8)?);
let bits = u64::from_le_bytes(raw);
let x = ((bits & 0x1f_ffff) as i32 - 1_048_576) as f32 * (1.0 / 1_048_576.5);
let y = (((bits >> 21) & 0x1f_ffff) as i32 - 1_048_576) as f32 * (1.0 / 1_048_576.5);
let z = (((bits >> 42) & 0x1f_ffff) as i32 - 1_048_576) as f32 * (1.0 / 1_048_576.5);
let w = unit_w(x, y, z)?;
let w = if bits >> 63 != 0 { -w } else { w };
Some(normalize_quat([x, y, z, w]))
}
/// `Vector48` (SDK: `compressed_vector.h`): три half-float по 16 бит.
pub fn vector48(bytes: Option<&[u8]>) -> Option<[f32; 3]> {
let bytes = bytes?;
let value = |at: usize| -> Option<f32> {
let pair = bytes.get(at..at + 2)?;
Some(half_to_f32(u16::from_le_bytes([pair[0], pair[1]])))
};
let out = [value(0)?, value(2)?, value(4)?];
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// Значение канала на кадре (SDK: `ExtractAnimValue` из `bone_setup.cpp`). /// Значение канала на кадре (SDK: `ExtractAnimValue` из `bone_setup.cpp`).
/// ///
/// Блок значений: байт `valid` (сколько значений записано), байт `total` /// Блок значений: байт `valid` (сколько значений записано), байт `total`
@ -1003,6 +1089,45 @@ fn vec3_at(data: &[u8], offset: usize) -> [f32; 3] {
] ]
} }
/// Четвёртая компонента кватерниона из условия единичной длины.
///
/// Для данных из файла сумма может оказаться больше единицы (битые или
/// округлённые значения): тогда `w` не существует и вызывающий остаётся на
/// позе покоя, а не получает NaN.
fn unit_w(x: f32, y: f32, z: f32) -> Option<f32> {
let w2 = 1.0 - x * x - y * y - z * z;
(w2 >= 0.0 && w2.is_finite()).then(|| w2.sqrt())
}
/// Нормировка кватерниона после распаковки: квантование даёт длину,
/// отличную от единицы на десятые доли процента.
fn normalize_quat(q: [f32; 4]) -> [f32; 4] {
let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
if len > 1e-6 && len.is_finite() {
[q[0] / len, q[1] / len, q[2] / len, q[3] / len]
} else {
[0.0, 0.0, 0.0, 1.0]
}
}
/// Half-float (`float16` из SDK) → `f32`.
///
/// В `Vector48` обычные числа, поэтому хватает стандартного IEEE 754 half:
/// бесконечность в SDK превращается в максимальное значение, NaN — в ноль.
fn half_to_f32(bits: u16) -> f32 {
let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 };
let exponent = (bits >> 10) & 0x1f;
let mantissa = (bits & 0x3ff) as f32;
match exponent {
0 if mantissa == 0.0 => sign * 0.0,
// Субнормальное число: 2^-14 · mantissa/1024.
0 => sign * mantissa * (1.0 / 1024.0) * (1.0 / 16384.0),
31 if mantissa == 0.0 => sign * 65504.0,
31 => 0.0,
_ => sign * (1.0 + mantissa / 1024.0) * 2f32.powi(exponent as i32 - 15),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1094,6 +1219,87 @@ mod tests {
} }
} }
#[test]
fn raw_quaternion_and_vector_decode() {
// Quaternion48: x=0.5, y=-0.5, z=0, w>0.
let x = (0.5f32 * 32768.0 + 32768.0) as u16;
let y = (-0.5f32 * 32768.0 + 32768.0) as u16;
let z = 16384u16; // z = 0
let mut bytes = Vec::new();
bytes.extend_from_slice(&x.to_le_bytes());
bytes.extend_from_slice(&y.to_le_bytes());
bytes.extend_from_slice(&z.to_le_bytes());
let q = Mdl::quaternion48(Some(&bytes)).expect("Quaternion48");
assert!((q[0] - 0.5).abs() < 1e-4, "{q:?}");
assert!((q[1] + 0.5).abs() < 1e-4, "{q:?}");
assert!(q[2].abs() < 1e-4, "{q:?}");
assert!((q[3] - 0.5f32.sqrt()).abs() < 1e-4, "{q:?}");
// Знак w — старший бит z.
let mut neg = bytes.clone();
neg[4..6].copy_from_slice(&(z | 0x8000).to_le_bytes());
let qn = Mdl::quaternion48(Some(&neg)).expect("Quaternion48 с -w");
assert!((qn[3] + 0.5f32.sqrt()).abs() < 1e-4, "{qn:?}");
// Quaternion64: единичный поворот.
let raw = 1_048_576u64 | (1_048_576u64 << 21) | (1_048_576u64 << 42);
let q = Mdl::quaternion64(Some(&raw.to_le_bytes())).expect("Quaternion64");
assert!((q[3] - 1.0).abs() < 1e-5, "{q:?}");
// Vector48: half-float 1, 2, -0.5.
let mut vec = Vec::new();
for half in [0x3c00u16, 0x4000, 0xb800] {
vec.extend_from_slice(&half.to_le_bytes());
}
let p = Mdl::vector48(Some(&vec)).expect("Vector48");
assert!((p[0] - 1.0).abs() < 1e-6, "{p:?}");
assert!((p[1] - 2.0).abs() < 1e-6, "{p:?}");
assert!((p[2] + 0.5).abs() < 1e-6, "{p:?}");
// Обрезанные данные — None, без паники.
assert!(Mdl::quaternion48(Some(&bytes[..5])).is_none());
assert!(Mdl::quaternion64(Some(&[0u8; 7])).is_none());
assert!(Mdl::vector48(Some(&vec[..5])).is_none());
}
#[test]
fn animation_tracks_parse_raw_values() {
// Дорожка 0: RAWPOS|RAWROT, далее Quaternion48 (identity) и Vector48.
// Дорожка 1: RAWROT2 через nextoffset; Quaternion64 (identity).
let mut data = vec![0u8; 4 + 6 + 6 + 4 + 8];
data[0] = 3; // bone
data[1] = STUDIO_ANIM_RAWPOS | STUDIO_ANIM_RAWROT;
data[2..4].copy_from_slice(&16i16.to_le_bytes()); // nextoffset
// Quaternion48 identity: x=32768, y=32768, z=16384.
data[4..6].copy_from_slice(&32768u16.to_le_bytes());
data[6..8].copy_from_slice(&32768u16.to_le_bytes());
data[8..10].copy_from_slice(&16384u16.to_le_bytes());
// Vector48: 0, 0, 38.5 (half 0x50D0 = 38.5).
data[10..12].copy_from_slice(&0u16.to_le_bytes());
data[12..14].copy_from_slice(&0u16.to_le_bytes());
data[14..16].copy_from_slice(&0x50d0u16.to_le_bytes());
// Вторая дорожка.
data[16] = 7;
data[17] = STUDIO_ANIM_RAWROT2;
data[18..20].copy_from_slice(&0i16.to_le_bytes());
let raw = 1_048_576u64 | (1_048_576u64 << 21) | (1_048_576u64 << 42);
data[20..28].copy_from_slice(&raw.to_le_bytes());
let tracks = Mdl::animation_tracks(&data, 0);
assert_eq!(tracks.len(), 2);
assert_eq!(tracks[0].bone, 3);
assert_eq!(tracks[0].flags & STUDIO_ANIM_RAWROT, STUDIO_ANIM_RAWROT);
assert!((tracks[0].raw_rot.expect("raw rot")[3] - 1.0).abs() < 1e-5);
assert!((tracks[0].raw_pos.expect("raw pos")[2] - 38.5).abs() < 1e-4);
assert_eq!(tracks[1].bone, 7);
assert!((tracks[1].raw_rot.expect("raw rot2")[3] - 1.0).abs() < 1e-5);
// Обрезанная запись: дорожка читается, но неполный RAW не разбирается.
let short = Mdl::animation_tracks(&data[..8], 0);
assert_eq!(short.len(), 1);
assert!(short[0].raw_rot.is_none());
assert!(short[0].raw_pos.is_none());
}
#[test] #[test]
fn garbage_never_panics() { fn garbage_never_panics() {
let mut state: u32 = 0xDEAD_BEEF; let mut state: u32 = 0xDEAD_BEEF;

View file

@ -4,7 +4,7 @@
уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий
шаг, грабли. шаг, грабли.
Обновлён: 2026-10-16, вечер. Обновлён: 2026-10-23, день.
--- ---
@ -150,10 +150,25 @@ origin. Посадка: глаз + 20 вперёд + 6 вправо − 10 вн
`ModelVertex` и CPU-скиннинга. Заливка вершин на GPU для этого уже готова — `ModelVertex` и CPU-скиннинга. Заливка вершин на GPU для этого уже готова —
тот же путь `moved_ranges`, что у движущихся пропов. тот же путь `moved_ranges`, что у движущихся пропов.
**A26, сделано 2026-10-23 (этот заход):** постоянные дорожки
`RAWROT`/`RAWROT2`/`RAWPOS` разобраны по `compressed_vector.h` (Quaternion48 6
байт, Quaternion64 8, Vector48 — три half-float). Замеры: `v_pistol @idle01` —
10 дорожек, 7 RAWROT + 2 RAWPOS; `m_anm @taunt_muscle_base` — 53 дорожки, 24
RAWROT2, кватернионы единичные, неизвестных флагов нет. `@taunt` теперь
двигает 3145 из 3147 вершин между кадрами 0 и 30.
Выбор анимации по состоянию — `engine::animation`: `idle_all_01`, `a_WalkN`,
`a_RunN`, `@cidle_all`, `a_CwalkN`, `@jump_fist` (первая существующая), восемь
направлений относительно взгляда. Фаза walk↔run сохраняется, смена типа —
сброс. Клиент играет выбранное имя; вьюмодель качается при ходьбе и отстаёт
при повороте, ЛКМ без захвата проигрывает `fire`. Проверки:
`real_animation_raw_flags_decode`, `real_player_taunt_moves_vertices_with_raw_tracks`,
юнит-тесты `engine::animation`.
**Следующее:** (а) до-аллокация вершин под спавн вместо пересборки рендерера; **Следующее:** (а) до-аллокация вершин под спавн вместо пересборки рендерера;
(б) реестр SWEP из Lua и вьюмодель; (в) вращение пропа колесом и заморозка у (б) блендинг анимаций игрока и вьюмодели; (в) вращение пропа колесом и
физгана; (г) скрытие своей модели, когда камера внутри неё; (д) скелет+анимации заморозка у физгана; (г) оружие в руках связывает `@idle_*`/`@jump_*` и
моделей; (е) консольные команды спавна на данных из Lua (`gm_spawn`). вьюмодель; (д) консольные команды спавна на данных из Lua (`gm_spawn`).
## 3. Что уже сделано и лежит в `main` ## 3. Что уже сделано и лежит в `main`

View file

@ -3262,9 +3262,51 @@ GPU» работает целиком. Пока это не анимация: н
добавлен вид со стороны `--third-person` — без него собственную модель не видно добавлен вид со стороны `--third-person` — без него собственную модель не видно
(в первом лице камера внутри неё, и она уезжает под карту). (в первом лице камера внутри неё, и она уезжает под карту).
**Остаток по A26:** флаги RAWROT/RAWROT2 (постоянный поворот дорожки) пока не **Сделано (постоянные дорожки RAW, 2026-10-23).** Разобраны все три вида
разбираются — такие кости остаются в позе покоя (в `@taunt_muscle_base` 24 постоянных значений `mstudioanim_t` — код взят по спеке `compressed_vector.h`:
дорожки из 53); выбор секвенции по состоянию (`ACT_MP_*`) и блендинг между ними.
- `STUDIO_ANIM_RAWROT` (0x02) — `Quaternion48`: 6 байт, x/y по 16 бит со
смещением 32768 и шагом 1/32768, z — 15 бит со смещением 16384 (1/16384),
старший бит — знак `w`; `w` восстанавливается из единичной длины;
- `STUDIO_ANIM_RAWROT2` (0x20) — `Quaternion64`: 8 байт, x/y/z по 21 биту
(`(raw − 1048576) / 1048576.5`), старший бит — знак `w`;
- `STUDIO_ANIM_RAWPOS` (0x01) — `Vector48`: три half-float; `pPos` идёт за
Quaternion48/64 (смещение учитывает оба флага).
`AnimTrack` теперь несёт готовые `raw_rot`/`raw_pos`, а `frame_pose` отдаёт им
приоритет над дорожками и позой покоя (как `CalcBoneQuaternion` /
`CalcBonePosition`). Замеры на живых данных (`real_animation_raw_flags_decode`):
`v_pistol @idle01` — 10 дорожек, 7 RAWROT и 2 RAWPOS, у 7 повороты отличаются
от позы покоя; `m_anm @taunt_muscle_base` — 53 дорожки, **24 RAWROT2**, все
кватернионы единичные, неизвестных флагов нет. Анимация `@taunt_muscle_base`
теперь двигает 3145 вершин из 3147 между кадрами 0 и 30 (тест
`real_player_taunt_moves_vertices_with_raw_tracks`), вылет от центра 63,7
юнита — скелет не разваливается.
**Сделано (выбор анимации по состоянию и вьюмодель, 2026-10-23).**
`engine::animation` — чистая функция выбора и состояние фазы:
- стоя — `idle_all_01`; шаг — `a_Walk<dir>`; бег — `a_Run<dir>` (флаг `sprint`
или скорость ≥ 320); присед — `@cidle_all` / `a_Cwalk<dir>`; в воздухе —
первая существующая из `@jump_fist`, `@jump_pistol`, `a_gmod_jump_delta`, иначе
остаётся текущая;
- восемь направлений (N/NE/E/SE/S/SW/W/NW) считаются по горизонтальной
скорости относительно курса взгляда: стрейф вправо даёт `E`, влево — `W`;
- walk ↔ run и смена направления сохраняют фазу, смена типа (стоя/присед/
воздух) сбрасывает время; если анимации нет в модели, остаётся текущая.
Клиент (`crates/client/src/map.rs`) держит `AnimationState` и играет выбранное
имя. Вьюмодель получила покачивание при ходьбе (амплитуда от скорости) и
инерционный сдвиг при повороте; ЛКМ без тела под прицелом проигрывает
одноразовую `fire`/`attack_player`, если она есть у модели. Привязка вьюмодели
идёт к центру **анимированной** позы: у `v_pistol` `RAWPOS` корня сдвигает меш
на 68 юнитов, и без поправки ствол уезжает из кадра. Проверка выбора —
юнит-тесты `engine::animation`; кадры клиента — в отчёте задачи.
**Остаток по A26:** блендинг между секвенциями (сейчас переключение жёсткое),
направление взгляда/корпуса отдельно от движения, оружие в руках влияет на
`@jump_*`/`@idle_*` (сейчас кулаки/общие), блендинг вьюмодели между
состояниями.
**Замер по масштабу кости (историческая заметка).** У кватерниона модуль **Замер по масштабу кости (историческая заметка).** У кватерниона модуль
вектора поворота не больше единицы у **всех** костей и кадров — это и есть вектора поворота не больше единицы у **всех** костей и кадров — это и есть