Браш-сущности в своём origin: рендер и коллизия
Some checks are pending
CI / checks (push) Waiting to run

`LUMP_FACES` хранит полигоны всех моделей карты подряд, а не только
статического мира: модель 0 — worldspawn (грани 0..9363), модели 1..12 —
`func_brush`/`func_vehicleclip`/`func_illusionary` с геометрией в локальных
координатах вокруг начала координат. Мы рисовали весь ламп подряд, и в
центре карты вырастали четыре продублированные плиты 2480 × 1936 × 8 плюс
три плиты, ложащиеся строго на оси — то, что на глаз читалось как «оси
координат 3д».

* `BrushModels` (вместо мёртвого `NavHulls`): диапазоны граней из
  `LUMP_MODELS`, привязка модели к сущности по `model "*k"`, сдвиг вершины
  на `origin`. Клипы, порталы и `StartDisabled` не рисуются.
* Коллизия делала ту же ошибку: кирпичи 2184/2185 (оба `CONTENTS_SOLID`,
  габарит `(-1240,-968,-4)..(1240,968,4)`) стояли в (0,0,0). Привязка
  «кирпич → модель» сделана обходом поддерева `dmodel_t.headnode` по
  `LUMP_LEAFBRUSHES`; плоскость пересчитывается как `d + n·origin`.
  `func_illusionary` и `func_vehicleclip` коллизии не дают — иначе это
  невидимые стены.
* `LUMP_LEAFBRUSHES` добавлен в `Geometry`; `dmodel_t` по смещению 36
  подтверждён как корень поддерева (мир — 2 168 кирпичей, модели — по 1..6
  с габаритами, совпадающими с габаритами моделей; 14 кирпичей не
  принадлежат ни одной модели и остаются в мире).

Замеры из ответа Gemini подтверждены: `LUMP_FACES` не сжат (`529 760 =
56 × 9460`), раскладка `dface_t` совпадает байт-в-байт, `startPosition` у
всех 110 displacement'ов — угол грани (худшее 0.053 юнита), `power` и
`DispVertStart` сходятся. Куда деваются 21 displacement-грань из 110 — никуда:
счётчики отсева дают `110 = 89 (мир) + 21 (3D-скайбокс)`.

Тесты: `real_map_brush_models_keep_their_origin`,
`real_map_brush_model_collision_follows_origin`,
`real_map_displacement_coverage`, `real_map_displacement_start_position`.
Документация: docs/PARITY.md E16–E17, GEMINI.md часть 2 (вопросы 5–7).
This commit is contained in:
Niko Marmeladkov 2026-10-16 09:34:49 +03:00
parent c87665dee8
commit a6441ac9ad
5 changed files with 815 additions and 125 deletions

View file

@ -88,4 +88,84 @@
Наш парсер даёт 9 460 граней. **Совпадает ли 56 × 9460 = 529 360 с
распакованным размером?** Если да — значит `dface_t` у нас разбирается
правильно; если нет — у нас сдвиг на какое-то поле, и это может быть
причиной и плоского рельефа, и белых плит.
причиной и плоского рельефа, и белых плит.
---
# Часть 2. Что подтвердилось, и новые вопросы
## Проверено по вашему ответу — подтвердилось
1. **`LUMP_FACES` не сжат** (`fourCC = 0`), `529 760 / 56 = 9 460` ровно.
Все поля `dface_t` читаются по вашей таблице смещений байт-в-байт.
2. **Белые плиты в (0,0,0) — это браш-сущности без `origin`.** Замер
`LUMP_MODELS`: 624 / 48 = 13 моделей, модель 0 — грани 0..9363, модели
1..12 — грани 9363..9460. Модели 1, 8 лежат на YZ, 2 и 5 на XZ, 3 и 4 на
XY — это и есть «оси координат 3д» из жалобы. Исправлено в рендере, плиты
уехали на `origin`.
3. **`LUMP_BRUSHES`/`LUMP_BRUSHSIDES` для рендера не нужны.** Правило
«`contents == 0` → не рисуем» удалено.
4. **Порядок углов и UV лайтмапы displacement у нас совпадают с вашим
алгоритмом**: `L(v)=lerp(C0,C1,v)`, `R(v)=lerp(C3,C2,v)`,
`base=lerp(L,R,u)`, `vert_idx = row*N+col`,
`luxel = 0.5 + param * LightmapTextureSizeInLuxels`.
5. **Ваши вопросы сняты измерением:** `110 = 89 (мир) + 21 (3D-скайбокс)`,
потерь нет; у всех 110 `numedges = 4`; все 110 в `models[0]`;
`startPosition` у всех 110 совпадает с углом грани (худшее расстояние
0.053 юнита); `power` и `DispVertStart` сходятся (сумма сеток 12 638 =
`LUMP_DISP_VERTS`).
## Что мы дополнительно нашли про коллизию (вам это может быть интересно)
В `dmodel_t` поле по смещению 36 оказалось **корнем поддерева BSP**
(`headnode`), а не `visleafs`: обход из этого узла по `LUMP_LEAFBRUSHES`
даёт для модели 0 — 2 168 кирпичей, для моделей 1..5 — по 1..5 кирпичей с
габаритами, **в точности совпадающими** с локальными габаритами моделей.
14 кирпичей не принадлежат ни одной модели.
Кирпичи 2184 и 2185 (оба `CONTENTS_SOLID`, габарит
`(−1240,−968,−4)..(1240,968,4)`) — это те самые две плиты, их коллизия
стояла в (0,0,0).
## Вопрос 5. Порядок `dDispVert` — строка или столбец
Мы индексируем `verts[a * side + b]`, где `a` идёт вдоль ребра
`C0 → C1` (это `v`, строка сетки), `b` — вдоль `C0 → C3` (это `u`, столбец).
То есть `index = row * (N) + col`.
**Верно ли это для `LUMP_DISP_VERTS`, или vbsp раскладывает записи
по-другому?** Симптом: игрок жалуется, что «кривая поверхность,
дисплейсменты вывернуты» — при этом нормали наружу, углы у `startPosition`,
`power` и `DispVertStart` все сходятся, а рёбра соседних displacement'ов
совпадают. Перепутанные `row`/`col` дали бы именно «вывернутую», но
сшитую по краям поверхность.
## Вопрос 6. Резкие границы света на дисплейсментах
На траве видны **резкие прямые границы** светлого и тёмного с чётким
углом — так, будто лайтмапа вырезана плитами, а не градиентом.
1. Для displacement запекается **своя** лайтмапа, не та же, что у базового
браша, верно? И `dface_t.lightofs` для displacement-грани указывает
именно на её блок?
2. Правильно ли мы берём размер блока как `LightmapTextureSizeInLuxels`,
а ширину строки — `size[0] + 1`, и индекс `light_offset +
(y * (size[0]+1) + x) * 4`?
3. Нужно ли учитывать `LightmapTextureMinsInLuxels` для displacement
(мы для displacement его **не** вычитаем, потому что `lightofs` уже
указывает на начало блока этой грани)?
4. Что делает поле `ddispinfo_t.lightmapSamplePositionStart` и участвует ли
оно в раскладке люкселей? Мы его не читаем.
## Вопрос 7. `angles` браш-сущностей
Мы переносим геометрию и коллизию браш-моделей только трансляцией на
`origin`, без поворота по `angles`.
1. Правильно ли, что `dmodel_t` хранит геометрию **уже повёрнутую** по
`angles` сущности и отцентрированную вокруг `origin`, то есть поворот
применять не нужно? Или в BSP лежит неповёрнутая геометрия, и поворот
делает движок при рендере?
2. Как в Source ведут себя `func_illusionary` и `func_vehicleclip` с точки
зрения коллизии клиента? Мы для них коллизию отключаем, а для
`func_brush`, `func_reflective_glass` и `func_button` — оставляем.

View file

@ -1,9 +1,10 @@
//! Игрок и коллизия: выпуклые браши BSP + треугольники displacement'ов.
use formats::KvDocument;
use formats::bsp::geometry::Geometry;
use formats::bsp::{Bsp, BspError, lump};
use math::{Aabb, Vec3};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
/// Счётчики отбора брашей — нужны для проверки на реальных картах.
#[derive(Debug, Default, Clone, Copy)]
@ -360,18 +361,41 @@ impl MoveResult {
impl Collision {
/// Собирает браши из BSP — только твёрдые (см. [`contents::is_solid`]).
///
/// Кирпичи браш-сущностей (`func_brush` и родня, модели `*1..*N`)
/// хранятся в **локальных** координатах вокруг начала координат, поэтому
/// сдвигаются на `origin` своей сущности. Без этого в (0,0,0) остаются
/// невидимые стены (замер на gm_construct: кирпичи 2184/2185 — те самые
/// две плиты габарита `(-1240,-968,-4)..(1240,968,4)`, `CONTENTS_SOLID`),
/// а в реальном месте этих сущностей коллизии нет.
///
/// Привязка «кирпич → модель» делается обходом поддерева `dmodel_t.headnode`
/// в дереве BSP: листы перечисляют кирпичи через `LUMP_LEAFBRUSHES`
/// (замер: модель 0 — 2 168 кирпичей, модели 1..12 — по 1..6, локальные
/// габариты кирпичей совпадают с габаритами моделей).
pub fn from_bsp(bsp: &Bsp) -> Result<Self, BspError> {
let geometry: Geometry = bsp.geometry()?;
let brush_data = bsp.lump(lump::BRUSHES)?.into_owned();
let side_data = bsp.lump(lump::BRUSHSIDES)?.into_owned();
let entities = bsp.entities().unwrap_or_default();
let owner = Self::brush_owners(&geometry, &entities);
let mut brushes = Vec::new();
let mut liquids: Vec<Liquid> = Vec::new();
let mut counts = BrushCounts::default();
for chunk in brush_data.chunks_exact(12) {
for (position, chunk) in brush_data.chunks_exact(12).enumerate() {
let brush_index = position as u32;
let first = i32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
let count = i32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
let brush_contents = i32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
// `None` — кирпич выключенной или бестеловой сущности: его не
// должно быть в мире. Непривязанный кирпич остаётся в (0,0,0):
// 14 кирпичей gm_construct не достижимы ни из одного поддерева,
// и ронять их — значит потерять реальную геометрию коллизии.
let offset = match owner.get(&brush_index) {
Some(None) => continue,
other => other.copied().flatten().unwrap_or(Vec3::ZERO),
};
counts.total += 1;
if contents::is_solid(brush_contents) {
counts.solid += 1;
@ -406,7 +430,13 @@ impl Collision {
};
planes.push((
Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]),
plane.dist,
// Плоскость лежит в локальных координатах модели. Мировая
// точка `p_world = p_local + offset`, значит
// `n·(p_world - offset) <= d` → `n·p_world <= d + n·offset`.
plane.dist
+ plane.normal[0] * offset.x
+ plane.normal[1] * offset.y
+ plane.normal[2] * offset.z,
));
}
if planes.len() < 4 {
@ -445,6 +475,95 @@ impl Collision {
})
}
/// Какому смещению в мировые координаты соответствует каждый кирпич.
///
/// `Some(origin)` — кирпич браш-сущности, он лежит в локальных
/// координатах и должен встать на `origin`; `Some(ZERO)` — обычный мир;
/// `None` — сущность не даёт коллизии; кирпича нет в карте — ключа нет,
/// и вызывающий оставляет его в мире.
fn brush_owners(geometry: &Geometry, entities: &[KvDocument]) -> HashMap<u32, Option<Vec3>> {
let mut out = HashMap::new();
let Some(world) = geometry.models.first() else {
return out;
};
for index in Self::brushes_under(geometry, world.head_node) {
out.insert(index, Some(Vec3::ZERO));
}
// Модели сущностей идут вторыми и перезаписывают мир: поддерева BSP
// не пересекаются, но перезапис делает это явным, а не «кто первый».
for (number, model) in geometry.models.iter().enumerate().skip(1) {
let offset = Self::model_offset(entities, number);
for index in Self::brushes_under(geometry, model.head_node) {
out.insert(index, offset);
}
}
out
}
/// Какие кирпичи лежат в поддереве узла `root` (отрицательный — лист).
fn brushes_under(geometry: &Geometry, root: i32) -> Vec<u32> {
let mut out: Vec<u32> = Vec::new();
let mut seen = HashSet::new();
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node < 0 {
let Some(leaf) = geometry.leaves.get((-1 - node) as usize) else {
continue;
};
let first = leaf.first_leaf_brush as usize;
let last = first + leaf.num_leaf_brushes as usize;
let Some(slice) = geometry.leaf_brushes.get(first..last) else {
continue;
};
for &index in slice {
// Один кирпич попадает в много листов — считаем один раз.
if seen.insert(index) {
out.push(index as u32);
}
}
} else if let Some(node) = geometry.nodes.get(node as usize) {
stack.push(node.children[0]);
stack.push(node.children[1]);
}
}
out
}
/// Смещение браш-модели номер `number` — `origin` её сущности.
///
/// Сущности без коллизии дают `None`. Список приблизительный и
/// сверяется с эталоном GMod: `func_illusionary` в Source получает
/// `SOLID_NONE`, а `func_vehicleclip` режет только машины.
fn model_offset(entities: &[KvDocument], number: usize) -> Option<Vec3> {
const NO_SOLID: &[&str] = &[
"func_illusionary",
"func_vehicleclip",
"func_occluder",
"func_areaportal",
"func_areaportalwindow",
];
let wanted = format!("*{number}");
let entity = entities
.iter()
.find(|entity| entity.get_str("model") == Some(wanted.as_str()))?;
if let Some(class) = entity.get_str("classname") {
let hidden = class.starts_with("trigger_")
|| NO_SOLID.iter().any(|name| name.eq_ignore_ascii_case(class));
if hidden {
return None;
}
}
if entity.get_str("StartDisabled") == Some("1") {
return None;
}
Some(
entity
.get_str("origin")
.and_then(crate::world::parse_vec3)
.unwrap_or(Vec3::ZERO),
)
}
/// Добавляет треугольники displacement'ов в коллизию.
/// Обновляет габариты подвижных тел — вызывать каждый кадр.
///
@ -3999,6 +4118,81 @@ mod tests {
}
}
/// Кирпичи браш-сущностей стоят на `origin`, а не в начале координат.
///
/// Жалоба: «белые херни на 0 0 пропали, но коллизия осталась». Рендер мы
/// починили в части E16 (`BrushModels`), а коллизия читает `LUMP_BRUSHES`
/// напрямую — кирпичи моделей `*1..*N` там лежат в локальных координатах
/// вокруг нуля. Привязка делается обходом поддерева `dmodel_t.headnode`.
///
/// Требует локальной установки Garry's Mod:
///
/// ```text
/// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \\
/// cargo test -p engine -- --ignored real_map_brush_model_collision_follows_origin --nocapture
/// ```
#[test]
#[ignore = "нужна карта: SOURCE_RS_TEST_BSP"]
fn real_map_brush_model_collision_follows_origin() {
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 collision = Collision::from_bsp(&bsp).expect("коллизия строится");
let has_bounds = |lo: Vec3, hi: Vec3| -> bool {
collision.brushes.iter().any(|brush| {
(brush.bounds.min - lo).length() < 1.0 && (brush.bounds.max - hi).length() < 1.0
})
};
// Локальные габариты моделей 3 и 4 — ровно то, что раньше стояло в
// (0,0,0). В мире таких кирпичей быть не должно.
assert!(
!has_bounds(
Vec3::new(-1240.0, -968.0, -4.0),
Vec3::new(1240.0, 968.0, 4.0)
),
"плиты браш-сущностей всё ещё лежат в начале координат"
);
// Их `origin` из entity-лампа: модель 3 — (-2048,-3600,156),
// модель 4 — (-2048,-3600,-260). Габарит сдвигается вместе с ними.
assert!(
has_bounds(
Vec3::new(-3288.0, -4568.0, 152.0),
Vec3::new(-808.0, -2632.0, 160.0)
),
"модель 3 не встала на свой origin"
);
assert!(
has_bounds(
Vec3::new(-3288.0, -4568.0, -264.0),
Vec3::new(-808.0, -2632.0, -256.0)
),
"модель 4 не встала на свой origin"
);
// `func_vehicleclip` (модели 9 и 10) — клипы: их коллизия не должна
// появляться нигде, в том числе на своём `origin`.
assert!(
!has_bounds(
Vec3::new(-1408.0, -3712.0, 236.0),
Vec3::new(-1388.0, -2048.0, 256.0)
),
"func_vehicleclip дал коллизию там, где игрока ничего не должно держать"
);
println!("кирпичей в коллизии: {}", collision.brushes.len());
assert!(
collision.brushes.len() > 2000,
"потеряли кирпичи: {}",
collision.brushes.len()
);
}
/// Нормали дисплейсментов смотрят наружу, а не вниз.
///
/// Жалоба: «дисплейсменты вывернуты». У кирпичных граней нормаль берётся

