Прыжок 200 вместо 270 и исправлены флаги граней — пропало 100 дырок в картинке

«Дырки в крыше, сквозь которые нельзя упасть»: граней в картинке было меньше,
чем в коллизии. Причина — константы SURF_* в world.rs были перепутаны. По
public/bspflags.h Source настоящие значения:

  SURF_SKY2D    0x0002      SURF_NOPORTAL 0x0020
  SURF_SKY      0x0004      SURF_TRIGGER  0x0040
  SURF_WARP     0x0008      SURF_NODRAW   0x0080
  SURF_HINT     0x0100      SURF_SKIP     0x0200

SURF_NOPORTAL означает «нельзя поставить портал», SURF_TRIGGER — служебный
флаг; ни то, ни другое не про рисование, а мы их пропускали. Замер на
gm_construct: старая маска скрывала 2511 граней, новая 2411 — в картинке
появилось 100 граней, не пропало ни одной.

Тесты surface_flags_match_source_bspflags и
skip_mask_covers_only_faces_that_are_not_drawn не дадут значениям снова разойтись.

«Прыжки марсианские»: скорость прыжка бралась как 270 из Source SDK, а у GMod
она своя — вики GMod в примере Player:GetJumpPower() печатает 200. Прыжок был
выше в 1,8 раза.

  высота     60,8 -> 33,3 юнита
  до вершины 0,45 -> 0,33 с
  полёт      0,90 -> 0,67 с

sv_gravity = 600 подтверждён вики отдельно и остаётся.

Попутно исправлены две ошибки в самих измерениях: брался максимальный z над
точкой вместо высоты поверхности на месте (крыша на z=1296 заслоняла крышу на
z=240), и зазор между габаритами считался без клампа по нулю. На этих
артефактах диагностика рапортовала чужие дефекты.
This commit is contained in:
Niko Marmeladkov 2026-10-14 08:43:51 +03:00
parent 7b99c58bd9
commit 229372ffa1
3 changed files with 324 additions and 30 deletions

View file

@ -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<f32> = at
.split(',')
.map(|v| v.trim().parse::<f32>().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<f32> {
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<f32>)> = 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<i32, usize> = 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 секунд ходьбы игрок либо на земле, либо

View file

@ -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");

View file

@ -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 невидимых стен при реальном нуле.