Заметки для ревью Gemini: четыре вопроса с измеренными фактами
Some checks are pending
CI / checks (push) Waiting to run

This commit is contained in:
Niko Marmeladkov 2026-10-16 08:21:32 +03:00
parent 6fd44ba014
commit c87665dee8

231
GEMINI.md
View file

@ -1,182 +1,91 @@
# Задача для ревью: рендер Source-карты в Rust (gmod-rs)
# Вопросы Gemini по Source BSP — gmod-rs
## Что это
Репозиторий открыт: `git@git.n1ko.dev:Niko/gmod-rs.git`
Ключевые файлы: `crates/engine/src/world.rs`, `crates/formats/src/bsp.rs`,
`crates/render/src/world.wgsl`.
`gmod-rs` — клиент Garry's Mod на Rust, который должен быть совместим с
настоящими серверами и максимально близок к GMod в одиночной игре. Рендер
свой, на wgpu; карты читаются напрямую из BSP (Valve, формат Source v1).
Ниже — только то, что **уже измерено**. Прежние гипотезы помечены как
опровергнутые, чтобы не тратили время.
Карта для всех замеров — `gm_construct.bsp`, она в серверной и клиентской
установках совпадает побайтно (md5 `a671368902315ad97006be74f85346ef`).
## Контекст
## Как воспроизвести
Клиент Garry's Mod на Rust. Карта `gm_construct` (BSP v20, 36 МБ).
Жалоба игрока: «трава вывернута, видно комнату под землёй, в 0 0 белые плиты,
которых нет в оригинале».
```bash
# Контрольный кадр ровно в точке эталона
cargo run -q -p client -- map --assets ~/gmod_server/server gm_construct \
--hidden --frames 2 --noclip --fov 100 --width 960 --height 540 \
--flythrough /tmp/out --flythrough-frames 2 \
--route="-399.495911 -1604.489868 67.883812|3.475900 146.330063; -400 -1605 67|3.475900 146.330063"
```
## Что измерено и уже исключено
Эталон GMod снят так:
1. **`LUMP_DISP_VERTS` (33) читается верно** — f32, шаг 20 байт.
`vec ≈ (0,0,1)`, `dist` 18.1 / 18.6 / 19.3 / 20.1 на соседних вершинах.
Гипотеза «нормаль берётся из `dv.vec`» неверна.
2. **Ориентация рельефа в порядке** — 110 базовых плоскостей смотрят вверх,
0 вниз; намотка не перевёрнута (0 из 11 776 треугольников).
Рельеф в радиусе 600 от спавна: z от −151.4 до −148.0, то есть почти плоский.
3. **Чёрные области — не тени.** Принудительный `return 1.0` в `sun_shadow`
даёт тот же кадр. Значит чёрное приходит из `lit = in.light * ambient`.
4. Справочник лампов — **stride 16**, не 48. `LUMP_NODES` (5) — это дерево BSP.
```
setpos -399.495911 -1604.489868 67.883812; setang 3.475900 146.330063 0
fov 100
```
## Вопрос 1. Главный
**Внимание на порядок углов.** У GMod `setang yaw pitch roll`, у нас в маршруте
`--route "x y z pitch yaw"` — обратный порядок. Ошибёшься — получишь кадр в
другую сторону и будешь сравнивать не то.
Кадр с камеры `(0, 0, 2200)`, pitch 55, yaw 180, fov 100 показывает:
## Картинки с проблемами
- трава — **ровная оливковая плоскость без рельефа и без вариаций лайтмапы**;
при этом габарит рельефа по z от −148 до 11 235, то есть холмы в карте есть;
- в центре — **бетонная плита 2480 × 1936 × 8** с габаритом
`(−1240,−968,−4)..(1240,968,4)`, материал
`maps/gm_construct/concrete/concretefloor028a_1280_-1600_-64`,
продублированная (грани 9379 == 9385, 9380 == 9386);
- лампа `LUMP_DISP_INFO` (26) — 19 360 байт; в рендер попадает 89
displacement-граней при 110 плоскостях.
1. **Освещение, сравнение бок о бок** (сверху GMod, снизу наш рендер, одна и та
же камера)
https://img.n1ko.dev/0fab7c19770b8b2512496d2e.png
2. **Трава плоская** — кривая поверхность без градиента
https://img.n1ko.dev/552fa38a1f3b3e47d0cb0c93.png
3. **Холмы плоские** — та же проблема издали
https://img.n1ko.dev/e0a4b9279049aa48855b6968.png
4. **Вода** — рябь выглядит гофрированной, а не как вода
https://img.n1ko.dev/3260bcfc5bc2d0db0263f3e6.png
5. **Пролёт по карте** (15 с, с тенями, водой, постройками)
https://img.n1ko.dev/27d048752049c8eff4cb0ca9.mp4
**Почему рельеф читается плоским, если геометрия изогнута, и куда деваются
21 displacement-грань из 110?**
## Что уже измерено и что чинить НЕ надо
Конкретно: правильно ли в `build_displacement` (`world.rs`, ~строка 1676)
восстанавливать позицию вершины как `base + dv.vec * dv.dist`, где `base` —
билинейная интерполяция угла грани, и брать углы грани из
`geometry.face_vertex_indices`? Или углы надо брать из `DispInfo.start_position`
и `DispInfo` иначе, и из-за этого сетка «съезжает»?
Это важно, чтобы не тратить время на ложные следы.
## Вопрос 2. Как в Source грувится displacement-поверхность
* **Порядок каналов в лайтмапе.** Люксель лежит как `B, G, R` — читался как
`R, G, B`, картинка уходила в синеву. Исправлено, подтверждено эталоном:
нормированный тон стал `(1.000, 0.960, 0.756)` против `(1.000, 0.973, 0.866)`
у GMod.
* **Размер блока лайтмапы.** Блок — это `(extents[0]+1) × (extents[1]+1)`
люкселей по 4 байта. Ширина строки бралась на люксель меньше.
* **Яркость лайтмапы.** Экспонента из четвёртого байта НЕ применяется: у
большинства люкселей байт равен 255, что как `i8` даёт −1 и масштаб 0,5 —
вдвое темнее. Масштаб подобран по эталону на двух материалах: 3.0 даёт
бетон 147,0 / траву 129,0 при эталонных 143,6 / 127,1.
* **Нормали кирпичных граней.** Были перевёрнуты, исправлено: было «вверх» 17
вершин против «вниз» 18 096, стало вверх 15 407 / вниз 2 954.
* **Намотка рельефа.** Проверена переворотом: при инверсии вверх 8 008 / вниз
10 353 и разворачиваются ВСЕ 11 776 треугольников — то есть явно хуже.
Значит намотка правильная, и «вывернутая трава» — не про неё.
Нужен алгоритм движка Source (не код, а порядок шагов), который
восстанавливает поверхность:
## Проблема 1. Трава выглядит «вывернутой» — наклон нормалей почти нулевой
1. Откуда берутся 4 опорные точки сетки и в каком порядке (сравнение с
`startPosition` как у нас, или иначе)?
2. Какой знак и какая база у `dv.dist` — смещение от базовой плоскости
грани или от плоскости `DispInfo`?
3. Нужно ли учитывать `DispInfo.axis`/`scale`/`subdiv`?
4. Почему у нас `vec` почти всегда `(0,0,1)` — это нормально для Source или
мы читаем не то поле?
**Замер, который это вскрыл:**
## Вопрос 3. Отсев невидимых кирпичей
```
ЗАМЕР наклон нормалей: из 39585 вершин наклонено 26091
ЗАМЕР гистограмма nz: <0.5 24184, 0.5-0.67 14, 0.67-0.83 56, 0.83-1.0 34
```
На карте 2 203 кирпича и 9 460 граней, номера **не параллельны**
(14 946 сторон). Ни одна сущность не имеет ключа `brushlist`.
Связь «грань → кирпич» находится только через `planenum` + проверку, что все
вершины грани внутри выпуклой оболочки кирпича.
Из 15 407 вершин, смотрящих вверх, наклонены всего **104**. То есть нормали
рельефа почти все строго `(0, 0, 1)`, хотя геометрия кривая — холмы на картинках
изогнуты, а свет по ним не идёт.
В Source BSP v1 `dbrushside_t` занимает ровно 8 байт и **не хранит** номер
грани. Значит ли это, что в движке действительно нет прямой связи, и он
определяет принадлежность иначе — например, по `LUMP_LEAFBRUSHES` (17)
или по тому, что не-твёрдые кирпичи (`contents == 0`) вообще не получают
граней?
Код построения рельефа: `crates/engine/src/world.rs`, функция
`build_displacement`. Нормаль каждой вершины усредняется по треугольникам сетки
(`add_normal`), затем нормализуется. Габарит рельефа на gm_construct:
`x -14848..15360, y -14336..14848, z -148..11235`.
**Как в Source клиент решает, что грань не рисуется, если у кирпича
`CONTENTS_EMPTY`, а `SURF_NODRAW` не выставлен?**
Что проверить:
- правильно ли смещение вершины сетки считается (`base + dv.vec * dv.dist`);
- правильно ли усредняются нормали треугольников;
- не теряется ли наклон при переводе из базовой плоскости грани в сетку;
- верно ли читается `ddispinfo_t` и сам displacement.
У нас под правило «`contents == 0` → не рисуем» попадают 330 граней, из них
250 — `plasterwall022c`, 50 — `CONSTRUCT_CONCRETE_GROUND`, и это явно видимая
геометрия. Значит правило в лоб неверно.
## Проблема 2. Теней фактически нет, тон не тот
## Вопрос 4. `dface_t` в BSP v20
В движке появился один каскад теней солнца: карта 2048×2048, `Depth32Float`,
ортографическая проекция, радиус 2 600 юнитов вокруг камеры. Проверка выводом
самого коэффициента затенения показывает, что карта работает (тоннель, стена под
зданием и пол за корпусом в тени).
Но на финальной картинке разница видна, и она не в яркости:
| зона | GMod | наш |
|---|---|---|
| бетон в солнце | 143,6 | 147,0 |
| трава в солнце | 127,1 | 129,0 |
| небо | 199,7 | 196,9 |
| **стена в тени** | **36,4** | **124,6** |
| **туннель** | **96,1** | **165,8** |
Яркость освещённых поверхностей сошлась, а затенённые у нас втрое светлее.
Причина по коду: основной свет даёт запечённая лайтмапа
(`lit = in.light * ambient + dynamic`), а динамическое солнце при
`SUN_BRIGHTNESS_SCALE = 900` вносит малую долю. Тени солнца гасят только
динамическую часть, а она мала. То есть затенение в GMod сидит в самой лайтмапе
карты, и у нас на этом участке лайтмапа читается примерно втрое светлее.
Что проверить:
- правильно ли выбирается люксель по UV для крупных граней (внутри блока на
больших гранях сдвиг даёт ошибку);
- не занижается ли лайтмапа в тенях из-за домножения на `ambient`;
- как в Source соотносится запечённый свет и `r_lightmap` / `LightmapGamma`;
- нужен ли правильный tone mapping вместо прямой выдачи линейного значения.
Отдельно про тон: небо у нас насыщеннее и холоднее, чем в GMod; трава у нас
темнее и более оливковая. Возможно, это следствие той же яркости либо
отсутствия tone mapping.
## Проблема 3. Вода выглядит плохо
Сейчас поверхность считается суммой пяти герстнеровских волн (длины 46, 23, 11,
5,5 и 2,7 юнита) плюс мелкая рябь. Френель и блик от солнца есть,
полупрозрачность через `mix(0.42, 0.94, fresnel)`.
Что не так по картинкам:
- рябь читается как гофрированный металл, а не как вода;
- полосы слишком правильные и высокочастотные;
- вдали рябь превращается в муар;
- поверхность «стеклянная» — нет подповерхностного рассеивания и мутной
глубины, как в настоящей воде;
- береговая линия местами рваная.
Подсказка из референсов по волнам: «Gerstner, not sine» — горизонтальный сдвиг
к гребню даёт острые гребни и широкие ложбины. Сейчас сдвиг реализован, но
результат всё равно читается как гофра.
Что можно попробовать:
- нормали из суммы волн считать аналитически по производной, а не
приближённо;
- добавить подповерхностное рассеивание (глубина + цвет дна);
- сделать зависимость отражения неба от реальной карты неба, а не от
константы `mix(sky_steep, sky_grazing, facing)`;
- ограничить частоту по производной (fwidth) против алиасинга, а не только по
расстоянию.
## Что хотим от ревью
1. **Диагноз проблемы 1** по коду `build_displacement`: где теряется наклон
нормалей. Это главная жалоба, повторяется несколько раз.
2. **Разбор модели освещения**: как правильно совместить запечённую лайтмапу
Source с динамическим солнцем, чтобы получались тени, и какой тон-маппинг
нужен, чтобы сходился с GMod.
3. **Рецепт воды**, который будет выглядеть как настоящая, а не как гофра.
4. По каждому пункту — конкретные места файлов и что именно менять.
## Требования к правкам из этого репозитория
- Никаких `unwrap()`, `panic!`, `expect()` на данных из файлов и сети.
- `catch_unwind` на границах: вызовы Lua, FFI, обработчики задач.
- Код Source SDK 2013 / Source.NET копировать нельзя — только как спецификация
поведения. Реализация своя.
- Ассеты Garry's Mod не коммитим.
- Комментарии и документация — на русском, код и имена — на английском.
- `cargo fmt --all`, `cargo clippy --workspace --all-targets -- -D warnings`,
`cargo test --workspace` должны проходить.
- Тесты с реальной картой помечаются `#[ignore]` и запускаются с
`SOURCE_RS_TEST_BSP`. Примечание: пробы в `crates/engine/src/player.rs`
строят мир с пустым `VirtualFs::new()`, поэтому текстуры там всегда
заглушки — анализ текстур годен только в клиенте, где VFS настоящий.
- `Batch::first_index` — это смещение в буфере **индексов**, а не вершин.
Обращение `vertices[first_index + k]` молча даёт чужие вершины; правильно
`vertices[indices[first_index + k]]`. На этом уже обожглись.
- Меряй, а не рассуждай: гипотеза проверяется замером, ложные следы
фиксируются явно. Подробности в `docs/PARITY.md`, части E8–E12.
`LUMP_FACES` (7) — 529 760 байт, версия 1 (LZMA). После распаковки сколько
байт на грань? У Source BSP v20 `dface_t` = 56 байт с полями
`planenum, side, firstedge, numedges, texinfo, styles[4], lightofs`.
Наш парсер даёт 9 460 граней. **Совпадает ли 56 × 9460 = 529 360 с
распакованным размером?** Если да — значит `dface_t` у нас разбирается
правильно; если нет — у нас сдвиг на какое-то поле, и это может быть
причиной и плоского рельефа, и белых плит.