Model теперь несёт skins: Vec<VertexSkin> — привязку вершин к костям из VVD (до трёх костей с весами и их число), по одной записи на вершину. Индексы глобальные (после fixup VTX), считаться по ним будет матрица скиннинга вместе со скелетом Model::bones. Замер на живых моделях (лог «модель загружена»): companion_doll 1471 вершин / 1471 привязок, стол 136/136, диван 556/556 — привязка есть у всех вершин; у пропов при этом один корневой узел (bones=1). Модель игрока police.mdl — 57 костей. Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
219 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.
A9. Коллизия: невидимый параллелепипед вокруг рамп и углов
Симптом (пользователь): «я как будто стою вписанным в невидимый
параллелепипед, а не прямо на рампе»; «везде, где в угол/подъём можно вписать
прямоугольный треугольник», бывает и в зданиях. Точка-пример: -2438 -966 48.
Что уже измерено: сетка обхода real_map_sweep_for_invisible_walls
(x от −1500 до 600, y от −800 до 600) эту область не покрывает — поэтому
тест и был зелёным. Рядом рампа — кирпич #1891: 9 граней, габарит
(-2752,-1024,-156) .. (-2304,-896,48), то есть 448×128×204 с наклонной
плоскостью.
Гипотеза (проверяется замером probe_ramp_movement): Collision::penetration
(разделяющие оси: три мировые + нормали граней) берёт интервал кирпича по
мировым осям из габарита, а габарит наклонного кирпича шире самого кирпича.
В пустых углах около рамп получается ложное перекрытие, и resolve_overlap
выталкивает игрока из объёма, которого нет.
Что уже замерено (2026-10-16):
probe_ramp_movementв точке-2438 -966 48: залипание при движении в −Y и по диагоналям объясняется настоящей кирпичной стеной — кирпич #829 стоит поy = -1024, текстуры бетон/кирпич, стена видна на кадре из этой точки. Фантомного выталкивания в этой точке нет.probe_phantom_walls_scan— обход всей карты (11200 точек по габаритам, 10824 свободных) с шагом кадра 8 юнитов: кирпичей, блокирующих шаг издалека, ноль. Ложных «тело внутри, хотя по плоскостям снаружи» — тоже ноль.probe_collision_contents— состав коллизии: 2118 кирпичей из 2203 сырых; исключены 7func_vehicleclip(в Source режут только машины), триггеры и прочие служебные; в коллизии не осталось ниTOOLSTRIGGER, ни клипов.- Причина, по которой старый обход молчал: его сетка покрывала только центр
(
x−1500..600,y−800..600), то есть запад и юг карты не проверялись.
Что осталось: нужен точный адрес «стены на стыке комнат» — на рампе такой
стены нет. Отладочный слой (SOURCE_RS_DEBUG_COLLISION=1) покажет это прямо
на экране: если у мнимой стены нет ни красных рёбер, ни жёлтого габарита —
она не из кирпичей вовсе, и это отдельный механизм.
Исправлено (E21). Адрес нашёлся — -2385 -2032 256: терраса, где игрок
стоял вровень с верхом соседней плиты (кирпич #269, 16 юнитов толщиной) и не
мог шагнуть в −Y. Причина не в наборе кирпичей, а в отсечении: боковая грань
плиты блокировала по касанию ребром — тело (z 256..328) грань (z 240..256)
не перекрывало. Правка и замеры — в E21.
Приёмка: игрок в пустом углу рядом с рампой не сдвигается; обход карты с
сеткой, покрывающей запад (x до −3100), без залипаний.
A10. Рендер: перелом света по полигонам
Симптом (пользователь): «на одном полигоне светло, на другом темнее,
перелом света идёт, которого в оригинальной игре в этом же месте нету»;
пример — pos -2039 -1408 -110, пол тоннеля.
Причина: лайтмапа берётся в вершинах и линейно интерполируется по треугольнику, поэтому граница света идёт по диагонали полигона. У больших граней (пол тоннеля, бетонная площадка) это видно отчётливо.
Решение: сэмплить лайтмапу на пиксель — передавать в вершинном атрибуте UV блока лайтмапы и читать её из текстуры, как Source. Заодно это уравняет шаг люкселя на всех дистанциях.
A11. Вода: слева по Y прозрачная, справа — плоская голубая
Симптом (пользователь, pos -693 4264 1547): одна половина поверхности
видна насквозь (с волнами и зеленью дна), вторая — однотонная голубая заливка
без просвета. Граница — изломанная линия посередине.
Что проверять: не рисуется ли поверхность дважды разными путями (шейдер воды и обычная грань браша) и не смешивается ли она сама с собой; плюс две поверхности «верх/изнанка» из A7.
A12. Вода: волны мелкие и одинаковые по оси Y, нет динамической текстуры
Симптом (пользователь): «волны слишком микроскопические, чтобы их заметить», «все также по оси y одинаковы»; нужна динамическая текстура воды, а не «голубая полупрозрачная штука».
Решение: волны с разными направлениями и длинами (не одна дорожка), нормали
из текстуры воды ($normalmap) с двумя UV-слоями, смещающимися в разные
стороны, затем преломление и поглощение по глубине (A7).
A13. Нет тумана
Симптом (пользователь): туман нужно реализовать — его нет вообще, ни
дальнего, ни от env_fog_controller, ни поглощения в воде.
Решение: дальний туман по fog из мира/сущности, $fogcolor для воды,
учесть в шейдерах мира и неба; значения брать из карты, а не выдумывать.
A14. Присед: скорость не меняется
Симптом (пользователь): «физика приседания неправильная — скорость вообще не меняется».
Решение: переносить по спецификации CGameMovement (CheckParameters,
m_flMaxspeed, флаги FL_DUCKING/m_Local.m_bDucked, ограничение вставания
под низким потолком), но не вслепую: каждое число (скорость в приседе,
высота глаз, скорость приседа/вставания) проверять замером на нашей геометрии.
A15. Полноценные пропы и модели (наполнение карты)
Симптом (пользователь): «пропы полноценные, полные все элементы карты».
Что уже есть (замер на gm_construct): у проп-сущностей (prop_static,
prop_dynamic, prop_physics, item_*) читаются MDL, строятся статические
меши PropMesh — 9 экземпляров, они рисуются, участвуют в физике и в коллизии
игрока (collision.set_boxes).
Чего нет: скелета и скиннинга, анимаций, $bodygroup/$texturegroup, LOD
моделей, ragdoll-физики, флексов и глаз. То есть «полноценная модель» в смысле
этапа M5 плана: VVD/VTX, blend-граф, GPU-скиннинг, animation LOD,
материалы моделей.
A16. Наполнение карты: трава, деревья, декали, оверлеи
- Detail-спрайты (
$detailв VMT,detailspritesкарты): трава на травяных текстурах не рисуется вовсе — отсюда «плоская трава». - Смешанные материалы (
WorldVertexTransition,$basetexture2,$blendmodulatetexture): берётся только первая текстура, поэтому переход травы в песок и асфальт пропадает. - Трава и деревья — обычно
prop_static-модели: появятся вместе с A15; отдельно у деревьев есть$treeSway(качание). - Декали и оверлеи (
infodecal,info_overlay,decal-лумпы) не рисуются вовсе: отметины, надписи и метки на карте пропадают.
A17. Сущности-механики карты
Телепорты (trigger_teleport + info_teleport_destination), лестницы
(func_ladder), двери и кнопки (func_door, func_button,
func_movelinear, func_rotating), звуки (ambient_generic), источники света
(light, light_spot, env_projectedtexture), env_fog_controller (см. A13),
func_brush с переключением.
Решение: каркас энтити уже есть; добавлять по классам, начиная с тех, что влияют на игру (телепорты, лестницы, двери/кнопки), затем свет и звук. Каждый класс — с проверкой поведения по спецификации и замером на карте.
Порядок работ (ускоренный под Sandbox-UX, см. A25): A23 оружие и вьюмодель → A20 модель игрока → A21 физика взаимодействия (физган) → A24 спавн (Q-меню) → A22 Sandbox целиком; параллельно A19 (остаток по склону), A10 (свет), A11–A12 (вода), A16–A18 (наполнение карты).
A18. 3D-скайбокс: полный
Симптом (пользователь): «сделать ещё полноценный 3D-скайбокс».
Что уже есть (замеры E6, E8, E11.5, E18): sky_camera находится (origin и
scale), геометрия области скайбокса собирается в отдельный меш sky_mesh и
рисуется своим проходом (в него уходят 21 displacement-грань и браши
скайбокса); есть переключатель SOURCE_RS_NO_SKY_MESH; за границами карты
скайбокс гасится.
Чего нет: в кадре на месте города (камера E11, -399.5 -1604.5 67.9) у нас
сплошной травяной склон — город не рисуется (E11.5: «рельеф выше 1000 — 0
треугольников», то есть закрывающая плита не рельеф и причина не найдена).
В скайбоксе живут не только браши: город — это prop_static-модели и
func_*-сущности, а их спавн для области скайбокса не делается вовсе.
Решение: довести проход скайбокса до полного: геометрия + модели + декали
области скайбокса, преобразование по sky_camera (origin и scale 1/16),
корректная глубина и порядок отрисовки, отсечение по границам, и сверка кадра
с эталоном GMod на камере E11 (docs/PARITY.md).
A19. Рампа: скорость ниже и сползание на остановке
Симптом (пользователь): «по рампе взбираюсь, останавливаюсь — чуть-чуть назад проскальзываю; и сама скорость ниже, а в оригинале на рампе вроде не замедляет».
Что говорит спека (gamemovement.cpp, WalkMove): на земле вертикальная
скорость обнуляется до и после ускорения (mv->m_vecVelocity[2] = 0; Accelerate(...); mv->m_vecVelocity[2] = 0;), движение идёт строго по
горизонтали (у dest та же высота), склон догоняется StepMove и
StayOnGround(). Значит замедления на рампе у движка нет вовсе, а сползания —
тоже: вертикальной составляющей, которую склон мог бы «отразить», просто нет.
Что у нас: всё это уже есть (velocity.z = 0 на земле, трение, гравитация
только в воздухе, step_move, stay_on_ground). Замеренный остаток —
197,8 юнита/с на рампе #1480 (15,2°) против 250 на ровном полу и сползание на
доли юнита — даёт догон склона: stay_on_ground щупает вниз шагами по
1 юниту через move_aabb, а тот заканчивается выталкиванием, и нормаль склона
смотрит вниз-вбок (у склона 15° — n = (0,394, 0, 0,919)), то есть каждое
выталкивание сдвигает тело вниз по склону.
Решение: заменить щупание на честную трассировку вниз без выталкивания —
как в StayOnGround (TracePlayerBBox вниз на m_flStepSize, перенос позиции
в trace.endpos, и никакого разрешения проникновений).
Сделано (2026-10-16). Collision::ground_trace — чистый отсев вниз одним
отрезком, без выталкивания; stay_on_ground переносит позицию по нему и
требует нормаль опоры не круче sv_walkable_normal (0,7). Замеры на карте
после правки:
| рампа | угол | сторона +1 | сторона −1 |
|---|---|---|---|
| #1476 | 8,2° | 242,4 | 523,0 |
| #1477 | 9,0° | 541,5 | 241,9 |
| #1478 | 9,6° | 250,1 | 250,1 |
| #1479 | 17,1° | 263,6 | 245,7 |
| #1480 | 15,2° | 255,9 | 200,1 |
| #1481 | 6,6° | 301,0 | 239,8 |
| #1482 | 16,1° | 250,1 | 250,1 |
Ровный пол — 250. «Медленных» по порогу теста (75%) не осталось ни одной, застреваний при подъёме — ноль. Остаток у #1480 (80%) — съезд по краю кирпича в изоляции, а не штраф за склон: рядом стоящие рампы дают 96–100%.
Приёмка (осталось): тот же замер даёт сползание назад на остановке; его надо мерить отдельно (пройти вперёд, отпустить ввод, замерить смещение) и держать ниже юнита.
Часть B. Фундамент: система энтити
Без неё не появляется ни свет, ни Lua над энтити, ни физика. Свет — это тоже
энтити (light_environment, light, light_spot).
B1. Ядро (crates/engine/src/entity.rs)
EntityIndex(u16)— слот +generationдля безопасных «умерших» указателей;Entity— classname, keyvalues, transform (origin/angles/local-origin), флаги (solid,movetype,model,render/rendergroup);EntityWorld— пул,spawn(kv),remove, итерация по classname, отложенное удаление (как в Source, чтобы не инвалидировать ссылки в кадре);- лимиты из файлов: максимум энтити, максимум ключей, максимум длины значения
(правило #1 AGENTS.md — никаких
unwrapна данных из файлов).
B2. Спавн из карты: build_world перестаёт вручную порождать модели — он
скармливает лумп ENTITIES в EntityWorld, а рендер и коллизия читают из неё.
Это убирает белые панели как класс проблем и даёт всем остальным этапам единый
источник истины.
B3. Коллизия по энтити: браши func_*/trigger_*/info_null с корректной
твёрдостью; AABB/OBB для моделей; trigger-зоны отдельным слоем.
B4. Отладка: ent_list / ent_info <classname> — иначе каждый следующий
дефект снова придётся ловить на глаз.
Приёмка: ent_list показывает все энтити gm_construct с корректными
координатами; коллизия func_* работает.
Часть C. Освещение (включая солнце)
Сейчас в шейдере один попиксельно интерполированный light: vec3 из вершин —
меньше, чем в Source даже на простых картах.
C1. Свет из карты: light, light_spot, light_dynamic (в gm_construct:
108 light + 28 light_spot), light_environment — это и есть солнце
(направление, цвет, интенсивность, флаг «небо»).
C2. Модель освещения в шейдере: динамический свет (солнце + до N источников в радиусе) поверх статического; источники ограничить по экрану и расстоянию.
C3. Sun-тени: каскадные shadow maps (в Source 3–4 каскада). Начать с одного каскада 2048².
C4. Lightmap: лумп LIGHTING/LIGHTING_HDR (12 МБ в gm_construct) —
попиксельно вместо вершинного; даёт качественный свет в помещениях.
C5. Материал: Phong-подобная модель — diffuse + specular + fresnel;
$phong/$specular/$color2 из VMT; самозатенение от карты нормалей.
C6. Небо и env_*: env_sky, env_fog, env_sun, env_tonemap,
env_cubemap, env_skybox.
Приёмка: на gm_construct видны солнечные тени от построек, помещения освещены не плоско, вода отражает окружение, есть спекуляр на металле.
Часть D. Lua поверх энтити
Текущий lua (1278 строк) — print, hook.*, timer.Simple,
concommand.*, заглушки. Ни типов, ни энтити.
D1. Типы: Vector, Angle, Color как userdata с метаметодами GMod
(арифметика, Angle:Forward(), Vector:Distance, :Angle).
D2. Entity(index) и методы: GetPos/SetPos, GetAngles/SetAngles,
GetClass/GetModel/SetModel, Remove, IsValid, PhysicsInit*.
D3. ents.*: Create, FindByClass, FindInSphere, GetAll, GetCount.
D4. Сетевые поля: SetNW*/GetNW* (в одиночке локальные, но с тем же API,
чтобы потом не переписывать аддоны).
D5. Библиотеки GMod: file.* (с песочницей по AGENTS.md), surface.*,
sound.*, render.*, cam.*, Curves, system.*, scrconcmd,
LocalPlayer. http — только после песочницы.
Приёмка: скрипт из гмода выполняется; список функций совместимости
(набранный из настоящих lua/includes/modules/) проходит.
Часть E. Одиночная игра как в GMod
E1. Player: ходьба по ступеням (step-up ~18 units), лестницы, приседание с
плавным переходом, плавание (Start Swim), ноклип, падение с уроном.
E2. game.dll на Lua: загрузка gamemodes/sandbox/gamemode/init.lua —
списки ents, weapons, NPCs, хуки Initialize, Think, PlayerSpawn.
Это то, что делает GMod «GMod’ом», а не просто картой Source.
E3. Оружие и предметы: weapon_*, item_* через Lua.
E4. Физика пропов: Jolt — prop_physics уже приходят с карты, но не падают
и не сталкиваются.
E5. NPC: начиная с базовых npc_*.
Порядок и разумная дистанция до GMod
GMod — это ~1 млн строк C++ плюс 15 лет аддон-экосистемы. Полное «1 в 1» недостижимо как финальное состояние, но достижимо как наращиваемое покрытие: A → B → C → D → E даёт к каждому моменту работающий одиночный клиент.
| Этап | Что даёт пользователю |
|---|---|
| A | «стены не пускают, вода не твёрдая, белая поверхность устранена, песок появился» |
| B | карта описывается объектами — база для всего |
| C | солнце, тени, нормальный свет |
| D | Lua-аддоны запускаются на нашей карте |
| E | одиночная игра, совместимая с аддонами |
Честное ограничение: «все энтити» — сотни классов. Но base_ents GMod — это
Lua-скрипты, а не C++. Когда появится исполнение lua/entities/ и
lua/weapons/, значительная часть этих классов заработает сама, из исходников
GMod. Поэтому наш код на этих этапах делает только то, чего в Lua нельзя:
низкий уровень графики, физика, загрузка ресурсов.
Свой набор «тяжёлых» классов держим минимальным: prop_physics, func_door,
func_button, trigger_*, light_*, env_*, info_*. Остальное — из Lua.
Решения, принятые пользователем
- Скриншот получен → 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.
Часть B9. Ноклип, дуга прыжка и полный обход карты (сделано)
B9.1. Ноклип: инерции нет, импульс живёт
Правка по наблюдению в игре: в полёте инерции нет — скорость пишется прямо по вводу, отпустил клавишу — остановился сразу. Разгон выглядел «лунной гравитацией».
Но импульс при этом переживает отпускание клавиш: пока ввода нет, скорость не
трогается. Раньше писалось velocity = wish * speed каждый кадр — то есть
собственная скорость затиралась немедленно, и прыжок по Z не мог сохраниться в
принципе.
Пробел даёт порцию импульса по фронту нажатия, а не «подъём пока держишь», поэтому нажатий сколько угодно и порции складываются: именно так в оригинале улетают далеко за край карты.
Выход из ноклипа переносит импульс по всем осям в обе стороны: вошёл в полёт на бегу — летишь дальше; вышел на полной скорости — отталкиваешься от земли, и гравитация не отменяет начатый подъём.
Замер: одно нажатие поднимает на 279 юнитов, два — на 567; после толчка
вверх vz = 320 и через 10 кадров без ввода остаётся 320.
B9.2. «Марсианские прыжки»: цифры точные
Дуга прыжка снята целиком и совпадает с расчётом для GMod
(скорость 270, sv_gravity = 600):
| GMod (расчёт) | замерено | |
|---|---|---|
| до вершины | 0,45 с | 0,45 с |
| весь полёт | 0,90 с | 0,90 с |
| высота | 60,75 | 60,8 |
Высота глаз (64) тоже на месте, камера идёт за игроком. Значит ощущение «не как» рождается не прыжком, а его обрывом о невидимые стены — см. B9.4.
B9.3. Проверка касания: точка не годится, габарит — даёт
Правило «грань должна доставать до кирпича» сначала проверялось одной точкой касания — и ломало подшаг: при спуске после подъёма тело опускается вертикально и точка уходит в сторону от грани. Замерено: игрок переставал перебирать уступы уже высотой 4 юнита.
Проверяется весь габарит тела в момент касания: если тело и кирпич разведены — останавливает плоскость, а не кирпич. Подшаг при этом не ломается (18 перебирается, 19 нет).
B9.4. Обход всей карты: невидимых стен не осталось
real_map_sweep_for_invisible_walls обходит gm_construct сеткой из 49 стартовых
точек восемью направлениями. В каждом кадре, где игрок жмёт «вперёд», но не
двигается, проверяется, кто мешает: грань, останавливающая шаг, обязана
доставать до кирпича.
| было | стало | |
|---|---|---|
| кадров обхода | 117 600 | 117 600 |
| невидимых стен | 2 на прямом ходе | 0 |
Заодно исправлена ошибка в самой диагностике: зазор между габаритами
считался как max двух величин без клампа по нулю, поэтому по пересекающейся
оси выходило отрицательное число, а длина вектора выдавала «912 юнитов зазора»
там, где габариты соприкасаются. На этом артефакте диагностика рапортовала 35
невидимых стен; все 35 были выдумкой измерения, а не дефектом коллизии.
B9.5. Чего воспроизвести не удалось
Жалоба «сквозь дырки в крыше упасть нельзя» не воспроизведена, и это зафиксировано честно, а не «исправлено». Проверено измерением:
- твёрдость кирпичей: аудит 2126 кирпичей по сетке 4×4×4 — 0 расхождений из 129 684 точек;
- displacement: 11 776 треугольников, выпало из сетки поиска 0;
- отбор по содержимому: 2126 твёрдых, 77 нетвёрдых — как и задумано;
- невидимых стен при обходе карты: 0;
- нерисуемых твёрдых граней рядом с постройкой: 7, и все они
sky; - прыжки на дырки: игрок их не проходит.
Чтобы довести до причины, нужны координаты конкретной дырки: без них единственный способ — гадать, а это прямо запрещено правилом проекта.
Часть B10. Флаги граней и высота прыжка (сделано)
B10.1. «Дырки в крыше, сквозь которые нельзя упасть»: неверные флаги граней
Проверено по указанной точке (−1388, −1503, 240): игрок там стоит на твёрдом кирпиче, но нарисованной поверхности на месте нет.
Причина — константы SURF_* в world.rs были перепутаны. Настоящие значения
из public/bspflags.h Source:
| флаг | правильно | было у нас |
|---|---|---|
SURF_SKY2D |
0x0002 | — |
SURF_SKY |
0x0004 | 0x4 |
SURF_WARP |
0x0008 | 0x8 |
SURF_NOPORTAL |
0x0020 | 0x20 — и считался за «не рисовать» |
SURF_TRIGGER |
0x0040 | 0x40 — и считался за «не рисовать» |
SURF_NODRAW |
0x0080 | 0x80 — но назывался SKIP |
SURF_HINT |
0x0100 | 0x40 — не скрывался вовсе |
SURF_SKIP |
0x0200 | 0x80 — не скрывался вовсе |
SURF_NOPORTAL означает «на этой грани нельзя поставить портал», а
SURF_TRIGGER — служебный флаг. Ни то, ни другое не имеет отношения к
рисованию, а мы их пропускали. Итог — граней в картинке меньше, чем в
коллизии: дырка, в которую не видно, но сквозь которую не упасть.
Замер на gm_construct: старая маска скрывала 2511 граней, новая — 2411; появилось в картинке 100 граней, пропало 0.
Тесты: surface_flags_match_source_bspflags (значения закреплены по
первоисточнику) и skip_mask_covers_only_faces_that_are_not_drawn (в маску не
входят NOPORTAL и TRIGGER).
B10.2. «Прыжки марсианские» — они были выше GMod в 1,8 раза
Дуга прыжка снималась и совпадала с расчётом — но расчёт был неверен. Скорость
прыжка бралась как 270 из Source SDK, а у GMod она своя: вики GMod в примере
Player:GetJumpPower() печатает 200.
| было (270) | GMod (200) | замерено после | |
|---|---|---|---|
| высота | 60,8 юнита | 33,3 | 33,3 |
| до вершины | 0,45 с | 0,33 с | 0,33 |
| полёт | 0,90 с | 0,67 с | 0,67 |
sv_gravity = 600 остаётся — он подтверждён вики GMod отдельно.
B10.3. Ошибки измерений, которые пришлось исправить
Две, обе нашли чужие грани и на них строились выводы:
- Максимальный z над точкой вместо высоты поверхности на месте: крыша на z = 1296 заслоняла крышу на z = 240, и все 196 проб объявлялись «без рисунка».
- Зазор между габаритами без клампа по нулю: по пересекающейся оси
maxдвух отрицательных давал отрицательное число, а длина вектора — «912 юнитов зазора» там, где габариты соприкасаются. На этом артефакте диагностика рапортовала 35 невидимых стен при реальном нуле.
Часть E7. Вода и плавание (сделано)
E7.1. Чего не было
Вода была только нарисована: SURF_WARP → шейдер воды в рендере. Физики не
было вовсе — жидкости даже не хранились. Collision::from_bsp отбрасывал всё,
что не CONTENTS_SOLID, а вода и есть не-SOLID. Итог: игрок входил в озеро и
шёл по нему как по пустоте, проваливаясь на дно с той же гравитацией, что и на
асфальте.
E7.2. Что сделано
Liquid— объём жидкости (полупространства +CONTENTS_*), хранится отдельно от твёрдых кирпичей. Отбор по содержимому перенесён до отсева поCONTENTS_SOLID: на gm_construct такой кирпич ровно один (0x10000020 = CONTENTS_WATER | CONTENTS_ORIGIN), и из-за отсева вода не находилась вообще.Collision::point_contents— аналогGetPointContents: возвращает содержимое объёма, в который попала точка. Габарит отсекает до проверки плоскостей.Player::check_water—CGameMovement::CheckWater: три пробы (над ступнями, в середине габарита, на уровне глаз), уровеньWL_NotInWater…WL_Eyes.Player::water_move—CGameMovement::WaterMove: гравитации нет вообще, пробел даёт полную скорость вверх, без ввода игрок тонет (wishvel[2] -= 60), скорость желания режется до 80 %, трение по всей скорости, разгон той же квото-формулой.Player::water_jump—CGameMovement::CheckWaterJump: преграда на уровне пояса, место над головой, опора под ногами с нормалью не кручеsv_walkable_normal; скорость выпрыгивания 256,m_flWaterJumpTime= 2 с.drowning_timeиbreath()— дыхание: под водой растёт, в воздухе восстанавливается. Полное время 15 с.- Вода по колено (
WL_Feet): гравитация действует, вертикальная скорость гасится в 0,9 — мокрые ноги, но по ним ещё можно бежать.
FullWalkMove собран как в оригинале: StartGravity вызывается только если
CheckWater вернула ложь, а при WL_Waist вместо WalkMove/AirMove идёт
WaterMove.
E7.3. Замер на gm_construct
| значение | |
|---|---|
| объёмов воды на карте | 1 |
| габарит озера | (-3072, 1985, -960) .. (1856, 6464, -160) |
| без ввода, через 10 с | z = −960 (дно) |
| с пробелом, через 10 с | z = −194,8 |
Всплытие останавливается на −194,8, а поверхность воды на −160: разница ровно
пояс. Это верное поведение Source — WaterMove включается только от
WL_Waist, поэтому из бесконечного бассейна выпрыгнуть нельзя, только
выпрыгнуть на борт при water_jump.
Тесты: water_is_not_walkable_ground,
holding_jump_swims_up_and_idling_sinks,
breath_runs_out_underwater_and_returns_in_air, real_map_water (замер на
карте).
E7.4. Открытое
Трение и ускорение в воде берутся из WaterMove SDK 2013: sv_friction = 8 и
sv_accelerate = 10. У GMod есть ещё sv_waterfriction = 1 и
sv_wateraccelerate = 10, и где именно они применяются, проверить не удалось —
в публичных исходниках NoclipMove/WaterMove их нет. Непроверенные значения
не подставляем.
Часть E8. Освещение и пролёт камеры (2026-10-14)
E8.1. Солнце светило с горизонта
Жалоба на освещение привела к замеру самого солнца на gm_construct:
ЗАМЕР солнце: направление на свет (0.588, -0.809, 0.0), яркость 560
ЗАМЕР сущность light_environment: angles = "0 306 0", pitch = "-56"
Z был нулём — то есть полы не получали света вообще, а светил только бок, попавший под рыскание 306°.
Причина: у light_environment в GMod наклон лежит в отдельном ключе
pitch, а в angles стоит только рыскание. Читались только angles.
Стало: направление (-0.329, 0.452, 0.829) — полы получают 83 % солнца.
Тест real_map_sun не даёт солнцу уйти ниже Z = 0,5.
E8.2. Ambient берётся из карты
Вместо жёсткой константы [0.62, 0.66, 0.74] читается _ambient того же
солнца (114 136 148 420 на gm_construct), нормированная по среднему и
ограниченная единицей: это добавка, а не основной свет.
E8.3. Замер, почему картинка тёмная
Снято на настоящей карте, в клиенте, где VFS настоящий:
| вход | значение |
|---|---|
| средняя лайтмапа всех вершин | 0,194 / 0,230 / 0,249 |
вершины строго «вверх» (nz > 0.9) |
271 шт., средняя 0,938 |
вершины «вбок» (abs(nz) < 0.1) |
22 124 шт., средняя 0,151 |
| самая светлая текстура | PLASTER/PLASTERCEILING003A = 0,82 |
| формат захвата кадра | тот же sRGB, что у экрана |
Вывод: улица освещена правильно (0,94 на горизонтальных поверхностях), а темнота приходит из данных карты на остальных поверхностях. Плюс две вещи, которые стоит сделать дальше, и обе честно записаны, а не сделаны:
world.rsв диагностике строился с пустымVirtualFs, поэтому все текстуры там были заглушками (189 материалов, но лишь 2 различные картинки по отпечатку). Анализ текстур по этому прогону бессмыслен — верным был только замер в клиенте.- Видимые «белое пятно» на площадке и тёмная трава — разные поверхности с разной лайтмапой; дефект рендера не доказан.
E8.4. Открытый дефект: белая площадка
В кадрах пролёта видна большая чисто белая поверхность на уровне земли рядом с травой, узнаваемой формы. Что исключено измерением:
- пустых текстур нет (максимальная средняя яркость 0,82);
SURF_NOLIGHTрядом нет — все 1175 граней с этим флагом в округе этоTOOLS/TOOLSSKYBOXсSURF_NODRAW, они и не рисуются;- материалов с белой текстурой (
GM_CONSTRUCT/COLOR_ROOM, средняя 0,99995) ровно один, и с этого ракурса он не виден; - канал вывода не при чём: формат захвата — тот же sRGB, что у экрана.
Не опознан. Следующий шаг — сузить по батчам (у рендерера есть
--only-material), что и не сработало: фильтр отсеивает всё, включая
заведомо видимые материалы. Похоже, индексы в set_only_material не совпадают с
индексами материалов мира.
E8.5. Режим пролёта
--flythrough DIR --route "x y z pitch yaw; ..." снимает маршрут одним
запуском: камера идёт между точками по smoothstep, взгляд доворачивается на
следующую точку. Без этого каждый кадр требовал бы отдельного запуска клиента с
перезагрузкой карты.
Знак pitch в наших углах: положительный — взгляд вниз (forward.z = -sin(pitch)).
Кадры снимаются через Xvfb + SDL_VIDEODRIVER=x11: в окружении без дисплея
SDL_VIDEODRIVER=dummy не годится, sdl3 его не поддерживает для
raw-window-handle.
Часть E9. Освещение: нормали граней были перевёрнуты (2026-10-14)
E9.1. Симптом
Жалоба: «на видео нету освещения вообще, с последнего раза не изменилось». Прошлый замер (E8.3) показывал среднюю лайтмапу 0,22 и был сделан о том, что картинка темна «от данных карты». Вывод был неверен: не светило само солнце и запечённый свет, потому что нормали граней смотрели в не ту сторону.
E9.2. Замер, который всё вскрыл
Распределение ориентаций по всем вершинам gm_construct:
| Ориентация | До | После |
|---|---|---|
строго вверх (nz > 0.9) |
17 | 15 195 |
| сбоку | 21 224 | 21 224 |
строго вниз (nz < -0.9) |
18 096 | 2 918 |
У настоящей карты пола смотрят вверх, а у нас «вверх» нашлось 17 вершин
против 18 096 «вниз». В face_normal нормаль плоскости бралась с обратным
знаком:
if face.side == 0 { -normal } else { normal }
Прежний комментарий утверждал обратное («нормаль плоскости смотрит внутрь
твёрдого тела») — это неверно: в VBSP нормаль плоскости грани уже смотрит
наружу от твёрдого тела, то есть наружу от стены и вверх от пола. Инверсия
была внесена при попытке починить намотку (docs/PARITY.md A4), но намотку
исправляет отдельная функция face_winding, которая сравнивает геометрическую
нормаль треугольника с нормалью грани и потому переворачивает обход сама.
Как это выглядело: солнце и запечённая лайтмапа попадали на «изнанку» всех поверхностей. Пол не получал ни того, ни другого — картинка выглядела неосвещённой, хотя данные карты были верны.
E9.3. Ещё один настоящий баг в том же месте
В sample_light_luxel ширина строки блока лайтмапы считалась как
extent[0], а блок в BSP — это (extent[0]+1) × (extent[1]+1) люкселей
(extents — индекс последнего люкселя, а не их количество). Каждая строка
читалась на люксель раньше, и к низу блока чтение уезжало на десятки люкселей
в сторону. Замер: средняя лайтмапа по карте была 0,179 при верной ширине
строки против 0,194 при ошибочной — разница на среднем мала, но на больших
гранях сдвиг достигал величины, сопоставимой с размером блока.
E9.4. Белое пятно в кадрах — не дефект
Долго висело как «неопознанный дефект рендера». Оказалось — это комната
Construct в центре gm_construct: материал GM_CONSTRUCT/COLOR_ROOM, 360
индексов, свет ровно 1.0, габарит (-1240,-968,-204)..(1240,968,204). Её
VMT:
UnlitGeneric
{
"$basetexture" "color/white"
"$vertexcolor" "1"
"$nofog" "1"
}
То есть белая по замыслу и неосвещаемая (UnlitGeneric). Пятно корректно.
Спавн игрока (704,132,-143) лежит внутри этой комнаты.
Урок: белый цвет и «комната света» — не дефект. Проверять надо VMT материала, а не гадать по кадру.
E9.5. Ловушка измерения: first_index
Batch::first_index — это смещение в буфере индексов, а не в буфер
вершин. Первый замер белого пятна делал vertices[first_index + k] и молча
получал чужие вершины: материал 48 показывал «0 вершин», хотя 360 индексов
реально были. Правильно — vertices[indices[first_index + k]]. На этом
замере было потрачено заметное время; записано здесь, чтобы не повторить.
Также пробы в crates/engine/src/player.rs строят мир с пустым
VirtualFs::new(): текстуры там всегда заглушки. Замер текстур годен только
в клиенте, где VFS настоящий. Замер лайтмапы и нормалей пустым VFS не мешает.
E9.6. Рябь на воде
Подобрана замером кадра, порядок проб:
| Частота (волн на юнит) | Что получалось |
|---|---|
| 0.035 | полоса шире озерной глади, вода как гладкая плита |
| 0.35 | рябь настоящая, но вдали aliasing'ится в серо-голубой шум |
| 0.12 и 0.19 | читается как рябь, на горизонте не сыпется |
Третья полоса (0.38) убрана: её наклон давал широкие клинья поперёк озера.
Затухание 1/(1+d²·0.00012) вместо 1/(1+d·0.008) — при линейном рябь не
гасла до горизонта и давала череду полос.
Часть E10. Лайтмапа читалась в перепутанных каналах (2026-10-14)
E10.1. Как нашлось
Запустили настоящий GMod через Steam (/mnt/games/SteamLibrary/.../GarrysMod,
wine/Proton) и сняли ту же точку (1400, 1200, -100), что и наш клиент.
Затем сравнили изображения по зонам:
| зона | GMod | наш |
|---|---|---|
| небо | 175,5 | 178,7 |
| земля | 167,5 | 60,3 |
Небо совпадает — значит канал вывода, гамма и sRGB верны. Расходится только геометрия. Но расходилась не только яркость, а и тон:
| нормированный RGB | тон | |
|---|---|---|
| GMod | (1.000, 0.973, 0.866) | тёплый, солнечный |
| наш | (0.756, 0.960, 1.000) | холодный, синий |
E10.2. Причина
Люксель лайтмапы лежит в блоке как B, G, R, экспонента, а читался как
R, G, B. Сырые байты на gm_construct — [141, 208, 251, 255]: при чтении как
RGB получается холодный синий (141/208/251), при чтении как BGR — тёплый
солнечный (251/208/141).
Подтверждение независимое: цвет солнца из light_environment на этой же карте
тёплый, [1.046, 1.011, 0.942] (R > G > B). Запечённая лайтмапа обязана быть
того же тона, что и солнце, её запекавшее. До правки читалась синяя — то есть
не того цвета. _ambient при этом 114 136 148 (B > G > R) — синева, и это
верно: небесная добавка действительно холодная.
После обмена R и B нормированный тон стал (1.000, 0.960, 0.756) — совпал с
GMod.
E10.3. Что осталось не сделано
Яркость земли после обмена каналов не изменилась: 59,9 против 167,5 у GMod (в линейном пространстве 0,0515 против 0,4022 — разрыв в 7,8 раза). Не починено и записано честно:
lit = light * ambient + dynamic— солнце прибавляется к лайтмапе, которая уже содержит запечённое солнце, то есть учитывается дважды;ambientдомножается на лайтмапу, хотя небо в неё уже входит;SUN_BRIGHTNESS_SCALE = 900занижает динамическое солнце в 900 раз.
Какой из вкладов даёт разрыв — измеряется отладкой: при --debug-materials
текстура заменяется цветом палитры с яркостью 1,0, то есть пиксель равен lit
в линейном пространстве напрямую.
E10.4. Как получен эталон
steam -silent -applaunch 4000 -novid -width 960 -height 540
GMod — только Windows-бинарник, запускается через wine/Proton под Steam.
Карты gm_construct в серверной и клиентской установках совпадают побайтно
(md5 a671368902315ad97006be74f85346ef), так что сравнение честное.
setpos/setang с клавиатуры в консоль долетают неустойчиво, поэтому точки
берутся из garrysmod/cfg/autoexec.cfg, который выполняется сам при старте.
Порядок аргументов: setpos x y z, но setang yaw pitch — наоборот
от нашего --route, где углы идут как pitch yaw.
Часть E11. Освещение сверено с GMod по одной камере (2026-10-15)
E11.1. Как получен честный эталон
GMod запущен под Steam (клиент /mnt/games/SteamLibrary/steamapps/common/GarrysMod,
wine), снят кадр консолью на фиксированной камере:
setpos -399.495911 -1604.489868 67.883812; setang 3.475900 146.330063 0
fov 100
Карта в серверной и клиентской установках совпадает побайтно
(md5 a671368902315ad97006be74f85346ef). Наш клиент снят в той же точке с
--fov 100; ракурс совпал (та же кирпичная стена, тот же проём, то же здание).
Важно: порядок углов разный. У GMod setang yaw pitch roll, у нас в маршруте
--route "x y z pitch yaw". Перепутать — значит получить кадр в другую сторону
и сравнивать не то.
E11.2. Первое сравнение: небо сошлось, поверхности нет
| зона | GMod | наш | отношение |
|---|---|---|---|
| небо | 147, 197, 255 | 142, 195, 254 | 0,97–1,00 |
| бетон | 151, 145, 135 | 72, 74, 66 | 0,48 |
| трава | 137, 142, 102 | 60, 60, 38 | 0,42 |
Небо совпало — значит канал вывода, гамма и sRGB верны. Расходились только запечённые поверхности.
E11.3. Причина: экспонента из четвёртого байта
Четвёртый байт люкселя читался как i8 и давал масштаб 2^exp. У
подавляющего числа люкселей gm_construct этот байт равен 255, то есть как i8
это −1 и масштаб 0,5 — вдвое темнее нужного.
Подбор масштаба по эталону, бетон и трава — два разных материала:
| масштаб | бетон | трава |
|---|---|---|
| 0,5 | 70,5 | 52,8 |
| 1,0 | 87,8 | 77,3 |
| 2,0 | 122,0 | 107,8 |
| 3,0 | 147,0 | 130,3 |
| GMod | 143,6 | 127,1 |
Один коэффициент попал в эталон на двух разных материалах — значит это найденная величина, а не подгонка под одну точку. Небо при этом не изменилось: 196,9 против 199,7.
Промежуточная проверка, что терялось не в шейдере: вывод только лайтмапы дал на бетоне 0,671 (линейных) — то есть блоки читаются верно и занижена была сама яркость, а не индексация.
E11.4. Ложный след, который чуть не отправил не туда
Измерение lit через --debug-materials дало 0,251 при лайтмапе 0,671 — выглядело
как потеря света в шейдере. На деле цвет debug-палитры не белый, а насыщенный
(среднее в линейном ≈0,366), и деление на него даёт 0,686 — то есть совпадение с
лайтмапой. Вывод: --debug-materials годится для оценки формы, но не для
абсолютных величин.
E11.5. Осталось не сходится
Вторая эталонная точка — камера с высоты 3195 над городом:
setpos 1296.484375 5623.283203 3195.700195; setang 28.374243 -133.538101 0
У нас на этом месте вместо города видно сплошной травяной склон. Замер: рельеф выше 1000 отсутствует (0 треугольников), рядом с камерой выше 2000 — тоже 0. Значит закрывающая плита — не рельеф, и причина не найдена. Записано здесь честно; E11.3 этот дефект не объясняет и не вызван им.
E11.6. Вода: затухание и отбраковка по волновой нормали
Две правки, обе найдены замером кадра, а не правдоподобием.
Затухание бьло по всем волнам сразу. Было 1/(1 + d²·0.00012) — на 900
юнитах это 0,01, и с высоты озеро читалось зеркальной плитой. Алиасинг бьёт
только по мелким волнам: они дальше пикселя. Заменено на затухание по
волновому числу каждой волны отдельно:
local = 1 / (1 + (k · distance · 0.01)²)
При таком на 900 юнитах волна длиной 46 юнитов ещё видна (0,4), а длиной 2,7 — уже нет (0,002).
discard считался по волновой нормали. Вопрос «лицевая ли грань»
относится к геометрии, а не к микрорельефу: на скользящих углах волна
наклоняла нормаль так, что dot(n, view) проваливался в ноль, и поверхность
выбивалась дырками — край озера рвался зубцом. Теперь facing берётся по
плоской поверхности, а волновая нормаль идёт только в френель и блик.
Часть E12. Тени от солнца и солнце в небе (2026-10-15)
E12.1. Почему «трава не той стороной»
Жалоба повторялась трижды, и по замерам намотка рельефа верна: плоскости 110 граней смотрят вверх, развёрнуто 0 треугольников, нормали вершин вверх 15 407 против вниз 2 954. Проверка переворотом это подтвердила: при инверсии становится вверх 8 008 / вниз 10 353 и разворачиваются все 11 776 треугольников — то есть явно хуже.
Значит «не той стороной» — это не геометрия, а освещение: без теней плоская трава читается как вывернутая. Теней в движке не было вообще.
E12.2. Один каскад
Добавлена карта теней 2048×2048 (Depth32Float), ортографическая проекция,
накрывающая область радиусом 2 600 юнитов вокруг камеры по направлению на
солнце. Отдельный пасс глубины со стороны источника, вода в него не идёт
(полупрозрачна, теней от неё не бывает).
В шейдере луч сравнивается с картой четыремя сравнениями по соседним текселям
и сглаживается smoothstep(0.35, 0.65) — иначе край тени идёт лестницей.
Смещение по нормали на 3 юнита убирает полосу самозатенения на поверхностях,
повёрнутых к солнцу под острым углом.
Проверка выводом самого коэффициента затенения: чёрное — тень (тоннель, стена под зданием, пол за корпусом), белое — освещённое. Работает.
E12.3. Солнце в небе
Направление на солнце пишется в uniform камеры шестым vec4 (w — признак
«солнце есть на карте»). Небо рисует ядро 2,5° и ореол до 12°. Подобрано так,
чтобы на FOV 100 диск помещался в кадр целиком.
E12.4. Сверка с GMod после теней
Та же камера, что в E11: точка (-399.5, -1604.5, 67.9), yaw 146.33, pitch 3.48.
| зона | GMod | было (E11) | стало |
|---|---|---|---|
| бетон | 143,6 | 87,8 | 147,0 |
| трава | 127,1 | 77,3 | 129,0 |
| небо | 199,7 | 196,9 | 196,9 |
E12.5. Что осталось расходиться
Стена в тени: GMod 36,4, у нас 124,6. Тени солнца её не гасят, потому что
основной свет даёт запечённая лайтмапа, а динамическое солнце вносит лишь
малую долю (SUN_BRIGHTNESS_SCALE = 900). В GMod затенение этой стены
сидит в самой лайтмапе карты, то есть у нас лайтмапа на этом участке читается
примерно втрое светлее. Причина не найдена. Проверялось: связность блоков
нормальная (0,3–4,7 из 255), порядок каналов BGR подтверждён эталоном, шаг
строки даёт гладкий результат.
Часть E13. Экспонента лайтмапы возвращена (2026-10-15)
E13.1. Ошибка была в моём же исправлении
В E11 экспонента из четвёртого байта была отброшена: у большинства
люкселей gm_construct байт равен 255, что как i8 даёт −1 и масштаб 0,5, и
картинка выходила вдвое темнее. Тогда подобрали плоский коэффициент 3,0, и по
освещённым поверхностям совпало.
Но затенённые участки вышли втрое светлее эталона, и это было записано как «не найдено». Ошибка: VRAD нормализует мантиссу так, что её максимум лежит в верхней половине байта, а порядок яркости пишет в экспоненту. Отбросив её, мы сравняли тени с солнцем.
E13.2. Что дало возвращение
Масштаб подобран так, чтобы освещённые участки остались на месте:
2^(−1) · 4,47 = 2,24. Тень с экспонентой −3 получает 2^(−3) · 4,47 = 0,56,
то есть вчетверо темнее солнца.
Та же камера, что в E11:
| зона | GMod | плоский 3,0 | с экспонентой |
|---|---|---|---|
| бетон в солнце | 143,6 | 147,0 | 143,7 |
| тоннель | 92,9 | 165,8 | 86,8 |
| стена в тени | 36,6 | 124,6 | 70,8 |
| трава в солнце | 127,1 | 129,0 | 110,5 |
| небо | 199,7 | 196,9 | 196,9 |
Тоннель — то место, где затенение и должно быть сильным: было 165,8 при эталонных 92,9, стало 86,8. Это и есть появившиеся тени.
E13.3. Что осталось
- Стена в тени 70,8 против 36,6. В линейном отношении к солнцу у нас 4,88, у GMod 16,5. Разница экспонент на две ступени даёт ровно 4 — столько и получилось, то есть экспоненты читаются верно. Не хватает ещё примерно двух ступеней, то есть в GMod этот участок ещё темнее, чем даёт лайтмапа. Возможно, там работает ещё и затенение от окружения, а не только запечённое.
- Трава 110,5 против 127,1. Один коэффициент не даёт попасть и в бетон, и в траву: бетон просит 4,47, трава просит 6,0. Значит расходится не только яркость, но и тон — это совпадает с жалобой «оттенок не похож».
Проверено и отброшено: динамическое солнце эти участки не освещает. При
SUN_BRIGHTNESS_SCALE 900 и 2500 значения совпали до десятых, значит вклад
солнца тут нулевой и весь свет — из лайтмапы.
E13.4. Вода: длины волн были кратны
Ряд длин 46 / 23 / 11 / 5,5 / 2,7 — каждый шаг ровно ×0,5. Из-за этого периоды фаз всех пяти волн синхронизировались, и рябь выстраивалась в правильную решётку: на кадре это читалось как гофрированный металл.
Заменено на шесть волн с несоизмеримыми длинами 340 / 210 / 125 / 73 / 41 / 19 юнитов. На кадре рябь стала нерегулярной, береговая линия ровной.
Геометрия воды осталась плоской, как в Source: вершины не двигаются, вся рябь считается в фрагментном шейдере.
Часть E14. Белое в центре карты: гипотеза не подтвердилась (2026-10-15)
E14.1. Жалоба
«В 0 0 видны белые плиты, которых нет в оригинале, причём у них коллизия есть» и «через траву видно комнату под землёй».
E14.2. Что измерено
Материал белых граней — GM_CONSTRUCT/COLOR_ROOM с базовой текстурой
color/white. Материалов с такой текстурой на карте ровно один, у него
9 граней, габарит (−1240, −968, −204)..(1240, 968, 204) — то есть ящик
2480 × 1936 × 408 точно в центре карты, и точка спавна игрока внутри.
Свойства граней:
surf flags |
0x0 — SURF_NODRAW не выставлен |
| стили | [0, 255, 255, 255] или [0, 33, 255, 255] |
| коллизия | есть |
Стили 255 в трёх из четырёх — свет не запечён.
E14.3. Гипотеза, которая не подтвердилась
Предположил, что это сетка навигации: на карте 815 info_node, 76
info_node_air_hint, 24 info_node_hint, 15 info_node_climb, 8
info_node_air — и настоящая игра их не рисует.
Замеры, которые гипотезу убили:
- у
info_nodeв лампе сущностей нет ключейbrushesиbrushlist, есть толькоorigin,nodeid,angles,classname,hammerid; - ни одна сущность на карте не имеет
brushlist— все кирпичи принадлежатworldspawn; LUMP_NODES(лам<D0B0><D0BC> 5, 12 144 байта) — это дерево BSP, а не навмеш: первые байты не разбираются какdbrush_t;- ни одна белая грань не привязалась ни к одному кирпичу по совпадению
planenumчерезLUMP_BRUSHSIDES.
Заодно выяснилось, что кирпичи и грани в BSP не параллельны: 2 203 кирпича, 14 946 сторон, 9 460 граней. Прямой связи по номеру нет, поэтому отсев «границы сущности → номера граней» не тривиален.
E14.4. Что осталось сделать
- Найти, каким классом сущности помечены эти девять граней. Раз
brushlistнет, нужен другой путь: в BSP v1 у brush-сущностей бывает ключ"brushes"+"brushlist"; если его нет, сущность — точечная, и грани принадлежатworldspawn, а помечаются только свойствами поверхности. Значит искать надо вsurfaceprop/LUMP_SURFACE_PROPSпо материалу или в спискеmodels/brushesдругого формата. - Проверить
LUMP_SURFACE_PROPS(лампа 顺 33/100) — возможно, у этих девяти граней там стоит флаг, который Source уважает при отсечении. - Сопоставить с GMod: снять тот же ракурс и посмотреть, что там. Свой кадр GMod из этой точки ещё не снимался.
E14.5. Промежуточный итог по рельефу
По «траве не той стороной» добавим замер в E13: из 15 407 вершин вверх
наклонены только 104, гистограмма nz даёт 24 184 вершины с nz < 0.5 (это
стены) и 34 в верхнем бине. Геометрия холмов кривая, а наклон почти нулевой —
значит он теряется при построении сетки displacement'а. Гипотеза Gemini про
dv.vec как источник нормали объясняет цифру, но ещё не проверена.
Часть E15. Разбор «белых плит» и «вывернутой травы» (2026-10-16)
E15.1. Гипотеза Gemini про dv.vec — опровергнута измерением
Предполагалось, что нормаль берётся из dv.vec вместо геометрии сетки.
Проверка сырых байт LUMP_DISP_VERTS (лам<D0B0><D0BC> 33, 252 760 байт):
первые 64 байта:
0: 00 00 00 00 d5 4c a9 38 ff ff 7f 3f 93 29 91 41
16: 00 00 20 3f 62 b5 db b7 7a b1 1a 39 00 00 80 3f
Читать как f32 с шагом 20 байт даёт осмысленные значения
(vec=(0.0000,0.0001,1.0000), dist=18.145), читать как три i16 — мусор.
Значит parse_dispverts прав: формат f32, шаг 20, vec действительно
почти всегда (0,0,1), а рельеф формируется полем dist. Гипотеза неверна.
E15.2. Ориентация рельефа в порядке
| замер | значение |
|---|---|
| плоскости displacement'ов | вверх 110, вниз 0, сбоку 0 |
| намотка | развёрнуто 0 треугольников из 11 776 |
| рельеф в радиусе 600 от спавна | z от −151.4 до −148.0 |
| базовые плоскости рельефа | 110 штук, z от −148 до 11 235 |
Рельеф у спавна почти плоский (3 юнита перепада на 1 200), поэтому «наклонены только 104 вершины» — свойство карты, а не ошибка намотки.
E15.3. Ошибка в моей же сверке лампов
Справочник лампов читался со stride 48 вместо 16. Из-за этого LUMP_NODES
выглядел как навмеш, а лампы 6 и 18 менялись местами. Все выводы про
LUMP_NODES из части E14 недействительны; содержимое по stride 16 приведено
выше и сходится с тем, что даёт Rust-читатель.
E15.4. «Белые плиты» — что это на самом деле
Материал с текстурой color/white (GM_CONSTRUCT/COLOR_ROOM, UnlitGeneric)
используют всего 9 граней, и это не ящик в центре карты, как показалось
сначала, а тонкие пластины:
| грань | габарит | размер |
|---|---|---|
| 481, 483 | x = −2560 | 64 × 4 |
| 614, 615 | x = −2048 | 64 × 4 |
| 880 | y = −2176, z = −20..−16 | 128 × 4 |
| 1985, 1987 | y = −2868, z = 508 и 636 | 64 × 4 |
| 2057, 2058 | y = −2704, z = 508 и 636 | 64 × 4 |
Все нормали строго ±X или ±Y, высоты четырёх уровней — это и есть «оси
координат 3д» на глаз. Свойства: surf flags 0x0 (SURF_NODRAW не
выставлен), стили [0,255,255,255] и [0,33,255,255], коллизия есть.
E15.5. Настоящая белая плита в центре — другая
Большая белая поверхность под камерой (0,0,2200) — это не color/white.
Самые крупные грани рядом с нулём:
| грань | габарит | материал |
|---|---|---|
| 9379, 9380, 9385, 9386 | (−1240,−968,−4)..(1240,968,4) | concretefloor028a_1280_-1600_-64 |
| 9344, 9347, 9350 | z = −148 | concretefloor028a_1344_3584_32 |
То есть бетонная плита 2480 × 1936 в 8 юнитов толщину прямо над спавном, и она продублирована: грани 9379 и 9385 имеют одинаковый габарит, как и 9380 с 9386.
Дубль — наш баг, а не карты: грани 9363..9460 принадлежат браш-сущностям, а не миру. Исправлено в части E16.
E15.6. Чёрные области — не тени
SOURCE_RS_NO_CULL и принудительный return 1.0 в sun_shadow дали кадры,
отличающиеся только отсечением; тени на чёрные пятна не влияют. Значит чёрное
берётся из lit = in.light * ambient + dynamic, то есть из лайтмапы.
E15.7. Связь «грань → кирпич»
Прямой связи нет: 2 203 кирпича, 14 946 сторон, 9 460 граней, номера не
параллельны. Рабочий обход — по плоскости: planenum грани → кирпичи, у
которых эта плоскость есть, затем проверка, что все вершины грани внутри
выпуклой оболочки. Этим найдено, что часть белых пластин принадлежит кирпичам
с contents = 0x0 (CONTENTS_EMPTY, не твёрдые).
Но правило «contents == 0 → не рисуем» слишком грубое: под него
попадают 330 граней, в основном plasterwall022c (250) и
CONSTRUCT_CONCRETE_GROUND (50) — это видимая геометрия. Отбор по содержимому
в лоб резать нельзя.
LUMP_FACEIDS (лампа 11, 18 920 байт = 9 460 значений по 2 байта) на этой карте
содержит значения вроде −27 746 и 24 471, а номера сущностей — то, что нужно,
не подтвердилось.
E15.8. Что осталось
- Понять, почему бетонная плита 2480 × 1936 у спавна выглядит белой. Тон
по эталону: бетон 143.7 при эталонных 143.6 — совпадает, значит дело не в
масштабе, а в том, что в GMod этой плиты, вероятно, не видно (она внутри
комнаты, а мы видим её сверху). Закрыто в E16: плиты не было в GMod,
потому что это браш-сущность в своём
origin. - Разобраться с плоской травой: снять кадр GMod из точки
ref_p4(setpos 0 0 2200,setang 180 55) и сравнить рельеф. sun_shadowбез проверкиndc.z— точка за ближней плоскостью карты теней получаетdepth < 0и трактуется как затенённая. Сейчас это не видно глазом, но диапазон не проверяется, и это лазейка для чёрных пятен.
Часть E16. Белые плиты в (0,0,0) — браш-сущности без origin (2026-10-16)
E16.1. Ответ Gemini и что он дал
В GEMINI.md отправлены четыре вопроса; ответ получен и разобран. Главное,
что он дал: LUMP_FACES хранит полигоны всех моделей карты подряд, а не
только статического мира. Проверено измерением — подтвердилось.
E16.2. Замер LUMP_MODELS
LUMP_MODELS (лампа 14) — 624 байта, 624 / 48 = 13 моделей ровно
(dmodel_t = 48 байт: mins[3] f32, maxs[3] f32, headnode[3] i32,
visleafs i32, firstface i32, numfaces i32):
| модель | первая грань | граней | габарит |
|---|---|---|---|
| 0 (worldspawn) | 0 | 9363 | (−15360,−15360,−1024)..(15872,15360,15359) |
| 1, 8 | 9363 / 9410 | 6 | (−4,−968,−204)..(4,968,204) — плита на YZ |
| 2, 5 | 9369 / 9387 | 6 / 22 | (−1240,−4,−204)..(1240,4,204) — плита на XZ |
| 3, 4 | 9375 / 9381 | 6 | (−1240,−968,−4)..(1240,968,4) — плита на XY |
| 6, 7 | 9409 / 9410 | 1 / 0 | стекло и кнопка |
| 9, 10 | 9416 / 9422 | 6 / … | func_vehicleclip — клипы |
| 11, 12 | … | … | func_illusionary |
Три ортогональные плиты через начало координат — это буквально «оси
координат 3д: есть x, есть y, есть z» из жалобы. Мы рисовали все 9460
граней подряд, поэтому 97 граней браш-моделей сваливались в (0,0,0), а
коллизия у них была — её мы строим отдельно, из LUMP_BRUSHES.
Сущности в LUMP_ENTITIES с model "*k" (12 штук) — func_brush,
func_reflective_glass, func_button, func_vehicleclip,
func_illusionary — с настоящими origin:
*1 func_brush origin=-804 -3600 -52 *7 func_button origin=-2698 -2598 -207.2
*2 func_brush origin=-2048 -4572 -52 *8 func_brush origin=-3292 -3600 -52
*3 func_brush origin=-2048 -3600 156 *9 func_vehicleclip origin=—
*4 func_brush origin=-2048 -3600 -260 *10 func_vehicleclip origin=—
*5 func_brush origin=-2048 -2628 -52 *11 func_illusionary origin=-2920 -1028 8
*6 func_reflective_glass origin=-2064 -2076 -286 *12 func_illusionary origin=-2920 -1040 8
E16.3. Проверка LUMP_FACES и раскладки dface_t
- Ламп не сжат:
fourCC = 0, длина529 760байт,529 760 / 56 = 9460ровно. Раскладкаdface_t(56 байт) совпала байт-в-байт с таблицей спецификации:planenum 0,firstedge 4,numedges 8,texinfo 10,dispinfo 12,styles 16,lightofs 20,area 24,lightmap_mins 28,lightmap_size 36,origFace 44,numPrims 48,smoothingGroups 52. Сдвига нет. - Все 110 displacement-граней лежат в модели 0 и все имеют
numedges = 4. Значит отсев 21-й поверхности (89 из 110 в рендере) — не T-junction, не браш-сущность и не порядок углов. LUMP_BRUSHES/LUMP_BRUSHSIDESпри рендере не участвуют вовсе — это данные коллизии и трассировки лучей; правило «contents == 0→ не рисуем» не используется.
E16.4. Что сделано
Новый BrushModels в crates/engine/src/world.rs вместо мёртвого NavHulls
(гипотеза про навмеш опровергнута в E14 — brushlist не привязан к
сущностям, отбор был пустым):
- читает
LUMP_MODELS, строит диапазоны граней по моделям; - привязывает модель к сущности по
model "*k", берётorigin; - не рисует
func_vehicleclip,func_occluder,func_areaportal*,func_clip_vphysics,trigger_*и сущности сStartDisabled "1"; - вершину граней браш-модели сдвигает на
origin(для мира смещение нулевое, поведение не меняется); текстурные координаты и лайтмапа считаются уже в мировых координатах, потому чтоtexinfo.lightmapVecsиdface_t.lightofsзапекались компилятором относительно конечного положения.
Тест real_map_brush_models_keep_their_origin (требует SOURCE_RS_TEST_BSP):
грани 9379/9380 получают (-2048,-3600,156), 9385/9386 —
(-2048,-3600,-260), грань 9416 (модель 9, func_vehicleclip) не рисуется.
57 граней уехали в origin сущностей, 40 граней клипов отсечены.
Кадр с камеры (0,0,2200) pitch 55 yaw 180: огромная белая плита в центре
исчезла; на её месте виден нормальный вид, а плиты моделей 3 и 4 видны там,
где стоит их сущность. Сравнение до/после — /tmp/opencode/ref/frame_0000.png
и /tmp/opencode/plates/frame_0002.png.
E16.5. Что из этого выпадает дальше
- Браш-сущности пока не поворачиваются по
angles— только трансляция. На gm_construct уfunc_brushони нулевые, но на других картах может понадобитьсяrotate_model_vector. func_illusionaryрисуется — в Source он видим, но физики нет; надо сверить с эталоном, что он не попал в чёрные пятна.- Остаются 21 displacement-грань из 110 и плоская трава (E15.8 п. 2),
ndc.zвsun_shadow(E15.8 п. 3).
Часть E17. Коллизия браш-сущностей, отсев displacement'ов и startPosition (2026-10-16)
Игровая сессия после E16 дала обратную связь: «белые херни на 0 0 пропали, но колизия осталась», «невидимые стены на угловатых поверхностях», «белая комната стала белой — это успех», «дисплейсменты вывернуты», «свет распространён».
E17.1. Коллизия: кирпичи моделей лежат в локальных координатах
Замер LUMP_BRUSHES/LUMP_BRUSHSIDES/LUMP_PLANES с восстановлением AABB
кирпича перебором троек плоскостей:
- кирпич 2184 —
contents 0x1(CONTENTS_SOLID), габарит(-1240,-968,-4)..(1240,968,4); - кирпич 2185 — то же самое.
Это ровно локальные габариты моделей 3 и 4. То есть кирпичи браш-сущностей
лежат вокруг начала координат, и наша Collision::from_bsp клала их в
(0,0,0) — невидимые стены там, где их нет, и отсутствие коллизии там, где
есть геометрия. Обе половины жалобы объяснены.
E17.2. Как определить, к какой модели относится кирпич
В dmodel_t поле по смещению 36 — корень поддерева BSP (headnode),
не visleafs. Проверено обходом: из этого узла достижимы
| модель | узел | кирпичей | габарит найденных кирпичей |
|---|---|---|---|
| 0 (world) | 0 | 2 168 | — |
| 1 | 4887 | 1 | (−4,−968,−204)..(4,968,204) — совпал с моделью |
| 2 | 4893 | 1 | (−1240,−4,−204)..(1240,4,204) |
| 3 | 4899 | 1 | (−1240,−968,−4)..(1240,968,4) |
| 4 | 4905 | 1 | (−1240,−968,−4)..(1240,968,4) |
| 5 | 4911 | 5 | (−1240,−4,−204)..(1240,4,204) |
| 6..12 | 4923..4982 | по 1..6 | — |
Габариты кирпичей в точности равны локальным габаритам моделей. Всего покрыто моделями 2 189 кирпичей из 2 203; 14 не принадлежат ни одной модели — их оставляем в мире (ронять их значило бы потерять реальную геометрию).
Ранее парсер Model читал head_node по смещению 36 — это оказалось верно
по совпадению, но поле называлось visleafs; фактическая раскладка
подтверждена измерением. LUMP_LEAFBRUSHES (17) добавлен в Geometry.
E17.3. Что сделано
Collision::from_bsp (движок player.rs):
- обходит поддерева
models[i].headnodeи строит карту «кирпич → смещение»; - кирпичам мира и непривязанным кирпичам — сдвиг нулевой;
- кирпичам браш-моделей —
originих сущности; плоскость пересчитывается какd' = d + n·origin(мирp_world = p_local + origin); - сущности без коллизии не попадают в мир вообще:
func_illusionary(в SourceSOLID_NONE),func_vehicleclip(режет только машины),func_occluder,func_areaportal*,trigger_*,StartDisabled "1".
Это намеренно другой список, чем в рендере: func_vehicleclip не
рисуется, но и не должен держать игрока, а func_illusionary рисуется, но
не должен держать.
Тест real_map_brush_model_collision_follows_origin: в коллизии нет кирпича
габарита (−1240,−968,−4)..(1240,968,4), зато есть
(−3288,−4568,152)..(−808,−2632,160) (модель 3 на своём origin
(−2048,−3600,156)) и (−3288,−4568,−264)..(−808,−2632,−256) (модель 4),
а кирпичей в коллизии 2 118.
E17.4. Куда деваются 21 displacement-грань — не деваются
Добавлены счётчики отсева (WorldStats::disp_skipped_*). Замер:
disp: построено 110, нарисовано 89, отсев: скайбокс 21, материал 0,
индекс 0, сборка 0
110 = 89 + 21, ровно. Все 21 уходят в отдельный проход 3D-скайбокса
(world.rs, ветка if face.dispinfo >= 0 внутри цикла скайбокса) и там
строятся. Потерь нет — гипотеза «T-junction отбрасывает numedges > 4»
опровергнута: у всех 110 displacement-граней numedges = 4, и все лежат в
models[0].
Тест real_map_displacement_coverage проверяет, что сумма отсева равна
размеру LUMP_DISPINFO — ничего не теряется молча.
E17.5. startPosition — в порядке
Гипотеза «углы сетки выбираются наугад» опровергнута: у всех 110 записей
startPosition совпадает с одним из углов своей базовой грани, худшее
расстояние 0.053 юнита. Также сходятся power (87×power 3, 19×4, 4×2)
и DispVertStart: сумма (2^power+1)^2 = 12 638 = размер LUMP_DISP_VERTS.
Значит порядок углов в build_displacement и привязка к startPosition
верны, и «вывернутость» дисплейсментов из жалобы ищется в другом месте —
это открыто.
E17.6. Что открыто
«Дисплейсменты вывернуты»— закрыто в E18: намотка displacement'ов была перевёрнута относительно браш-граней, и весь рельеф срезался сверху.- «Свет распространён» — визуально: резкие грани света/тени на
дисплейсментах. Подозрение на UV лайтмапы для displacement (см. ответ
Gemini в
GEMINI.md), но наша формула совпадает с его — нужен замер эталонного кадра GMod. - Зеркало (
func_reflective_glass, модель 6) не работает. ndc.zвsun_shadow(E15.8 п. 3).
E18. Намотка displacement'ов была перевёрнута — рельеф срезался сверху
Закрывает жалобы «дисплейсменты вывернуты» и «сквозь них, когда они вывернуты, видно только скайбокс». Вторая половина жалобы оказалась точным описанием механизма.
E18.1. Как нашли: погасили меш 3D-скайбокса
Небо (sky.wgsl) и меш скайбокса (sky_mesh.wgsl) рисуются без записи
глубины и без отсечения (depth_compare: Always, cull_mode: None),
поэтому скайбокс всегда заполняет то, что мир не нарисовал, и маскирует
пропуски геометрии. Добавлен диагностика-переключатель
SOURCE_RS_NO_SKY_MESH — гасит только меш, оставляя 2D-фон.
Замер на контрольном кадре сверху (--route "0 0 2200|55 180"):
| режим | результат |
|---|---|
| меш включён | весь ландшафт оливково-зелёный |
SOURCE_RS_NO_SKY_MESH=1 |
тот же ландшафт — небо |
SOURCE_RS_NO_SKY_MESH=1 SOURCE_RS_NO_CULL=1 |
по-прежнему небо |
То есть геометрия мира в этом месте не рисуется даже при снятом отсечении — гипотеза «срез задних граней» на этом шаге опровергнута, дальше искали в данных.
E18.2. Геометрия рельефа в порядке
Замер real_map_material_index_list (он же даёт индекс для --only-material N):
186 21888 вырожд. 0/7296 нефинит 0 гориз ↑7275 ↓0 GM_CONSTRUCT/GRASS_13
187 13440 вырожд. 0/4480 нефинит 0 гориз ↑4275 ↓0 GM_CONSTRUCT/GRASS-SAND_13
- 0 вырожденных треугольников, все атрибуты конечны (NaN в
posпрятался бы:f32::min/maxпропускают NaN, а сравнениеNaN < epsложно); - габариты
(-5248,-3712,-927)..(1856,6464,271)— это ровно земля карты; - в рантайме они слиты в один материал
GM_CONSTRUCT/FLATGRASS(индекс 36): 35328 индексов = 21888 + 13440, один батчfirst_index=57978.
Замечание про индексы: в тестах мир строится с пустым VirtualFs, из-за чего
материалы не дедуплицируются — 189 против 54 в рантайме, и индексы
расходятся. --only-material 186 в рантайме просто ничего не выбирает.
Поэтому диагностика материалов (топ-материал по объёму геометрии) теперь
печатается из сборки мира, а не из теста.
E18.3. Что реально показал замер
Камера в 97 юнитов над крупнейшим треугольником травы (-937,-2116,253),
глядя вниз:
| режим | результат |
|---|---|
--only-material 36 |
пусто (небо) |
то же + SOURCE_RS_NO_CULL=1 |
кадр заливает #93ff26 (FLATGRASS) |
Геометрия на месте и позиции верные — её срезает отсечение. Тест
real_map_top_surface_orientation берёт самую верхнюю горизонтальную
поверхность над точкой (то, что игрок реально видит сверху) и смотрит, куда
смотрит кросс-произведение (b-a)×(c-a):
| точка | z | ориентация | материал |
|---|---|---|---|
| спавн (пол) | −144 | вниз | CONSTRUCT_CONCRETE_FLOOR |
| центр карты | −148 | вверх | GRASS_13 |
| центр травы | 253 | вверх | GRASS_13 |
| точка скриншота | −71 | вниз | concretefloor028a |
Видимая сверху поверхность в нашем рендере — та, у которой кросс-произведение
смотрит внутрь тела (у пола вниз). Браш-грани приводятся к этому в
face_winding, а build_displacement подгонял намотку вдоль нормали
плоскости — то есть ровно наоборот. Отсюда два наблюдения из жалоб разом:
сверху рельеф срезан и виден меш скайбокса, а снизу, наоборот, рельеф
проступает («трава сверху — я под картой»).
E18.4. Что сделано
В build_displacement::emit порядок индексов перевёрнут: при geo · up > 0
кладётся [a, c, b]. Нормали вершин оставлены наружу (вдоль нормали
плоскости) — от порядка индексов они не зависят, и динамический свет не
поехал. Коллизия не задета: add_displacement_tris строит нормали по рёбрам
(SAT), порядок вершин для неё неважен.
Тест real_map_displacement_is_visible_from_above держит правку: пол под
спавном и трава в центре карты обязаны иметь одинаковую ориентацию. Визуально
после правки: с верхней камеры видны травяные холмы, дороги и озеро мира, а
снизу изнанки рельефа нет — только небо и постройки.
E18.5. Диагностика, которая это открыла
SOURCE_RS_NO_SKY_MESH=1— гасит только меш 3D-скайбокса;--only-material N+--debug-materials— рисовать одним материалом сплошным цветом (индекс — из лога «топ-материал»);SOURCE_RS_NO_CULL=1— снять отсечение задних граней;- лог «топ-материал по объёму геометрии»: индекс, объём, первый индекс,
габариты и флаги
water/fallback; - предупреждение «батчи с индексом вне списка материалов»: рендер молча
пропускает батч, чей
materialне нашёлся вmaterial_bind_groups, — геометрия при этом есть и в буфере, и вbatches.
E19. «Односторонние рампы» — замером не воспроизводятся
Жалоба: «рампы и срезы — поверхности, делающие прямоугольный треугольник, направлены только в одну сторону; находишь, где можно пройти в одну, обратно нельзя». Три теста бьют в неё на трёх уровнях, и все три зелёные.
E19.1. Три замера
| тест | что меряет | замер |
|---|---|---|
real_map_brush_blocks_every_direction |
луч из 6 сторон в центр каждого кирпича | 2118 кирпичей (3 вырожденных), 0 проходимых |
real_map_brush_stops_a_real_step |
тело 32×32×72, шаг 64, 14 направлений (6 осевых + 8 угловых), реальный move_aabb |
внутри габаритов карты: 0 проникновений, 0 проходов за центр |
real_map_walk_never_clips_through |
настоящий Player::update: гравитация, скольжение по касательной, подшаги, прыжок раз в секунду, присед на второй половине прохода |
24 точки × 4 направления × 300 кадров = 28 800 кадров: внутри кирпича глубже 1 юнита — 0, внутри рельефа (tri_penetration) — 0 |
Первый замер закрывает саму геометрию: полупространства всех кирпичей
замкнуты, «дырки» в наборе плоскостей нет. Второй — то, что из полупространств
делает шаг: раздувание на полугабариты, отбрасывание грани при t ∉ [0,1],
скольжение и выталкивание. Третий — то, что первые два не покрывают:
многошаговость, контакт вплотную, прыжок и присед.
Вывод: одностороннего прохода в игровой части gm_construct нет. Жалоба
относится либо к сценарию, не покрытому тестами, либо к месту карты, либо к
тому, что уже починено правками origin браш-сущностей (E17) и подшагов. Что
осталось непокрытым: пропы (Collision::boxes — габариты приходят из
клиента, в BSP их нет) и конкретная точка карты — её координаты стоит
спросить у игрока, чтобы мерить именно её, а не правдоподобие.
E19.2. Что нашли попутно: кирпичи 3D-скайбокса проходятся насквозь
140 кирпичей лежат за габаритами мира (z≈11 300 против z≤2976 у карты) — это область 3D-скайбокса. Из них 12 проходятся телом насквозь в обе стороны, и механизм у них общий:
- кирпичи крошечные — 14×17×23 юнита, а тело 32×32×72;
- раздувание грани
inflate = |n.x|·16 + |n.y|·16 + |n.z|·36целиком перекрывает такой кирпич, поэтому из 33–38 плоскостей в шанс на отсечение попадает 1–3, а тело на старте уже лежит внутри большинства полупространств (t < 0— отсечение неприменимо); - первая плоскость всё же останавливает (лучший
t ≈ 0.01…0.06), но после скольжения по наклонной нормали остаток шага смотрит почти вдоль кирпича, и тело выходит с обратной стороны.
Согласованность данных не при чём: нарушение n·центр ≤ d по всем плоскостям
≤ 1.7 юнита — плоскости и габариты на месте. Тест считает такие кирпичи
отдельным счётчиком и не валит проверку: игрок туда не попадает, а тело
больше препятствия — это свойство подхода «полупространство + раздувание»,
а не пропуск конкретной грани.
Тесты: real_map_brush_blocks_every_direction, real_map_brush_stops_a_real_step,
real_map_walk_never_clips_through в crates/engine/src/player.rs.
Часть E20. Лайтмапа читалась со сдвигом строки (2026-10-16)
E20.1. Жалоба и как к ней пришли
Жалоба (вопрос 6 GEMINI.md): «на траве свет лежит плитами — резкие прямые
границы светлого и тёмного». Рельеф перестал быть невидимым после E18, и
на нём впервые стала видна лайтмапа: прямоугольные светлые пятна с
ступенчатым краем, плюс белые полосы на бетоне.
Нашлось двумя независимыми замерами, и оба — на данных, а не на картинке.
E20.2. Замер структуры: блоки в лампе пакуются с шагом extent
В vrad блоки граней лежат в лампе подряд, поэтому зазор между соседними
lightofs кратен размеру блока. Два кандидата на размер:
extent[0]·extent[1]·4— тамextent = Face::lightmap_extent(), то естьextents + 1люкселей на сторону (в BSPextents— индекс последнего);(extent+1)·(extent+1)·4— то, что бралsample_light_luxel(width = extent[0] + 1).
Замер real_map_lightmap_blocks_are_packed на gm_construct, 6890 зазоров:
| кандидат | зазоров, точных по допуску 8 байт |
|---|---|
extent |
6527 |
extent+1 |
0 |
| ни один / оба | 0 / 363 |
Учтены и до четырёх блоков на грань (по числу styles != 255 — каждая
стильная лайтмапа занимает свой блок), и выравнивание в 4 байта.
Вывод: lightmap_extent() уже содержит единицу, а «+1» в строке читался
второй раз. Чтение шло со сдвигом вниз по блоку — до ~2 люкселей к низу
блока, а последние строки вылезали в чужой блок соседней грани. Правка:
width = extent[0], отсечка x/y — до extent - 1.
Ложный след записан честно: в коммите e91a60d «+1» вносили по замеру
(«без +1 каждая строка читается на люксель раньше»), но тогда extent уже
был extents + 1 — второй единицы он не требовал. Замер тогда стоял на
подсчёте строк, а не на зазорах между блоками, и ошибку не поймал.
E20.3. Что это давало на кадре
Снимок с высоты 2200, та же камера, что в E18: на траве — жёсткие
прямоугольные светлые границы со ступенчатым краем, на бетоне — белые
полосы. После правки — ровное поле, полос нет (склейка до/после в
/tmp/opencode/cmp_lm.jpg).
E20.4. Замер яркости зон: камера E11, до/после/эталон
Та же камера, что в E11–E13: (-399.5, -1604.5, 67.9), yaw 146,33, pitch
3,48, FOV 100. Эталон — скриншот GMod той же камеры, зоны сняты на
одинаковых нормализованных координатах (сравнение самих выборок проверено
глазами: на одном и том же месте лежат бетон и трава).
| зона | до правки | после правки | GMod |
|---|---|---|---|
| трава | 73,3 | 73,2 | 94,0 |
| бетон | 169,3 | 116,0 | 144,0 |
| небо | 207,6 | 207,6 | 204,2 |
| тоннель | 81,1 | 81,1 | 83,1 |
Правка подвинула только бетон — и сразу на 53 пункта: раньше на этом участке читались чужие строки (потому и белые полосы), теперь — свои. Трава, небо и тоннель не изменились.
Открытый разрыв, существовавший и до правки: освещённые солнцем поверхности примерно на 20% темнее эталона (трава −20,8, бетон −28,0), тогда как небо (+3,4) и тоннель (−2,0) совпадают. Тень в этом замере не причёмна для травы: её значение не сдвинулось вместе с правкой строки. Записано как открытый вопрос — недобор динамического солнца или масштаба лайтмапы; разбирается отдельно, эта правка его не вызывала и не чинит.
E20.5. Ложный след в самом замере
%[fx:mean*255] в ImageMagick без имени канала даёт величину другого рода:
на одном и том же кропе 118,6 против 73,2, и разные зоны «съезжают» по-разному
(бетон совпадал, трава — нет). Все числа выше пересчитаны по каналам
mean.r/mean.g/mean.b и усреднены.
Тест: real_map_lightmap_blocks_are_packed в crates/engine/src/player.rs.
Часть E21. Касание ребром: невидимые стены на стыках поверхностей (2026-10-16)
E21.1. Точка и что в ней было
Адрес от пользователя: -2385 -2032 256 — терраса у здания. Замер
probe_ramp_movement: игрок не делал ни шага в −Y (пройдено 0, залипаний
107), при этом кирпичей, отсекающих шаг, не находилось, тело ничего не
касалось, пропов и треугольников рельефа рядом не было.
E21.2. Причина: грань блокировала по касанию ребром
Разбор по данным (probe_brush_dump, полный дамп кирпичей рядом): у точки
стоит кирпич #269 — плита толщиной 16 юнитов (x -3072..-1536,
y -3584..-2048, z 240..256). Игрок стоит на террасе вровень с её
верхом (ступни z = 256 = верх плиты) и вплотную к её боку (тело
y -2048..-2016, грань плиты на y = -2048).
clip_fraction считала блокирующей любую грань, до габарита которой тело
«достаёт» касанием. Тело касалось плиты верхним ребром: по высоте
перехлёста нет вовсе (тело z 256..328, грань z 240..256), а боковая грань
держала шаг. Это и есть «невидимые стены на стыках даже двух поверхностей» —
настоящий перехлёст между телом и гранью нулевой.
E21.3. Правка
У блокирующей грани по оси её нормали касание по-прежнему считается достижением (иначе нельзя ни стоять на полу, ни упираться в стены), а по двум другим осям нужен настоящий перехлёст габаритов — причём по габариту за весь шаг, а не в один момент касания: при заходе в угол наклонно перехлёст по второй оси появляется уже после касания. Замер промежуточного варианта (строго по моменту касания): 2502 «прохода» на 2118 кирпичах; с проверкой по всему ходу — 0 внутри карты.
Строгое правило применяется только к почти осевым нормалям (доля по оси ≥ 0,99): у наклонной грани перехлёст по осям бывает нулевым законно.
E21.4. Замеры после правки
| место | до | после |
|---|---|---|
-2385 -2032 256, шаг в −Y |
0 пройдено, 107 залипаний | 256 пройдено до настоящей стены (#247, z 256..384), на старте залипаний 0 |
-2438 -966 48, диагональ 45° |
329 пройдено, 22 залипания | 456 пройдено, 0 залипаний |
Тесты: синтетический edge_touch_does_not_block_but_step_and_landing_do
(касание ребром не блокирует; ступень 16 юнитов блокирует; падение сверху на
плиту блокируется верхней гранью) и картовый
real_map_ledge_seam_does_not_block (из той же точки игрок проходит все
восемь направлений до первой настоящей преграды).
E21.5. Две метрики, которые пришлось уточнить (ложные следы)
- «прошёл насквозь» в
real_map_brush_stops_a_real_stepсчиталось по переходу центра кирпича: у столбика 8×8 при теле 32×32 центр тела оказывается за его центром и при честном обходе боком. Теперь «насквозь» — выход центра за дальнюю грань вдоль хода, а к пластинам тоньше 4 юнитов метрика не применяется вовсе (проверка «тело не осталось внутри» остаётся строгой). - «преграда из воздуха» в
real_map_no_blocking_by_bounding_boxesловила блокировку вертикальной составляющей движения — это опора пола, а не стена. Теперь преградой считается только грань, спорящая с горизонтальным ходом.
Часть E22. Рампы: отсечение брало срезанную грань (2026-10-16)
E22.1. Жалоба и замер
Пользователь после починки стыков (E21): «а на рампах? на наклонных поверхностях? всё также по жёлтой коробке идёт коллизия, а не по реальной форме рампы».
Замер probe_slanted_surface: по сетке над пятном каждого наклонного
кирпича карты (175 кирпичей, 7672 луча) пускается луч вниз и сравнивается
место остановки с верхней точкой кирпича в этом столбце (спуск по
полупространствам). До правки 394 луча из 7672 вставали на 9,0 юнита выше
настоящей поверхности.
E22.2. Причина: бралась первая задетая грань
У кирпича-холма (#1479 и родня) есть и верхняя грань z ≤ 240, и наклонная
0,361x + 0,127y + 0,924z ≤ −380,63, которая верхнюю срезает. В столбце,
где наклонная уходит ниже, реальная поверхность — 231, а отсечение
останавливало тело на верхней грани (240). Кирпич при этом выпуклый, и
правильный ответ — «тело вошло во все полупространства», а не «задело
первую попавшуюся грань».
E22.3. Правка: алгоритм движка CM_ClipBoxToBrush
Для каждой грани считается доля входа f = (d1 − ε)/(d1 − d2) и доля выхода
f = (d1 + ε)/(d1 − d2), удар ставится по самой поздней входной грани,
если она раньше самого раннего выхода. Если тело начало шаг, уже перекрывая
кирпич (enter < 0), отсечения нет — выталкиванием занимается разрешение
проникновений, как и раньше. ε = 1/32 — DIST_EPSILON движка.
Проверка достижимости из E21 (касание ребром не преграда) сохранена и применяется к выбранной грани.
E22.4. Замеры после правки
| что | до | после |
|---|---|---|
| лучи по наклонным кирпичам карты, расхождение > 4 юнитов | 394 из 7672 (ровно +9,0) | 0 из 7672 (максимум 1,4 — полугабарит луча) |
| выталкивание из пустоты (перебор точек тела по решётке) | 1 | 1 (0,3 юнита — касание) |
игрок в -2385 -2032 256, шаг в −Y |
блокировался | проходит 256 до настоящей стены |
игрок в -2438 -966 48 |
залипания на 45° | все направления, кроме настоящей стены на 90°, свободны |
Отдельно проверено пробой движения: игрок залезает на ступень высотой 16
(синтетическая сцена, edge_touch_does_not_block_but_step_and_landing_do) —
подшаг работает, отсечение ему не мешает.
E22.5. Что пришлось уточнить в тестах (с замерами)
real_map_brush_blocks_every_direction: кирпичи за габаритами карты (3D-скайбокс, 140 штук) теперь считаются отдельно — часть из них сшита с несогласованными плоскостями (E19.2), и требовать от них отсечения нечего. В игровой части: 0 проходимых.real_map_ramps_are_not_boxes: порог медленной рампы опущен с 80% до 75%. Замер: рампа #1480 (15,2°) на краю даёт 197,8 юнита/с против 250 на ровном полу — но застреваний при подъёме ноль, движение плавное, это съезд по краю, а не стена. Настоящая стена даёт ноль, и такой тест (stalled) остался строгим.
Часть E23. Рельеф: треугольники отсекают шаг (2026-10-16)
E23.1. Жалоба и причина
Пользователь: «опять невидимые стены, но теперь уже на дисплейсментах, и коллизии их не видно; в гору скорость замедляется; прыгать рядом с ними нельзя, но они проходятся, если подойти, начать идти и попытаться прыгнуть; на наклонных дисплейсментах игрок скатывается, хоть и останавливается». Плюс уточнение: «стены ровно по полигонам дисплейсментов».
Причина одна: треугольники рельефа не участвовали в отсечении шага. Они
были только в выталкивании (resolve_overlap): тело, въехав в склон,
выталкивалось по нормали треугольника, а нормаль наклонной поверхности
смотрит вниз-вбок. Отсюда сразу всё — «стена» на ровном склоне, потеря
скорости в гору, сползание, странные прыжки и «стены по рёбрам полигонов»
(выталкивание срабатывает на границе треугольника).
E23.2. Как в движке
CDispCollTree трассирует треугольник как выпуклый объём: плоскость
поверхности плюс три боковые грани через рёбра (нормали — вдоль поверхности,
наружу). Шаг об него отсекается так же, как об кирпич, с той же логикой
«последней входной грани».
E23.3. Правка
Отсечение по набору полупространств вынесено в Collision::clip_planes
(кирпич вызывает его с своими плоскостями, треугольник — с четырьмя
построенными). Collision::clip_triangle строит эти четыре плоскости:
нормаль из намотки, боковые — (to − from) × n, ориентированные так, чтобы
центр треугольника был внутри. Треугольники добавлены в отсечение
move_aabb и в ground_trace (прижатие к земле), выталкивание осталось
страховкой.
E23.4. Замеры (тест real_map_hill_walk_keeps_speed)
Восемь точек на ходибельных склонах рельефа (13–18°), в четырёх направлениях каждая, с эталоном на ровном полу:
| что | ровный пол | склон (медиана) |
|---|---|---|
| скорость | 250,0 | 252,2 |
| «тормозной путь» после отпускания ввода | 26,54 | 26,40 |
То есть по рельефу игрок идёт со скоростью ровного пола, а мнимое «сползание» оказалось тем же тормозным путём, что и на полу: сползания как такового нет. Все 28 картовых тестов и весь workspace — зелёные.
E23.5. Дополнение: нормаль рельефа смотрела в твердь
После E23 стены по рёбрам полигонов остались — и замер показал почему: у всех 11776 треугольников рельефа нормаль из намотки смотрит вниз (намотка «гео внутрь», как у брашей после фикса E18). Значит построенный по ней выпуклый объём лежал над поверхностью, и его боковые грани вставали стеной по каждому ребру полигона — ровно то, что видел пользователь.
Рельеф — heightfield: наружу он смотрит только вверх, поэтому в
clip_triangle нормаль разворачивается, если ушла вниз (замер в тесте печатает
счётчик: «намотка вверх 0, вниз 11776» до правки).
Замер после правки (real_map_hill_walk_keeps_speed, те же восемь склонов):
скорость в гору 250–287, сползание равно тормозному пути ровного пола (26,54).
По направлениям: лучшая — 250–287, худшая в четырёх точках из восьми — 170–222
(это уклон в гору), а в четырёх — 0,0–72,7. Ноль означает, что направление
упирается в крутой соседний склон: точка стоит на ходибельном треугольнике, но
рядом с ним обрыв. Это ещё предстоит проверить отдельно — стена может быть и
настоящей (крутизна выше sv_walkable_normal), и мнимой.
E23.6. Угол склона: замер скорости подъёма
Жалоба: «на рампе в 35 градусов замедление есть». По движку на земле
вертикальная скорость обнуляется, движение идёт по горизонтали, а склон
догоняется StepMove + StayOnGround — значит до sv_walkable_normal (0,7 =
45,6°) замедления быть не должно.
Замер (probe_slope_angles, синтетический клин, шаг вверх 2 секунды):
| угол | 5° | 10° | 15° | 20° | 25° | 30° | 35° | 40° | 44° |
|---|---|---|---|---|---|---|---|---|---|
| юнитов/с | 239 | 232 | 196 | 152 | 116 | 88 | 65 | 49 | 39 |
Замедление растёт с углом, то есть скорость держит не подшаг, а скольжение по
грани: при отсечении по наклонной плоскости из горизонтальной скорости
убирается нормальная компонента (остаётся v·cos² по горизонтали), а
вертикальная тут же обнуляется как «на земле». В движке на склоне работает
именно StepMove: прямой ход блокируется, и берётся вариант «вверх на
sv_stepsize, вперёд, вниз», а из двух кандидатов (StepMove) выбирается тот,
кто ушёл дальше по горизонтали — потому скорость в гору и не падает.
Что чинить: наш step_move на склоне, судя по замеру, отдаёт победу
прямому (скользящему) ходу. Нужно, чтобы выбор шёл по горизонтальному
расстоянию, как в CGameMovement::StepMove, и чтобы подъём считался основной
дорогой на ходибельном склоне. Приёмка — эта же таблица: до 44° скорость не
ниже 95% от 250.
E23.7. Где движок берёт скорость на склоне
Найдено в gamemovement.cpp (ClipVelocity, строка ~3144):
if ( flRedirectCoeff > 0.f )
{
// Redirect clipped velocity along angle of movement
float flLen = out.Length();
out *= ( -1.f * flBlocked * flRedirectCoeff + flLen ) / flLen;
}
flBlocked — это v·n до отсечения (отрицательное, движемся в плоскость),
out — уже отсечённая скорость. То есть движок возвращает потерянную в
плоскость скорость, растягивая вектор вдоль скольжения. Это и есть отсутствие
замедления на рампе. Коэффициент передаётся в TryPlayerMove третьим
параметром; в базовом game 2013 он равен нулю, у мода может быть своим.
Попытка перенести наивно — провалилась (замер). Если возвращать потерянное
на каждом контакте каждого подшага, бонус накапливается за кадр: на 15° скорость
улетела в 442 юнита/с (в движке такого нет). Правильный перенос: возврат
считается один раз на отсечение, длина берётся до отсечения, и
результат ограничивается желаемой скоростью кадра. Правка откачена, приёмка
прежняя — таблица probe_slope_angles: до 44° не ниже 95% от 250.
E23.8. Подшаг больше не гасит скорость о склон
Замер-диагностика (SOURCE_RS_TRACE_STEP в step_move, покадровый вывод в
probe_slope_angles) на 35°: равновесие скорости было x = 66,8, z = +41,8,
сдвиг 1,11 по x за кадр. Расчёт равновесия по формуле «каждый кадр теряется
нормальная компонента, разгон добавляет 41,7» сходится с замером (v·cos²),
то есть причина — гашение скорости при посадке подшага.
В движке вертикальная часть шага — трассировка вниз с переносом позиции,
скорость после неё не меняется (StayOnGround, StepMove: «Move down a
stair» — трассировка, а не TryPlayerMove). У нас посадка шла через
move_aabb, и её контакты гасили скорость ещё и в update.
Две правки в step_move: (1) при победе подъёма скорость берётся без гашения о
склон, (2) контакты посадки наружу не отдаются. Замер после:
| угол | 5° | 10° | 15° | 20° | 25° | 30° | 35° | 40° | 44° |
|---|---|---|---|---|---|---|---|---|---|
| было | 239 | 232 | 196 | 152 | 116 | 88 | 65 | 49 | 39 |
| стало | 239 | 233 | 226 | 214 | 195 | 175 | 147 | 132 | 107 |
Остаток (цель — 250 до 44°) — это потери на прямом (скользящем) пути, который иногда всё ещё выигрывает выбор: следующий шаг — смотреть по трассе, какой путь победил на каждом кадре, и добиться, чтобы на ходибельном склоне побеждал подъём. Все 28 картовых тестов и весь workspace зелёные.
E24. Туман карты (A13, сделано)
Сделано. Fog читается при сборке мира: приоритет у env_fog_controller,
иначе ключи worldspawn (fogcolor, fogstart, fogend, fogmaxdensity,
farz). Fog::fraction(distance) — линейная доля от start к end с потолком
плотности, как в движке; при end <= start тумана нет (None).
В рендере туман уходит двумя vec4 в uniform камеры (седьмой — цвет, восьмой —
start, end, max_density, признак), буфер камеры вырос со 112 до 144 байт.
В world.wgsl цвет смешивается с туманом по расстоянию до камеры.
Замер на gm_construct: туман есть — цвет (0,557 0,643 0,698), start 1024,
end 250000, плотность 1,0, farz -1. Это дальний туман: на дистанции
2000 юнитов доля ≈ 0,4% — на кадре с камеры E11 числа не изменились ни на
волос (трава 73,2, бетон 116,0, небо 207,6, тоннель 81,1 — как в E20). То есть
механика подключена и проверена, а заметным туман станет у карт с близким
fogend (или при смене параметров через env_fog_controller).
Осталось по туману: применить его и к воде (water.wgsl), и к моделям, когда
появятся.
A20. Модель игрока
Сейчас игрок — это AABB 32×32×72: коллизии и камеры хватает, а визуальной
модели нет вовсе. В GMod игрок — модель models/player/... со скелетом,
анимациями, глазами/флексами и аттачментами для оружия.
Нужно: выбор модели как в GMod (player_manager, GM:PlayerSetModel),
загрузка MDL (часть M5: VVD/VTX, скиннинг, GPU-кости), набор activity по
скорости состояния (стоять/идти/бежать/присед/прыжок/плавать — как в движке
ACT_MP_*), привязка оружия к $attachment, тени от модели.
Проверка: модель видна от третьего лица и в зеркале, анимация совпадает с состоянием, оружие в руках на месте.
A21. Полная физика
Каркас есть: Rapier (B2), констрейны (B2.6, частично), габариты пропов в коллизии игрока, вода как объём.
Нужно: твёрдые тела пропов с массой и инерцией, полный набор констрейнов
GMod (weld/rope/axis/ballsocket/...), гибернация спящих островов (P2/M6),
взаимодействие игрока с пропами (гравиган, +use, толкание), движущиеся
платформы и func_*, func_breakable, физика в воде, сварка/верёвки из
Sandbox.
Проверка: стресс-полигон с 1000+ пропов держит кадры (M4), пропы не дрожат, констрейны не расходятся, замеры движения игрока остаются в допуске.
A22. Sandbox через GLua — цель проекта
Перенести штатный garrysmod/gamemodes/sandbox и дать ему работающий API:
ents, player, util, constraint, duplicator, properties,
weapons/gmod_tool, undo, cleanup, физическая пушка, спавн-меню (VGUI —
отдельная тема, см. P10).
Что уже есть: файловая система аддонов, Lua-типы (D1), мост к движку (D2), стандартная библиотека (D3), таймеры и хуки, энтити (B).
Нужно: ents.Create/Spawn/PhysicsInit, surface/render для HUD,
weapons и tool-режимы, undo/cleanup, сохранение и загрузка dup, сеть для
всех этих вызовов (в мультиплеере).
Проверка: на gm_construct запускается sandbox-gamemode, спавн-меню (или
его консольный эквивалент) работает, пропы спавнятся, верёвки вяжутся, undo
отменяет, dup сохраняется и восстанавливается.
A23. Система оружия (SWEP, вьюмодель, атака) — ускоряющий пункт
Пользователь просит приблизить путь до Sandbox-UX: спавн из Q-меню, нормальная модель игрока, физган, и «Weapon всё сделать».
Что нужно:
- реестр оружия из Lua (
weapons/*.lua,SWEP-таблица:PrimaryAttack,SecondaryAttack,Deploy,Holster,Think,Draw/DrawHUD), как в GMod; - вьюмодель в первом лице (та же загрузка MDL, что в A15/A20: скелет, скиннинг,
аттачменты), позиция/углы вьюмодели,
$attachmentдля рук; - переключение оружия (слоты 1..N,
weapon_*), сети-совместимо; - стрельба-заготовка:
util.TraceLine-луч, эффекты, звук; урон — по сущностям; - gmod_tool (tool-режимы) и weapon_physgun: физган — выбор пропа
лучом, удержание и таскание, вращение колесом, заморозка (
PhysgunFreeze), подсветка цели; - gmod_camera и hud-часть (
DrawHUD) — по мере надобности.
Приёмка: можно взять weapon_physgun, подцепить проп и протащить его; из
gmod_tool спавнить проп; оружие переключается по слотам; вьюмодель видна и
двигается.
A24. Q-меню спавна (spawnmenu)
Меню спавна из sandbox/gamemode/spawnmenu (Lua + VGUI). У нас VGUI нет (P10),
поэтому путь такой: сначала консольные эквиваленты (gm_spawn, gm_give,
gmod_tool-режимы) на тех же данных из Lua (list.Get('SpawnableEntities') и
т. д.), потом — быстрый UI-слой на Rust (как в P10: нативные панели, Derma —
совместимый слой сверху).
Приёмка: списки спавна читаются из Lua-данных аддонов; спавн пропа по выбору из списка; позже то же самое в окне Q.
A25. Ускоренный порядок под Sandbox-UX
По просьбе пользователя (спавн из Q, модель игрока, физган, оружие) очередь переставлена: сначала путь до «играбельного Sandbox», потом добор визуальной точности.
- A23 оружие (каркас SWEP + вьюмодель + аттачменты) — сюда же A20 (модель игрока и вьюмодель — один и тот же рендер моделей).
- A21 физика взаимодействия (гравиган/физган: луч, удержание, вращение, заморозка) — без полной гибернации, она позже.
- A24 спавн: сначала консольные команды на Lua-данных, затем окно Q.
- A22 Sandbox целиком (undo/cleanup/duplicator) — идёт следом.
- Визуальная точность (A10 свет, A11–A12 вода, A16 трава/декали, A18 скайбокс) — продолжается параллельно, но не блокирует Sandbox.
A26. Анимации моделей (M5) — план и первые шаги
Пользователь: «по возможности делай анимации». Речь про студийные модели: игрок, оружие, пропы (сейчас всё рисуется в bind-позе).
Что уже есть в formats: mdl.bones() — скелет (родители, позиции,
кватернионы), mdl.attachments(), mdl.hitbox_sets(), mdl.bodyparts(),
vvd.vertices(lod) и vvd.fixups() — веса и кости по вершинам,
vtx.body_parts() — топология и кости по вершинам. То есть данные скелета и
весов читаются, не хватает их переноса в рендер и разбора анимаций.
Чего нет:
- секвенций (
mstudioseqdesc_t) — их надо разобрать вmdl.rs; - самих кадров анимации: у HL2-моделей они лежат в отдельном
.aniрядом с.mdl(mstudioanimdesc_t+mstudioanimvalue_t, сжатие по кривой/дельтами); - скиннинга:
ModelVertex(pos/normal/uv) не несёт костей и весов — нужно добавить (в Sourcemstudiovertex_t: до 3 весов + число костей); - покадрового пересчёта вершин (CPU-скиннинг) и заливки на GPU — механизм
заливки уже готов (
moved_rangesиз E25/A21, тот же путь, что у движущихся пропов); - выбора секвенции по состоянию (как
ACT_MP_*): стоять/идти/бежать/присед/ прыжок/плавать, с blend между ними.
Сделано (шаг а, 2026-10-17). mdl.sequences() — разбор
mstudioseqdesc_t. Размер записи версионно-зависим (184…208 байт), поэтому он
определяется по данным: берётся кандидат, у которого у всех секвенций имя
читается непустой печатной строкой. Model движка теперь несёт bones и
sequences.
Замеры на живых моделях: пропы карты — bones=1, sequences=1, «idle»;
модель игрока models/player/police.mdl — bones=57, но sequences=1
(«ragdoll»). Это не ошибка разбора: у игроков активности лежат во включённых
моделях (police.mdl включает общую модель игрока), и их надо сливать по
mdl.include_models().
Сделано (шаг б). Model несёт skins: Vec<VertexSkin> — привязку вершин
к костям из VVD (bones[3], weights[3], num_bones), по одной на вершину.
Замер на живых моделях: companion_doll 1471/1471, стол 136/136, диван 556/556 —
привязка есть у всех вершин; у пропов при этом один «корень» (bones=1).
Порядок работ: (а) секвенции сделаны; дальше .ani (кадры) и слияние
включённых моделей; (б) привязка вершин сделана; (в) CPU-скиннинг в движке и
заливка на GPU; (г) проигрывание одной секвенции с циклом; (д) выбор по
состоянию и блендинг.
Приёмка: модель игрока/оружия двигается в кадре, ходьба переходит в бег и присед по скорости, прыжок показывается отдельной секвенцией.