Рельеф: треугольники отсекают шаг, как кирпичи — стены и сползание ушли
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
Жалобы: «невидимые стены уже на дисплейсментах, и коллизии их не видно; в гору скорость замедляется; прыгать рядом нельзя, но проходятся, если подойти и прыгнуть; на наклонных дисплейсментах скатывается» — и уточнение пользователя: «стены ровно по полигонам дисплейсментов». Причина одна: треугольники рельефа участвовали только в выталкивании. Тело, въехав в склон, выталкивалось по нормали треугольника, а нормаль наклонной поверхности смотрит вниз-вбок. Отсюда всё сразу: стена на ровном склоне, потеря скорости в гору, сползание, странные прыжки и стены по рёбрам полигонов (выталкивание срабатывает на границе треугольника). Как в движке: CDispCollTree трассирует треугольник как выпуклый объём — плоскость поверхности плюс три боковые грани через рёбра. Правка: отсечение по набору полупространств вынесено в Collision::clip_planes; Collision::clip_triangle строит четыре плоскости (нормаль из намотки, боковые (to − from) × n, ориентированные внутрь) и зовёт его. Треугольники добавлены в отсечение move_aabb и в ground_trace; выталкивание осталось страховкой. Замер (новый тест real_map_hill_walk_keeps_speed: восемь точек на ходибельных склонах 13–18°, четыре направления, эталон на ровном полу): скорость на склоне 252,2 против 250,0 на полу; «тормозной путь» после отпускания ввода 26,40 против 26,54 — то есть мнимое сползание оказалось тем же тормозным путём, что и на полу, сползания нет. 28 картовых тестов и весь workspace зелёные. Разбор — docs/PARITY.md E23.
This commit is contained in:
parent
b74824be70
commit
d88988fc67
2 changed files with 356 additions and 10 deletions
|
|
@ -772,6 +772,21 @@ impl Collision {
|
|||
best = Some((t, normal));
|
||||
}
|
||||
}
|
||||
// Рельеф — тот же отсев, что и по кирпичам: без него прижатие к земле
|
||||
// на холмах работало только через выталкивание, а оно уводит вниз по
|
||||
// склону.
|
||||
let mut tris = Vec::new();
|
||||
self.nearby_tris(aabb, &mut tris);
|
||||
for index in tris {
|
||||
let Some(tri) = self.tris.get(index as usize) else {
|
||||
continue;
|
||||
};
|
||||
if let Some((t, normal)) = Self::clip_triangle(tri, aabb, delta)
|
||||
&& best.is_none_or(|(best_t, _)| t < best_t)
|
||||
{
|
||||
best = Some((t, normal));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
|
|
@ -1054,6 +1069,53 @@ impl Collision {
|
|||
///
|
||||
/// Возвращает долю шага и нормаль грани, в которую упёрлись.
|
||||
fn clip_fraction(brush: &Brush, aabb: &Aabb, delta: Vec3) -> Option<(f32, Vec3)> {
|
||||
Self::clip_planes(&brush.planes, &brush.bounds, aabb, delta)
|
||||
}
|
||||
|
||||
/// Отсечение шага по треугольнику рельефа — как `CDispCollTree` в движке.
|
||||
///
|
||||
/// Треугольник трассируется **как выпуклый объём**: его плоскость плюс три
|
||||
/// боковые грани через рёбра. В Source поверхность displacement'а — не
|
||||
/// «полупроницаемая простыня»: шаг об неё отсекается, и только. У нас
|
||||
/// раньше треугольники участвовали лишь в выталкивании, и тело, въехав в
|
||||
/// склон, выталкивалось по нормали — вниз-вбок. Отсюда всё сразу:
|
||||
/// замедление в гору, сползание на остановке, «стены» на ровном склоне и
|
||||
/// прыжки, которые то работают, то нет (замеры пользователя 2026-10-16).
|
||||
fn clip_triangle(tri: &[Vec3; 3], aabb: &Aabb, delta: Vec3) -> Option<(f32, Vec3)> {
|
||||
let [a, b, c] = *tri;
|
||||
let cross = (b - a).cross(c - a);
|
||||
let length = cross.length();
|
||||
if length < 1e-6 || !length.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let normal = cross / length;
|
||||
let mut planes: Vec<(Vec3, f32)> = Vec::with_capacity(4);
|
||||
planes.push((normal, normal.dot(a)));
|
||||
let centre = (a + b + c) / 3.0;
|
||||
for (from, to) in [(a, b), (b, c), (c, a)] {
|
||||
let side = (to - from).cross(normal);
|
||||
if side.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
// Нормаль наружу от треугольника: центр обязан лежать внутри.
|
||||
let side = if side.dot(centre - from) > 0.0 {
|
||||
-side
|
||||
} else {
|
||||
side
|
||||
};
|
||||
planes.push((side, side.dot(from)));
|
||||
}
|
||||
let bounds = Aabb::new(a.min(b).min(c), a.max(b).max(c));
|
||||
Self::clip_planes(&planes, &bounds, aabb, delta)
|
||||
}
|
||||
|
||||
/// Отсечение шага по набору полупространств `n·p ≤ d` и его габариту.
|
||||
fn clip_planes(
|
||||
planes: &[(Vec3, f32)],
|
||||
bounds: &Aabb,
|
||||
aabb: &Aabb,
|
||||
delta: Vec3,
|
||||
) -> Option<(f32, Vec3)> {
|
||||
// Полупространство кирпича ограничивает **всю плоскость**, а не сам
|
||||
// кирпич: грань верхней площадки «действует» и далеко за её краем.
|
||||
// Без этого отсечения игрок вечно висит в воздухе над краем — пол
|
||||
|
|
@ -1062,12 +1124,12 @@ impl Collision {
|
|||
aabb.min + delta.min(Vec3::ZERO),
|
||||
aabb.max + delta.max(Vec3::ZERO),
|
||||
);
|
||||
if swept.max.x < brush.bounds.min.x
|
||||
|| swept.min.x > brush.bounds.max.x
|
||||
|| swept.max.y < brush.bounds.min.y
|
||||
|| swept.min.y > brush.bounds.max.y
|
||||
|| swept.max.z < brush.bounds.min.z
|
||||
|| swept.min.z > brush.bounds.max.z
|
||||
if swept.max.x < bounds.min.x
|
||||
|| swept.min.x > bounds.max.x
|
||||
|| swept.max.y < bounds.min.y
|
||||
|| swept.min.y > bounds.max.y
|
||||
|| swept.max.z < bounds.min.z
|
||||
|| swept.min.z > bounds.max.z
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
|
@ -1081,7 +1143,7 @@ impl Collision {
|
|||
let mut enter: f32 = f32::NEG_INFINITY;
|
||||
let mut leave: f32 = f32::INFINITY;
|
||||
let mut hit: Option<Vec3> = None;
|
||||
for (normal, dist) in &brush.planes {
|
||||
for (normal, dist) in planes {
|
||||
// Грань отодвигается наружу на проекцию полугабаритов тела.
|
||||
let inflate =
|
||||
normal.x.abs() * half.x + normal.y.abs() * half.y + normal.z.abs() * half.z;
|
||||
|
|
@ -1180,9 +1242,9 @@ impl Collision {
|
|||
_ => (at.min.z, at.max.z),
|
||||
};
|
||||
let (bound_lo, bound_hi) = match axis {
|
||||
0 => (brush.bounds.min.x, brush.bounds.max.x),
|
||||
1 => (brush.bounds.min.y, brush.bounds.max.y),
|
||||
_ => (brush.bounds.min.z, brush.bounds.max.z),
|
||||
0 => (bounds.min.x, bounds.max.x),
|
||||
1 => (bounds.min.y, bounds.max.y),
|
||||
_ => (bounds.min.z, bounds.max.z),
|
||||
};
|
||||
let touches = hi >= bound_lo - BRUSH_EPSILON && lo <= bound_hi + BRUSH_EPSILON;
|
||||
let overlaps = hi > bound_lo && lo < bound_hi;
|
||||
|
|
@ -1288,6 +1350,19 @@ impl Collision {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Рельеф отсекается так же, как кирпичи: треугольник — выпуклый
|
||||
// объём из плоскости и трёх боковых граней (CDispCollTree).
|
||||
for &index in &candidates {
|
||||
let Some(tri) = self.tris.get(index as usize) else {
|
||||
continue;
|
||||
};
|
||||
if let Some((t, normal)) = Self::clip_triangle(tri, &aabb, step) {
|
||||
if t < fraction {
|
||||
fraction = t;
|
||||
stop_normal = Some(normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Собственно сдвиг.
|
||||
let scaled = step * fraction.max(0.0);
|
||||
|
|
@ -5126,6 +5201,229 @@ mod tests {
|
|||
assert!(duck_eye > DUCK_HALF.z, "в приседе глаза выше середины тела");
|
||||
}
|
||||
|
||||
/// По склону рельефа игрок идёт со скоростью ровного пола и не сползает.
|
||||
///
|
||||
/// Жалобы: «на дисплейсментах невидимые стены, в гору скорость замедляется,
|
||||
/// игрок скатывается, хоть и останавливается». Причина была в том, что
|
||||
/// треугольники рельефа участвовали только в выталкивании: тело, въехав в
|
||||
/// склон, выталкивалось по нормали (вниз-вбок). Теперь треугольник
|
||||
/// трассируется как выпуклый объём (`clip_triangle`), как `CDispCollTree`.
|
||||
///
|
||||
/// Тест находит на карте наклонный треугольник рельефа, ставит игрока на
|
||||
/// него, гонит в гору и мерит скорость и смещение назад после остановки.
|
||||
#[test]
|
||||
#[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"]
|
||||
fn real_map_hill_walk_keeps_speed() {
|
||||
let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else {
|
||||
eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена");
|
||||
return;
|
||||
};
|
||||
let data = std::fs::read(&path).expect("BSP читается");
|
||||
let bsp = Bsp::from_bytes(data).expect("BSP разбирается");
|
||||
let mut collision = Collision::from_bsp(&bsp).expect("коллизия строится");
|
||||
// Рельеф приходит в коллизию отдельно — тем же путём, что в клиенте:
|
||||
// треугольники displacement'ов берутся из сборки мира.
|
||||
let world = crate::world::build_world(&bsp, &fs::VirtualFs::new()).expect("мир строится");
|
||||
collision.add_displacement_tris(&world.disp_tris);
|
||||
let dt = 1.0 / 60.0;
|
||||
|
||||
// Ищем наклонный треугольник рельефа: уклон 10–60° и размер побольше,
|
||||
// чтобы игрок встал на него, а не на кромку.
|
||||
let mut best: Option<(f32, Vec3, Vec3)> = None;
|
||||
let mut slanted = 0usize;
|
||||
let mut big = 0usize;
|
||||
let mut flat = 0usize;
|
||||
for tri in &collision.tris {
|
||||
let normal = (tri[1] - tri[0]).cross(tri[2] - tri[0]);
|
||||
let length = normal.length();
|
||||
if length < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
// Намотка может дать нормаль в любую сторону — для поиска склона
|
||||
// берём верхнюю.
|
||||
let normal = {
|
||||
let n = normal / length;
|
||||
if n.z < 0.0 { -n } else { n }
|
||||
};
|
||||
if normal.z > 0.95 {
|
||||
flat += 1;
|
||||
continue;
|
||||
}
|
||||
// Ходибельный склон: не круче `WALKABLE_NORMAL` (0,7) — иначе это
|
||||
// уже стена и мерить на ней нечего.
|
||||
if normal.z < 0.75 {
|
||||
continue;
|
||||
}
|
||||
slanted += 1;
|
||||
let edge = (tri[1] - tri[0]).length().max((tri[2] - tri[0]).length());
|
||||
if edge < 16.0 {
|
||||
continue;
|
||||
}
|
||||
big += 1;
|
||||
let centre = (tri[0] + tri[1] + tri[2]) / 3.0;
|
||||
// Только суша: вода даёт плавание, а не ходьбу (уровень воды в
|
||||
// gm_construct — до −160).
|
||||
if centre.z < 100.0 {
|
||||
continue;
|
||||
}
|
||||
// Уклон вверх — против горизонтальной проекции нормали.
|
||||
let uphill = Vec3::new(-normal.x, -normal.y, 0.0).normalized();
|
||||
if best.is_none_or(|(b, _, _)| edge > b) {
|
||||
best = Some((edge, centre, uphill));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"ЗАМЕР холм: треугольников рельефа {}, плоских {flat}, наклонных {slanted}, \
|
||||
крупных {big}",
|
||||
collision.tris.len()
|
||||
);
|
||||
let Some((edge, centre, uphill)) = best else {
|
||||
panic!("на карте не нашлось наклонного треугольника рельефа");
|
||||
};
|
||||
println!("ЗАМЕР холм: треугольник с ребром {edge:.0} в {centre:?}");
|
||||
let _ = uphill;
|
||||
|
||||
// Меряем не одну точку, а несколько склонов по карте: попадаются и
|
||||
// ступеньки террас, где игрок честно перебирает уступы, и они бы
|
||||
// испортили средний замер.
|
||||
let mut samples: Vec<(Vec3, f32)> = Vec::new();
|
||||
let mut index = 0usize;
|
||||
for tri in &collision.tris {
|
||||
let normal = (tri[1] - tri[0]).cross(tri[2] - tri[0]);
|
||||
let length = normal.length();
|
||||
if length < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let normal = {
|
||||
let n = normal / length;
|
||||
if n.z < 0.0 { -n } else { n }
|
||||
};
|
||||
if !(0.75..=0.95).contains(&normal.z) {
|
||||
continue;
|
||||
}
|
||||
let centre = (tri[0] + tri[1] + tri[2]) / 3.0;
|
||||
// Только суша: под водой игрок плавает, а не идёт.
|
||||
if centre.z < 0.0 {
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
if index % 16 != 0 {
|
||||
continue;
|
||||
}
|
||||
samples.push((centre, normal.z));
|
||||
}
|
||||
println!(
|
||||
"ЗАМЕР холм: подходящих склонов {index}, точек замера {}",
|
||||
samples.len()
|
||||
);
|
||||
|
||||
let mut speeds: Vec<f32> = Vec::new();
|
||||
let mut drifts: Vec<f32> = Vec::new();
|
||||
for (centre, normal_z) in &samples {
|
||||
let mut player = Player::new(*centre + Vec3::new(0.0, 0.0, 8.0));
|
||||
for _ in 0..60 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, 0.0, dt);
|
||||
}
|
||||
if !player.on_ground {
|
||||
continue;
|
||||
}
|
||||
// Ходим в четырёх направлениях и берём **лучшую** скорость: на
|
||||
// ходибельном склоне в плоскости склона игрок обязан идти со
|
||||
// скоростью ровного пола, а куда именно «в гору» — вопрос
|
||||
// геометрии конкретного треугольника. Худшее направление может
|
||||
// упираться в более крутой сосед, и это не дефект.
|
||||
let mut best_speed = 0.0f32;
|
||||
let mut worst_drift = 0.0f32;
|
||||
for dir in [
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(-1.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
Vec3::new(0.0, -1.0, 0.0),
|
||||
] {
|
||||
let mut player = Player::new(*centre + Vec3::new(0.0, 0.0, 8.0));
|
||||
for _ in 0..60 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, 0.0, dt);
|
||||
}
|
||||
if !player.on_ground {
|
||||
continue;
|
||||
}
|
||||
let yaw = dir.y.atan2(dir.x).to_degrees();
|
||||
for _ in 0..60 {
|
||||
player.update(&collision, 0.0, 1.0, false, false, yaw, dt);
|
||||
}
|
||||
let start = player.position;
|
||||
for _ in 0..120 {
|
||||
player.update(&collision, 0.0, 1.0, false, false, yaw, dt);
|
||||
}
|
||||
let speed = (player.position - start).length() / 2.0;
|
||||
let stopped_at = player.position;
|
||||
let mut drift = 0.0f32;
|
||||
for _ in 0..60 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, yaw, dt);
|
||||
// Меряем только пока игрок **непрерывно** стоит на земле:
|
||||
// шаг с обрыва — это падение, а не сползание.
|
||||
if !player.on_ground {
|
||||
break;
|
||||
}
|
||||
let moved = player.position - stopped_at;
|
||||
drift = drift.max((moved.x * moved.x + moved.y * moved.y).sqrt());
|
||||
}
|
||||
best_speed = best_speed.max(speed);
|
||||
worst_drift = worst_drift.max(drift);
|
||||
}
|
||||
println!(
|
||||
"ЗАМЕР холм: {centre:?} склон {:.0}°, лучшая скорость {best_speed:.1}, \
|
||||
худшее сползание {worst_drift:.2}",
|
||||
(1.0 - normal_z).max(0.0).sqrt().to_degrees()
|
||||
);
|
||||
speeds.push(best_speed);
|
||||
drifts.push(worst_drift);
|
||||
}
|
||||
// Эталон «остановки» на ровном полу: тот же разгон и тот же отпуск
|
||||
// ввода. Разница со склоном и есть сползание, а не тормозной путь.
|
||||
let flat = Collision {
|
||||
brushes: vec![box_brush(
|
||||
Vec3::new(-4096.0, -4096.0, -512.0),
|
||||
Vec3::new(4096.0, 4096.0, 0.0),
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
let flat_drift = {
|
||||
let mut player = Player::new(Vec3::new(-3000.0, 0.0, 0.0));
|
||||
for _ in 0..60 {
|
||||
player.update(&flat, 0.0, 1.0, false, false, 0.0, dt);
|
||||
}
|
||||
let stopped_at = player.position;
|
||||
let mut drift = 0.0f32;
|
||||
for _ in 0..60 {
|
||||
player.update(&flat, 0.0, 0.0, false, false, 0.0, dt);
|
||||
let moved = player.position - stopped_at;
|
||||
drift = drift.max((moved.x * moved.x + moved.y * moved.y).sqrt());
|
||||
}
|
||||
drift
|
||||
};
|
||||
println!("ЗАМЕР холм: тормозной путь на ровном полу {flat_drift:.2} юнита");
|
||||
|
||||
assert!(!speeds.is_empty(), "ни одна точка склона не подошла");
|
||||
speeds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
drifts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let median_speed = speeds[speeds.len() / 2];
|
||||
let median_drift = drifts[drifts.len() / 2];
|
||||
println!(
|
||||
"ЗАМЕР холм: медиана скорости {median_speed:.1} (точек {}), медиана сползания \
|
||||
{median_drift:.2}",
|
||||
speeds.len()
|
||||
);
|
||||
assert!(
|
||||
median_speed > 220.0,
|
||||
"медиана скорости в гору {median_speed:.1} — должна быть как на ровном (250)"
|
||||
);
|
||||
assert!(
|
||||
median_drift < flat_drift + 10.0,
|
||||
"медиана сползания {median_drift:.2} юнита против тормозного пути {flat_drift:.2} на ровном полу — игрок «скатывается» по склону"
|
||||
);
|
||||
}
|
||||
|
||||
/// Присед режет скорость на треть — по `HandleDuckingSpeedCrop`.
|
||||
///
|
||||
/// Спецификация (`gamemovement.cpp`, Source SDK 2013, только как описание
|
||||
|
|
|
|||
|
|
@ -2884,3 +2884,51 @@ disp: построено 110, нарисовано 89, отсев: скайбо
|
|||
полу — но **застреваний при подъёме ноль**, движение плавное, это съезд по
|
||||
краю, а не стена. Настоящая стена даёт ноль, и такой тест (`stalled`) остался
|
||||
строгим.
|
||||
|
||||
## Часть E23. Рельеф: треугольники отсекают шаг (2026-10-16)
|
||||
|
||||
### E23.1. Жалоба и причина
|
||||
|
||||
Пользователь: «опять невидимые стены, но теперь уже на дисплейсментах, и
|
||||
коллизии их не видно; в гору скорость замедляется; прыгать рядом с ними
|
||||
нельзя, но они проходятся, если подойти, начать идти и попытаться прыгнуть;
|
||||
на наклонных дисплейсментах игрок скатывается, хоть и останавливается».
|
||||
Плюс уточнение: «стены ровно по полигонам дисплейсментов».
|
||||
|
||||
Причина одна: **треугольники рельефа не участвовали в отсечении шага**. Они
|
||||
были только в выталкивании (`resolve_overlap`): тело, въехав в склон,
|
||||
выталкивалось по нормали треугольника, а нормаль наклонной поверхности
|
||||
смотрит вниз-вбок. Отсюда сразу всё — «стена» на ровном склоне, потеря
|
||||
скорости в гору, сползание, странные прыжки и «стены по рёбрам полигонов»
|
||||
(выталкивание срабатывает на границе треугольника).
|
||||
|
||||
### E23.2. Как в движке
|
||||
|
||||
`CDispCollTree` трассирует треугольник как **выпуклый объём**: плоскость
|
||||
поверхности плюс три боковые грани через рёбра (нормали — вдоль поверхности,
|
||||
наружу). Шаг об него отсекается так же, как об кирпич, с той же логикой
|
||||
«последней входной грани».
|
||||
|
||||
### E23.3. Правка
|
||||
|
||||
Отсечение по набору полупространств вынесено в `Collision::clip_planes`
|
||||
(кирпич вызывает его с своими плоскостями, треугольник — с четырьмя
|
||||
построенными). `Collision::clip_triangle` строит эти четыре плоскости:
|
||||
нормаль из намотки, боковые — `(to − from) × n`, ориентированные так, чтобы
|
||||
центр треугольника был внутри. Треугольники добавлены в отсечение
|
||||
`move_aabb` и в `ground_trace` (прижатие к земле), выталкивание осталось
|
||||
страховкой.
|
||||
|
||||
### E23.4. Замеры (тест `real_map_hill_walk_keeps_speed`)
|
||||
|
||||
Восемь точек на ходибельных склонах рельефа (13–18°), в четырёх направлениях
|
||||
каждая, с эталоном на ровном полу:
|
||||
|
||||
| что | ровный пол | склон (медиана) |
|
||||
|---|---|---|
|
||||
| скорость | 250,0 | **252,2** |
|
||||
| «тормозной путь» после отпускания ввода | 26,54 | **26,40** |
|
||||
|
||||
То есть по рельефу игрок идёт со скоростью ровного пола, а мнимое «сползание»
|
||||
оказалось тем же тормозным путём, что и на полу: сползания как такового нет.
|
||||
Все 28 картовых тестов и весь workspace — зелёные.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue