gmod-rs/docs/PARITY.md
Niko Marmeladkov e91a60d40d Освещение: нормали граней были перевёрнуты, карта не получала света
Симптом: «на видео нету освещения вообще». Предыдущий замер показывал
среднюю лайтмапу 0,22 и делал вывод, что темно «от данных карты». Вывод был
неверен.

Замер ориентаций вершин gm_construct вскрыл причину: строго вверх — 17
вершин, строго вниз — 18 096. У настоящей карты пола смотрят вверх.

Причина: face_normal брала нормаль плоскости с обратным знаком
(`if side == 0 { -normal }`). В VBSP нормаль плоскости грани уже смотрит
наружу от твёрдого тела. Инверсия попала в код при попытке починить
намотку, но намотку чинит отдельная face_winding — она сравнивает
геометрическую нормаль треугольника с нормалью грани и переворачивает обход
сама.

Следствие: солнце и запечённая лайтмапа попадали на изнанку всех
поверхностей. Пол не получал ни того, ни другого.

Стало: вверх 15 195, вниз 2 918. Тест real_map_normals_are_not_inverted
не даёт дефекту вернуться.

Заодно второй настоящий баг в том же месте: в sample_light_luxel ширина
строки блока лайтмапы считалась как extent[0], а блок — это
(extent[0]+1) × (extent[1]+1) люкселей. Каждая строка читалась на люксель
раньше, и к низу блока чтение уезжало на десятки люкселей в сторону.

Долго висевшее «белое пятно» оказалось не дефектом: это комната Construct,
материал GM_CONSTRUCT/COLOR_ROOM с VMT UnlitGeneric и базовой текстурой
color/white — белая по замыслу и неосвещаемая. Спавн игрока внутри неё.

Рябь на воде подобрана замером: частота 0.035 давала плиту, 0.35 —
aliasing на горизонте, остановились на 0.12 и 0.19; третью полосу убрали —
её наклон давал клинья поперёк озера.
2026-10-14 12:15:15 +03:00

111 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.


Часть 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) — при линейном рябь не гасла до горизонта и давала череду полос.