Вьюмодель: доворот в координатах модели и камерный базис; коллизия из меша
Some checks are pending
CI / checks (push) Waiting to run

Вьюмодель (жалобы пользователя):
- доворот ствола (-90°) теперь применяется в координатах модели, до поворота
  камеры: прибавка к мировому yaw при взгляде вниз превращалась в крен;
- смещения (вправо/вниз) задаются в базисе камеры, а не мировым (0,0,-12) —
  иначе при наклоне камеры вьюмодель уезжала к центру экрана («ось ниже»);
- play_model_animation принимает model_yaw (доворот модели), rewrite_instance
  тоже — путь без скелета больше не теряет ориентацию.

Коллизия пропов из настоящего меша:
- Physics::add_prop_hull — выпуклая оболочка вершин модели для динамических
  пропов (как vcollide в Source), вместо коробки с выступами;
- Physics::add_static_mesh — тримеш из треугольников модели для prop_static:
  раньше у статических пропов коллизии не было вовсе, игрок проходил сквозь
  мебель;
- PropMesh хранит индексы модели; оба места создания тел используют оболочку
  (с откатом на коробку, если вершин < 4).

Все сьюты и 29 картовых тестов зелёные.
This commit is contained in:
Niko Marmeladkov 2026-10-23 14:03:45 +03:00
parent b52da2ed2e
commit a491010aa6
4 changed files with 166 additions and 27 deletions

View file

@ -779,23 +779,30 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
{
let mut w = world.borrow_mut();
if let Some(index) = view_model_instance {
let angles = math::Vec3::new(camera.pitch, camera.yaw + view_yaw, view_roll);
let angles = math::Vec3::new(camera.pitch, camera.yaw, view_roll);
// Базовая поза вьюмодели: чуть справа и ниже глаз, ствол вперёд.
// Крен — потому что модели оружия лежат в мире на боку.
let eye = camera.pos;
let forward = camera.forward();
let (sin_yaw, cos_yaw) = camera.yaw.to_radians().sin_cos();
let right = math::Vec3::new(sin_yaw, -cos_yaw, 0.0);
let centre = engine::model::rotate_model_vector(
view_model_center,
angles.x,
angles.y,
angles.z,
// Доворот модели — в её собственных координатах (ствол вдоль
// +Y модели), а не прибавкой к мировому yaw: иначе при взгляде
// вниз доворот превращается в крен (жалоба пользователя).
let centre_local =
engine::model::rotate_model_vector(view_model_center, 0.0, view_yaw, 0.0);
let centre =
engine::model::rotate_model_vector(centre_local, angles.x, angles.y, angles.z);
// Смещения — в базисе камеры, а не в мировом: «вниз» мировым
// вектором при наклоне камеры уезжает к центру экрана
// (жалоба пользователя: «ось ниже где-то»).
let up = engine::model::rotate_model_vector(
math::Vec3::new(0.0, 0.0, 1.0),
camera.pitch,
camera.yaw,
0.0,
);
let origin = eye + forward * view_distance + right * 5.0
- math::Vec3::new(0.0, 0.0, 8.0)
- centre;
w.play_model_animation(index, origin, angles, "idle01", wave_time);
let origin = eye + forward * view_distance + right * 8.0 - up * 12.0 - centre;
w.play_model_animation(index, origin, angles, "idle01", wave_time, view_yaw);
}
if let Some(index) = player_model_instance {
let inside = camera.pos.distance(player.eye_position()) < 48.0;
@ -810,6 +817,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
math::Vec3::new(0.0, camera.yaw + 180.0, 0.0),
"idle_all_01",
wave_time,
0.0,
);
}
moved_ranges = w.take_moved_ranges();

View file

@ -106,6 +106,8 @@ const STAND_HALF: Vec3 = Vec3 {
pub(crate) enum Kind {
/// `prop_physics` — динамическое твёрдое тело.
Dynamic,
/// `prop_static` — тримеш, прибитый к карте.
Static,
/// Игрок — кинематический контроллер.
Character,
}
@ -413,6 +415,97 @@ impl PhysicsWorld {
})
}
/// Динамический проп по настоящему мешу: выпуклая оболочка вершин.
///
/// Коробка давала выступы за пределы предмета (жалоба пользователя:
/// «колизия по размеру, но с выступами»). Source тоже хранит для пропов
/// выпуклые оболочки (`vcollide`), а не точный меш, — оболочка из вершин
/// модели повторяет её форму и не торчит углами.
pub fn add_prop_hull(
&mut self,
entity: EntityIndex,
origin: Vec3,
angles_deg: Vec3,
vertices: &[[f32; 3]],
mass: f32,
) -> Result<BodyId, PhysicsError> {
if self.handles.len() >= MAX_BODIES {
return Err(PhysicsError::TooManyBodies(self.handles.len()));
}
let points: Vec<Vector> = vertices
.iter()
.filter(|p| p.iter().all(|v| v.is_finite()))
.map(|p| Vector::new(p[0], p[1], p[2]))
.collect();
if points.len() < 4 {
return Err(PhysicsError::TooManyBodies(0));
}
let Some(hull) = ColliderBuilder::convex_hull(&points) else {
return Err(PhysicsError::TooManyBodies(0));
};
let collider = hull
.mass(mass.max(0.1))
.friction(0.6)
.restitution(0.1)
.build();
let desc = RigidBodyBuilder::dynamic()
.translation(Vector::new(origin.x, origin.y, origin.z))
.rotation(quat_from_angles(angles_deg).to_scaled_axis());
let handle = self.world.insert_body(desc);
self.world.insert_collider(collider, Some(handle));
self.handles.push((handle, entity, Kind::Dynamic));
Ok(BodyId {
inner: handle,
kind: Kind::Dynamic,
})
}
/// Статический проп: тримеш из настоящих треугольников модели.
///
/// `prop_static` в Source имеет коллизию (его физическая модель), у нас
/// раньше её не было вовсе — игрок проходил сквозь мебель. Тримеш даёт
/// точное совпадение с видимой геометрией.
pub fn add_static_mesh(
&mut self,
entity: EntityIndex,
origin: Vec3,
angles_deg: Vec3,
vertices: &[[f32; 3]],
indices: &[u32],
) -> Result<BodyId, PhysicsError> {
if self.handles.len() >= MAX_BODIES {
return Err(PhysicsError::TooManyBodies(self.handles.len()));
}
let points: Vec<Vector> = vertices
.iter()
.filter(|p| p.iter().all(|v| v.is_finite()))
.map(|p| Vector::new(p[0], p[1], p[2]))
.collect();
let tris: Vec<[u32; 3]> = indices
.chunks_exact(3)
.filter(|t| t.iter().all(|i| (*i as usize) < points.len()))
.map(|t| [t[0], t[1], t[2]])
.collect();
if points.is_empty() || tris.is_empty() {
return Err(PhysicsError::TooManyBodies(0));
}
let Ok(collider) = ColliderBuilder::trimesh(points, tris) else {
return Err(PhysicsError::TooManyBodies(0));
};
let collider = collider.friction(0.6).restitution(0.1).build();
let desc = RigidBodyBuilder::fixed()
.translation(Vector::new(origin.x, origin.y, origin.z))
.rotation(quat_from_angles(angles_deg).to_scaled_axis());
let handle = self.world.insert_body(desc);
self.world.insert_collider(collider, Some(handle));
self.static_bodies += 1;
self.handles.push((handle, entity, Kind::Static));
Ok(BodyId {
inner: handle,
kind: Kind::Static,
})
}
/// Добавляет кинематический контроллер игрока.
pub fn add_character(
&mut self,

View file

@ -2235,6 +2235,7 @@ impl World {
count: u32,
origin: Vec3,
angles: Vec3,
model_yaw: f32,
) -> bool {
let (first, count) = (first as usize, count as usize);
let Some(mesh) = self.prop_meshes.get(mesh_index) else {
@ -2245,13 +2246,18 @@ impl World {
}
let (pitch, yaw, roll) = (angles.x, angles.y, angles.z);
for i in 0..count {
let local = Vec3::new(
let local_model = Vec3::new(
mesh.positions[i][0],
mesh.positions[i][1],
mesh.positions[i][2],
);
// Доворот в координатах модели (ось вперёд у оружия своя), затем
// поворот камеры. Порядок важен: доворот по мировому yaw при
// взгляде вниз превращался в крен.
let local = crate::model::rotate_model_vector(local_model, 0.0, model_yaw, 0.0);
let pos = crate::model::rotate_model_vector(local, pitch, yaw, roll) + origin;
let local_n = Vec3::new(mesh.normals[i][0], mesh.normals[i][1], mesh.normals[i][2]);
let local_n = crate::model::rotate_model_vector(local_n, 0.0, model_yaw, 0.0);
let normal = crate::model::rotate_model_vector(local_n, pitch, yaw, roll);
let vertex = &mut self.vertices[first + i];
vertex.pos = [pos.x, pos.y, pos.z];
@ -2303,6 +2309,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.indices = model.indices.clone();
mesh_data.anim = model.anim_data.clone();
self.prop_meshes.push(mesh_data);
let mesh = self.prop_meshes.len() - 1;
@ -2424,15 +2431,24 @@ impl World {
let half = self.prop_meshes[mesh].bounds_half_extents;
let centre = self.prop_meshes[mesh].bounds_center;
let positions = self.prop_meshes[mesh].positions.clone();
let physics_body = if half.length_squared() > 1.0 {
match self.physics.add_prop_offset(
entity_index,
origin,
angles,
half,
centre,
mass.max(0.1),
) {
// Оболочка из вершин модели — вместо коробки, которая торчала
// выступами за пределы предмета.
let body = self
.physics
.add_prop_hull(entity_index, origin, angles, &positions, mass.max(0.1))
.or_else(|_| {
self.physics.add_prop_offset(
entity_index,
origin,
angles,
half,
centre,
mass.max(0.1),
)
});
match body {
Ok(body) => Some(body),
Err(err) => {
tracing::warn!(model = %model_name, "тело пропа не создано: {err}");
@ -2475,6 +2491,7 @@ impl World {
angles: Vec3,
animation: &str,
time: f32,
model_yaw: f32,
) -> bool {
let Some(instance) = self.model_instances.get(index) else {
return false;
@ -2491,7 +2508,14 @@ 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);
return self.rewrite_instance(
mesh_index,
first as u32,
count as u32,
origin,
angles,
model_yaw,
);
}
// Данные анимации — ссылкой: дальше меш понадобится снять из мира,
// чтобы одновременно читать его вершины и писать в общий буфер.
@ -2501,7 +2525,14 @@ impl World {
});
let Some((data, anim)) = found else {
// Кадров нет — экземпляр остаётся в позе покоя (обычный перенос).
return self.rewrite_instance(mesh_index, first as u32, count as u32, origin, angles);
return self.rewrite_instance(
mesh_index,
first as u32,
count as u32,
origin,
angles,
model_yaw,
);
};
// Кадр по времени; дробная часть — бленд между кадром и следующим.
@ -2571,6 +2602,7 @@ impl World {
} else {
blended
};
let local = crate::model::rotate_model_vector(local, 0.0, model_yaw, 0.0);
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) {
@ -2590,7 +2622,7 @@ impl World {
let (mesh, first, count) = (instance.mesh, instance.first_vertex, instance.vertex_count);
self.model_instances[index].origin = origin;
self.model_instances[index].angles = angles;
self.rewrite_instance(mesh, first, count, origin, angles)
self.rewrite_instance(mesh, first, count, origin, angles, 0.0)
}
pub fn move_prop(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool {
@ -2615,7 +2647,7 @@ impl World {
// запоминает диапазон вершин для заливки на GPU. Раньше здесь была
// своя копия перезаписи, и диапазоны не попадали в `moved_ranges` —
// физика считала, а картинка стояла (жалоба пользователя).
let rewritten = self.rewrite_instance(mesh_index, first, count, origin, angles);
let rewritten = self.rewrite_instance(mesh_index, first, count, origin, angles, 0.0);
self.update_prop_bounds();
rewritten
}
@ -2912,6 +2944,9 @@ pub struct PropMesh {
pub bones: Vec<formats::mdl::Bone>,
/// Привязка вершин к костям, по одной на `positions`.
pub skins: Vec<crate::model::VertexSkin>,
/// Индексы треугольников в координатах модели — для настоящих
/// коллайдеров (оболочка/тримеш вместо коробки).
pub indices: Vec<u32>,
/// Данные анимаций: своя модель и включённые библиотеки (A26).
pub anim: Vec<std::sync::Arc<AnimData>>,
/// Нормали в локальных координатах модели.
@ -2947,6 +2982,7 @@ impl PropMesh {
normals,
bones: Vec::new(),
skins: Vec::new(),
indices: Vec::new(),
anim: Vec::new(),
bounds_center: center,
bounds_half_extents: half_extents,
@ -4452,6 +4488,7 @@ mod tests {
prop_meshes: vec![PropMesh {
bones: Vec::new(),
skins: Vec::new(),
indices: Vec::new(),
anim: Vec::new(),
positions: vec![[1.0, 2.0, 3.0]],
normals: vec![[0.0, 0.0, 1.0]],
@ -4514,6 +4551,7 @@ mod tests {
prop_meshes: vec![PropMesh {
bones: Vec::new(),
skins: Vec::new(),
indices: Vec::new(),
anim: Vec::new(),
positions: vec![[1.0, 0.0, 0.0]],
normals: vec![[1.0, 0.0, 0.0]],

View file

@ -436,7 +436,7 @@ fn real_view_model_skin_stays_coherent() {
assert!(world.move_model_instance(index, origin, math::Vec3::ZERO));
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, 0.0));
let posed = instance_positions(&world, index);
let mut max_delta = 0.0f32;
let mut moved = 0usize;
@ -747,14 +747,14 @@ fn real_player_model_animation_moves_vertices() {
// Первый кадр: поза из библиотеки анимаций, имя без `@`.
assert!(
world.play_model_animation(index, origin, angles, "idle_all_01", 0.0),
world.play_model_animation(index, origin, angles, "idle_all_01", 0.0, 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));
assert!(world.play_model_animation(index, origin, angles, "idle_all_01", 1.0, 0.0));
let second = instance_positions(&world, index);
let moved = first
.iter()