Четыре жалобы из игры, все закрыты измерением. Константы движения (gmodwiki.com/ConVars_In_Garrysmod, не source-sdk-2013): sv_friction 4 -> 8 торможение 250 -> 10 за 0,40 с, путь 26 юнитов sv_stopspeed 100 -> 10 sv_gravity 800 -> 600 прыжок 45,6 -> 60,8 юнита, как в GMod sv_airaccelerate 12 -> 10 sv_maxvelocity 350 -> 3500 падение 585/589/590 -> 600/1200/1800/2400/3000 «В воздухе управление слабее»: в AirAccelerate кап sv_air_max_wishspeed ограничивает ЦЕЛЬ, а прибавка считается по некапленной wishspeed. Мы ограничивали и то и другое — прибавка была в 8,3 раза слабее. Замер после правки: рывок вбок даёт ровно 30,0 юнитов/с, как в Source. Поворот при полёте вперёд по-прежнему заблокирован (проекция 177 >> капа 30) — это верное поведение оригинала. «Высота шага терялась единица»: подъём ровно на sv_stepsime ставит низ габарита точно на грань ступени, грань считается касанием, и подшаг блокирует сам себя. Замерено: брались уступы до 17 и застревали на 18. В оригинале подъём на flStepSize + DIST_EPSILON. После правки — все до 18 включительно, 19 нет. «Невидимые стены, рампа как параллелипипед»: грань кирпича ограничивает плоскость, а не кирпич, и останавливает шаг где угодно на её уровне. Замерено: игрок останавливался за 3,4 юнита до настоящей преграды, блокировала верхняя грань соседнего кирпича. Правка: при t около нуля проверяется, что точка касания лежит на самом кирпиче. Замер после правки: преград «из воздуха» 0 (было 2), упоров за 30 с ходьбы 0. Проверено и отброшено измерением: кисти не работают как ящик (аудит 2126 кирпичей, 0 расхождений из 129684 точек); displacement не выпадает из сетки (11776 треугольников, выпало 0); скорость вдоль склона 239-263 против 250. Пара sv_falldistance/fall_speed НЕ введена осознанно: в таблице cvar'ов GMod её нет, а формула HL2 при отрицательной velocity[2] даёт замедление падения, а не разгон. Ставить непроверенные константы значило бы гадать. 228 тестов зелёные, clippy -D warnings чист.
86 KiB
План 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.
Исправление:
- Разбор
GAME_LUMP(запись лумпа 16 байт:fileofs, disksize, offset, fourCC), под-лумп 12 → список(текстура, scaleX, scaleY). - Проекция деталь-текстуры на мировые координаты по UV грани.
- Шейдер: дополнительные сэмплы со смешением,
$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 мебели) — все с
корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.
Кандидаты причины, по убыванию вероятности:
- Не найдена VTF-текстура для части поверхностей —
fallbackрисует белым. Особенно вероятно для displacement-поверхностей у воды: у них отдельныеtexinfo, которые не всегда есть в списке. - Не разрешён VMT — тогда материал падает в тот же белый fallback.
- Отдельная поверхность, которой полагается деталь-текстура, но базовая не загрузилась (связано с 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 их две ($abovewater1 и 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.
Решения, принятые пользователем
- Скриншот получен → A6 переформулирован: это не панели пропов, а нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0» отвергнута.
AngleVectors→ вопрос снят, работа делается как A8: сверка с эталонной формулой Source и фиксация unit-тестом, без опоры на память.- Приоритет → свет (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):
-
Четвёртая компонента
_lightозначает разные вещи. Уlightиlight_spotэто РАДИУС освещения (_light 255 255 255 300), а уlight_environment— множитель яркости солнца (_light 242 234 218 560). Если считать это яркостью везде, лампы дают вклад ×300 и карта выбивается в белый. -
Яркость нельзя домножать в цвет. Если умножить цвет на
scaleпри разборе и учестьscaleещё раз в шейдере, получается квадрат яркости. Цвет нормируется к единичному среднему, яркость хранится отдельным полемscale. -
Радиус 0 означал «светить во everywhere». Функция затухания при радиусе 0 возвращала 1.0 на любом расстоянии, и 22 прожектора складывались в сумму. Теперь у ламп без
_radiusподставляется 300. -
Лайтмапа уже содержит запечённое солнце. Это ключевой момент:
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. Четыре дефекта, найденных измерением
Все четыре нашлись не «правдоподобным декодированием», а замером на карте. Порядок важен: каждый следующий дефект был бы невидим, пока не закрыт предыдущий.
-
Рельеф раздувался в 15 277 коллайдеров. Сетка по
LAND_CELL = 64дублировала каждый перекрытый треугольник в каждую ячейку: 89 треугольников давали 15 277 коллайдеров. Заменено одним объединённым trimesh с дедупликацией вершин по кванту 1/16 юнита: 11 776 треугольников → 6 148 вершин, 1 коллайдер. Точнее (никакого огрубления на швах ячеек) и на порядки дешевле. -
Уровень gm_construct построен брашами, а не displacement'ами. Рельеф покрывает лишь часть карты; игрок ходит по 2 126 брашам. Без брашей все пропы падали насквозь.
PhysicsWorld::add_brushesвосстанавливает вершины браша перебором троек плоскостей (Крамер): точка, подходящая под все плоскости — вершина выпуклой оболочки. У осевого браша 6 плоскостей = 20 троек. Замер: 2121 построен, 5 пропущено (вырожденные). -
Broad-phase Rapier наполняется только на
step(). После вставки геометрии луч её не видел: «под пропом пола нет» при 2 121 браше в мире. Лечится явной регистрациейbroad_phase.set_aabb()при вставке статики. -
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. Что показали измерения, а не рассуждения
-
Констрейн не «стягивает» тела — и это правильно. Якорь в мировой точке по определению совпадает у обоих тел в момент создания, поэтому ограничение выполнено сразу и «включается» только когда тела расходятся. Первые пять тестов проверяли «расстояние между телами» и падали именно поэтому. Правильный инвариант — совпадение точек крепления под действием силы.
-
Сила в Rapier действует один шаг и сбрасывается после интегрирования:
add_forceнадо вызывать каждый кадр. Тест, подававший силу один раз, измерял не силу, а её отсутствие — и «падал» одинаково при 8000 и при 40 000. -
Спящее тело честно сообщает нулевую скорость. Проверять эффект силы по скорости бессмысленно, если тело уснуло на полу: корректный тест сравнивает прогон с силой и контрольный прогон без неё.
-
Ползун (
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. Две гипотезы, обе опровергнуты измерением
- «Пропов нет в коллизии игрока». Гипотеза была верной как диагноз,
но не как объяснение жалобы: игрок ходил по брашам. Пробел устранён
(
Collision::boxes), проп теперь стоит под ногами — это нужно в любом случае, но от проваливания по брашам не избавило. - «На низком 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. Ошибки в моих же правках, пойманные тестами
jump_latched = jump— защёлка вставала на первом же кадре, ещё до первого прыжка, и прыгнуть было нельзя вообще.jump_latched = jump && self.on_ground— флаг сбрасывался тем же кадром, когда прыжок уже отработал (on_groundсброшен выше), bhop возвращался.- Тест на рамку мерил первоначальное падение (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; }
Три отличия от нашего кода, каждое даёт симптом:
- Пробуются оба варианта, побеждает тот, что ушёл дальше по горизонтали. Мы всегда идём путём «поднять на 18 и уронить». На рампе вверх путь вниз (без подшага) уходит дальше — значит оригинал его и берёт, а подшаг не выполняется вовсе. У нас он выполняется всегда и режет путь.
- Вертикальная скорость берётся из движения ВНИЗ
(
m_vecVelocity.z = vecDownVel.z), а не из движения вверх. Иначе на рампе появляется паразитная вертикальная составляющая. - Отказ от подшага при
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. Порядок работы на будущее
StepMoveпо оригиналу: оба варианта, побеждает дальний,zиз вниз. Чинит и потерю скорости, и «прыжки с рампы».Friction()безусловно на земле.StartGravity/FinishGravityпополам.Accelerate/AirAccelerateквото-формулой сsv_air_max_wishspeed.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.