Браш-сущности в своём origin: рендер и коллизия
Some checks are pending
CI / checks (push) Waiting to run
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:
parent
c87665dee8
commit
a6441ac9ad
5 changed files with 815 additions and 125 deletions
82
GEMINI.md
82
GEMINI.md
|
|
@ -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` — оставляем.
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
/// Нормали дисплейсментов смотрят наружу, а не вниз.
|
||||
///
|
||||
/// Жалоба: «дисплейсменты вывернуты». У кирпичных граней нормаль берётся
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
208
docs/PARITY.md
208
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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue