gmod-rs/docs/PARITY.md
Niko Marmeladkov 1f42bb9628
Some checks are pending
CI / checks (push) Waiting to run
Слияние feat/hdr: попиксельная лайтмапа, HDR, мягкие тени
Из ветки feat/hdr (dbecb3c, 1a34203, 74db5f5, dec8b67):
- свет по полигонам: LUMP_LIGHTING раскладывается в атлас Rgba16Float
  (полочная упаковка, рамка 1 тексель), у вершины появился lightmap_uv,
  шейдер мира читает атлас на каждый пиксель с билинейной фильтрацией.
  На gm_construct: 6891 грань, 751 718 люкселей, атлас 2048² (32 МиБ).
  Фолбэк на старый вершинный свет, если атлас не собрался;
- HDR: мир/небо/вода в Rgba16Float, композит экспозиция → ACES → bloom
  (порог в ½ разрешения, два прохода, аддитивно), UI отдельно в sRGB;
- тени: PCF 9 отсчётов (центр + два кольца) с поворотом по мировой позиции
  и нормальным смещением, без acne;
- переключатели SOURCE_RS_NO_HDR/EXPOSURE/BLOOM/BLOOM_THRESHOLD/
  DEBUG_SHADOW/NO_SHADOW/NO_VSYNC/LOG_FPS;
- замер A/B: до правок ~24,5 fps, с HDR ~23,7, без HDR ~24,4 — разница в
  пределах разброса; узкое место — ~2661 draw call на проход, не HDR;
- тесты lightmap_atlas_maps_face_block и half_conversion_known_values.

Конфликт был только в docs/DIARY.md (взята секция из ветки).
Проверено: 25 сьютов, 29 картовых тестов, 15 world_real — зелёные; кадр
туннеля показывает плавный градиент вместо ступеней по полигонам.
2026-10-23 14:30:13 +03:00

233 KiB
Raw Blame History

План 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.*.

Часть 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), оказались одной ошибкой:

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 устроен принципиально иначе, чем наш подшаг:

// 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), а не «если нет ввода». Формула оригинала:

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. Гравитация делится пополам до и после движения

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-алгоритм, а не линейная интерполяция

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 отличается одной строкой — ограничением желаемой скорости:

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 — аддитивную:

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-скайбокса:

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(). Обе берут ровно половину гравитации:

// 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 попоосно

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 дают разные вещи:

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 нормаль плоскости бралась с обратным знаком:

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:

*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_*). Замер:

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):

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):

	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 спавнить проп; оружие переключается по слотам; вьюмодель видна и двигается.

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-скиннинг сделан; (г) декодер кадров и проигрывание секвенции с циклом; (д) выбор по состоянию и блендинг.

Приёмка: модель игрока/оружия двигается в кадре, ходьба переходит в бег и присед по скорости, прыжок показывается отдельной секвенцией.