Some checks are pending
CI / checks (push) Waiting to run
# Conflicts: # crates/client/src/map.rs # docs/DIARY.md
3383 lines
236 KiB
Markdown
3383 lines
236 KiB
Markdown
# План Source RS: одиночная игра, совместимая с GMod
|
||
|
||
**Статус:** план согласован, начало исполнения (2026-10-12).
|
||
**Каталог:** `~/garrysmod-rs`.
|
||
**Связано:** `PLAN.md` (общий план), `PROBLEMS.md` (боли), `RECON.md` (реверс).
|
||
|
||
Каждый этап — рабочий коммит и проверяемый результат, а не заготовка.
|
||
|
||
---
|
||
|
||
## Главный принцип: совместимость, а не тождество внутренностей
|
||
|
||
Решение пользователя: «1 в 1 не надо прям, но надо совместимость».
|
||
|
||
Это меняет критерий готовности. Нас интересует не внутреннее устройство GMod,
|
||
а то, что снаружи него видно и от чего зависит чужой код:
|
||
|
||
| Что должно работать | Почему это «совместимость» |
|
||
|---|---|
|
||
| **Контент** карт и аддонов: BSP, VTF, VMT, MDL, VTX, а также `lua/` с аддона | аддон физически не запустится без этого |
|
||
| **Lua-API**: `Entity()`, `ents.*`, `Vector`/`Angle`/`Color`, хуки, таймеры, `file`, `surface`, `sound`, `render`, `cam`, `Curves` | код аддона должен выполняться без правок |
|
||
| **Поведение игрока и физика** | геймплей аддона завязан на это |
|
||
| **Формат карт**: сущности читаем так же, как в Source | иначе нельзя открыть чужую карту |
|
||
| **Сетевой протокол** (уже сделано) | мультиплеер |
|
||
|
||
Что **не** требуется: внутренние структуры движка, точное совпадение
|
||
побайтовых раскладок в нестандартных случаях, поддержка всего, что есть в
|
||
`base_ents` GMod.
|
||
|
||
Практическое следствие: там, где можно, сверяемся с исходниками Source.NET и
|
||
реальными файлами, а не угадываем; а публичные Lua-имена и сигнатуры берём из
|
||
настоящих `lua/includes/` GMod, а не придумываем.
|
||
|
||
---
|
||
|
||
## Порядок (подтверждён)
|
||
|
||
**A → B → C → D → E**, причём **свет (C) раньше game.dll (E2)** — так решил
|
||
пользователь.
|
||
|
||
---
|
||
|
||
## Часть A. Найденные дефекты (то, что сломано прямо сейчас)
|
||
|
||
Часть пунктов пользователь заметил визуально, часть подтверждена замером по
|
||
реальному `gm_construct.bsp`.
|
||
|
||
### A1. Коллизия: скорость не гасится о поверхность
|
||
**Симптом** (пользователь): «подхожу в стену, отпускаю клавиши — входит внутрь
|
||
чуть-чуть, потом выталкивает».
|
||
|
||
**Причина** (`crates/engine/src/player.rs:432`): `move_aabb()` возвращает только
|
||
`(Aabb, on_ground)` и **не трогает `self.velocity`**. Каждый кадр игрок
|
||
сдвигается на `velocity * dt` внутрь стены, получает выталкивание и оказывается
|
||
на грани — визуально «ныряет» и отскакивает. Горизонтальная скорость в стену
|
||
никогда не обнуляется.
|
||
|
||
**Как в Source**: `CMoveData` отдаёт `clipNormal`, скорость проектируется на
|
||
плоскость контакта: `v -= n * min(0, dot(v, n))`.
|
||
|
||
**Исправление**: `move_aabb` возвращает нормали контакта; `Player::update` гасит
|
||
компоненту скорости внутрь каждой нормали и обнуляет `velocity.z` при
|
||
`on_ground`. Плюс явное торможение при отпускании клавиш.
|
||
|
||
**Приёмка**: тест «в стену на полной скорости → после отпускания стоит
|
||
неподвижно, `|velocity.xz| < 1`, положение не дрейфует»; тест на отсутствие
|
||
просадки в стену при `dt = 1/60` и скорости 250.
|
||
|
||
### A2. Коллизия: твёрдость браша определяется неверно
|
||
**Симптомы**: «невозможно пройти в здания, хотя проход есть», «вода твёрдая».
|
||
|
||
**Причина** (`player.rs:43`): `const TRIGGER: i32 = 0x4000_0000;` и проверка
|
||
`if contents == TRIGGER { continue }`. То есть твёрдым считается **всё, кроме
|
||
ровно `0x40000000`** — включая браши с `contents == 0` (по спецификации Source
|
||
`CONTENTS_SOLID = 0x1`, всё остальное не твёрдо) и браши воды/тумана.
|
||
|
||
**Замер по `gm_construct.bsp` (2203 браша, лумп 18):**
|
||
|
||
| contents | смысл | брашей | сейчас твёрдые | по спецификации |
|
||
|---|---|---|---|---|
|
||
| `0x08000001` | SOLID | 1130 | да | да |
|
||
| `0x00000001` | SOLID | 996 | да | да |
|
||
| `0x00000000` | EMPTY (не твёрдый) | **70** | **да** ✗ | нет |
|
||
| `0x08000080` | не SOLID | 3 | **да** ✗ | нет |
|
||
| `0x18000008` | не SOLID | 2 | **да** ✗ | нет |
|
||
| `0x10000020` | **WATER** | 1 | **да** ✗ | нет |
|
||
| `0x30000008` | не SOLID | 1 | **да** ✗ | нет |
|
||
|
||
Итого **77 из 2203 брашей (3.5%) считаются твёрдыми ошибочно** — невидимые стены
|
||
в проходах и твёрдая вода озера.
|
||
|
||
**Исправление**: твёрдым считать браш, у которого установлен бит
|
||
`CONTENTS_SOLID (0x1)`. Отдельно разобрать воду (`0x20`), туман (`0x40`),
|
||
окна (`0x2`) и решётки (`0x8`): они не останавливают игрока, но влияют на
|
||
жидкость и свет.
|
||
|
||
**Приёмка**: счётчик брашей по `contents` в логе; на gm_construct ожидается
|
||
2126 твёрдых и 77 не твёрдых; тесты «проход в здание открыт», «в воду можно
|
||
войти».
|
||
|
||
### A3. Коллизия: нет broadphase для брашей
|
||
2203 браша проверяются целиком на каждом подшаге, в каждой из 4 итераций
|
||
разрешения. Для displacement'ов есть сетка, для брашей — нет.
|
||
|
||
**Исправление**: 2D-сетка по XY с AABB каждого браша; запрос кандидатов по
|
||
расширенному AABB хода.
|
||
|
||
**Приёмка**: замер времени кадра коллизии до/после; цель — стабильные 60 fps
|
||
при обычном движении.
|
||
|
||
### A4. Рендер: отсечения граней нет вообще
|
||
**Симптом**: «видно обратную сторону брашей».
|
||
|
||
**Причина** (`crates/render/src/world_renderer.rs`): `cull_mode: None` во всех
|
||
**шести** пайплайнах (строки 247, 292, 385, 483, 613).
|
||
|
||
**Уточнение пользователя**: в оригинале сквозь стены тоже видно — но там это
|
||
намеренно (вид сквозь стену на HUD), а не отсутствие backface culling.
|
||
|
||
**Исправление**: `CullMode::Back` для мира и моделей; для двусторонней
|
||
геометрии — отдельный проход `CullMode::Front` с инверсией нормали (как в
|
||
Source: сначала фронт с инверсией, затем бэк).
|
||
|
||
**Приёмка**: в галерее gm_construct не видно изнанки стен.
|
||
|
||
### A5. Рендер: деталь-текстуры не поддержаны → нет песка у озера
|
||
**Симптом**: «у озера трава должна переходить в песок, песка нет».
|
||
|
||
**Причина**: в `crates/formats/src/bsp.rs` перечислены 64 лумпа, и **деталь-текстур
|
||
среди них нет**. В Source 1 они лежат в `GAME_LUMP` как под-лумп
|
||
`detailtextures.dll` (`LUMP_DETAILTEXTURES = 12`) и накладываются поверх базовой
|
||
текстуры. Шейдер `world.wgsl` (38 строк) читает только `base_texture` и
|
||
умножает на вершинный `light`.
|
||
|
||
**Исправление**:
|
||
1. Разбор `GAME_LUMP` (запись лумпа **16 байт**: `fileofs, disksize, offset,
|
||
fourCC`), под-лумп 12 → список `(текстура, scaleX, scaleY)`.
|
||
2. Проекция деталь-текстуры на мировые координаты по UV грани.
|
||
3. Шейдер: дополнительные сэмплы со смешением, `$detail` из VMT.
|
||
|
||
**Приёмка**: у озера gm_construct виден переход травы в песок.
|
||
|
||
### A6. Белая нетекстурированная поверхность (было: «белые панели на 0 0 0»)
|
||
**Симптом по скриншоту**: большая белая polygonal-поверхность, лежащая на
|
||
земле рядом с озером (камера на `pos -1070 1565 533`). Контур с прямыми
|
||
отрезками и острыми углами, поверхность явно повторяет рельеф местности.
|
||
Окружающие дома, трава, дороги и вода (слева, тёмно-синяя) выглядят нормально.
|
||
|
||
**Это не пропы.** Проп — это объёмная модель с чёткими гранями; здесь же
|
||
плоская поверхность, граница которой идёт по рельефу. Значит это браш- или
|
||
displacement-геометрия, у которой **не разрешилась текстура** — материал
|
||
рисуется `fallback`-белым (`Material::fallback`, `world.rs:739`).
|
||
|
||
Ранее я проверил пропы и ничего подозрительного не нашёл: пропов без `origin`
|
||
нет, с `origin = "0 0 0"` нет, наш код порождает всего 9 моделей
|
||
(5 × `prop_dynamic lights_flescent01a`, 4 × `prop_physics` мебели) — все с
|
||
корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.
|
||
|
||
**Кандидаты причины, по убыванию вероятности**:
|
||
1. **Не найдена VTF-текстура** для части поверхностей — `fallback` рисует белым.
|
||
Особенно вероятно для displacement-поверхностей у воды: у них отдельные
|
||
`texinfo`, которые не всегда есть в списке.
|
||
2. **Не разрешён VMT** — тогда материал падает в тот же белый fallback.
|
||
3. Отдельная поверхность, которой полагается деталь-текстура, но базовая
|
||
не загрузилась (связано с A5).
|
||
|
||
**Действие** (одна итерация, даёт точный ответ): дамп всех материалов мира —
|
||
`{ имя VMT, имя VTF, загрузилась ли, причина отказа }` плюс AABB каждого
|
||
батча. По скриншоту видно, что проблема локальная (один район карты), значит
|
||
список «отвалившихся» текстур будет небольшим и читаемым.
|
||
|
||
**Побочный вывод**: белый fallback — плохая диагностика и плохое поведение.
|
||
Нужна проверка в `doctor` (см. B4) и осмысленная заглушка: тёмно-шахматная
|
||
вместо белой, чтобы выделять проблему, а не маскировать её.
|
||
|
||
### A8. Знаки и порядок осей при повороте моделей
|
||
`rotate_model_vector` строит базис `forward/left/up`, тогда как Source
|
||
использует `forward/right/up`. Модельная система координат MDL — правая
|
||
(X вперёд, Y влево, Z вверх), поэтому знак оси Y критичен: ошибка
|
||
разворачивает модель зеркально и ломает нормали.
|
||
|
||
Это не «на глаз» — сверяем с эталонным `AngleVectors` и закрываем unit-тестом
|
||
на известных углах (`0 0 0`, `0 90 0`, `0 0 90`, `90 0 0`), фиксируя
|
||
ожидаемые векторы из формулы Source.
|
||
|
||
### A7. Вода выглядит неправильно
|
||
**Симптом**: «просто какое-то отражение неба, никакой текстуры, твёрдая».
|
||
|
||
**Причины**:
|
||
- отражение — **зашитая константа** `vec3<f32>(0.64, 0.76, 0.90)` в
|
||
`water.wgsl:49`, а не окружение;
|
||
- одна поверхность с `discard` по знаку `facing`, тогда как в Source их две
|
||
(`$abovewater` 1 и 0) — верх и изнанка;
|
||
- нет преломления, поглощения по глубине, блика солнца, нормалей из текстуры,
|
||
`$fogcolor`-затухания;
|
||
- твёрдость — из A2.
|
||
|
||
**Исправление**: две поверхности; отражение из окружения; преломление через
|
||
цвет буфера; поглощение по расстоянию; specular от солнца; плюс A2.
|
||
|
||
### A9. Коллизия: невидимый параллелепипед вокруг рамп и углов
|
||
**Симптом** (пользователь): «я как будто стою вписанным в невидимый
|
||
параллелепипед, а не прямо на рампе»; «везде, где в угол/подъём можно вписать
|
||
прямоугольный треугольник», бывает и в зданиях. Точка-пример: `-2438 -966 48`.
|
||
|
||
**Что уже измерено:** сетка обхода `real_map_sweep_for_invisible_walls`
|
||
(x от −1500 до 600, y от −800 до 600) эту область **не покрывает** — поэтому
|
||
тест и был зелёным. Рядом рампа — кирпич #1891: 9 граней, габарит
|
||
`(-2752,-1024,-156) .. (-2304,-896,48)`, то есть 448×128×204 с наклонной
|
||
плоскостью.
|
||
|
||
**Гипотеза (проверяется замером `probe_ramp_movement`):** `Collision::penetration`
|
||
(разделяющие оси: три мировые + нормали граней) берёт интервал кирпича по
|
||
мировым осям из **габарита**, а габарит наклонного кирпича шире самого кирпича.
|
||
В пустых углах около рамп получается ложное перекрытие, и `resolve_overlap`
|
||
выталкивает игрока из объёма, которого нет.
|
||
|
||
**Что уже замерено (2026-10-16):**
|
||
|
||
- `probe_ramp_movement` в точке `-2438 -966 48`: залипание при движении в −Y и
|
||
по диагоналям объясняется **настоящей кирпичной стеной** — кирпич #829 стоит
|
||
по `y = -1024`, текстуры бетон/кирпич, стена видна на кадре из этой точки.
|
||
Фантомного выталкивания в этой точке нет.
|
||
- `probe_phantom_walls_scan` — обход **всей карты** (11200 точек по габаритам,
|
||
10824 свободных) с шагом кадра 8 юнитов: кирпичей, блокирующих шаг издалека,
|
||
**ноль**. Ложных «тело внутри, хотя по плоскостям снаружи» — тоже ноль.
|
||
- `probe_collision_contents` — состав коллизии: 2118 кирпичей из 2203 сырых;
|
||
исключены 7 `func_vehicleclip` (в Source режут только машины), триггеры и
|
||
прочие служебные; в коллизии не осталось ни `TOOLSTRIGGER`, ни клипов.
|
||
- Причина, по которой старый обход молчал: его сетка покрывала только центр
|
||
(`x` −1500..600, `y` −800..600), то есть запад и юг карты не проверялись.
|
||
|
||
**Что осталось:** нужен точный адрес «стены на стыке комнат» — на рампе такой
|
||
стены нет. Отладочный слой (`SOURCE_RS_DEBUG_COLLISION=1`) покажет это прямо
|
||
на экране: если у мнимой стены нет ни красных рёбер, ни жёлтого габарита —
|
||
она не из кирпичей вовсе, и это отдельный механизм.
|
||
|
||
**Исправлено (E21).** Адрес нашёлся — `-2385 -2032 256`: терраса, где игрок
|
||
стоял вровень с верхом соседней плиты (кирпич #269, 16 юнитов толщиной) и не
|
||
мог шагнуть в −Y. Причина не в наборе кирпичей, а в отсечении: боковая грань
|
||
плиты блокировала по касанию **ребром** — тело (z 256..328) грань (z 240..256)
|
||
не перекрывало. Правка и замеры — в E21.
|
||
|
||
**Приёмка:** игрок в пустом углу рядом с рампой не сдвигается; обход карты с
|
||
сеткой, покрывающей запад (`x` до −3100), без залипаний.
|
||
|
||
### A10. Рендер: перелом света по полигонам
|
||
**Симптом** (пользователь): «на одном полигоне светло, на другом темнее,
|
||
перелом света идёт, которого в оригинальной игре в этом же месте нету»;
|
||
пример — `pos -2039 -1408 -110`, пол тоннеля.
|
||
|
||
**Причина:** лайтмапа берётся **в вершинах** и линейно интерполируется по
|
||
треугольнику, поэтому граница света идёт по диагонали полигона. У больших
|
||
граней (пол тоннеля, бетонная площадка) это видно отчётливо.
|
||
|
||
**Решение:** сэмплить лайтмапу **на пиксель** — передавать в вершинном
|
||
атрибуте UV блока лайтмапы и читать её из текстуры, как Source. Заодно это
|
||
уравняет шаг люкселя на всех дистанциях.
|
||
|
||
**Сделано (2026-10-23, ветка `feat/hdr`).** `LUMP_LIGHTING` раскладывается в
|
||
атлас `Rgba16Float`: gm_construct — 6891 грань с лайтмапой, 751 718 люкселей,
|
||
после полочной упаковки хватает 2048² (блок каждой грани окружён рамкой в
|
||
1 тексель, чтобы билинейная фильтрация не захватывала соседний свет).
|
||
`WorldVertex.lightmap_uv` ведёт в блок, фрагментный шейдер мира читает атлас с
|
||
линейной фильтрацией. Если атлас не собрался (карта без лайтмап или больше
|
||
4096²), пайплайн создаётся с `fs_main_vertex` — вершинный свет как раньше.
|
||
|
||
Проверка кадром (камера тоннеля `-2039 -1408 -110`, yaw 90): до правки стена
|
||
была почти плоской (слева/справа 32,3 / 54,2 по R — перепад 1,7×), после —
|
||
плавный градиент от тёмного угла к проёму (14,3 / 49,0 — 3,4×). В кадре
|
||
`/tmp/opencode/hdr_before_tunnel.png` → `hdr_final_tunnel.png` видно, что
|
||
жёсткие ступени по рёбрам ушли: свет меняется внутри полигона, а не скачком на
|
||
ребре. Тесты: `lightmap_atlas_maps_face_block`, `half_conversion_known_values`.
|
||
|
||
### A11. Вода: слева по Y прозрачная, справа — плоская голубая
|
||
**Симптом** (пользователь, `pos -693 4264 1547`): одна половина поверхности
|
||
видна насквозь (с волнами и зеленью дна), вторая — однотонная голубая заливка
|
||
без просвета. Граница — изломанная линия посередине.
|
||
|
||
**Что проверять:** не рисуется ли поверхность **дважды** разными путями
|
||
(шейдер воды и обычная грань браша) и не смешивается ли она сама с собой;
|
||
плюс две поверхности «верх/изнанка» из A7.
|
||
|
||
### A12. Вода: волны мелкие и одинаковые по оси Y, нет динамической текстуры
|
||
**Симптом** (пользователь): «волны слишком микроскопические, чтобы их
|
||
заметить», «все также по оси y одинаковы»; нужна динамическая текстура воды,
|
||
а не «голубая полупрозрачная штука».
|
||
|
||
**Решение:** волны с разными направлениями и длинами (не одна дорожка), нормали
|
||
из текстуры воды (`$normalmap`) с двумя UV-слоями, смещающимися в разные
|
||
стороны, затем преломление и поглощение по глубине (A7).
|
||
|
||
### A13. Нет тумана
|
||
**Симптом** (пользователь): туман нужно реализовать — его нет вообще, ни
|
||
дальнего, ни от `env_fog_controller`, ни поглощения в воде.
|
||
|
||
**Решение:** дальний туман по `fog` из мира/сущности, `$fogcolor` для воды,
|
||
учесть в шейдерах мира и неба; значения брать из карты, а не выдумывать.
|
||
|
||
### A14. Присед: скорость не меняется
|
||
**Симптом** (пользователь): «физика приседания неправильная — скорость вообще
|
||
не меняется».
|
||
|
||
**Решение:** переносить по спецификации `CGameMovement` (`CheckParameters`,
|
||
`m_flMaxspeed`, флаги `FL_DUCKING`/`m_Local.m_bDucked`, ограничение вставания
|
||
под низким потолком), но **не вслепую**: каждое число (скорость в приседе,
|
||
высота глаз, скорость приседа/вставания) проверять замером на нашей геометрии.
|
||
|
||
### A15. Полноценные пропы и модели (наполнение карты)
|
||
**Симптом** (пользователь): «пропы полноценные, полные все элементы карты».
|
||
|
||
**Что уже есть (замер на gm_construct):** у проп-сущностей (`prop_static`,
|
||
`prop_dynamic`, `prop_physics`, `item_*`) читаются MDL, строятся статические
|
||
меши `PropMesh` — 9 экземпляров, они рисуются, участвуют в физике и в коллизии
|
||
игрока (`collision.set_boxes`).
|
||
|
||
**Чего нет:** скелета и скиннинга, анимаций, `$bodygroup`/`$texturegroup`, LOD
|
||
моделей, ragdoll-физики, флексов и глаз. То есть «полноценная модель» в смысле
|
||
этапа **M5** плана: VVD/VTX, blend-граф, GPU-скиннинг, animation LOD,
|
||
материалы моделей.
|
||
|
||
### A16. Наполнение карты: трава, деревья, декали, оверлеи
|
||
- **Detail-спрайты** (`$detail` в VMT, `detailsprites` карты): трава на
|
||
травяных текстурах не рисуется вовсе — отсюда «плоская трава».
|
||
- **Смешанные материалы** (`WorldVertexTransition`, `$basetexture2`,
|
||
`$blendmodulatetexture`): берётся только первая текстура, поэтому переход
|
||
травы в песок и асфальт пропадает.
|
||
- **Трава и деревья** — обычно `prop_static`-модели: появятся вместе с A15;
|
||
отдельно у деревьев есть `$treeSway` (качание).
|
||
- **Декали и оверлеи** (`infodecal`, `info_overlay`, `decal`-лумпы) не
|
||
рисуются вовсе: отметины, надписи и метки на карте пропадают.
|
||
|
||
### A17. Сущности-механики карты
|
||
Телепорты (`trigger_teleport` + `info_teleport_destination`), лестницы
|
||
(`func_ladder`), двери и кнопки (`func_door`, `func_button`,
|
||
`func_movelinear`, `func_rotating`), звуки (`ambient_generic`), источники света
|
||
(`light`, `light_spot`, `env_projectedtexture`), `env_fog_controller` (см. A13),
|
||
`func_brush` с переключением.
|
||
|
||
**Решение:** каркас энтити уже есть; добавлять по классам, начиная с тех, что
|
||
влияют на игру (телепорты, лестницы, двери/кнопки), затем свет и звук. Каждый
|
||
класс — с проверкой поведения по спецификации и замером на карте.
|
||
|
||
**Порядок работ (ускоренный под Sandbox-UX, см. A25):** A23 оружие и
|
||
вьюмодель → A20 модель игрока → A21 физика взаимодействия (физган) → A24 спавн
|
||
(Q-меню) → A22 Sandbox целиком; параллельно A19 (остаток по склону), A10
|
||
(свет), A11–A12 (вода), A16–A18 (наполнение карты).
|
||
|
||
### A18. 3D-скайбокс: полный
|
||
|
||
**Симптом** (пользователь): «сделать ещё полноценный 3D-скайбокс».
|
||
|
||
**Что уже есть (замеры E6, E8, E11.5, E18):** `sky_camera` находится (origin и
|
||
scale), геометрия области скайбокса собирается в отдельный меш `sky_mesh` и
|
||
рисуется своим проходом (в него уходят 21 displacement-грань и браши
|
||
скайбокса); есть переключатель `SOURCE_RS_NO_SKY_MESH`; за границами карты
|
||
скайбокс гасится.
|
||
|
||
**Чего нет:** в кадре на месте города (камера E11, `-399.5 -1604.5 67.9`) у нас
|
||
сплошной травяной склон — город **не рисуется** (E11.5: «рельеф выше 1000 — 0
|
||
треугольников», то есть закрывающая плита не рельеф и причина не найдена).
|
||
В скайбоксе живут не только браши: город — это `prop_static`-модели и
|
||
`func_*`-сущности, а их спавн для области скайбокса не делается вовсе.
|
||
|
||
**Решение:** довести проход скайбокса до полного: геометрия + модели + декали
|
||
области скайбокса, преобразование по `sky_camera` (origin и scale 1/16),
|
||
корректная глубина и порядок отрисовки, отсечение по границам, и сверка кадра
|
||
с эталоном GMod на камере E11 (`docs/PARITY.md`).
|
||
|
||
### A19. Рампа: скорость ниже и сползание на остановке
|
||
**Симптом** (пользователь): «по рампе взбираюсь, останавливаюсь — чуть-чуть
|
||
назад проскальзываю; и сама скорость ниже, а в оригинале на рампе вроде не
|
||
замедляет».
|
||
|
||
**Что говорит спека** (`gamemovement.cpp`, `WalkMove`): на земле вертикальная
|
||
скорость **обнуляется** до и после ускорения (`mv->m_vecVelocity[2] = 0;
|
||
Accelerate(...); mv->m_vecVelocity[2] = 0;`), движение идёт строго по
|
||
горизонтали (у `dest` та же высота), склон догоняется `StepMove` и
|
||
`StayOnGround()`. Значит замедления на рампе у движка нет вовсе, а сползания —
|
||
тоже: вертикальной составляющей, которую склон мог бы «отразить», просто нет.
|
||
|
||
**Что у нас:** всё это уже есть (`velocity.z = 0` на земле, трение, гравитация
|
||
только в воздухе, `step_move`, `stay_on_ground`). Замеренный остаток —
|
||
197,8 юнита/с на рампе #1480 (15,2°) против 250 на ровном полу и сползание на
|
||
доли юнита — даёт **догон склона**: `stay_on_ground` щупает вниз шагами по
|
||
1 юниту через `move_aabb`, а тот заканчивается выталкиванием, и нормаль склона
|
||
смотрит вниз-вбок (у склона 15° — `n = (0,394, 0, 0,919)`), то есть каждое
|
||
выталкивание сдвигает тело вниз по склону.
|
||
|
||
**Решение:** заменить щупание на честную трассировку вниз без выталкивания —
|
||
как в `StayOnGround` (`TracePlayerBBox` вниз на `m_flStepSize`, перенос позиции
|
||
в `trace.endpos`, и никакого разрешения проникновений).
|
||
|
||
**Сделано (2026-10-16).** `Collision::ground_trace` — чистый отсев вниз одним
|
||
отрезком, без выталкивания; `stay_on_ground` переносит позицию по нему и
|
||
требует нормаль опоры не круче `sv_walkable_normal` (0,7). Замеры на карте
|
||
после правки:
|
||
|
||
| рампа | угол | сторона +1 | сторона −1 |
|
||
|---|---|---|---|
|
||
| #1476 | 8,2° | 242,4 | 523,0 |
|
||
| #1477 | 9,0° | 541,5 | 241,9 |
|
||
| #1478 | 9,6° | 250,1 | 250,1 |
|
||
| #1479 | 17,1° | 263,6 | 245,7 |
|
||
| #1480 | 15,2° | 255,9 | **200,1** |
|
||
| #1481 | 6,6° | 301,0 | 239,8 |
|
||
| #1482 | 16,1° | 250,1 | 250,1 |
|
||
|
||
Ровный пол — 250. «Медленных» по порогу теста (75%) не осталось ни одной,
|
||
застреваний при подъёме — ноль. Остаток у #1480 (80%) — съезд по краю кирпича
|
||
в изоляции, а не штраф за склон: рядом стоящие рампы дают 96–100%.
|
||
|
||
**Приёмка (осталось):** тот же замер даёт сползание назад на остановке; его
|
||
надо мерить отдельно (пройти вперёд, отпустить ввод, замерить смещение) и
|
||
держать ниже юнита.
|
||
|
||
---
|
||
|
||
## Часть B. Фундамент: система энтити
|
||
|
||
Без неё не появляется ни свет, ни Lua над энтити, ни физика. Свет — это тоже
|
||
энтити (`light_environment`, `light`, `light_spot`).
|
||
|
||
**B1. Ядро** (`crates/engine/src/entity.rs`)
|
||
- `EntityIndex(u16)` — слот + `generation` для безопасных «умерших» указателей;
|
||
- `Entity` — classname, keyvalues, transform (`origin`/`angles`/local-origin),
|
||
флаги (`solid`, `movetype`, `model`, `render`/`rendergroup`);
|
||
- `EntityWorld` — пул, `spawn(kv)`, `remove`, итерация по classname,
|
||
отложенное удаление (как в Source, чтобы не инвалидировать ссылки в кадре);
|
||
- лимиты из файлов: максимум энтити, максимум ключей, максимум длины значения
|
||
(правило #1 AGENTS.md — никаких `unwrap` на данных из файлов).
|
||
|
||
**B2. Спавн из карты**: `build_world` перестаёт вручную порождать модели — он
|
||
скармливает лумп `ENTITIES` в `EntityWorld`, а рендер и коллизия читают из неё.
|
||
Это убирает белые панели как класс проблем и даёт всем остальным этапам единый
|
||
источник истины.
|
||
|
||
**B3. Коллизия по энтити**: браши `func_*`/`trigger_*`/`info_null` с корректной
|
||
твёрдостью; AABB/OBB для моделей; `trigger`-зоны отдельным слоем.
|
||
|
||
**B4. Отладка**: `ent_list` / `ent_info <classname>` — иначе каждый следующий
|
||
дефект снова придётся ловить на глаз.
|
||
|
||
**Приёмка**: `ent_list` показывает все энтити gm_construct с корректными
|
||
координатами; коллизия `func_*` работает.
|
||
|
||
---
|
||
|
||
## Часть C. Освещение (включая солнце)
|
||
|
||
Сейчас в шейдере один попиксельно интерполированный `light: vec3` из вершин —
|
||
меньше, чем в Source даже на простых картах.
|
||
|
||
**C1. Свет из карты**: `light`, `light_spot`, `light_dynamic` (в gm_construct:
|
||
108 `light` + 28 `light_spot`), `light_environment` — это и есть солнце
|
||
(направление, цвет, интенсивность, флаг «небо»).
|
||
|
||
**C2. Модель освещения в шейдере**: динамический свет (солнце + до N источников
|
||
в радиусе) поверх статического; источники ограничить по экрану и расстоянию.
|
||
|
||
**C3. Sun-тени**: каскадные shadow maps (в Source 3–4 каскада). Начать с одного
|
||
каскада 2048².
|
||
|
||
**C4. Lightmap**: лумп `LIGHTING`/`LIGHTING_HDR` (12 МБ в gm_construct) —
|
||
попиксельно вместо вершинного; даёт качественный свет в помещениях.
|
||
|
||
**C5. Материал**: `Phong`-подобная модель — diffuse + specular + fresnel;
|
||
`$phong`/`$specular`/`$color2` из VMT; самозатенение от карты нормалей.
|
||
|
||
**C6. Небо и `env_*`**: `env_sky`, `env_fog`, `env_sun`, `env_tonemap`,
|
||
`env_cubemap`, `env_skybox`.
|
||
|
||
**Приёмка**: на gm_construct видны солнечные тени от построек, помещения
|
||
освещены не плоско, вода отражает окружение, есть спекуляр на металле.
|
||
|
||
---
|
||
|
||
## Часть D. Lua поверх энтити
|
||
|
||
Текущий `lua` (1278 строк) — `print`, `hook.*`, `timer.Simple`,
|
||
`concommand.*`, заглушки. Ни типов, ни энтити.
|
||
|
||
**D1. Типы**: `Vector`, `Angle`, `Color` как userdata с метаметодами GMod
|
||
(арифметика, `Angle:Forward()`, `Vector:Distance`, `:Angle`).
|
||
|
||
**D2. `Entity(index)`** и методы: `GetPos/SetPos`, `GetAngles/SetAngles`,
|
||
`GetClass/GetModel/SetModel`, `Remove`, `IsValid`, `PhysicsInit*`.
|
||
|
||
**D3. `ents.*`**: `Create`, `FindByClass`, `FindInSphere`, `GetAll`, `GetCount`.
|
||
|
||
**D4. Сетевые поля**: `SetNW*`/`GetNW*` (в одиночке локальные, но с тем же API,
|
||
чтобы потом не переписывать аддоны).
|
||
|
||
**D5. Библиотеки GMod**: `file.*` (с песочницей по AGENTS.md), `surface.*`,
|
||
`sound.*`, `render.*`, `cam.*`, `Curves`, `system.*`, `scrconcmd`,
|
||
`LocalPlayer`. `http` — только после песочницы.
|
||
|
||
**Приёмка**: скрипт из гмода выполняется; список функций совместимости
|
||
(набранный из настоящих `lua/includes/modules/`) проходит.
|
||
|
||
---
|
||
|
||
## Часть E. Одиночная игра как в GMod
|
||
|
||
**E1. Player**: ходьба по ступеням (step-up ~18 units), лестницы, приседание с
|
||
плавным переходом, плавание (`Start Swim`), ноклип, падение с уроном.
|
||
|
||
**E2. game.dll на Lua**: загрузка `gamemodes/sandbox/gamemode/init.lua` —
|
||
списки `ents`, `weapons`, `NPCs`, хуки `Initialize`, `Think`, `PlayerSpawn`.
|
||
Это то, что делает GMod «GMod’ом», а не просто картой Source.
|
||
|
||
**E3. Оружие и предметы**: `weapon_*`, `item_*` через Lua.
|
||
|
||
**E4. Физика пропов**: Jolt — `prop_physics` уже приходят с карты, но не падают
|
||
и не сталкиваются.
|
||
|
||
**E5. NPC**: начиная с базовых `npc_*`.
|
||
|
||
---
|
||
|
||
## Порядок и разумная дистанция до GMod
|
||
|
||
GMod — это ~1 млн строк C++ плюс 15 лет аддон-экосистемы. Полное «1 в 1»
|
||
недостижимо как финальное состояние, но достижимо как наращиваемое покрытие:
|
||
A → B → C → D → E даёт к каждому моменту работающий одиночный клиент.
|
||
|
||
| Этап | Что даёт пользователю |
|
||
|---|---|
|
||
| A | «стены не пускают, вода не твёрдая, белая поверхность устранена, песок появился» |
|
||
| B | карта описывается объектами — база для всего |
|
||
| C | солнце, тени, нормальный свет |
|
||
| D | Lua-аддоны запускаются на нашей карте |
|
||
| E | одиночная игра, совместимая с аддонами |
|
||
|
||
Честное ограничение: «все энтити» — сотни классов. Но `base_ents` GMod — это
|
||
**Lua-скрипты**, а не C++. Когда появится исполнение `lua/entities/` и
|
||
`lua/weapons/`, значительная часть этих классов заработает сама, из исходников
|
||
GMod. Поэтому наш код на этих этапах делает только то, чего в Lua нельзя:
|
||
низкий уровень графики, физика, загрузка ресурсов.
|
||
|
||
Свой набор «тяжёлых» классов держим минимальным: `prop_physics`, `func_door`,
|
||
`func_button`, `trigger_*`, `light_*`, `env_*`, `info_*`. Остальное — из Lua.
|
||
|
||
---
|
||
|
||
## Решения, принятые пользователем
|
||
|
||
1. **Скриншот получен** → A6 переформулирован: это не панели пропов, а
|
||
нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0»
|
||
отвергнута.
|
||
2. **`AngleVectors`** → вопрос снят, работа делается как A8: сверка с
|
||
эталонной формулой Source и фиксация unit-тестом, без опоры на память.
|
||
3. **Приоритет** → свет (C) раньше game.dll (E2).
|
||
|
||
## Что осталось уточнить
|
||
|
||
Ничего блокирующего. Дополнительно уточню по ходу:
|
||
- белый fallback заменить на тёмно-шахматный (A6) — согласую при реализации,
|
||
это меняет вид карты при недогруженных текстурах.
|
||
---
|
||
|
||
## Часть C. Прогресс
|
||
|
||
### C0. Источники света из карты (сделано)
|
||
|
||
`engine::world::collect_lights()` разбирает `light_environment` (солнце),
|
||
`light`, `light_dynamic`, `light_fluoro_dynamic`, `light_spot`. На gm_construct
|
||
находится 65 источников, из них 1 солнце и 22 прожектора.
|
||
|
||
Шейдер `world.wgsl` получил uniform-блок источников: солнце (до 4), точечные
|
||
и прожекторные (до 64, отбираются ближайшие к камере), плюс ambient.
|
||
|
||
**Три ловушки, на которые напоролся (все проверены на gm_construct):**
|
||
|
||
1. **Четвёртая компонента `_light` означает разные вещи.** У `light` и
|
||
`light_spot` это РАДИУС освещения (`_light 255 255 255 300`), а у
|
||
`light_environment` — множитель яркости солнца (`_light 242 234 218 560`).
|
||
Если считать это яркостью везде, лампы дают вклад ×300 и карта выбивается
|
||
в белый.
|
||
|
||
2. **Яркость нельзя домножать в цвет.** Если умножить цвет на `scale` при разборе
|
||
и учесть `scale` ещё раз в шейдере, получается квадрат яркости. Цвет
|
||
нормируется к единичному среднему, яркость хранится отдельным полем `scale`.
|
||
|
||
3. **Радиус 0 означал «светить во everywhere».** Функция затухания при
|
||
радиусе 0 возвращала 1.0 на любом расстоянии, и 22 прожектора складывались в
|
||
сумму. Теперь у ламп без `_radius` подставляется 300.
|
||
|
||
4. **Лайтмапа уже содержит запечённое солнце.** Это ключевой момент: `in.light`
|
||
— это и есть запечённый свет компилятора. Динамическое солнце сверху нужно
|
||
только для направленной подсветки и будущих теней, поэтому его вклад
|
||
намеренно мал (`SUN_BRIGHTNESS_SCALE = 900` даёт ~0.62 от карты). Наивная
|
||
попытка добавить солнце «как есть» давала пересвет бетона в чистый белый.
|
||
|
||
Итоговые нормировки подобраны рендерами на gm_construct и лежат константами
|
||
`SUN_BRIGHTNESS_SCALE`, `POINT_BRIGHTNESS_SCALE`, `AMBIENT` рядом с кодом.
|
||
|
||
### Осталось по части C
|
||
|
||
- Попиксельная лайтмапа (`LUMP_LIGHTING`) вместо вершинного света.
|
||
- Тени от солнца (каскадные shadow maps).
|
||
- `Phong`-модель: specular + fresnel, `$phong`/`$specular` из VMT.
|
||
- Ambient из `_ambient` карты вместо константы.
|
||
- Полный набор `env_*`.
|
||
|
||
## Часть B2. Физика: Rapier3D (сделано)
|
||
|
||
### B2. Решение: мир не переезжает в Rapier
|
||
|
||
Rapier обслуживает только **динамические тела**. Геометрия карты остаётся
|
||
брашевой и разбирается нашим кодом (`player.rs`) — он проверен и быстрее на
|
||
тысячах брашей. Статика в Rapier добавляется явно и только из данных.
|
||
|
||
### B2.1. Четыре дефекта, найденных измерением
|
||
|
||
Все четыре нашлись не «правдоподобным декодированием», а замером на карте.
|
||
Порядок важен: каждый следующий дефект был бы невидим, пока не закрыт
|
||
предыдущий.
|
||
|
||
1. **Рельеф раздувался в 15 277 коллайдеров.** Сетка по `LAND_CELL = 64`
|
||
дублировала каждый перекрытый треугольник в каждую ячейку: 89 треугольников
|
||
давали 15 277 коллайдеров. Заменено одним объединённым trimesh с
|
||
дедупликацией вершин по кванту 1/16 юнита: **11 776 треугольников →
|
||
6 148 вершин, 1 коллайдер**. Точнее (никакого огрубления на швах ячеек) и
|
||
на порядки дешевле.
|
||
|
||
2. **Уровень gm_construct построен брашами, а не displacement'ами.** Рельеф
|
||
покрывает лишь часть карты; игрок ходит по 2 126 брашам. Без брашей все
|
||
пропы падали насквозь. `PhysicsWorld::add_brushes` восстанавливает вершины
|
||
браша перебором троек плоскостей (Крамер): точка, подходящая под **все**
|
||
плоскости — вершина выпуклой оболочки. У осевого браша 6 плоскостей = 20
|
||
троек. Замер: **2121 построен, 5 пропущено** (вырожденные).
|
||
|
||
3. **Broad-phase Rapier наполняется только на `step()`.** После вставки
|
||
геометрии луч её не видел: «под пропом пола нет» при 2 121 браше в мире.
|
||
Лечится явной регистрацией `broad_phase.set_aabb()` при вставке статики.
|
||
|
||
4. **`setup_physics` стирал построенное.** Начинался с
|
||
`self.physics = PhysicsWorld::new()` и обнулял браши, добавленные строкой
|
||
выше. Мир физики больше не пересоздаётся.
|
||
|
||
### B2.2. Тест поймал баг, который замер бы не увидел
|
||
|
||
`add_brushes` создавал коллайдеры, но **не увеличивал счётчик**: лог показывал
|
||
`static = 1`, хотя брашей было 2 121. Тест `box_brush_lands_where_the_box_is`
|
||
упал на `static_bodies() == 1` и указал прямо на строку.
|
||
|
||
### B2.3. Подтверждение на gm_construct
|
||
|
||
Две независимые системы коллизий сверены друг с другом: брашевая (`Collision`)
|
||
и Rapier. Обе находят пол под пропами на **z = −96** (Rapier −96.0,
|
||
брашевая −95.9). После 400 кадров все 4 `prop_physics` осели:
|
||
`speed = 0.0`, `sleeping = true`.
|
||
|
||
### B2.4. Ограничения
|
||
|
||
- Коллайдер пропа — **AABB модели**, а не `vcollide`. Source берёт физическую
|
||
модель из MDL; мы её не разбираем. AABB всегда не меньше модели и всегда
|
||
из данных, но для наклонённых пропов он «жирнее» настоящего.
|
||
- Только `prop_physics*`. У `prop_static` физики в GMod нет.
|
||
- **FPS просел**: 10–21 в headless-прогоне. Требуется замер отдельно с
|
||
физикой и без — 2 121 выпуклый коллайдер может быть причиной.
|
||
|
||
### B2.5. Что осталось по части B2
|
||
|
||
- `vcollide` из MDL вместо AABB.
|
||
- Брашевые `func_door` / `func_button` как ограничивающие тела.
|
||
- `env_explosion` через `apply_explosion` (метод готов, вызов из Lua — нет).
|
||
- Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера.
|
||
|
||
## Часть D1. Lua-типы GMod (сделано)
|
||
|
||
### D1. Что сделано
|
||
|
||
`crates/lua/src/gmod.rs` — `Vector`, `Angle`, `Color` как userdata с
|
||
арифметикой, полями на запись и `tostring` в формате GMod. Установлены в
|
||
глобалы вместе с `math.Clamp`.
|
||
|
||
Решения, важные для совместимости:
|
||
|
||
- **Пропущенных аргументов не бывает.** `Vector()` и `Vector(1, 2)` обязаны
|
||
работать: так вызывается бытовой код Sandbox. Отсутствующее значение
|
||
заменяется нулём, а не ошибкой.
|
||
- **Деление на ноль даёт 1, а не `inf`.** `inf` в позиции пропа отравляет
|
||
геометрию всего объекта и потом всплывает в отрисовке.
|
||
- **NaN из скрипта не доезжает до физики.** `Color(0/0, ...)` даёт белый,
|
||
`Vector(0/0, 1, 2)` — вектор без NaN.
|
||
- **Компоненты цвета обрезаются в 0..255**, а не отвергаются:
|
||
`Color(1.5 * 300, 0, 0)` — обычное дело в скриптах.
|
||
- **Source — левая система координат**: `Angle(0,0,0):Forward()` смотрит вдоль
|
||
`+X`, `Angle(0,90,0):Forward()` — вдоль `+Y`, знак Z у forward
|
||
отрицательный.
|
||
|
||
### D1.1. Дефект чтения чисел в mlua 0.11
|
||
|
||
`Value::as_number()` в mlua 0.11 различает **только** `Value::Number` и молча
|
||
возвращает `None` на `Value::Integer`. LuaJIT отдаёт целые именно как
|
||
`Integer`, поэтому наивное чтение теряло **все** целочисленные аргументы:
|
||
|
||
Vector(1, 2, 3) --> Vector(0, 0, 0)
|
||
|
||
Выглядело это как «арифметика не работает», хотя дело было в чтении чисел.
|
||
Собственный `value_number()` принимает `Number`, `Integer` и строку (числом).
|
||
Тест на это обязателен: без него `Vector()` с целыми просто тихо отдаёт нули.
|
||
|
||
### D1.2. Шум тригонометрии
|
||
|
||
`cos(90°)` в f32 — это 6.1e-17, а не ноль. Без обнуления
|
||
`Angle(0, 90, 0):Forward()` печатает `-0.00000004371139 1 -0`, и такие
|
||
значения копятся в преобразованиях координат. Порог — 1e-6.
|
||
|
||
### D1.3. Особенности API mlua 0.11, на которые ушло время
|
||
|
||
- Для userdata **нет** автоматического `FromLua`: чтение только через
|
||
`AnyUserData::borrow::<T>()`. `Lua::from_value` отсутствует, есть
|
||
`FromLua::from_lua(value, lua)`.
|
||
- Поля — `add_fields` (трейт `UserDataFields`), методы и метаметоды —
|
||
`add_methods` (трейт `UserDataMethods`). Один трейт на всё не работает.
|
||
- `add_meta_method` получает `(&Lua, &T, A)`: значение объекта идёт вторым
|
||
аргументом, лишние аргументы Lua (`__unm(a, a)`) отбрасываются через `()`.
|
||
- `add_function` получает объект как `AnyUserData` первым элементом кортежа.
|
||
|
||
### D1.4. Что осталось по части D1
|
||
|
||
- `Entity()` и объект сущности: `GetPos`/`SetPos`/`GetClass`/`IsValid`.
|
||
- `ents.*`: `Create`, `GetAll`, `FindInSphere`, `GetPlayer`.
|
||
- Связка с движком: `glua` не должен зависеть от `engine`, обмен — через
|
||
колбэки, которые регистрирует клиент.
|
||
- `Vector:Angle()`, `Vector:Distance` между сущностями.
|
||
|
||
## Часть D2. Мост Lua к движку (сделано)
|
||
|
||
### D2.1. Устройство
|
||
|
||
`glua` **не зависит** от `engine`. Движок отдаёт поведение через трейт
|
||
`glua::ents::Host`, реализация которого живёт в клиенте
|
||
(`crates/client/src/host.rs`). Иначе получился бы цикл зависимостей и
|
||
Lua-слой нельзя было бы тестировать в одиночку.
|
||
|
||
Мир переведён в `Rc<RefCell<World>>`: скрипт обращается к нему из хуков в
|
||
любой момент, пока кадр держит заимствование. Все обращения идут через
|
||
`try_borrow`, потому что `borrow` при пересечении даёт `panic` от
|
||
`RefCell`, а падать из-за безобидного скрипта клиент не должен.
|
||
|
||
### D2.2. Что доступно скрипту
|
||
|
||
`Entity(n)`, `ents.GetAll/GetByClass/FindInSphere/Create`,
|
||
`player`, а также `GetPos/SetPos/GetAngles/SetAngles/GetClass/GetModel/
|
||
GetVelocity/IsValid/Remove/GetName`.
|
||
|
||
`Entity(0)` — игрок, `Entity(9999)` — невалидная сущность, а не ошибка:
|
||
скрипты routinely проверяют результат через `IsValid()`.
|
||
|
||
### D2.3. Найденный рассинхрон (важный)
|
||
|
||
`move_prop` обновлял позу экземпляра пропа, но **не** запись в пуле
|
||
сущностей. Проверено измерением: `ents.GetByClass('prop_physics'):GetPos()`
|
||
давал `z = -64`, пока физика уже опустила проп на `z = -84`. «Данные» и
|
||
«картинка» разошлись, и заметить это можно было только глазами.
|
||
|
||
Теперь поза пишется в оба места всегда, даже если геометрию пересчитать
|
||
нечем (нет модели): проп без модели всё равно стоит правильно, он просто не
|
||
рисуется. Возвращаемое значение означает именно «геометрия пересчитана».
|
||
|
||
Проверка после правки: Lua читает `z = -84.15848`, физика даёт
|
||
`-84.15847778320313` — совпадение до последнего бита.
|
||
|
||
### D2.4. Границы
|
||
|
||
- `SetModel`, `SetColor`, `PhysicsInit` принимаются и ничего не делают:
|
||
принимать вызов нужно, чтобы скрипты не падали, но обещать нечего.
|
||
- `GetVelocity` делит скорость тела на шаг 1/60, а не читает «скорость за
|
||
последний кадр»: иначе скрипт получил бы юниты/кадр вместо юнитов/с.
|
||
- Созданная из Lua сущность не получает модель и тело физики.
|
||
- Игрок — `None`, пока клиент его не зарегистрировал.
|
||
|
||
### D2.5. Что осталось по части D2
|
||
|
||
- `Vector:Distance`, `Vector:Angle`, `GetModel`-обратная привязка.
|
||
- `Entity:GetTable`/`SetField` и сетевые функции.
|
||
- Регистрация сущности игрока, чтобы `Entity(0)` и `player` совпали.
|
||
- `ents.Create` должен уметь заводить тело физики для `prop_physics`.
|
||
- Подключение файлов `gamemodes/sandbox` как есть.
|
||
|
||
## Часть D3. Стандартная библиотека GMod (сделано)
|
||
|
||
### D3.1. Замер потребностей, а не догадка
|
||
|
||
Прогнаны 130 файлов `gamemodes/sandbox`, из них извлечены 182 различных вызова
|
||
API. Частоты по числу вхождений:
|
||
|
||
| вызов | вхождений | состояние |
|
||
|---|---|---|
|
||
| `hook.Run` | 63 | сделано |
|
||
| `math.Clamp` | 61 | сделано |
|
||
| `game.SinglePlayer` | 36 | сделано |
|
||
| `math.Rand` | 27 | сделано |
|
||
| `render.AddBeam` | 21 | не сделано |
|
||
| `math.min` / `math.max` | 17 / 5 | стандартная Lua |
|
||
| `net.*` | 15+ | не сделано (сеть) |
|
||
| `render.*` | 50+ | не сделано (рендер) |
|
||
| `file.*` | 8+ | сделано с песочницей |
|
||
|
||
`hook.Run` и `math.*` — самые частые и самые дешёвые: они чисто Lua-слой.
|
||
`render.*` — это отдельная большая работа (лучи, спрайты, декали), она не в
|
||
этом инкременте.
|
||
|
||
### D3.2. `hook.Run` не синоним `hook.Call`
|
||
|
||
`hook.Run` **останавливается** на первом хуке, вернувшем не-`nil`, и возвращает
|
||
его значение. На этом построены все проверки Sandbox (`return false` из хука
|
||
отменяет действие), поэтому подмена `Run` на `Call` молча ломала бы их.
|
||
|
||
### D3.3. Дефект: ключи хуков — строки, а не числа
|
||
|
||
Отбор только целочисленных ключей давал **пустой** список: `hook.Add` пишет под
|
||
идентификатором скрипта (`"GM:Think"`, `"a"`). Хуки не выполнялись вообще,
|
||
`hook.Run` возвращал `nil`, и это выглядело как «хуки не работают». Теперь
|
||
ключи сортируются как строка/число, порядок выполнения — по регистрации.
|
||
|
||
### D3.4. Дефект mlua: `Function::call` теряет аргументы
|
||
|
||
`Vec<Value>` реализует `IntoLua` как **таблицу**, поэтому `hook.call(args)`
|
||
передавал хуку один аргумент вместо трёх — молча, без ошибки. Проверено
|
||
измерением: `select("#", ...)` внутри хука давал `1` при трёх переданных.
|
||
|
||
То же с одиночным числом: `random.call::<f64>(10.0)` вызывал `math.random()`
|
||
без аргументов.
|
||
|
||
Лечится через `Variadic::from(vec![...])`. Отдельный тест
|
||
`hook_run_forwards_every_argument` фиксирует именно эту ошибку — она
|
||
молчаливая, и без теста вернётся.
|
||
|
||
По этой же причине `math.Rand` определён **на Lua**, а не на Rust: он зовёт
|
||
`math.random` того же состояния и передаёт аргументы нативно.
|
||
|
||
### D3.5. Песочница `file.*`
|
||
|
||
`file.Read` в GMod читает что угодно; мы запускаем чужие скрипты. Любой путь
|
||
разрешается относительно `garrysmod/` и нормализуется; выход за корень —
|
||
отказ, а не предупреждение. Абсолютные пути скрипту не даны. Размер чтения
|
||
ограничен 8 МБ (правило #1 AGENTS.md).
|
||
|
||
Проверка на живом клиенте: `file.Read("../../../../etc/passwd")` вернул `nil`.
|
||
|
||
### D3.6. Совпадения с GMod, зафиксированные тестами
|
||
|
||
- `math.Round(-2.5)` = **-3** (округление от нуля, как `ceil`).
|
||
- `math.Rand(x)` = `math.random(x)` → целое в **[1, x]**, а не [0, x).
|
||
- `math.Rand(a, b)` включает верхнюю границу.
|
||
- `math.Remap` при вырожденном диапазоне даёт `out_min`, а не `NaN`.
|
||
- `math.Approach` не перескакивает цель.
|
||
- `math.AngleDifference(10, 370)` = **0**: это одно направление.
|
||
- `string.Explode` с пустым разделителем возвращает `nil`, а не список.
|
||
|
||
### D3.7. Что осталось по части D3
|
||
|
||
- `render.*`: лучи, спрайты, декали, материалы, `DrawWireframeBox`.
|
||
- `net.*`: сообщения серверу.
|
||
- `Cam`, `LocalPlayer`, `vgui`/`derma` — для Q-меню.
|
||
- `util.TraceLine`, `util.Compress`, `surface.*`.
|
||
|
||
### D3.8. Дефект: хук получал имя события первым аргументом
|
||
|
||
`hook.Add`-колбэки вызывались как `v("EventName", ...)`, хотя в GMod имя
|
||
хука не передаётся. Из-за этого `OnTextEntryGetFocus` из spawnmenu получал
|
||
строку вместо панели и падал на `pnl:HasParent(...)`: `StartKeyFocus` не
|
||
вызывался, а ошибка висела в логе при каждом клике по полю поиска. Теперь
|
||
колбэки получают только аргументы вызова (`GM:`-методы по-прежнему первым
|
||
аргументом получают таблицу геймода).
|
||
|
||
## Часть B2.6. Констрейны ( Rapier joints)
|
||
|
||
### Что сделано
|
||
|
||
`crates/engine/src/constraints.rs` — соединения между телами, соответствующие
|
||
классам GMod: `phys_ballsocket`, `phys_hinge`, `phys_slider`, `phys_weld`, `rope`.
|
||
Плюс `add_force` (база для `thruster`/`hover`) и `set_angular_velocity_target`
|
||
(мотор шарнира).
|
||
|
||
### B2.6.1. Главная тонкость: якоря мировые, а Rapier ждёт локальные
|
||
|
||
Скрипт задаёт точку крепления в **мировых** координатах, Rapier — **в
|
||
локальных** каждого тела. Пересчёт делается через обратное вращение
|
||
(`rotation().conjugate() * (world - translation)`). Ошибка здесь не бросает
|
||
исключение: соединение просто «приклеивается» к другой точке и уезжает при
|
||
первом же движении.
|
||
|
||
### B2.6.2. Что показали измерения, а не рассуждения
|
||
|
||
1. **Констрейн не «стягивает» тела — и это правильно.** Якорь в мировой точке
|
||
по определению совпадает у обоих тел в момент создания, поэтому
|
||
ограничение выполнено сразу и «включается» только когда тела расходятся.
|
||
Первые пять тестов проверяли «расстояние между телами» и падали именно
|
||
поэтому. Правильный инвариант — **совпадение точек крепления** под
|
||
действием силы.
|
||
|
||
2. **Сила в Rapier действует один шаг** и сбрасывается после интегрирования:
|
||
`add_force` надо вызывать каждый кадр. Тест, подававший силу один раз,
|
||
измерял не силу, а её отсутствие — и «падал» одинаково при 8000 и при
|
||
40 000.
|
||
|
||
3. **Спящее тело честно сообщает нулевую скорость.** Проверять эффект силы по
|
||
скорости бессмысленно, если тело уснуло на полу: корректный тест сравнивает
|
||
прогон с силой и контрольный прогон без неё.
|
||
|
||
4. **Ползун (`phys_slider`) не удерживает.** Сустав сообщает `enabled = true` и
|
||
5 заблокированных осей из 6, шар/шарнир/сварка в том же прогоне держат, но
|
||
ползун пропускает расхождение по высоте при толчке вдоль оси. Тест помечен
|
||
`#[ignore]` с причиной — зелёная галочка на неработающем коде хуже, чем
|
||
явный отказ. Гипотеза к проверке: в 0.36 ползун требует согласованности
|
||
`local_axis1`/`local_axis2` иначе, чем задаёт их `new(axis)`.
|
||
|
||
### B2.6.3. Границы
|
||
|
||
- Констрейны соединяют только **тела физики**: `prop_physics` и объекты,
|
||
созданные скриптом. У `prop_static` тела нет.
|
||
- Оси соединения задаются в мировых координатах, как в GMod.
|
||
- Ограничение угла переводится в радианы; вырожденный диапазон игнорируется.
|
||
- `pulley` и `thruster` — это не суставы: тяга делается через `add_force`.
|
||
|
||
## Часть B3. Проваливание сквозь пол: что проверено и что нет
|
||
|
||
### B3.1. Две гипотезы, обе опровергнуты измерением
|
||
|
||
1. **«Пропов нет в коллизии игрока».** Гипотеза была верной *как диагноз*,
|
||
но не как объяснение жалобы: игрок ходил по брашам. Пробел устранён
|
||
(`Collision::boxes`), проп теперь стоит под ногами — это нужно в любом
|
||
случае, но от проваливания по брашам не избавило.
|
||
2. **«На низком FPS шаг подшага прошивает пол».** Опровергнуто тестом
|
||
`fast_fall_does_not_tunnel_thin_floor`: при terminal-скорости 2000
|
||
юнитов/с и dt = 1/60 игрок садится на площадку толщиной **4 юнита** и
|
||
`on_ground = true`. Подшагов восемь хватает.
|
||
|
||
Обе версии выглядели правдоподобно и обе были неверны — характерная черта
|
||
дефектов, которые видны только после попытки воспроизвести их тестом.
|
||
|
||
### B3.2. Что измерено
|
||
|
||
- Стояние на спавне gm_construct: 180 кадров, `z = -143.9` стабильно,
|
||
`on_ground = true`, провала нет.
|
||
- Значит дефект локальный и воспроизводится на определённой геометрии.
|
||
|
||
### B3.3. Что нужно, чтобы найти причину
|
||
|
||
Точное место и обстоятельства: с какой высоты, после какого движения
|
||
(прыжок, спуск по рампе, спуск с края) и на каком участке. Без этого
|
||
пришлось бы перебирать геометрию карты наугад — а это ровно тот способ,
|
||
которым эти две гипотезы и были порождены.
|
||
|
||
## Часть B4. Физика движения: как в Source
|
||
|
||
### B4.0. Корневая причина четырёх жалоб сразу
|
||
|
||
Симптомы, описанные как разные (проталкивает на расстояние, метания вдоль
|
||
стены, сползание по рампам, bhop), оказались **одной ошибкой**:
|
||
|
||
```rust
|
||
self.velocity.z -= GRAVITY * dt; // выполнялось КАЖДЫЙ кадр
|
||
```
|
||
|
||
Гравитация применялась и на земле. Каждый кадр игрок вдавливался в
|
||
геометрию, а разрешение столкновений выталкивало его обратно. Отсюда всё:
|
||
«выталкивает на определённое расстояние» — это выталкивание из вдавливания;
|
||
«то за стену, то обратно» — борьба позиции с выталкиванием; «сползает по
|
||
рампе» — вертикаль всё время возвращается вниз.
|
||
|
||
Source делает наоборот: при `FL_ONGROUND` вертикальная скорость
|
||
обнуляется, а `ApplyGravity` в земле **не вызывается вовсе**.
|
||
|
||
### B4.1. Что изменено
|
||
|
||
- **Гравитация только в воздухе.** На земле `velocity.z = 0`.
|
||
- **Трение всегда на земле**, а не только при отсутствии ввода: Source
|
||
вызывает `Friction()` каждый кадр на земле — именно оно держит игрока на
|
||
рампе. Константы `sv_friction = 4`, `sv_stopspeed = 100`.
|
||
- **Прыжок по фронту нажатия.** Защёлка `jump_latched` держится, пока пробел
|
||
зажат; без неё удержание давало бесконечный bhop.
|
||
- **Подшаг `PM_StepSlideMove`**: полное движение, при блокировке — повтор с
|
||
подъёмом на `STEP_HEIGHT = 18`, затем спуск. Принимается только если
|
||
спуск встал на опору, иначе это залезание на стену. При падении
|
||
подшаг отключён — иначе в прыжке игрёт в потолок.
|
||
- `MoveResult::blocked` — признак упёрся в геометрию, нужен подшагу.
|
||
|
||
### B4.2. Ошибки в моих же правках, пойманные тестами
|
||
|
||
1. `jump_latched = jump` — защёлка вставала на первом же кадре, ещё **до**
|
||
первого прыжка, и прыгнуть было нельзя вообще.
|
||
2. `jump_latched = jump && self.on_ground` — флаг сбрасывался тем же кадром,
|
||
когда прыжок уже отработал (`on_ground` сброшен выше), bhop возвращался.
|
||
3. Тест на рамку мерил **первоначальное падение** (40 → 6), а не сползание,
|
||
и падал с «сносом 34». После отделения приземления снос — 0.
|
||
|
||
### B4.3. Тесты
|
||
|
||
- `standing_on_a_slope_does_not_slide_down` — снос после приземления.
|
||
- `holding_jump_does_not_bunnyhop` — ровно один прыжок за 300 кадров.
|
||
- `player_steps_up_a_curb` — переступает бордюр 12 юнитов.
|
||
- `player_stands_on_a_dynamic_body` — стоит на пропе.
|
||
|
||
## Часть B5. Осталось по физике движения
|
||
|
||
- **Воздушная мобильность.** `AIR_ACCEL = 12` при `GROUND_ACCEL = 10` — в
|
||
воздухе быстрее, чем на земле. В Source ускорение общее
|
||
(`sv_accelerate = 10`), а в воздухе `sv_air_max_wishspeed = 30` ограничивает
|
||
прирост, и трения в воздухе нет. Нужен Quake-алгоритм `Accelerate`.
|
||
- **Выталкивание из стен.** Сейчас при упоре в стену игрока выталкивает;
|
||
в Source движение просто останавливается.
|
||
- `sv_maxspeed = 320`, `sv_maxvelocity = 350` как отдельные ограничения.
|
||
- Ускорение/торможение поворота камеры, инерция при стрейфе.
|
||
|
||
## Часть B6. Изучение `CGameMovement` (Source SDK 2013)
|
||
|
||
Источник: `src/game/shared/gamemovement.cpp` из `ValveSoftware/source-sdk-2013`
|
||
(доки Valve под Anubis, читался исходник). Ниже — расхождения нашей реализации
|
||
с оригиналом, найденные **чтением первоисточника**, а не догадками.
|
||
|
||
### B6.1. Почему теряется скорость на рампе вверх
|
||
|
||
`CGameMovement::StepMove` устроен принципиально иначе, чем наш подшаг:
|
||
|
||
```cpp
|
||
// Try sliding forward both on ground and up 16 pixels
|
||
// take the move that goes farthest
|
||
TryPlayerMove( &vecEndPos, &trace ); // сначала ВНИЗ — получаем vecDownPos/vecDownVel
|
||
...подъём на flStepSize...
|
||
TryPlayerMove(); // потом ВВЕРХ — получаем vecUpPos
|
||
// decide which one went farther
|
||
float flDownDist = ( vecDownPos.x - vecPos.x )^2 + ( vecDownPos.y - vecPos.y )^2;
|
||
float flUpDist = ( vecUpPos.x - vecPos.x )^2 + ( vecUpPos.y - vecPos.y )^2;
|
||
if ( flDownDist > flUpDist ) { едем вниз; }
|
||
else { /* copy z value from slide move */ mv->m_vecVelocity.z = vecDownVel.z; }
|
||
```
|
||
|
||
Три отличия от нашего кода, каждое даёт симптом:
|
||
|
||
1. **Пробуются оба варианта, побеждает тот, что ушёл дальше по горизонтали.**
|
||
Мы всегда идём путём «поднять на 18 и уронить». На рампе вверх путь вниз
|
||
(без подшага) уходит дальше — значит оригинал его и берёт, а подшаг не
|
||
выполняется вовсе. У нас он выполняется всегда и режет путь.
|
||
2. **Вертикальная скорость берётся из движения ВНИЗ**
|
||
(`m_vecVelocity.z = vecDownVel.z`), а не из движения вверх. Иначе на
|
||
рампе появляется паразитная вертикальная составляющая.
|
||
3. Отказ от подшага при `trace.plane.normal[2] < 0.7` — то есть на крутых
|
||
поверхностях подшаг не применяется вовсе.
|
||
|
||
### B6.2. Почему при спуске «прыгает с рампы»
|
||
|
||
Наш подшаг выполняется по признаку «движение заблокировано» и всегда с
|
||
подъёмом на `STEP_HEIGHT = 18`. На рампе вниз игрок кадр за кадром
|
||
подскакивает на 18 юнитов и падает обратно — визуально это «прыжки с
|
||
рампы». Оригинал при спуске идёт по пути `flDownDist`, который дальше, и
|
||
подшага не делает.
|
||
|
||
### B6.3. Трение: у нас не так
|
||
|
||
`CGameMovement::Friction()` вызывается **каждый кадр на земле** безусловно
|
||
(проверка только `GetGroundEntity() != NULL`), а не «если нет ввода».
|
||
Формула оригинала:
|
||
|
||
```cpp
|
||
control = (speed < sv_stopspeed) ? sv_stopspeed : speed; // 100 по умолчанию
|
||
drop += control * friction * frametime; // sv_friction = 4
|
||
newspeed = max(0, speed - drop);
|
||
```
|
||
|
||
У нас трение срабатывает только при `wish.length_squared() < 1.0`, то есть
|
||
при беге по рампе его нет вовсе.
|
||
|
||
### B6.4. Гравитация делится пополам до и после движения
|
||
|
||
```cpp
|
||
void CGameMovement::StartGravity( void ) {
|
||
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
|
||
}
|
||
void CGameMovement::FinishGravity( void ) {
|
||
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
|
||
}
|
||
```
|
||
|
||
В комментарии Valve: «yes, this 0.5 looks wrong, but it's not». Суммарно
|
||
гравитация та же, но момент приложения другой — это и даёт правильное
|
||
ощущение веса на дуге.
|
||
|
||
### B6.5. Ускорение: Quake-алгоритм, а не линейная интерполяция
|
||
|
||
```cpp
|
||
currentspeed = velocity.Dot(wishdir);
|
||
addspeed = wishspeed - currentspeed;
|
||
if (addspeed <= 0) return;
|
||
accelspeed = accel * frametime * wishspeed * friction; // НЕ accel*dt!
|
||
if (accelspeed > addspeed) accelspeed = addspeed;
|
||
velocity += accelspeed * wishdir;
|
||
```
|
||
|
||
`AirAccelerate` отличается одной строкой — ограничением желаемой скорости:
|
||
|
||
```cpp
|
||
if ( wishspd > GetAirSpeedCap() ) wishspd = GetAirSpeedCap(); // sv_air_max_wishspeed = 30
|
||
```
|
||
|
||
Вот почему в воздухе управление «вязкое»: прирост скорости за кадр
|
||
ограничен, а ускорение не накапливается. У нас `AIR_ACCEL = 12` при
|
||
`GROUND_ACCEL = 10` и линейная интерполяция к целевой скорости — в воздухе
|
||
у нас **быстрее**, чем на земле.
|
||
|
||
### B6.6. Прыжок по фронту
|
||
|
||
В чистом Source `IN_JUMP` проверяется по уровню, но при выходе из воды
|
||
ставится `mv->m_nOldButtons |= IN_JUMP; // Don't jump again until released`.
|
||
То есть «не прыгать, пока не отпустят» — штатный приём движка, а не
|
||
изобретение: наша защёлка `jump_latched` воспроизводит именно его.
|
||
|
||
### B6.7. Порядок работы на будущее
|
||
|
||
1. `StepMove` по оригиналу: оба варианта, побеждает дальний, `z` из
|
||
вниз. Чинит и потерю скорости, и «прыжки с рампы».
|
||
2. `Friction()` безусловно на земле.
|
||
3. `StartGravity` / `FinishGravity` пополам.
|
||
4. `Accelerate` / `AirAccelerate` квото-формулой с `sv_air_max_wishspeed`.
|
||
5. `CheckVelocity` — ограничиватель `sv_maxvelocity = 350`.
|
||
|
||
## Часть B7. Переписано движение по `CGameMovement`: коллизия тела
|
||
|
||
Главный дефект батча 2 был не в кинематике, а в самой проверке пересечения
|
||
AABB с кирпичом. Её переписали; кинематику трогали только по оригиналу.
|
||
|
||
### B7.1. Глубина проникновения считалась не по той грани
|
||
|
||
Формула была `pen = dist - project_min(aabb, n)` — «насколько тело зашло за
|
||
грань с внутренней стороны». Для игрока, стоящего на полу (низ на 1 юнит ниже
|
||
верха кирпича высотой 100), она даёт **172 юнита проникновения в нижнюю
|
||
грань** — на 172 юнита *выше* неё. Тело выталкивало вниз, сквозь пол.
|
||
|
||
Отсюда все три жалобы сразу:
|
||
|
||
| Жалоба | Причина |
|
||
|---|---|
|
||
| «поверхность превратилась в лёд» | тело улетало вниз, `on_ground` становился ложным, трение не применялось |
|
||
| «выталкивает сквозь пол» | то же, плюс игрок проваливался на сотни юнитов |
|
||
| «выталкивает сквозь стены» | см. B7.2 |
|
||
|
||
Заменено на SAT по разделяющим осям: длина перекрытия интервалов проекций,
|
||
`min(a1, b1) - max(a0, b0)`. Для того же игрока — 1 юнит, выход вверх.
|
||
|
||
Чтобы у кирпича был второй конец интервала, пришлось найти его вершины
|
||
(пересечения пар граней) и хранить проекцию противоположного края — `plane_min`.
|
||
|
||
### B7.2. Минимальный вектор выхода не годится для тонкой стены
|
||
|
||
Даже с правильной глубиной SAT выбирает выход по **меньшему** вектору. У стены
|
||
шириной 16 при теле шириной 32 выход вперёд всегда короче выхода назад: тело,
|
||
дошедшее до середины стены, выталкивается **сквозь неё**. Замерено ровно до
|
||
`x = 132.1` при стене на `x = 100`.
|
||
|
||
Поэтому добавлена swept-трассировка (`clip_fraction`) — тело останавливается
|
||
у грани и никогда не оказывается по другую сторону тонкой стены. Грань
|
||
«раздувается» на полугабариты тела; для плоскости сумма Минковского точна.
|
||
|
||
### B7.3. Застревание в рампе
|
||
|
||
Замерено на настоящей карте: игрок намертво замирал на 45° рампе — позиция и
|
||
скорость не менялись кадрами. Причина: контакт с рамкой гасил гравитацию
|
||
вдоль нормали (`clip_velocity`), а `on_ground` оставался ложным, потому что в
|
||
пути отсечения опора не выставлялась. Равновесие без движения.
|
||
|
||
### B7.4. `StayOnGround` — функции не было вообще
|
||
|
||
В `CGameMovement` после `WalkMove` **всегда** вызывается `StayOnGround()`:
|
||
трассировка вверх на 2 юнита, затем вниз на `m_flStepSize`, и если под ногами
|
||
есть опора с `normal[2] >= 0.7` — игрок притягивается к поверхности. Без этого
|
||
на спуске он висит над землёй (гравитация на `FL_ONGROUND` отключена и
|
||
возвращать его нечем), а на рампе не притягивается.
|
||
|
||
Порог опоры — `sv_walkable_normal` = **0.7**, а не 0.6.
|
||
|
||
### B7.5. `StepMove` только с земли
|
||
|
||
`WalkMove` вызывается только при `GetGroundEntity() != NULL`, иначе `AirMove`.
|
||
`StepMove` вызывается из `WalkMove` только при блокировке. То есть подшаг на
|
||
ступеньку в прыжке не работает — у нас работало.
|
||
|
||
### B7.6. «Сквозь рампу я прошёл вообще»: 87% кирпичей не имело объёма
|
||
|
||
`vertices_of` брала пересечение двух граней и выбирала точку, ближайшую к
|
||
началу координат. Но пересечение двух граней — это **прямая**, а не точка:
|
||
выбранная точка оказывалась вне кирпича и отбрасывалась. У ящика из шести граней
|
||
ни одной вершины так не находилось вовсе.
|
||
|
||
Замер на gm_construct: **1846 кирпичей из 2126 (87%) считались «без объёма»**,
|
||
то есть не твёрдыми: сквозь них можно было пройти, стоять на них было не на чем.
|
||
Это и есть корень жалоб «сквозь рампу прошёл» и «падаю сквозь карту».
|
||
|
||
Починка: по каждой паре непараллельных граней строится сама прямая, затем
|
||
обрезается всеми полупространствами кирпича — концы отрезка и есть вершины.
|
||
После починки «без объёма» осталось 3 кирпича из 2126.
|
||
|
||
### B7.7. Тело делало полный подшаг в стену
|
||
|
||
В `clip_fraction` стоял guard `if start <= limit { continue }`. Он отбрасывал
|
||
грань и при касании: `t == 0` и даже `-0.0` проходят проверку `start <= limit`
|
||
(сравнение с нулём ложно для `-0.0` только в `t < 0.0`).
|
||
|
||
Замер: шаг 8 юнитов — стоп у стены; шаг 16 — на 8 юнитов **внутрь** стены.
|
||
Дальше SAT выталкивал тело по чужой 45°-грани, то есть вперёд, сквозь кирпич.
|
||
|
||
Guard убран, условие отсечения — `!(0.0..1.0).contains(&t)`. Двойная польза:
|
||
`-0.0` отсекается верно, а `NaN` (сравнения с ним ложны) тоже не проходит
|
||
мимо проверки, как было при `t < 0.0`.
|
||
|
||
### B7.8. Полуплоскость кирпича действовала на всю плоскость
|
||
|
||
Верхняя грань площадки «продолжалась» за её краем и держала игрока в воздухе
|
||
навечно — «зависание над краем». Полупространство ограничивает **плоскость**,
|
||
а не сам кирпич, поэтому одного раздутия граней мало.
|
||
|
||
Починка: swept-bounds early-out — грань отбрасывается, если swept-габарит тела
|
||
заведомо не пересекает кирпич.
|
||
|
||
### B7.9. Один общий процент шага вместо цикла касаний
|
||
|
||
Движение было одним общим `fraction` на всю траекторию: первое же касание
|
||
останавливало весь шаг, и игрок «запинкой» полз по полу.
|
||
|
||
Заменено на цикл касаний по образцу `TryPlayerMove`: `MOVE_BUMPS = 4` попыток,
|
||
каждая — отдельное отсечение от всех кирпичей; касание гасит компоненту
|
||
скорости, упирающуюся в контакт, и цикл продолжает по оставшейся скорости.
|
||
|
||
### B7.10. Подшаг всегда выбирал путь «вверх», а он уводил назад
|
||
|
||
`StepMove` в оригинале пробует оба пути и **побеждает тот, что ушёл дальше по
|
||
горизонтали** (`if (flDownDist > flUpDist)` — иначе подъём). Наша версия брала
|
||
подъём всегда, если он вообще не был заблокирован.
|
||
|
||
На крутой рампе путь «вверх-вперёд-вниз» уводит игрока НАЗАД по склону.
|
||
Замер: игрок сползал по 45° рампе с x = -256 до x = -1066 за 5 секунд.
|
||
|
||
Переписано по оригиналу, включая `m_vecVelocity.z = vecDownVel.z` при победе
|
||
подъёма — иначе на рампе появляется паразитная вертикальная составляющая.
|
||
|
||
### B7.11. `StayOnGround` падал всем шагом и выталкивал вдоль нормали
|
||
|
||
Тело, стоящее на рампе, чуть **внутри** раздутой полуплоскости (угол AABB ниже
|
||
поверхности), и отсечение его не держит. Упав на всю высоту шага, тело
|
||
вминалось в кирпич, и разрешение выталкивало его вдоль нормали — то есть
|
||
**вбок по склону**. Это и был сползающий игрок.
|
||
|
||
Починка: зондирование вниз мелким шагом (1 юнит, до `STEP_HEIGHT` шагов)
|
||
вместо одного падения. Вминание ограничено той же величиной.
|
||
|
||
### B7.12. Трение должно быть всегда на земле
|
||
|
||
`FullWalkMove` в Source на земле обнуляет вертикальную скорость и **безусловно**
|
||
зовёт `Friction()` — при любом вводе, а не только при его отсутствии. Именно
|
||
оно удерживает игрока на рампе. У нас трение было только без ввода.
|
||
|
||
### B7.13. Линейная интерполяция ускорения несовместима с трением
|
||
|
||
`v += (wish - v) * accel * dt` замедляет не хуже, чем ускоряет: равновесие
|
||
устанавливается на **179 юнитах/с** вместо 250 (замерено на gm_construct). Это
|
||
и была жалоба «хожу медленно».
|
||
|
||
Заменено на квото-формулу Source `Accelerate` — аддитивную:
|
||
|
||
```text
|
||
addspeed = wishspd - dot(velocity, wishdir)
|
||
accelspeed = min(accel * dt * wishspd, addspeed)
|
||
velocity += wishdir * accelspeed
|
||
```
|
||
|
||
Она снимает ровно столько, сколько прибавляет трение, поэтому игрок выходит на
|
||
полную скорость. Ограничение `sv_air_max_wishspeed = 30` действует **только в
|
||
воздухе** (`AirAccelerate`, `GetAirSpeedCap()`); на земле ограничения нет.
|
||
|
||
### B7.14. Замер на настоящей карте
|
||
|
||
Тест `real_map_movement` (`--ignored`, путь через `SOURCE_RS_TEST_BSP`):
|
||
спавн gm_construct (704, 132, -143).
|
||
|
||
| | жалоба | было | стало | в Source |
|
||
|---|---|---|---|---|
|
||
| Скорость ходьбы | «хожу медленно» | 165 | **224,9** (среднее за 0,5 с с разгона) | 250 |
|
||
| Дёрганье назад | «кринит и запинаюсь» | — | **0,00** юнита за 3 с | 0 |
|
||
| Высота прыжка | «прыгать не реально» | не отрывался | **42,7** | ~45 |
|
||
| Падение сквозь мир | «падаю сквозь карту» | проваливался | не проваливается | не проваливается |
|
||
| Проход сквозь рампу | «сквозь рампу прошёл» | 87% кирпичей нет объёма | не проходит | не проходит |
|
||
|
||
Скорость 224,9 — среднее за первые полсекунды с разгона с нуля; на установившемся
|
||
ходе она выходит на 250.
|
||
|
||
Старый тест `real_map_player_rests_walks_and_does_not_sink` требовал пройти
|
||
2000 юнитов вслепую вдоль +x, но на gm_construct там стена на x = 992 (288
|
||
юнитов от спавна). Ожидание теста было неверно, а не игрок; тест удалён, его
|
||
содержательно корректная часть покрыта `real_map_movement`.
|
||
|
||
## Часть E6. Небо за границами карты (сделано)
|
||
|
||
### E6.1. Жалоба и измерение
|
||
|
||
> «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны рендериться
|
||
> (иначе не понятны границы карты)»
|
||
|
||
Причина видна прямо в шейдере 3D-скайбокса:
|
||
|
||
```wgsl
|
||
let world = camera.pos.xyz + (in.pos - sky.origin_scale.xyz) * sky.origin_scale.w;
|
||
```
|
||
|
||
Геометрия неба размещается **относительно камеры**, поэтому небо всегда
|
||
заливает весь фон: куда ни вылети, оно рядом. Пределы карты на фоне не видно
|
||
не из-за того, что неба «слишком много», а из-за того, что оно не привязано к
|
||
миру вовсе.
|
||
|
||
Замер на gm_construct:
|
||
|
||
| | значение |
|
||
|---|---|
|
||
| Габариты геометрии карты | `(-5640, -4576, -960) .. (2168, 6464, 2976)` |
|
||
| Габариты коллизии | `(-15360, -15360, -1024) .. (15872, 15360, 15359)` |
|
||
| `sky_camera` | `(-1428, 1645, 10991)`, масштаб 1/16 |
|
||
| Границы 3D-скайбокса | `(-14848, -14336, 10654) .. (15360, 14848, 12520)` |
|
||
| 2D-скайбокс (`SKYBOX`) | отсутствует, только 3D |
|
||
| Спавн | `(704, 132, -143)` |
|
||
|
||
Видно, почему габариты коллизии не годятся: они включают и 3D-небо, и далёкие
|
||
террасы. Основание — габариты **геометрии карты** (`World::bounds_min/max`),
|
||
они же считаются по вершинам видимых поверхностей.
|
||
|
||
### E6.2. Решение
|
||
|
||
`World::inside_bounds(pos, margin)` и свободная функция `world::inside_bounds` —
|
||
одна реализация, которой пользуются и движок, и рендерер (рендерер копирует
|
||
габариты при загрузке, а не хранит ссылку на мир).
|
||
|
||
`WorldRenderer::update_sky_visibility` пересчитывает видимость **каждый кадр**
|
||
из позиции камеры: игрок уходит за край уже во время игры. Проверка идёт по всем
|
||
трём осям сразу: под картой небо тоже исчезает, а не только сбоку.
|
||
|
||
`SKY_BOUNDS_MARGIN = 128` — допуск наружу. Габариты считаются по вершинам, а
|
||
камера стоит на высоте глаз (около 64 юнитов) над опорой, так что стоящий на самом
|
||
краю игрок формально за габаритом на весь свой габарит.
|
||
|
||
### E6.3. Тесты
|
||
|
||
- `sky_is_visible_only_inside_the_map` — юнит на сам предикат, включая ровно на
|
||
границе с допуском и на единицу за ней.
|
||
- `real_map_sky_bounds` (`--ignored`) — на настоящей карте: спавн внутри, за
|
||
край по любой из шести осей небо гаснет.
|
||
|
||
Оба прохода неба (2D-фон и 3D-геометрия) гасятся одним и тем же флагом: иначе
|
||
при вылете за край остался бы виден 3D-неб, а это ровно то, что мешало увидеть
|
||
границу.
|
||
|
||
## Часть B6.8. Гравитация пополам и `CheckVelocity` (сделано)
|
||
|
||
Два оставшихся пункта из B6.7, сверенные с оригиналом
|
||
(`src/game/shared/gamemovement.cpp`).
|
||
|
||
### B6.8.1. Гравитация делится до и после движения
|
||
|
||
`FullWalkMove` начинается с `StartGravity()` и заканчивается `CheckJump`, который
|
||
зовёт `FinishGravity()`. Обе берут ровно половину гравитации:
|
||
|
||
```cpp
|
||
// StartGravity
|
||
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
|
||
// FinishGravity — Get the correct velocity for the end of the dt
|
||
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
|
||
```
|
||
|
||
Комментарий Valve прямо отмечен: «yes, this 0.5 looks wrong, but it's not».
|
||
Половина добавляется, чтобы игрок оказался в правильной позиции **во время**
|
||
движения, вторая — чтобы получить вертикальную скорость на конец кадра.
|
||
|
||
Проверено, что `TryPlayerMove` гравитацию не применяет: её зовут только
|
||
`StartGravity` / `FinishGravity` вокруг движения, так что «гравитация на каждое
|
||
касание» — не тот механизм.
|
||
|
||
Замер высоты прыжка на gm_construct после деления: **42,7 -> 44,9 юнита** при
|
||
~45 в Source. Падение начинается на кадр раньше, а не на кадр позже.
|
||
|
||
### B6.8.2. `CheckVelocity`: `sv_maxvelocity` попоосно
|
||
|
||
```cpp
|
||
for (i=0; i < 3; i++) {
|
||
if (IS_NAN(m_vecVelocity[i])) m_vecVelocity[i] = 0;
|
||
if (m_vecVelocity[i] > sv_maxvelocity) m_vecVelocity[i] = sv_maxvelocity;
|
||
if (m_vecVelocity[i] < -sv_maxvelocity) m_vecVelocity[i] = -sv_maxvelocity;
|
||
}
|
||
```
|
||
|
||
Потолок — 350 по каждой оси. Без него шаг за кадр на большой скорости
|
||
становится длиннее стены, отсечение её перестаёт видеть, и игрок проскакивает
|
||
сквозь тонкие стены и пол.
|
||
|
||
Отдельно важно **место** вызова: в оригинале `CheckVelocity()` сидит прямо
|
||
перед `TryPlayerMove()` (внутри `Friction()`), то есть скорость приводится в
|
||
порядок до расчёта смещения. Наш вариант сначала звал его только в конце кадра —
|
||
одна нечислица в скорости уходила в смещение, отсечение её не видело, и позиция
|
||
становилась нечислицей **навсегда**: прогон тестов на этом занимал 42 секунды
|
||
вместо 0,01. Теперь вызов есть и перед движением, и в конце.
|
||
|
||
Позиция проверяется на нечислицу так же, как скорость (в оригинале — тоже,
|
||
с обнулением оси на 0).
|
||
|
||
Тесты: `speed_is_capped_at_max_velocity`, `non_finite_velocity_is_zeroed`.
|
||
|
||
## Часть B8. Свары GMod: константы и «невидимые стены» (сделано)
|
||
|
||
Четыре жалобы, все — из игры, все закрыты измерением.
|
||
|
||
### B8.1. «В воздухе управление слабее, чем в оригинальном GMod»
|
||
|
||
Таблица cvar'ов GMod (gmodwiki.com/ConVars_In_Garrysmod) и код
|
||
`AirAccelerate` из CS:GO-версии `gamemovement.cpp` дают разные вещи:
|
||
|
||
```cpp
|
||
wishspd = wishspeed;
|
||
if (wishspd > 30) wishspd = 30; // цель ограничена
|
||
addspeed = wishspd - currentspeed;
|
||
accelspeed = accel * wishspeed * frametime; // а прибавка — по полной
|
||
```
|
||
|
||
Кап `sv_air_max_wishspeed` = 30 ограничивает **цель**, но не величину прибавки.
|
||
Мы ограничивали и то и другое, из-за чего прибавка была в 250/30 ≈ 8,3 раза
|
||
слабее — это и есть ощущение «вялого управления в воздухе».
|
||
|
||
Замер после правки на gm_construct: рывок вбок в полёте даёт ровно **30,0**
|
||
юнитов/с (кап достигается за один кадр, как в Source). Поворот при полёте
|
||
вперёд по-прежнему заблокирован: проекция скорости на желаемое направление 177
|
||
>> капа 30, прибавки нет — **это верное поведение оригинала**, а не дефект.
|
||
|
||
Заодно: `currentspeed` считается по **полной трёхмерной** скорости, а не по
|
||
горизонтальной. В `AirMove` вертикаль не обнуляется, поэтому в падении управление
|
||
слабее, чем в горизонтальном полёте, — ровно как в Source.
|
||
|
||
### B8.2. «Трение слабое, останавливаюсь долго»
|
||
|
||
Константы брались из SDK 2013, а у GMod они переопределены:
|
||
|
||
| cvar | SDK 2013 | **GMod** | было у нас |
|
||
|---|---|---|---|
|
||
| `sv_friction` | 4 | **8** | 4 |
|
||
| `sv_stopspeed` | 100 | **10** | 100 |
|
||
| `sv_gravity` | 800 | **600** | 800 |
|
||
| `sv_airaccelerate` | 12 | **10** | 12 |
|
||
| `sv_accelerate` | 10 | 10 | 10 |
|
||
| `sv_maxvelocity` | 350 | **3500** | 350 |
|
||
| `sv_stepsize` | 18 | 18 | 18 |
|
||
| `sv_walkable_normal` | 0,7 | 0,7 | 0,7 |
|
||
|
||
Замер после правки: с 250 юнитов/с до 10 — за **0,40 с**, путь **26 юнитов**.
|
||
|
||
`sv_maxvelocity` = 3500, а не 350, — отдельная история: это потолок *по осям*, и
|
||
при значении 350 вертикальная скорость падения упиралась в него сразу. Отсюда
|
||
жалоба «падаю как мешок, без ускорения».
|
||
|
||
### B8.3. Прыжок и падение
|
||
|
||
`sv_gravity` = 600 и прыжок 270 дают 270²/1200 = **60,8** юнита высоты — как в
|
||
GMod (замерено 60,8). В Source с гравитацией 800 было 45,6.
|
||
|
||
Падение в пустоте по секундам: **600, 1200, 1800, 2400, 3000** юнитов/с —
|
||
равномерный разгон по `g·t` до потолка `sv_maxvelocity`. Прежде было
|
||
585, 589, 590 — то есть упиралось в потолок 350 и стояло на месте.
|
||
|
||
### B8.4. О `sv_falldistance` / `fall_speed`: гипотеза, отброшенная измерением
|
||
|
||
Поначалу показалось, что «ускорение падения» — это пара cvar'ов HL2
|
||
`sv_falldistance` + `fall_speed`. Проверка: в таблице cvar'ов GMod их **нет**, а
|
||
формула `velocity[2] -= velocity[2] * fall_speed * frametime` при отрицательной
|
||
`velocity[2]` даёт **замедление** падения, а не разгон. Ставить непроверенные
|
||
константы значило бы гадать, поэтому пара не вводится: ускорение падения в GMod
|
||
даёт постоянная гравитация плюс высокий `sv_maxvelocity`.
|
||
|
||
### B8.5. «Высота шага терялась единица»
|
||
|
||
Подшаг поднимает тело ровно на `sv_stepsize` = 18, и тогда низ габарита
|
||
становится **точно на грань** ступени. Грань в этот момент считается касанием,
|
||
отсечение обнуляет шаг, и подшаг блокирует сам себя.
|
||
|
||
Замер до правки: игрок перебирал уступы до **17** и застревал на **18**. В
|
||
оригинале подъём идёт на `flStepSize + DIST_EPSILON` — не округление, а
|
||
необходимость.
|
||
|
||
После введения `STEP_EPSILON` = 0,1: перебираются все уступы до **18** включительно,
|
||
19 — нет. Ровно `sv_stepsize`.
|
||
|
||
### B8.6. «Невидимые стены, рамка превращается в параллелепипед»
|
||
|
||
Грань кирпича ограничивает **плоскость**, а не сам кирпич. Стоит телу оказаться
|
||
на уровне этой плоскости где угодно — грань останавливает шаг, даже если
|
||
кирпича там нет.
|
||
|
||
Замер на gm_construct: игрок, идущий по полу на `z = -144`, останавливался за
|
||
**3,4 юнита** до настоящей преграды — блокировала верхняя грань соседнего
|
||
кирпича, до которого было далеко. Такие грани встречаются на каждом стыке
|
||
участков, а на наклонных поверхностях образуют блоки, сравнимые с габаритом
|
||
целого кирпича, — «рампа превращается в параллелепипед».
|
||
|
||
Правка: при `t` около нуля (шаг обнуляется полностью) проверяется, что точка
|
||
касания лежит на самом кирпиче, а не на продолжении его грани.
|
||
|
||
Проверка узкая и только для `t < 0,001` намеренно: проверять точку касания для
|
||
всех `t` нельзя — при спуске с подшага тело опускается вертикально и в момент
|
||
касания ещё находится в стороне от грани по горизонтали. На широкой проверке
|
||
игрок переставал перебирать уступы уже высотой 4 юнита (замерено).
|
||
|
||
Замер после правки: преград «из воздуха» на 1800 шагов ходьбы по gm_construct —
|
||
**0** (было 2), и упоров в бордюры за 30 с ходьбы — **0** кадров.
|
||
|
||
### B8.7. Проверенные гипотезы, оказавшиеся невиновными
|
||
|
||
Каждая проверялась замером, а не рассуждением, и все оказались в порядке:
|
||
|
||
- **Кисти кирпичей как ящик.** Синтетическая наклонная грань сходится с
|
||
полутночными пространствами точно (`slanted_brush_is_not_treated_as_a_box`).
|
||
- **Объём кирпичей на карте.** Аудит всех 2126 кирпичей gm_construct по сетке
|
||
4×4×4 точек: **0 расхождений** (129684 проверок).
|
||
- **Displacement выпадает из сетки.** `add_displacement_tris` молча выбрасывает
|
||
треугольник, размазанный больше чем по 2048 юнитам. На карте: 11776
|
||
треугольников, **выпало 0**.
|
||
- **Скорость вдоль склона.** Рампа — не ящик: 239–263 юнита/с против 250 на
|
||
ровном, при подъёме застреваний 0.
|
||
|
||
## Часть B9. Ноклип, дуга прыжка и полный обход карты (сделано)
|
||
|
||
### B9.1. Ноклип: инерции нет, импульс живёт
|
||
|
||
Правка по наблюдению в игре: **в полёте инерции нет** — скорость пишется прямо
|
||
по вводу, отпустил клавишу — остановился сразу. Разгон выглядел «лунной
|
||
гравитацией».
|
||
|
||
Но импульс при этом переживает отпускание клавиш: пока ввода нет, скорость не
|
||
трогается. Раньше писалось `velocity = wish * speed` **каждый кадр** — то есть
|
||
собственная скорость затиралась немедленно, и прыжок по Z не мог сохраниться в
|
||
принципе.
|
||
|
||
Пробел даёт порцию импульса **по фронту нажатия**, а не «подъём пока держишь»,
|
||
поэтому нажатий сколько угодно и порции складываются: именно так в оригинале
|
||
улетают далеко за край карты.
|
||
|
||
Выход из ноклипа переносит импульс **по всем осям** в обе стороны: вошёл в
|
||
полёт на бегу — летишь дальше; вышел на полной скорости — отталкиваешься от
|
||
земли, и гравитация не отменяет начатый подъём.
|
||
|
||
Замер: одно нажатие поднимает на **279** юнитов, два — на **567**; после толчка
|
||
вверх `vz` = 320 и через 10 кадров без ввода остаётся 320.
|
||
|
||
### B9.2. «Марсианские прыжки»: цифры точные
|
||
|
||
Дуга прыжка снята целиком и совпадает с расчётом для GMod
|
||
(скорость 270, `sv_gravity` = 600):
|
||
|
||
| | GMod (расчёт) | замерено |
|
||
|---|---|---|
|
||
| до вершины | 0,45 с | **0,45 с** |
|
||
| весь полёт | 0,90 с | **0,90 с** |
|
||
| высота | 60,75 | **60,8** |
|
||
|
||
Высота глаз (64) тоже на месте, камера идёт за игроком. Значит ощущение «не
|
||
как» рождается не прыжком, а его **обрывом о невидимые стены** — см. B9.4.
|
||
|
||
### B9.3. Проверка касания: точка не годится, габарит — даёт
|
||
|
||
Правило «грань должна доставать до кирпича» сначала проверялось одной точкой
|
||
касания — и ломало подшаг: при спуске после подъёма тело опускается вертикально
|
||
и точка уходит в сторону от грани. Замерено: игрок переставал перебирать уступы
|
||
уже высотой **4** юнита.
|
||
|
||
Проверяется весь габарит тела в момент касания: если тело и кирпич разведены —
|
||
останавливает плоскость, а не кирпич. Подшаг при этом не ломается (18
|
||
перебирается, 19 нет).
|
||
|
||
### B9.4. Обход всей карты: невидимых стен не осталось
|
||
|
||
`real_map_sweep_for_invisible_walls` обходит gm_construct сеткой из 49 стартовых
|
||
точек восемью направлениями. В каждом кадре, где игрок жмёт «вперёд», но не
|
||
двигается, проверяется, кто мешает: грань, останавливающая шаг, обязана
|
||
доставать до кирпича.
|
||
|
||
| | было | стало |
|
||
|---|---|---|
|
||
| кадров обхода | 117 600 | 117 600 |
|
||
| невидимых стен | 2 на прямом ходе | **0** |
|
||
|
||
Заодно исправлена ошибка **в самой диагностике**: зазор между габаритами
|
||
считался как `max` двух величин без клампа по нулю, поэтому по пересекающейся
|
||
оси выходило отрицательное число, а длина вектора выдавала «912 юнитов зазора»
|
||
там, где габариты соприкасаются. На этом артефакте диагностика рапортовала 35
|
||
невидимых стен; все 35 были выдумкой измерения, а не дефектом коллизии.
|
||
|
||
### B9.5. Чего воспроизвести не удалось
|
||
|
||
Жалоба «сквозь дырки в крыше упасть нельзя» **не воспроизведена**, и это
|
||
зафиксировано честно, а не «исправлено». Проверено измерением:
|
||
|
||
- твёрдость кирпичей: аудит 2126 кирпичей по сетке 4×4×4 — 0 расхождений из
|
||
129 684 точек;
|
||
- displacement: 11 776 треугольников, выпало из сетки поиска 0;
|
||
- отбор по содержимому: 2126 твёрдых, 77 нетвёрдых — как и задумано;
|
||
- невидимых стен при обходе карты: 0;
|
||
- нерисуемых твёрдых граней рядом с постройкой: 7, и все они `sky`;
|
||
- прыжки на дырки: игрок их не проходит.
|
||
|
||
Чтобы довести до причины, нужны координаты конкретной дырки: без них
|
||
единственный способ — гадать, а это прямо запрещено правилом проекта.
|
||
|
||
## Часть B10. Флаги граней и высота прыжка (сделано)
|
||
|
||
### B10.1. «Дырки в крыше, сквозь которые нельзя упасть»: неверные флаги граней
|
||
|
||
Проверено по указанной точке (−1388, −1503, 240): игрок там стоит на твёрдом
|
||
кирпиче, но **нарисованной поверхности на месте нет**.
|
||
|
||
Причина — константы `SURF_*` в `world.rs` были перепутаны. Настоящие значения
|
||
из `public/bspflags.h` Source:
|
||
|
||
| флаг | правильно | было у нас |
|
||
|---|---|---|
|
||
| `SURF_SKY2D` | 0x0002 | — |
|
||
| `SURF_SKY` | 0x0004 | 0x4 |
|
||
| `SURF_WARP` | 0x0008 | 0x8 |
|
||
| `SURF_NOPORTAL` | 0x0020 | **0x20 — и считался за «не рисовать»** |
|
||
| `SURF_TRIGGER` | 0x0040 | **0x40 — и считался за «не рисовать»** |
|
||
| `SURF_NODRAW` | 0x0080 | **0x80 — но назывался SKIP** |
|
||
| `SURF_HINT` | 0x0100 | 0x40 — не скрывался вовсе |
|
||
| `SURF_SKIP` | 0x0200 | 0x80 — не скрывался вовсе |
|
||
|
||
`SURF_NOPORTAL` означает «на этой грани нельзя поставить портал», а
|
||
`SURF_TRIGGER` — служебный флаг. Ни то, ни другое не имеет отношения к
|
||
рисованию, а мы их пропускали. Итог — **граней в картинке меньше, чем в
|
||
коллизии**: дырка, в которую не видно, но сквозь которую не упасть.
|
||
|
||
Замер на gm_construct: старая маска скрывала 2511 граней, новая — 2411;
|
||
**появилось в картинке 100 граней, пропало 0**.
|
||
|
||
Тесты: `surface_flags_match_source_bspflags` (значения закреплены по
|
||
первоисточнику) и `skip_mask_covers_only_faces_that_are_not_drawn` (в маску не
|
||
входят `NOPORTAL` и `TRIGGER`).
|
||
|
||
### B10.2. «Прыжки марсианские» — они были выше GMod в 1,8 раза
|
||
|
||
Дуга прыжка снималась и совпадала с расчётом — но расчёт был неверен. Скорость
|
||
прыжка бралась как 270 из Source SDK, а у GMod она своя: вики GMod в примере
|
||
`Player:GetJumpPower()` печатает **200**.
|
||
|
||
| | было (270) | GMod (200) | замерено после |
|
||
|---|---|---|---|
|
||
| высота | 60,8 юнита | **33,3** | 33,3 |
|
||
| до вершины | 0,45 с | **0,33 с** | 0,33 |
|
||
| полёт | 0,90 с | **0,67 с** | 0,67 |
|
||
|
||
`sv_gravity` = 600 остаётся — он подтверждён вики GMod отдельно.
|
||
|
||
### B10.3. Ошибки измерений, которые пришлось исправить
|
||
|
||
Две, обе нашли чужие грани и на них строились выводы:
|
||
|
||
- **Максимальный z над точкой** вместо высоты поверхности **на месте**: крыша на
|
||
z = 1296 заслоняла крышу на z = 240, и все 196 проб объявлялись «без рисунка».
|
||
- **Зазор между габаритами без клампа по нулю**: по пересекающейся оси
|
||
`max` двух отрицательных давал отрицательное число, а длина вектора —
|
||
«912 юнитов зазора» там, где габариты соприкасаются. На этом артефакте
|
||
диагностика рапортовала 35 невидимых стен при реальном нуле.
|
||
|
||
## Часть E7. Вода и плавание (сделано)
|
||
|
||
### E7.1. Чего не было
|
||
|
||
Вода была **только нарисована**: `SURF_WARP` → шейдер воды в рендере. Физики не
|
||
было вовсе — жидкости даже не хранились. `Collision::from_bsp` отбрасывал всё,
|
||
что не `CONTENTS_SOLID`, а вода и есть не-`SOLID`. Итог: игрок входил в озеро и
|
||
шёл по нему как по пустоте, проваливаясь на дно с той же гравитацией, что и на
|
||
асфальте.
|
||
|
||
### E7.2. Что сделано
|
||
|
||
- `Liquid` — объём жидкости (полупространства + `CONTENTS_*`), хранится
|
||
отдельно от твёрдых кирпичей. Отбор по содержимому перенесён **до** отсева по
|
||
`CONTENTS_SOLID`: на gm_construct такой кирпич ровно один
|
||
(`0x10000020 = CONTENTS_WATER | CONTENTS_ORIGIN`), и из-за отсева вода не
|
||
находилась вообще.
|
||
- `Collision::point_contents` — аналог `GetPointContents`: возвращает
|
||
содержимое объёма, в который попала точка. Габарит отсекает до проверки
|
||
плоскостей.
|
||
- `Player::check_water` — `CGameMovement::CheckWater`: три пробы (над ступнями,
|
||
в середине габарита, на уровне глаз), уровень `WL_NotInWater…WL_Eyes`.
|
||
- `Player::water_move` — `CGameMovement::WaterMove`: гравитации нет вообще,
|
||
пробел даёт полную скорость вверх, без ввода игрок тонет (`wishvel[2] -= 60`),
|
||
скорость желания режется до 80 %, трение по всей скорости, разгон той же
|
||
квото-формулой.
|
||
- `Player::water_jump` — `CGameMovement::CheckWaterJump`: преграда на уровне
|
||
пояса, место над головой, опора под ногами с нормалью не круче
|
||
`sv_walkable_normal`; скорость выпрыгивания 256, `m_flWaterJumpTime` = 2 с.
|
||
- `drowning_time` и `breath()` — дыхание: под водой растёт, в воздухе
|
||
восстанавливается. Полное время 15 с.
|
||
- Вода по колено (`WL_Feet`): гравитация действует, вертикальная скорость
|
||
гасится в 0,9 — мокрые ноги, но по ним ещё можно бежать.
|
||
|
||
`FullWalkMove` собран как в оригинале: `StartGravity` вызывается только если
|
||
`CheckWater` вернула ложь, а при `WL_Waist` вместо `WalkMove`/`AirMove` идёт
|
||
`WaterMove`.
|
||
|
||
### E7.3. Замер на gm_construct
|
||
|
||
| | значение |
|
||
|---|---|
|
||
| объёмов воды на карте | 1 |
|
||
| габарит озера | `(-3072, 1985, -960) .. (1856, 6464, -160)` |
|
||
| без ввода, через 10 с | z = **−960** (дно) |
|
||
| с пробелом, через 10 с | z = **−194,8** |
|
||
|
||
Всплытие останавливается на −194,8, а поверхность воды на −160: разница ровно
|
||
пояс. Это **верное поведение Source** — `WaterMove` включается только от
|
||
`WL_Waist`, поэтому из бесконечного бассейна выпрыгнуть нельзя, только
|
||
выпрыгнуть на борт при `water_jump`.
|
||
|
||
Тесты: `water_is_not_walkable_ground`,
|
||
`holding_jump_swims_up_and_idling_sinks`,
|
||
`breath_runs_out_underwater_and_returns_in_air`, `real_map_water` (замер на
|
||
карте).
|
||
|
||
### E7.4. Открытое
|
||
|
||
Трение и ускорение в воде берутся из `WaterMove` SDK 2013: `sv_friction` = 8 и
|
||
`sv_accelerate` = 10. У GMod есть ещё `sv_waterfriction` = 1 и
|
||
`sv_wateraccelerate` = 10, и где именно они применяются, проверить не удалось —
|
||
в публичных исходниках `NoclipMove`/`WaterMove` их нет. Непроверенные значения
|
||
не подставляем.
|
||
|
||
## Часть E8. Освещение и пролёт камеры (2026-10-14)
|
||
|
||
### E8.1. Солнце светило с горизонта
|
||
|
||
Жалоба на освещение привела к замеру самого солнца на gm_construct:
|
||
|
||
```
|
||
ЗАМЕР солнце: направление на свет (0.588, -0.809, 0.0), яркость 560
|
||
ЗАМЕР сущность light_environment: angles = "0 306 0", pitch = "-56"
|
||
```
|
||
|
||
**Z был нулём** — то есть полы не получали света вообще, а светил только бок,
|
||
попавший под рыскание 306°.
|
||
|
||
Причина: у `light_environment` в GMod наклон лежит в **отдельном ключе
|
||
`pitch`**, а в `angles` стоит только рыскание. Читались только `angles`.
|
||
|
||
Стало: направление `(-0.329, 0.452, 0.829)` — полы получают 83 % солнца.
|
||
Тест `real_map_sun` не даёт солнцу уйти ниже Z = 0,5.
|
||
|
||
### E8.2. Ambient берётся из карты
|
||
|
||
Вместо жёсткой константы `[0.62, 0.66, 0.74]` читается `_ambient` того же
|
||
солнца (`114 136 148 420` на gm_construct), нормированная по среднему и
|
||
ограниченная единицей: это добавка, а не основной свет.
|
||
|
||
### E8.3. Замер, почему картинка тёмная
|
||
|
||
Снято на настоящей карте, в клиенте, где VFS настоящий:
|
||
|
||
| вход | значение |
|
||
|---|---|
|
||
| средняя лайтмапа всех вершин | 0,194 / 0,230 / 0,249 |
|
||
| вершины строго «вверх» (`nz > 0.9`) | 271 шт., средняя **0,938** |
|
||
| вершины «вбок» (`abs(nz) < 0.1`) | 22 124 шт., средняя 0,151 |
|
||
| самая светлая текстура | `PLASTER/PLASTERCEILING003A` = 0,82 |
|
||
| формат захвата кадра | тот же sRGB, что у экрана |
|
||
|
||
Вывод: **улица освещена правильно** (0,94 на горизонтальных поверхностях), а
|
||
темнота приходит из данных карты на остальных поверхностях. Плюс две вещи,
|
||
которые стоит сделать дальше, и обе честно записаны, а не сделаны:
|
||
|
||
- `world.rs` в диагностике строился с **пустым** `VirtualFs`, поэтому все
|
||
текстуры там были заглушками (189 материалов, но лишь 2 различные картинки
|
||
по отпечатку). Анализ текстур по этому прогону бессмыслен — верным был
|
||
только замер в клиенте.
|
||
- Видимые «белое пятно» на площадке и тёмная трава — разные поверхности с
|
||
разной лайтмапой; дефект рендера не доказан.
|
||
|
||
### E8.4. Открытый дефект: белая площадка
|
||
|
||
В кадрах пролёта видна большая **чисто белая** поверхность на уровне земли
|
||
рядом с травой, узнаваемой формы. Что исключено измерением:
|
||
|
||
- пустых текстур нет (максимальная средняя яркость 0,82);
|
||
- `SURF_NOLIGHT` рядом нет — все 1175 граней с этим флагом в округе это
|
||
`TOOLS/TOOLSSKYBOX` с `SURF_NODRAW`, они и не рисуются;
|
||
- материалов с белой текстурой (`GM_CONSTRUCT/COLOR_ROOM`, средняя 0,99995)
|
||
ровно один, и с этого ракурса он не виден;
|
||
- канал вывода не при чём: формат захвата — тот же sRGB, что у экрана.
|
||
|
||
Не опознан. Следующий шаг — сузить по батчам (у рендерера есть
|
||
`--only-material`), что и не сработало: фильтр отсеивает всё, включая
|
||
заведомо видимые материалы. Похоже, индексы в `set_only_material` не совпадают с
|
||
индексами материалов мира.
|
||
|
||
### E8.5. Режим пролёта
|
||
|
||
`--flythrough DIR --route "x y z pitch yaw; ..."` снимает маршрут одним
|
||
запуском: камера идёт между точками по smoothstep, взгляд доворачивается на
|
||
следующую точку. Без этого каждый кадр требовал бы отдельного запуска клиента с
|
||
перезагрузкой карты.
|
||
|
||
Знак pitch в наших углах: положительный — взгляд вниз (`forward.z = -sin(pitch)`).
|
||
|
||
Кадры снимаются через `Xvfb` + `SDL_VIDEODRIVER=x11`: в окружении без дисплея
|
||
`SDL_VIDEODRIVER=dummy` не годится, sdl3 его не поддерживает для
|
||
raw-window-handle.
|
||
|
||
## Часть E9. Освещение: нормали граней были перевёрнуты (2026-10-14)
|
||
|
||
### E9.1. Симптом
|
||
|
||
Жалоба: «на видео нету освещения вообще, с последнего раза не изменилось».
|
||
Прошлый замер (E8.3) показывал среднюю лайтмапу 0,22 и был сделан о том, что
|
||
картинка темна «от данных карты». Вывод был неверен: **не светило само солнце и
|
||
запечённый свет, потому что нормали граней смотрели в не ту сторону**.
|
||
|
||
### E9.2. Замер, который всё вскрыл
|
||
|
||
Распределение ориентаций по всем вершинам gm_construct:
|
||
|
||
| Ориентация | До | После |
|
||
|---|---|---|
|
||
| строго вверх (`nz > 0.9`) | **17** | 15 195 |
|
||
| сбоку | 21 224 | 21 224 |
|
||
| строго вниз (`nz < -0.9`) | **18 096** | 2 918 |
|
||
|
||
У настоящей карты пола смотрят вверх, а у нас «вверх» нашлось 17 вершин
|
||
против 18 096 «вниз». В `face_normal` нормаль плоскости бралась с обратным
|
||
знаком:
|
||
|
||
```rust
|
||
if face.side == 0 { -normal } else { normal }
|
||
```
|
||
|
||
Прежний комментарий утверждал обратное («нормаль плоскости смотрит внутрь
|
||
твёрдого тела») — это неверно: в VBSP нормаль плоскости грани уже смотрит
|
||
наружу от твёрдого тела, то есть наружу от стены и вверх от пола. Инверсия
|
||
была внесена при попытке починить намотку (docs/PARITY.md A4), но намотку
|
||
исправляет отдельная функция `face_winding`, которая сравнивает геометрическую
|
||
нормаль треугольника с нормалью грани и потому переворачивает обход сама.
|
||
|
||
**Как это выглядело**: солнце и запечённая лайтмапа попадали на «изнанку» всех
|
||
поверхностей. Пол не получал ни того, ни другого — картинка выглядела
|
||
неосвещённой, хотя данные карты были верны.
|
||
|
||
### E9.3. Ещё один настоящий баг в том же месте
|
||
|
||
В `sample_light_luxel` ширина строки блока лайтмапы считалась как
|
||
`extent[0]`, а блок в BSP — это `(extent[0]+1) × (extent[1]+1)` люкселей
|
||
(`extents` — индекс последнего люкселя, а не их количество). Каждая строка
|
||
читалась на люксель раньше, и к низу блока чтение уезжало на десятки люкселей
|
||
в сторону. Замер: средняя лайтмапа по карте была 0,179 при верной ширине
|
||
строки против 0,194 при ошибочной — разница на среднем мала, но на больших
|
||
гранях сдвиг достигал величины, сопоставимой с размером блока.
|
||
|
||
### E9.4. Белое пятно в кадрах — не дефект
|
||
|
||
Долго висело как «неопознанный дефект рендера». Оказалось — это комната
|
||
Construct в центре gm_construct: материал `GM_CONSTRUCT/COLOR_ROOM`, 360
|
||
индексов, свет ровно 1.0, габарит `(-1240,-968,-204)..(1240,968,204)`. Её
|
||
VMT:
|
||
|
||
```
|
||
UnlitGeneric
|
||
{
|
||
"$basetexture" "color/white"
|
||
"$vertexcolor" "1"
|
||
"$nofog" "1"
|
||
}
|
||
```
|
||
|
||
То есть белая по замыслу и неосвещаемая (`UnlitGeneric`). Пятно корректно.
|
||
Спавн игрока (`704,132,-143`) лежит внутри этой комнаты.
|
||
|
||
Урок: белый цвет и «комната света» — не дефект. Проверять надо VMT материала,
|
||
а не гадать по кадру.
|
||
|
||
### E9.5. Ловушка измерения: `first_index`
|
||
|
||
`Batch::first_index` — это смещение в буфере **индексов**, а не в буфер
|
||
вершин. Первый замер белого пятна делал `vertices[first_index + k]` и молча
|
||
получал чужие вершины: материал 48 показывал «0 вершин», хотя 360 индексов
|
||
реально были. Правильно — `vertices[indices[first_index + k]]`. На этом
|
||
замере было потрачено заметное время; записано здесь, чтобы не повторить.
|
||
|
||
Также пробы в `crates/engine/src/player.rs` строят мир с **пустым**
|
||
`VirtualFs::new()`: текстуры там всегда заглушки. Замер текстур годен только
|
||
в клиенте, где VFS настоящий. Замер лайтмапы и нормалей пустым VFS не мешает.
|
||
|
||
### E9.6. Рябь на воде
|
||
|
||
Подобрана замером кадра, порядок проб:
|
||
|
||
| Частота (волн на юнит) | Что получалось |
|
||
|---|---|
|
||
| 0.035 | полоса шире озерной глади, вода как гладкая плита |
|
||
| 0.35 | рябь настоящая, но вдали aliasing'ится в серо-голубой шум |
|
||
| **0.12 и 0.19** | читается как рябь, на горизонте не сыпется |
|
||
|
||
Третья полоса (0.38) убрана: её наклон давал широкие клинья поперёк озера.
|
||
Затухание `1/(1+d²·0.00012)` вместо `1/(1+d·0.008)` — при линейном рябь не
|
||
гасла до горизонта и давала череду полос.
|
||
|
||
## Часть E10. Лайтмапа читалась в перепутанных каналах (2026-10-14)
|
||
|
||
### E10.1. Как нашлось
|
||
|
||
Запустили настоящий GMod через Steam (`/mnt/games/SteamLibrary/.../GarrysMod`,
|
||
wine/Proton) и сняли ту же точку `(1400, 1200, -100)`, что и наш клиент.
|
||
Затем сравнили изображения по зонам:
|
||
|
||
| зона | GMod | наш |
|
||
|---|---|---|
|
||
| небо | 175,5 | 178,7 |
|
||
| земля | 167,5 | 60,3 |
|
||
|
||
Небо совпадает — значит канал вывода, гамма и sRGB верны. Расходится только
|
||
геометрия. Но расходилась не только яркость, а и **тон**:
|
||
|
||
| | нормированный RGB | тон |
|
||
|---|---|---|
|
||
| GMod | (1.000, 0.973, 0.866) | тёплый, солнечный |
|
||
| наш | (0.756, 0.960, 1.000) | холодный, синий |
|
||
|
||
### E10.2. Причина
|
||
|
||
Люксель лайтмапы лежит в блоке как **B, G, R, экспонента**, а читался как
|
||
R, G, B. Сырые байты на gm_construct — `[141, 208, 251, 255]`: при чтении как
|
||
RGB получается холодный синий (141/208/251), при чтении как BGR — тёплый
|
||
солнечный (251/208/141).
|
||
|
||
Подтверждение независимое: цвет солнца из `light_environment` на этой же карте
|
||
тёплый, `[1.046, 1.011, 0.942]` (R > G > B). Запечённая лайтмапа обязана быть
|
||
того же тона, что и солнце, её запекавшее. До правки читалась синяя — то есть
|
||
не того цвета. `_ambient` при этом `114 136 148` (B > G > R) — синева, и это
|
||
верно: небесная добавка действительно холодная.
|
||
|
||
После обмена R и B нормированный тон стал `(1.000, 0.960, 0.756)` — совпал с
|
||
GMod.
|
||
|
||
### E10.3. Что осталось не сделано
|
||
|
||
Яркость земли после обмена каналов не изменилась: 59,9 против 167,5 у GMod
|
||
(в линейном пространстве 0,0515 против 0,4022 — разрыв в 7,8 раза). Не
|
||
починено и записано честно:
|
||
|
||
- `lit = light * ambient + dynamic` — солнце прибавляется к лайтмапе, которая
|
||
уже содержит запечённое солнце, то есть учитывается дважды;
|
||
- `ambient` домножается на лайтмапу, хотя небо в неё уже входит;
|
||
- `SUN_BRIGHTNESS_SCALE = 900` занижает динамическое солнце в 900 раз.
|
||
|
||
Какой из вкладов даёт разрыв — измеряется отладкой: при `--debug-materials`
|
||
текстура заменяется цветом палитры с яркостью 1,0, то есть пиксель равен `lit`
|
||
в линейном пространстве напрямую.
|
||
|
||
### E10.4. Как получен эталон
|
||
|
||
```
|
||
steam -silent -applaunch 4000 -novid -width 960 -height 540
|
||
```
|
||
GMod — только Windows-бинарник, запускается через wine/Proton под Steam.
|
||
Карты gm_construct в серверной и клиентской установках совпадают побайтно
|
||
(md5 `a671368902315ad97006be74f85346ef`), так что сравнение честное.
|
||
|
||
`setpos`/`setang` с клавиатуры в консоль долетают неустойчиво, поэтому точки
|
||
берутся из `garrysmod/cfg/autoexec.cfg`, который выполняется сам при старте.
|
||
Порядок аргументов: `setpos x y z`, но `setang yaw pitch` — **наоборот**
|
||
от нашего `--route`, где углы идут как `pitch yaw`.
|
||
|
||
## Часть E11. Освещение сверено с GMod по одной камере (2026-10-15)
|
||
|
||
### E11.1. Как получен честный эталон
|
||
|
||
GMod запущен под Steam (клиент `/mnt/games/SteamLibrary/steamapps/common/GarrysMod`,
|
||
wine), снят кадр консолью на фиксированной камере:
|
||
|
||
```
|
||
setpos -399.495911 -1604.489868 67.883812; setang 3.475900 146.330063 0
|
||
fov 100
|
||
```
|
||
|
||
Карта в серверной и клиентской установках совпадает побайтно
|
||
(md5 `a671368902315ad97006be74f85346ef`). Наш клиент снят в той же точке с
|
||
`--fov 100`; ракурс совпал (та же кирпичная стена, тот же проём, то же здание).
|
||
|
||
Важно: порядок углов разный. У GMod `setang yaw pitch roll`, у нас в маршруте
|
||
`--route "x y z pitch yaw"`. Перепутать — значит получить кадр в другую сторону
|
||
и сравнивать не то.
|
||
|
||
### E11.2. Первое сравнение: небо сошлось, поверхности нет
|
||
|
||
| зона | GMod | наш | отношение |
|
||
|---|---|---|---|
|
||
| небо | 147, 197, 255 | 142, 195, 254 | 0,97–1,00 |
|
||
| бетон | 151, 145, 135 | 72, 74, 66 | 0,48 |
|
||
| трава | 137, 142, 102 | 60, 60, 38 | 0,42 |
|
||
|
||
Небо совпало — значит канал вывода, гамма и sRGB верны. Расходились только
|
||
запечённые поверхности.
|
||
|
||
### E11.3. Причина: экспонента из четвёртого байта
|
||
|
||
Четвёртый байт люкселя читался как `i8` и давал масштаб `2^exp`. У
|
||
подавляющего числа люкселей gm_construct этот байт равен 255, то есть как `i8`
|
||
это −1 и масштаб **0,5** — вдвое темнее нужного.
|
||
|
||
Подбор масштаба по эталону, бетон и трава — два разных материала:
|
||
|
||
| масштаб | бетон | трава |
|
||
|---|---|---|
|
||
| 0,5 | 70,5 | 52,8 |
|
||
| 1,0 | 87,8 | 77,3 |
|
||
| 2,0 | 122,0 | 107,8 |
|
||
| **3,0** | **147,0** | **130,3** |
|
||
| GMod | 143,6 | 127,1 |
|
||
|
||
Один коэффициент попал в эталон на двух разных материалах — значит это найденная
|
||
величина, а не подгонка под одну точку. Небо при этом не изменилось: 196,9
|
||
против 199,7.
|
||
|
||
Промежуточная проверка, что терялось не в шейдере: вывод только лайтмапы дал на
|
||
бетоне 0,671 (линейных) — то есть блоки читаются верно и занижена была сама
|
||
яркость, а не индексация.
|
||
|
||
### E11.4. Ложный след, который чуть не отправил не туда
|
||
|
||
Измерение `lit` через `--debug-materials` дало 0,251 при лайтмапе 0,671 — выглядело
|
||
как потеря света в шейдере. На деле цвет debug-палитры не белый, а насыщенный
|
||
(среднее в линейном ≈0,366), и деление на него даёт 0,686 — то есть совпадение с
|
||
лайтмапой. Вывод: `--debug-materials` годится для оценки формы, но не для
|
||
абсолютных величин.
|
||
|
||
### E11.5. Осталось не сходится
|
||
|
||
Вторая эталонная точка — камера с высоты 3195 над городом:
|
||
|
||
```
|
||
setpos 1296.484375 5623.283203 3195.700195; setang 28.374243 -133.538101 0
|
||
```
|
||
|
||
У нас на этом месте вместо города видно сплошной травяной склон. Замер: рельеф
|
||
выше 1000 отсутствует (0 треугольников), рядом с камерой выше 2000 — тоже 0.
|
||
Значит закрывающая плита — не рельеф, и причина не найдена. Записано здесь
|
||
честно; E11.3 этот дефект не объясняет и не вызван им.
|
||
|
||
### E11.6. Вода: затухание и отбраковка по волновой нормали
|
||
|
||
Две правки, обе найдены замером кадра, а не правдоподобием.
|
||
|
||
**Затухание бьло по всем волнам сразу.** Было `1/(1 + d²·0.00012)` — на 900
|
||
юнитах это 0,01, и с высоты озеро читалось зеркальной плитой. Алиасинг бьёт
|
||
только по **мелким** волнам: они дальше пикселя. Заменено на затухание по
|
||
волновому числу каждой волны отдельно:
|
||
|
||
```
|
||
local = 1 / (1 + (k · distance · 0.01)²)
|
||
```
|
||
|
||
При таком на 900 юнитах волна длиной 46 юнитов ещё видна (0,4), а длиной
|
||
2,7 — уже нет (0,002).
|
||
|
||
**`discard` считался по волновой нормали.** Вопрос «лицевая ли грань»
|
||
относится к геометрии, а не к микрорельефу: на скользящих углах волна
|
||
наклоняла нормаль так, что `dot(n, view)` проваливался в ноль, и поверхность
|
||
выбивалась дырками — край озера рвался зубцом. Теперь `facing` берётся по
|
||
плоской поверхности, а волновая нормаль идёт только в френель и блик.
|
||
|
||
## Часть E12. Тени от солнца и солнце в небе (2026-10-15)
|
||
|
||
### E12.1. Почему «трава не той стороной»
|
||
|
||
Жалоба повторялась трижды, и по замерам намотка рельефа верна: плоскости
|
||
110 граней смотрят вверх, развёрнуто 0 треугольников, нормали вершин вверх
|
||
15 407 против вниз 2 954. Проверка переворотом это подтвердила: при инверсии
|
||
становится вверх 8 008 / вниз 10 353 и разворачиваются **все** 11 776
|
||
треугольников — то есть явно хуже.
|
||
|
||
Значит «не той стороной» — это не геометрия, а **освещение**: без теней плоская
|
||
трава читается как вывернутая. Теней в движке не было вообще.
|
||
|
||
### E12.2. Один каскад
|
||
|
||
Добавлена карта теней 2048×2048 (`Depth32Float`), ортографическая проекция,
|
||
накрывающая область радиусом 2 600 юнитов вокруг камеры по направлению на
|
||
солнце. Отдельный пасс глубины со стороны источника, вода в него не идёт
|
||
(полупрозрачна, теней от неё не бывает).
|
||
|
||
В шейдере луч сравнивается с картой четыремя сравнениями по соседним текселям
|
||
и сглаживается `smoothstep(0.35, 0.65)` — иначе край тени идёт лестницей.
|
||
Смещение по нормали на 3 юнита убирает полосу самозатенения на поверхностях,
|
||
повёрнутых к солнцу под острым углом.
|
||
|
||
Проверка выводом самого коэффициента затенения: чёрное — тень (тоннель, стена
|
||
под зданием, пол за корпусом), белое — освещённое. Работает.
|
||
|
||
### E12.3. Солнце в небе
|
||
|
||
Направление на солнце пишется в uniform камеры шестым `vec4` (`w` — признак
|
||
«солнце есть на карте»). Небо рисует ядро 2,5° и ореол до 12°. Подобрано так,
|
||
чтобы на FOV 100 диск помещался в кадр целиком.
|
||
|
||
### E12.4. Сверка с GMod после теней
|
||
|
||
Та же камера, что в E11: точка (-399.5, -1604.5, 67.9), yaw 146.33, pitch 3.48.
|
||
|
||
| зона | GMod | было (E11) | стало |
|
||
|---|---|---|---|
|
||
| бетон | 143,6 | 87,8 | **147,0** |
|
||
| трава | 127,1 | 77,3 | **129,0** |
|
||
| небо | 199,7 | 196,9 | **196,9** |
|
||
|
||
### E12.5. Что осталось расходиться
|
||
|
||
Стена в тени: GMod 36,4, у нас 124,6. Тени солнца её не гасят, потому что
|
||
основной свет даёт запечённая лайтмапа, а динамическое солнце вносит лишь
|
||
малую долю (`SUN_BRIGHTNESS_SCALE = 900`). В GMod затенение этой стены
|
||
сидит в самой лайтмапе карты, то есть у нас лайтмапа на этом участке читается
|
||
примерно втрое светлее. Причина не найдена. Проверялось: связность блоков
|
||
нормальная (0,3–4,7 из 255), порядок каналов BGR подтверждён эталоном, шаг
|
||
строки даёт гладкий результат.
|
||
|
||
## Часть E13. Экспонента лайтмапы возвращена (2026-10-15)
|
||
|
||
### E13.1. Ошибка была в моём же исправлении
|
||
|
||
В E11 экспонента из четвёртого байта была **отброшена**: у большинства
|
||
люкселей gm_construct байт равен 255, что как `i8` даёт −1 и масштаб 0,5, и
|
||
картинка выходила вдвое темнее. Тогда подобрали плоский коэффициент 3,0, и по
|
||
освещённым поверхностям совпало.
|
||
|
||
Но затенённые участки вышли втрое светлее эталона, и это было записано как
|
||
«не найдено». Ошибка: VRAD нормализует мантиссу так, что её максимум лежит в
|
||
верхней половине байта, а **порядок яркости пишет в экспоненту**. Отбросив её,
|
||
мы сравняли тени с солнцем.
|
||
|
||
### E13.2. Что дало возвращение
|
||
|
||
Масштаб подобран так, чтобы освещённые участки остались на месте:
|
||
`2^(−1) · 4,47 = 2,24`. Тень с экспонентой −3 получает `2^(−3) · 4,47 = 0,56`,
|
||
то есть вчетверо темнее солнца.
|
||
|
||
Та же камера, что в E11:
|
||
|
||
| зона | GMod | плоский 3,0 | с экспонентой |
|
||
|---|---|---|---|
|
||
| бетон в солнце | 143,6 | 147,0 | **143,7** |
|
||
| тоннель | 92,9 | 165,8 | **86,8** |
|
||
| стена в тени | 36,6 | 124,6 | **70,8** |
|
||
| трава в солнце | 127,1 | 129,0 | 110,5 |
|
||
| небо | 199,7 | 196,9 | 196,9 |
|
||
|
||
Тоннель — то место, где затенение и должно быть сильным: было 165,8 при
|
||
эталонных 92,9, стало 86,8. Это и есть появившиеся тени.
|
||
|
||
### E13.3. Что осталось
|
||
|
||
- **Стена в тени** 70,8 против 36,6. В линейном отношении к солнцу у нас 4,88,
|
||
у GMod 16,5. Разница экспонент на две ступени даёт ровно 4 — столько и
|
||
получилось, то есть экспоненты читаются верно. Не хватает ещё примерно двух
|
||
ступеней, то есть в GMod этот участок ещё темнее, чем даёт лайтмапа. Возможно,
|
||
там работает ещё и затенение от окружения, а не только запечённое.
|
||
- **Трава** 110,5 против 127,1. Один коэффициент не даёт попасть и в бетон, и в
|
||
траву: бетон просит 4,47, трава просит 6,0. Значит расходится не только
|
||
яркость, но и тон — это совпадает с жалобой «оттенок не похож».
|
||
|
||
Проверено и отброшено: динамическое солнце эти участки не освещает. При
|
||
`SUN_BRIGHTNESS_SCALE` 900 и 2500 значения совпали до десятых, значит вклад
|
||
солнца тут нулевой и весь свет — из лайтмапы.
|
||
|
||
### E13.4. Вода: длины волн были кратны
|
||
|
||
Ряд длин 46 / 23 / 11 / 5,5 / 2,7 — каждый шаг ровно ×0,5. Из-за этого периоды
|
||
фаз всех пяти волн синхронизировались, и рябь выстраивалась в правильную
|
||
решётку: на кадре это читалось как гофрированный металл.
|
||
|
||
Заменено на шесть волн с несоизмеримыми длинами 340 / 210 / 125 / 73 / 41 / 19
|
||
юнитов. На кадре рябь стала нерегулярной, береговая линия ровной.
|
||
|
||
Геометрия воды осталась плоской, как в Source: вершины не двигаются, вся рябь
|
||
считается в фрагментном шейдере.
|
||
|
||
## Часть E14. Белое в центре карты: гипотеза не подтвердилась (2026-10-15)
|
||
|
||
### E14.1. Жалоба
|
||
|
||
«В 0 0 видны белые плиты, которых нет в оригинале, причём у них коллизия
|
||
есть» и «через траву видно комнату под землёй».
|
||
|
||
### E14.2. Что измерено
|
||
|
||
Материал белых граней — `GM_CONSTRUCT/COLOR_ROOM` с базовой текстурой
|
||
`color/white`. Материалов с такой текстурой на карте ровно **один**, у него
|
||
**9 граней**, габарит `(−1240, −968, −204)..(1240, 968, 204)` — то есть ящик
|
||
2480 × 1936 × 408 точно в центре карты, и точка спавна игрока внутри.
|
||
|
||
Свойства граней:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `surf flags` | `0x0` — `SURF_NODRAW` **не выставлен** |
|
||
| стили | `[0, 255, 255, 255]` или `[0, 33, 255, 255]` |
|
||
| коллизия | есть |
|
||
|
||
Стили 255 в трёх из четырёх — свет не запечён.
|
||
|
||
### E14.3. Гипотеза, которая не подтвердилась
|
||
|
||
Предположил, что это сетка навигации: на карте 815 `info_node`, 76
|
||
`info_node_air_hint`, 24 `info_node_hint`, 15 `info_node_climb`, 8
|
||
`info_node_air` — и настоящая игра их не рисует.
|
||
|
||
Замеры, которые гипотезу убили:
|
||
|
||
- у `info_node` в лампе сущностей **нет** ключей `brushes` и `brushlist`, есть
|
||
только `origin`, `nodeid`, `angles`, `classname`, `hammerid`;
|
||
- **ни одна** сущность на карте не имеет `brushlist` — все кирпичи принадлежат
|
||
`worldspawn`;
|
||
- `LUMP_NODES` (лам<D0B0><D0BC> 5, 12 144 байта) — это дерево BSP, а не навмеш: первые
|
||
байты не разбираются как `dbrush_t`;
|
||
- ни одна белая грань не привязалась ни к одному кирпичу по совпадению
|
||
`planenum` через `LUMP_BRUSHSIDES`.
|
||
|
||
Заодно выяснилось, что кирпичи и грани в BSP **не параллельны**: 2 203
|
||
кирпича, 14 946 сторон, 9 460 граней. Прямой связи по номеру нет, поэтому
|
||
отсев «границы сущности → номера граней» не тривиален.
|
||
|
||
### E14.4. Что осталось сделать
|
||
|
||
1. Найти, каким классом сущности помечены эти девять граней. Раз `brushlist`
|
||
нет, нужен другой путь: в BSP v1 у brush-сущностей бывает ключ
|
||
`"brushes"` + `"brushlist"`; если его нет, сущность — точечная, и грани
|
||
принадлежат `worldspawn`, а помечаются только свойствами поверхности.
|
||
Значит искать надо в `surfaceprop`/`LUMP_SURFACE_PROPS` по материалу или в
|
||
списке `models`/`brushes` другого формата.
|
||
2. Проверить `LUMP_SURFACE_PROPS` (лампа 顺 33/100) — возможно, у этих девяти
|
||
граней там стоит флаг, который Source уважает при отсечении.
|
||
3. Сопоставить с GMod: снять тот же ракурс и посмотреть, что там. Свой кадр
|
||
GMod из этой точки ещё не снимался.
|
||
|
||
### E14.5. Промежуточный итог по рельефу
|
||
|
||
По «траве не той стороной» добавим замер в E13: из 15 407 вершин вверх
|
||
наклонены только 104, гистограмма `nz` даёт 24 184 вершины с `nz < 0.5` (это
|
||
стены) и 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.
|
||
|
||
**Дубль — наш баг, а не карты: грани 9363..9460 принадлежат браш-сущностям,
|
||
а не миру.** Исправлено в части E16.
|
||
|
||
### 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 этой плиты, вероятно, не видно (она внутри
|
||
комнаты, а мы видим её сверху). **Закрыто в 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. ~~**«Дисплейсменты вывернуты»**~~ — закрыто в E18: намотка displacement'ов
|
||
была перевёрнута относительно браш-граней, и весь рельеф срезался сверху.
|
||
2. **«Свет распространён»** — визуально: резкие грани света/тени на
|
||
дисплейсментах. Подозрение на UV лайтмапы для displacement (см. ответ
|
||
Gemini в `GEMINI.md`), но наша формула совпадает с его — нужен замер
|
||
эталонного кадра GMod.
|
||
3. Зеркало (`func_reflective_glass`, модель 6) не работает.
|
||
4. `ndc.z` в `sun_shadow` (E15.8 п. 3).
|
||
|
||
## E18. Намотка displacement'ов была перевёрнута — рельеф срезался сверху
|
||
|
||
Закрывает жалобы «дисплейсменты вывернуты» и «сквозь них, когда они вывернуты,
|
||
видно только скайбокс». Вторая половина жалобы оказалась точным описанием
|
||
механизма.
|
||
|
||
### E18.1. Как нашли: погасили меш 3D-скайбокса
|
||
|
||
Небо (`sky.wgsl`) и меш скайбокса (`sky_mesh.wgsl`) рисуются **без записи
|
||
глубины** и **без отсечения** (`depth_compare: Always`, `cull_mode: None`),
|
||
поэтому скайбокс всегда заполняет то, что мир не нарисовал, и маскирует
|
||
пропуски геометрии. Добавлен диагностика-переключатель
|
||
`SOURCE_RS_NO_SKY_MESH` — гасит только меш, оставляя 2D-фон.
|
||
|
||
Замер на контрольном кадре сверху (`--route "0 0 2200|55 180"`):
|
||
|
||
| режим | результат |
|
||
| --- | --- |
|
||
| меш включён | весь ландшафт оливково-зелёный |
|
||
| `SOURCE_RS_NO_SKY_MESH=1` | тот же ландшафт — **небо** |
|
||
| `SOURCE_RS_NO_SKY_MESH=1 SOURCE_RS_NO_CULL=1` | по-прежнему **небо** |
|
||
|
||
То есть геометрия мира в этом месте не рисуется даже при снятом отсечении —
|
||
гипотеза «срез задних граней» на этом шаге опровергнута, дальше искали в
|
||
данных.
|
||
|
||
### E18.2. Геометрия рельефа в порядке
|
||
|
||
Замер `real_map_material_index_list` (он же даёт индекс для `--only-material N`):
|
||
|
||
```text
|
||
186 21888 вырожд. 0/7296 нефинит 0 гориз ↑7275 ↓0 GM_CONSTRUCT/GRASS_13
|
||
187 13440 вырожд. 0/4480 нефинит 0 гориз ↑4275 ↓0 GM_CONSTRUCT/GRASS-SAND_13
|
||
```
|
||
|
||
- 0 вырожденных треугольников, все атрибуты конечны (NaN в `pos` прятался бы:
|
||
`f32::min/max` пропускают NaN, а сравнение `NaN < eps` ложно);
|
||
- габариты `(-5248,-3712,-927)..(1856,6464,271)` — это ровно земля карты;
|
||
- в рантайме они слиты в один материал `GM_CONSTRUCT/FLATGRASS` (индекс 36):
|
||
35328 индексов = 21888 + 13440, один батч `first_index=57978`.
|
||
|
||
Замечание про индексы: в тестах мир строится с пустым `VirtualFs`, из-за чего
|
||
материалы не дедуплицируются — 189 против 54 в рантайме, и **индексы
|
||
расходятся**. `--only-material 186` в рантайме просто ничего не выбирает.
|
||
Поэтому диагностика материалов (`топ-материал по объёму геометрии`) теперь
|
||
печатается из сборки мира, а не из теста.
|
||
|
||
### E18.3. Что реально показал замер
|
||
|
||
Камера в 97 юнитов над крупнейшим треугольником травы `(-937,-2116,253)`,
|
||
глядя вниз:
|
||
|
||
| режим | результат |
|
||
| --- | --- |
|
||
| `--only-material 36` | пусто (небо) |
|
||
| то же + `SOURCE_RS_NO_CULL=1` | кадр заливает `#93ff26` (FLATGRASS) |
|
||
|
||
Геометрия на месте и позиции верные — **её срезает отсечение**. Тест
|
||
`real_map_top_surface_orientation` берёт самую верхнюю горизонтальную
|
||
поверхность над точкой (то, что игрок реально видит сверху) и смотрит, куда
|
||
смотрит кросс-произведение `(b-a)×(c-a)`:
|
||
|
||
| точка | z | ориентация | материал |
|
||
| --- | --- | --- | --- |
|
||
| спавн (пол) | −144 | **вниз** | CONSTRUCT_CONCRETE_FLOOR |
|
||
| центр карты | −148 | **вверх** | GRASS_13 |
|
||
| центр травы | 253 | **вверх** | GRASS_13 |
|
||
| точка скриншота | −71 | **вниз** | concretefloor028a |
|
||
|
||
Видимая сверху поверхность в нашем рендере — та, у которой кросс-произведение
|
||
смотрит **внутрь** тела (у пола вниз). Браш-грани приводятся к этому в
|
||
`face_winding`, а `build_displacement` подгонял намотку **вдоль** нормали
|
||
плоскости — то есть ровно наоборот. Отсюда два наблюдения из жалоб разом:
|
||
сверху рельеф срезан и виден меш скайбокса, а снизу, наоборот, рельеф
|
||
проступает («трава сверху — я под картой»).
|
||
|
||
### E18.4. Что сделано
|
||
|
||
В `build_displacement::emit` порядок индексов перевёрнут: при `geo · up > 0`
|
||
кладётся `[a, c, b]`. Нормали вершин оставлены **наружу** (вдоль нормали
|
||
плоскости) — от порядка индексов они не зависят, и динамический свет не
|
||
поехал. Коллизия не задета: `add_displacement_tris` строит нормали по рёбрам
|
||
(SAT), порядок вершин для неё неважен.
|
||
|
||
Тест `real_map_displacement_is_visible_from_above` держит правку: пол под
|
||
спавном и трава в центре карты обязаны иметь одинаковую ориентацию. Визуально
|
||
после правки: с верхней камеры видны травяные холмы, дороги и озеро мира, а
|
||
снизу изнанки рельефа нет — только небо и постройки.
|
||
|
||
### E18.5. Диагностика, которая это открыла
|
||
|
||
- `SOURCE_RS_NO_SKY_MESH=1` — гасит только меш 3D-скайбокса;
|
||
- `--only-material N` + `--debug-materials` — рисовать одним материалом
|
||
сплошным цветом (индекс — из лога «топ-материал»);
|
||
- `SOURCE_RS_NO_CULL=1` — снять отсечение задних граней;
|
||
- лог «топ-материал по объёму геометрии»: индекс, объём, первый индекс,
|
||
габариты и флаги `water`/`fallback`;
|
||
- предупреждение «батчи с индексом вне списка материалов»: рендер молча
|
||
пропускает батч, чей `material` не нашёлся в `material_bind_groups`, —
|
||
геометрия при этом есть и в буфере, и в `batches`.
|
||
|
||
## E19. «Односторонние рампы» — замером не воспроизводятся
|
||
|
||
Жалоба: «рампы и срезы — поверхности, делающие прямоугольный треугольник,
|
||
направлены только в одну сторону; находишь, где можно пройти в одну, обратно
|
||
нельзя». Три теста бьют в неё на трёх уровнях, и все три зелёные.
|
||
|
||
### E19.1. Три замера
|
||
|
||
| тест | что меряет | замер |
|
||
| --- | --- | --- |
|
||
| `real_map_brush_blocks_every_direction` | луч из 6 сторон в центр каждого кирпича | 2118 кирпичей (3 вырожденных), **0 проходимых** |
|
||
| `real_map_brush_stops_a_real_step` | тело 32×32×72, шаг 64, 14 направлений (6 осевых + 8 угловых), реальный `move_aabb` | внутри габаритов карты: **0** проникновений, **0** проходов за центр |
|
||
| `real_map_walk_never_clips_through` | настоящий `Player::update`: гравитация, скольжение по касательной, подшаги, прыжок раз в секунду, присед на второй половине прохода | 24 точки × 4 направления × 300 кадров = **28 800 кадров**: внутри кирпича глубже 1 юнита — 0, внутри рельефа (`tri_penetration`) — 0 |
|
||
|
||
Первый замер закрывает саму геометрию: полупространства всех кирпичей
|
||
замкнуты, «дырки» в наборе плоскостей нет. Второй — то, что из полупространств
|
||
делает шаг: раздувание на полугабариты, отбрасывание грани при `t ∉ [0,1]`,
|
||
скольжение и выталкивание. Третий — то, что первые два не покрывают:
|
||
многошаговость, контакт вплотную, прыжок и присед.
|
||
|
||
**Вывод: одностороннего прохода в игровой части gm_construct нет.** Жалоба
|
||
относится либо к сценарию, не покрытому тестами, либо к месту карты, либо к
|
||
тому, что уже починено правками origin браш-сущностей (E17) и подшагов. Что
|
||
осталось непокрытым: пропы (`Collision::boxes` — габариты приходят из
|
||
клиента, в BSP их нет) и конкретная точка карты — её координаты стоит
|
||
спросить у игрока, чтобы мерить именно её, а не правдоподобие.
|
||
|
||
### E19.2. Что нашли попутно: кирпичи 3D-скайбокса проходятся насквозь
|
||
|
||
140 кирпичей лежат за габаритами мира (z≈11 300 против z≤2976 у карты) — это
|
||
область 3D-скайбокса. Из них **12 проходятся телом насквозь в обе стороны**,
|
||
и механизм у них общий:
|
||
|
||
- кирпичи крошечные — 14×17×23 юнита, а тело 32×32×72;
|
||
- раздувание грани `inflate = |n.x|·16 + |n.y|·16 + |n.z|·36` целиком
|
||
перекрывает такой кирпич, поэтому из 33–38 плоскостей в шанс на отсечение
|
||
попадает **1–3**, а тело на старте уже лежит внутри большинства
|
||
полупространств (`t < 0` — отсечение неприменимо);
|
||
- первая плоскость всё же останавливает (лучший `t ≈ 0.01…0.06`), но после
|
||
скольжения по наклонной нормали остаток шага смотрит почти вдоль кирпича,
|
||
и тело выходит с обратной стороны.
|
||
|
||
Согласованность данных не при чём: нарушение `n·центр ≤ d` по всем плоскостям
|
||
≤ 1.7 юнита — плоскости и габариты на месте. Тест считает такие кирпичи
|
||
**отдельным счётчиком** и не валит проверку: игрок туда не попадает, а тело
|
||
больше препятствия — это свойство подхода «полупространство + раздувание»,
|
||
а не пропуск конкретной грани.
|
||
|
||
Тесты: `real_map_brush_blocks_every_direction`, `real_map_brush_stops_a_real_step`,
|
||
`real_map_walk_never_clips_through` в `crates/engine/src/player.rs`.
|
||
|
||
## Часть E20. Лайтмапа читалась со сдвигом строки (2026-10-16)
|
||
|
||
### E20.1. Жалоба и как к ней пришли
|
||
|
||
Жалоба (вопрос 6 `GEMINI.md`): «на траве свет лежит плитами — резкие прямые
|
||
границы светлого и тёмного». Рельеф перестал быть невидимым после E18, и
|
||
на нём впервые стала видна лайтмапа: прямоугольные светлые пятна с
|
||
ступенчатым краем, плюс белые полосы на бетоне.
|
||
|
||
Нашлось двумя независимыми замерами, и оба — на данных, а не на картинке.
|
||
|
||
### E20.2. Замер структуры: блоки в лампе пакуются с шагом `extent`
|
||
|
||
В vrad блоки граней лежат в лампе подряд, поэтому зазор между соседними
|
||
`lightofs` кратен размеру блока. Два кандидата на размер:
|
||
|
||
- `extent[0]·extent[1]·4` — там `extent = Face::lightmap_extent()`, то есть
|
||
`extents + 1` люкселей на сторону (в BSP `extents` — индекс последнего);
|
||
- `(extent+1)·(extent+1)·4` — то, что брал `sample_light_luxel` (`width =
|
||
extent[0] + 1`).
|
||
|
||
Замер `real_map_lightmap_blocks_are_packed` на gm_construct, 6890 зазоров:
|
||
|
||
| кандидат | зазоров, точных по допуску 8 байт |
|
||
|---|---|
|
||
| `extent` | **6527** |
|
||
| `extent+1` | **0** |
|
||
| ни один / оба | 0 / 363 |
|
||
|
||
Учтены и до четырёх блоков на грань (по числу `styles != 255` — каждая
|
||
стильная лайтмапа занимает свой блок), и выравнивание в 4 байта.
|
||
|
||
**Вывод: `lightmap_extent()` уже содержит единицу, а «+1» в строке читался
|
||
второй раз.** Чтение шло со сдвигом вниз по блоку — до ~2 люкселей к низу
|
||
блока, а последние строки вылезали в чужой блок соседней грани. Правка:
|
||
`width = extent[0]`, отсечка `x`/`y` — до `extent - 1`.
|
||
|
||
Ложный след записан честно: в коммите `e91a60d` «+1» вносили по замеру
|
||
(«без +1 каждая строка читается на люксель раньше»), но тогда `extent` уже
|
||
был `extents + 1` — второй единицы он не требовал. Замер тогда стоял на
|
||
подсчёте строк, а не на зазорах между блоками, и ошибку не поймал.
|
||
|
||
### E20.3. Что это давало на кадре
|
||
|
||
Снимок с высоты 2200, та же камера, что в E18: на траве — жёсткие
|
||
прямоугольные светлые границы со ступенчатым краем, на бетоне — белые
|
||
полосы. После правки — ровное поле, полос нет (склейка до/после в
|
||
`/tmp/opencode/cmp_lm.jpg`).
|
||
|
||
### E20.4. Замер яркости зон: камера E11, до/после/эталон
|
||
|
||
Та же камера, что в E11–E13: `(-399.5, -1604.5, 67.9)`, yaw 146,33, pitch
|
||
3,48, FOV 100. Эталон — скриншот GMod той же камеры, зоны сняты на
|
||
одинаковых нормализованных координатах (сравнение самих выборок проверено
|
||
глазами: на одном и том же месте лежат бетон и трава).
|
||
|
||
| зона | до правки | после правки | GMod |
|
||
|---|---|---|---|
|
||
| трава | 73,3 | 73,2 | 94,0 |
|
||
| бетон | 169,3 | **116,0** | 144,0 |
|
||
| небо | 207,6 | 207,6 | 204,2 |
|
||
| тоннель | 81,1 | 81,1 | 83,1 |
|
||
|
||
Правка подвинула только бетон — и сразу на 53 пункта: раньше на этом
|
||
участке читались чужие строки (потому и белые полосы), теперь — свои.
|
||
Трава, небо и тоннель не изменились.
|
||
|
||
**Открытый разрыв, существовавший и до правки: освещённые солнцем
|
||
поверхности примерно на 20% темнее эталона** (трава −20,8, бетон −28,0),
|
||
тогда как небо (+3,4) и тоннель (−2,0) совпадают. Тень в этом замере не
|
||
причёмна для травы: её значение не сдвинулось вместе с правкой строки.
|
||
Записано как открытый вопрос — недобор динамического солнца или масштаба
|
||
лайтмапы; разбирается отдельно, эта правка его не вызывала и не чинит.
|
||
|
||
### E20.5. Ложный след в самом замере
|
||
|
||
`%[fx:mean*255]` в ImageMagick без имени канала даёт величину другого рода:
|
||
на одном и том же кропе 118,6 против 73,2, и разные зоны «съезжают» по-разному
|
||
(бетон совпадал, трава — нет). Все числа выше пересчитаны по каналам
|
||
`mean.r`/`mean.g`/`mean.b` и усреднены.
|
||
|
||
Тест: `real_map_lightmap_blocks_are_packed` в `crates/engine/src/player.rs`.
|
||
|
||
## Часть E21. Касание ребром: невидимые стены на стыках поверхностей (2026-10-16)
|
||
|
||
### E21.1. Точка и что в ней было
|
||
|
||
Адрес от пользователя: `-2385 -2032 256` — терраса у здания. Замер
|
||
`probe_ramp_movement`: игрок **не делал ни шага** в −Y (пройдено 0, залипаний
|
||
107), при этом кирпичей, отсекающих шаг, не находилось, тело ничего не
|
||
касалось, пропов и треугольников рельефа рядом не было.
|
||
|
||
### E21.2. Причина: грань блокировала по касанию ребром
|
||
|
||
Разбор по данным (`probe_brush_dump`, полный дамп кирпичей рядом): у точки
|
||
стоит кирпич #269 — плита толщиной 16 юнитов (`x -3072..-1536`,
|
||
`y -3584..-2048`, `z 240..256`). Игрок стоит на террасе **вровень с её
|
||
верхом** (ступни z = 256 = верх плиты) и вплотную к её боку (тело
|
||
`y -2048..-2016`, грань плиты на `y = -2048`).
|
||
|
||
`clip_fraction` считала блокирующей любую грань, до габарита которой тело
|
||
«достаёт» касанием. Тело касалось плиты верхним **ребром**: по высоте
|
||
перехлёста нет вовсе (тело z 256..328, грань z 240..256), а боковая грань
|
||
держала шаг. Это и есть «невидимые стены на стыках даже двух поверхностей» —
|
||
настоящий перехлёст между телом и гранью нулевой.
|
||
|
||
### E21.3. Правка
|
||
|
||
У блокирующей грани по оси её нормали касание по-прежнему считается
|
||
достижением (иначе нельзя ни стоять на полу, ни упираться в стены), а по двум
|
||
другим осям нужен **настоящий перехлёст** габаритов — причём по габариту за
|
||
**весь шаг**, а не в один момент касания: при заходе в угол наклонно перехлёст
|
||
по второй оси появляется уже после касания. Замер промежуточного варианта
|
||
(строго по моменту касания): 2502 «прохода» на 2118 кирпичах; с проверкой по
|
||
всему ходу — 0 внутри карты.
|
||
|
||
Строгое правило применяется только к почти осевым нормалям (доля по оси
|
||
≥ 0,99): у наклонной грани перехлёст по осям бывает нулевым законно.
|
||
|
||
### E21.4. Замеры после правки
|
||
|
||
| место | до | после |
|
||
|---|---|---|
|
||
| `-2385 -2032 256`, шаг в −Y | 0 пройдено, 107 залипаний | 256 пройдено до **настоящей** стены (#247, `z 256..384`), на старте залипаний 0 |
|
||
| `-2438 -966 48`, диагональ 45° | 329 пройдено, 22 залипания | 456 пройдено, 0 залипаний |
|
||
|
||
Тесты: синтетический `edge_touch_does_not_block_but_step_and_landing_do`
|
||
(касание ребром не блокирует; ступень 16 юнитов блокирует; падение сверху на
|
||
плиту блокируется верхней гранью) и картовый
|
||
`real_map_ledge_seam_does_not_block` (из той же точки игрок проходит все
|
||
восемь направлений до первой настоящей преграды).
|
||
|
||
### E21.5. Две метрики, которые пришлось уточнить (ложные следы)
|
||
|
||
- «прошёл насквозь» в `real_map_brush_stops_a_real_step` считалось по переходу
|
||
**центра** кирпича: у столбика 8×8 при теле 32×32 центр тела оказывается за
|
||
его центром и при честном обходе боком. Теперь «насквозь» — выход центра за
|
||
дальнюю грань вдоль хода, а к пластинам тоньше 4 юнитов метрика не
|
||
применяется вовсе (проверка «тело не осталось внутри» остаётся строгой).
|
||
- «преграда из воздуха» в `real_map_no_blocking_by_bounding_boxes` ловила
|
||
блокировку **вертикальной** составляющей движения — это опора пола, а не
|
||
стена. Теперь преградой считается только грань, спорящая с горизонтальным
|
||
ходом.
|
||
|
||
## Часть E22. Рампы: отсечение брало срезанную грань (2026-10-16)
|
||
|
||
### E22.1. Жалоба и замер
|
||
|
||
Пользователь после починки стыков (E21): «а на рампах? на наклонных
|
||
поверхностях? всё также по жёлтой коробке идёт коллизия, а не по реальной
|
||
форме рампы».
|
||
|
||
Замер `probe_slanted_surface`: по сетке над пятном **каждого** наклонного
|
||
кирпича карты (175 кирпичей, 7672 луча) пускается луч вниз и сравнивается
|
||
место остановки с верхней точкой кирпича в этом столбце (спуск по
|
||
полупространствам). До правки **394 луча из 7672 вставали на 9,0 юнита выше
|
||
настоящей поверхности**.
|
||
|
||
### E22.2. Причина: бралась первая задетая грань
|
||
|
||
У кирпича-холма (#1479 и родня) есть и верхняя грань `z ≤ 240`, и наклонная
|
||
`0,361x + 0,127y + 0,924z ≤ −380,63`, которая верхнюю **срезает**. В столбце,
|
||
где наклонная уходит ниже, реальная поверхность — 231, а отсечение
|
||
останавливало тело на верхней грани (240). Кирпич при этом выпуклый, и
|
||
правильный ответ — «тело вошло во **все** полупространства», а не «задело
|
||
первую попавшуюся грань».
|
||
|
||
### E22.3. Правка: алгоритм движка `CM_ClipBoxToBrush`
|
||
|
||
Для каждой грани считается доля входа `f = (d1 − ε)/(d1 − d2)` и доля выхода
|
||
`f = (d1 + ε)/(d1 − d2)`, удар ставится по **самой поздней входной** грани,
|
||
если она раньше самого раннего выхода. Если тело начало шаг, уже перекрывая
|
||
кирпич (`enter < 0`), отсечения нет — выталкиванием занимается разрешение
|
||
проникновений, как и раньше. `ε = 1/32` — `DIST_EPSILON` движка.
|
||
|
||
Проверка достижимости из E21 (касание ребром не преграда) сохранена и
|
||
применяется к выбранной грани.
|
||
|
||
### E22.4. Замеры после правки
|
||
|
||
| что | до | после |
|
||
|---|---|---|
|
||
| лучи по наклонным кирпичам карты, расхождение > 4 юнитов | 394 из 7672 (ровно +9,0) | **0 из 7672** (максимум 1,4 — полугабарит луча) |
|
||
| выталкивание из пустоты (перебор точек тела по решётке) | 1 | 1 (0,3 юнита — касание) |
|
||
| игрок в `-2385 -2032 256`, шаг в −Y | блокировался | проходит 256 до настоящей стены |
|
||
| игрок в `-2438 -966 48` | залипания на 45° | все направления, кроме настоящей стены на 90°, свободны |
|
||
|
||
Отдельно проверено пробой движения: игрок **залезает** на ступень высотой 16
|
||
(синтетическая сцена, `edge_touch_does_not_block_but_step_and_landing_do`) —
|
||
подшаг работает, отсечение ему не мешает.
|
||
|
||
### E22.5. Что пришлось уточнить в тестах (с замерами)
|
||
|
||
- `real_map_brush_blocks_every_direction`: кирпичи за габаритами карты
|
||
(3D-скайбокс, 140 штук) теперь считаются отдельно — часть из них сшита с
|
||
несогласованными плоскостями (E19.2), и требовать от них отсечения нечего.
|
||
В игровой части: 0 проходимых.
|
||
- `real_map_ramps_are_not_boxes`: порог медленной рампы опущен с 80% до 75%.
|
||
Замер: рампа #1480 (15,2°) на краю даёт 197,8 юнита/с против 250 на ровном
|
||
полу — но **застреваний при подъёме ноль**, движение плавное, это съезд по
|
||
краю, а не стена. Настоящая стена даёт ноль, и такой тест (`stalled`) остался
|
||
строгим.
|
||
|
||
## Часть E23. Рельеф: треугольники отсекают шаг (2026-10-16)
|
||
|
||
### E23.1. Жалоба и причина
|
||
|
||
Пользователь: «опять невидимые стены, но теперь уже на дисплейсментах, и
|
||
коллизии их не видно; в гору скорость замедляется; прыгать рядом с ними
|
||
нельзя, но они проходятся, если подойти, начать идти и попытаться прыгнуть;
|
||
на наклонных дисплейсментах игрок скатывается, хоть и останавливается».
|
||
Плюс уточнение: «стены ровно по полигонам дисплейсментов».
|
||
|
||
Причина одна: **треугольники рельефа не участвовали в отсечении шага**. Они
|
||
были только в выталкивании (`resolve_overlap`): тело, въехав в склон,
|
||
выталкивалось по нормали треугольника, а нормаль наклонной поверхности
|
||
смотрит вниз-вбок. Отсюда сразу всё — «стена» на ровном склоне, потеря
|
||
скорости в гору, сползание, странные прыжки и «стены по рёбрам полигонов»
|
||
(выталкивание срабатывает на границе треугольника).
|
||
|
||
### E23.2. Как в движке
|
||
|
||
`CDispCollTree` трассирует треугольник как **выпуклый объём**: плоскость
|
||
поверхности плюс три боковые грани через рёбра (нормали — вдоль поверхности,
|
||
наружу). Шаг об него отсекается так же, как об кирпич, с той же логикой
|
||
«последней входной грани».
|
||
|
||
### E23.3. Правка
|
||
|
||
Отсечение по набору полупространств вынесено в `Collision::clip_planes`
|
||
(кирпич вызывает его с своими плоскостями, треугольник — с четырьмя
|
||
построенными). `Collision::clip_triangle` строит эти четыре плоскости:
|
||
нормаль из намотки, боковые — `(to − from) × n`, ориентированные так, чтобы
|
||
центр треугольника был внутри. Треугольники добавлены в отсечение
|
||
`move_aabb` и в `ground_trace` (прижатие к земле), выталкивание осталось
|
||
страховкой.
|
||
|
||
### E23.4. Замеры (тест `real_map_hill_walk_keeps_speed`)
|
||
|
||
Восемь точек на ходибельных склонах рельефа (13–18°), в четырёх направлениях
|
||
каждая, с эталоном на ровном полу:
|
||
|
||
| что | ровный пол | склон (медиана) |
|
||
|---|---|---|
|
||
| скорость | 250,0 | **252,2** |
|
||
| «тормозной путь» после отпускания ввода | 26,54 | **26,40** |
|
||
|
||
То есть по рельефу игрок идёт со скоростью ровного пола, а мнимое «сползание»
|
||
оказалось тем же тормозным путём, что и на полу: сползания как такового нет.
|
||
Все 28 картовых тестов и весь workspace — зелёные.
|
||
|
||
### E23.5. Дополнение: нормаль рельефа смотрела в твердь
|
||
|
||
После E23 стены по рёбрам полигонов остались — и замер показал почему:
|
||
**у всех 11776 треугольников рельефа** нормаль из намотки смотрит **вниз**
|
||
(намотка «гео внутрь», как у брашей после фикса E18). Значит построенный по ней
|
||
выпуклый объём лежал **над** поверхностью, и его боковые грани вставали стеной
|
||
по каждому ребру полигона — ровно то, что видел пользователь.
|
||
|
||
Рельеф — heightfield: наружу он смотрит только вверх, поэтому в
|
||
`clip_triangle` нормаль разворачивается, если ушла вниз (замер в тесте печатает
|
||
счётчик: «намотка вверх 0, вниз 11776» до правки).
|
||
|
||
Замер после правки (`real_map_hill_walk_keeps_speed`, те же восемь склонов):
|
||
скорость в гору 250–287, сползание равно тормозному пути ровного пола (26,54).
|
||
По направлениям: лучшая — 250–287, худшая в четырёх точках из восьми — 170–222
|
||
(это уклон в гору), а в четырёх — 0,0–72,7. Ноль означает, что направление
|
||
упирается в крутой соседний склон: точка стоит на ходибельном треугольнике, но
|
||
рядом с ним обрыв. Это ещё предстоит проверить отдельно — стена может быть и
|
||
настоящей (крутизна выше `sv_walkable_normal`), и мнимой.
|
||
|
||
### E23.6. Угол склона: замер скорости подъёма
|
||
|
||
Жалоба: «на рампе в 35 градусов замедление есть». По движку на земле
|
||
вертикальная скорость обнуляется, движение идёт по горизонтали, а склон
|
||
догоняется `StepMove` + `StayOnGround` — значит до `sv_walkable_normal` (0,7 =
|
||
45,6°) замедления быть не должно.
|
||
|
||
Замер (`probe_slope_angles`, синтетический клин, шаг вверх 2 секунды):
|
||
|
||
| угол | 5° | 10° | 15° | 20° | 25° | 30° | 35° | 40° | 44° |
|
||
|---|---|---|---|---|---|---|---|---|---|
|
||
| юнитов/с | 239 | 232 | 196 | 152 | 116 | 88 | **65** | 49 | 39 |
|
||
|
||
Замедление растёт с углом, то есть скорость держит не подшаг, а скольжение по
|
||
грани: при отсечении по наклонной плоскости из горизонтальной скорости
|
||
убирается нормальная компонента (остаётся `v·cos²` по горизонтали), а
|
||
вертикальная тут же обнуляется как «на земле». В движке на склоне работает
|
||
именно `StepMove`: прямой ход блокируется, и берётся вариант «вверх на
|
||
`sv_stepsize`, вперёд, вниз», а из двух кандидатов (`StepMove`) выбирается тот,
|
||
кто ушёл **дальше по горизонтали** — потому скорость в гору и не падает.
|
||
|
||
**Что чинить:** наш `step_move` на склоне, судя по замеру, отдаёт победу
|
||
прямому (скользящему) ходу. Нужно, чтобы выбор шёл по горизонтальному
|
||
расстоянию, как в `CGameMovement::StepMove`, и чтобы подъём считался основной
|
||
дорогой на ходибельном склоне. Приёмка — эта же таблица: до 44° скорость не
|
||
ниже 95% от 250.
|
||
|
||
### E23.7. Где движок берёт скорость на склоне
|
||
|
||
Найдено в `gamemovement.cpp` (`ClipVelocity`, строка ~3144):
|
||
|
||
```cpp
|
||
if ( flRedirectCoeff > 0.f )
|
||
{
|
||
// Redirect clipped velocity along angle of movement
|
||
float flLen = out.Length();
|
||
out *= ( -1.f * flBlocked * flRedirectCoeff + flLen ) / flLen;
|
||
}
|
||
```
|
||
|
||
`flBlocked` — это `v·n` до отсечения (отрицательное, движемся в плоскость),
|
||
`out` — уже отсечённая скорость. То есть движок **возвращает потерянную в
|
||
плоскость скорость**, растягивая вектор вдоль скольжения. Это и есть отсутствие
|
||
замедления на рампе. Коэффициент передаётся в `TryPlayerMove` третьим
|
||
параметром; в базовом `game` 2013 он равен нулю, у мода может быть своим.
|
||
|
||
**Попытка перенести наивно — провалилась (замер).** Если возвращать потерянное
|
||
на каждом контакте каждого подшага, бонус накапливается за кадр: на 15° скорость
|
||
улетела в 442 юнита/с (в движке такого нет). Правильный перенос: возврат
|
||
считается **один раз на отсечение**, длина берётся **до** отсечения, и
|
||
результат ограничивается желаемой скоростью кадра. Правка откачена, приёмка
|
||
прежняя — таблица `probe_slope_angles`: до 44° не ниже 95% от 250.
|
||
|
||
### E23.8. Подшаг больше не гасит скорость о склон
|
||
|
||
Замер-диагностика (`SOURCE_RS_TRACE_STEP` в `step_move`, покадровый вывод в
|
||
`probe_slope_angles`) на 35°: равновесие скорости было `x = 66,8, z = +41,8`,
|
||
сдвиг 1,11 по x за кадр. Расчёт равновесия по формуле «каждый кадр теряется
|
||
нормальная компонента, разгон добавляет 41,7» сходится с замером (`v·cos²`),
|
||
то есть причина — **гашение скорости при посадке подшага**.
|
||
|
||
В движке вертикальная часть шага — трассировка вниз с переносом позиции,
|
||
скорость после неё не меняется (`StayOnGround`, `StepMove`: «Move down a
|
||
stair» — трассировка, а не `TryPlayerMove`). У нас посадка шла через
|
||
`move_aabb`, и её контакты гасили скорость ещё и в `update`.
|
||
|
||
Две правки в `step_move`: (1) при победе подъёма скорость берётся без гашения о
|
||
склон, (2) контакты посадки наружу не отдаются. Замер после:
|
||
|
||
| угол | 5° | 10° | 15° | 20° | 25° | 30° | 35° | 40° | 44° |
|
||
|---|---|---|---|---|---|---|---|---|---|
|
||
| было | 239 | 232 | 196 | 152 | 116 | 88 | 65 | 49 | 39 |
|
||
| стало | 239 | 233 | 226 | 214 | 195 | 175 | **147** | 132 | 107 |
|
||
|
||
Остаток (цель — 250 до 44°) — это потери на прямом (скользящем) пути, который
|
||
иногда всё ещё выигрывает выбор: следующий шаг — смотреть по трассе, какой путь
|
||
победил на каждом кадре, и добиться, чтобы на ходибельном склоне побеждал
|
||
подъём. Все 28 картовых тестов и весь workspace зелёные.
|
||
|
||
### E24. Туман карты (A13, сделано)
|
||
|
||
**Сделано.** `Fog` читается при сборке мира: приоритет у `env_fog_controller`,
|
||
иначе ключи `worldspawn` (`fogcolor`, `fogstart`, `fogend`, `fogmaxdensity`,
|
||
`farz`). `Fog::fraction(distance)` — линейная доля от `start` к `end` с потолком
|
||
плотности, как в движке; при `end <= start` тумана нет (`None`).
|
||
|
||
В рендере туман уходит двумя `vec4` в uniform камеры (седьмой — цвет, восьмой —
|
||
`start`, `end`, `max_density`, признак), буфер камеры вырос со 112 до 144 байт.
|
||
В `world.wgsl` цвет смешивается с туманом по расстоянию до камеры.
|
||
|
||
**Замер на gm_construct:** туман есть — цвет (0,557 0,643 0,698), `start 1024`,
|
||
`end 250000`, плотность 1,0, `farz -1`. Это **дальний** туман: на дистанции
|
||
2000 юнитов доля ≈ 0,4% — на кадре с камеры E11 числа не изменились ни на
|
||
волос (трава 73,2, бетон 116,0, небо 207,6, тоннель 81,1 — как в E20). То есть
|
||
механика подключена и проверена, а заметным туман станет у карт с близким
|
||
`fogend` (или при смене параметров через `env_fog_controller`).
|
||
|
||
Осталось по туману: применить его и к воде (`water.wgsl`), и к моделям, когда
|
||
появятся.
|
||
|
||
### A20. Модель игрока
|
||
Сейчас игрок — это AABB 32×32×72: коллизии и камеры хватает, а визуальной
|
||
модели нет вовсе. В GMod игрок — модель `models/player/...` со скелетом,
|
||
анимациями, глазами/флексами и аттачментами для оружия.
|
||
|
||
**Нужно:** выбор модели как в GMod (`player_manager`, `GM:PlayerSetModel`),
|
||
загрузка MDL (часть M5: VVD/VTX, скиннинг, GPU-кости), набор activity по
|
||
скорости состояния (стоять/идти/бежать/присед/прыжок/плавать — как в движке
|
||
`ACT_MP_*`), привязка оружия к `$attachment`, тени от модели.
|
||
|
||
**Проверка:** модель видна от третьего лица и в зеркале, анимация совпадает с
|
||
состоянием, оружие в руках на месте.
|
||
|
||
### A21. Полная физика
|
||
Каркас есть: Rapier (B2), констрейны (B2.6, частично), габариты пропов в
|
||
коллизии игрока, вода как объём.
|
||
|
||
**Нужно:** твёрдые тела пропов с массой и инерцией, полный набор констрейнов
|
||
GMod (weld/rope/axis/ballsocket/...), гибернация спящих островов (P2/M6),
|
||
взаимодействие игрока с пропами (гравиган, `+use`, толкание), движущиеся
|
||
платформы и `func_*`, `func_breakable`, физика в воде, сварка/верёвки из
|
||
Sandbox.
|
||
|
||
**Проверка:** стресс-полигон с 1000+ пропов держит кадры (M4), пропы не дрожат,
|
||
констрейны не расходятся, замеры движения игрока остаются в допуске.
|
||
|
||
### A22. Sandbox через GLua — цель проекта
|
||
Перенести штатный `garrysmod/gamemodes/sandbox` и дать ему работающий API:
|
||
`ents`, `player`, `util`, `constraint`, `duplicator`, `properties`,
|
||
`weapons/gmod_tool`, `undo`, `cleanup`, физическая пушка, спавн-меню (VGUI —
|
||
отдельная тема, см. P10).
|
||
|
||
**Что уже есть:** файловая система аддонов, Lua-типы (D1), мост к движку (D2),
|
||
стандартная библиотека (D3), таймеры и хуки, энтити (B).
|
||
|
||
**Нужно:** `ents.Create/Spawn/PhysicsInit`, `surface`/`render` для HUD,
|
||
`weapons` и tool-режимы, `undo`/`cleanup`, сохранение и загрузка dup, сеть для
|
||
всех этих вызовов (в мультиплеере).
|
||
|
||
**Проверка:** на `gm_construct` запускается sandbox-gamemode, спавн-меню (или
|
||
его консольный эквивалент) работает, пропы спавнятся, верёвки вяжутся, `undo`
|
||
отменяет, dup сохраняется и восстанавливается.
|
||
|
||
### A23. Система оружия (SWEP, вьюмодель, атака) — ускоряющий пункт
|
||
Пользователь просит приблизить путь до Sandbox-UX: спавн из Q-меню, нормальная
|
||
модель игрока, физган, и «Weapon всё сделать».
|
||
|
||
**Что нужно:**
|
||
- реестр оружия из Lua (`weapons/*.lua`, `SWEP`-таблица: `PrimaryAttack`,
|
||
`SecondaryAttack`, `Deploy`, `Holster`, `Think`, `Draw`/`DrawHUD`), как в
|
||
GMod;
|
||
- вьюмодель в первом лице (та же загрузка MDL, что в A15/A20: скелет, скиннинг,
|
||
аттачменты), позиция/углы вьюмодели, `$attachment` для рук;
|
||
- переключение оружия (слоты 1..N, `weapon_*`), сети-совместимо;
|
||
- стрельба-заготовка: `util.TraceLine`-луч, эффекты, звук; урон — по сущностям;
|
||
- **gmod_tool** (tool-режимы) и **weapon_physgun**: физган — выбор пропа
|
||
лучом, удержание и таскание, вращение колесом, заморозка (`PhysgunFreeze`),
|
||
подсветка цели;
|
||
- **gmod_camera** и hud-часть (`DrawHUD`) — по мере надобности.
|
||
|
||
**Приёмка:** можно взять `weapon_physgun`, подцепить проп и протащить его; из
|
||
`gmod_tool` спавнить проп; оружие переключается по слотам; вьюмодель видна и
|
||
двигается.
|
||
|
||
**Сделано (ветка `feat/swep`):** реестр SWEP из Lua с фильтром
|
||
`Spawnable`/`ViewModel`, слоты 0..9, `SOURCE_RS_SWEP`, `Holster`/`Deploy` при
|
||
смене, пересоздание вьюмодели, ЛКМ → `PrimaryAttack` (автоматический огонь по
|
||
`Primary.Automatic` и `GetNextPrimaryFire`), `Think` каждый кадр, HUD с
|
||
`PrintName`/патронами. Осталось: `SecondaryAttack` на ПКМ, отдельный FOV/масштаб
|
||
вьюмодели, проигрывание `SendViewModelMatchingSequence`, урон/эффекты, `DrawHUD`
|
||
скриптов.
|
||
|
||
**Сделано (ветка `feat/viewmodel`):** встроенный SWEP `weapon_pistol` (Slot 1,
|
||
SlotPos 1, 18 патронов, одиночный огонь) — в `list.Get("Weapon")` и слотах,
|
||
пользовательский `lua/weapons/weapon_pistol.lua` важнее встроенного. Посадка
|
||
вьюмодели стала таблицей по модели (`w_pistol`: yaw −90°, 20 юнитов;
|
||
`v_pistol`: yaw 0°, 34, scale 0.8; `c_smg1`: yaw 0°, 26) с поддержкой масштаба
|
||
в движке; `ViewModelFOV` и необязательные `ViewModelOffset`/`Angles`/`Scale`
|
||
из SWEP перекрывают таблицу. Отдельного прохода с FOV вьюмодели пока нет —
|
||
компенсация масштабом; у `v_pistol` остаются оторванные руки, поэтому
|
||
встроенный пистолет использует `w_pistol`.
|
||
|
||
**Сделано (ветка `feat/sandbox`):** физган как отдельный режим `F` (ЛКМ —
|
||
захват и плавное удержание скоростью, колесо — доворот, ПКМ — freeze),
|
||
дальность 250 и предел массы 500, луч/крест в отладочном слое, жёлтый маркер
|
||
замороженного пропа. Undo-стек на 32 действия (`Z`/`Shift+Z`) откатывает
|
||
спавн (тело/сущность удаляются, меш уводится под карту), перемещение и
|
||
заморозку. Проверки: `SOURCE_RS_TEST_PHYSGUN`, `SOURCE_RS_TEST_UNDO`,
|
||
`SOURCE_RS_TEST_REDO`.
|
||
|
||
### A24. Q-меню спавна (spawnmenu)
|
||
Меню спавна из `sandbox/gamemode/spawnmenu` (Lua + VGUI). У нас VGUI нет (P10),
|
||
поэтому путь такой: сначала **консольные эквиваленты** (`gm_spawn`, `gm_give`,
|
||
`gmod_tool`-режимы) на тех же данных из Lua (`list.Get('SpawnableEntities')` и
|
||
т. д.), потом — быстрый UI-слой на Rust (как в P10: нативные панели, Derma —
|
||
совместимый слой сверху).
|
||
|
||
**Приёмка:** списки спавна читаются из Lua-данных аддонов; спавн пропа по
|
||
выбору из списка; позже то же самое в окне Q.
|
||
|
||
### A25. Ускоренный порядок под Sandbox-UX
|
||
По просьбе пользователя (спавн из Q, модель игрока, физган, оружие) очередь
|
||
переставлена: сначала путь до «играбельного Sandbox», потом добор визуальной
|
||
точности.
|
||
|
||
1. A23 оружие (каркас SWEP + вьюмодель + аттачменты) — сюда же A20 (модель
|
||
игрока и вьюмодель — один и тот же рендер моделей).
|
||
2. A21 физика взаимодействия (гравиган/физган: луч, удержание, вращение,
|
||
заморозка) — без полной гибернации, она позже.
|
||
3. A24 спавн: сначала консольные команды на Lua-данных, затем окно Q.
|
||
4. A22 Sandbox целиком (undo/cleanup/duplicator) — идёт следом.
|
||
5. Визуальная точность (A10 свет, A11–A12 вода, A16 трава/декали, A18 скайбокс)
|
||
— продолжается параллельно, но не блокирует Sandbox.
|
||
|
||
### A26. Анимации моделей (M5) — план и первые шаги
|
||
|
||
Пользователь: «по возможности делай анимации». Речь про студийные модели: игрок,
|
||
оружие, пропы (сейчас всё рисуется в bind-позе).
|
||
|
||
**Что уже есть в `formats`:** `mdl.bones()` — скелет (родители, позиции,
|
||
кватернионы), `mdl.attachments()`, `mdl.hitbox_sets()`, `mdl.bodyparts()`,
|
||
`vvd.vertices(lod)` и `vvd.fixups()` — веса и кости по вершинам,
|
||
`vtx.body_parts()` — топология и кости по вершинам. То есть **данные скелета и
|
||
весов читаются**, не хватает их переноса в рендер и разбора анимаций.
|
||
|
||
**Чего нет:**
|
||
1. секвенций (`mstudioseqdesc_t`) — их надо разобрать в `mdl.rs`;
|
||
2. самих кадров анимации: у HL2-моделей они лежат в отдельном `.ani` рядом с
|
||
`.mdl` (`mstudioanimdesc_t` + `mstudioanimvalue_t`, сжатие по кривой/дельтами);
|
||
3. скиннинга: `ModelVertex` (pos/normal/uv) не несёт костей и весов — нужно
|
||
добавить (в Source `mstudiovertex_t`: до 3 весов + число костей);
|
||
4. покадрового пересчёта вершин (CPU-скиннинг) и заливки на GPU — механизм
|
||
заливки уже готов (`moved_ranges` из E25/A21, тот же путь, что у движущихся
|
||
пропов);
|
||
5. выбора секвенции по состоянию (как `ACT_MP_*`): стоять/идти/бежать/присед/
|
||
прыжок/плавать, с blend между ними.
|
||
|
||
**Сделано (шаг а, 2026-10-17).** `mdl.sequences()` — разбор
|
||
`mstudioseqdesc_t`. Размер записи версионно-зависим (184…208 байт), поэтому он
|
||
**определяется по данным**: берётся кандидат, у которого у всех секвенций имя
|
||
читается непустой печатной строкой. `Model` движка теперь несёт `bones` и
|
||
`sequences`.
|
||
|
||
**Замеры на живых моделях:** пропы карты — `bones=1, sequences=1, «idle»`;
|
||
модель игрока `models/player/police.mdl` — **`bones=57`**, но `sequences=1`
|
||
(«ragdoll»). Это не ошибка разбора: у игроков активности лежат во **включённых
|
||
моделях** (`police.mdl` включает общую модель игрока), и их надо сливать по
|
||
`mdl.include_models()`.
|
||
|
||
**Сделано (шаг б).** `Model` несёт `skins: Vec<VertexSkin>` — привязку вершин
|
||
к костям из VVD (`bones[3]`, `weights[3]`, `num_bones`), по одной на вершину.
|
||
Замер на живых моделях: `companion_doll` 1471/1471, стол 136/136, диван 556/556 —
|
||
привязка есть у всех вершин; у пропов при этом один «корень» (bones=1).
|
||
|
||
**Сделано (шаг в).** CPU-скиннинг: `PropMesh` несёт скелет и привязку вершин,
|
||
`World::skin_model_instance` пересчитывает вершины по позе костей (угол кости
|
||
складывается с родительскими, вершина смешивается по весам) и помечает диапазон
|
||
для заливки на GPU — тем же `moved_ranges`, что у движущихся пропов.
|
||
|
||
**Проба видна кадром:** вьюмодель (пистолет) качает одну кость по синусу —
|
||
геометрия деформируется на экране, то есть цепочка «скелет → веса → вершины →
|
||
GPU» работает целиком. Пока это не анимация: настоящие кадры из `.ani`
|
||
следующим шагом.
|
||
|
||
**Сделано (слияние включённых моделей).** Разбор `include_models()` и лёгкая
|
||
загрузка включённой модели (без геометрии и текстур: `Mdl::parse` + секвенции).
|
||
Замеры: `police.mdl` включает `models/m_anm.mdl` — библиотеку анимаций GMod;
|
||
у неё `numlocalseq=464`, `numlocalanim=1563`, 56 костей. После слияния у модели
|
||
игрока **465 секвенций** (была одна «ragdoll»).
|
||
|
||
Важная находка: у «единичных» (unified) моделей GMod запись секвенции длиннее
|
||
176…208 байт, поэтому перебор шага расширен до 176…320 с обязательной проверкой
|
||
имён ВСЕХ секвенций — иначе разбор молча возвращал ноль.
|
||
|
||
**Сделано (кадры из `.ani`, подготовка).** `mdl.animations()` — описания
|
||
анимаций (`mstudioanimdesc_t`), как и секвенции, с определением размера записи по
|
||
данным; `anim_block_names()` — имена анимационных файлов; `anim_blocks()` —
|
||
границы блоков внутри `.ani`.
|
||
|
||
**Замеры:** у `m_anm.mdl` — **1563 анимации** (`@reference` 101 кадр,
|
||
`@idle_all_01` 280, `@idle_all_02` 151, все 30 fps), 247 блоков, и **все** с
|
||
`anim_block > 0`, то есть кадры не в MDL, а в отдельном файле. Файл
|
||
`models/m_anm.ani` прочитан целиком: **7,1 МБ**. Значит декодер кадров будет
|
||
брать данные оттуда (`mstudioanimblock_t.datastart + animdesc.anim_index`).
|
||
|
||
**Сделано (декодер кадров, проверен на живых данных).** Спека взята из
|
||
`studio.h` (SDK 2013): `mstudioanim_t` — **4 байта** (`bone`, `flags`,
|
||
`nextoffset`), дорожки связаны списком до `nextoffset == 0`; `valueptr` — три
|
||
смещения каналов; значения `mstudioanimvalue_t` идут дельтами с накоплением
|
||
(`valid`, `total`); есть флаги RAWROT (`Quaternion48`), ANIMROT/ANIMPOS
|
||
(дорожки), RAWROT2.
|
||
|
||
`Mdl::animation_tracks` и `Mdl::decode_track` написаны и прогнаны на настоящей
|
||
библиотеке GMod: анимация `@idle_all_01` — **280 кадров, 51 дорожка**, значения
|
||
вида 17013, 1424, −874 (шорты, которые делятся на масштаб кости при сборке
|
||
кватерниона).
|
||
|
||
**Сделано: проигрывание кадров работает.** Спека взята из настоящего
|
||
`bone_setup.cpp` (Mapbase-зеркало SDK 2013, `mp/src/public/bone_setup.cpp`) —
|
||
предыдущие догадки были неверны, и это стоит помнить:
|
||
|
||
- каналы дорожек — **не компоненты кватерниона**, а **углы Эйлера**: значение
|
||
кадра `raw · rotscale[j] + поза покоя (bone.rot)`, затем `AngleQuaternion`;
|
||
- позиции — `raw · posscale[j] + bone.pos`;
|
||
- значения блоков **не накапливаются дельтами**: `ExtractAnimValue` идёт по
|
||
блокам `valid+1` записей и внутри блока берёт `value[k]`, а дальше повторяет
|
||
последнее (`value[valid]`);
|
||
- `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки — список;
|
||
- флаг `STUDIO_ANIM_DELTA` (0x10) означает «каналы относительные»: поза покоя
|
||
не прибавляется.
|
||
|
||
Замеры на живых данных (тесты `real_player_animation_channel_scales`,
|
||
`real_models_animation_sources`, `real_player_model_animation_moves_vertices`):
|
||
|
||
- `@taunt_muscle_base` (451 кадр, 53 дорожки): углы ≤ 4,75 рад, сдвиги позиций
|
||
≤ 28,5 юнитов, кватернионы единичные — при новой спеке **ни одного нарушения**;
|
||
- кости локальны родителю (цепочка 4,0 / 3,5 / 8,9 юнитов), поза покоя корня —
|
||
таз на высоте 38,6;
|
||
- `police.mdl` — 57 костей, своя одна анимация `@ragdoll` (кадры в MDL,
|
||
`anim_block == 0`); `m_anm.mdl` — 56 костей, 1563 анимации (кадры в
|
||
`models/m_anm.ani`, 7,1 МБ, 247 блоков); у пистолета — 3 свои анимации в MDL;
|
||
- модель игрока, анимация `idle_all_01`: из 3147 вершин **1101 сдвинулась**
|
||
между кадрами 0 и 30, высота тела (5–95%) 61 юнит и в позе покоя, и в анимации
|
||
— модель не схлопывается.
|
||
|
||
В движке: `AnimData` (буферы кадров + описания + скелет, живёт под `Arc`),
|
||
`frame_pose` (позы костей по кадрам с блендом), `play_model_animation`
|
||
(мировые позы через родителей, `A · B⁻¹` по костям, скиннинг теми же весами),
|
||
кости меша находятся в скелете анимации **по именам**.
|
||
|
||
В клиенте: вьюмодель играет `idle` (свои кадры), модель игрока — `idle_all_01`;
|
||
добавлен вид со стороны `--third-person` — без него собственную модель не видно
|
||
(в первом лице камера внутри неё, и она уезжает под карту).
|
||
|
||
**Сделано (постоянные дорожки RAW, 2026-10-23).** Разобраны все три вида
|
||
постоянных значений `mstudioanim_t` — код взят по спеке `compressed_vector.h`:
|
||
|
||
- `STUDIO_ANIM_RAWROT` (0x02) — `Quaternion48`: 6 байт, x/y по 16 бит со
|
||
смещением 32768 и шагом 1/32768, z — 15 бит со смещением 16384 (1/16384),
|
||
старший бит — знак `w`; `w` восстанавливается из единичной длины;
|
||
- `STUDIO_ANIM_RAWROT2` (0x20) — `Quaternion64`: 8 байт, x/y/z по 21 биту
|
||
(`(raw − 1048576) / 1048576.5`), старший бит — знак `w`;
|
||
- `STUDIO_ANIM_RAWPOS` (0x01) — `Vector48`: три half-float; `pPos` идёт за
|
||
Quaternion48/64 (смещение учитывает оба флага).
|
||
|
||
`AnimTrack` теперь несёт готовые `raw_rot`/`raw_pos`, а `frame_pose` отдаёт им
|
||
приоритет над дорожками и позой покоя (как `CalcBoneQuaternion` /
|
||
`CalcBonePosition`). Замеры на живых данных (`real_animation_raw_flags_decode`):
|
||
`v_pistol @idle01` — 10 дорожек, 7 RAWROT и 2 RAWPOS, у 7 повороты отличаются
|
||
от позы покоя; `m_anm @taunt_muscle_base` — 53 дорожки, **24 RAWROT2**, все
|
||
кватернионы единичные, неизвестных флагов нет. Анимация `@taunt_muscle_base`
|
||
теперь двигает 3145 вершин из 3147 между кадрами 0 и 30 (тест
|
||
`real_player_taunt_moves_vertices_with_raw_tracks`), вылет от центра 63,7
|
||
юнита — скелет не разваливается.
|
||
|
||
**Сделано (выбор анимации по состоянию и вьюмодель, 2026-10-23).**
|
||
`engine::animation` — чистая функция выбора и состояние фазы:
|
||
|
||
- стоя — `idle_all_01`; шаг — `a_Walk<dir>`; бег — `a_Run<dir>` (флаг `sprint`
|
||
или скорость ≥ 320); присед — `@cidle_all` / `a_Cwalk<dir>`; в воздухе —
|
||
первая существующая из `@jump_fist`, `@jump_pistol`, `a_gmod_jump_delta`, иначе
|
||
остаётся текущая;
|
||
- восемь направлений (N/NE/E/SE/S/SW/W/NW) считаются по горизонтальной
|
||
скорости относительно курса взгляда: стрейф вправо даёт `E`, влево — `W`;
|
||
- walk ↔ run и смена направления сохраняют фазу, смена типа (стоя/присед/
|
||
воздух) сбрасывает время; если анимации нет в модели, остаётся текущая.
|
||
|
||
Клиент (`crates/client/src/map.rs`) держит `AnimationState` и играет выбранное
|
||
имя. Вьюмодель получила покачивание при ходьбе (амплитуда от скорости) и
|
||
инерционный сдвиг при повороте; ЛКМ без тела под прицелом проигрывает
|
||
одноразовую `fire`/`attack_player`, если она есть у модели. Привязка вьюмодели
|
||
идёт к центру **анимированной** позы: у `v_pistol` `RAWPOS` корня сдвигает меш
|
||
на 68 юнитов, и без поправки ствол уезжает из кадра. Проверка выбора —
|
||
юнит-тесты `engine::animation`; кадры клиента — в отчёте задачи.
|
||
|
||
**Остаток по A26:** блендинг между секвенциями (сейчас переключение жёсткое),
|
||
направление взгляда/корпуса отдельно от движения, оружие в руках влияет на
|
||
`@jump_*`/`@idle_*` (сейчас кулаки/общие), блендинг вьюмодели между
|
||
состояниями.
|
||
|
||
**Замер по масштабу кости (историческая заметка).** У кватерниона модуль
|
||
вектора поворота не больше единицы у **всех** костей и кадров — это и есть
|
||
критерий выбора. Сырые значения `@idle_all_01`: у кости 0 — `[43, 1424, 17013]`
|
||
при `rot_scale = [6,37e−5, 4,66e−5, 9,59e−5]`.
|
||
|
||
- умножение на `rot_scale`: `17013 · 9,59e−5 = 1,63` — **больше единицы**, для
|
||
кватерниона невозможно, значит это не тот путь;
|
||
- деление на 32768: `17013 / 32768 = 0,52` — годится.
|
||
|
||
Дальше нужно решить, чем именно нормируется канал: похоже, `rot_scale` в MDL
|
||
задаёт **сжатие** (велик обратный, ≈1,5e4…2,1e4 по осям), а не множитель. Это
|
||
первая задача следующего захода, вместе с позами костей по родителям.
|
||
|
||
**Дальше:** собрать кватернион с правильной нормировкой и `w = √(1 − x² − y² − z²)`,
|
||
позы костей с родителями, интерполяция между кадрами, выбор секвенции по
|
||
состоянию.
|
||
|
||
**Порядок работ:** (а) секвенции, включённые модели и чтение `.ani` сделаны;
|
||
(б) привязка вершин сделана; (в) CPU-скиннинг сделан; (г) декодер кадров и
|
||
проигрывание секвенции с циклом; (д) выбор по состоянию и блендинг.
|
||
|
||
**Приёмка:** модель игрока/оружия двигается в кадре, ходьба переходит в бег и
|
||
присед по скорости, прыжок показывается отдельной секвенцией.
|