From d88988fc671d704b42072ef75e7784b1179df440 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Fri, 16 Oct 2026 21:54:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D1=8C=D0=B5=D1=84:=20=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D1=83=D0=B3=D0=BE=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=D1=81=D0=B5=D0=BA=D0=B0=D1=8E=D1=82=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3,=20=D0=BA=D0=B0=D0=BA=20=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D1=87=D0=B8=20=E2=80=94=20=D1=81=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D1=81=D0=BF=D0=BE=D0=BB=D0=B7=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=83=D1=88=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Жалобы: «невидимые стены уже на дисплейсментах, и коллизии их не видно; в гору скорость замедляется; прыгать рядом нельзя, но проходятся, если подойти и прыгнуть; на наклонных дисплейсментах скатывается» — и уточнение пользователя: «стены ровно по полигонам дисплейсментов». Причина одна: треугольники рельефа участвовали только в выталкивании. Тело, въехав в склон, выталкивалось по нормали треугольника, а нормаль наклонной поверхности смотрит вниз-вбок. Отсюда всё сразу: стена на ровном склоне, потеря скорости в гору, сползание, странные прыжки и стены по рёбрам полигонов (выталкивание срабатывает на границе треугольника). Как в движке: 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. --- crates/engine/src/player.rs | 318 ++++++++++++++++++++++++++++++++++-- docs/PARITY.md | 48 ++++++ 2 files changed, 356 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index c9467f2..faf8a9c 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -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 = 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 = Vec::new(); + let mut drifts: Vec = 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, только как описание diff --git a/docs/PARITY.md b/docs/PARITY.md index 4da72db..ec6d426 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -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 — зелёные.