diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index 04bbfe5..27a1bea 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -1105,8 +1105,12 @@ const GRAVITY: f32 = 600.0; /// поднят на порядок, иначе падение упирается в него и перестаёт расти. const MAX_VELOCITY: f32 = 3500.0; -/// Скорость прыжка (`m_flJumpSpeed` в GMod = 270). -const JUMP_SPEED: f32 = 270.0; +/// Скорость прыжка — `Player:GetJumpPower()` в GMod по умолчанию **200**. +/// +/// Вики GMod в примере `GetJumpPower` печатает 200; величина 270 из Source SDK +/// к GMod отношения не имеет, и была взята по привычке. Отсюда прыжок был +/// 60,8 юнита вместо 200²/1200 = **33,3** — «слишком высокий». +const JUMP_SPEED: f32 = 200.0; /// Скорость шага (`+forward speed` в Source = 250). const WALK_SPEED: f32 = 250.0; /// Скорость бега (`+speed` в Source = 520). @@ -2295,8 +2299,9 @@ mod tests { let before = player.velocity.z; player.update(&collision, 0.0, 0.0, true, false, 0.0, 1.0 / 60.0); // Прыжок — единственный кадр, где вертикальная скорость взлетает - // до значения прыжка. - if player.velocity.z > 250.0 && before <= 0.0 { + // до значения прыжка. Порог от `JUMP_SPEED`, а не число: прыжок в + // GMod это 200 (`GetJumpPower`), а не 270 из Source SDK. + if player.velocity.z > JUMP_SPEED * 0.9 && before <= 0.0 { jumps += 1; } } @@ -3059,6 +3064,106 @@ mod tests { ); } + /// Есть ли в рендере поверхность под заданной точкой. + /// + /// Диагностика жалобы «сквозь дырки в крыше упасть нельзя»: ищем точки, + /// где игрок стоит на твёрдом кирпиче, а нарисованной поверхности над ним + /// нет — то есть ровно то место, где игрок видит дырку и не может + /// провалиться. + #[test] + #[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP и SOURCE_RS_TEST_BSP_AT"] + fn probe_drawn_surface_under() { + use crate::world::build_world; + use fs::VirtualFs; + + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + return; + }; + let Ok(at) = std::env::var("SOURCE_RS_TEST_BSP_AT") else { + return; + }; + let parts: Vec = at + .split(',') + .map(|v| v.trim().parse::().unwrap_or(0.0)) + .collect(); + let center = Vec3::new(parts[0], parts[1], parts[2]); + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let world = build_world(&bsp, &VirtualFs::new()).expect("мир строится"); + let collision = Collision::from_bsp(&bsp).expect("коллизия"); + + /// Точка внутри треугольника по его проекции на XY. + fn point_in_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option { + let d = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y); + if d.abs() < 1e-9 { + return None; + } + let w0 = ((b.z - c.z) * (p.x - c.x) + (c.x - b.x) * (p.y - c.y)) / d; + let w1 = ((c.z - a.z) * (p.x - c.x) + (a.x - c.x) * (p.y - c.y)) / d; + let w2 = 1.0 - w0 - w1; + if w0 < -1e-3 || w1 < -1e-3 || w2 < -1e-3 { + return None; + } + Some(a.z * w0 + b.z * w1 + c.z * w2) + } + + let step = 24.0f32; + let mut solid = 0usize; + let mut invisible = 0usize; + let mut samples: Vec<(f32, f32, f32, Option)> = Vec::new(); + let mut x = center.x - 160.0; + while x <= center.x + 160.0 { + let mut y = center.y - 160.0; + while y <= center.y + 160.0 { + // Куда игрок встаёт, если его сбросить сверху. + let mut drop = Player::new(Vec3::new(x, y, center.z + 200.0)); + for _ in 0..300 { + drop.update(&collision, 0.0, 0.0, false, false, 0.0, 1.0 / 60.0); + } + if !drop.on_ground { + y += step; + continue; + } + solid += 1; + // Что нарисовано прямо НА МЕСТЕ, где он стоит: берём все + // треугольники, накрывающие точку по XY, и ищем тот, что + // совпадает с местом приземления. + // + // Раньше здесь брался максимальный z над точкой — это ошибка: + // крыша тысячу юнитов выше заслоняла собой крышу под ногами, и + // все 196 точек объявлялись «без рисунка». + let probe = Vec3::new(x, y, 0.0); + let mut covers = false; + for tri in world.indices.chunks_exact(3) { + let v = |i: usize| { + let v = world.vertices[tri[i] as usize].pos; + Vec3::new(v[0], v[1], v[2]) + }; + if let Some(z) = point_in_tri(probe, v(0), v(1), v(2)) { + if (z - drop.position.z).abs() < 8.0 { + covers = true; + break; + } + } + } + if !covers { + invisible += 1; + if samples.len() < 8 { + samples.push((x, y, drop.position.z, None)); + } + } + y += step; + } + x += step; + } + println!("ЗАМЕР крыша вокруг {center:?}: мест {solid}, твёрдо без рисунка {invisible}"); + for (x, y, z, drawn) in samples.iter().take(8) { + println!( + "ЗАМЕР точка ({x:.0}, {y:.0}): игрок стоит на z={z:.1}, нарисовано {drawn:?}" + ); + } + } + /// Что за геометрия вокруг заданной точки: кирпичи и содержимое. /// /// Диагностика жалобы «сквозь дырки в крыше упасть нельзя». Точка и радиус @@ -3116,42 +3221,111 @@ mod tests { } println!("ЗАМЕР рядом: кирпичей {brushes}, треугольников {tris}"); + // Сколько граней рендер теперь пропускает: было 2451 до исправления + // флагов, должно стать заметно меньше. + { + use crate::world::build_world; + use fs::VirtualFs; + let world = build_world(&bsp, &VirtualFs::new()).expect("мир строится"); + println!( + "ЗАМЕР мир: граней пропущено {}, треугольников {}", + world.stats.skipped, world.stats.triangles + ); + } + // Флаги граней рядом: рисуется грань или нет. Кирпич может быть твёрдым // и при этом не иметь ни одной нарисованной грани — это и есть // невидимая стена. + // Флаги граней, лежащих ровно на уровне, где стоит игрок: если у них + // стоит «запрет рисования», то крыша твёрдая, но невидимая. let geometry = bsp.geometry().expect("геометрия"); - let mut flagged: Vec<(i32, Vec3)> = Vec::new(); + let texdata = bsp.texdata().expect("texdata"); + let names = bsp.texture_names().expect("имена текстур"); let mut plain = 0usize; - for (index, face) in geometry.faces.iter().enumerate() { + let mut noportal = 0usize; + let mut flagged: Vec<(i32, String, f32)> = Vec::new(); + for face in geometry.faces.iter() { let Some(plane) = geometry.planes.get(face.planenum as usize) else { continue; }; let normal = Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]); let dist = plane.dist; - // Плоскость должна проходить рядом с точкой. - if (normal.dot(center) - dist).abs() > 300.0 { + if (normal.dot(center) - dist).abs() > 8.0 { continue; } let Some(texinfo) = geometry.texinfos.get(face.texinfo as usize) else { continue; }; - let skip = texinfo.flags & (0x20 | 0x80 | 0x40 | 0x200) != 0; - if skip { + // `SURF_NOPORTAL` = 0x20: грань пропускает порталы, но рисуется как + // обычно. У нас же 0x20 считался за «не рисовать», и такие грани + // просто исчезали из картинки. + if texinfo.flags & 0x20 != 0 { + noportal += 1; + } + // Маска рендерера из world.rs. + if texinfo.flags & (0x4 | 0x8 | 0x20 | 0x40 | 0x80) != 0 { + let name = texdata + .get(texinfo.texdata.max(0) as usize) + .map(|t| { + names + .get(t.name_id.max(0) as usize) + .cloned() + .unwrap_or_default() + }) + .unwrap_or_default(); if flagged.len() < 20 { - flagged.push((texinfo.flags, Vec3::new(index as f32, dist, 0.0))); + flagged.push((texinfo.flags, name, dist)); } } else { plain += 1; } } println!( - "ЗАМЕР граней рядом: рисуемых {plain}, с запретом рисования {}", + "ЗАМЕР граней на уровне игрока: рисуемых {plain}, пропущенных {}, из них с SURF_NOPORTAL (0x20) {noportal}", flagged.len() ); - for (flags, _) in flagged.iter().take(20) { - println!("ЗАМЕР грань с флагами {flags:#x}"); + for (flags, name, dist) in flagged.iter().take(20) { + println!("ЗАМЕР грань z={dist:.1} флаги {flags:#x} текстура {name:?}"); } + // Флаги по всей карте: что скрывает маска рендерера. + // + // Настоящие значения из `public/bspflags.h` Source: + // SURF_SKY 0x4, SURF_WARP 0x8, SURF_NOPORTAL 0x20, + // SURF_TRIGGER 0x40, SURF_NODRAW 0x80, SURF_HINT 0x100, + // SURF_SKIP 0x200, SURF_NOLIGHT 0x400. + // + // У нас стояло `NODRAW = 0x20`, `HINT = 0x40`, `SKIP = 0x80` — то есть + // под «не рисуем» попадали `NOPORTAL` и `TRIGGER`, а настоящие `NODRAW`, + // `HINT` и `SKIP` проходили в рендер. + let mut old_mask = 0usize; + let mut new_mask = 0usize; + let mut newly_hidden = 0usize; + let mut newly_shown = 0usize; + for face in geometry.faces.iter() { + let Some(texinfo) = geometry.texinfos.get(face.texinfo as usize) else { + continue; + }; + let flags = texinfo.flags; + let old = flags & (0x4 | 0x8 | 0x20 | 0x40 | 0x80) != 0; + let new = flags & (0x2 | 0x4 | 0x80 | 0x100 | 0x200) != 0; + if old { + old_mask += 1; + } + if new { + new_mask += 1; + } + if new && !old { + newly_hidden += 1; + } + if old && !new { + newly_shown += 1; + } + } + println!( + "ЗАМЕР флаги граней: старая маска скрывала {old_mask}, новая {new_mask}, появилось в картинке {newly_shown}, пропало из картинки {newly_hidden}" + ); + // Расклад содержимого по всей карте: что вообще попадает в коллизию. let brush_data = bsp.lump(lump::BRUSHES).expect("BRUSHES"); let mut hist: HashMap = HashMap::new(); @@ -3459,8 +3633,8 @@ mod tests { println!("ЗАМЕР дёрганье назад за 1с разгона: {jitter:.2} юнитов"); assert!(jitter < 1.0, "игрок дёргается назад: {jitter:.2} юнитов"); - // 4. Прыжок: 270 юнитов/с при `sv_gravity` = 600 даёт 270²/1200 ≈ 60,8 - // юнита высоты — как в GMod (в Source гравитация 800 и высота 45,6). + // 4. Прыжок: `GetJumpPower` = 200 при `sv_gravity` = 600 даёт + // 200²/1200 = 33,3 юнита высоты — как в GMod. let mut player = Player::new(Vec3::new(spawn.x, spawn.y, rest_z)); for _ in 0..10 { player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); @@ -3476,8 +3650,8 @@ mod tests { peak = peak.max(player.position.z); } let height = peak - ground_z; - // Дуга прыжка целиком: до вершины, обратно. В GMod при скорости 270 - // и `sv_gravity` = 600 это ровно 0,45 с вверх и 0,9 с полёта. + // Дуга прыжка целиком: до вершины, обратно. В GMod при скорости прыжка + // 200 и `sv_gravity` = 600 это ровно 0,333 с вверх и 0,667 с полёта. { let mut arc = Player::new(Vec3::new(spawn.x, spawn.y, rest_z)); for _ in 0..10 { @@ -3505,21 +3679,21 @@ mod tests { apex - ground ); assert!( - (apex_frame as f32 * dt - 0.45).abs() < 0.05, - "до вершины должно быть 0,45 с, а вышло {:.2}", + (apex_frame as f32 * dt - 1.0 / 3.0).abs() < 0.04, + "до вершины должно быть 0,33 с, а вышло {:.2}", apex_frame as f32 * dt ); assert!( - (land_frame as f32 * dt - 0.9).abs() < 0.06, - "полёт должен длиться 0,9 с, а вышел {:.2}", + (land_frame as f32 * dt - 2.0 / 3.0).abs() < 0.05, + "полёт должен длиться 0,67 с, а вышел {:.2}", land_frame as f32 * dt ); } - println!("ЗАМЕР прыжок: оторвался={left_ground} высота={height:.1} юнита (в GMod ~60,8)"); + println!("ЗАМЕР прыжок: оторвался={left_ground} высота={height:.1} юнита (в GMod ~33,3)"); assert!(left_ground, "прыжок не оторвал игрока от земли"); assert!( - height > 50.0, - "прыжок слишком низкий: {height:.1} юнита при ~60,8 в GMod" + height > 28.0, + "прыжок слишком низкий: {height:.1} юнита при ~33,3 в GMod" ); // 5. Падение сквозз мир: за 30 секунд ходьбы игрок либо на земле, либо diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index a6ac5af..3a16e6e 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -10,13 +10,32 @@ use fs::{FsError, VirtualFs}; use math::Vec3; use std::collections::HashMap; -/// Флаги `texinfo.flags` (SURF_*). +/// Флаги `texinfo.flags` (`SURF_*`) — значения из `public/bspflags.h` Source. +/// +/// Раньше здесь стояли другие числа, и это стоило 100 граней на gm_construct: +/// под «не рисуем» попадали `SURF_NOPORTAL` и `SURF_TRIGGER`, которые рисуются +/// как обычные, а настоящие `SURF_NODRAW`, `SURF_HINT` и `SURF_SKIP` в рендер +/// проходили. Итог — дырки в картинке при твёрдой коллизии: в дырку не видно, +/// а сквозь неё не упасть. +/// +/// ```text +/// SURF_SKY2D 0x0002 // не рисуем, но рисуем 2D-небо +/// SURF_SKY 0x0004 // не рисуем, но отдаём в скайбокс +/// SURF_WARP 0x0008 // вода +/// SURF_NOPORTAL 0x0020 // порталы не ставим — грань рисуется как обычно +/// SURF_TRIGGER 0x0040 // служебный флаг, не про «не рисовать» +/// SURF_NODRAW 0x0080 // не рисуем вовсе +/// SURF_HINT 0x0100 // грань-подсказка для разбиения BSP +/// SURF_SKIP 0x0200 // игнорируем, допускает незамкнутые кирпичи +/// SURF_NOLIGHT 0x0400 // без освещения +/// ``` +const SURF_SKY2D: i32 = 0x2; const SURF_SKY: i32 = 0x4; /// SURF_WARP — вода (шейдер Water). const SURF_WARP: i32 = 0x8; -const SURF_NODRAW: i32 = 0x20; -const SURF_HINT: i32 = 0x40; -const SURF_SKIP: i32 = 0x80; +const SURF_NODRAW: i32 = 0x80; +const SURF_HINT: i32 = 0x100; +const SURF_SKIP: i32 = 0x200; /// Специальные материалы, которые не рендерим. const SKIP_MATERIALS: &[&str] = &[ @@ -954,7 +973,10 @@ fn resolve_material( checker: &TextureData, stats: &mut WorldStats, ) -> Resolved { - if texinfo.flags & (SURF_NODRAW | SURF_HINT | SURF_SKIP | SURF_SKY) != 0 { + // `SURF_NOPORTAL` и `SURF_TRIGGER` в маску **не входят**: обе грань + // рисуются как обычная. `SURF_SKY2D` тоже пропускаем — двумерное небо + // рисует отдельный проход. + if texinfo.flags & (SURF_NODRAW | SURF_HINT | SURF_SKIP | SURF_SKY | SURF_SKY2D) != 0 { return Resolved::Skip; } if texinfo.texdata < 0 { @@ -2328,6 +2350,46 @@ mod tests { ); } + /// Флаги граней совпадают с `public/bspflags.h` Source. + /// + /// Значения были перепутаны местами, и это стоило 100 граней на gm_construct: + /// под «не рисуем» попадали `SURF_NOPORTAL` и `SURF_TRIGGER`, которые рисуются + /// как обычные. Итог — дырки в картинке при твёрдой коллизии: в дырку не + /// видно, а сквозь неё не упасть. + #[test] + fn surface_flags_match_source_bspflags() { + assert_eq!(SURF_SKY2D, 0x0002, "SURF_SKY2D"); + assert_eq!(SURF_SKY, 0x0004, "SURF_SKY"); + assert_eq!(SURF_WARP, 0x0008, "SURF_WARP"); + assert_eq!(SURF_NODRAW, 0x0080, "SURF_NODRAW"); + assert_eq!(SURF_HINT, 0x0100, "SURF_HINT"); + assert_eq!(SURF_SKIP, 0x0200, "SURF_SKIP"); + } + + /// Грань рисуется, если на ней нет ни одного «скрывающего» флага. + /// + /// Проверяется именно то, из-за чего появились дырки: `SURF_NOPORTAL` + /// (0x20) и `SURF_TRIGGER` (0x40) в маску пропуска не входят. + #[test] + fn skip_mask_covers_only_faces_that_are_not_drawn() { + let hidden = SURF_SKY2D | SURF_SKY | SURF_NODRAW | SURF_HINT | SURF_SKIP; + for flags in [0x20, 0x40, 0x400, 0x1000, 0x10, 0] { + assert!( + flags & hidden == 0, + "грань с флагами {flags:#x} не должна скрываться" + ); + } + for flags in [SURF_SKY2D, SURF_SKY, SURF_NODRAW, SURF_HINT, SURF_SKIP] { + assert_ne!( + flags & hidden, + 0, + "грань с флагом {flags:#x} обязана скрываться" + ); + } + // Вода рисуется отдельным проходом: SURF_WARP в маску не входит. + assert_eq!(SURF_WARP & hidden, 0, "вода рисуется отдельным проходом"); + } + #[test] fn checker_is_opaque() { let t = checker_texture("test"); diff --git a/docs/PARITY.md b/docs/PARITY.md index 956d88f..60f8e2d 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1380,3 +1380,61 @@ GMod (замерено 60,8). В Source с гравитацией 800 было 4 Чтобы довести до причины, нужны координаты конкретной дырки: без них единственный способ — гадать, а это прямо запрещено правилом проекта. + +## Часть B10. Флаги граней и высота прыжка (сделано) + +### B10.1. «Дырки в крыше, сквозь которые нельзя упасть»: неверные флаги граней + +Проверено по указанной точке (−1388, −1503, 240): игрок там стоит на твёрдом +кирпиче, но **нарисованной поверхности на месте нет**. + +Причина — константы `SURF_*` в `world.rs` были перепутаны. Настоящие значения +из `public/bspflags.h` Source: + +| флаг | правильно | было у нас | +|---|---|---| +| `SURF_SKY2D` | 0x0002 | — | +| `SURF_SKY` | 0x0004 | 0x4 | +| `SURF_WARP` | 0x0008 | 0x8 | +| `SURF_NOPORTAL` | 0x0020 | **0x20 — и считался за «не рисовать»** | +| `SURF_TRIGGER` | 0x0040 | **0x40 — и считался за «не рисовать»** | +| `SURF_NODRAW` | 0x0080 | **0x80 — но назывался SKIP** | +| `SURF_HINT` | 0x0100 | 0x40 — не скрывался вовсе | +| `SURF_SKIP` | 0x0200 | 0x80 — не скрывался вовсе | + +`SURF_NOPORTAL` означает «на этой грани нельзя поставить портал», а +`SURF_TRIGGER` — служебный флаг. Ни то, ни другое не имеет отношения к +рисованию, а мы их пропускали. Итог — **граней в картинке меньше, чем в +коллизии**: дырка, в которую не видно, но сквозь которую не упасть. + +Замер на gm_construct: старая маска скрывала 2511 граней, новая — 2411; +**появилось в картинке 100 граней, пропало 0**. + +Тесты: `surface_flags_match_source_bspflags` (значения закреплены по +первоисточнику) и `skip_mask_covers_only_faces_that_are_not_drawn` (в маску не +входят `NOPORTAL` и `TRIGGER`). + +### B10.2. «Прыжки марсианские» — они были выше GMod в 1,8 раза + +Дуга прыжка снималась и совпадала с расчётом — но расчёт был неверен. Скорость +прыжка бралась как 270 из Source SDK, а у GMod она своя: вики GMod в примере +`Player:GetJumpPower()` печатает **200**. + +| | было (270) | GMod (200) | замерено после | +|---|---|---|---| +| высота | 60,8 юнита | **33,3** | 33,3 | +| до вершины | 0,45 с | **0,33 с** | 0,33 | +| полёт | 0,90 с | **0,67 с** | 0,67 | + +`sv_gravity` = 600 остаётся — он подтверждён вики GMod отдельно. + +### B10.3. Ошибки измерений, которые пришлось исправить + +Две, обе нашли чужие грани и на них строились выводы: + +- **Максимальный z над точкой** вместо высоты поверхности **на месте**: крыша на + z = 1296 заслоняла крышу на z = 240, и все 196 проб объявлялись «без рисунка». +- **Зазор между габаритами без клампа по нулю**: по пересекающейся оси + `max` двух отрицательных давал отрицательное число, а длина вектора — + «912 юнитов зазора» там, где габариты соприкасаются. На этом артефакте + диагностика рапортовала 35 невидимых стен при реальном нуле.