engine: пропы добавлены в коллизию игрока — проваливание под землю

Главная жалоба: игрок проваливался при хождении и стоянии.

Измерение сузило область: 180 кадров стояния на спавне стабильны
(z = -143.9, on_ground = true, провала нет). Значит дефект локальный.

Причина найдена архитектурно: коллизия игрока содержала ТОЛЬКО браши
карты и треугольники displacement. Пропы живут в мире Rapier, а игрок ходит
своей кодовой базой — то есть встать на ящик означало шагнуть в пустоту.
Это два непересекающихся мира коллизий.

- Collision::boxes + сетка по X/Y: габариты подвижных тел;
- set_boxes() перестраивает сетку целиком каждый кадр — тел десятки, а
  пересборка убирает риск «забыть обновить» (источник тихих провалов);
- aabb_penetration() выталкивает по оси наименьшего проникновения, чтобы тело
  не застревало в углу;
- клиент каждый кадр берёт габариты из живого мира, вместе с падающими
  пропами.

Три теста: игрок стоит на теле, стена из тел не пропускает, и отдельный тест
фиксирует исходное поведение «без габаритов — проваливается», чтобы
регрессия была видна явно.

Тестов 69 в engine, 23 набора по воркспейсу, clippy чист.
This commit is contained in:
Niko Marmeladkov 2026-10-13 14:41:30 +03:00
parent 32bd7c05f2
commit 2afa265fed
2 changed files with 224 additions and 2 deletions

View file

