Замеры по «белым плитам» и «вывернутой траве»: три гипотезы опровергнуты

Гипотеза Gemini (нормаль из dv.vec) не подтвердилась: сырые байты LUMP_DISP_VERTS
читаются как f32 с шагом 20 и дают осмысленные значения (vec=(0,0,1),
dist=18.145). Формат разобран верно.

Ориентация рельефа в порядке: 110 плоскостей вверх, 0 вниз, намотка не
перевёрнута (0 из 11776). У спавна рельеф плоский — z от -151.4 до -148.0,
поэтому «наклонены 104 вершины» это свойство карты, а не баг.

Найдена ошибка в моей же сверке: справочник лампов читался со stride 48 вместо
16. Из-за этого LUMP_NODES выглядел навмешем, а лампы 6 и 18 менялись местами.
Выводы части E14 про LUMP_NODES недействительны.

«Белые плиты» разобраны: у материала color/white всего 9 граней, и это не
ящик, а тонкие пластины 64x4 и 128x4 с нормалями ±X/±Y на четырёх высотах —
то, что на глаз читается как «оси координат 3д». Большая белая поверхность в
центре — другая: бетонная плита 2480x1936 над спавном, продублированная
(9379==9385, 9380==9386), дубль есть в самой карте.

Чёрные области проверены экспериментом: отключение тенев и отсечения задних
граней меняет картину, значит чёрное приходит из лайтмапы, а не из теней.

Подробности и следующие шаги в PARITY.md E15.
This commit is contained in:
Niko Marmeladkov 2026-10-16 08:21:12 +03:00
parent 5f1fdcf5e2
commit 6fd44ba014
2 changed files with 254 additions and 135 deletions

View file

@ -3789,8 +3789,54 @@ mod tests {
}
}
println!("ЗАМЕР габарит рельефа: {lo:?} .. {hi:?}");
// Точки самого рельефа: треугольники displacement'а после сборки мира.
// Высота рельефа рядом со спавном (704, 132) — там игрок стоит,
// и если он оказывается ПОД поверхностью, рельеф «вывернут».
let world = crate::world::build_world(&bsp, &fs::VirtualFs::new()).expect("мир строится");
let spawn = (704.0f32, 132.0f32);
let mut near: Vec<(f32, f32)> = Vec::new();
for tri in &world.disp_tris {
let center = (tri[0] + tri[1] + tri[2]) / 3.0;
let d = ((center.x - spawn.0).powi(2) + (center.y - spawn.1).powi(2)).sqrt();
if d < 600.0 {
near.push((center.z, d));
}
}
near.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if !near.is_empty() {
let lo_z = near.iter().map(|n| n.0).fold(f32::MAX, f32::min);
let hi_z = near.iter().map(|n| n.0).fold(f32::MIN, f32::max);
println!(
"ЗАМЕР рельеф в радиусе 600 от спавна: z от {lo_z} до {hi_z}, треугольников {}",
near.len()
);
} else {
println!("ЗАМЕР у спавна треугольников рельефа нет");
}
for (z, d) in near.iter().take(4) {
println!("ЗАМЕР ближайший треугольник: z={z}, расстояние {d}");
}
// Базовая плоскость и смещение displacement'ов: если dist положителен,
// поверхность поднимается над базовой плоскостью.
let mut base_z: Vec<f32> = Vec::new();
for face in &geometry.faces {
if face.dispinfo < 0 {
continue;
}
let Some(plane) = geometry.planes.get(face.planenum as usize) else {
continue;
};
if plane.normal[2].abs() > 0.5 {
base_z.push(plane.dist);
}
}
base_z.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
println!(
"ЗАМЕР базовые плоскости рельефа (z): {} штук, от {:?} до {:?}",
base_z.len(),
base_z.first(),
base_z.last()
);
// Точки самого рельефа: треугольники displacement'а после сборки мира.
let mut spots: Vec<(f32, f32, f32)> = Vec::new();
for tri in &world.disp_tris {
let center = (tri[0] + tri[1] + tri[2]) / 3.0;
@ -3837,151 +3883,120 @@ mod tests {
let world = build_world(&bsp, &VirtualFs::new()).expect("мир строится");
let geometry = bsp.geometry().expect("геометрия");
// Ищем грани, чей материал — белая текстура color/white.
let white: std::collections::HashSet<usize> = world
.materials
.iter()
.enumerate()
.filter(|(_, m)| m.texture.name == "color/white" || m.name.contains("COLOR_ROOM"))
.map(|(i, _)| i)
.collect();
println!("ЗАМЕР материалов с белой текстурой: {}", white.len());
let mut with_nodraw = 0usize;
let mut without_nodraw = 0usize;
let mut shown = 0usize;
for face in &geometry.faces {
let Some(texinfo) = geometry.texinfos.get(face.texinfo as usize) else {
continue;
};
let Some(mat) = world.materials.get(texinfo.texdata as usize) else {
continue;
};
if !white.contains(&mat_index(&world, mat)) {
continue;
}
let nodraw = texinfo.flags & crate::world::SURF_NODRAW != 0;
if nodraw {
with_nodraw += 1;
} else {
without_nodraw += 1;
}
if shown < 8 {
println!(
"ЗАМЕР грань #{}: surf flags {:#x}, NODRAW {}, стили {:?}, коллизия-да",
shown, texinfo.flags, nodraw, face.styles
);
shown += 1;
// Считаем, сколько граней принадлежит кирпичам с содержимым
// `CONTENTS_EMPTY`: в Source такие кирпичи не рисуются.
let brush_data = bsp
.lump(formats::bsp::lump::BRUSHES)
.expect("кирпичи")
.into_owned();
let side_data = bsp
.lump(formats::bsp::lump::BRUSHSIDES)
.expect("стороны")
.into_owned();
// Плоскость → номера кирпичей: искать все подряд нельзя, их 2203.
let mut plane_brushes: std::collections::HashMap<usize, Vec<usize>> = Default::default();
let mut contents_of: Vec<i32> = Vec::new();
for (bi, chunk) in brush_data.chunks_exact(12).enumerate() {
let first = i32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) as usize;
let count = i32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]) as usize;
let contents = i32::from_le_bytes([chunk[8], chunk[9], chunk[10], chunk[11]]);
contents_of.push(contents);
for s in first..first + count {
let Some(side) = side_data.get(s * 8..s * 8 + 2) else {
break;
};
plane_brushes
.entry(u16::from_le_bytes([side[0], side[1]]) as usize)
.or_default()
.push(bi);
}
}
println!("ЗАМЕР граней с этим материалом: с NODRAW {with_nodraw}, без {without_nodraw}");
// Чья это сущность: у brush-сущностей есть ключи `brushes`/`brushlist`.
// Смотрим, какие классы вообще есть на карте и сколько у них кирпичей.
let Some(ent) = bsp.entities().ok() else {
eprintln!("сущности не читаются");
return;
let hull = |bi: usize| -> Vec<(Vec3, f32)> {
let first = i32::from_le_bytes([
brush_data[bi * 12],
brush_data[bi * 12 + 1],
brush_data[bi * 12 + 2],
brush_data[bi * 12 + 3],
]) as usize;
let count = i32::from_le_bytes([
brush_data[bi * 12 + 4],
brush_data[bi * 12 + 5],
brush_data[bi * 12 + 6],
brush_data[bi * 12 + 7],
]) as usize;
let mut planes = Vec::new();
for s in first..first + count {
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;
if let Some(p) = geometry.planes.get(planenum) {
planes.push((Vec3::new(p.normal[0], p.normal[1], p.normal[2]), p.dist));
}
}
planes
};
let mut owners: std::collections::HashMap<String, (usize, usize)> =
std::collections::HashMap::new();
for kv in ent {
let class = kv.get_str("classname").unwrap_or("?").to_string();
let entry = owners.entry(class).or_insert((0, 0));
entry.0 += 1;
if kv.get_str("brushes").is_some() || kv.get_str("brushlist").is_some() {
entry.1 += 1;
}
}
// Кирпич, которому принадлежит белая грань: ищем по совпадению плоскостей.
{
let brush_data = bsp
.lump(formats::bsp::lump::BRUSHES)
.expect("кирпичи")
.into_owned();
let side_data = bsp
.lump(formats::bsp::lump::BRUSHSIDES)
.expect("стороны")
.into_owned();
let mut shown = 0;
'outer: for face in &geometry.faces {
let Some(texinfo) = geometry.texinfos.get(face.texinfo.max(0) as usize) else {
continue;
};
let Some(mat) = world.materials.get(texinfo.texdata as usize) else {
continue;
};
if mat.texture.name != "color/white" {
let mut by_contents: std::collections::BTreeMap<i32, usize> = Default::default();
let mut empty_faces: Vec<usize> = Vec::new();
let mut no_brush = 0usize;
for (index, face) in geometry.faces.iter().enumerate() {
let Some(candidates) = plane_brushes.get(&(face.planenum as usize)) else {
no_brush += 1;
continue;
};
let Ok(indices) = geometry.face_vertex_indices(index) else {
continue;
};
let mut best: Option<usize> = None;
for &bi in candidates {
let planes = hull(bi);
if planes.len() < 4 {
continue;
}
for (bi, chunk) in brush_data.chunks_exact(12).enumerate() {
let first = i32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
let count = i32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]);
let contents = i32::from_le_bytes([chunk[8], chunk[9], chunk[10], chunk[11]]);
if first < 0 || count <= 0 {
continue;
}
for s in first as usize..(first + count) as usize {
let Some(side) = side_data.get(s * 8..s * 8 + 2) else {
break;
};
if u16::from_le_bytes([side[0], side[1]]) as usize != face.planenum as usize
{
continue;
}
if shown < 12 {
println!(
"ЗАМЕР белая грань: кирпич #{bi}, содержимое {contents:#x}, сторон {count}"
);
shown += 1;
}
continue 'outer;
let inside = indices.iter().all(|v| {
geometry.vertices.get(*v as usize).is_some_and(|v| {
let p = Vec3::new(v.pos[0], v.pos[1], v.pos[2]);
planes.iter().all(|(n, d)| n.dot(p) <= *d + 0.25)
})
});
if inside {
best = Some(bi);
break;
}
}
match best {
Some(bi) => {
let contents = contents_of[bi];
*by_contents.entry(contents).or_default() += 1;
if contents == 0 {
empty_faces.push(index);
}
}
None => *by_contents.entry(-1).or_default() += 1,
}
}
let mut top: Vec<(&String, &(usize, usize))> = owners.iter().collect();
top.sort_by_key(|(_, (n, _))| std::cmp::Reverse(*n));
println!("ЗАМЕР сущности карты (всего, из них с кирпичами):");
// Параллельны ли кирпичи и грани: если да, по диапазону кирпичей
// сущности можно точно назвать её грани.
{
let brush_data = bsp.lump(formats::bsp::lump::BRUSHES).expect("кирпичи");
let side_data = bsp
.lump(formats::bsp::lump::BRUSHSIDES)
.expect("стороны кирпичей");
let mut brushes = 0usize;
let mut sides = 0usize;
for chunk in brush_data.as_ref().chunks_exact(12) {
brushes += 1;
let first = i32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
let count = i32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]);
if first >= 0 && count >= 0 {
sides += count as usize;
}
}
println!(
"ЗАМЕР кирпичей {brushes}, сторон {sides}, сторон на кирпич {:.2}, граней {}",
sides as f64 / brushes.max(1) as f64,
geometry.faces.len()
);
let side_len = side_data.as_ref().len();
println!(
"ЗАМЕР блок сторон {side_len} байт, при 8 байт на сторону — {}",
side_len / 8
);
println!("ЗАМЕР граней по содержимому кирпича:");
for (contents, count) in &by_contents {
println!("ЗАМЕР содержимое {contents:#x}: граней {count}");
}
for (class, (n, with_brushes)) in top.iter().take(16) {
println!("ЗАМЕР {class}: {n}, с кирпичами {with_brushes}");
println!("ЗАМЕР граней, у которых плоскости нет ни в одном кирпиче: {no_brush}");
let mut materials_empty: std::collections::HashMap<String, usize> = Default::default();
for &index in &empty_faces {
let face = &geometry.faces[index];
let name = geometry
.texinfos
.get(face.texinfo.max(0) as usize)
.and_then(|ti| world.materials.get(ti.texdata as usize))
.map(|m| m.name.clone())
.unwrap_or_else(|| "?".to_string());
*materials_empty.entry(name).or_default() += 1;
}
println!("ЗАМЕР материалы граней с содержимым 0x0:");
let mut list: Vec<(&String, &usize)> = materials_empty.iter().collect();
list.sort_by_key(|(_, c)| std::cmp::Reverse(**c));
for (name, count) in list.iter().take(20) {
println!("ЗАМЕР {name}: {count}");
}
}
/// Индекс материала по имени (для диагностики).
fn mat_index(world: &crate::world::World, mat: &crate::world::Material) -> usize {
world
.materials
.iter()
.position(|m| std::ptr::eq(m, mat))
.unwrap_or(usize::MAX)
}
/// Нормали дисплейсментов смотрят наружу, а не вниз.

View file

@ -2022,3 +2022,107 @@ local = 1 / (1 + (k · distance · 0.01)²)
стены) и 34 в верхнем бине. Геометрия холмов кривая, а наклон почти нулевой —
значит он теряется при построении сетки displacement'а. Гипотеза Gemini про
`dv.vec` как источник нормали объясняет цифру, но ещё не проверена.
## Часть E15. Разбор «белых плит» и «вывернутой травы» (2026-10-16)
### E15.1. Гипотеза Gemini про `dv.vec` — опровергнута измерением
Предполагалось, что нормаль берётся из `dv.vec` вместо геометрии сетки.
Проверка сырых байт `LUMP_DISP_VERTS` (лам<D0B0><D0BC> 33, 252 760 байт):
```
первые 64 байта:
0: 00 00 00 00 d5 4c a9 38 ff ff 7f 3f 93 29 91 41
16: 00 00 20 3f 62 b5 db b7 7a b1 1a 39 00 00 80 3f
```
Читать как f32 с шагом 20 байт даёт осмысленные значения
(`vec=(0.0000,0.0001,1.0000)`, `dist=18.145`), читать как три `i16` — мусор.
Значит `parse_dispverts` прав: формат f32, шаг 20, `vec` действительно
почти всегда `(0,0,1)`, а рельеф формируется полем `dist`. Гипотеза неверна.
### E15.2. Ориентация рельефа в порядке
| замер | значение |
|---|---|
| плоскости displacement'ов | вверх 110, вниз 0, сбоку 0 |
| намотка | развёрнуто 0 треугольников из 11 776 |
| рельеф в радиусе 600 от спавна | z от −151.4 до −148.0 |
| базовые плоскости рельефа | 110 штук, z от −148 до 11 235 |
Рельеф у спавна почти плоский (3 юнита перепада на 1 200), поэтому «наклонены
только 104 вершины» — свойство карты, а не ошибка намотки.
### E15.3. Ошибка в моей же сверке лампов
Справочник лампов читался со stride 48 вместо 16. Из-за этого `LUMP_NODES`
выглядел как навмеш, а лампы 6 и 18 менялись местами. Все выводы про
`LUMP_NODES` из части E14 недействительны; содержимое по stride 16 приведено
выше и сходится с тем, что даёт Rust-читатель.
### E15.4. «Белые плиты» — что это на самом деле
Материал с текстурой `color/white` (`GM_CONSTRUCT/COLOR_ROOM`, `UnlitGeneric`)
используют всего **9 граней**, и это не ящик в центре карты, как показалось
сначала, а тонкие пластины:
| грань | габарит | размер |
|---|---|---|
| 481, 483 | x = −2560 | 64 × 4 |
| 614, 615 | x = −2048 | 64 × 4 |
| 880 | y = −2176, z = −20..−16 | 128 × 4 |
| 1985, 1987 | y = −2868, z = 508 и 636 | 64 × 4 |
| 2057, 2058 | y = −2704, z = 508 и 636 | 64 × 4 |
Все нормали строго ±X или ±Y, высоты четырёх уровней — это и есть «оси
координат 3д» на глаз. Свойства: `surf flags 0x0` (`SURF_NODRAW` **не
выставлен**), стили `[0,255,255,255]` и `[0,33,255,255]`, коллизия есть.
### E15.5. Настоящая белая плита в центре — другая
Большая белая поверхность под камерой `(0,0,2200)` — это не `color/white`.
Самые крупные грани рядом с нулём:
| грань | габарит | материал |
|---|---|---|
| 9379, 9380, 9385, 9386 | (−1240,−968,−4)..(1240,968,4) | `concretefloor028a_1280_-1600_-64` |
| 9344, 9347, 9350 | z = −148 | `concretefloor028a_1344_3584_32` |
То есть бетонная плита 2480 × 1936 в 8 юнитов толщину прямо над спавном, и
она **продублирована**: грани 9379 и 9385 имеют одинаковый габарит, как и
9380 с 9386. Дубль есть в самой карте (две одинаковые плиты), это не наш баг.
### E15.6. Чёрные области — не тени
`SOURCE_RS_NO_CULL` и принудительный `return 1.0` в `sun_shadow` дали кадры,
отличающиеся только отсечением; тени на чёрные пятна не влияют. Значит чёрное
берётся из `lit = in.light * ambient + dynamic`, то есть из лайтмапы.
### E15.7. Связь «грань → кирпич»
Прямой связи нет: 2 203 кирпича, 14 946 сторон, 9 460 граней, номера не
параллельны. Рабочий обход — по плоскости: `planenum` грани → кирпичи, у
которых эта плоскость есть, затем проверка, что все вершины грани внутри
выпуклой оболочки. Этим найдено, что часть белых пластин принадлежит кирпичам
с `contents = 0x0` (`CONTENTS_EMPTY`, не твёрдые).
Но правило «`contents == 0` → не рисуем» **слишком грубое**: под него
попадают 330 граней, в основном `plasterwall022c` (250) и
`CONSTRUCT_CONCRETE_GROUND` (50) — это видимая геометрия. Отбор по содержимому
в лоб резать нельзя.
`LUMP_FACEIDS` (лампа 11, 18 920 байт = 9 460 значений по 2 байта) на этой карте
содержит значения вроде −27 746 и 24 471, а номера сущностей — то, что нужно,
не подтвердилось.
### E15.8. Что осталось
1. Понять, почему бетонная плита 2480 × 1936 у спавна выглядит белой. Тон
по эталону: бетон 143.7 при эталонных 143.6 — совпадает, значит дело не в
масштабе, а в том, что в GMod этой плиты, вероятно, не видно (она внутри
комнаты, а мы видим её сверху).
2. Разобраться с плоской травой: снять кадр GMod из точки `ref_p4`
(`setpos 0 0 2200`, `setang 180 55`) и сравнить рельеф.
3. `sun_shadow` без проверки `ndc.z` — точка за ближней плоскостью карты
теней получает `depth < 0` и трактуется как затенённая. Сейчас это не
видно глазом, но диапазон не проверяется, и это лазейка для чёрных пятен.