View file

@ -36,131 +36,118 @@ const SURF_WARP: i32 = 0x8;
/// Грань не рисуется (кирпичи `clip`, `trigger`, `func_brush`).
pub const SURF_NODRAW: i32 = 0x80;
/// Кирпичи, которые в GMod не видны, но твёрдые.
/// Принадлежность грани модели карты и смещение для её рисования.
///
/// `info_node` и родственники (`info_node_air`, `info_node_hint`,
/// `info_node_climb`, `info_node_air_hint`) — это сетка навигации бота. На
/// gm_construct их 815 штук, и в центре карты они образуют большой белый
/// ящик: материал `GM_CONSTRUCT/COLOR_ROOM` с белой текстурой `color/white`.
/// Настоящая игра их не рисует, а мы рисовали — отсюда «белые плиты, которых
/// нет в оригинале», и ощущение комнаты под землёй.
/// `LUMP_FACES` хранит полигоны всех моделей подряд, а не только статического
/// мира. Модель 0 — `worldspawn`, её грани лежат в мире как есть. Модели
/// 1..N — браш-сущности (`func_brush`, `func_vehicleclip`, `func_illusionary`,
/// `func_button`), чья геометрия записана в локальных координатах,
/// отцентрированных вокруг начала координат.
///
/// Отсев сделан по геометрии, а не по индексу грани: кирпичи и грани в BSP не
/// параллельны (замер: 2 203 кирпича, 14 946 сторон, 9 460 граней), прямой
/// связи по номеру нет. Зато у каждого кирпича есть выпуклая оболочка из
/// плоскостей, и грань внутри неё гарантированно принадлежит этому кирпичу.
struct NavHulls {
/// По одной выпуклой оболочке на кирпич: список плоскостей (нормаль, `dist`).
hulls: Vec<Vec<(Vec3, f32)>>,
/// Мы рисовали весь `LUMP_FACES` подряд, поэтому эти сущности сваливались в
/// (0,0,0). На gm_construct `LUMP_MODELS` даёт 13 моделей по 48 байт:
/// модели 1, 2, 3, 4, 5, 8 — это тонкие плиты, ложащиеся строго на оси
/// (модели 1 и 8 габаритом `(-4,-968,-204)..(4,968,204)` — плита на YZ, 2 и 5
/// на XZ, 3 и 4 на XY). На глаз это и есть «оси координат 3д: есть x, есть y,
/// есть z». Модели 3 и 4 обе габаритом `(-1240,-968,-4)..(1240,968,4)` и,
/// оказавшись в одном месте, дают продублированные бетонные плиты — «белые
/// плиты, которых нет в оригинале».
///
/// У `func_vehicleclip` (модели 9 и 10) коллизия есть, а рисовать их нельзя:
/// это клипы. Сущность с `StartDisabled "1"` тоже не показываем.
///
/// `LUMP_BRUSHES`/`LUMP_BRUSHSIDES` при рендере не участвуют — это данные
/// коллизии и трассировки лучей, прямой связи «грань → кирпич» в формате нет.
/// Их мы и не ищем: правило «`contents == 0` → не рисуем» на замере давало
/// 330 срабатываний, из них 250 — видимые `plasterwall022c`.
struct BrushModels {
/// Диапазоны граней: `(firstface, numfaces, смещение)`. Смещение `None`
/// означает, что модель не рисуется.
ranges: Vec<(u32, u32, Option<Vec3>)>,
}
impl NavHulls {
/// Собирает оболочки кирпичей невидимых сущностей.
fn collect(bsp: &formats::Bsp, entities: &[formats::KvDocument]) -> NavHulls {
const HIDDEN: [&str; 5] = [
"info_node",
"info_node_air",
"info_node_hint",
"info_node_climb",
"info_node_air_hint",
impl BrushModels {
/// Диапазоны из `LUMP_MODELS`, смещения — из сущностей с `model "*k"`.
fn collect(bsp: &formats::Bsp, entities: &[formats::KvDocument]) -> BrushModels {
// Скрытые браш-сущности: никогда не рисуются.
const HIDDEN: &[&str] = &[
"func_vehicleclip",
"func_occluder",
"func_areaportal",
"func_areaportalwindow",
"func_clip_vphysics",
];
let mut hulls: Vec<Vec<(Vec3, f32)>> = Vec::new();
let (Ok(brush_data), Ok(side_data)) = (
bsp.lump(formats::bsp::lump::BRUSHES),
bsp.lump(formats::bsp::lump::BRUSHSIDES),
) else {
return NavHulls { hulls };
};
let (brush_data, side_data) = (brush_data.into_owned(), side_data.into_owned());
let brushes: Vec<(usize, usize)> = brush_data
.chunks_exact(12)
.map(|c| {
let first = i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as usize;
let count = i32::from_le_bytes([c[4], c[5], c[6], c[7]]) as usize;
(first, count)
})
.collect();
let Some(geometry) = bsp.geometry().ok() else {
return NavHulls { hulls };
};
for kv in entities {
let Some(class) = kv.get_str("classname") else {
let mut offsets: HashMap<usize, Option<Vec3>> = HashMap::new();
for entity in entities {
let Some(model) = entity.get_str("model") else {
continue;
};
if !HIDDEN.iter().any(|h| class.eq_ignore_ascii_case(h)) {
let Some(number) = model
.strip_prefix('*')
.and_then(|raw| raw.parse::<usize>().ok())
else {
continue;
}
// `brushes` — сколько кирпичей, `brushlist` — их номера через запятую.
let count: usize = kv
.get_str("brushes")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let list: Vec<usize> = kv
.get_str("brushlist")
.map(|v| {
v.split(',')
.filter_map(|p| p.trim().parse::<usize>().ok())
.collect()
})
.unwrap_or_default();
if list.is_empty() {
continue;
}
for index in list.iter().take(count.max(1)) {
let Some(&(first, sides)) = brushes.get(*index) else {
continue;
};
let mut hull = Vec::with_capacity(sides);
for s in first..first + sides {
let Some(side) = side_data.get(s * 8..s * 8 + 2) else {
break;
};
let planenum = u16::from_le_bytes([side[0], side[1]]) as usize;
let Some(plane) = geometry.planes.get(planenum) else {
continue;
};
hull.push((
Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]),
plane.dist,
));
}
if hull.len() >= 4 {
hulls.push(hull);
}
}
};
let visible = entity.get_str("classname").is_some_and(|class| {
!class.starts_with("trigger_")
&& !HIDDEN.iter().any(|h| h.eq_ignore_ascii_case(class))
}) && entity
.get_str("StartDisabled")
.is_none_or(|value| value != "1");
// `angles` браш-сущности применяются только при повороте; на
// gm_construct у всех `func_brush` они нулевые, поэтому пока
// переносим чисто трансляцией.
let offset = entity
.get_str("origin")
.and_then(parse_vec3)
.unwrap_or(Vec3::ZERO);
offsets.insert(number, visible.then_some(offset));
}
let Ok(models) = bsp.lump(formats::bsp::lump::MODELS) else {
return BrushModels {
ranges: vec![(0, u32::MAX, Some(Vec3::ZERO))],
};
};
let models = models.into_owned();
let mut ranges: Vec<(u32, u32, Option<Vec3>)> = Vec::new();
for chunk in models.chunks_exact(48) {
let read = |range: core::ops::Range<usize>| -> u32 {
let mut bytes = [0u8; 4];
bytes.copy_from_slice(&chunk[range]);
i32::from_le_bytes(bytes) as u32
};
let first = read(40..44);
let num = read(44..48);
// Номер модели — это порядковый индекс, мы сохраняем порядок.
let offset = offsets
.get(&ranges.len())
.copied()
.unwrap_or(Some(Vec3::ZERO));
ranges.push((first, num, offset));
}
if ranges.is_empty() {
// Нет данных о моделях — ведём себя как раньше, весь `LUMP_FACES`.
ranges.push((0, u32::MAX, Some(Vec3::ZERO)));
}
tracing::info!(
brushes = hulls.len(),
"кирпичи невидимых сущностей (info_node и родственники) отсечены от рендера"
models = ranges.len(),
visible = ranges.iter().filter(|r| r.2.is_some()).count(),
"модели карты: статический мир + браш-сущности со своим смещением"
);
NavHulls { hulls }
BrushModels { ranges }
}
/// Грань лежит внутри оболочки невидимого кирпича?
fn contains_face(&self, geometry: &Geometry, face_index: usize) -> bool {
if self.hulls.is_empty() {
return false;
}
// Центр грани по её вершинам.
let Ok(indices) = geometry.face_vertex_indices(face_index) else {
return false;
};
let mut center = Vec3::ZERO;
let mut count = 0.0f32;
for index in &indices {
if let Some(v) = geometry.vertices.get(*index as usize) {
center += Vec3::new(v.pos[0], v.pos[1], v.pos[2]);
count += 1.0;
/// Смещение для грани: `Some(offset)` — рисуем (ноль для мира), `None` —
/// сущность невидима. Грани вне любых диапазонов считаем частью мира.
fn offset_for(&self, face_index: usize) -> Option<Vec3> {
let index = face_index as u64;
for (first, num, offset) in &self.ranges {
if index >= *first as u64 && index < *first as u64 + *num as u64 {
return *offset;
}
}
if count < 3.0 {
return false;
}
center = center * (1.0 / count);
self.hulls
.iter()
.any(|hull| hull.iter().all(|(n, d)| n.dot(center) <= *d + 0.5))
Some(Vec3::ZERO)
}
}
const SURF_HINT: i32 = 0x100;
@ -277,6 +264,12 @@ pub struct WorldStats {
pub faces_flipped: usize,
/// Сколько треугольников displacement'ов развернули.
pub disp_triangles_flipped: usize,
/// Отсев displacement'ов по причинам — чтобы измерять, а не гадить,
/// почему из `LUMP_DISPINFO` (110 штук на gm_construct) строится 89.
pub disp_skipped_sky: usize,
pub disp_skipped_material: usize,
pub disp_skipped_index: usize,
pub disp_skipped_build: usize,
}
/// Собранный мир.
@ -462,10 +455,9 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
flags
};
// Отсев навмеша: гипотеза не подтвердилась (кирпичи не привязаны к
// сущностям через brushlist), поэтому отбор пуст и ничего не отсекает.
// Оставлен как заготовка, см. docs/PARITY.md E14.
let nav_hulls = NavHulls::collect(bsp, &entities);
// Принадлежность граней моделям карты: мир рисуем как есть, браш-сущности —
// со смещением на `origin`, невидимые (клипы, триггеры, отключённые) — нет.
let brush_models = BrushModels::collect(bsp, &entities);
let mut resolved: Vec<Option<Resolved>> = vec![None; texdata.len()];
let mut texture_budget_left = TEXTURE_BUDGET;
@ -481,10 +473,10 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
if face.num_edges < 3 {
continue;
}
if nav_hulls.contains_face(&geometry, face_index) {
let Some(offset) = brush_models.offset_for(face_index) else {
world.stats.skipped += 1;
continue;
}
};
let Some(texinfo) = geometry.texinfos.get(face.texinfo.max(0) as usize) else {
continue;
};
@ -527,12 +519,17 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
let Some(vertex) = geometry.vertices.get(index as usize) else {
continue;
};
let pos = Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]);
// Браш-сущности хранятся в локальных координатах, поэтому вершину
// сдвигаем на `origin` сущности. Для мира смещение нулевое, поведение
// не меняется. Лайтмапа и текстурные координаты считаются в тех же
// мировых координатах: `texinfo.lightmapVecs` и `dface_t.lightofs`
// запекались компилятором относительно конечного положения.
let pos = Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]) + offset;
// Грани без лайтмапы (SURF_NOLIGHT, вода) движок рисует с белой
// лайтмапой — sample_light вернёт белый.
let light = sample_light(&lighting, face, texinfo, pos);
world.vertices.push(WorldVertex {
pos: vertex.pos,
pos: [pos.x, pos.y, pos.z],
normal: [normal.x, normal.y, normal.z],
uv: texture_uv(pos, texinfo, inv),
light,
@ -590,14 +587,18 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
continue;
}
let Some(info) = disps.get(face.dispinfo as usize) else {
// `dispinfo` указывает за пределы `LUMP_DISPINFO`.
world.stats.disp_skipped_index += 1;
continue;
};
let Some(texinfo) = geometry.texinfos.get(face.texinfo.max(0) as usize) else {
world.stats.disp_skipped_index += 1;
continue;
};
// Скайбокс-геометрия собирается отдельным проходом.
if sky_faces.get(face_index).copied().unwrap_or(false) {
world.stats.disp_skipped_sky += 1;
continue;
}
@ -615,6 +616,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
);
let Resolved::Material(material) = resolved else {
world.stats.skipped += 1;
world.stats.disp_skipped_material += 1;
continue;
};
@ -665,6 +667,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
world.stats.triangles += world.indices.len() / 3;
}
Err(err) => {
world.stats.disp_skipped_build += 1;
tracing::warn!(face = face_index, "displacement пропущен: {err}");
world.stats.skipped += 1;
}
@ -1007,6 +1010,10 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
tracing::info!(
faces = world.stats.faces,
disp_faces = world.stats.disp_faces,
disp_skipped_sky = world.stats.disp_skipped_sky,
disp_skipped_material = world.stats.disp_skipped_material,
disp_skipped_index = world.stats.disp_skipped_index,
disp_skipped_build = world.stats.disp_skipped_build,
skipped = world.stats.skipped,
vertices = world.vertices.len(),
triangles = world.indices.len() / 3,
@ -2447,7 +2454,7 @@ pub fn collect_lights(entities: &[formats::KvDocument]) -> Vec<Light> {
result
}
fn parse_vec3(text: &str) -> Option<Vec3> {
pub(crate) fn parse_vec3(text: &str) -> Option<Vec3> {
let mut parts = text.split_whitespace();
let x = parts.next()?.parse().ok()?;
let y = parts.next()?.parse().ok()?;
@ -2545,6 +2552,197 @@ mod tests {
assert!(!inside_bounds(min, max, Vec3::new(0.0, 0.0, 577.0), margin));
}
/// `startPosition` displacement'а совпадает с углом своей базовой грани.
///
/// Жалоба: «дисплейсменты вывернуты». Углы сетки выбираются так: тот угол
/// базовой грани, что ближе всего к `startPosition`, становится углом
/// (0,0) сетки, остальные идут по обходу. Если `startPosition` не совпадает
/// ни с одним углом (битое поле, неверный сдвиг), угол выбирается наугад
/// и сетка скручивается «бабочкой».
///
/// Требует локальной установки Garry's Mod:
///
/// ```text
/// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \\
/// cargo test -p engine -- --ignored real_map_displacement_start_position --nocapture
/// ```
#[test]
#[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"]
fn real_map_displacement_start_position() {
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 geometry = Geometry::from_bsp(&bsp).expect("геометрия разбирается");
let dispinfo = bsp
.lump(formats::bsp::lump::DISPINFO)
.expect("LUMP_DISPINFO читается")
.into_owned();
let infos = parse_dispinfo(&dispinfo);
let mut far = 0usize;
let mut worst = 0.0f32;
let mut checked = 0usize;
for (index, info) in infos.iter().enumerate() {
let Some(face_index) = geometry
.faces
.iter()
.position(|face| face.dispinfo >= 0 && face.dispinfo as usize == index)
else {
continue;
};
let Ok(indices) = geometry.face_vertex_indices(face_index) else {
continue;
};
let mut best = f32::MAX;
for &vertex in &indices {
let Some(v) = geometry.vertices.get(vertex as usize) else {
continue;
};
let pos = Vec3::new(v.pos[0], v.pos[1], v.pos[2]);
best = best.min((pos - info.start_position).length());
}
if !best.is_finite() {
continue;
}
checked += 1;
worst = worst.max(best);
if best > 1.0 {
far += 1;
if far <= 5 {
println!(
"startPosition не у угла: disp {index} face {face_index} \
dist {best:.1} pos=({:.0},{:.0},{:.0})",
info.start_position.x, info.start_position.y, info.start_position.z
);
}
}
}
println!(
"проверено {checked}, дальше 1 юнита от угла: {far}, худшее расстояние {worst:.3}"
);
assert_eq!(
far, 0,
"у {far} displacement'ов startPosition не совпадает ни с одним углом грани"
);
}
/// Куда деваются displacement-грани: полный разбор отсева.
///
/// На gm_construct `LUMP_DISPINFO` содержит 110 записей, а в рендер
/// попадает 89. Прежде причины пропуска были молчаливые — пришлось
/// добавить счётчики, чтобы измерять.
///
/// Требует локальной установки Garry's Mod:
///
/// ```text
/// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \\
/// cargo test -p engine -- --ignored real_map_displacement_coverage --nocapture
/// ```
#[test]
#[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"]
fn real_map_displacement_coverage() {
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 world = build_world(&bsp, &VirtualFs::new()).expect("мир строится");
// Насколько вообще displacement-граней: 176 байт на `ddispinfo_t`.
let dispinfo = bsp
.lump(formats::bsp::lump::DISPINFO)
.expect("LUMP_DISPINFO читается");
let total = dispinfo.len() / 176;
let stats = world.stats;
let sum = stats.disp_faces
+ stats.disp_skipped_sky
+ stats.disp_skipped_material
+ stats.disp_skipped_index
+ stats.disp_skipped_build;
println!(
"disp: построено {total}, нарисовано {}, отсев: скайбокс {}, \
материал {}, индекс {}, сборка {}",
stats.disp_faces,
stats.disp_skipped_sky,
stats.disp_skipped_material,
stats.disp_skipped_index,
stats.disp_skipped_build
);
// Ничего не теряется молча: каждый из 110 accounted.
assert_eq!(sum, total, "сумма отсева должна равняться LUMP_DISPINFO");
}
/// Браш-сущности рисуются в своём `origin`, а не в начале координат.
///
/// `LUMP_FACES` хранит полигоны всех моделей подряд: модель 0 — мир,
/// модели 1..N — `func_brush`/`func_vehicleclip`/`func_illusionary`, чья
/// геометрия записана в локальных координатах. Раньше весь ламп рисовался
/// как есть, и в центре карты вырастали четыре продублированные плиты
/// 2480 × 1936 × 8 (грани 9379/9380 модели 3 и 9385/9386 модели 4),
/// плюс плиты на осях X/Y/Z — то, что на глаз читалось как «оси
/// координат 3д».
///
/// Требует локальной установки Garry's Mod:
///
/// ```text
/// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \\
/// cargo test -p engine -- --ignored real_map_brush_models_keep_their_origin --nocapture
/// ```
#[test]
#[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"]
fn real_map_brush_models_keep_their_origin() {
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 entities = bsp.entities().expect("entity-ламп разбирается");
let models = BrushModels::collect(&bsp, &entities);
// Модель 0 — статический мир: рисуем без смещения.
assert_eq!(models.offset_for(0), Some(Vec3::ZERO));
assert_eq!(models.offset_for(9362), Some(Vec3::ZERO));
// Последняя грань мира — 9362, дальше идут браш-сущности (замер
// LUMP_MODELS: первая модель занимает диапазон 0..9363).
assert_eq!(models.offset_for(9363).map(|_| ()), Some(()));
// Две продублированные плиты уезжают из (0,0,0) в origin своих
// сущностей (значения из entity-лампа, `model "*3"` и `model "*4"`).
assert_eq!(
models.offset_for(9379),
Some(Vec3::new(-2048.0, -3600.0, 156.0)),
"грани 9379/9380 — модель 3"
);
assert_eq!(
models.offset_for(9385),
Some(Vec3::new(-2048.0, -3600.0, -260.0)),
"грани 9385/9386 — модель 4"
);
// `func_vehicleclip` (модели 9 и 10) — клипы: коллизия есть, рисовать нельзя.
assert_eq!(
models.offset_for(9416),
None,
"модель 9 принадлежит func_vehicleclip и не рисуется"
);
// Ни одна браш-модель не остаётся на месте: все смещения ненулевые.
let placed = (0..9460)
.filter_map(|face| models.offset_for(face))
.filter(|offset| *offset != Vec3::ZERO)
.count();
println!("граней, перенесённых на origin сущностей: {placed}");
assert!(
placed >= 40,
"ожидали десятки граней браш-сущностей, получили {placed}"
);
}
/// Замер границ карты на настоящей карте.
///
/// Требует локальной установки Garry's Mod:

View file

@ -669,6 +669,16 @@ pub mod geometry {
pub nodes: Vec<Node>,
pub leaves: Vec<Leaf>,
pub leaf_faces: Vec<u16>,
/// `LUMP_LEAFBRUSHES`: какие кирпичи лежат в каких листах дерева BSP.
///
/// Нужно, чтобы узнать, к какой модели карты относится кирпич: у
/// `dmodel_t` есть `headnode`, и обход поддерева собирает ровно те
/// кирпичи, что принадлежат модели (замер на gm_construct: модель 0 —
/// 2 168 кирпичей, модели 1..5 — по 1–5, и их габариты совпадают с
/// локальными габаритами моделей). Кирпичи браш-сущностей хранятся в
/// локальных координатах вокруг нуля, поэтому без такой привязки их
/// коллизия оказывалась в (0,0,0).
pub leaf_brushes: Vec<u16>,
pub models: Vec<Model>,
pub texdata_count: usize,
}
@ -691,6 +701,7 @@ pub mod geometry {
let nodes = bsp.lump(lump::NODES)?;
let leaves = bsp.lump(lump::LEAFS)?;
let leaf_faces = bsp.lump(lump::LEAFFACES)?;
let leaf_brushes = bsp.lump(lump::LEAFBRUSHES)?;
let models = bsp.lump(lump::MODELS)?;
let f32_at = |chunk: &[u8], offset: usize| -> f32 {
@ -797,6 +808,9 @@ pub mod geometry {
for chunk in leaf_faces.chunks_exact(2) {
out.leaf_faces.push(u16_at(chunk, 0));
}
for chunk in leaf_brushes.chunks_exact(2) {
out.leaf_brushes.push(u16_at(chunk, 0));
}
for chunk in models.chunks_exact(MODEL_SIZE) {
out.models.push(Model {
mins: [f32_at(chunk, 0), f32_at(chunk, 4), f32_at(chunk, 8)],

View file

@ -2090,7 +2090,10 @@ local = 1 / (1 + (k · distance · 0.01)²)
То есть бетонная плита 2480 × 1936 в 8 юнитов толщину прямо над спавном, и
она **продублирована**: грани 9379 и 9385 имеют одинаковый габарит, как и
9380 с 9386. Дубль есть в самой карте (две одинаковые плиты), это не наш баг.
9380 с 9386.
**Дубль — наш баг, а не карты: грани 9363..9460 принадлежат браш-сущностям,
а не миру.** Исправлено в части E16.
### E15.6. Чёрные области — не тени
@ -2120,9 +2123,210 @@ local = 1 / (1 + (k · distance · 0.01)²)
1. Понять, почему бетонная плита 2480 × 1936 у спавна выглядит белой. Тон
по эталону: бетон 143.7 при эталонных 143.6 — совпадает, значит дело не в
масштабе, а в том, что в GMod этой плиты, вероятно, не видно (она внутри
комнаты, а мы видим её сверху).
комнаты, а мы видим её сверху). **Закрыто в E16: плиты не было в GMod,
потому что это браш-сущность в своём `origin`.**
2. Разобраться с плоской травой: снять кадр GMod из точки `ref_p4`
(`setpos 0 0 2200`, `setang 180 55`) и сравнить рельеф.
3. `sun_shadow` без проверки `ndc.z` — точка за ближней плоскостью карты
теней получает `depth < 0` и трактуется как затенённая. Сейчас это не
видно глазом, но диапазон не проверяется, и это лазейка для чёрных пятен.
## Часть E16. Белые плиты в (0,0,0) — браш-сущности без `origin` (2026-10-16)
### E16.1. Ответ Gemini и что он дал
В `GEMINI.md` отправлены четыре вопроса; ответ получен и разобран. Главное,
что он дал: **`LUMP_FACES` хранит полигоны всех моделей карты подряд, а не
только статического мира.** Проверено измерением — подтвердилось.
### E16.2. Замер `LUMP_MODELS`
`LUMP_MODELS` (лампа 14) — 624 байта, `624 / 48 = 13` моделей ровно
(`dmodel_t` = 48 байт: `mins[3] f32`, `maxs[3] f32`, `headnode[3] i32`,
`visleafs i32`, `firstface i32`, `numfaces i32`):
| модель | первая грань | граней | габарит |
|---|---|---|---|
| 0 (worldspawn) | 0 | 9363 | (−15360,−15360,−1024)..(15872,15360,15359) |
| 1, 8 | 9363 / 9410 | 6 | (−4,−968,−204)..(4,968,204) — плита на **YZ** |
| 2, 5 | 9369 / 9387 | 6 / 22 | (−1240,−4,−204)..(1240,4,204) — плита на **XZ** |
| 3, 4 | 9375 / 9381 | 6 | (−1240,−968,−4)..(1240,968,4) — плита на **XY** |
| 6, 7 | 9409 / 9410 | 1 / 0 | стекло и кнопка |
| 9, 10 | 9416 / 9422 | 6 / … | `func_vehicleclip` — клипы |
| 11, 12 | … | … | `func_illusionary` |
**Три ортогональные плиты через начало координат — это буквально «оси
координат 3д: есть x, есть y, есть z» из жалобы.** Мы рисовали все 9460
граней подряд, поэтому 97 граней браш-моделей сваливались в (0,0,0), а
коллизия у них была — её мы строим отдельно, из `LUMP_BRUSHES`.
Сущности в `LUMP_ENTITIES` с `model "*k"` (12 штук) — `func_brush`,
`func_reflective_glass`, `func_button`, `func_vehicleclip`,
`func_illusionary` — с настоящими `origin`:
```text
*1 func_brush origin=-804 -3600 -52 *7 func_button origin=-2698 -2598 -207.2
*2 func_brush origin=-2048 -4572 -52 *8 func_brush origin=-3292 -3600 -52
*3 func_brush origin=-2048 -3600 156 *9 func_vehicleclip origin=—
*4 func_brush origin=-2048 -3600 -260 *10 func_vehicleclip origin=—
*5 func_brush origin=-2048 -2628 -52 *11 func_illusionary origin=-2920 -1028 8
*6 func_reflective_glass origin=-2064 -2076 -286 *12 func_illusionary origin=-2920 -1040 8
```
### E16.3. Проверка `LUMP_FACES` и раскладки `dface_t`
- **Ламп не сжат**: `fourCC = 0`, длина `529 760` байт,
`529 760 / 56 = 9460` ровно. Раскладка `dface_t` (56 байт) совпала
байт-в-байт с таблицей спецификации: `planenum 0`, `firstedge 4`,
`numedges 8`, `texinfo 10`, `dispinfo 12`, `styles 16`, `lightofs 20`,
`area 24`, `lightmap_mins 28`, `lightmap_size 36`, `origFace 44`,
`numPrims 48`, `smoothingGroups 52`. Сдвига нет.
- **Все 110 displacement-граней лежат в модели 0** и все имеют
`numedges = 4`. Значит отсев 21-й поверхности (89 из 110 в рендере) —
не T-junction, не браш-сущность и не порядок углов.
- `LUMP_BRUSHES`/`LUMP_BRUSHSIDES` при рендере не участвуют вовсе — это
данные коллизии и трассировки лучей; правило «`contents == 0` → не
рисуем» не используется.
### E16.4. Что сделано
Новый `BrushModels` в `crates/engine/src/world.rs` вместо мёртвого `NavHulls`
(гипотеза про навмеш опровергнута в E14 — `brushlist` не привязан к
сущностям, отбор был пустым):
- читает `LUMP_MODELS`, строит диапазоны граней по моделям;
- привязывает модель к сущности по `model "*k"`, берёт `origin`;
- не рисует `func_vehicleclip`, `func_occluder`, `func_areaportal*`,
`func_clip_vphysics`, `trigger_*` и сущности с `StartDisabled "1"`;
- вершину граней браш-модели сдвигает на `origin` (для мира смещение нулевое,
поведение не меняется); текстурные координаты и лайтмапа считаются уже в
мировых координатах, потому что `texinfo.lightmapVecs` и `dface_t.lightofs`
запекались компилятором относительно конечного положения.
Тест `real_map_brush_models_keep_their_origin` (требует `SOURCE_RS_TEST_BSP`):
грани 9379/9380 получают `(-2048,-3600,156)`, 9385/9386 —
`(-2048,-3600,-260)`, грань 9416 (модель 9, `func_vehicleclip`) не рисуется.
**57 граней уехали в `origin` сущностей, 40 граней клипов отсечены.**
Кадр с камеры `(0,0,2200)` pitch 55 yaw 180: огромная белая плита в центре
исчезла; на её месте виден нормальный вид, а плиты моделей 3 и 4 видны там,
где стоит их сущность. Сравнение `до`/`после` — `/tmp/opencode/ref/frame_0000.png`
и `/tmp/opencode/plates/frame_0002.png`.
### E16.5. Что из этого выпадает дальше
1. Браш-сущности пока **не поворачиваются** по `angles` — только трансляция.
На gm_construct у `func_brush` они нулевые, но на других картах может
понадобиться `rotate_model_vector`.
2. `func_illusionary` рисуется — в Source он видим, но физики нет; надо
сверить с эталоном, что он не попал в чёрные пятна.
3. Остаются 21 displacement-грань из 110 и плоская трава (E15.8 п. 2),
`ndc.z` в `sun_shadow` (E15.8 п. 3).
## Часть E17. Коллизия браш-сущностей, отсев displacement'ов и `startPosition` (2026-10-16)
Игровая сессия после E16 дала обратную связь: «белые херни на 0 0 пропали,
но колизия осталась», «невидимые стены на угловатых поверхностях», «белая
комната стала белой — это успех», «дисплейсменты вывернуты», «свет
распространён».
### E17.1. Коллизия: кирпичи моделей лежат в локальных координатах
Замер `LUMP_BRUSHES`/`LUMP_BRUSHSIDES`/`LUMP_PLANES` с восстановлением AABB
кирпича перебором троек плоскостей:
- **кирпич 2184** — `contents 0x1` (`CONTENTS_SOLID`), габарит
`(-1240,-968,-4)..(1240,968,4)`;
- **кирпич 2185** — то же самое.
Это ровно локальные габариты моделей 3 и 4. То есть кирпичи браш-сущностей
лежат вокруг начала координат, и наша `Collision::from_bsp` клала их в
(0,0,0) — невидимые стены там, где их нет, и отсутствие коллизии там, где
есть геометрия. Обе половины жалобы объяснены.
### E17.2. Как определить, к какой модели относится кирпич
В `dmodel_t` поле по смещению 36 — **корень поддерева BSP** (`headnode`),
не `visleafs`. Проверено обходом: из этого узла достижимы
| модель | узел | кирпичей | габарит найденных кирпичей |
|---|---|---|---|
| 0 (world) | 0 | 2 168 | — |
| 1 | 4887 | 1 | `(−4,−968,−204)..(4,968,204)` — совпал с моделью |
| 2 | 4893 | 1 | `(−1240,−4,−204)..(1240,4,204)` |
| 3 | 4899 | 1 | `(−1240,−968,−4)..(1240,968,4)` |
| 4 | 4905 | 1 | `(−1240,−968,−4)..(1240,968,4)` |
| 5 | 4911 | 5 | `(−1240,−4,−204)..(1240,4,204)` |
| 6..12 | 4923..4982 | по 1..6 | — |
Габариты кирпичей **в точности равны** локальным габаритам моделей.
Всего покрыто моделями 2 189 кирпичей из 2 203; **14 не принадлежат ни
одной модели** — их оставляем в мире (ронять их значило бы потерять реальную
геометрию).
Ранее парсер `Model` читал `head_node` по смещению 36 — это оказалось верно
по совпадению, но поле называлось `visleafs`; фактическая раскладка
подтверждена измерением. `LUMP_LEAFBRUSHES` (17) добавлен в `Geometry`.
### E17.3. Что сделано
`Collision::from_bsp` (движок `player.rs`):
- обходит поддерева `models[i].headnode` и строит карту «кирпич → смещение»;
- кирпичам мира и непривязанным кирпичам — сдвиг нулевой;
- кирпичам браш-моделей — `origin` их сущности; плоскость пересчитывается
как `d' = d + n·origin` (мир `p_world = p_local + origin`);
- сущности без коллизии не попадают в мир вообще: `func_illusionary`
(в Source `SOLID_NONE`), `func_vehicleclip` (режет только машины),
`func_occluder`, `func_areaportal*`, `trigger_*`, `StartDisabled "1"`.
Это **намеренно другой список**, чем в рендере: `func_vehicleclip` не
рисуется, но и не должен держать игрока, а `func_illusionary` рисуется, но
не должен держать.
Тест `real_map_brush_model_collision_follows_origin`: в коллизии нет кирпича
габарита `(−1240,−968,−4)..(1240,968,4)`, зато есть
`(−3288,−4568,152)..(−808,−2632,160)` (модель 3 на своём `origin`
`(−2048,−3600,156)`) и `(−3288,−4568,−264)..(−808,−2632,−256)` (модель 4),
а кирпичей в коллизии 2 118.
### E17.4. Куда деваются 21 displacement-грань — не деваются
Добавлены счётчики отсева (`WorldStats::disp_skipped_*`). Замер:
```text
disp: построено 110, нарисовано 89, отсев: скайбокс 21, материал 0,
индекс 0, сборка 0
```
`110 = 89 + 21`, ровно. Все 21 уходят в **отдельный проход 3D-скайбокса**
(`world.rs`, ветка `if face.dispinfo >= 0` внутри цикла скайбокса) и там
строятся. Потерь нет — гипотеза «T-junction отбрасывает `numedges > 4`»
опровергнута: у всех 110 displacement-граней `numedges = 4`, и все лежат в
`models[0]`.
Тест `real_map_displacement_coverage` проверяет, что сумма отсева равна
размеру `LUMP_DISPINFO` — ничего не теряется молча.
### E17.5. `startPosition` — в порядке
Гипотеза «углы сетки выбираются наугад» опровергнута: у всех 110 записей
`startPosition` совпадает с одним из углов своей базовой грани, худшее
расстояние **0.053 юнита**. Также сходятся `power` (87×power 3, 19×4, 4×2)
и `DispVertStart`: сумма `(2^power+1)^2` = 12 638 = размер `LUMP_DISP_VERTS`.
Значит порядок углов в `build_displacement` и привязка к `startPosition`
верны, и «вывернутость» дисплейсментов из жалобы ищется в другом месте —
это открыто.
### E17.6. Что открыто
1. **«Дисплейсменты вывернуты»** — `startPosition`, `power`, `DispVertStart`,
нормали и порядок углов проверены и верны; остаётся порядок индексов
`dDispVert` (`a * side + b`) и трактовка скайбокс-прохода.
2. **«Свет распространён»** — визуально: резкие грани света/тени на
дисплейсментах. Подозрение на UV лайтмапы для displacement (см. ответ
Gemini в `GEMINI.md`), но наша формула совпадает с его — нужен замер
эталонного кадра GMod.
3. Зеркало (`func_reflective_glass`, модель 6) не работает.
4. `ndc.z` в `sun_shadow` (E15.8 п. 3).