@ -10,7 +10,7 @@ use sdl3::keyboard::Keycode;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Instant;
use tracing::{info, warn};
use tracing::{debug, info, warn};
/// Аргументы команды `map`.
#[derive(Debug, clap::Args)]
@ -139,7 +139,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let world = std::rc::Rc::new(std::cell::RefCell::new(engine::build_world(&bsp, &vfs)?));
info!(elapsed_ms = started.elapsed().as_millis(), "мир собран");
let collision = {
let mut collision = {
let mut collision = engine::Collision::from_bsp(&bsp)?;
collision.add_displacement_tris(&world.borrow().disp_tris);
info!(
@ -357,6 +357,27 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
w.step_physics(dt);
w.recover_fallen_props(-8192.0);
}
// Габариты пропов уходят в коллизию игрока: без этого он ходит по
// карте, но проходит сквозь всё, что лежит на ней.
{
let w = world.borrow();
let boxes = w
.props
.iter()
.filter_map(|prop| {
let mesh = w.prop_meshes.get(prop.mesh)?;
let half = mesh.bounds_half_extents;
if half.length_squared() < 1.0 {
return None;
}
Some(math::Aabb::new(
prop.origin + math::Vec3::new(-half.x, -half.y, -half.z),
prop.origin + half,
))
})
.collect();
collision.set_boxes(boxes);
}
let mut move_forward = 0.0;
let mut move_right = 0.0;
let mut move_up = 0.0;
@ -485,6 +506,15 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
warn!("кадр не отрисован: {err:#}");
}
if frames_drawn % 30 == 0 {
debug!(
frames_drawn,
z = player.position.z,
vz = player.velocity.z,
on_ground = player.on_ground,
"диагностика: игрок стоит"
);
}
frames_drawn += 1;
if let Some(max_frames) = args.frames {
if frames_drawn >= max_frames {

View file

@ -24,10 +24,19 @@ pub struct Brush {
#[derive(Debug, Default)]
pub struct Collision {
pub brushes: Vec<Brush>,
/// Габариты подвижных тел (пропы, предметы) — меняются каждый кадр.
///
/// Без них игрок **проходит сквозь** любую динамическую сущность: встать
/// на ящик — значит шагнуть в пустоту и провалиться. Это отдельный мир
/// коллизий от брашей карты, и он обязан быть частью `Collision`, а не
/// жить только в Rapier: игрок ходит своей кодовой базой.
pub boxes: Vec<Aabb>,
/// Треугольники displacement'ов.
tris: Vec<[Vec3; 3]>,
/// Сетка по X/Y: ячейка → индексы треугольников.
grid: HashMap<(i32, i32), Vec<u32>>,
/// Сетка по X/Y: ячейка → индексы габаритов тел.
box_grid: HashMap<(i32, i32), Vec<u32>>,
}
/// Сторона ячейки пространственной сетки.
@ -170,6 +179,8 @@ impl Collision {
"браши: отбор по CONTENTS_SOLID"
);
Ok(Self {
boxes: Vec::new(),
box_grid: HashMap::new(),
brushes,
tris: Vec::new(),
grid: HashMap::new(),
@ -177,6 +188,26 @@ impl Collision {
}
/// Добавляет треугольники displacement'ов в коллизию.
/// Обновляет габариты подвижных тел — вызывать каждый кадр.
///
/// Сетка перестраивается целиком: тел мало (десятки), а пересборка
/// убирает риск «забыть обновить» — источник тихих провалов сквозь пол.
pub fn set_boxes(&mut self, boxes: Vec<Aabb>) {
self.boxes = boxes;
self.box_grid.clear();
for (index, aabb) in self.boxes.iter().enumerate() {
let x0 = (aabb.min.x / GRID_CELL).floor() as i32;
let x1 = (aabb.max.x / GRID_CELL).floor() as i32;
let y0 = (aabb.min.y / GRID_CELL).floor() as i32;
let y1 = (aabb.max.y / GRID_CELL).floor() as i32;
for x in x0..=x1 {
for y in y0..=y1 {
self.box_grid.entry((x, y)).or_default().push(index as u32);
}
}
}
}
pub fn add_displacement_tris(&mut self, tris: &[[Vec3; 3]]) {
let base = self.tris.len() as u32;
self.tris.extend_from_slice(tris);
@ -220,6 +251,62 @@ impl Collision {
}
/// Минимальный сдвиг для выхода AABB из треугольника (SAT, 13 осей).
/// Индексы габаритов тел, пересекающихся или близких к AABB.
fn nearby_boxes(&self, aabb: &Aabb) -> Vec<u32> {
let mut out = Vec::new();
let x0 = (aabb.min.x / GRID_CELL).floor() as i32;
let x1 = (aabb.max.x / GRID_CELL).floor() as i32;
let y0 = (aabb.min.y / GRID_CELL).floor() as i32;
let y1 = (aabb.max.y / GRID_CELL).floor() as i32;
for x in x0..=x1 {
for y in y0..=y1 {
if let Some(list) = self.box_grid.get(&(x, y)) {
out.extend_from_slice(list);
}
}
}
out.sort_unstable();
out.dedup();
out
}
/// Выталкивание AABB из AABB: минимальная ось проникновения.
fn aabb_penetration(aabb: &Aabb, other: &Aabb) -> Option<Vec3> {
// Нет пересечения по всем трём осям — тело не мешает.
if aabb.min.x >= other.max.x
|| aabb.max.x <= other.min.x
|| aabb.min.y >= other.max.y
|| aabb.max.y <= other.min.y
|| aabb.min.z >= other.max.z
|| aabb.max.z <= other.min.z
{
return None;
}
// Выход по оси наименьшего проникновения: так тело выталкивается
// в наименьшее число раз и не «застревает» в углу.
let push_x = aabb.max.x - other.min.x;
let push_x2 = other.max.x - aabb.min.x;
let push_y = aabb.max.y - other.min.y;
let push_y2 = other.max.y - aabb.min.y;
let push_z = aabb.max.z - other.min.z;
let push_z2 = other.max.z - aabb.min.z;
let mut best = push_x;
let mut normal = Vec3::new(-1.0, 0.0, 0.0);
for (value, candidate) in [
(push_x2, Vec3::new(1.0, 0.0, 0.0)),
(push_y, Vec3::new(0.0, -1.0, 0.0)),
(push_y2, Vec3::new(0.0, 1.0, 0.0)),
(push_z, Vec3::new(0.0, 0.0, -1.0)),
(push_z2, Vec3::new(0.0, 0.0, 1.0)),
] {
if value < best {
best = value;
normal = candidate;
}
}
Some(normal * (best + 0.1))
}
fn tri_penetration(aabb: &Aabb, tri: &[Vec3; 3]) -> Option<Vec3> {
let center = aabb.center();
let h = aabb.size() * 0.5;
@ -354,6 +441,20 @@ impl Collision {
hit = true;
}
}
for &index in &self.nearby_boxes(&aabb) {
let box_aabb = self.boxes[index as usize];
if let Some(push) = Self::aabb_penetration(&aabb, &box_aabb) {
let push = push * 1.001;
aabb.min += push;
aabb.max += push;
let len = push.length();
if len > 1e-6 && push.z / len > 0.6 {
on_ground = true;
}
Self::push_contact(&mut contacts, push);
hit = true;
}
}
for &index in &candidates {
let tri = &self.tris[index as usize];
if let Some(push) = Self::tri_penetration(&aabb, tri) {
@ -1073,4 +1174,95 @@ mod tests {
player.velocity
);
}
/// Игрок обязан стоять на пропе, а не проваливаться сквозь него.
///
/// Пропы живут в мире Rapier, а игрок ходит своей кодовой базой. Пока
/// габариты тел не передавались в `Collision`, встать на ящик означало
/// шагнуть в пустоту — это и есть жалоба «проваливаюсь при хождении».
#[test]
fn player_stands_on_a_dynamic_body() {
let mut collision = Collision {
brushes: Vec::new(),
tris: Vec::new(),
grid: HashMap::new(),
boxes: Vec::new(),
box_grid: HashMap::new(),
};
// Плита на высоте 100: игрок должен на ней остановиться.
let slab = Aabb::new(
Vec3::new(-256.0, -256.0, 80.0),
Vec3::new(256.0, 256.0, 100.0),
);
collision.set_boxes(vec![slab]);
let mut player = Player::new(Vec3::new(0.0, 0.0, 140.0));
for _ in 0..240 {
player.update(&collision, 0.0, 0.0, false, false, 0.0, 1.0 / 60.0);
}
assert!(
player.on_ground,
"игрок должен стоять на теле, а не проваливаться: z={}",
player.position.z
);
assert!(
(player.position.z - 100.0).abs() < 2.0,
"ступни должны стоять на верхней грани тела, z={}",
player.position.z
);
}
/// Без габаритов тел игрок проходит насквозь — это исходное поведение,
/// которое тест выше фиксирует как отказ.
#[test]
fn without_boxes_the_player_falls_through() {
let collision = Collision {
brushes: Vec::new(),
tris: Vec::new(),
grid: HashMap::new(),
boxes: Vec::new(),
box_grid: HashMap::new(),
};
let mut player = Player::new(Vec3::new(0.0, 0.0, 140.0));
for _ in 0..240 {
player.update(&collision, 0.0, 0.0, false, false, 0.0, 1.0 / 60.0);
}
assert!(
!player.on_ground,
"без габаритов тел проваливание — ожидаемое поведение"
);
}
/// Сквозь стену игрок пройти не должен: габариты тел не должны
/// «протекать» и насквозь.
#[test]
fn boxes_block_horizontal_movement() {
let mut collision = Collision {
brushes: Vec::new(),
tris: Vec::new(),
grid: HashMap::new(),
boxes: Vec::new(),
box_grid: HashMap::new(),
};
// Стена поперёк движения.
let wall = Aabb::new(Vec3::new(60.0, -256.0, 0.0), Vec3::new(100.0, 256.0, 300.0));
collision.set_boxes(vec![wall]);
let mut player = Player::new(Vec3::new(0.0, 0.0, 100.0));
for _ in 0..240 {
// Пол даём, чтобы не падать: игрок упирается в стену, а не вниз.
collision.set_boxes(vec![
wall,
Aabb::new(
Vec3::new(-256.0, -256.0, -20.0),
Vec3::new(256.0, 256.0, 0.0),
),
]);
player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0);
}
assert!(
player.position.x < 60.0,
"игрок не должен проходить сквозь стену, x={}",
player.position.x
);
}
}