diff --git a/GEMINI.md b/GEMINI.md index 4e1c6a6..f3b4bd3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -88,4 +88,84 @@ Наш парсер даёт 9 460 граней. **Совпадает ли 56 × 9460 = 529 360 с распакованным размером?** Если да — значит `dface_t` у нас разбирается правильно; если нет — у нас сдвиг на какое-то поле, и это может быть -причиной и плоского рельефа, и белых плит. \ No newline at end of file +причиной и плоского рельефа, и белых плит. + +--- + +# Часть 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` — оставляем. \ No newline at end of file diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index 0180e32..208b8c8 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -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 { 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 = 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> { + 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 { + let mut out: Vec = 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 { + 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() + ); + } + /// Нормали дисплейсментов смотрят наружу, а не вниз. /// /// Жалоба: «дисплейсменты вывернуты». У кирпичных граней нормаль берётся diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index 26f2c16..6945dd8 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -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>, +/// Мы рисовали весь `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)>, } -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::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> = 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::().ok()) + else { continue; - } - // `brushes` — сколько кирпичей, `brushlist` — их номера через запятую. - let count: usize = kv - .get_str("brushes") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - let list: Vec = kv - .get_str("brushlist") - .map(|v| { - v.split(',') - .filter_map(|p| p.trim().parse::().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)> = Vec::new(); + for chunk in models.chunks_exact(48) { + let read = |range: core::ops::Range| -> 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 { + 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 { 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> = vec![None; texdata.len()]; let mut texture_budget_left = TEXTURE_BUDGET; @@ -481,10 +473,10 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result { 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 { 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 { 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 { ); 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.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 { 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 { result } -fn parse_vec3(text: &str) -> Option { +pub(crate) fn parse_vec3(text: &str) -> Option { 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: diff --git a/crates/formats/src/bsp.rs b/crates/formats/src/bsp.rs index b403c80..aaa40d9 100644 --- a/crates/formats/src/bsp.rs +++ b/crates/formats/src/bsp.rs @@ -669,6 +669,16 @@ pub mod geometry { pub nodes: Vec, pub leaves: Vec, pub leaf_faces: Vec, + /// `LUMP_LEAFBRUSHES`: какие кирпичи лежат в каких листах дерева BSP. + /// + /// Нужно, чтобы узнать, к какой модели карты относится кирпич: у + /// `dmodel_t` есть `headnode`, и обход поддерева собирает ровно те + /// кирпичи, что принадлежат модели (замер на gm_construct: модель 0 — + /// 2 168 кирпичей, модели 1..5 — по 1–5, и их габариты совпадают с + /// локальными габаритами моделей). Кирпичи браш-сущностей хранятся в + /// локальных координатах вокруг нуля, поэтому без такой привязки их + /// коллизия оказывалась в (0,0,0). + pub leaf_brushes: Vec, pub models: Vec, 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)], diff --git a/docs/PARITY.md b/docs/PARITY.md index 85fe5f4..ec76e6e 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -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).